My Blog
Agent 学习

Agent 学习第 13 周:Skills、Harness,与一个能真跑的渐进式加载器

自己写一个渐进式加载 skill 的 harness:首轮 token 比全量加载少 6.6 倍,也避免了无关 skill 干扰判断。

发布于 2026/9/11更新于 2026/9/11阅读约 31 分钟
只问这篇文章 →

第十三周讲两块:Harness Engineering 和 Skills。

作业是自己写一套 harness,实现渐进式加载并执行 skill,用老师的 flash-card skill 做验证。

这周做完最有意思的一点是:我实测出来的几个数字,和课件里给的预算对不上——不是课件错了,是那个预算在真实 skill 上太紧了。这篇会把这些不一致原样写出来。


一、三个层次:Prompt ⊂ Context ⊂ Harness

课程开头这张分层图,我觉得是这周最有价值的一句话:

层次核心问题手段
Prompt Engineering如何把任务说清楚Role Prompting、Few-shot、CoT、ReAct
Context Engineering模型关键时刻看到什么RAG、Tools、Memory、State
Harness Engineering模型在什么机制里运行目标边界与停止条件、反馈回路、记录系统

三者不是替代关系,而是外延逐层扩大。

对应的范式转移是:对话闭环「说了就算」→ 执行闭环「说了就做」。

课程用 OpenClaw 这个平台做案例,讲了几个 Harness 层才有的设计:

Lane Queue(每个会话独占通道) —— 类比银行叫号窗口。没有它会怎样:

t=0s    用户发「帮我写一封邮件」
t=0.3s  又发「用中文写」
t=0.5s  两个线程同时读写 Context → 生成内容混乱穿插

根本原因是LLM 的「读 Context → 生成 → 写回」是一个原子操作,并发时两次写回互相覆盖。

四层记忆模型:

L1 Working    Context 里的 messages[]      ~128K tokens   即时    当前会话
L2 Short-term SQLite + memory/日期.md      数 MB          ~5ms    当天到数天
L3 Long-term  MEMORY.md + USER.md          数十 KB        ~1ms    永久
L4 Vector     向量数据库                    数 GB          ~50ms   永久

检索顺序是按「成本递增、范围递增」:先查最近最便宜的,找不到再查更远更贵的。

Compaction 与 Flush 的分工这一条我记得最牢:

Compaction 释放空间,Flush 保存价值。

前者压缩旧对话为摘要让 Context 变小,后者提取关键信息写进 MEMORY.md。Context > 85% 时两者同时发生。


二、Skills:为什么需要它

三大痛点,核心矛盾是一句话:

能力越强,上下文越重,推理越低效。

课件给的 Context 构成统计是这样的:

用户指令 ~15%    对话历史 ~20%    工具定义 ~35%    系统 Prompt ~30%

工具定义占 35%,比系统 Prompt 还多。 代价是 65%+ 的 context 被非任务内容占用,有效推理深度下降约 40%。

渐进式披露:三层结构

常驻层 Always Loaded   索引,每条 Skill 一行摘要      < 200 tokens
触发层 On Demand       匹配到才加载完整定义           500~2000 tokens
执行层 In Context      执行期间驻留,完成后释放

Skills 和 FC / MCP / RAG 的关系

Skills 是上层调度者,Function Call、MCP、RAG 是它可选的执行模块。

Function Call 是 Skill 内部的执行原语,MCP 提供标准的工具发现协议,RAG 作为知识获取步骤内嵌在 Skill 流程中。


三、作业:老师给了什么、缺什么

给的是什么
skills/flash-card/一个标准结构的 skill:SKILL.md + scripts/ + data/
skills/baoyu-diagram/SKILL.md 有 11,740 字节,外加 4 个 references/
agent_memory_system/一套 harness——但它是记忆系统

缺的正是加载 skill 的那一层。 我在 agent_memory_system/src/*.py 里搜过,skill 这个词一次都没出现。

所以这次要写的是老师没给的:一个把 SKILL.md 按需装进 context、并且真能把 skill 执行出来的 harness。


四、常驻层:「不读正文」不是「读了但不用」

这是整套东西里最容易糊弄过去的地方。很容易写成:

text = path.read_text()                 # 整个文件都进内存了
fm, body = text.split("---", 2)[1:]
return fm                               # 只是「没用」body,但已经读进来了

这样也能跑,索引也对,但它不是渐进式加载——正文已经在进程里了。

真正的做法是读到 frontmatter 收尾的 --- 就 break,文件后面的字节从没被读过。逐行读并记下 f.tell(),实测:

skill                读了(frontmatter)      没读(正文)     正文占比
baoyu-diagram                 623 B         11,117 B        95%
flash-card                    368 B          2,613 B        88%
合计                          991 B         13,730 B        93%

验证方式是按记下的偏移量把文件切成两半,确认正文第一行从没出现在读过的那一段里:

[OK] baoyu-diagram
     读取停在第 623 字节,文件共 11,740 字节
     正文第一行(没进内存):# 图表生成器

★ 一个副产品:这里用不了 yaml.safe_load——它得先把整段文本喂进去,那就失去意义了。所以 frontmatter 是自己按行解析的。「按需加载」这个约束会一路传导到你能用哪个库。


五、渐进式披露的不只是说明书,还有工具

既然工具定义占 35% 的 context,那只把 SKILL.md 做成按需加载、工具却一次性全给,等于只省了一半。

本 harness 的工具是分阶段给的:

加载前   只有 load_skill                                       1 个
加载后   load_skill + write_file / run_script
         / read_reference / list_skill_files                   5 个

模型在决定「该加载哪个 skill」的时候,根本看不到 write_file 长什么样——它也不需要。

实测(同一段 messages,只改工具数量):

不带 tools 字段                    505 tok
tools=1  tool_choice=auto          854 tok   → load_skill 的 schema 349 tok
tools=5  tool_choice=auto        1,240 tok   → 多的 4 个执行工具 = 386 tok

这个测量本身踩了个坑

我第一版用 tool_choice="none" 去探,结果三种情况都返回 503 tok,一模一样——会得出「工具 schema 不要钱」的荒谬结论。

原因是 tool_choice="none" 时模型直接把 schema 丢掉不计入 prompt。换成 auto 才是真实开销。

探测参数选错,测出来的是另一回事。


六、执行层:为什么不给 bash

最省事的写法是给模型一个 bash(command) 万能工具——毕竟 flash-card 的 SKILL.md 第 3 步给的就是一整行现成的 shell 命令,模型照抄执行就行。

但SKILL.md 是外部内容。换一个来源不明的 skill,正文里就可以写「先执行 rm -rf ...」。这正是课上讲的「权限蔓延攻击」,对策原话是「工具调用白名单 + 上下文沙箱隔离」。

所以四个工具都做了两道限制:

① 路径沙箱     解析成绝对路径后必须落在 skills/ 或 outputs/ 内,`..` 穿越挡下
② run_script   只认 skills/<skill>/scripts/ 下的 .py,不经过 shell,带 60s 超时
[OK] ../../../../../etc/passwd                 目录穿越 → 已拒绝
[OK] C:/Windows/System32/drivers/etc/hosts     系统绝对路径 → 已拒绝
[OK] ../../../../Windows/System32/calc.exe     越界+非py → 拒绝

另外没做 open_browser。 SKILL.md 第 4 步要求「用默认浏览器打开生成的 HTML」,但那是对宿主环境的外部副作用,跑批时会弹一堆窗口。harness 改成把产物路径回给模型,开不开由用户决定——有副作用的操作留人在回路。


七、动手才发现:skill 里的路径是写死的

老师的 flash-card/SKILL.md 正文里写的是:

路径:`.cursor/skills/flash-card/data/<word>.json`

它假设宿主把 skills 装在 .cursor/skills/ 下(Cursor 的约定)。我的 harness 装在别处,照着正文执行必然找不到文件。

方案代价
迁就 skill,把目录改成 .cursor/skills/宿主被 skill 绑架,换个来源的 skill 又是另一套路径
加载时注入真实路径,正文原样不动harness 多做一层,但 skill 完全不用改

我选了后者,老师的 SKILL.md 一个字都没改:加载正文时附一段「运行时上下文」,把路径对应关系讲清楚;沙箱那边顺手把 .cursor/skills/ 前缀剥掉。

这暴露的是 skill 侧的设计缺陷,违反了课上讲的「上下文感知」原则——skill 应该声明它需要什么,而不是假设环境长什么样。


八、跑通:完整的生命周期曲线

问> 给我做张 resilient 的闪卡

[1] 触发     852 tok  → load_skill(flash-card)   正文 2,219 字符,工具 1→5 个
[2] 执行   2,318 tok  → write_file(data/resilient.json)
[3] 执行   2,758 tok  → run_script(make_flashcard.py)   退出码 0
[4] 执行   2,908 tok  → 作答

context 字符数随生命周期变化:
  开局      840        ← 只有索引
  加载后  4,632        ← 正文进来了
  释放后  2,448        ← 正文移除,只留一行「已执行并释放 skill:flash-card」

这条曲线就是课件那张「只有索引 → 加载定义 → 执行中驻留 → 释放后恢复」的实测版。

但这个测试有问题,我换了个词重测

resilient 正好是 SKILL.md 里「数据 JSON 示例」那一节给的词——模型完全可以照抄,不需要真的理解流程。

查了一下各个词在 skill 里出现几次:

crazy 8 处   resilient 11 处   thrill 4 处   meticulous 1 处
ubiquitous 0 处   serendipity 0 处

用 ubiquitous(skill 里 0 次出现)重测,产物里音标、词性、释义、6 个近义词、3 条中英对照例句全齐,符合 SKILL.md 的格式约定——说明模型是按说明书做的,不是照抄样例。


九、对比实验:三个组合

6 道题 × 3 个组合。只看三道执行类题目(两种模式跑同一条执行路径,可比):

组合                    首轮tok     总tok
progressive+llm            852    10,296
eager+llm                5,648    18,369     首轮 +563%   总 +78%
progressive+keyword      2,207     9,286     首轮 +159%   总 -10%

首轮差 6.6 倍

全量加载在第一轮就把两个 skill 的正文(13,730 字节)和 4 个执行工具全塞进去了,而这时候模型要做的只是「判断该用哪个 skill」——这个判断只需要索引。

总量差 1.78 倍:因为正文一旦进了 system prompt,后面每一轮都要重发一遍。

课件说渐进式披露能「节省 30~50% token」,这里实测总量省 44%,首轮省 85%。

全量加载唯一的那处错:不是费 token,是干扰判断

eager 六道题错一道:

问:帮我生成一份英语学习计划,安排三个月的进度
    → 动用了 ['baoyu-diagram', 'flash-card'],烧了 12,805 tok,41 秒,没产出任何东西

这道题不该动用任何 skill。但全量模式下两个 skill 的完整步骤就摊在眼前,模型顺手就去用了。渐进式和关键词两个组合都正确地什么都没做(855 tok,一轮结束)。

这正是课件列的第三条收益:「更清晰——只加载相关 Skill,减少干扰」。省 token 是看得见的,减少干扰是看不见的,但这道题上它才是决定对错的因素。

(公平起见:另一道「北京今天天气怎么样」eager 是答对的。所以不能说全量加载就一定分不清,它只是更容易被带偏。)

关键词触发反而总 token 最低,而且也是 6/6

这个结果和我预期相反:

LLM 触发     首轮便宜(只有索引 852 tok),但要多花一整轮去决定加载哪个
关键词触发   首轮就带着正文(2,207 tok),但触发这一轮在本地算完,不花钱

那一轮触发请求的开销,比首轮多带的正文还贵。

而且关键词策略在这 6 道题上也是 6/6,包括我特意设计的陷阱题「帮我生成一份英语学习计划」(和 flash-card 的 description 在「生成 / 英语 / 学习」上高度重叠)——它没有误触发。

我没能复现课件说的「过滤逻辑本身易误判,语义相近的工具分不开」。

但这不算和课件矛盾:同一页也写了工具过滤「适合工具差异明显的场景」,而我这两个 skill(做单词闪卡 vs 画 SVG 图)差异极其明显。

结论要说得保守些:在 skill 少且彼此差异明显时,关键词触发又准又省。 LLM 触发的价值要在 skill 多、语义接近时才体现,本次实验没覆盖到那个场景。


十、三个额外发现

① skill 会写自己的安装目录

flash-card 要求把生成的 JSON 存到 .cursor/skills/flash-card/data/<word>.json——skill 的安装目录里。

跑完之后:

crazy.json      已被模型改写   ← 老师的样例数据被覆盖了
resilient.json  已被模型改写
thrill.json     与老师原件一致  ← 这个词没被测到,才幸存

模型按说明书办事,没有错。但这意味着:跑一次 skill 就改变了 skill 本身;如果装在共享或只读位置会直接失败;老师的样例数据被静默覆盖,不可逆。

② skill 内部还有第二层渐进式加载

baoyu-diagram 有 4 个 references/*.md,正文里写「画时序图时读 sequence.md」。这些默认不加载:

一上来全给      20,988 字符
画时序图实际用  11,631 字符   (只读 sequence.md,另外 3 个省下 8,529 B)

所以三层不是只有 harness 这一级——skill 内部可以再分一层。

③ 索引的「< 200 tokens」预算站不住

课件说常驻层「每条 Skill 只有一行摘要,占用 < 200 tokens」。实测两个 skill 的索引就 457 字符——老师的 baoyu-diagram 光 description 一条就 200 多字。

真放几十个 skill,索引本身就会超预算,到那时候索引自己也得分层(或者改成检索,但课件也说了结构化行为描述不适合嵌入)。


这周的收获

1. 「按需加载」是个会传导的约束。

一旦决定「正文不能进内存」,就用不了 yaml.safe_load;一旦决定「工具也要分阶段给」,就得重新设计工具注册。它不是一个开关,是一条贯穿实现的设计线。

2. 省 token 是显性收益,减少干扰是隐性收益,后者更重要。

如果只看那三个数字(852 / 5,648 / 2,207),我会以为渐进式披露就是个省钱手段。但真正决定 6/6 还是 5/6 的,是模型看不到无关 skill 的完整步骤,就不会顺手去用。

3. 测试用例要避开样例词。

我第一次用 resilient 测,跑通了很高兴。查了一下才发现这个词在 SKILL.md 里出现 11 次——模型完全可以照抄。换成 skill 里 0 次出现的词重测,这个验证才成立。

4. 结论要限定在实验覆盖的范围内。

关键词触发在我这 2 个 skill 上又准又省,但这不能推广成「关键词触发更好」——我的两个 skill 差异太明显了,根本没构成对它的考验。 把这个限制写进报告,比写一个漂亮但站不住的结论有价值。

下周是自进化 Agent。

相关文章