从0打造编码Agent 阅读 + 实验 ≈ 30 分钟 页内实验 ×2
A6 记忆与会话:明天它还记得吗? 第 3 课

几十个技能说明,全塞进 prompt 吗?

几十份技能说明怎么放
Hermes 随包的 77 个 SKILL.md 全文合计 215,494 token,是本课录制模型窗口的 5.3 倍,换成 20 万的窗口也放不下。现代做法是渐进披露:system prompt 里只放索引(Hermes 实际注入的索引 2,209 token),模型觉得相关时再读正文,附属文件用到才读。本课在真实文件上算五种注入方式,现场演示 Hermes 的 60 字符截断,再看 skill 怎么分发(MCP Skills Extension 已 Final)、agent 怎么自己写 skill。
❓

本课一问:「怎么调试」「怎么写测试」「怎么做代码评审」这类流程性知识,一份就是几千 token。几十份这样的技能说明,全塞进 prompt 吗?

这一课先不碰:MCP 工具定义的按需加载(A2 第 4 课);skill 里的脚本在哪执行、归哪道权限闸管(A3、A4)。

从已有出发

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

前两课放进 prompt 的是两类知识:人写的规矩(AGENTS.md,几十到几百 token)和 agent 学到的事实(记忆,Hermes 上限 2,200 字符)。它们小,整份注入没问题。第三类是流程:遇到某种任务,该按什么步骤做。修 test_parse_date 这种 bug,一份讲系统调试的说明就可能用得上。

业界把这类说明写成 SKILL.md:一个目录里放一个 Markdown 文件,开头是 YAML frontmatter(至少 name 与 description),后面是正文,目录里还可以放脚本和参考资料。Claude Code、Codex、Pi、Hermes 都支持这个格式(agentskills.io 规范)。

本课的语料换了一个:本工作区自己只有 1 个 skill,撑不起「几十个」的问题;Hermes 的 pin 随包带了 77 个真实的 SKILL.md(分 15 个类别,安装时拷进 ~/.hermes/skills/),公开、可复现,正好用来算。

先自己设计:每个 SKILL.md 的全文都放进 system prompt

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案全量加载:每个 SKILL.md 的全文都放进 system prompt

最大的三个(整个文件的 token 数):research-paper-writing(research)16,816、claude-code(autonomous-ai-agents)9,067、humanizer(creative)7,648;中位数 2,526。其中 6 个的正文超过了 agentskills.io 建议的 5,000 token,9 个超过建议的 500 行。本工作区的 interactive-course-builder 也一样:正文 7,718 token、250 行。

一轮修 bug 的任务里,真正用得上的通常只有一两份。

改进:渐进披露,在真实文件上算

下面五条共用同一把尺子(模型窗口,可切换)。①②③④ 的文本都由 pin 源码的原函数在这 77 个文件上排出来,再用录制模型的 tokenizer 数;⑤ 是 ③ 加上你勾选的正文。

几十个技能说明怎么进 prompt:同一批真实 SKILL.md 的五种注入方式 尺子(模型窗口):
Hermes 随包的 77 个 skill:勾选「这一轮真的用到」的,它的正文加进「按需」那一条
截断试验台:把一段 description 交给三家的规则

口径:语料是 hermes-agent@56dc01d9 的 skills/ 目录(安装时由 skills_sync.py 拷进 ~/.hermes/skills/)。三种索引的原文都由 pin 源码的原函数排出:Hermes 用 build_skills_system_prompt(在临时 HERMES_HOME 里跑,macOS),Pi 用 formatSkillsForSystemPrompt(bun;location 按 Pi README 的约定目录构造)。token 用录制模型 Qwen/Qwen3-8B-MLX-4bit 的 tokenizer 数,正文按整个 SKILL.md 文件计;换成别的模型,绝对数会变,比例大体不变。规范上限据 agentskills.io 与 Claude Code skills 文档(2026-09-26 抓取)。

🎯

试三件事:① 尺子切到 200,000,全量那一条还是超出窗口吗?再看 ③ 占窗口百分之几。② 展开技能表,搜 debug,勾上你觉得修 test_parse_date 会用到的 skill,看 ⑤ 涨了多少——和 ① 比,还差几个数量级?③ 在截断试验台里,把默认的那段中文描述改短到 60 个字符以内,看 Hermes 那一行什么时候不再被截断;再把关键用途挪到最前面。

读数时的四件事:

  1. 索引很小。Hermes 实际注入的索引(9,322 字符)是 2,209 token,全量的 1.0%;其中一大段是固定的使用说明,不随 skill 数量增长。
  2. Pi 放完整描述,Hermes 截到 60 字符。Pi 的索引 4,639 token,是 Hermes 的 2.1 倍。Hermes 随包的 77 条描述都写在 60 字符以内,一条也没被截——截断是给用户和 agent 自己新写的 skill 兜底的。
  3. 编码场景还能再省。Hermes 的编码画像在 focus 模式下,把 18 个非编码类别(Apple、邮件、购物……)降为只列名字:1,461 token。名字仍然全在,想用照样能加载。
  4. 按需的代价只在用到的那一轮。正文是作为工具结果进入对话的,排在前缀的后面,不打穿前面的缓存;Claude Code 官方文档把「调用 skill」列在「保持缓存」的动作里——skill 的内容以 user 消息的形式追加在当前位置,前面的对话不变。
修 test_parse_date 的某一轮:按需加载两份调试相关的 skill 后,skill 相关的上下文 = 7,999 token
= + +
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
正文按整个文件的 token 数计(含 frontmatter);Hermes 的 skill_view 返回的是文件内容加少量元数据,本页没有录制这一步,所以只按文件本身算。

三家的索引长什么样

Pi 把每个 skill 写成三行 XML:名字、完整描述、文件位置,再加三句使用说明——「任务和描述对得上,就去读整个文件」:

system-prompt.ts pi@31b513e3:packages/agent/src/harness/system-prompt.ts:3-25需联网 MIT
3export function formatSkillsForSystemPrompt(skills: Skill[]): string {
4 const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);
5 if (visibleSkills.length === 0) return "";
6
7 const lines = [
8 "The following skills provide specialized instructions for specific tasks.",
9 "Read the full skill file when the task matches its description.",
10 "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
11 "",
12 "<available_skills>",
13 ];
14
15 for (const skill of visibleSkills) {
16 lines.push(" <skill>");
17 lines.push(` <name>${escapeXml(skill.name)}</name>`);
18 lines.push(` <description>${escapeXml(skill.description)}</description>`);
19 lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
20 lines.push(" </skill>");
21 }
22
23 lines.push("</available_skills>");
24 return lines.join("\n");
25}
👆 带下划线的行可以点:点哪行,讲哪行。

Hermes 的截断只有几行,本页的截断试验台就是它的移植:

skill_utils.py hermes-agent@56dc01d9:agent/skill_utils.py:847-866需联网 MIT 最新版已变化 · 勘误表 H5
847# ── Description extraction ────────────────────────────────────────────────
848
849SKILL_PROMPT_DESC_LIMIT = 60
850
851
852def _normalize_skill_description(frontmatter: Dict[str, Any]) -> str:
853 """Normalize a skill's description field for comparison/truncation."""
854 raw_desc = frontmatter.get("description", "")
855 return str(raw_desc).strip().strip("'\"") if raw_desc else ""
856
857
858def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
859 """Extract a system-prompt-length description from parsed frontmatter."""
860 desc = _normalize_skill_description(frontmatter)
861 if not desc:
862 return ""
863 if len(desc) > SKILL_PROMPT_DESC_LIMIT:
864 return desc[:SKILL_PROMPT_DESC_LIMIT - 3] + "..."
865 return desc
866
👆 带下划线的行可以点:点哪行,讲哪行。
prompt_builder.py hermes-agent@56dc01d9:agent/prompt_builder.py:1894-1918需联网 MIT
1894 index_lines = []
1895 for category in sorted(skills_by_category.keys()):
1896 # Deduplicate and sort skills within each category
1897 seen = set()
1898 if category in demoted:
1899 names = sorted({name for name, _ in skills_by_category[category]})
1900 index_lines.append(f" {category} [names only]: {', '.join(names)}")
1901 continue
1902 cat_desc = category_descriptions.get(category, "")
1903 if cat_desc:
1904 index_lines.append(f" {category}: {cat_desc}")
1905 else:
1906 index_lines.append(f" {category}:")
1907 for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):
1908 if name in seen:
1909 continue
1910 seen.add(name)
1911 if desc:
1912 index_lines.append(f" - {name}: {desc}")
1913 else:
1914 index_lines.append(f" - {name}")
1915
1916 result = (
1917 "## Skills (mandatory)\n"
1918 "Before replying, scan the skills below. If a skill matches or is even partially relevant "
👆 带下划线的行可以点:点哪行,讲哪行。

Claude Code 的做法写在官方 skills 文档里(2026-09-26 读取):description 与 when_to_use 合计超过 1,536 字符的部分被截掉;整个技能列表有一个字符预算,约为模型窗口的 1%,超了就先丢「最少被调用的 skill」的描述,名字始终保留;官方建议把关键用途写在描述最前面。压缩之后,调用过的 skill 正文会重新注入(每个最多 5,000 token、合计 25,000),技能列表本身不再重注入。learn-claude-code 的 s07 是最小教学版:system 里一份「名字: 描述」目录,正文靠一个 load_skill 工具返回。

⚠
常见误解一:「skill 就是工具。」

不是一回事。工具是可执行的接口:有名字、有参数 schema,每个请求都把定义发给模型,模型调用它、harness 执行它。skill 是一段说明文本(可以附带脚本),平时只有名字和一句描述在 prompt 里,用到时才把正文读进对话;读它这个动作本身通常借一个工具完成(Hermes 的 skill_view、learn-claude-code 的 load_skill、Pi 直接用读文件的工具)。

常见误解二:「描述写得越详细,模型越能用对。」

描述活在一个有预算的索引里:Hermes 截到 60 个字符,Claude Code 单条 1,536 字符、列表总共约窗口的 1%,超了还会整条丢掉。写太长,后半截模型根本看不到。把「什么时候用它」放在第一句。

规范与分发

agent 自己写 skill:Hermes 的自我改进闭环

skill 不一定是人写的。Hermes 在一次任务里工具迭代累计到 10 次(可配置),就在后台 fork 出一个自省的 agent,问它「有没有值得存成 skill 或记忆的东西」:

turn_finalizer.py hermes-agent@56dc01d9:agent/turn_finalizer.py:732-738需联网 MIT
732 # Check skill trigger NOW — based on how many tool iterations THIS turn used.
733 _should_review_skills = False
734 if (agent._skill_nudge_interval > 0
735 and agent._iters_since_skill >= agent._skill_nudge_interval
736 and "skill_manage" in agent.valid_tool_names):
737 _should_review_skills = True
738 agent._iters_since_skill = 0
👆 带下划线的行可以点:点哪行,讲哪行。
background_review.py hermes-agent@56dc01d9:agent/background_review.py:1-17需联网 MIT
1"""Background memory/skill review — fork the agent to evaluate the turn.
2
3After every turn, ``AIAgent.run_conversation`` may call
4:func:`spawn_background_review` to fire off a daemon thread that replays
5the conversation snapshot in a forked :class:`AIAgent` and asks itself
6"should any skill/memory be saved or updated?". Writes go straight to
7the memory + skill stores. Main conversation and prompt cache are never
8touched.
9
10The fork inherits the parent's live runtime (provider, model, base_url,
11credentials, cached system prompt) so it hits the same prefix cache and
12uses the same auth. It runs with a tool whitelist limited to memory and
13skill management tools; everything else is denied at runtime.
14
15See the ``hermes-agent-dev`` skill (``references/self-improvement-loop.md``)
16for invariants and PR review criteria.
17"""
👆 带下划线的行可以点:点哪行,讲哪行。

agent 写出来的 skill 由一个定期运行的 curator 维护,规则里有一条「只归档、从不删除」:

curator.py hermes-agent@56dc01d9:agent/curator.py:1-19需联网 MIT
1"""Curator — background skill maintenance orchestrator.
2
3The curator is an auxiliary-model task that periodically reviews agent-created
4skills and maintains the collection. It runs inactivity-triggered (no cron
5daemon): when the agent is idle and the last curator run was longer than
6``interval_hours`` ago, ``maybe_run_curator()`` spawns a forked AIAgent to do
7the review.
8
9Responsibilities:
10 - Auto-transition lifecycle states based on derived skill activity timestamps
11 - Spawn a background review agent that can pin / archive / consolidate /
12 patch agent-created skills via skill_manage
13 - Persist curator state (last_run_at, paused, etc.) in .curator_state
14
15Strict invariants:
16 - Only touches agent-created skills (see tools/skill_usage.is_agent_created)
17 - Never auto-deletes — only archives. Archive is recoverable.
18 - Pinned skills bypass all auto-transitions
19 - Uses the auxiliary client; never touches the main session's prompt cache
👆 带下划线的行可以点:点哪行,讲哪行。

证据三问

问本课的回答
① 它解决什么问题?流程性知识越攒越多,又不能每个请求都整份带着
② 为什么渐进披露省?常驻的只有索引,正文只在用到的那一轮、以追加的方式进入对话
③ 实验上呢?各家的格式与机制有源码与官方文档为证 ;「模型会不会在该读正文的时候去读」没有本课的实测——那取决于模型与描述写法,Böckeler 把它归为「由模型决定加载」固有的不确定性

演进地图:你在这里

记忆与会话的演进:每一代都被上一代的某个真数字逼出来
  1. 最省事的做法:任务说明里顺手写上「不许改测试文件」「日期用 DD/MM/YYYY」
    解决了不用改 harness,当场就生效。
    卡在A5 的分层压缩运行里,规矩写在任务说明中,17 份真实摘要只有 2 份还带着「不许改测试文件」;换一个新会话更彻底:T10 day2 的第一个请求只有 2 条消息、1,138 token,来自前一天对话的是 0 条。
  2. Claude Code 的 CLAUDE.md、Codex / OpenCode 的 AGENTS.md(Codex 从项目根到 cwd 逐级叠加、32 KiB 上限;OpenCode 同名文件 first-match)、Hermes 三段 prompt 的 context 段
    解决了会话开始从磁盘读一次,拼进每个请求;压缩之后从磁盘重新注入。T10 day2 的每一轮都带着 AGENTS.md 块(87 token),验收「按 AGENTS.md 的格式输出」:通过。
    卡在它只装人写好的规矩。agent 自己在干活时学到的东西(比如这个仓库怎么跑测试)写到哪、什么时候生效?
  3. 本课项目 miniagent 的 refresh 模式(T10 刷新组)
    解决了本会话马上就能用上刚学到的东西。
    卡在T10 刷新组 day1 第 7 轮:上一轮命中 2,072 token,刷新后这一轮 2,300 token 只命中 294——前缀在记忆块处断开,后面的工具定义与全部历史都要重算。
  4. Hermes MEMORY.md(2,200 字符)/ USER.md(1,375 字符);miniagent frozen 模式;仓库准则「Per-conversation prompt caching is sacred」
    解决了会话内 system 字节不变:T10 冻结组 day1 的 8 个后续请求里,8 个把上一轮的请求整个读回了缓存(最多差 1 个模板 token);day2 开新会话,第一个请求就带着前一天写的记忆。
    卡在流程性知识越攒越多:Hermes 随包的 77 个 SKILL.md 全文合计 215,494 token,是录制模型窗口(40,960)的 5.3 倍,全塞进快照放不下。
  5. Pi(name + description + location)、Hermes(描述截到 60 字符)、Claude Code(单条 1,536 字符上限)、agentskills.io 规范;分发:MCP Skills Extension(SEP-2640,2026-09-13 Final)
    解决了同样 77 个 skill,Hermes 实际注入的索引 2,209 token;模型觉得相关时再读正文。
    卡在知识的问题解决了,会话本身怎么存?想回到三轮前换条路:T10 从 e0005 分叉,线性列表只能删掉它后面的 15 条(原来那条成功的路径)。
  6. Pi(id / parentId,一个 JSONL,/tree /fork /clone)、Claude Code(/branch、--fork-session)、Codex rollout(append-only,压缩写成 CompactedItem)、miniagent session.py
    解决了两条分支并存,压缩有损、历史无损;分叉的第一个请求与原分支共享前缀:T10 fork 第 1 轮 1,236 token,实测命中 1,197。
    卡在一个会话、一个窗口的边界还在:一件事大到一个 agent 的上下文装不下、或需要别人把关时 → 下一章 A7 子 agent。
旁支 按相关性挑选记忆注入,或检索(RAG) 没有比较证据
learn-claude-code s09(每次用户发话先让模型从目录里挑相关记忆,最多 20,000 字符);Hermes 的外部记忆 provider 插件(默认关闭,每轮预取、只拼进当轮 user 消息)
和文件式冻结快照谁更好?档案里没有任何比较性证据——能讲清的只有设计维度:谁决定加载、什么时候注入、会不会打穿前缀缓存。

数字口径:A5 的摘要来自 pd-v2-layered-s1 的真实运行(a05-context.json);其余来自本课项目 miniagent 的 T10 两天会话(Qwen3-8B-MLX-4bit,本机 mlx_lm.server,2026-09-26),缓存命中是服务端报告的 cached_tokens;skill 的 token 数由录制模型的 tokenizer 在 Hermes pin 的 77 个 SKILL.md 上现算(a06-memory.json,2026-09-26)。

一句话

📌
索引常驻,正文按需。
🚦

先停在这里:记住三层(元数据、正文、附属文件)和它们各自进入上下文的时机;描述要短、关键用途放最前。
再深一层:在实验课里换一个窗口大小,看同样 77 个 skill 在不同模型上各占多少。

下一个缺口

会话本身怎么存?想回到三轮前换条路呢? → 第 4 课:会话该存成列表还是树

规矩、记忆、技能都解决了「知识」放在哪。还剩会话自己:A5 说完整历史留在磁盘上,那磁盘上的它长什么样?想退回三轮前试另一条路,原来那条路还在不在?

你可能会问