14. Skill 体系#
一句话结论:skill 是放在 system prompt 外的按需知识块——目录常驻(name + description),正文由模型自己判断要不要调
Skill(skill=…)读进来;代价是漏触发看不见,所以用真跑模型的触发率验收兜住(13/13 正例、0/5 误触发)。
速背卡#
| # | hint(≤15 字) | 展开一句 |
|---|---|---|
| 1 | 目录常驻、正文按需 | 每轮只付 7 行 description,正文只在触发那轮进上下文 |
| 2 | 7 份内置 skill | search-discovery / purchase-research / bundle-planning / order-care / memory-personalization / image-shopping / cross-border-duty |
| 3 | 预注入 → 模型自觉 | D1 按 planner 预注入,S2 整条删掉,改模型读 description 自己判 |
| 4 | 同轮发出,不等往返 | Skill(...) 和 item_search(...) 同一次回复里一起发 |
| 5 | 13/13 触发率 | S4 首轮 11/13,收紧「单品直搜」边界后 13/13,负例误触发 0/5 |
| 6 | description 是唯一触发面 | 正文写得再好,description 不准就永远读不到 |
| 7 | present_guide 是终结工具 | 「怎么挑」类答案从 chat_fallback 拆出来,分节 + 来源,调完即收尾 |
30 秒版#
skill 就是把「某一类活的完整打法」从 system prompt 里搬出来,写成 skills/<name>/SKILL.md。框架只把每份的 name + description 拼成 <agent-skills> 常驻在 system prompt 后面,正文由模型自己调内置 Skill 工具按需读。好处是每轮固定成本只有 7 行描述,坏处是可能漏读——所以我做了一套真跑模型的触发验收集,18 条 query 看模型第一批工具调用里有没有那份 skill,改了一句分流表措辞就从 11/13 到 13/13。
3 分钟版#
- 问题:system prompt 一路涨到 144 行,里面大半规则(套装怎么拆、订单怎么答、关税口径)一轮只有一类用得上,却每轮都付 token、每轮都要模型读一遍。
- 做法:按意图切成 7 份 SKILL.md,框架
LocalSkillLoader扫skills/,只把 name + description + dir 进 system prompt(app/agent/skills.py)。 - 加载:模型看 description 判断,调
Skill(skill="search-discovery")拿正文。prompt 里一张 7 行意图分流表告诉它「这轮像哪行读哪份」。 - 中间走过弯路:D1(42311aa)按 planner 的
bundle_slots确定性预注入套装 skill 省一次往返;S2(8b851f4)把这条整条删了,理由见下节。 - 同轮发出:prompt 明写「
Skill(…)和本轮第一个检索/读取工具同一次回复里一起发」,不等 skill 回来再搜。 - 验收:
scripts/eval/run_skill_trigger.py在post_reflect挂探针,拿到第一批 tool_call 就抛BaseException掐断整轮,只判「读没读对那份」,一轮实付约 $0.007。 - 用户侧:用户可以写自己的 skill(
user_skills表,my/前缀),走同一条框架通路;也可在输入框/显式选一份,正文以<selected-skill authority="reference_only">拼进本轮用户消息。
它解决什么问题#
① 每轮都为用不上的规则付钱
- 坏法:所有打法写进 system prompt。144 行里套装 17 行、订单细则、research 口径每轮都在,而一轮只用得上一类。坏了看不见——只表现为输入 token 偏高。
- 修法:S1(53a48ea)切出 3 份新 skill,system prompt 144 → 126 行,只留每轮都成立的东西(自动 price_compare + item_picker、ask_user 两种形态、
<termination>/<constraints>/<security_boundary>、两条红线)。 - 代价:多了一次模型判断,判错就等于那份打法没生效。
② 预注入省往返 vs 前缀稳定
- 坏法(D1,42311aa):planner 拆出
len(bundle_slots) >= 2就由控制面预取 bundle-planning 正文注入,省掉模型「读 description → 调 Skill」那次往返。 - 修法(S2,8b851f4):整条删掉(
_prefetch_skill/_skill_prefetch_due/BUNDLE_SKILL_NAME/SKILL_PREFETCH全删),改模型自觉。三条理由:往返本来就能靠同轮发出省掉;判据不硬(除bundle_slots外只能退回关键词正则,而bundle_slots本身也是 LLM 输出);HarnessSession每轮新建 +state.context跨轮累积,预注入会让同一份正文在多轮会话里躺 N 份(实测prefill_planner在 3 轮会话里出现 6 次)。 - 代价:漏触发从「机制保证不会发生」变成「靠验收发现」。所以才有 S4 那套触发率脚本。
- 分界线(写进
app/harness/prefill.py模块 docstring 防回潮):机制只管事实接地(KB 预取、图理解),模型自觉管打法加载。
③ 「怎么挑」类答案没有合适出口
- 坏法:选购标准答案和「你好」「谢谢」共用
chat_fallback,整段塞进message,来源混在正文末尾、分节靠模型自己打标题。 - 修法:S3(3943ecf)加
present_guide终结工具,把结构变成字段(GuideSection标题 + 要点、明说的假设、来源表),前端出指南卡(frontend/src/components/GuideCard.tsx)。 - 代价:多一个终结出口,模型要多判一次走哪个;靠
<termination>一句话分(讲标准走present_guide,其余走chat_fallback)。
④ 文案被工具内部 LLM 换掉
- 坏法:终结工具在内部再调一次 fast 模型「归纳一下」。2026-09-16 的教训:1500+ 字符的电动牙刷选购指南被归纳成一句客套话。
- 修法:
present_guide与shopping_summary同口径——文案由主模型在入参里给,工具只排版(app/tools/present_guide.py模块 docstring)。 - 代价:入参变长,主模型要一次把结构写对。
⑤ 用户想让 Agent 按自己的套路选购
- 坏法:给用户开「自定义 prompt」,等于让用户改系统指令。
- 修法:用户个人 skill(
app/db/user_skills.py)走同一条框架通路——name 加my/前缀进<agent-skills>目录、正文由内置Skill工具按需读,不加新工具、不改 harness。显式选用那条路明标authority="reference_only":不能新增工具、不能扩权、不能代替下单确认、不能改本轮硬约束。 - 代价:多一次 SELECT(每次模型调用都会
list_skills),单机 SQLite 几百微秒。
7 份内置 skill 各管什么#
| skill | 管什么 | 分流表上的触发行 |
|---|---|---|
search-discovery | 描述性需求 → 一份清单:哪些约束进 filter、哪些进 query、怎么核「都在预算内」 | 多约束叠着 / 送礼 / intent_grounding=web / 召回空 |
purchase-research | 先讲判断依据再对标准;澄清最多一轮 | 问「这品类怎么挑」/ 评价某款值不值(evaluate) |
bundle-planning | 槽位规划:bundle(一套齐,MCKP)vs parallel(多类并列)两种形态 | plan 的 bundle_slots ≥ 2 |
order-care | 下单 / 查单 / 取消 / 售后:确认卡机制、取消的强制顺序、办不了的怎么说 | 这轮沾到订单 |
memory-personalization | 长期事实在哪、怎么写、被问起时怎么答 | 记忆本身成为话题 |
image-shopping | 图搜措辞与边界:找到的是同品类相似款、不是同款 | 用户传了参考图 |
cross-border-duty | 到手价口径:关税、de minimis、运费分档、收货国四层解析 | 关税 / 运费 / 到手价 |
前 3 份与 order-care 出自 S1/S3(对照 ../commerce-agents/shopping-agent/skills/ 同名 skill,本仓数据支撑不了的规则如缺货替代、退货窗口、物流事件按计划删掉);memory-personalization 出自 M4;image-shopping、cross-border-duty、bundle-planning 是批 4-3(e11e9f1,2026-09-07)就有的三份。
内置 skill vs 用户个人 skill#
| 内置 | 个人 | |
|---|---|---|
| 存哪 | skills/<name>/SKILL.md,随仓库走 | user_skills 表,按 user_id 归属 |
| 谁能改 | 只有我(前端只读,可「复制为我的」当初稿) | 用户自己,CRUD 在 app/api/skills.py |
| 名字空间 | 目录名 | my/ 前缀(USER_SKILL_PREFIX),撞不上内置 |
| 怎么进上下文 | LocalSkillLoader(scan_subdir=True) → <agent-skills> | UserSkillLoader.list_skills() → 同一个 <agent-skills> |
| 上限 | 无(但每份多一行常驻 description) | 每人 20 份 / description ≤400 字 / 正文 ≤8000 字 |
| 额外入口 | — | 输入框 / 显式选中 → 正文拼进本轮用户消息 |
机制怎么跑#
skill_loaders()(app/agent/skills.py)返回两个 loader:LocalSkillLoader(SKILLS_DIR, scan_subdir=True)+UserSkillLoader()。SKILLS_ENABLED=0时返回空表。scan_subdir=True是必须的:默认只在给定目录自身找SKILL.md,本仓布局是skills/<name>/SKILL.md,不开就静默加载 0 个(框架只打一行 info)。- 框架在
_get_system_prompt里现算<agent-skills>块,拼在本仓定稿 prompt 之后:[基线正文 + on_system_prompt 钩子策略块] + [<agent-skills> 目录块]。顺序是框架定的,不去抢。 - 框架自动挂内置只读工具
Skill(SKILL_VIEWER_TOOL_NAME = "Skill"),白名单app/security/tool_whitelist.py要认得它。 - 模型读 prompt 里的 7 行意图分流表(
prompt/prompts.yml,<workflow>段)判这轮像哪行,把Skill(skill="…")和本轮第一个检索工具同一次回复发出去。 - 正文回来后模型照着那份打法走剩下的轮次;
Skill是只读工具,权限永远 ALLOW。 - 用户显式选用时走另一条路:
orchestrator.py:401resolve_selected_skill(skill)按目录名找正文,orchestrator.py:446把render_selected_skill的<selected-skill>块拼到本轮turn_query末尾(不是 system prompt),模型不必再调Skill。 - 讲标准类答案收尾:模型调
present_guide(在TERMINAL_TOOLS里,app/agent/constants.py:34),入参就是最终答案,工具只做形状校验(≤6 节 / 每节 ≤5 点 / ≤8 条来源)与排版,前端渲染指南卡。
触发率怎么验收#
为什么必须真跑模型:S2 之后 skill 加载是模型的一次自由判断,没有任何确定性判据可断言,单测测不出来。
- 标注集内联在脚本里(
scripts/eval/build_skill_trigger.py的CASES)——data/*被 gitignore,人工策划的 query 没法复现,所以数据跟代码走,main()生成data/eval/skill_trigger.jsonl。 - 18 条:6 份 skill 各出正例共 13 条 + 5 条负例(不该读 skill 的单品直搜等)。
image-shopping登记在UNCOVERED(判据是image_paths,uploaded/被 gitignore,无可复现图源),理由原样写进产物头部,不让「没测」看起来像「测过没事」。 - 覆盖自检:
skills/下每份要么有正例、要么在UNCOVERED里,否则SystemExit。 - 截停点选
post_reflect(app/harness/adapter.py的_run_post_reflect):跑在on_reasoning之后、工具执行之前,拿到第一批 tool_call 就判定。 - 用
BaseException掐断(_StopAfterFirstCall):HarnessMiddleware.run的最后一层是except Exception,普通异常会被吞掉只留一行日志。探针不用模块级装饰器注册——否则「import 一下」就往生产 pipeline 里插了个会掐断别人用例的 hook,装的动作留给main。 - 四种判法分开记:
PASS/MISS(压根没读)/WRONG_SKILL(读错份)/ 负例FALSE_FIRE——前两者要修的东西不一样。 - 结果:首轮 11/13、误触发 0/5(451b6d5)。两条 MISS(多约束、送礼)的第一批都只发了
item_search,而search-discovery的 description 明写了这两种情形——不是描述没覆盖,是「我已经知道该搜什么」被当成了不读的理由。 - 修的是分流表措辞(c922059):收紧「单品直搜」那条例外——只有点名具体东西(型号/书名/明确款式)才算;叠着的约束与送礼哪怕检索词一眼能写出来也要先读,并点明那份 skill 讲的是检索词之外的事。结果 13/13,负例仍 0/5(触发率不是拿多读换来的)。
- 没解决的:同轮发出那条也给了具体形状(同一次回复两条工具调用 + 例子),没起作用——13 条正例里只有 2 条同轮带了业务工具,报告里记为
lone_skill_round(11 条)。留作下一个靶子。
演进时间线#
| 日期 | 提交 | 改了什么 | 为什么 |
|---|---|---|---|
| 2026-09-07 | e11e9f1 | 批 4-3:接框架 LocalSkillLoader,3 份 skill(cross-border-duty / bundle-planning / image-shopping)只发主 Agent | 不自建 loader;目录常驻正文按需 |
| 2026-09-14 | 55f1981 | 个人 Skill:按用户存库 + 进 <agent-skills> + 输入框 / 显式选用 | 用户要自己的选购套路,又不能让他改系统指令 |
| 2026-09-19 | c300282 | M4:新增 memory-personalization,删 skills/memory-forget/ | 记忆改成只经模型上下文生效 |
| 2026-09-19 | 42311aa | D1:套装 skill 按 bundle_slots 预注入 + 新增 memory-forget | 省掉「读 description → 调 Skill」那次往返 |
| 2026-09-19 | 53a48ea | S1:新增 search-discovery / purchase-research / order-care,bundle-planning 并入 planning-goals 四条,system prompt 144 → 126 行 | 对照 Anthropic commerce-agents 的 skill 切法 |
| 2026-09-19 | 8b851f4 | S2:删除全部 skill 预注入(含 D1),改模型自觉 + 同轮发出 | 判据不硬、正文跨轮重复躺 N 份、往返本可同轮省掉 |
| 2026-09-19 | 3943ecf | S3:present_guide 终结工具 + 前端指南卡 | 「怎么挑」的答案形状固定,不该和闲聊共用出口 |
| 2026-09-19 | 451b6d5 | S4:触发验收集(18 条)+ 真跑模型的触发率脚本 | 模型自觉之后漏触发只能靠验收发现 |
| 2026-09-19 | c922059 | S4 靶子一:分流表收紧「单品直搜」边界 | 正例 11/13 → 13/13 |
| 2026-09-22 | 0d50ff4 | Skill 页分「我的 / 系统内置」两组 + 内置只读详情 + GET /api/skills/builtin/{name} | 内置 7 份原先只在输入框菜单里看得到 |
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
| 7 | 内置 skill 份数 | ls skills/(HEAD 634f4ab) | ✓ |
| 144 → 126 行 | S1 后 system prompt 行数 | 53a48ea 提交正文 | ✓(提交正文) |
| 13/13、0/5 | S4 正例触发 / 负例误触发 | data/eval/skill_trigger_report_r2.json 的 summary | ✓ |
| 11/13 | S4 首轮正例触发 | 451b6d5 提交正文 + skill_trigger_report.json | ✓ |
| 约 $0.007 | 跑一轮 18 条的实付 | 451b6d5 提交正文 | 仅口径 |
| 6 次 / 3 轮 | 预注入导致 prefill_planner 在 3 轮会话里重复躺的次数 | 8b851f4 提交正文 | ✓(提交正文,未复跑) |
| 20 / 400 / 8000 | 个人 skill 每人份数、description 字数、正文字数上限 | app/db/user_skills.py:21-23 | ✓ |
| 线上个人 skill 数据量 | 用户在 gcjp 上有没有写过个人 skill | — | 未验证 |
追问 10 题#
- 问:skill 和把知识写进 system prompt 有什么区别? 答:成本模型不同——skill 是目录常驻(每份一行 description)、正文按需;写进 prompt 是每轮都付。证据:
app/agent/skills.py模块 docstring。 - ⚠ 问:skill 会不会破坏前缀缓存? 答:
<agent-skills>块是静态的(只随 SKILL.md 文件变)且排在策略块之后,缓存本来就断在策略块;但跑任务期间改 SKILL.md 会让这一轮后续所有请求的 system prompt 变掉,从头失效(框架每次调用都重扫目录 + getmtime)。证据:同上,推论 2、3。 - ⚠ 问:为什么不按 planner 结果确定性预注入? 答:试过(D1)又删了(S2)。三条:同轮发出就能省掉那次往返;除
bundle_slots外没有硬判据,而它本身也是 LLM 输出;state.context跨轮累积会让正文重复躺 N 份。 - 问:模型漏读 skill 怎么办? 答:目前只能靠触发率验收发现,然后改 description 或分流表措辞——S4 那次改的是 prompt 里「单品直搜」的例外边界,不是 skill 正文。
- 问:触发率脚本为什么要抛
BaseException? 答:HarnessMiddleware.run的最后一层是except Exception,普通异常会被吞掉,只留一行日志,整轮照跑照收费。 - 问:负例是干什么的? 答:防「靠让模型多读换触发率」。13/13 那次负例仍是 0/5,说明提升不是拿误触发换的。
- 问:用户个人 skill 会不会成为提权口子? 答:两层——进目录那条路和内置走同一个只读
Skill工具,不加新工具不改 harness;显式选用那条路明标authority="reference_only",正文里写「不能新增工具、不能扩权、不能代替下单确认、不能改本轮硬约束」。 - ⚠ 问:
present_guide和shopping_summary什么关系? 答:同一条口径(文案由主模型在入参给、工具只排版),但服务两类答案:前者是判断依据、没有商品卡垫着,答案被换掉就什么都不剩;两者都在TERMINAL_TOOLS。 - 问:
Skill工具算读还是写? 答:只读,权限永远 ALLOW,它是框架内置的SkillViewer,名字就叫Skill。 - 问:为什么
EXPECTED_SKILLS是白名单不是自动扫描? 答:每份 SKILL.md 给每轮 system prompt 常驻多一行 description,这笔开销要有人看见——加一份就得改一次测试。证据:42311aa 提交正文、tests/test_skills.py。
坑与易混点#
LocalSkillLoader不开scan_subdir=True会静默加载 0 个 skill(只有一行 info 日志),表现为「写了 skill 但模型从来不知道」。- 跑任务期间改 SKILL.md 会让那一轮的前缀缓存从头失效——改完重开一轮。
app/agent/skills.py的模块 docstring 还写着「三个 skill」「三条推论」,是批 4-3 的原文;现在内置是 7 份,docstring 未同步(不改代码,记在这里)。skills/memory-forget/被 D1 加过又被 M4 删掉——看提交历史容易以为它还在,当前skills/下没有。- 「模型自觉」只保证读到,不保证同轮发出:13 条正例里 11 条是单独一轮只发
Skill(报告字段lone_skill_round),prompt 写了形状也没扳回来。
本章和别章的接口#
- 主循环、
TERMINAL_TOOLS与终结判定在第 2 章。 - 长期记忆的写入与注入(
memory-personalization讲的那套事实)在第 5 章。 - Rubric 评测与其他离线评测脚本在评测那章;本章只讲触发率这一条验收。