开源自托管 Agent 项目 Hermes Agent 的学习链路拆解

2026-07-30

本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。

这条链路的关键判断是:那张”学习图”根本不参与决策,它只是给人看的;真正让 agent 下次行为不同的,是磁盘上的三样东西——一份 SKILL.md、一个计数用的 sidecar JSON、以及会话开场时被拼进系统提示的技能索引。 我把 NousResearch/hermes-agent(MIT 许可证,LICENSE 署名 Nous Research)里负责这件事的四个文件读完之后,最直接的收获不是”它做了自我学习”,而是看清了它把哪一环交给了模型、哪一环交给了确定性代码,以及每一环各自会怎么坏掉。先说清一件事:Hermes 这个名字同时对应 Nous Research 的开源模型系列,也对应若干同名商标和库;本文只讲这个常驻在你自己机器上、能开终端、能连你聊天账号的开源 Agent 项目。

站内几篇相邻的文章分工不同——ECC 的持续学习设计讲的是另一个项目的做法,Agent 记忆分层目标漂移讲的是不挂具体实现的通用方法论;本文只干一件事:把这个具体仓库的代码位置、判定条件和失效方式指出来,让你能自己打开文件核对。

一、先把落点摆出来

这条链路不是一个模块,而是七处代码接力。你要先知道每一环住在哪,才谈得上判断某个行为是”模型没听话”还是”代码就这么写的”。

环节它负责什么仓库位置你什么时候会碰到它
提示自己去总结/learn 后面的自由文本拼成一段自带写作规范的指令,作为普通一轮交给同一个 agentagent/learn_prompt.py/learn,或从其它入口触发”学一个技能”
技能落盘skill_manageaction="create")写出一份 SKILL.md,必要时往 scripts/ 补脚本tools/skill_manager_tool.py每次 /learn 收尾
拼成图把已学技能与记忆片段变成节点,再连两类边agent/learning_graph.pyhermes journey、TUI 的 /journey、桌面面板
图上改与删按节点 id 反查磁盘位置,改写或归档agent/learning_mutations.pyjourney edit / journey delete
真正影响行为把技能的名字与描述编成系统提示里的索引agent/prompt_builder.pyagent/skill_utils.py每次会话开场
生命周期维护按不活跃时长把技能标 stale、挪进 .archive/agent/curator.py空闲时被动触发的后台复查
事后计量从会话数据库里数 skill_view / skill_manage 的调用agent/insights.pyhermes insights

看这张表最该注意的是第三行和第五行不在一条线上。图的构建函数 build_learning_graph 只被三个显示端调用:CLI 的 journey 命令、本地 web 服务的一个 REST 接口、TUI 的一个方法。没有任何 prompt 组装路径读它。所以”把图变成后续行为”这句话,在这个仓库里成立的方式是间接的——图和行为共用同一批磁盘文件,而不是图驱动行为。

二、第一环:/learn 没有蒸馏引擎,只有一段写给自己看的规范

agent/learn_prompt.py 整个文件只有一个公开函数 build_learn_prompt,返回一个字符串。文件开头的说明写得很直白:没有单独的蒸馏引擎,也没有额外的模型工具占用,agent 用它本来就有的工具集干活,所以在本地、Docker、远程终端三种后端上行为一致。

具体做法是:把用户在 /learn 后面写的任何东西塞进一个模板,模板里交代两步——第一步”把你提到的每个来源都收集起来”,并且点名用哪个工具收哪种来源(本地文件和目录用 read_file / search_files,链接用 web_extract,“我们刚才做的那件事”用当前会话历史,粘贴的文字原样用);第二步”写一份 SKILL.md,用 skill_manage 保存”。如果用户什么都没写,函数会把请求默认替换成”复盘这轮对话的步骤并提炼成一个可复用技能”。

模板里还有一段专门防偷懒的话:请求可能把”要读的来源”和”对成品的要求”混在一起,路径或链接后面跟的那句话不是废话,而是用户在说他想从这个来源里得到什么;不许只抓第一个来源就开始写。这条约束存在,恰好说明它防的是真发生过的行为。

真正占篇幅的是模块级常量 _AUTHORING_STANDARDS——一整套写作硬规矩直接嵌进提示词:description 只能一句且不超过 60 个字符;author 永远写字面量 Hermes,明确禁止从宿主环境、登录名或 git 配置里取,理由是技能会被分享发布,从环境推出来的名字属于用户没同意过的隐私泄漏;platforms 只在真用了平台绑定原语时才声明。正文段落顺序也钉死了八节,从”标题加两三句说清它做什么、不做什么、依赖立场”一路到”一条能证明技能生效的检查”。

还有一条措辞规矩很能说明这个项目的取向:叙述里必须用它自己的工具名(terminalread_filesearch_filespatchweb_extract 等),不许写 cat、grep、sed、curl 抓页面、echo 重定向。它的解释是,这样写出来的才是技能,而不是一份 shell 说明书。

这对你意味着什么:这一环的全部确定性都在字符串里,没有一行代码校验产出。规范写得再细,落地全靠模型这一轮愿不愿意照做。你要么接受它,要么在自己的项目里给它补一个落盘后的校验。而它自己在提示里反复强调”写完数一遍字符数”的那条 60 字符规则,下面就能看到为什么不是洁癖。

三、第二环:图是怎么长出来的,以及它在哪两处会变空

agent/learning_graph.py 的开头就限定了范围:只画用户真正学到的东西——非基础的、学来的技能,加上 MEMORY.md / USER.md 里的记忆片段作为一等节点。

技能节点由 build_skill_nodes 生成:遍历仓库自带的 skills 目录(标记为 base)和主目录下的 skills(标记为 profile),递归找所有 SKILL.md,跳过路径里带 .archive.hubnode_modules.git 的。每份文件只读前 4000 字节解析 frontmatter——够拿元数据,不用把正文全读进内存。分类的取法有个务实兜底:frontmatter 里没写 category 就用路径倒数第三段,即 …/skills/<分类>/<技能>/SKILL.md 里那个目录名。使用数据来自 sidecar ~/.hermes/skills/.usage.json,按技能名索引,use_countstatecreated_bypinned 都从这里读;读不到就退回文件修改时间当时间戳。

然后是这个文件里最该记住的六行:

learned_skills = {
    name: node
    for name, node in all_skills.items()
    if node.source != "base" and (node.created_by == "agent" or node.use_count > 0)
}

两个条件同时成立才进图。第一个条件把仓库自带的技能全部排除;第二个条件要求”agent 创建的”或”用过至少一次”。也就是说,你自己手写、放进主目录、但一次没用过的技能,在图上完全不存在——不是画得淡,是没有这个节点。

边有两类。技能之间的边来自 frontmatter 里声明的 related_skillsbuild_edges 做成无向去重,但有个硬条件:两端都必须在传进来的节点集合里。而传进来的是上面筛完的 learned_skills。仓库自带那批技能里有几十份声明了 related_skills,它们全是 base,早在筛选那步就被剔掉了,所以这些声明在这张图上一条边都连不出来。

第二类是记忆到技能的边,靠词面重合算出来。_memory_cardsMEMORY.mdUSER.md 按裸 § 分隔符切块,每块一张卡,标题取首行去掉井号、超过 80 字符截断,正文截到 1200 字符。然后 _memory_skill_edges 给每张卡打分:技能名整串出现在卡片文本里加 6 分,再加上技能名分词与卡片分词的交集大小,取前四高连边。分词函数就一行:

def _tokenize(text: str) -> set[str]:
    return {t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) >= 3}

切分字符集只有 a-z0-9,而且要求长度不小于 3。中文记忆经过它之后是空集合。如果你的 MEMORY.md 主要用中文写,除非技能名(通常是英文小写连字符)原样出现在里面,否则记忆卡和技能之间连不出边——卡片还在,边没了。这不是 bug 定性,而是这套词面重合方案的固有边界,你得知道它在你的语言环境里会退化成什么样。

density_stats 顺手给出一批统计:节点数、边数、每节点边数、孤立节点百分比、分类数、agent 创建数、被用过的数量、前八大分类。这个模块还能直接当脚本跑(python -m agent.learning_graph),打印真实数据下的边密度。会专门留一个测边密度的入口,说明”图容易变得稀疏”是维护者自己也盯着的问题。

四、第三环:从图回到行为的三条真实路径

图本身是只读展示,但图上的节点能被改,改完会经由三条确定性路径影响后续行为。

第一条:agent/learning_mutations.py 的写回。 它给每个节点一个稳定 id:技能是技能名,记忆是 memory:<source>:<index>,其中 source 取 memory(对应 MEMORY.md)或 profile(对应 USER.md),index 是节点在合并后卡片列表里的位置——MEMORY.md 的卡片全排在前面。node_detail 给编辑做预填(技能返回整份 SKILL.md,记忆返回原始块),edit_nodedelete_node 执行改写。两个设计细节值得抄:删技能不是删,是归档,注释和返回消息都告诉你用 hermes curator restore 恢复;被 pin 住的技能直接拒绝删,返回消息里带上 hermes curator unpin 的用法。写记忆走 MemoryStore._write_file,临时文件加原子改名,并发读者不会看到半截文件。

这里有个必须知道的坑:记忆的 id 是位置索引。删掉一条记忆之后,它后面所有卡片的 index 都会前移。代码防了一半——_memory_local_index 会检查目标位置上那张卡的 source 对不对,不对就报”节点 id 已过期,刷新图”。但如果你删的和要改的在同一个文件里,source 一致,位置校验就拦不住你改错对象。批量操作前重新拉一次图,不是建议,是必要动作。

第二条:系统提示里的技能索引。 build_skills_system_prompt 把技能编成一份紧凑索引,两层缓存(进程内 LRU 加一份磁盘快照,用 mtime/size 清单校验),都没命中才做全量扫描。索引里每个条目的描述由 agent/skill_utils.py 处理:

SKILL_PROMPT_DESC_LIMIT = 60


def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
    desc = _normalize_skill_description(frontmatter)
    if not desc:
        return ""
    if len(desc) > SKILL_PROMPT_DESC_LIMIT:
        return desc[:SKILL_PROMPT_DESC_LIMIT - 3] + "..."
    return desc

这就是第一环里那条 60 字符规则的真正原因——超出部分被静默截掉,而这份索引每次会话都加载。写长了不会报错,只会让技能永远路由不上。同一个文件里还有 is_skill_description_truncated_for_prompt,专门判断”这条描述在系统提示里会不会被截”,说明这个问题常见到需要一个专用谓词。索引侧还有几层过滤:平台不匹配的隐藏、运行环境不相关的在”提供”阶段隐藏(显式加载不受影响)、按可用工具与工具集做条件启用、个人技能与组织技能撞名时两边都标注冲突而不是让谁悄悄赢。

改完技能之后,learning_mutations 会调 clear_skills_system_prompt_cache(clear_snapshot=True) 把两层缓存一起清掉——不清就是下次开场读到旧描述。记忆这边没有对应动作,因为它走另一套机制:MemoryStore.load_from_disk 在加载时就把要进系统提示的内容冻成一份快照,注释明确说是为了保住前缀缓存不变。所以你在 journey 里改一条记忆,当前会话不会变,下次才生效。同一处还做了一件安全动作:每条记忆按严格档扫注入模式,命中就在快照里换成 [BLOCKED: …] 占位,活跃状态里保留原文,让用户还能看到并删掉被投毒的条目——静默丢弃等于替攻击者藏证据。威胁模式库在 tools/threat_patterns.py

第三条:agent/curator.py 的生命周期。 它不是定时任务,是不活跃触发:agent 闲着、距上次复查超过配置的 interval_hoursmaybe_run_curator() 才 fork 一个用辅助模型的 agent 去复查。apply_automatic_transitions 按派生出的活跃时间戳走三态(active / stale / archived),规矩写得相当克制:pin 住的一律不动;被任何 cron 任务引用的技能视同 pin(理由是调度器只在任务真触发时才记一次使用,触发频率低于归档阈值的任务会让自己的技能被从脚下抽走);从没用过的技能有一条宽限地板——没到 stale_after_days 就完全不碰,因为 use_count 为 0 是”没有证据”,不是”有证据说它没用”。还有一份 PROTECTED_BUILTIN_SKILLS 白名单(定义在 tools/skill_usage.py,由那一侧的 is_protected_builtin 在候选筛选与归档路径上统一拦下,curator 自己不重复判断),目前只有 plan 一个,注释说明理由:它支撑着 /plan 这条对外承诺过的路径,静默归档会让斜杠命令变成”未知命令”而用户毫无察觉。模块的不变量写在文件头:只碰 agent 创建的技能,永不自动删除、只归档,pin 绕过全部自动流转,用辅助客户端因此不动主会话的提示缓存。

五、边界与代价

这套设计放弃的东西相当明确,值得逐条摆出来。

它不学模型,只学文件。 整条链路的产物是 markdown 和 JSON,没有任何权重更新。好处是可读、可 diff、可手改、可删;代价是所有”学到的东西”都得挤进系统提示的预算里,学得越多,索引越长。这也是为什么索引侧要做分类降级——把整类技能压成只有名字的一行,描述全丢,靠脚注告诉模型这些名字仍然能用 skill_view 加载。

它不判断技能对不对。 /learn 那一环没有校验,curator 那一环判的是”多久没用”,insights 那一环数的是”被加载了几次”。整条链路里没有任何地方衡量一个技能被用之后结果是好是坏。频次不是质量,一个每天被误触发的坏技能在这套指标里会显得很健康。

统计口径本身是近似的。 _get_tool_usage 从两个来源取数——tool 角色消息上的 tool_name 列,和 assistant 消息上的 tool_calls JSON(后者覆盖 tool_name 没被写入的 CLI 场景)。两边都有数据时它按工具取较大值,注释写明理由是两个来源可能重叠、取 max 避免重复计数。这是个诚实的折中,但别拿这些数字当精确计数用。技能维度更明显:_get_skill_usage 只认 skill_viewskill_manage 调用里的 name 参数,报表里的 loads 与 edits 就是这两个计数。成本一侧涉及各家模型服务商,规则不同且会调整,以官方最新说明为准。

它明确不管的事。 图不参与决策;技能内容的正确性没人验;related_skills 要靠人手写,写错名字或指向基础技能就是一条不存在的边;记忆的组织方式是裸 § 分块的散文,没有 schema,没有类型;跨设备同步不在这几个文件的职责里。

风险要说清楚。 这是个常驻你机器上的东西,会开终端执行命令、往磁盘写文件、访问外部服务,还能接你的聊天账号。/learn 这条路径的本质是”让模型把它读到的内容写成一份以后每次会话都会加载的文件”,链路上任何一步被污染,污染物就进了系统提示。记忆侧对此有前面说的注入扫描加占位替换,技能侧的等价防线主要是”只有 agent 创建的技能才被 curator 管理”和背景复查的写入守卫,不是内容层面的安全审计。把这台机器当成一台会自己改自己配置的机器来对待,别当成一个聊天窗口。相关的权限边界怎么划,可以对着最小权限设计那篇的思路核一遍。

六、上手与避坑清单

  • 描述超 60 字符:为什么会踩 —— 你写描述时想的是”说清楚”,而超长既不报错也不告警,技能页面看起来完全正常。怎么避:写完按字符数一遍,或者直接调 is_skill_description_truncated_for_prompt 判一下;把这条做成落盘后的检查,别指望模型每次都数。
  • 手写技能在图上找不到:为什么会踩 —— 你以为图画的是”所有学来的技能”,实际筛选要求 created_by == "agent"use_count > 0怎么避:先用一次(skill_view 会记数),或者接受”手写且没用过的不上图”这个事实,别去调筛选条件——它是刻意收窄到”有学习信号”的。
  • related_skills 连不出边:为什么会踩 —— 你在某份技能里声明了关联,但对面是仓库自带的基础技能,或者名字拼错了。build_edges 要求两端都在节点集合里,缺一端就静默丢边。怎么避:关联只在学来的技能之间声明,写完对着图或 density_stats 的孤立节点比例核一次。
  • 中文记忆连不出边:为什么会踩 —— 分词只认 a-z0-9 且长度不小于 3。怎么避:想让记忆和某个技能挂上,就把技能名(英文小写连字符那串)原样写进记忆条目里,那是加 6 分的强信号;不要指望语义匹配,这里没有向量。
  • 按 id 批量删记忆删错:为什么会踩 —— id 是位置索引,删一条后面全前移;同文件内的越界只有 source 校验拦不住。怎么避:每次改动前重新拉一次图,一次只改一条,改完再拉。
  • 改了记忆当前会话没反应:为什么会踩 —— 进系统提示的是加载时冻结的快照,为的是保住前缀缓存。怎么避:别在同一个会话里反复试探,改完开新会话验证;技能不同,技能编辑会显式清索引缓存。
  • 技能被后台归档了却以为丢了:为什么会踩 —— curator 会按不活跃时长把技能挪进 .archive/,而它是空闲触发的,你不一定注意到什么时候跑过。怎么避:重要技能直接 pin(pin 绕过所有自动流转),被 cron 引用的会自动视同 pin;真被归档了用 hermes curator restore 拿回来,它从不自动删除。
  • 拿加载次数当效果:为什么会踩 —— 报表里最显眼的就是 loads 和 edits,看起来像效果指标。怎么避:把它当曝光量看,效果得你自己另建判据。判据怎么设计,Agent 评测方法那篇讲得比这里细。

收尾

如果只带走一句:这套设计把”总结”交给模型、把”归档、路由、计时、计量”交给确定性代码,中间那张图是给人看的检查面板,不是决策输入。这个分工本身比它的任何一个实现细节更值得借鉴——你要抄,先抄这个边界划法。

自检三条,逐条能回答再说你读懂了:你的技能描述会不会在系统提示里被截;你的关联声明两端是不是都在会上图的那个集合里;你的记忆条目在只认 a-z0-9 的分词器面前还剩下什么。

接着读哪个文件:想看技能落盘和写入守卫,去 tools/skill_manager_tool.py;想看使用计数、归档与恢复的完整实现,去 tools/skill_usage.py;想看记忆的分块、原子写和注入防护,去 tools/memory_tool.pytools/threat_patterns.py。想先摸清仓库的整体规模,可以从数目录开始:skills/ 是 14 个分类目录共 70 份 SKILL.mdoptional-skills/ 是 21 个分类目录共 111 份,plugins/ 有 18 个顶层插件目录,optional-mcps/ 有 6 个,tests/ 下以 test_ 开头的测试文件有 2499 个。这些数你自己数一遍就知道,我也是这么数的。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源 Agent 项目 Hermes Agent 的消息分档路由怎么做开源自托管项目 Hermes Agent 怎么兜住技能自动创建

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。