← 返回文章归档

Hermes Agent 自动沉淀 Skill:从后台复盘到 Curator

结合源码、prompt 和示意图,拆解 Hermes Agent 如何在复杂任务后复盘、写入 Skill、标记来源,并由 Curator 做二次整理。

#Hermes Agent#AI Agent#Skills#Memory

本文介绍 Hermes Agent 的 Skill 自动沉淀机制。

这个机制要解决的问题很具体:Agent 做完一个复杂任务后,怎么把过程中出现的可复用经验,转成下次能被检索、加载和继续改进的 Skill,而不是把所有内容都塞进短期对话或长期记忆里。

Hermes 的做法不是在主任务里边干活边写经验总结,而是把“交付”和“复盘”拆开:主 Agent 先完成用户任务;如果这轮工具调用足够复杂,再 fork 一个后台 review agent,让它只使用 memory 和 skills 相关工具,判断是否需要创建或更新 Skill。

下面会按四个问题展开:

  • 什么时候触发后台复盘?
  • review fork 为什么能读上下文,却不能乱用工具?
  • skill_manage 如何写入 Skill,并区分前台创建和后台自动沉淀?
  • Skill 越积越多以后,Curator 如何做整理和归档?

文中结论基于本地 hermes-agent 仓库 main@ff5652d0f,关键位置会直接引用源码或 prompt 摘录。

机制总览:一次任务如何变成 Skill

Hermes Agent 自动沉淀 Skill 总览

这张图里最重要的是三段:

阶段做什么关键文件
主对话正常完成用户任务,并累计工具迭代数agent/conversation_loop.pyagent/codex_runtime.py
后台复盘新开 review fork,只允许 memory / skills 工具agent/background_review.py
后续治理记录使用情况,定期整理 agent-created skillstools/skill_usage.pyagent/curator.py

所以它的目标不是“多记一点”,而是把反复会用到的做法写成可复用步骤。这个区别很重要:多记一点很容易变成噪声;可复用步骤才像 Skill。

触发器:不是每轮都复盘

先看初始化。Hermes 给 skill review 准备了一个计数器阈值,默认是 10

# agent/agent_init.py
agent._skill_nudge_interval = 10
try:
    skills_config = _agent_cfg.get("skills", {})
    agent._skill_nudge_interval = int(
        skills_config.get("creation_nudge_interval", 10)
    )
except Exception:
    pass

这个配置叫 skills.creation_nudge_interval。它不是“聊 10 句”,而是工具调用循环跑了多少轮。复杂任务、调试任务、来回试错的任务,更容易把这个计数打上去。

主循环里每轮工具迭代都会加一次:

# agent/conversation_loop.py
# Track tool-calling iterations for skill nudge.
# Counter resets whenever skill_manage is actually used.
if (agent._skill_nudge_interval > 0
        and "skill_manage" in agent.valid_tool_names):
    agent._iters_since_skill += 1

等任务结束后,它才检查是否要复盘:

# agent/conversation_loop.py
if (agent._skill_nudge_interval > 0
        and agent._iters_since_skill >= agent._skill_nudge_interval
        and "skill_manage" in agent.valid_tool_names):
    _should_review_skills = True
    agent._iters_since_skill = 0

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,
    )

这里有几个结论可以直接读出来:

  • 必须有最终回复,用户不能中断。
  • skill_manage 必须可用,否则不安排 Skill review。
  • 触发后计数器清零,避免每轮都重复 review。
  • review 是在主回复之后跑,主任务不会被它卡住。

Codex runtime 这条路径也做了同样的事,只是计数来源变成 turn.tool_iterations

# agent/codex_runtime.py
agent._iters_since_skill = (
    getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
)

翻译成人话:Hermes 不是问完一个简单问题就立刻“长记性”。它先看这轮有没有足够多的工具活动,再决定要不要在后台复盘。

后台复盘:继承上下文,但工具被拴住

后台 review fork 工作方式

触发之后,Hermes 不会让主 Agent 原地停下来复盘。它会新建一个 review_agent

# agent/background_review.py
review_agent = AIAgent(
    model=agent.model,
    max_iterations=16,
    quiet_mode=True,
    provider=agent.provider,
    api_mode=_parent_api_mode,
    parent_session_id=agent.session_id,
    enabled_toolsets=getattr(agent, "enabled_toolsets", None),
    disabled_toolsets=getattr(agent, "disabled_toolsets", None),
    skip_memory=True,
)
review_agent._memory_write_origin = "background_review"
review_agent._memory_nudge_interval = 0
review_agent._skill_nudge_interval = 0

这段代码说明两件事。

第一,review fork 尽量继承父 Agent 的运行环境,比如 model、provider、toolset 配置和 session。这样它不需要重新猜 provider,也更接近刚才那次任务的上下文。

第二,它把自己的 memory / skill nudge 都关掉。也就是说,后台复盘不会再触发下一轮后台复盘。这个设计很实在,不然很容易变成“复盘复盘的复盘”。

还有一个细节跟成本有关。review fork 会继承父 Agent 缓存好的 system prompt:

# agent/background_review.py
review_agent._cached_system_prompt = agent._cached_system_prompt
review_agent.session_start = agent.session_start
review_agent.session_id = agent.session_id

源码注释里写得很直白:这么做是为了让 Anthropic / OpenRouter 这类 provider 的 prefix cache 更容易命中。也就是说,后台复盘不是完全另起炉灶,它会尽量复用主对话已经暖过的 prompt 前缀。

但继承上下文会带来风险:review fork 会不会顺手跑 terminal、web、delegate 之类的工具?

源码里用白名单挡住:

# agent/background_review.py
review_whitelist = {
    t["function"]["name"]
    for t in get_tool_definitions(
        enabled_toolsets=["memory", "skills"],
        quiet_mode=True,
    )
}
set_thread_tool_whitelist(
    review_whitelist,
    deny_msg_fmt=(
        "Background review denied non-whitelisted tool: "
        "{tool_name}. Only memory/skill tools are allowed."
    ),
)

所以后台复盘能做的事很窄:读 Skill、改 Skill、写 memory。它不能借着复盘的名义去跑 shell、查网页、发请求或派子任务。

它复盘时到底在问什么

这一段很关键。后台 review 的 prompt 不是“总结一下刚才发生了什么”,而是更像一张检查表。

先看原 prompt 的开头:

# agent/background_review.py
Review the conversation above and update the skill library. Be ACTIVE...

Target shape of the library: CLASS-LEVEL skills, each with a rich
SKILL.md and a `references/` directory...
Not a long flat list of narrow one-session-one-skill entries.

这里有两个方向同时存在:

第一,它鼓励主动更新,甚至说“多数 session 至少应该有一次小更新”。这会让模型不那么懒,不会把每次复盘都变成 Nothing to save.

第二,它明确反对“一次任务一个 Skill”。目标形态是 class-level skill,也就是按任务类别沉淀,而不是按当天的 bug、PR 编号、报错字符串建一堆窄 Skill。

prompt 里还列了触发信号。摘几条最有代表性的:

Signals to look for:
  • User corrected your style, tone, format, legibility, or verbosity.
  • User corrected your workflow, approach, or sequence of steps.
  • Non-trivial technique, fix, workaround, debugging path...
  • A skill that got loaded or consulted this session turned out
    to be wrong, missing a step, or outdated.

所以它不是只记“技术修复方法”。用户纠正了输出风格、步骤顺序、工作习惯,也被当作 Skill 信号。比如用户说“不要全白话,适当引用原始代码或者 prompt”,这不是单纯的情绪反馈,而是一个会影响“写源码拆解博客”这类任务的工作规范。

更关键的是优先级。原 prompt 写得很明确:

Preference order:
  1. UPDATE A CURRENTLY-LOADED SKILL.
  2. UPDATE AN EXISTING UMBRELLA (via skills_list + skill_view).
  3. ADD A SUPPORT FILE under an existing umbrella.
  4. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL when no existing skill covers the class.

这四条决定了自动沉淀的风格。

如果本轮已经加载过某个 Skill,就优先 patch 它。没有加载过,但库里有一个大类 Skill 能覆盖,就 patch 那个 umbrella。再不行,把细节写进 references/templates/scripts/。最后才新建新的 class-level umbrella。

所以一个失败的设计是这样:

debug-pr-1234-ci-timeout
fix-today-astro-build
remember-user-angry-about-blog-style

更符合 Hermes prompt 的设计是这样:

ci-debugging
astro-blog-writing
repo-blog-writer

具体的一次任务细节,可以进这些 Skill 的小节,或者进 references/。Skill 名本身应该描述一类任务。

prompt 还专门写了反模式:

Do NOT capture:
  • Environment-dependent failures...
  • Negative claims about tools or features...
  • One-off task narratives...

If a tool failed because of setup state, capture the FIX...

这个约束很重要。比如某次 npm 因为本机依赖没装失败了,不能沉淀成“npm 不可用”。真正该沉淀的是“遇到这个项目时先安装依赖”或者“这个环境变量要设置”。它要保存修复路径,不保存临时故障本身。

写入:skill_manage 是唯一入口

后台 review 真的要改 Skill 时,不会自己写文件,而是调用 skill_manage

这个工具的 schema description 本身就把使用规则写进去了:

# tools/skill_manager_tool.py
Actions: create, patch, edit, delete, write_file, remove_file.

Create when: complex task succeeded (5+ calls), errors overcome,
user-corrected approach worked, non-trivial workflow discovered,
or user asks you to remember a procedure.

After difficult/iterative tasks, offer to save as a skill.
Skip for simple one-offs.

这里有个很实用的点:patch 是 preferred for fixes,edit 是 major overhauls only。也就是说,平时小修应该 patch,不要整篇重写。

最小 Skill 是一个目录加一个 SKILL.md

~/.hermes/skills/
└── repo-blog-writer/
    └── SKILL.md

复杂一点可以带支持文件:

~/.hermes/skills/
└── repo-blog-writer/
    ├── SKILL.md
    ├── references/
    │   └── evidence-checklist.md
    ├── templates/
    │   └── blog-outline.md
    └── scripts/
        └── verify-post-links.py

SKILL.md 是入口,references/ 放更细的知识,templates/ 放可复制的模板,scripts/ 放可重复运行的脚本。background review prompt 也是这么要求的:如果新增了支持文件,umbrella 的 SKILL.md 里还要加一行指向它,不然未来 Agent 不知道这个文件存在。

Provenance:为什么同样 create,后果不一样

foreground create 与 background review create 的区别

这块很容易误会:只要调用 skill_manage(action="create"),就算自动沉淀吗?

答案是否定的。

先看写入成功后的 telemetry 逻辑:

# tools/skill_manager_tool.py
if action == "create":
    if is_background_review():
        mark_agent_created(name)
elif action in {"patch", "edit", "write_file", "remove_file"}:
    bump_patch(name)
elif action == "delete":
    forget(name)

只有 is_background_review() 为真时,新建 Skill 才会被标记成 agent-created。

这个判断来自 ContextVar

# tools/skill_provenance.py
BACKGROUND_REVIEW = "background_review"

def get_current_write_origin() -> str:
    return _write_origin.get()

def is_background_review() -> bool:
    return get_current_write_origin() == BACKGROUND_REVIEW

前面提到过,review fork 创建时会设置:

review_agent._memory_write_origin = "background_review"
review_agent._memory_write_context = "background_review"

所以同样是 create,有两种情况:

来源是否写入 Skill是否标记 created_by=agentCurator 是否默认纳入
用户明确要求前台 Agent 创建
background review fork 自己创建

mark_agent_created 最后写到 .usage.json

# tools/skill_usage.py
def mark_agent_created(skill_name: str) -> None:
    def _apply(rec: Dict[str, Any]) -> None:
        rec["created_by"] = "agent"
    _mutate(skill_name, _apply, require_curation_eligible=True)

这就是 provenance 的意义:Hermes 不把所有本地 Skill 都当成“Agent 自动生成资产”。用户明确创建的 Skill 更像用户资产,默认不交给 Curator 自动整理。

下次怎么复用

沉淀完只是第一步。下次能不能被用上,靠三条路径。

第一条是系统提示里的 Skill 索引。prompt_builder.py 会把 Skill 名和描述放进 system prompt,并要求模型先加载相关 Skill:

# agent/prompt_builder.py
Before replying, scan the skills below.
If a skill matches or is even partially relevant to your task,
you MUST load it with skill_view(name) and follow its instructions.

这段语气很硬:不是“有空可以看”,而是“匹配就必须 load”。这样 Skill 不需要把全文都塞进系统提示,只要先暴露索引,真正相关时再 skill_view

第二条是 slash command。agent/skill_commands.py 会把 Skill 变成 /skill-name 形式。用户可以直接说:

/repo-blog-writer 帮我把这个仓库机制写成博客

这时 Skill 内容会作为用户消息注入,而不是永久塞进 system prompt。对 prompt cache 更友好。

第三条是 telemetry。skill_view 成功后会记录 view 和 use:

# tools/skills_tool.py
if resolved:
    from tools.skill_usage import bump_use, bump_view
    bump_view(str(resolved))
    bump_use(str(resolved))

.usage.json 里会积累 view_countuse_countpatch_countlast_used_atlast_patched_at 等字段。后面的 Curator 就靠这些信息判断一个 Skill 是活跃、过期,还是可以归档。

Curator:自动沉淀之后还要整理

Curator 生命周期

如果只会写 Skill,不会整理,最后还是会乱。

Hermes 里 Curator 的职责是整理 agent-created skills。它有两类动作:一类不用 LLM,按使用时间做状态转换;一类用 LLM,看内容能不能合并成 umbrella。

先看自动状态转换:

# agent/curator.py
for row in _u.agent_created_report():
    name = row["name"]
    if row.get("pinned"):
        continue

    if anchor <= archive_cutoff and current != _u.STATE_ARCHIVED:
        ok, _msg = _u.archive_skill(name)
    elif anchor <= stale_cutoff and current == _u.STATE_ACTIVE:
        _u.set_state(name, _u.STATE_STALE)

默认配置大致是:

interval_hours: 168
min_idle_hours: 2
stale_after_days: 30
archive_after_days: 90

也就是每 7 天左右检查一次,空闲足够久才跑。30 天没活动标成 stale,90 天没活动归档。pin 掉的 Skill 会跳过。

归档不是删目录。archive_skill 的实现是移动到 .archive/

# tools/skill_usage.py
def archive_skill(skill_name: str) -> Tuple[bool, str]:
    """Move a curator-eligible skill directory to ~/.hermes/skills/.archive/."""
    ...
    skill_dir.rename(dest)
    ...
    set_state(skill_name, STATE_ARCHIVED)

再看 LLM curator 的 prompt。它的目标不是“找重复项”,而是做 umbrella-building:

# agent/curator.py
This is an UMBRELLA-BUILDING consolidation pass...

The right target shape is CLASS-LEVEL skills with rich SKILL.md
bodies + `references/`, `templates/`, and `scripts/` subfiles...

Hard rules:
1. DO NOT touch bundled or hub-installed skills.
2. DO NOT delete any skill. Archiving ... is the maximum destructive action.
3. DO NOT touch skills shown as pinned=yes.

这和 background review prompt 是同一个方向:不要把 Skill 库变成一堆今天的问题。能合并成大类,就合并成大类;细节放支持文件。

举个例子,如果库里出现:

astro-build-failure
astro-image-path-debugging
astro-content-schema-fix

Curator 更希望最后变成:

astro-blog-maintenance/
├── SKILL.md
└── references/
    ├── build-failures.md
    ├── image-paths.md
    └── content-schema.md

读起来更少,命中更准,也更容易维护。

源码里几个边界

1. “agent-created”在报告里有两层意思

skill_usage.pyis_agent_created(skill_name) 的 docstring 写的是“既不是 bundled,也不是 hub-installed”。但 Curator 真正枚举候选时,不只看文件位置,还要求 usage record 显式 opt in:

# tools/skill_usage.py
if not _is_curator_managed_record(usage.get(name)):
    continue

def _is_curator_managed_record(record: Any) -> bool:
    return record.get("created_by") == "agent" or record.get("agent_created") is True

所以当前实现比“本地非内置 Skill 都可管”更保守。没有 created_by=agent 的本地手写 Skill,不会自动进入 Curator 候选。

2. Curator prompt 说 archive,但 skill_manage 的 delete 语义要小心

Curator prompt 里写 skill_manage action=delete — archive a skill,但 skill_manage(action="delete") 这个工具本身是删除类操作。确定性的 archive_skill() 才是移动到 .archive/

所以重要 Skill 最稳的保护方式是 pin:

hermes curator pin my-important-skill

源码里也写了 pinned skill 的保护规则:delete 会被拒绝,但 patch 和 edit 仍然允许。也就是说,pin 是防丢,不是防改。

3. 后台复盘是 best-effort

主循环里 _spawn_background_review 外面包了 try/except

try:
    agent._spawn_background_review(...)
except Exception:
    pass  # Background review is best-effort

这意味着自动沉淀失败不会让用户任务失败。它是“顺手学习”,不是主流程的一部分。这个取舍挺合理:复盘坏了可以以后修,不能因为复盘坏了把用户刚完成的任务也判失败。

按源码阅读顺序

如果你要继续读仓库,建议按这个顺序:

文件重点
agent/agent_init.py_skill_nudge_interval 的默认值和配置读取
agent/conversation_loop.py主循环如何累计 _iters_since_skill,任务结束后如何 fork review
agent/codex_runtime.pyCodex runtime 路径如何用 turn.tool_iterations 补同一套逻辑
agent/background_review.pyreview fork、review prompt、工具白名单、prompt cache 复用
tools/skill_manager_tool.pyskill_manage 的 action、写入校验、成功后的 telemetry
tools/skill_provenance.pyforeground 和 background review 的来源区分
tools/skill_usage.py.usage.jsoncreated_by、view/use/patch 计数、归档
agent/prompt_builder.pySkill 索引如何进 system prompt
agent/skill_commands.py/skill-name 如何把 Skill 内容注入对话
agent/curator.pystale、archive、umbrella 合并和 dry-run 规则

最后总结

Hermes 的自动沉淀 Skill 机制可以压缩成这条链路:

复杂任务产生足够多工具迭代
  -> 主回复完成
  -> 后台 review fork 复盘
  -> 优先 patch 旧 Skill
  -> 必要时 create class-level Skill
  -> background_review create 才标记 created_by=agent
  -> 下次通过索引、/skill-name、skill_view 复用
  -> Curator 定期 stale / archive / umbrella 合并

我觉得这里最值得借鉴的是两个克制点。

第一,主任务和学习任务分开。用户先拿结果,后台再复盘。

第二,自动沉淀不等于疯狂新建。Prompt 反复强调 class-level、umbrella、references,代码又用 provenance 区分用户资产和自动资产。这样做的目的很明确:让 Skill 库变成可复用的操作手册,而不是一堆任务残留。