给 Agent 新增一个工具,比如“获取某个平台的公开帖子”。它在线上稳定跑了两周。随后,你把函数的 limit 默认值从 25 改成 20,又给 sort 枚举加了一个新值。代码改完,测试全绿,顺利合并。
三天后,生产环境开始偶发报错。模型调用工具时传入了你上周删掉的枚举值,运行时校验拒绝了它,而堆栈却指向分发层。你盯着分发代码查了半小时,发现那里一行都没问题。真正的问题根本不在这里:函数签名变了,但模型读取的工具描述没有同步更新。模型仍在依据旧 schema 生成调用,自然无法匹配现在的接口。
这就是工具描述漂移。它是 Agent 工程里最常见、也最难追踪的一类 Bug,难就难在报错位置和根因位置不在同一处:错误出现在执行层,根因却藏在一个没人会主动打开的 JSON 文件里。本文要解决的不是“记得保持同步”,而是从结构上消灭这个问题——让系统里不再存在第二份会漂移的副本。
工具描述为什么会漂移
拆开看,漂移的根源很简单:你维护了两份事实来源。
第一份是实际执行的代码:函数签名、参数校验、默认值、枚举约束。这是硬约束,出了问题会立刻明确失败。
第二份是模型读取的工具描述:name、description 和 parameters JSON schema。这是软约束,写错了不会马上爆炸。模型只是生成一次错误调用,最后在执行层才失败。
只要靠人工让这两份内容保持一致,发生漂移就只是时间问题。你可能改了代码参数却忘了改描述;也可能更新了描述却漏改代码;甚至两边都改了,但语义仍然没有对齐。这些问题在提交时通常不会暴露,直到模型刚好生成了一次触及差异的调用。到那时,距离你两周前做的修改已经太久了。解决方案只有一个方向:把两份副本变成一份。
唯一事实来源:声明就是接口
关键的思维转变是:你其实不需要单独维护那份工具描述 JSON。
函数的解析器声明,加上 docstring,已经包含了工具描述所需的全部信息。下面是一段普通的 argparse 声明:
subreddit = commands.add_parser("subreddit", help="Query a public board's feed")
subreddit.add_argument("subreddit")
subreddit.add_argument(
"--sort",
choices=("hot", "new", "top", "rising", "controversial"),
default="hot",
)
subreddit.add_argument("--limit", type=int, default=25)
help 就是命令的一句话说明;choices 对应枚举约束;default 给出默认值;type 定义参数类型;位置参数则是必填字段。模型调用工具所需的信息——工具做什么、有哪些参数、哪些必填、枚举有哪些值、默认值是什么——全都已经在这里。而且这份声明正是运行时用于解析和校验的同一份定义,所以它不可能和执行逻辑漂移:它本身就是执行逻辑的一部分。
因此,不要再手写第二份工具描述。正确的做法是把那份文档视为不存在:系统里只有代码;需要工具描述时,就从代码投影生成。这个投影只能单向进行——从代码到描述,绝不反过来。
从声明自动生成完整能力目录
一旦接受“声明即接口”,工具描述就不该手工维护,而应由派生器统一生成。
派生器做的都是机械工作:遍历每个平台注册上下文,导入其解析器,并从 argparse 的 action 列表中读取信息,构造三个不可变结构:Platform、Command 和 Parameter。每个 Parameter 都带有名称、类型、必填标记、枚举值、默认值和帮助文本。这就是完全由代码派生出的接口读模型。
有了这个读模型,所有输出格式都只是它的下游结果。describe --format json 可以输出完整的机器可读接口,供 Agent 做工具选择;render_skill() 可以生成供人或模型阅读的能力目录。目录里的命令总数也不是手填常量,而是即时计算的 sum(len(platform.commands))。目前它统计出 22 个平台上下文和 241 条命令,其中没有一条是手工录入目录的。
这带来一个很舒服的特性:新增一个平台,只需新增平台上下文,目录会自动收录它的所有命令;修改一个参数,只需改解析器声明,目录中对应的枚举和默认值就会自动更新。你不会再遇到“新命令写好了却忘记注册”或“参数改了但目录还是旧的”这种情况,因为根本没有“注册”这个动作。目录是计算出来的,不是维护出来的。
(这种“尽量派生,而非手工维护”的直觉,也体现在用集合运算判断跨语言迁移究竟完成了哪些内容上,详见跨语言迁移一文。)
用 CI 抓住漂移:提交时直接亮红灯
派生机制解决了“新命令会自动进入目录”,但还有一个缺口:有人改了解析器声明,却忘记重新运行派生器,也没有提交重新生成后的目录。仓库里的副本又会过期,漂移便从侧门溜了回来。
最后一道闸门应放在 CI 中,核心只需要一条断言:
docs-check:
$(PYTHON) -c 'from pathlib import Path; from reverse.catalog import render_skill; \
path = Path("skill/SKILL.md"); \
assert path.read_text(encoding="utf-8") == render_skill(), \
"skill/SKILL.md is out of sync with the code; run make docs"'
它会读取仓库中已提交的目录,并与根据当前代码重新生成的目录逐字节比较。只要相差一个字符,CI 就会失败,并提示目录已与代码不同步,需要运行 make docs。
这一行的价值,在于它改变了发现漂移的时机。过去,漂移像是运行时的幽灵:两周后在生产环境爆炸,堆栈还指向错误的位置。现在,它变成提交时的红色 X。你会在 pull request 阶段被拦下,错误信息明确告诉你目录过期了,重新生成即可解决。漂移从“最难排查的 Bug”降级为一条命令就能清掉的编译错误。这就是 interface-as-code 的完整闭环:声明是源代码,能力目录是构建产物,CI 则是类型检查。你不会手写构建产物,也不会容忍构建产物与源文件不一致;工具描述也该得到同样的对待。
哪些能力该固化为代码,哪些交给模型
派生和 CI 能保证接口描述准确,但在此之前还有一个更早的判断:某项能力应该写成固定代码,还是让模型按现场情况编排?如果这个边界划错了,再准确的接口也救不了你。
可以把能力分成三个层次来看。
底层原语负责读取一类数据,或完成一个清晰动作。它的输入稳定、输出结构化,也能单独测试。这一层完全由代码处理,不消耗任何推理能力。241 条命令中的绝大部分都属于这一层。
确定性工作流是在单个平台内执行的强顺序流程,拥有共享状态和明确的成功条件。例如创意流水线 creative-pipeline,按固定顺序执行:先找机会,再找 Top Ads,接着匹配创作者、生成创意简报,最后完成生成前检查。各步骤的顺序和依赖关系都是确定的。这一层同样应该固化进代码:既然顺序已经确定,让模型每次重新规划只会更慢、更不稳定。标记方式只需一行:为命令添加 set_defaults(_command_level="workflow")。这是整个代码库中唯一的一行此类定义,也正因如此,目录会将工作流和原语分为两个层级展示。
Agent 编排层则负责跨平台研究、实时权衡,以及失败后的改道。这里才真正适合交给模型,因为下一步该查询什么取决于上一轮查询得到了什么,这无法预先写死。
判断标准很清楚:能力若需要稳定的阶段状态、共享上下文或生成副作用,就应固化进代码;如果涉及查询扩展、跨平台验证或失败后的路径调整,就交给模型。两种方向都会出问题。把研究假设硬编码进客户端,是过度固化,平台一变你又得改代码;把固定流程交给模型每次重新拼装,则是固化不足——省下了一次编码,却换来一堆不稳定性。
让模型知道何时降级:六种阶段状态
要让编排层做出判断,底层返回的信息必须让模型看得懂。一个不透明的成功/失败布尔值远远不够。你给模型一个 success: false,它只能猜下一步该怎么办。
因此,工作流的每个阶段都应返回阶段状态而非布尔值,共有六种:completed、empty、ready、skipped、unavailable 和 blocked。真正有价值的信息,主要来自那些没有继续执行的状态之间的区别:
skipped表示操作方有意关闭了这一步,例如把某条采集路径的上限设为 0。这不是错误,模型不应该重试。unavailable表示该步骤依赖的某项资源暂时不可用,例如接口报错或 session 缺失。模型可以跳过它继续执行,或者提示获取新的 session 后再回来处理。blocked表示前置条件没有满足,例如研究证据为空,或预检失败。模型不应强行推进下一步,而应返回去补齐证据。
还是以创意流水线为例。它会将“平台预检就绪”和“研究证据就绪”分开判断,最终得到 ready = platform_ready and research_ready。只要任一条件不满足,生成阶段就会返回 blocked,并携带 blockers 列表说明卡在哪里;当所有商业搜索结果都为空时,它不会提交生成任务。
为什么这种设计是为模型服务的?编排模型读到 seedance_generation: blocked 和 blockers: [research_evidence_empty],就知道该回去补证据,而不是重试提交。读到 organic_discovery: skipped,它知道这是用户意图而不是故障,因此不会动它。读到标记为 unavailable 的步骤,它知道可以绕开该步骤进行降级处理。将“主动关闭”“暂时不可用”和“前置条件未满足”区分开来后,模型才能选择正确的降级路径。把三者全都压平成 false,即使是很强的模型也只能原地打转。
不同层级该用什么模型
上面这套架构在不同层面对模型提出的要求完全不同。(那篇四阶段逆向工程文章在逆向工程场景中给出了同样的四层表格;这里则把它放进 Agent 技术栈。)按层级分配模型,可以避免浪费能力:
Agent 技术栈中的工作 | 所需能力 | 选择 | model id |
|---|---|---|---|
将 241 条命令的 | 长上下文,能一次读完整个目录 | Kimi K3 |
|
编排:读取阶段状态与 blockers,决定降级、改道或继续 | 强推理能力,能针对状态做出正确判断 | Claude Opus 5 |
|
批量根据 docstring 生成模型友好的工具描述文本 | 成本低,可高并发运行数百次调用 | Claude Sonnet 5 |
|
工具调用错误归因:读取错误和声明,判断是漂移还是上游变化 | 中等推理能力,能针对具体字段解释原因 | GPT-5.6 Sol |
|
最值得展开的是编排层。读取 blocked 与 skipped 后决定下一步,是整条链路中唯一一个换模型会明显改变结果的环节,因为它考验的正是模型能否针对状态作出正确判断。较弱的模型会把 skipped 当成失败而重试,或看到 blocked 仍然继续提交。强推理模型则会读取 blockers,精确地重新路由。这和指纹识别一文中“反证部分是否真的在反驳自身”的差距是同一种问题:提出候选方案谁都能做,难的是作出判断。
不必相信我的说法,直接测试即可:
从你自己的某条工作流中拿一份真实返回结果,包含其
stages和blockers;或者构造一个blocked响应,并设置blockers: [research_evidence_empty]。将这份响应、你的能力目录(
describejson),以及“决定下一步操作”的指令,分别交给claude-opus-5和gpt-5.6-sol。只看一件事:模型给出的下一步操作,能否正确区分
blocked(回去补证据)、skipped(用户意图,不处理)和unavailable(获取 session 或绕行降级),还是会把skipped当作失败来重试?正确降级路径的比例,就是你的选型标准。它决定了 Agent 在真实故障下是原地打转,还是能够自行绕过问题。
真正难的是模型切换成本
这四个模型来自三家供应商,而函数调用场景下的切换成本尤其高。OpenAI 的 tools / tool_calls 和 Anthropic 的 tool_use / tool_result 是两套不同格式。即便你发现某个模型更适合编排层的判断,也得重写整套工具分发和错误解析路径。这才是大多数人最终把一个模型锁死在编排层的真正原因,即使它经常误读阶段状态也是如此。
AIReiter 把这层差异抹平了。一个 key,一套 OpenAI 兼容接口,背后接入全部四个模型;切换模型只需修改请求体里的 model 字段。
# Orchestration decision: hand the reasoning tier the catalog plus one blocked workflow response, ask for the next action
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<describe json> + <stages/blockers response> + decide the next action"}]
}'
# Generate tool-description text in bulk: change the model field, leave the rest
# "model": "claude-sonnet-5"
# Error attribution:
# "model": "gpt-5.6-sol"
原生函数调用只需额外传入一个 tools 数组,而 OpenAI 工具协议可原样通过该接口,因此换模型仍然只是改一个字段。如果你已经在使用 OpenAI SDK,把 base_url 指向 https://aireiter.com/api/v1,其余内容无需修改。使用 Anthropic SDK 时,则通过同一个 key 请求 POST /api/v1/messages。
价格方面,Claude 模型按标价七折收费,GPT 模型半价,Kimi K3 也可通过同一个 key 使用。对这套架构而言,折扣恰好落在最关键的成本项上。编排层每向前推进一步,都会多产生一次推理层调用,因此它既是整个 Agent 中调用最频繁、也是最昂贵的一层,Claude 的折扣正好覆盖这里。批量从 241 个 docstring 生成工具描述,则是高并发的 Sonnet 工作,同样享受折扣。这两部分构成了大头成本;用于错误归因的 GPT-5.6 调用则少得多。
免注册试用:手动跑几轮,把同一个
blocked响应分别交给两个模型,在把其中一个接入编排层之前,亲自看看哪个模型能正确降级。
结语
工具描述漂移不是靠“记得同步”就能治好的问题。这种做法只是把结构性缺陷压缩成个人纪律问题。真正的修复方式,是删除“两份事实来源”的结构:解析器声明加 docstring 是唯一来源,能力目录是由它派生出的构建产物,一条 CI 断言充当类型检查。漂移会从运行时幽灵变成提交时的红色 X。
但派生只能保证描述准确,不能保证分层正确。哪些能力应该固化进代码,哪些交给模型编排,以及让模型判断“重试还是降级”的六种阶段状态,才是决定 Agent 能否自主运行的两个关键。模型在这套架构中承担两项具体工作:在编排层做权衡,以及在工具调用失败时判断根因。能力是否应该固化、该选择哪条降级路径,取决于你设计的阶段状态和写下的 CI,而不是模型自己决定。
这与基于集合的迁移核对和不构建统一响应 Model两篇文章的立场一致:AI 压缩的是单个步骤所需的时间,最终判断仍然被你硬编码的约束所限定。当整套系统已经顺畅运行,剩下的摩擦只会是模型切换;这是基础设施问题,而统一接口可以解决它。