数据截至 (上游 commit 4ac938ddecce)
自我进化闭环 —— 技能、记忆、策展与跨会话回忆
30 秒导读: Hermes 的招牌不是"能调工具",而是它会把每一轮对话的收获写回磁盘。 一轮聊完,它 fork 一个只准调 memory / skill 工具的自己去复盘;复盘结果落到三处存储 (记忆文件、技能目录、使用元数据);下一次会话启动时,系统提示把这些读回来。 再加一个定期跑的策展器给技能库做减法,和一套 FTS5 全文索引让它翻旧账。
本章只讲这个闭环。一轮对话本身怎么跑完见 01-turn-lifecycle; 系统提示怎么拼、缓存怎么分层见 02-context-engineering; SessionDB 的存储与修复细节见 05-runs-anywhere。
1. 这一章讲什么(零基础版)
1.1 先问一个问题
普通聊天机器人和 Hermes 的差别,一句话:普通机器人下次见你还是一张白纸,Hermes 不是。
要做到"下次不是白纸",一个 agent 必须回答三个问题:
| 问题 | Hermes 的答案 | 存到哪 |
|---|---|---|
| 我该记住这个人是谁? | 记忆(memory) | ~/.hermes/memories/MEMORY.md、USER.md |
| 我该记住这类活怎么干? | 技能(skill) | ~/.hermes/skills/<name>/SKILL.md |
| 我上次到底聊了啥? | 会话历史 + 全文索引 | ~/.hermes/state.db 的 FTS5 表 |
再加一个运维问题:技能越攒越多、越攒越碎怎么办?答案是策展器(curator)——一个定期 给技能库做减法的后台任务。
1.2 一个直觉类比
把这四件事当成一个人的四种笔记:
- 记忆 = 通讯录上写的一行备注("老王喜欢直说,别绕")。空间很小,必须精挑。
- 技能 = 你写给自己的操作手册("发版流程:1. …2. …")。可以很厚,按需翻。
- 策展 = 每季度整理一次手册:把五篇碎片合并成一篇总纲,过期的塞进档案盒。
- 会话回忆 = 你的聊天记录全文搜索。不占脑容量,想不起来了再去翻。
关键在于谁来写这些笔记:不是人,是 agent 自己,在每轮对话结束、用户已经拿到回复 之后,悄悄跑一遍。
1.3 用起来什么样
用户什么都不用做。一轮结束后,终端上可能多出这么一行:
💾 Self-improvement review: Skill 'pr-triage-salvage' patched: "…" → "…" · Memory updated
这行的生成逻辑在 agent/background_review.py:1428-1441(_run_review_in_thread 尾部),
内容来自 summarize_background_review_actions(agent/background_review.py:640)。
想手动触发学习,还有一个 /learn 命令,把"刚才这套流程"直接固化成一篇 SKILL.md。
1.4 本章承重术语(一词一义)
后面几节反复出现四个词,含义固定,不换说法:
| 术语 | 指什么 | 不指什么 |
|---|---|---|
| 后台复盘 | 整条机制:触发判定 → fork → 写盘 → 一行汇总 | 不指某个具体进程 |
| 复盘 fork | 被 fork 出来的那个 AIAgent 实例(工具只剩 memory/skills) | 不是子 agent(delegate_task 那条路见 04) |
| 策展 fork | curator 合并阶段另起的 AIAgent 实例(跑辅助模型) | 与复盘 fork 是两条独立路径 |
| 记忆 / 技能 | 陈述性知识(这个人是谁) / 程序性知识(这类活怎么干) | 两者不互相替代 |
2. 顶层全景:一张闭环图
怎么读这张图: 从上往下是时间顺序。左边是主对话(用户看得见),右边是后台复盘 (用户看不见,只在最后冒一行汇总)。
主对话(前台) 后台复盘(daemon 线程)
────────────────────────────────────────────────────────────────
用户消息 → 模型 → 工具 → 回复
│
│ 回复已交付给用户
↓
① 查计数器
记忆:满 10 个用户轮?
技能:本轮工具迭代 ≥10?
│
│ 都没到 → 结束
↓ 到了
└──────────────────────→ ② fork 一个 AIAgent(复盘 fork)
· 继承父的 provider / model / 凭据
· 继承父的已缓存系统提示
· 工具白名单 = {memory, skills}
· 不压缩、不关会话、不碰外部记忆插件
│
↓
③ 写回三处磁盘存储
├─ memories/MEMORY.md · USER.md
├─ skills/<name>/SKILL.md (+references/)
└─ skills/.usage.json(provenance+计数)
│
④ 一行汇总打回终端 ←──────────────────────┘
(与此并行)每条消息自动落 state.db,触发器同步写两张 FTS5 索引
↓
⑤ 下一次会话启动:系统提示读回
· MEMORY/USER 冻结快照 · 技能索引(名字+≤60 字描述)
· 需要翻旧账时调 session_search
↓
(另有一条慢线)每 7 天一次 curator:给技能库做减法
各部件一句话职责:
| 部件 | 干什么 | 主文件 |
|---|---|---|
| 后台复盘 | 每轮后 fork 一个自己,决定要不要写记忆/技能 | agent/background_review.py |
| 技能读 | 发现、过滤、渐进披露地加载 SKILL.md | tools/skills_tool.py |
| 技能写 | 创建/编辑/补丁/删除,带四道保护闸 | tools/skill_manager_tool.py |
| 技能来源 | 从 GitHub / skills.sh / ClawHub 等装外部技能 | tools/skills_hub.py + tools/skills_guard.py |
| 使用元数据 | 计数、provenance、生命周期状态 | tools/skill_usage.py |
| 策展 | 定期淘汰陈旧技能、合并同类项、出报告 | agent/curator.py |
| 记忆 | 双 store、字符预算、冻结快照 | tools/memory_tool.py |
| 记忆编排 | 外部记忆 provider 的注册与围栏 | agent/memory_manager.py、agent/memory_provider.py |
| 跨会话回忆 | FTS5 检索 + 锚点滚动 | tools/session_search_tool.py、hermes_state.py |
3. 通道一:每轮之后的后台复盘
这是整个闭环的发动机。没有它,记忆和技能就只能靠用户主动喊"记一下"。
3.1 它要解决的小问题
模型在一轮对话里刚被用户纠正过("别这么啰嗦""这个步骤你漏了"),此刻它知道自己 错在哪。但这轮一结束,上下文一清,教训就没了。
最笨的做法是在主对话里插一句"顺便,你要不要记点什么"——代价是污染主对话、多烧一次 用户等待时间、把复盘的思考混进正式回复。
Hermes 的做法:回复先交付,然后另开一条线程,让一个受限的复盘 fork 去复盘。
3.2 什么时候触发
两个计数器,互相独立,默认都是 10:
| 触发器 | 计什么 | 默认阈值 | 判定位置 |
|---|---|---|---|
| 记忆复盘 | 用户轮数(_turns_since_memory) | 10 | agent/turn_context.py:714-721 |
| 技能复盘 | 本轮的工具迭代次数(_iters_since_skill) | 10 | agent/turn_finalizer.py:772-776 |
默认值在 agent/agent_init.py:1827(_memory_nudge_interval)和 :1296
(_skill_nudge_interval),都可以由 config 覆盖。
两个计数口径不一样是有讲究的:记忆看"聊了多久",技能看"这轮干了多少活"。一轮里 连着调了 10 次工具,通常意味着刚趟过一条非平凡的路径——正是值得沉淀成技能的时刻。
真正的 fork 在回复交付之后才发生:
# agent/turn_finalizer.py:453-461,已省略异常处理
if final_response and not interrupted and (_should_review_memory or _should_review_skills):
agent._spawn_background_review(
messages_snapshot=list(messages),
review_memory=_should_review_memory,
review_skills=_should_review_skills,
)
_spawn_background_review(run_agent.py:1859)只是薄薄一层,真正构造线程目标的是
spawn_background_review_thread(agent/background_review.py:1478),它按触发组合挑
三段 prompt 之一:只记忆(_MEMORY_REVIEW_PROMPT,同文件 :159)、只技能
(_SKILL_REVIEW_PROMPT,:170)、或合并版(_COMBINED_REVIEW_PROMPT,:275)。
3.3 复盘 fork 继承什么、不继承什么
这是全章最值得抄的一段工程。_run_review_in_thread(agent/background_review.py:1013)
构造 fork 时做了十几处显式设置,每一处都对应一个踩过的坑:
| 设置 | 值 | 为什么 |
|---|---|---|
| provider / model / api_key / base_url | 继承父的活运行时 | 重新从环境变量解析会对 OAuth-only、会话级凭据、凭据池全部失败 |
_cached_system_prompt | 继承父的 | 让 fork 的请求命中父刚焐热的前缀缓存 |
enabled_toolsets / disabled_toolsets | 继承父的 | tools[] 必须逐字节相同——Anthropic 的缓存键包含它 |
_skip_mcp_refresh | True | 轮间的 MCP 刷新会给 fork 加上晚连的工具,破坏 tools[] 一致性 |
skip_memory | True | 否则 fork 会重建自己的外部记忆 provider,把复盘 prompt 灌进用户真实记忆空间 |
_memory_store | 直接指父的对象 | 上一条关掉了外部 provider,但内建 MEMORY.md/USER.md 的写入必须照常落盘 |
compression_enabled | False | fork 与父共享 session_id,一旦它赢了压缩竞态,会把父轮换成一个网关永远不会认领的子会话 |
_end_session_on_close | False | fork 用完即关,不能顺手把父那条还活着的 session 行给收尾了 |
max_iterations | 16 | 复盘不是干活,给个小预算 |
suppress_status_output | True | 中途的限流重试、预算耗尽等状态消息不该冒到用户屏幕上 |
另外还有一层保险:整段执行被 thread_scoped_silence() 包住(agent/background_review.py:1089-1097)——
只把本工作线程的 stdout/stderr 写入引到 devnull,进程级的 redirect_stdout 会连
别的线程(比如网关的 Telegram 长轮询事件循环)的输出一起吞掉(#55769/#55925);并且给
这条线程装了一个自动拒绝的危险命令回调 _bg_review_auto_deny(:881-888)——否则
一旦复盘 fork 踩到需要人工确认的命令,input() 会和父进程的 prompt_toolkit TUI
死锁(注释点了 issue #15216)。
3.4 只准碰两类工具
fork 的工具白名单是运行时强制的,不是靠 prompt 自觉:
# agent/background_review.py:1150-1167,已简化
review_toolsets = ["skills"]
if review_agent._memory_enabled or review_agent._user_profile_enabled:
review_toolsets.insert(0, "memory") # profile 关掉 memory 时连白名单都不给(#54937)
review_whitelist = {
t["function"]["name"]
for t in get_tool_definitions(enabled_toolsets=review_toolsets, quiet_mode=True)
}
set_thread_tool_whitelist(
review_whitelist,
deny_msg_fmt="Background review denied non-whitelisted tool: {tool_name}. ...",
)
注意这里的双层设计:tools[] 数组仍然是父的完整工具集(为了缓存一致),但派发层
只放行 skills(以及 profile 开了 memory_enabled 才有的 memory 工具)。模型看得见所有
工具,调别的会在运行时被拒。finally 里的
clear_thread_tool_whitelist()(:1193)保证线程复用时不留残留。
3.5 缓存经济学:同模型全量重放 vs 换模型只放 digest
这是文件头 30-42 行那段策略注释讲的事,也是本章最"值钱"的一个设计。
先讲直觉。复盘要看完整对话才能判断"这轮学到了什么",所以它要把整段历史再喂给模型一次。 问题是——这一次重放到底贵不贵?
答案取决于跑在哪个模型上:
| 场景 | 缓存状态 | 策略 | 代价 |
|---|---|---|---|
| 默认(不路由):跟主对话同一个模型 | 父刚跑完,前缀缓存是热的 | 全量重放整段历史 | 便宜的缓存读 |
| 用户把复盘路由到别的(更便宜的)模型 | 缓存键不同,必然是冷的 | 只重放一份 digest | 尽量少的冷写 token |
关键推论是反直觉的:换个便宜模型不代表要少喂,而是因为反正缓存冷了,才值得少喂。 同模型下如果为了"省"而喂摘要,反而会把一份新的冷前缀写进缓存,得不偿失。
判定在 _resolve_review_runtime(agent/background_review.py:280):只有当
auxiliary.background_review.{provider,model} 指定了一个与父不同的具体模型时,
才返回 routed=True。同名同 provider 直接短路回父运行时(:163-164)。
digest 的构造在 _digest_history(agent/background_review.py:353),规则很朴素:
# 示意,非源码 —— 演示 digest 的形状
tail = 24 # 最近 24 条原样保留
old = messages[:-tail] # 更早的压成一条
lines = []
for m in old:
if m["role"] == "user":
lines.append("USER: " + m["text"][:300]) # 用户话截 300 字
elif m["role"] == "assistant":
lines.append("ASSISTANT[tools: read_file, patch]") # 助手只留工具名
lines.append("ASSISTANT: " + m["text"][:200]) # 正文截 200 字
digest = {"role": "user", "content": "[Earlier conversation digest …]\n" + "\n".join(lines)}
return [digest] + messages[-tail:]
重点看两处真源码里的细节:一是保留角色交替——摘要被塞成 user 角色,避免破坏
消息序列(agent/background_review.py:386-393);二是尾部不能以 tool 消息开头,
否则会出现孤儿 tool 结果,所以 :123-127 有个循环往前多留几条直到不是 tool 为止。
对应地,系统提示的继承也只在非路由时才做(:692-701)——路由到别的模型时,父的缓存
提示是给错模型的键,继承过去只会白搭。
3.6 复盘 prompt 教它抓什么、更教它别抓什么
三段 prompt 常量都在 agent/background_review.py:401-636。它们不是"看看有啥可存的"
这种泛泛指令,而是一份相当具体的编辑方针。
正面信号(任一条命中就该动手,_SKILL_REVIEW_PROMPT :180-194):
- 用户纠正了你的风格/语气/格式/啰嗦程度——"别这么写""直接给答案"算一级技能信号,不只是记忆信号
- 用户纠正了你的工作流或 步骤顺序
- 冒出了非平凡的技巧、修复、绕过、调试路径
- 本轮加载过的某个技能被证明是错的/缺步骤的——立刻打补丁
动作优先级(挑最靠前那个能用的,:195-230):
- 补丁本轮加载过的技能——它才是当时在场的那一个
- 补丁一个已有的伞形技能(class-level umbrella)
- 在伞形技能下加支持文件:
references/(会话细节与知识库)、templates/(拿去改的样板)、scripts/(可直接重跑的脚本) - 实在没有才新建一个 class-level 伞形技能
明令禁止捕获的四类(:249-264),这一段是本章我最想让读者带走的东西:
| 别记什么 | 为什么 |
|---|---|
环境依赖型故障(缺二进制、路径不对、command not found) | 用户能修好;记下来就变成一条永久的自我限制 |
| 对工具能力的负面断言("浏览器工具不能用""X 坏了") | 会固化成拒答理由,在问题修好几个月后还被自己引用 |
| 会话内已经自愈的瞬时错误 | 重试成功了,教训是重试模式,不是那次失败 |
| 一次性任务叙事("总结今天行情") | 那不是一类活 |
最后一条兜底规则(:265-268):如果某个工具因为环境没配好而失败,要记的是修法
(装什么、配哪个 key),而绝不是"这个工具不能用"。
还有一条元规则也值得注意:prompt 明确说 'Nothing to save.' 是真选项但不该是默认
(:269-272)——设计者认为"一次什么都没学到的复盘是浪费,不是中性结果"。
3.7 汇总怎么打回给用户
summarize_background_review_actions(agent/background_review.py:640)扫复盘 fork 的
消息列表,把成功的 memory / skill_manage 调用翻译成人话。两个不显然的细节:
- 跳过继承来的旧工具结果。 fork 继承了父的全部历史,里面本来就有上一轮的
"Entry added",不去重就会被当成"刚刚发生的后台工作"重新播报一遍。去重靠
prior_snapshot里的tool_call_id/ content 集合(:384-395,issue #14944)。 - 只认白名单里的两个工具名(
notify_tools,:401)。否则任何辅助工具成功了都会 被算成一次记忆写入。
显示详略由 notification_mode 控制:off 不显示、on 只说"Memory updated"、
verbose 带内容预览(如 📝 Skill 'x' patched: "旧" → "新")。
4. 通道二:技能体系
技能是 Hermes 的程序性记忆——记忆回答"这个人是谁",技能回答"这类活怎么干"。
这个区分在 prompt 里被反复强调(agent/background_review.py:476-482)。
4.1 磁盘长什么样
~/.hermes/skills/
├── my-skill/
│ ├── SKILL.md ← 必需,YAML frontmatter + 正文
│ ├── references/ ← 会话细节、外部文档摘录
│ ├── templates/ ← 拿去复制修改的样板
│ ├── scripts/ ← 可直接重跑的脚本
│ └── assets/
├── category/ ← 分类目录
│ └── another-skill/SKILL.md
├── .usage.json ← 使用计数 + provenance(sidecar)
├── .bundled_manifest ← 哪些是随 Hermes 出厂的
├── .curator_state ← 策展调度状态
├── .archive/ ← 策展归档区(可恢复)
└── .hub/ ← 外部源的锁文件、隔离区、审计日志
结构说明见 tools/skills_tool.py:14-46 的模块文档。注意所有技能都住在
~/.hermes/skills/,包括出厂自带的——安装时从仓库的 skills/ 播种过来,这样
"agent 改的、hub 装的、出厂的"三类共存而不污染 git 仓库(tools/skills_tool.py:140-142
的注释)。
4.2 三层渐进披露
这是从 Anthropic 的 Claude Skills 借来的思路(tools/skills_tool.py:9-12),核心是
别把整个技能库塞进上下文:
| 层 | 给模型看什么 | 入口 |
|---|---|---|
| 0 | 系统提示里的技能索引:名字 + ≤60 字描述 | build_skills_system_prompt(agent/prompt_builder.py:1828) |
| 1 | 全部技能的 name + description(JSON) | skills_list(tools/skills_tool.py:804) |
| 2 | 某个技能的完整 SKILL.md | skill_view(name)(tools/skills_tool.py:1072) |
| 3 | 技能下的某个支持文件 | skill_view(name, "references/api.md") |
那个 60 字上限是硬的:extract_skill_description(agent/skill_utils.py:1173-1180,
常量 SKILL_PROMPT_DESC_LIMIT = 60 定义在 :1164)在超过 60 时截成 desc[:57] + "..."。而 frontmatter 校验的上限是 1024
(MAX_DESCRIPTION_LENGTH,tools/skills_tool.py:164)——两者不一致,所以写长了不会报错,
只会被静默截断,永远路由不到。这正是 /learn 的作者标准把"数一遍字符数"写进
prompt 的原因(见 §4.7)。
4.3 发现与过滤:哪些技能会被列出来
_find_all_skills(tools/skills_tool.py:673)扫本地目录 + 配置的 external_dirs,
每个候选过五道筛:
SKILL.md 文件
│
├─ 路径含排除目录?(.git/node_modules/references/…) → 丢
├─ 只读前 4000 字节,解析 frontmatter
├─ skill_matches_platform() 不匹配当前 OS? → 丢
├─ skill_matches_environment() 不匹配运行环境? → 丢
├─ 名字已见过?(本地目录先扫,本地优先) → 丢
└─ 名字在 disabled 集合里? → 丢
↓
进列表
两个筛子性质不同,别搞混:
skill_matches_platform(tools/skills_tool.py:253,实现委托给agent/skill_utils.py) 是硬兼容门——macOS-only 的技能在 Linux 上根本不该出现。skill_matches_environment(tools/skills_tool.py:263)是offer 时的相关性门, 注释里写得很清楚:显式加载会绕过它。
性能上一个小细节:content = skill_md.read_text(encoding="utf-8")[:4000](:637)——
列表阶段只读文件头,不读全文。
4.4 加载:skill_view 的三件不显然的事
skill_view(tools/skills_tool.py:1072)看起来只是"读个文件", 实际上干了三件事。
第一件:名字解析拒绝猜。 它会用四种策略在所有搜索目录里找候选(直接路径、
分类路径、按目录名递归、按 frontmatter name 递归、legacy 扁平 <name>.md),然后——
如果候选多于一个,直接报错,列出所有匹配路径让用户显式指定
(tools/skills_tool.py:1355-1377)。理由写在注释里:本地技能被同名外部技能静默遮蔽
是一类真实 bug(/skills 显示的是这个,agent 加载的是那个),宁可吵也不要猜。
安全上,名字先过 _skill_lookup_path_error(:114),挡掉 ../ 遍历、绝对路径和
Windows 盘符(盘符里的 : 会被误读成插件命名空间前缀)。
第二件:缺环境变量时按需现场索要。 这是我觉得最巧的一块。技能可以在 frontmatter 里
声明 required_environment_variables;加载时发现缺,不是直接失败,而是走
_capture_required_environment_variables(tools/skills_tool.py:409):
skill_view("arxiv-search")
│
├─ 解析 required_environment_variables → 哪些没配?
│ (合并三个来源:新字段、setup.collect_secrets、legacy prerequisites.env_vars)
↓
缺
├─ 是网关面(Slack/Telegram 之类)且非交互? → 返回"去 CLI 里加载"的提示,不追问
├─ 没注册 secret 捕获回调? → 只报告缺什么
└─ 有回调 → 逐个弹安全输入框,存进 ~/.hermes/.env
↓
重新读一遍 env 快照,还缺的进 remaining
↓
readiness_status = setup_needed / available
"是网关面且非交互"这一判定写在 :358(_is_gateway_surface() and not env_var_enabled("HERMES_INTERACTIVE")),上面 :352-357 的注释说明了为什么要用
HERMES_INTERACTIVE 而不是"是不是网关"——桌面端 / TUI 也算网关面,但它们能弹安全输入,
所以要靠这个标志把两类网关面分开;tools/approval.py 用的是同一个标志。
_is_gateway_surface 本身定义在 :418。远程后端(docker/ssh/modal 等,
_REMOTE_ENV_BACKENDS,tools/skills_tool.py:174-176)还会额外提示:这些变量在远端
也得有(提示拼装在 :1498)。
第三件:加载即计数。 注册的 handler 不是 skill_view 本身,而是包了一层的
_skill_view_with_bump(tools/skills_tool.py:2122):成功后同时 bump_view 和
bump_use。注释解释了为什么 view 也算 use——"agent 调 skill_view 是在主动加载技能
去干活,那就是使用,不是浏览"(:1624-1626)。策展器的陈旧计时器正是看
last_used_at。
4.5 写:skill_manage 与它的四道闸
写入口是 skill_manage(tools/skill_manager_tool.py:1543),六个动作在 schema 的
enum 里列全(:1342):create / patch / edit / delete / write_file / remove_file。
真正有意思的是谁在写决定了能写什么。四道闸按严格程度排:
| 闸 | 挡谁 | 挡什么 | 位置 |
|---|---|---|---|
_pinned_guard | 前台 agent | 只挡 delete,pin 过的技能仍可改内容 | tools/skill_manager_tool.py:274 |
_background_review_write_guard | 复盘 fork | pinned / external_dirs / hub 装的 / 出厂的,一律拒写 | :238 |
_curator_consolidation_delete_guard | 策展 fork | 删除必须带 absorbed_into=<伞形技能> 且该伞确实存在,否则拒 | :332 |
_security_scan_skill | agent 自建技能 | 可选开关 skills.guard_agent_created,过 skills_guard 扫描 | :78 |
第二道闸的注释把设计意图说透了(:243-249):复盘 fork 比前台更严,恰恰是因为
"这里没有用户在回路里为这次编辑背书"。前台用户明说要改出厂技能,那是用户的自由;
自主维护要改,不行。
4.6 provenance:一个 ContextVar 决定技能归谁
怎么区分"用户让我写的技能"和"我自己攒的技能"?答案朴素得令人愉快:一个 ContextVar。
tools/skill_provenance.py 全文只有 78 行,核心是:
# tools/skill_provenance.py:37-45
_write_origin: contextvars.ContextVar[str] = contextvars.ContextVar(
"skill_write_origin", default="foreground",
)
BACKGROUND_REVIEW = "background_review"
run_agent.py 在每次工具循环前设置它,工具处理器用 is_background_review() 查。
落地效果在 tools/skill_manager_tool.py:1669-1675:
if action == "create":
record_created(
name,
agent_created=is_background_review(),
task_id=task_id,
session_id=session_id,
)
只有复盘 fork 创建的技能才被标记为 agent-created,而策展器只碰 agent-created 的技能。 一条链就把"