SKILL 是如何工作的?
写给完全没搞懂「skill 到底是个啥」的人。
本文拿一份虚构的 skill 当标本,从「你说一句话」讲到「文件落到你桌面上」 来剖析skill。
开始之前,先把前提说清楚:
本文举的 agent 客户端是 Codex。换成别的 agent 客户端,机制大同小异。 凡是支持 skill(或者叫规则文件、指令文件的)客户端,主线都是同一条:清单常驻 → 语义命中 → 读正文 → 按正文干活。差别主要在文件名、目录位置、触发语法、命令细节这些"皮"上。所以文中讲的主角是「大模型 + agent 客户端」这个组合,Codex 只是那个被拿出来示众的例子。
0. 先说结论(不想看长文的看这里)
- skill 不是一个程序。 它没有入口函数、没有按钮,你也没法「运行」它。它就是磁盘上的一份 Markdown 说明书。
- skill 不是被「调用」的,是被「想起来」的。 你说话 → 大模型想起来有这本手册 → 大模型去把它读进来 → 然后按它写的步骤干活。
- 「流程规范化」不是代码约束,是「指令遵从」。 真正管流程的是大模型,不是客户端。所以它是概率,不是合同。
- 所以写 skill 有一半功夫不是在写流程,是在堵漏洞------堵大模型偷懒、堵它脑补、堵它压缩上下文之后失忆。
下面展开讲。
1. 先分清四个角色,这是理解一切的前提
很多人把这四个东西混成一坨,然后越看越晕。先把它们拆开:
| 角色 | 是什么 | 能干什么 |
|---|---|---|
| LLM(大模型) | 一个只会「读文字、写文字、做决定」的脑子 | 没有手:不能读文件、不能跑命令,还记不住上一次 |
| Agent 客户端(比如 Codex) | 脑子 + 手 + 一个循环 | 给大模型接上工具(读文件、跑命令、开子 agent),让它反复「想 → 做 → 看结果 → 再想」 |
| Skill | 磁盘上的一份说明书(SKILL.md) |
它自己不会运行,只是「等着被翻开」 |
| Scripts | 说明书里夹着的计算器 | 确定性计算,不带判断 |
打个比方:
- 大模型是个刚招来的聪明实习生------理解力很强,但只会读读写写,而且还带点失忆;
- Codex 是给他配了工位、终端机,然后说一句「自己想办法干完」;
- skill 是工位上那本《操作手册》;
- 脚本是手册里夹着的计算器。
关键点:实习生不会自动去翻工位上那本手册。 得有人提醒他,或者他自己想起来。这一步,就是后面所有「玄学感」的来源。
顺便强调一下:换个客户端,就是换了个牌子的工位和终端机。手册还是那本手册,实习生还是那个实习生。
2. 拿一个(虚构的)需求当标本
假设有这么一个团队:客服每天收到用户发来的报错截图。
截图上的提示有时是「系统繁忙,请稍后重试」,有时是「当前订单状态不允许该操作」,还有时是一串没人看得懂的码,比如 E-1007。
客服想知道的是三件事:
- 这个码到底是什么意思?
- 用户在什么情况下会撞上它?
- 我这句回复该怎么写?
而这些码在代码里散得很开:几个枚举类、一堆常量类,还有些直接写在业务方法里的字符串字面量。前端展示的文案又是通过 i18n key 翻译出来的,肉眼根本对不上。
于是有了这个需求:
「给我出一份《错误码手册》,给客服和运营看的。代码一直在变,以后要能随时重新生成。」
这个活有几个特点,正好适合当标本:
- 它重复发生(代码一变,手册就过期);
- 它有对错(写错场景,客服就会照着错的信息回复用户,还不如不给);
- 它体量不小(多个模块、成百上千个码值);
- 它一半靠机器、一半靠判断(哪些码要收、码值本身是什么,机器干;"用户会怎么撞上它、该怎么回",只有读懂代码才知道)。
于是就有了下面这份 skill。它是本文虚构的 ,仓库名 demo-shop、模块名 order / pay / stock 也都是假的------只为讲清机制。
它的产出是一条固定路径的文件:
docs/错误码手册/错误码手册_YYYYMMDD.md
3. 先把原文摆出来,后面每一节都对照着它看
这份 skill 一共 4 个配置文件加 3 个脚本,其中 SKILL.md 是主角。建议先通读一遍,再往下看------不然后面讲「第 3 步在干什么」的时候,读者手上没有对照物。
markdown
---
name: error-code-handbook
description: 当需要生成或刷新《错误码手册》(把代码里的错误码整理成给客服和运营看的说明文档)时使用;也适用于回答"这个错误码是什么意思""用户在什么情况下会撞上它""同一个码是不是被复用在不同模块"这类问题
---
# 错误码手册生成
## 这个技能解决什么问题
客服和运营每天要面对用户的报错截图,但错误码散落在枚举类、常量类和字符串字面量里,前端文案又是 i18n key 翻译出来的,肉眼对不上。
本技能把「重新生成这份手册」变成一条可复现的流水线。
**产出**:`docs/错误码手册/错误码手册_YYYYMMDD.md`
## 铁律
违反任意一条,产物即为无效,必须重做:
1. **以代码为准,不以旧手册为准。** 旧手册只能当"对照物"用来发现遗漏,**绝不能当事实来源**。(2026-10 实测:旧手册里的 `E-1042` 已被下线,`E-2001` 的含义已被改写。)
2. **每个码都要有证据链。** 码值 → 定义位置 → 抛出位置,三跳都要能指出具体文件和行。给不出的必须进「待确认」。
3. **动态拼接、透传上游、日志专用码一律标注真实来源**,不许编一个"看起来合理"的场景。(客服会照着它回用户。)
4. **复用码必须分行列出**:同一个码值在不同模块含义不同时,两行都要写,不许挑一个。
5. **脚本扫到的码值必须 100% 落地**:要么进手册表格,要么在「待确认」里说明为什么不算用户可见的码。`verify_report.md` 里的未覆盖清单必须清空。
6. **不许静默跳过。** 脚本报的每一条 warning 都要么修掉,要么在最终回复里明确说出来。
## 输入
| 输入 | 位置 | 说明 |
| --- | --- | --- |
| 扫描配置 | `sources.yaml` | 要扫哪些模块、哪些包,哪些码段要忽略,前端文案从哪个 i18n 文件取 |
| 代码仓库 | `~/work/demo-shop/*` | 由 `sources.yaml` 的 `repos_root` 指定 |
前置:`python3` 且能 `import yaml`(缺就 `pip3 install pyyaml`)。
新增/下线一个模块,**只改 `sources.yaml`**,不要改本文件。
## 工作流
在技能目录下执行(`cd ~/work/skills/error-code-handbook`)。
### 第 1 步:抽取(确定性,不许跳过)
```bash
python3 scripts/extract_codes.py --config sources.yaml --out .work
```
先读 `.work/extract_report.md`。它会告诉你:扫到多少个码值、每个模块分布多少、每个码的定义位置、哪些码没有找到抛出点、**以及静态定义里出现但疑似动态拼接的部分**。
报错(文件不存在、i18n 路径对不上)必须先修 `sources.yaml`,再往下走。
### 第 2 步:填含义(语义判断,本技能的主要工作)
`extract_codes.py` 已经产出 `.work/codes_draft.yaml`:**码值、定义位置、抛出位置**是脚本抽的,这部分不需要大模型重新算。
大模型要做的是把「人话」补上,规则见 [references/judgment-rules.md](references/judgment-rules.md):
1. **用户会怎么撞上它**:去读抛出点的上下文(校验分支、前置状态、外部调用失败回滚),写成客服看得懂的一句话。
2. **客服该怎么回**:能自助解决的写操作路径;需要人工介入的写升级路径;查不清的写「按标准话术回复」。
3. **复用码排查**:同一个码值出现在多个模块时,逐一确认是不是同一件事,分行列出。
4. **动态码**:形如 `"E-" + module + code` 这种拼接出来的码,去代码里把可能取值列全;列不全就标注为动态码,并说明依据。
5. **透传码**:直接把上游返回的码透出来的,来源写「上游透传」,含义以对方文档为准,不要替对方编场景。
6. **两个固定惯例**(沿用上一版手册的形态,不要自创):
- 「日志专用码」不单独占一行,在备注里说明「仅落日志,不返回前端」。
- 备注里的模块名和枚举名直接取自代码,不要自己起名。
**大模型的判定写进 `overrides.yaml`,不要改脚本,也不要手改 `codes.yaml`。** 每条都要带 `evidence`(文件:行):
| 位置 | 用途 |
| --- | --- |
| `codes.<码值>` | 码级判定,覆盖自动抽取的结论 |
| `by_module.<模块>\|<码值>` | 复用码:同一码值在不同模块的不同含义 |
| `default_notes[]` | 一批码来源相同且都不返回前端时,按组给统一备注 |
| `open_questions[]` | 需要在交付文档里提醒人工确认的疑点 |
规则性的整理(丢弃 `ignored_codes`、动态码归并、复用码分行、日志码并入)由脚本完成,不要手工做:
```bash
python3 scripts/curate_codes.py --config sources.yaml --overrides overrides.yaml --work .work
```
脚本会打印「仍未确定含义的码」,这些码已自动进待确认;确认后请补进 `overrides.yaml` 的 `codes`,不要留在自动兜底里。
### 第 3 步:渲染 + 校验
```bash
python3 scripts/render_doc.py --config sources.yaml --codes .work/codes.yaml --work .work
```
脚本会校验:码值真的存在、扫到的码 100% 覆盖、标注为动态/透传的行有原因说明、来源 id 合法。
- **退出码非 0 = 文档没生成**,按 `.work/verify_report.md` 修 `codes.yaml` 后重跑。
- 提醒(warning)要人工过一遍,尤其是"未覆盖码值"。
### 第 4 步:子 agent 独立复核(不许省)
自己写的含义自己验,等于没验。用 `spawn_agent`(`fork_context: false`)起一个**看不到结论**的子 agent,让它独立走一遍证据链。
给它的输入只能是:
- 仓库根目录 `~/work/demo-shop`
- 要复核的**码值清单**,以及每个码所属模块
- 要求的输出格式
**不要**把 `codes.yaml`、`overrides.yaml` 或大模型的结论给它。
复核范围(按性价比排序):
1. **所有标注为动态/透传的行** ------ 必须全查。这些最容易被草率处理。
2. **所有 `by_module` 覆盖过的行** ------ 复用码在这里最容易两边都写错。
3. **其余行抽查不少于 15%**,优先挑:带枚举备注的码、上游透传的码、`open_questions` 里提到的码。
让子 agent 按「码值 → 定义位置 → 抛出点 → 用户可见性」输出它的结论 + 证据文件:行 + 它的不确定项。然后:
- 结论和 `codes.yaml` 不一致 → 自己去核实,**以代码为准**;真错了就改 `overrides.yaml` 重跑第 3 步。
- 子 agent 说"查不到" → 若大模型能给出证据,保留自己的结论并在最终回复里说明分歧;若给不出,降级为「待确认」。
- 子 agent 出错 → 在最终回复里写清它的误判点,不要只报"复核通过"。
### 第 5 步:交付前自检
```bash
python3 scripts/render_doc.py --config sources.yaml --codes .work/codes.yaml --work .work --check-only
```
再逐项确认:
- [ ] `verify_report.md` 里「未覆盖码值」为空,或已在「待确认」中说明
- [ ] 与上一版手册(`错误码手册/` 目录里日期最新的那份)做过 diff,**每一处变化都能解释**(新增码 = 代码新增;消失码 = 代码下线)
- [ ] 复用码清单不为空(只要存在复用,就该有内容;空清单通常意味着没认真查)
- [ ] 动态码与透传码都有来源说明,没有编造的场景
- [ ] 待确认清单不是空的(只要代码里存在歧义,就应该有内容)
## 常见错误
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| 某个模块的码值明显偏少 | 该模块的码定义在另一个包,没配进 `sources.yaml` | 把包加进 `scan_packages` |
| 出现一堆 `E-undefined` | 前端把 i18n key 当成了码值 | 检查 `sources.yaml` 的 `i18n_path`,key 与码值要分开抽 |
| 码值对上了,含义却是错的 | 校验只保证「码存在」,不保证「含义对」 | 第 2 步的证据链复核不能省 |
| 同一个码在两处含义不同,手册只有一行 | 复用码没分行 | 用 `by_module` 显式拆开 |
| 「用户会怎么撞上它」写成技术话术 | 直接抄了日志或异常栈 | 抄了客服也看不懂,要翻译成业务话 |
| 手改了 `.work/codes.yaml`,下次重跑全丢 | `.work/` 是中间产物 | 判定写进 `overrides.yaml`,重跑 `curate_codes.py` 即可复现 |
| `overrides.yaml` 里堆了几十条「查不到」 | 兜底规则没先跑或模块名对不上 | 先看 `curate_codes.py` 打印的未确定清单;能归组的写 `default_notes` |
| 复核说某个码其实是用户可见的 | 第 2 步直接判了「日志专用」 | 三种情况要特别当心:网关统一拦截返回的码、定时任务里的码、被 try/catch 吞掉又重抛的码 |
## 反合理化
| 想法 | 现实 |
| --- | --- |
| "旧手册里是这么写的" | 旧手册正是要修的东西。代码优先。 |
| "这个码看起来就是超时" | "看起来"不是证据。要么给出文件:行,要么进待确认。 |
| "码值对上了,校验也过了" | 校验只证明码存在。含义对不对是另一回事。 |
| "动态码太麻烦,随便写个通用解释" | 客服会照着通用解释回复用户,用户会当场发现不对。 |
| "未覆盖的码不重要" | 它们正是用户在截图里看到、手册里却查不到的那些。 |
| "待确认为空说明我很确定" | 代码里真实存在的歧义不会因为确定而消失。 |
## 资源
- [references/judgment-rules.md](references/judgment-rules.md) --- 判定细则:证据链怎么查、复用码怎么拆、动态码与透传码怎么标
- `sources.yaml` --- 扫描配置(模块、包、忽略码段、i18n 路径)
- `overrides.yaml` --- 人工判定(带 evidence),跨次生成长期有效
- `scripts/extract_codes.py` --- 抽取码值/定义位置/抛出位置,产出草稿
- `scripts/curate_codes.py` --- 草稿 + overrides → `codes.yaml`(丢弃/归并/分行/并入)
- `scripts/render_doc.py` --- 校验 + 渲染交付文档
## 迭代记录
| 日期 | 信号来源 | 修改点 | 验证方式 |
| --- | --- | --- | --- |
| 2026-10-08 | 无技能基线(2 个独立 agent):同一个码在两处给出不同含义;旧手册残留已下线的码;产出写到 /tmp、无日期无固定结构 | 建立 6 条铁律;抽取脚本落地码值/抛出点/i18n 解析;渲染脚本固化产出路径与章节 | 抽取 412 个码值、三跳证据齐全、校验 0 错误 0 提醒;与旧手册逐条比对后仅剩 9 个源码里已搜不到的码,已进「待确认」 |
| 2026-10-08 | 复盘首轮:判定逻辑写在一次性的 `.work/curate.py` 里,`.work/` 被 gitignore → 下次执行会退化成"重新判断一遍" | 判定沉淀为 `scripts/curate_codes.py`(规则)+ `overrides.yaml`(带 evidence 的判定);新增第 4 步「子 agent 独立复核」 | 全流程重跑,校验 0 错误 0 提醒;子 agent 复核结果见交付说明 |
| 2026-10-09 | 第 4 步子 agent 复核(2 个独立 agent,共查 63+51 条):查出 4 个码被误判为「日志专用」、2 个复用码被合成一行、1 处动态码的取值清单漏了分支 | 修正 `overrides.yaml`;`curate_codes.py` 改为「按模块覆盖先于归并生效」,避免复用码被并成一行 | 复核意见逐条回代码验证;修正后待确认 11→8,校验仍 0 错误 0 提醒 |
看完这份原文,应该能感觉到一件事:它写得非常"啰嗦"------铁律、常见错误、反合理化,这三节加起来占了一半篇幅。
这不是作者话多。看完第 8 节就知道,这三节才是这份 skill 里最值钱的部分。
4. 为什么长这样:两层结构,两个不同的读者
一份 SKILL.md 分两层,而且是给两个不同的消费者看的------这是整个设计里最关键的一点:
| 层 | 内容 | 格式要求 | 谁读 |
|---|---|---|---|
| frontmatter | 只有 name + description |
强制,YAML,多一个字段都可能算违规 | 客户端(机器),靠它建目录、做注入 |
| 正文 | 随便写:散文、表格、清单都行 | 完全没有格式要求 | 大模型(自然语言) |
对照上面那份原文:
- 开头那三行
---之间就是 frontmatter。它只干两件事:告诉机器「我叫error-code-handbook」,以及「什么时候该用我」。 #往下全部是正文。里面的「铁律」「工作流」「常见错误」,机器一个都不认识,它们是写给大模型看的。
为什么要这么分?因为两种东西的性质完全不同:
- 「这本手册叫什么、什么时候该用它」------必须机器能判定,所以强格式;
- 「这个活具体怎么干」------各种 skill 差异极大,只有自然语言能表达,所以不设格式。
一句话:索引层给机器,内容层给模型。
顺便说一句 description 该怎么写。原文里那一条长这样:
当需要生成或刷新《错误码手册》......时使用;也适用于回答"这个错误码是什么意思""用户在什么情况下会撞上它""同一个码是不是被复用在不同模块"这类问题
前半句是触发条件 ,后半句是用户可能说出的原话 。这么写的目的是:当用户用自然语言问「这个 E-1007 是啥意思」时,大模型能把它和这本手册对上号。
description 只写"什么时候用",绝不写"怎么干"。 原因见第 6 节。
5. 大模型是怎么「知道有这本手册」的?
每次会话开始,客户端会扫描 skills 目录,把所有 skill 的 frontmatter 拼进大模型的上下文------只有 name 和 description 两行,不是全文。
塞全文太贵了:一本手册正文几千字,几十个 skill 就是几十万 token 的常量开销。所以客户端只给大模型一份「货架清单」:
货架上有什么(常驻在大模型上下文里):
error-code-handbook ------ 生成/刷新错误码手册,或问某个码是什么意思时用
writing-skills ------ 创建或修改 skill 本身时用
systematic-debugging ------ 排查 bug 时用
......(本机挂着二十来个)
所以大模型开局就知道有这么一本手册、以及什么时候该翻它,但它不知道里面写了什么。
这一点很重要,因为它解释了后面的所有失败模式:大模型可能「没想起来」这本手册,也可能「想起来了但懒得上手翻」。
6. 怎么被触发?两条完全不同的路
#mermaid-svg-5M4TAkpUPJYMJccX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5M4TAkpUPJYMJccX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5M4TAkpUPJYMJccX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5M4TAkpUPJYMJccX .error-icon{fill:#552222;}#mermaid-svg-5M4TAkpUPJYMJccX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5M4TAkpUPJYMJccX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5M4TAkpUPJYMJccX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5M4TAkpUPJYMJccX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5M4TAkpUPJYMJccX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5M4TAkpUPJYMJccX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5M4TAkpUPJYMJccX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5M4TAkpUPJYMJccX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5M4TAkpUPJYMJccX .marker.cross{stroke:#333333;}#mermaid-svg-5M4TAkpUPJYMJccX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5M4TAkpUPJYMJccX p{margin:0;}#mermaid-svg-5M4TAkpUPJYMJccX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5M4TAkpUPJYMJccX .cluster-label text{fill:#333;}#mermaid-svg-5M4TAkpUPJYMJccX .cluster-label span{color:#333;}#mermaid-svg-5M4TAkpUPJYMJccX .cluster-label span p{background-color:transparent;}#mermaid-svg-5M4TAkpUPJYMJccX .label text,#mermaid-svg-5M4TAkpUPJYMJccX span{fill:#333;color:#333;}#mermaid-svg-5M4TAkpUPJYMJccX .node rect,#mermaid-svg-5M4TAkpUPJYMJccX .node circle,#mermaid-svg-5M4TAkpUPJYMJccX .node ellipse,#mermaid-svg-5M4TAkpUPJYMJccX .node polygon,#mermaid-svg-5M4TAkpUPJYMJccX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5M4TAkpUPJYMJccX .rough-node .label text,#mermaid-svg-5M4TAkpUPJYMJccX .node .label text,#mermaid-svg-5M4TAkpUPJYMJccX .image-shape .label,#mermaid-svg-5M4TAkpUPJYMJccX .icon-shape .label{text-anchor:middle;}#mermaid-svg-5M4TAkpUPJYMJccX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5M4TAkpUPJYMJccX .rough-node .label,#mermaid-svg-5M4TAkpUPJYMJccX .node .label,#mermaid-svg-5M4TAkpUPJYMJccX .image-shape .label,#mermaid-svg-5M4TAkpUPJYMJccX .icon-shape .label{text-align:center;}#mermaid-svg-5M4TAkpUPJYMJccX .node.clickable{cursor:pointer;}#mermaid-svg-5M4TAkpUPJYMJccX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5M4TAkpUPJYMJccX .arrowheadPath{fill:#333333;}#mermaid-svg-5M4TAkpUPJYMJccX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5M4TAkpUPJYMJccX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5M4TAkpUPJYMJccX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5M4TAkpUPJYMJccX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5M4TAkpUPJYMJccX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5M4TAkpUPJYMJccX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5M4TAkpUPJYMJccX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5M4TAkpUPJYMJccX .cluster text{fill:#333;}#mermaid-svg-5M4TAkpUPJYMJccX .cluster span{color:#333;}#mermaid-svg-5M4TAkpUPJYMJccX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5M4TAkpUPJYMJccX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5M4TAkpUPJYMJccX rect.text{fill:none;stroke-width:0;}#mermaid-svg-5M4TAkpUPJYMJccX .icon-shape,#mermaid-svg-5M4TAkpUPJYMJccX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5M4TAkpUPJYMJccX .icon-shape p,#mermaid-svg-5M4TAkpUPJYMJccX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5M4TAkpUPJYMJccX .icon-shape .label rect,#mermaid-svg-5M4TAkpUPJYMJccX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5M4TAkpUPJYMJccX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5M4TAkpUPJYMJccX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5M4TAkpUPJYMJccX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 你说了一句话
走哪条路?
路线一:你显式点名技能
路线二:你用自然语言描述需求
客户端做字符串解析
把 SKILL.md 全文直接塞进你这条消息
大模型手里已经有全文
连要不要用都不用判断
大模型拿你的话
跟货架上的 description 做语义比对
命中:大模型自己发起工具调用
去读 SKILL.md 全文
没命中:当普通对话回答
手册继续躺着
按手册执行流程
路线一:你显式点名
你打 $error-code-handbook,或者用 @ 引用那份技能文件。
这时候是客户端干的 :它做字符串解析,直接把 SKILL.md 全文塞进你那条消息里。
证据很硬:这条消息到达大模型手里的时候,里面已经带着技能名、路径和整篇正文了------大模型一个字都没读过文件,它自己就在上下文里了。
走这条路,大模型完全不参与「要不要用」的判断。所以「技能没生效」这种事不会发生在你身上。
路线二:你用自然语言
你说「帮我重新生成一版错误码手册」,或者「这个 E-1007 是什么意思」。
这时候是大模型干的 :货架上只有两行 description,得靠它做语义比对,判断「这句话是不是在说这本手册」;命中之后,它自己发起工具调用 把正文读进来,才能看到「第 3 步要跑 render_doc.py」这种细节。
这条路会出三种错,而且不止一种:
| 失败模式 | 长什么样 |
|---|---|
| 漏触发 | description 写太抽象(比如就写「处理错误码」),大模型根本想不到 |
| 误触发 | description 写太宽,不相干的活也往里套 |
| 触发了但不读正文 | description 里把工作流总结了一遍,大模型照着摘要就干了,跳过正文 |
第三条最阴。就拿这份手册举例:如果 description 写成「先跑抽取脚本,再补含义,再渲染校验,最后让子 agent 复核」,那么大模型很可能直接照着这句话干活,跳过第 4 步的独立复核------因为那句话听起来已经是一份完整的流程了。
这也是规范里明令禁止在 description 里写工作流摘要的原因:description 是相亲简介,不是简历。
所以如果你希望某个技能被用上,最省事的办法就是直接点名,走那条不依赖大模型语义判断的路。
7. 触发之后,到底发生了什么?
实际流程是这样:
大模型 Agent 客户端 你 大模型 Agent 客户端 你 #mermaid-svg-lCuAsF8olNmzCOWU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lCuAsF8olNmzCOWU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lCuAsF8olNmzCOWU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lCuAsF8olNmzCOWU .error-icon{fill:#552222;}#mermaid-svg-lCuAsF8olNmzCOWU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lCuAsF8olNmzCOWU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lCuAsF8olNmzCOWU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lCuAsF8olNmzCOWU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lCuAsF8olNmzCOWU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lCuAsF8olNmzCOWU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lCuAsF8olNmzCOWU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lCuAsF8olNmzCOWU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lCuAsF8olNmzCOWU .marker.cross{stroke:#333333;}#mermaid-svg-lCuAsF8olNmzCOWU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lCuAsF8olNmzCOWU p{margin:0;}#mermaid-svg-lCuAsF8olNmzCOWU .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lCuAsF8olNmzCOWU text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-lCuAsF8olNmzCOWU .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-lCuAsF8olNmzCOWU .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-lCuAsF8olNmzCOWU .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-lCuAsF8olNmzCOWU .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-lCuAsF8olNmzCOWU #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-lCuAsF8olNmzCOWU .sequenceNumber{fill:white;}#mermaid-svg-lCuAsF8olNmzCOWU #sequencenumber{fill:#333;}#mermaid-svg-lCuAsF8olNmzCOWU #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-lCuAsF8olNmzCOWU .messageText{fill:#333;stroke:none;}#mermaid-svg-lCuAsF8olNmzCOWU .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lCuAsF8olNmzCOWU .labelText,#mermaid-svg-lCuAsF8olNmzCOWU .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-lCuAsF8olNmzCOWU .loopText,#mermaid-svg-lCuAsF8olNmzCOWU .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-lCuAsF8olNmzCOWU .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-lCuAsF8olNmzCOWU .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-lCuAsF8olNmzCOWU .noteText,#mermaid-svg-lCuAsF8olNmzCOWU .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-lCuAsF8olNmzCOWU .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lCuAsF8olNmzCOWU .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lCuAsF8olNmzCOWU .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lCuAsF8olNmzCOWU .actorPopupMenu{position:absolute;}#mermaid-svg-lCuAsF8olNmzCOWU .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-lCuAsF8olNmzCOWU .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lCuAsF8olNmzCOWU .actor-man circle,#mermaid-svg-lCuAsF8olNmzCOWU line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-lCuAsF8olNmzCOWU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 帮我重新生成一版错误码手册发送系统提示、技能清单、工具清单、全部历史,以及你这句话请求读取 SKILL.md真的去读文件把文件内容作为工具结果追加进上下文,整个重发一遍请求运行抽取脚本真的去跑脚本,拿到 JSON工具结果追加,再重发输出结论 / 最终回答
注意这里有三件事:
- 「传参」不是大模型构造的。 是客户端以「整个请求」为单位构造的:系统提示、技能清单、工具清单、全部对话历史、全部工具往返结果,一次全发。
- 每一轮都要重发全部历史。 这是理解「为什么 skill 要死磕 token 效率」的钥匙------也是为什么原文让脚本直接吐 JSON,而不是让大模型去读几千行源码。
- 上下文是公共资源、有上限、超了会被压缩。 所以别指望大模型一直记得会话早期读过的代码------文件才是外部记忆,上下文不是。
真实的五步流水线
这份 skill 的设计哲学一句话概括:不需要判断的事全赶进脚本,需要判断的事才留给大模型。
| 步骤 | 谁干 | 干什么 | 耗时量级 |
|---|---|---|---|
| 1. 抽取 | 脚本 | 扫枚举/常量/字面量拿码值、定位定义与抛出点、解析 i18n 拿前端文案,输出草稿 | ~1 秒 |
| 2. 填含义 | 大模型 | 脚本只会抽码值,含义得读懂代码;这是本技能的主要工作量 | 几十次读文件 / 搜代码 |
| 3. 渲染 + 校验 | 脚本 | 校验四件事,任一不过就退出码非 0、不生成文档 | ~1 秒 |
| 4. 独立复核 | 另一个 agent | 起一个看不到结论的子 agent,让它自己走一遍证据链 | 几十次读文件 |
| 5. 交付自检 | 大模型 + 脚本 | 跟上一版手册做 diff,确认每处变化都能解释 | 分钟级 |
第 2 步为什么不能交给脚本?举三个例子(都是这份虚构手册里真会遇到的类型):
- 复用码 :
E-1007在支付模块表示「余额不足」,在库存模块表示「库存不足」。脚本只能抽到"这个码出现了两次";到底是不是同一件事,得读两处抛出点的上下文才能确定。原文里的by_module和「铁律第 4 条」就是为它准备的。 - 动态拼接码 :代码里写成
"E-" + module + "_" + code。脚本能扫到模板,但扫不出可能取值------得顺着变量的赋值链去找枚举定义,把取值列全。列不全就不能编,必须标成动态码并写清依据。 - 透传码 :第三方网关失败时,代码把对方返回的码原样抛出来。这时候含义不在本仓库里,只能标「上游透传,以对方文档为准」。原文「铁律第 3 条」堵的就是这里:不许替对方编一个看起来合理的场景------因为客服会照着它回用户。
第 3 步的校验也不是走形式,它拦的是四类硬错误:
- 手册里写的每个码值,在代码里必须真的存在(写错码值直接拦下);
- 脚本扫到的每个码,必须 100% 有交代(防漏码);
- 标「动态」「透传」「不返回前端」的行必须写明原因(防偷懒);
- 引用的来源 id 必须合法。
第 4 步是最值得抄的一个设计:自己写的含义自己验,等于没验。
所以原文强制另起一个 agent,不给它结论 (fork_context: false,不继承上下文),只给它「码值清单 + 所属模块」,让它独立走一遍证据链。
上面那份迭代记录里写着:这一步抓出了 4 个被误判为「日志专用」的码、2 个被合成一行的复用码、1 处动态码漏了分支。
8. 所以「流程规范化」到底是怎么实现的?
到这里就能回答那个最核心的问题了。先给一个可能有点反直觉的答案:
客户端根本不解析
SKILL.md的正文。
对 Codex(以及同类客户端)来说,那篇文档就是一坨不透明字符串 。它不知道「第 3 步」是什么意思,也不会检查大模型有没有先跑抽取脚本、后跑渲染脚本。如果大模型当时跳过抽取、直接手写手册,客户端不会拦它。
那流程到底是谁在管?是大模型这一侧在管。
| 客户端(运行时) | 大模型 | |
|---|---|---|
| 技能 | 扫目录、解析 frontmatter、把清单注入上下文 | 拿用户的话和 description 做语义比对,决定要不要用 |
| 工具 | 维护工具清单、执行工具调用、把结果回灌 | 决定什么时候调哪个工具、参数是什么 |
| 边界 | 硬约束:危险的删除命令会被直接拒绝 | 只能换个办法绕开 |
| 流程 | 完全不管 | 所有「第几步」「先跑什么后跑什么」,都是大模型读完散文之后自己编排的 |
看最后一行------这就是答案。客户端那层只是一个写死的循环:
把上下文发给模型 → 模型要么说话、要么发工具调用 → 执行工具、把结果追加 → 再发一次。
它没有任何关于业务流程的分支。所有的判断、顺序、纪律,全部来自大模型这一侧。
而大模型这一侧,靠的是「指令遵从」------不是程序约束,是自然语言指令的遵从。这带来三个必须接受的后果:
第一,它不保证。 同一份 skill,换个模型、或者上下文压力大的时候,执行结果会不一样。遵从是概率,不是契约。
第二,所以 skill 的写法才那么「啰嗦」。 回到原文第 3 节,那些「铁律」「不许静默跳过」「反合理化表」不是装饰,是在跟大模型的偷懒倾向对抗:
| 大模型脑子里的想法 | 手册里的现实 |
|---|---|
| 「旧手册里是这么写的」 | 旧手册正是要修的东西。代码优先。 |
| 「这个码看起来就是超时」 | 「看起来」不是证据。要么给出文件:行,要么进待确认。 |
| 「码值对上了,校验也过了」 | 校验只证明码存在。含义对不对是另一回事。 |
| 「动态码太麻烦,随便写个通用解释」 | 客服会照着通用解释回复用户,用户会当场发现不对。 |
| 「待确认为空说明我很确定」 | 代码里真实存在的歧义不会因为确定而消失。 |
这就是为什么原文里最长的三节是「铁律 / 常见错误 / 反合理化」------它们记录的全是踩过的坑。写 skill 有一半功夫,花在堵漏洞上。
第三,能机器化的部分,一律下沉成脚本。 这就是「自由度分级」:
| 活的类型 | 交给谁 | 为什么 |
|---|---|---|
| 确定性计算(扫码、定位、校验、渲染) | 脚本 | 快、准、可重复,出错会报错 |
| 语义判断(这个码什么含义、用户怎么撞上) | 大模型 | 脚本干不了 |
| 流程编排(第几步、什么时候停) | 大模型的指令遵从 | 只能这样 |
| 硬边界(不许删库、不许越权) | 客户端 | 这是写死的代码 |
9. 绕不开的话题:上下文被压缩了,手册会丢吗?
会。而且丢的正是手册正文------但「跑偏」的真实原因,通常不是你想的那个。
先把「什么在哪儿」分清楚,才知道谁会丢:
| 内容 | 存放位置 | 会被压缩吗 |
|---|---|---|
| 技能清单(name + description) | 系统提示,每轮重新注入 | 不会。这是「货架」,始终在 |
技能正文(SKILL.md 全文) |
对话历史里的一条消息 / 工具结果 | 会。它没有任何特权 |
| 读过的代码、跑过的命令返回 | 对话历史 | 会,而且这是损失最大的部分 |
| 磁盘上的文件 | 文件系统 | 完全不受影响 |
关键在第二行:技能正文一旦被读进来,它就只是"历史里的一段文字",没有 VIP 通道。真要腾空间,它和三个月前的一句闲聊待遇一样。
压缩到底做了什么
以 Codex 为例(其它客户端机制类似,细节可能有别),它的运行时里有一段专门写给大模型的压缩说明,原话大意是:
上下文耗尽时,对话会被自动摘要;但用户说过的每一句话你都能看到原文 。
也就是说时间不会用完,只是有时你看到的是摘要而不是完整历史。
遇到这种情况,假设压缩发生在你工作过程中,不要从头再来 ,对摘要里缺失的东西做合理假设。
翻译成机制:
- 用户说过的每句话:保留原文;
- 大模型的回答、探索过程、工具调用与返回:被摘要替代;
- 磁盘上的文件:无关,不受影响。
极端情况下还有一段兜底提示词,大意是:
当前窗口已耗尽。不要在这个窗口里继续任务或给最终答案。
立刻写一份 checkpoint,把目标、决策、进度、学到的、下一步,以及每个还没解决的用户请求的 id 记下来。
写完之后,开启新窗口。
也就是说,极端情况下上下文能不能续上,取决于那份 checkpoint 写得全不全。
还有一句更值得警惕的话
上面那段提示词里有一句:「对摘要里缺失的东西做合理假设」。
对写代码来说这没问题。但对「错误码手册」这种要求逐条精确 的活,合理假设就是造错的源头 ------大模型可能「合理地」记得某个码是「余额不足」,然后交出一份自相矛盾、而且没人能看出问题的手册。而这份手册是给客服用的,客服照着它回用户,用户当场就发现不对。
这也正好解释了原文里那些看起来"过度设计"的规矩------它们不是讲究,是在对冲这条机制:
| 原文里的设计 | 对冲的是什么 |
|---|---|
判定结果外置成 overrides.yaml,每条带 evidence(文件:行) |
不依赖大模型「记得自己判断过什么」 |
| 中间产物全部落盘(草稿 → 整理后 → 最终 md,每步是下一步的输入) | 天然 checkpoint,忘了也能从磁盘捡回来 |
| 校验脚本从文件重新算一遍码值是否存在、是否全覆盖 | 不依赖大模型的记忆,错了就退出码非 0 |
| 子 agent 独立复核,且不给它结论 | 对冲「大模型自己不知道自己在脑补」 |
| 命令写成完整的一整行 bash | 压缩后只要还能认出那行命令,就能重新捡起来继续 |
但诚实说,还有一个洞
「现在跑到第几步了」这件事没有落盘。
如果压缩发生在「第 3 步渲染完成」和「第 4 步独立复核」之间,大模型可能会忘记"还没做复核",直接交付 ------而这时候所有文件都齐了、校验也过了,看不出任何异常。
堵法也很简单:让 skill 每完成一步就往 .work/progress.md 追加一行,并且规定「开工第一条命令是先读它」。这样进度也变成磁盘状态------压缩只能让大模型失忆,不能让它丢失进度。
(顺便说,这个改进同样适用于开头那份原文。好东西不是一次写成的,都是被坑出来的。)
10. 给想写 skill 的人:6 条从坑里长出来的建议
- description 只写「什么时候用」,不要写「怎么干」。 写了工作流摘要,大模型可能照着摘要干、跳过正文------这是最隐蔽的失效方式。
- 把不需要判断的事全部下沉成脚本。 token 预算和大模型的注意力,都应该花在真正需要判断的地方。
- 重要的结论要落盘,别指望大模型的记忆。 带证据的判定写进配置文件,中间产物留在工作目录,每一步都能重跑。
- 校验要能「从文件重新算一遍」。 让校验脚本独立于大模型的记忆,错了就非 0 退出;不接受一句「检查过了」就当过关。
- 给自己配一个独立的复核者。 换个上下文、不给它结论,让它自己走一遍证据链。这一步抓出来的错,往往是你最自信的那些。
- 留一节「反合理化」和「常见错误」。 踩过的坑写下来就是下一次的护栏。那份虚构手册的迭代记录里写着:首轮因为没写清楚,同一个码在两处给出了两种不同含义------所以「铁律」才会越写越长。
11. 总结
你负责说一句话;
skill 负责把动作固定成流程;
脚本 负责不能出错的部分;
大模型 负责需要判断的部分;
agent 客户端 负责发请求、执行工具、以及拦住危险操作。
四者各管一段。缺了任何一环,这份手册要么做不出来,要么做出来不敢给客服看。
最后回到开头那条前提:文中所有机制都用 Codex 举例,但换成别的 agent 客户端,主线是一样的。 变的只是「货架目录叫什么名字」「点名技能用什么语法」「工具怎么配」这些细节;不变的是------说明书要靠大模型想起来并读懂,流程的纪律靠大模型的指令遵从,而可靠性只能靠脚本、校验和独立复核兜住。
附:本文里那些"机制细节"是怎么来的
为了避免"看起来像编的",交代一下出处:文中关于机制描述的部分(技能清单如何注入、触发路径有哪些、压缩时保留什么丢失什么、客户端不解析 skill 正文、预采样压缩的触发条件等)来自 Codex 运行时里可直接观察到的事实,以及作者本机日志、配置与二进制字符串的实测结果(Codex CLI 0.153.4,2026-09)。
这些是具体实现的行为,不是官方文档原文------写这篇文章时,官方文档域名返回了 403,没能引到页面。不同客户端、不同版本的行为细节可能有差异,如果官方文档有更明确的说明,以官方为准。
至于文中那份 error-code-handbook:它是为了讲清机制而虚构的,请当作一个"长得很像真的"的例子来看,别去找它的仓库。