第 9 期:skill——按需加载的知识
超窗改期要不要破例、客人发火了该怎么安抚、团体票怎么收集信息——这些场景
比“查订单““查政策“复杂得多,写成一段固定话术塞进系统提示词,会让提示词
越滚越长,而且大多数对话根本用不上。上册练习 16 到 18 解决的是同一个
问题:把这类知识写成磁盘上的文件,模型按需去读,不该读的时候,系统提示词
里只有一句话的目录。这一期照着练习 16-18 的做法,再加上公司项目里两套
真实实现之一(脱敏后)的具体写法:一张 registry 扫出来的清单,一个
load_skill 工具。
敲进去
第 9 期的代码在 code/ep09/。三份 skill 放在 skills/<name>/SKILL.md:
---
name: reschedule-dispute
description: 客人已经超过免费改期窗口,但有特殊情况(住院、证件问题、使领馆通知)要求破例改期时用。不涉及退款,不涉及取消。
allowed-tools: get_order, get_policy
---
# 超窗改例外处理
...
registry.py import 时扫一遍这个目录,把 frontmatter 和正文分开:
def _parse(path: Path) -> dict:
text = path.read_text()
_, front, body = text.split("---", 2)
meta = yaml.safe_load(front)
meta["body"] = body.strip()
return meta
SKILLS = {p.parent.name: _parse(p) for p in sorted(SKILLS_DIR.glob("*/SKILL.md"))}
build_available_skills_prompt() 把 name 和 description 拼成清单,注入
系统提示词——只有这句话进提示词,正文谁都看不见,除非有人调工具去拿。
load_skill 是新工具,也是这一期唯一的新机制:
@tool(description=(
"加载一份 skill 正文,拿到某个场景的详细处理办法。可选:" + ", ".join(SKILL_NAMES) + "。"
"清单里的一句话描述不够用、需要具体步骤和措辞的时候才调这个,不要每次都加载。"
))
def load_skill(name: str, runtime: ToolRuntime) -> Command | str:
if name not in SKILLS:
return f"没有这个 skill:{name},可选:{', '.join(SKILL_NAMES)}"
loaded = runtime.state.get("loaded_skills", [])
if name in loaded:
msg = f"{name} 这一场对话已经加载过了,不重复注入正文,按之前拿到的内容执行。"
return Command(update={"messages": [ToolMessage(msg, tool_call_id=runtime.tool_call_id)]})
body = SKILLS[name]["body"]
return Command(
update={
"messages": [ToolMessage(body, tool_call_id=runtime.tool_call_id)],
"loaded_skills": [name],
}
)
前面八期的工具都是“返回一个字符串,框架自动包成 ToolMessage“。这一期第一次
让工具自己返回 Command——一个工具调用不只能回答“这次问了什么”,还能顺手
改一下状态里别的字段。runtime.tool_call_id 是这次调用的 id,手动包
ToolMessage 的时候要用它,不然对不上号。runtime.state 能读到当前的
完整状态,loaded_skills 已经加载过哪些就是从这里查的。
state.py 加了这个字段:
class AgentState(TypedDict, total=False):
messages: Annotated[list[AnyMessage], add_messages]
loaded_skills: Annotated[list[str], operator.add]
operator.add 让它像日志一样往后追加——但会不会真的追加一条新记录,
由 load_skill 自己判断,已经加载过的不会重复写进去。
graph.py 跟第 8 期一模一样,load_skill 只是 TOOLS 列表里多的一项。
练习 16 的结论在这里成立:真正特殊的从来都是加载出来的内容碰巧是一份
说明书,加载它的这个工具本身,跟前面每一个工具没有任何区别。
跑起来
cd code
uv run python -m ep09.main wang t1 "订单 KL-901 想改期,但已经超过免费改期窗口了,我这边突然要住院,有诊断证明"
uv run python -m ep09.main chen c1 "你们这订单改期给拒了,态度还这么差,我要投诉到平台去,退全款!"
uv run python -m ep09.main li l1 "我们公司想订 15 张东京迪士尼的票,怎么弄"
你应该看到什么
实验一:超窗改例外,两个工具并行调用
[agent] 要调 get_order({'order_id': 'KL-901'}) (prompt_tokens=1528)
[agent] 要调 load_skill({'name': 'reschedule-dispute'}) (prompt_tokens=1528)
[tools] get_order 返回:{"order_id": "KL-901", "customer": "陈先生", ...
[tools] load_skill 返回:# 超窗改期例外处理...
[agent] 要调 get_policy({'product_id': 'SKU-1001', 'topic': 'reschedule'}) (prompt_tokens=2183)
[agent] 要调 get_current_time({'timezone': 'Asia/Shanghai'}) (prompt_tokens=2183)
[tools] get_policy 返回:东京迪士尼一日票 的 reschedule 政策...
[tools] get_current_time 返回:...
[agent] 回答:陈先生您好,已核对您的订单 KL-901(东京迪士尼一日票,出行日 2026-09-05)。
您的改期已超过免费窗口 1 天,但考虑到您突发住院的情况,这次为您做一次性特殊处理,
同意免费改期(仅此一次,不代表以后超窗也能改)。
请问您希望改到哪一天?我好为您操作。 (prompt_tokens=2483)
模型自己判断出这是一个需要 skill 的场景,get_order 和 load_skill 一次
并行调用,读完正文之后严格按里面的判断标准执行:超窗 1 天、有理由,属于
“可以破例“那一档,回复里也确实说了“仅此一次,不代表以后超窗都能改”。
顺带一提,reschedule-dispute 的 frontmatter 里 allowed-tools 只写了
get_order, get_policy,但模型还是调了 get_current_time(第 7 期接的
MCP 时间工具)。allowed-tools 只是写给人看、写给提示词看的一句话,
代码没有拦——这是故意的,见下面“发生了什么“。
实验二、三:另外两个 skill 各自触发对了
[agent] 要调 load_skill({'name': 'complaint-de-escalation'}) (prompt_tokens=1525)
[agent] 回答:非常抱歉让您有这样的体验。您是说改期被拒让您很不满,这一点我完全理解...
[agent] 要调 load_skill({'name': 'group-booking'}) (prompt_tokens=1516)
[agent] 回答:您好!15 人(10 人及以上)可以申请团体票,享 9 折优惠...
1. 出行日期
2. 想要的产品
3. 联系人姓名和手机号
4. 是否需要开统一发票
三场对话,三个不同的 skill,清单里只给了一句话描述,模型没有选错。 投诉那条完全照着“复述+承认“的步骤回应,团体票那条把说明书里要收集的 四项信息原样问了一遍。
清单便宜、正文贵,真实数字
三次实验第一次调用的 prompt_tokens 都在 1505~1528 之间——这是系统
提示词的固定成本,六个工具的描述加三个 skill 的一句话简介,不管这一轮
问题用不用得上,这个数字都在。加载 reschedule-dispute 正文(连同
get_order 的结果)之后,下一次调用涨到 2183,一次涨了 655。两个工具
结果混在一起进去了,没法单独拆出 skill 正文自己占了多少,加分练习里
留了这道题。
去重:已经加载过的不会再插一遍正文
模型第一次拿到正文之后,后续问题它自己会记得内容,不会主动再调一次
load_skill,这条规律在自然对话里很难亲眼看见。为了验证去重逻辑本身,
我直接在已经加载过 reschedule-dispute 的 thread 上手动注入了一次
“模型决定再调一次 load_skill“的状态,工具返回的是:
reschedule-dispute 这一场对话已经加载过了,不重复注入正文,按之前拿到的内容执行。
loaded_skills 里已经有这个名字,load_skill 直接短路,不会把几百个 token
的正文再塞一遍。
发生了什么
触发靠提示词里的一句话描述,不靠代码路由。 三个 skill 对应三种完全 不同的场景,清单里各自只有一句话,模型全部选对了。这句话描述写得好不好, 直接决定这一层能不能用——写得含糊,两个相近的场景就可能选错。
allowed-tools 是写给人看的边界,代码不检查。 实验一里模型调了不在
reschedule-dispute 允许范围内的 get_current_time,程序没有拦,回答
也没有出问题。这是这一期故意留出的诚实边界:allowed-tools 目前只是
文档,真的要强制,得在 ToolNode 那一层按 loaded_skills 过滤当前可用
的工具列表,这一期没有做(加分练习 2)。
一个工具返回 Command、跟别的工具并行调用时,拿到的更新变成一份
列表。 第一次跑实验一,main.py 直接崩在 changed["messages"]——
get_order 返回字符串、load_skill 返回 Command,两个工具在同一次
并行调用里各自写了一次状态,"tools" 这个节点的更新变成了
[{"messages": [...]}, {"loaded_skills": [...]}, {"messages": [...]}]
这样一份列表,前八期的 print_stream 只认单个字典,直接报错。修的时候
把 changed 统一当成列表处理(普通字典包一层变成单元素列表),不管
里面有几份、顺序是什么,都能正常读出每一份的 messages。往后只要有工具
可能返回 Command,且这个工具可能跟别的工具一起被并行调用,消费更新的
代码就得按列表处理,不能只认一种固定形态。
常见问题
为什么不让 load_skill 直接返回字符串,非要用 Command? 因为这一期
要在状态里记“这场对话加载过哪些 skill“,普通的 return 只能决定这次
ToolMessage 里写什么,改不了 messages 之外的字段。Command 能一次
更新多个 channel,这是它跟普通返回值的本质区别。
能不能让模型自己写一份新 skill 存到磁盘上? 上册练习 18 专门讨论过
这件事——某些产品真这么做过,官方文档自己也承认会攒出一堆“污染目录、
浪费 token 的近似重复技能“。这一期没有给模型写 SKILL.md 的能力,
write_file 这类工具压根没接进来。skill 目前是仓库里的文件,人写、
模型读,改动走代码审查,不走模型自由发挥。
新增一份 SKILL.md,要重启才能生效吗? 要。registry.py 是 import
时扫一次目录,运行中的进程不会感知到新文件。这一期用的是“一条命令一个
进程“的示例风格,天然每次都是新进程,重启的成本感觉不到;真实长期运行的
服务要么定时重新扫描,要么改文件后重启进程,这一期没有实现哪种。
加分练习
- 加一个第四个 skill,描述跟已有某一个刻意写得相近,看看模型会不会 选错——这是练习 17“清单只有两份说明书选对不难“往难了走一步。
- 把
allowed-tools从纯文档变成真正的限制:load_skill加载之后, 根据loaded_skills对应的allowed-tools过滤这一轮ToolNode里能用的工具,重跑实验一,看模型是不是真的调不了get_current_time了。 - 单独测一下
load_skill正文本身占多少 token:造一个只会触发load_skill、不会同时触发别的工具的问题,对比加载前后的prompt_tokens,比实验一的“655 里混了两个工具“干净。 - 让
registry.py支持热重载(不重启进程也能发现新 skill 或者内容变化), 想清楚缓存失效的时机该放在哪一步。