第十三周讲两块: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。