跳到主要内容

工具 Schema 弃用:为什么你不能直接重命名参数

· 阅读需 13 分钟
Tian Pan
Software Engineer

你在一个工具 schema 中将 query 重命名为 search_query。变更日志写着:“非破坏性更名:更清晰的命名”。PR 通过了评审。三天后,你的支持队列里塞满了关于助手“搜索结果为空”的报告。发生的事情并不是讨论帖里任何人会告诉你的。智能体(Agent)并没有失败。它们提交了旧的字段名称,你的工具服务器忽略了未知的 key,将 search_query 默认设置为空字符串,并返回了零条结果。模型看到一个看起来很正常的空响应,便自信地向用户解释为什么他们的查询没有返回任何相关内容。

这是智能体工程(Agent Engineering)中不符合从 REST API 版本管理借鉴来的心理模型的部分。发送已重命名字段的 REST 客户端会收到 400 错误和清晰的报错——该字段要么存在于验证器中,要么不存在。而发送已重命名字段的智能体得到的则是静默接受、一个毫无意义的结果以及一段幻觉式的合理解释。失败不在于线路传输(the wire);而在于运行时 schema 与模型关于工具外观的上下文心理模型(in-context mental model)之间的脱节。

工具 schema 存在于两个地方。第一个是运行时规范(runtime spec)——即你发布到 MCP 服务器或函数调用注册表的 JSON schema。第二个是该规范在模型中的上下文表示,它通过系统提示词(system prompt)中的 few-shot 示例、智能体在多轮任务中看到的序列化工具历史记录,以及模型在预训练期间已经吸收的关于你 API 的知识来在每一轮对话中不断强化。你可以原子化地更新前者,但你无法原子化地更新后者。这种不对称性就是问题的核心,这也是为什么“仅限添加,永久保留”——protobuf 和 GraphQL 运营商在十年前就已经内化的原则——现在需要迁移到工具 schema 层了。

你的 Schema 存在的两个地方

当工程师第一次将智能体发布到生产环境时,他们往往会像看待 OpenAPI 规范一样看待工具 schema:一个唯一的真理来源,发布一次,供客户端使用。这种直觉在特定方面是错误的。

运行时规范是服务器进行验证的依据。如果你收紧它——重命名字段、缩小类型范围、将可选参数设为必填——你就破坏了线路上的兼容性。这部分符合 REST 的直觉。

上下文规范是模型对你工具的内在认知。它在每次调用时重新组装,来源包括:(1) 注入到系统提示词中的工具定义块,(2) 你包含的任何演示工具使用的 few-shot 示例,(3) 当前会话中智能体自身先前工具调用的序列化记录,以及 (4) 预训练权重中已经编码的关于你领域内工具通用形状的信息。只有第一点是完全受你控制的,第三点和第四点则不是。

结果就是:一个在上一轮使用旧字段名称成功调用工具的智能体,在这一轮极有可能仍尝试以同样的方式调用它——即使你在两轮之间原子化地部署了重命名的 schema。提示词历史记录是模型自己编写的 few-shot 示例,它的权重比你想象的要大。

为什么“静默接受”比 400 错误更糟糕

接收到未知字段的 REST API 通常会拒绝它。现代函数调用流水线通常不会。框架接受模型的 JSON,剥离未知 key(或直接透传),然后将字典交给你的处理程序。处理程序发现缺少参数,于是使用默认值——通常是空字符串、null0——并开始运行。用户得到了一个看起来合情合理的空结果。

三个特性使得这种失败模式尤其具有破坏性:

  • 它是不可见的。 没有堆栈跟踪,没有警报,没有非 2xx 响应。传统的观测手段(错误率、延迟、5xx 错误)什么也查不出来。
  • 模型会为其掩护。 LLM 接受过生成流畅解释的训练。面对空结果,模型会生成一个听起来很正确的理由。“我搜索了但没有找到匹配的记录”与在确实没有匹配记录时的成功表现完全一致。
  • 它会传播。 如果出错的工具为下游工具提供输入——例如将搜索结果传给摘要生成器,或将检索到的文档传给推理器——随后的每一步都运行在受损的输入之上。一个工具上重命名的一个参数就能毁掉整个流水线。

团队第一次遇到这种情况时,通常是通过用户投诉而不是通过监控发现的。这就是你的工具 schema 变更管理尚未成熟的信号。

“仅限添加”是默认准则

protobuf 和 GraphQL 运营商总结出的规则,在适配智能体工具 schema 后,大致如下:

  1. 永远不要重命名参数。 添加一个新参数,并保留旧参数。
  2. 永远不要缩小类型范围。 扩大它,或者添加一个具有更严格类型的新字段。
  3. 永远不要将可选参数改为必填。 而是对新参数添加要求,并为旧路径设置默认值。
  4. 在遥测数据证明没有任何调用之前,永远不要删除参数。 然后保留该名称,以免它带着不同的语义卷土重来。
  5. 永远不要重复使用名称。 字段名称是永久性的标识符。如果 query 去年是一个字符串,那么今年它就不能是一个对象,即使是在“干净”的迁移之后也不行。

在你体验过另一种情况之前,这些规则听起来可能很迂腐。在传统 API 中,违反这些规则会破坏一组可识别的特定客户端,他们最终会注意到并提交工单。在智能体系统中,违反这些规则会破坏一组不可知的进行中会话,而其错误的报错行为会被模型本身合理化为合情合理的结果。

这种“仅限添加”的准则可以完美映射到 protobuf 的 [deprecated = true] 加上保留字段编号。它也可以映射到 GraphQL 的 @deprecated(reason: "...") 指令。而它目前尚不能映射——这正是生产环境中的巨大隐患(foot-gun)——的是 MCP,其规范几乎完全将工具演进交给了服务器实现者。

MCP 的缺口

MCP 具有协议级的版本控制(握手过程会声明协议版本)。但截至目前的规范,它所缺失的是一个标准化的工具级版本化层。工具是通过名称识别的。Schema 是服务器在 tools/list 期间发布的 JSON schema 对象。目前没有规范的方法来表示“此参数已弃用”,没有等同于 @deprecated 的标记,没有在保留旧名称的同时路由到新行为的约定,也没有在 CI 中标记破坏性变更的注册表级契约检查。

当一个团队同时拥有服务器和智能体(Agent)时,这并没什么大不了的。但只要你的 MCP 服务器拥有多个消费者,情况就会变得危险。每个消费者的智能体都有自己的提示词历史、自己的少样本示例(few-shot examples),以及自己对异常结果的容忍度。对于更新最快的消费者(即那些提示词昨天刚刚更新过的消费者)来说,“无害”的变更,对于那些保存的对话线程中仍包含上个月字段名称的消费者来说,却是灾难性的。

即使是看起来只是表面上的改动,也会变成行为上的变更。将工具描述从“搜索知识库”微调为“搜索语料库”,可能会改变模型路由到该工具的情况。在 Schema 对象中重新排列参数顺序,可能会改变模型对默认值的先验判断。智能体工具 Schema 中“破坏性变更”的范畴比 REST 终端中“破坏性变更”的范畴要广得多。

智能体工具的弃用方案

行之有效的模式借用了影子表(shadow-table)迁移的手册,并将其适配到同时作为提示词的 Schema 中。

第一阶段:增量影子。 在旧参数旁边引入新参数。两者都接受。如果两者同时存在,则以新参数为准;如果只有旧参数,则在内部将其路由到新行为。每当触发旧路径时,发出一条标记为 deprecated_field_hit: <name> 的结构化日志。现在的运行时 Schema 同时携带这两个字段;旧字段的描述写着:“已弃用:请改用 <new_name>。将在 <date> 或之后移除。”新字段的描述必须清晰明确,且至关重要的一点是,不要提及旧名称——不应该通过阅读新字段来引导模型生成已弃用的字段。

第二阶段:行为双运行。 保留影子参数足够长的时间,直到模型的上下文历史、少样本示例和保存的对话记录都已完成更替。“足够长”并不是一个固定的时间间隔——而是直到你的遥测数据显示已弃用字段的命中计数器趋于零。GraphQL 社区最先意识到了这一点:你无法单凭直觉知道移除已弃用字段是否安全;你的可观察性数据才知道。按客户端、按智能体、按天跟踪命中情况。如果一个智能体停止调用该字段,但另一个智能体有一个仅在周日运行的每周批处理作业,你就能从数据中发现它。

第三阶段:沟通截止日期。 在移除之前,更改工具描述以明确宣传移除日期——就在模型实际读取的描述文本中。类似于:“query 参数已弃用,将在 <date> 后被拒绝。请改用 search_query。”这会将信息推送到模型的上下文窗口(即上下文规范存在的地方),而不仅仅是推送到没人看的智能体变更日志中。

第四阶段:硬化,然后移除。 在移除字段之前,暂时翻转服务器的容忍度。不再默默接受旧名称并替换为默认值,而是使用解释迁移方案的结构化错误来拒绝调用。模型的自愈循环将使用新字段名称重试——这是一种特性,而不是 Bug,因为错误路径现在会反馈到上下文中,并像成功调用一样更新上下文中的规范。一旦错误计数器也归零,你就可以移除该字段了。

第五阶段:保留名称。 移除后,将旧名称添加到服务器上的保留列表中。如果某个智能体带着包含旧名称的古老提示词再次出现,你需要一个响亮且具体的错误——而不是引发这整个混乱的默认静默处理。

智能体中不同于 REST 的变化

智能体运行时的两个特性使得这些模式成为强制性的,而非可选的:

模型是非确定性客户端。 REST 客户端要么工作正常,要么已经损坏;你可以发布一次修复然后完事。而模型在给定包含已弃用字段的提示词历史时,有时会遵循新 Schema,有时又会回退到它之前见过的工作方式。你无法通过单次集成的检查来规避这一点;你需要对两条路径进行为期数周的遥测。

上下文窗口是你并不拥有的缓存。 每个长期运行的会话都携带了从会话开始时的 Schema 冻结视图。在这些会话过期之前——在具有持久记忆的助手(Assistants)中,它们可能永远不会过期——无论你的服务器今天发布了什么,旧的 Schema 都在生产环境中处于活跃状态。因此,请相应地保留你的名称。

改变局面的审查问题

当一个修改工具 Schema 的 PR 提交时,有用的审查问题不是“这在传输层面是向后兼容的吗?”,而是“如果一个智能体记得这个工具旧的形状,它在这一轮对话中还能得到正确的结果吗?”这种构思迫使审查者同时思考运行时规范和上下文中的规范。大多数“向后兼容”的 Schema 变更都无法通过这项测试。

各支表现优秀的团队已经采用了类似契约快照(contract snapshot)的方法——这是一个签入代码库的版本化文件,捕获了特定时间点的完整工具表面,包括名称、类型、描述以及必填/可选标志。快照在 CI 中从实时服务器重新生成;任何与提交版本的差异都会阻止合并,除非附带了迁移计划。这与 protobuf 的破坏性变更检测或 GraphQL 的 Schema 检查思路一致,并被引入到了 MCP / 函数调用领域,而这正是该领域迫切需要的。

需要内化的核心观点是:你的工具 Schema 不仅仅是与软件客户端的契约。它是与你向模型展示过的每一个提示词历史、每一份保存的对话记录以及每一个少样本示例的契约。传输协议可以原子化地更新。模型的心理模型(mental model)却不行。请据此计划弃用方案,否则就准备好向用户解释长达数周的静默失败吧。

References:Let's stay in touch and Follow me for more thoughts and updates