一位后端工程师重命名了一个字段。user_id 变成了 customer_id,因为团队终于在所有服务中统一了 “customer” 这个术语。他们还增加了一个参数 region,因为计费系统现在需要它。这次变更通过一个包含两个批准的普通拉取请求(pull request)发布。每一个调用该端点的下游服务都在同一个发布版本中进行了更新。集成测试全部通过。按照后端团队衡量的一切标准,这是一次常规且执行良好的 API 变更。
一周后,支持工单开始增加。负责下单的智能体偶尔会在没有关联客户的情况下下单,或者将其关联到错误的区域。没有人改动过智能体。没有人改动过提示词(prompt)。模型的版本与上个月完全相同。然而,智能体现在却出现了一种以前从未有过的错误。
原因既不是模型中的 Bug,也不是后端中的 Bug。而是工具 Schema(tool schema)有两个消费者,但在审查变更时,只有其中一个在场。
Schema 是包含两个读者的契约
当你向智能体暴露一个函数时,你会编写一个工具定义:名称、描述以及参数的 JSON Schema。你很容易将该 Schema 视为 OpenAPI 规范——一个供你自己的代码进行验证的机器可读描述。这种心理模型只对了一半,而缺失的那一半正是让你栽跟头的原因。
第一个消费者是编写集成代码的开发人员。他们阅读字段名称,连接调用点并处理响应。如果后端重命名了字段,这个消费者会立即发现:构建失败,类型不再匹配,代码检查工具(linter)报错。对于人类来说,这种破坏性变更是“响亮”的,因为人类与 Schema 的关系是由编译器介导的。
第二个消费者是模型。当你将 tools 数组传递给模型 API 时,供应商并不会将 Schema 交给某个独立的验证子系统。它会将你的工具定义——名称、描述、参数 Schema 以及任何示例——序列化为系统提示词,并将该提示词喂给模型。Schema 并不是放在模型旁边的配置。它就是 提示词文本。模型读取它的方式与读取其他指令完全相同:将其作为塑造下一个 token 的自然语言上下文。
这种区别正是问题的症结所在。模型与 Schema 的关系不是由编译器介导的。它是由对提示词的注意力介导的。当后端将 user_id 重命名为 customer_id 时,模型不会收到构建错误。它什么也收不到。它会继续输出 user_id,因为那是它锚定的字段名称,而在整个技术栈中,没有任何机制可以告诉它事实并非如此。
为什么模型会悄无声息地失效
API 的人类消费者会快速且彻底地失败。模型消费者则会缓慢且部分地失败,这两点都使得这种故障更难被察觉。
失败是缓慢的,因为模型并不会在每次请求时重新推演。无论它学到了什么形状——无论是从工具描述、少样本示例(few-shot examples),还是从其训练数据中偏向常见字段名(如 user_id)而非罕见字段名(如 customer_id)的统计引力中——这种形状都已经固化在它生成调用的方式中。后端可以在周二更改 Schema,而模型会继续生成周一的参数,直到有人更新它读取的提示词和示例。
失败是部分的,因为工具调用的准确性是统计性的,而非二进制的。模型不会从 100% 正确切换到 0% 正确。它会发生漂移。也许 85% 的调用恰好仍然正确,因为新的字段名与旧的足够接近,或者模型有时会从更新后的描述中复制正确的名称,有时又退回到之前的状态。故障率从接近于零上升到 15%,并不会触发旨在捕捉宕机的警报。它表现为一种模糊的质量退化,有人会在一周后、数据已经变脏时才注意到。
漂移的分类值得明确命名,因为每种变体都以其特有的安静方式失效:
重命名字段。 模型输出旧的键。你的工具接收到的新键为 undefined。如果你不进行验证,调用就会在缺少参数的情况下继续。
增加必填参数。 模型锚定的上下文中没有任何内容提到新的参数,因此它会完全忽略它。调用在结构上是不完整的。
更改枚举值。 模型从旧集合中输出一个值。这是一个看似合理但已不再有效的字符串。
更改类型。 模型在需要数字的地方发送了字符串。宽松的后端会对其进行强制转换;严格的后端会拒绝它;无论哪种方式,意图都被破坏了。
删除字段。 模型仍然发送已停用的参数,而它会被默默丢弃。
每一项按照 API 版本控制的标准定义都是破坏性变更 ——而标准定义在制定时考虑到的是人类消费者。模型也是消费者,而且它是更脆弱的那一个,因为它针对旧的形状进行了微调,并且没有任何契约测试在守护它。
借鉴你已有的词汇
好消息是,这并不是一类新问题。几十年来,软件工程一直在处理生产者与消费者之间的破坏性变更 (breaking changes),这些词汇可以完美平移到工具模式 (tool schemas) 中。你不需要发明一套新学科,你只需要将现有的学科应用到一个你之前未曾考虑过的消费者身上。
破坏性与增量式。 添加一个新的可选参数是增量式的——模型可以忽略它,旧的调用依然有效。重命名一个字段、删除一个字段、添加一个必填参数或更改类型则是破坏性的。管理公共 REST API 的规则同样适用于你的工具模式,模型理应获得与付费 API 客户同等的待遇。
扩展与收缩。 当你必须进行破坏性变更时,请分阶段进行。在保持对旧字段支持的同时添加新字段。更新工具描述和示例,以便模型在新形状上重新锚定。给它一些时间生效。只有到那时再移除旧字段。这种并行变更模式是你将后端部署与模型的重新学习曲线解耦的方式——这之所以重要,是因为模型重新学习的时钟与你的 CI 流水线不同,且通常更慢。
消费者驱动契约。 像 Pact 这样的工具允许消费者准确声明它对生产者的期望,并在该期望得到验证之前阻止生产者的部署。这种模式中的 can-i-deploy 关卡非常适合工具模式:在 Agent 的契约通过校验之前,后端不应该能够发布模式变更。目前这种情况之所以没有发生,原因很简单且可以解决——模型没有 Pact 文件。它是一个未被代表的消费者。CI 中没有任何环节知道它的存在,因此也就没有任何环节能阻止破坏它的变更。
解决方法是给模型一个代理人。
在边界处进行强校验 第一个代理人是位于模型输出与实际工具执行之间的验证层。在对模型参数进行任何操作之前,先通过 JSON Schema 运行它们。如果缺少必填字段或类型错误,不要强制转换,不要丢弃,也不要继续。向模型返回一个明确的错误——例如 Invalid arguments: missing customer_id——并让它在下一轮对话中自我修正。
这几乎不消耗任何成本。验证是确定性的代码,运行只需几毫秒,这意味着你可以负担得起在 100% 的生产流量上运行它。它将你看不见的失败模式转化为看得见的模式。静默转换 (silent coercion) 会产生错误的操作且没有信号。而强验证错误会产生一个指标:工具调用被拒绝的比例。该比例的激增是你最早、最廉价的探测器,表明模式在你不知情的情况下发生了变化——它会在工单堆积之前触发报警。
关于严格模式 (strict mode) 的一个警告。目前两大主流模型供应商都提供了约束解码 (constrained decoding)——即结构化输出 (structured outputs)——这从构造上保证了模型的输出符合模式验证。这确实很有用,但要理解它能解决和不能解决的问题。严格模式是针对 当前 模式进行验证的。它强制输出符合有效的形状。它并不会告诉模型模式已经改变,也不会让模型的 意图 变得正确。一个锚定在旧形状上的模型,如果被强行塞进新形状,可能会生成结构完美的 JSON,但其中包含错误的值——比如在正确的地区字段中填入了一个猜测值。因此,严格模式可能会掩盖偏差:JSON 是有效的,仪表盘是绿色的,但订单依然是错的。结构合法性是必要的,但它并不等同于正确的调用。
将工具定义和示例视为版本化代码 第二个代理人是将模式、描述和示例视为与后端变更同步的一等版本化产物 (versioned artifacts)。
模型通过工具描述和示例来学习一次高质量调用的“形状”。Anthropic 自己的指南直言不讳地指出:单靠 JSON Schema 无法表达格式约定或参数之间的关联——这些知识存在于描述和示例中。他们的数据显示,加入优质示例后,处理复杂参数的准确率从 72% 跃升至 90%。这些示例是契约的一部分。如果后端重命名字段后,示例中仍显示 user_id,那么你就是在用过时的文档直接喂给模型的上下文,而过时的文档比没有文档更糟糕。
因此,重命名工作在后端上线时并未完成。只有当工具描述、模式和每个示例都在同一个变更集中更新、共同评审并共同版本化时,才算真正完成。Model Context Protocol (MCP) 社区正趋向于此——提议将工具纳入语义化版本控制 (semantic versioning),将工具名称保留为永不更改的稳定标识符,并允许服务器同时提供多个版本的工具。方向很明确:工具定义就是 API,它应该像 API 一样进行版本管理。
最后一部分是一个模型真正会运行失败的契约测试。构建一个小型、固定的评估集 (eval suite),包含提示词及其预期的工具调用。在 CI 中针对实时模式运行它。当后端工程师重命名一个字段时,该评估会失败——在他们的 Pull Request 中清晰地报错,且发生在变更上线之前——因为预期的调用不再符合模型针对新定义生成的内容。这个评估就是模型的 can-i-deploy 关卡。它最终让这第二个消费者出现在了决策现场。
这比看起来更重要,因为模型对自己见过的工具的处理能力远超未见过的。经过微调的函数调用模型在训练数据中存在的工具上通常能获得 95% 以上的分数,而在未见过的工具上则会掉到 60–70%。模式变更不仅仅是编辑一个字段。它将你的工具从模型熟悉的分布推向了它必须靠猜测处理的分布。契约评估让你能在性能下降发生的瞬间捕捉到它,而不是在一周的错误数据之后才推断出来。
只有当所有消费者都更新后,变更才算完成 导致最初事故的直觉本身是好的。将术语统一为 "customer" 是正确的决定。在同一个版本中更新所有下游服务是严谨的工程实践。错误不在于变更本身,而在于资产盘点的错误:团队枚举了所有的消费者,但模型并不在列表中,因为它不会出现在依赖图中,也不会像服务那样出现在访问日志中,而且它从不提交 Bug。
它只是在悄无声息地变糟。
所以,请扩展“完成”(Definition of Done)的定义。一个工具 Schema 的变更只有在以下情况才算完成:后端已更新,并且 工具描述和示例已更新,并且 契约评估(contract eval)针对新结构运行通过,并且 边界验证器(boundary validator)已就位以捕获任何遗漏的情况。在此之前,你实际上是向一个被你遗忘的消费者发布了一个破坏性变更 —— 而那个消费者既读不了你的变更日志,也无法提交工单,更不会大声报错。它只会返回稍微有点错误的答案,直到有人开始追查原因为止。
会员专享
余下内容仅对会员开放。 会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。
— 完整文章,包含未公开存档的部分 — 可落地的工作框架,附带权衡与决策依据 — 新文章抢先看,先于公开发布 登录以继续阅读→ 随时取消 · 一次订阅,畅读全部