跳到主要内容

LLM 工具表面的契约测试:当供应商更改字段而你的智能体静默适应时

· 阅读需 12 分钟
Tian Pan
Software Engineer

上周二,某供应商在工具响应中将 "items" 更改为了 "results"。智能体没有崩溃。它围绕新结构重新进行了规划,返回了一个看起来很自信但丢失了三分之二行数据的答案,而轮值工程师在 3 天后因为客户询问导出数据为何缺失才发现。没有抛出异常。没有触发报警。运行在供应商变更前冻结的固定集(fixture)上的评测套件(eval suite)一直保持绿灯。

这种失败模式是十年前微服务中发明契约测试(contract testing)要捕捉的,而如今几乎没有智能体技术栈具备相应的对策。HTTP 服务有 Pact、schemathesis 和 oasdiff 位于消费者和提供者之间,拒绝让破坏性变更上线。你提供给智能体的工具——REST 端点、内部 RPC、供应商 SDK、MCP 服务器——都没有类似的保障。模型吸收了变化,优雅地进行了适应,并生成了一个看似正确但质量下降的答案。

在进一步讨论之前,值得内化的架构洞察是:工具的 API 是智能体提示词(prompt)的一部分。下游的每一个破坏性变更都是上游一次静默的提示词变更。如果采购工具开始返回字符串而不是之前的整数,你不仅破坏了解析器——你还改变了规划器用于选择下一步的潜在上下文(latent context)。而且,与你通过提示词版本管理流水线交付的变更不同,这种变更没有 PR,没有审核,也没有回滚按钮。

为什么异常救不了你

在确定性服务中,破坏性的字段重命名会导致 JSON 解析错误或 KeyError,你的处理器会捕获它,你呼叫相关人员,然后回滚。错误预算被消耗,系统会告知你。智能体的设计初衷则相反:当工具返回非预期内容时,规划器会对新结构进行推理并尝试恢复。这种恢复正是人们付费购买的功能,但它也恰恰掩盖了契约违约。

我在生产环境追踪中不断看到的三个具体模式:

  • 字段重命名itemsresults。工具以前返回 {"items": [...]}。现在返回 {"results": [...]}。智能体读取响应,注意到 items 缺失,于是断定“此查询未返回任何结果”。它继续执行。下游摘要显示“未找到匹配记录”。其实并非没有匹配记录——而是有 412 条。
  • 类型放宽:一个整数字段开始以字符串形式出现。智能体在下一次工具调用中再次将其字符串化,而接收工具要么将其强制转换回原类型(静默发生),要么将其视为文本从而不返回任何有用信息。
  • 空数组变 Null"tags": [] 变成了 "tags": null。规划器的下一步本是 for each tag in tags。有了 null,迭代被跳过,用户要求的整个工作分支都不会执行。

这些都不会抛出错误。这些都不会呼叫任何人。它们都是以 LLM 速度交付给用户的 Bug。

契约测试对工具意味着什么

微服务版本的契约测试有两种形式,你对工具这两种都需要。

消费者驱动的契约(Consumer-driven contracts,Pact 家族)让消费者写下它对提供者的预期。契约作为消费者测试的副产品生成,然后交给提供者,由提供者在 CI 中针对其实际服务运行。如果提供者的响应结构与消费者编码的不匹配,提供者的构建就会失败。这能在供应商的发布到达生产环境之前,捕捉到其破坏你智能体的行为。

基于 Schema 的契约(Schema-based contracts,OpenAPI / schemathesis 家族)将版本化的规范与实际行为进行对比。你持有供应商的 OpenAPI 文档,记录流量,并断言流量符合规范。像 oasdiff 这样的工具还可以让你比较今天的规范与昨天的规范,将每个差异分类为破坏性、非破坏性或弃用,并在出现高风险变化时使构建失败。

对于智能体工具,你需要消费者驱动的部分,因为 只有你的智能体知道它究竟依赖哪些字段。一个拥有 20 个响应字段的工具并不意味着这 20 个字段都同样重要——你的智能体规划器读取 4 个,格式化程序读取 2 个,其余的都是冗余。你想要强制执行的是这 4 个字段子集的契约,而不是完整的 Schema。如果供应商想增加字段,没问题。如果他们想重命名这 4 个字段中的一个,你想要的是构建变红,而不是一个质量下降的答案。

基于 Schema 的部分是你为供应商发布的规范(如果有的话)增加的保障。Pact 和 OpenAPI 在这里不是竞争关系。它们回答不同的问题:Pact 回答“这个提供者是否满足这个特定消费者的预期”,OpenAPI 一致性回答“这个提供者的运行结果是否与其自身的文档匹配”。对于一个调用 4 个供应商的 10 个工具的智能体,你最终会两者都用上。

Evals 告诉你模型表现如何;Contracts 告诉你它本来可以如何

我从那些已经拥有合理 Evals 套件的团队那里听到的最常见的反对意见是:“我们针对精选的对话集运行端到端评估 (Evals),这难道不是契约测试 (Contract Testing) 吗?”

并不是。Evals 告诉你模型在你已经见过的输入上产生了可接受的输出。Contracts 告诉你模型在你从未见过,但工具层保证能够生成的输入上,本来可以产生可接受的输出。区别在于对输入空间的覆盖范围。

一个静态的评估集针对的是静态的工具响应——通常是模拟的 (Mocked),有时是记录自数月前的固件 (Fixture)。当记录的响应符合模型预期时,Eval 结果为绿色(通过)。当实时响应已经偏离了记录,而 Eval 又不与实时工具通信时,Eval 结果也为绿色。因此,供应商可能在中午发布一个破坏性变更到生产环境,而你凌晨 3 点的每晚 Eval 可能全是绿色,而你的用户却在实时遭遇回归 (Regression)。

解决这一问题的准则是双层的:

  • Contracts (契约) 针对供应商已发布的或契约记录断言必须继续工作的一组代表性工具输入运行。它们不需要模型参与。它们不需要每例 0.50 美元的生成费用。它们在每个 PR 上以及针对供应商分段环境 (Staging) 定期运行,既便宜又快速。
  • Evals (评估) 针对带有模拟或记录的工具响应的模型运行,并演练模型必须进行推理的输入空间部分。它们很昂贵,但范围有限。

Contracts 捕捉无声的提示词变异。Evals 捕捉模型回归。你两者都需要,它们覆盖了互不重叠的故障面。

Schema 固件、对抗性变异器和你实际编写的测试

战术手册直接借鉴了微服务,并为 LLM 增加了一层。

每个工具的版本化 Schema 固件。你 Agent 调用的每个工具都有一个按工具名称和版本索引的固件文件目录。固件断言你的 Agent 所依赖的响应形状 (Shape),而不是完整的供应商 Schema。当供应商发布新规范时,你的 CI 会将固件与其进行比对(oasdiff 可以针对 OpenAPI 执行此操作),要么自动提升(增量变更),要么开启 PR(弃用),要么构建失败(破坏性变更)。

针对新工具响应的黄金追踪回放 (Golden-trace replay)。获取一个来自生产环境的静态 Agent 运行记录——即产生已知正确答案的工具调用和响应轨迹。当供应商的响应形状可能发生变化时,用新的实时响应替换并回放该轨迹,并断言 Agent 仍能产生等效的最终答案。这与快照测试 (Snapshot Testing) 的思路相同,被移植到了 Agent 工具边界,过去一年中几个可观测性平台已开始将其作为原生功能发布。

固件与实时响应之间的对抗性变异器 (Adversarial mutators)。你故意生成接近失败的实时响应:删除一个字段,在 Schema 声明字段不可为空的地方注入 null,将整数换成字符串,返回空数组而不是有内容的数组,返回比 Agent 上下文窗口所能容纳的更多的行。针对变异后的响应运行 Agent。断言它做出了正确的反应——通常是以下之一:产生结构化错误、请求澄清或使用备用路径。像 ToolFuzz 这种框架和各种 LLM 模糊测试工具正是这样做的;诀窍是将它们接入 CI,而不是仅在设计时运行一次。

针对供应商分段环境运行的 CI 流水线。捕捉破坏性变更的最佳位置是在它发布之前。大多数拥有复杂 API 的 B2B 供应商都会发布分段 (Staging) 或沙盒环境,并对版本进行管理。一个指向供应商分段环境、回放你的黄金追踪并在 Slack 频道发布差异的每晚契约任务,能将一个持续四天的无声回归变成当天的“他们的下一个版本会破坏我们的采购工具,去提交工单”。这不是理论——这是每个严肃的微服务公司十年来的运作方式。我们只是还没有将这种准则移植到 Agent 技术栈中。

团队会在哪里出错

来自实战的一些失败模式。

将幻觉出的字段名归咎于模型。你的团队看到 Agent 引用了一个实时工具并未返回的 total_count 字段,于是有人针对模型提交了一个幻觉工单。然后有人深入研究追踪记录,发现 total_count 在上周还由工具返回,但在没人阅读的供应商补丁版本中被重命名为了 count。模型并没有产生幻觉;它是根据一个被数月一致的工具响应隐式扩展的系统提示词工作的。修复方法不是换个更好的模型,而是一个本来可以使供应商发布失败的契约。

在错误的层进行断言。想到要增加工具响应验证的团队,通常会在解析层编写断言:“响应是 JSON 且包含 results 键”。这能捕捉到灾难性的破坏,但捕捉不到有趣的情况。你想要的契约更接近于“如果请求是 query=X,则响应至少有一行其 region 字段等于 Y”,因为这才是 Agent 实际进行推理的层级。

契约是文档,而非门禁。契约存在仓库里,没有 CI 运行它,Schema 发生了偏移,三个工程师在故障复盘期间分别重新发现了它。如果契约不能让构建失败,那它就不是契约——它只是个民间传说。

假装工具是确定性的。某些工具——搜索、推荐,任何带有排名的东西——在设计上就是非确定性的。将它们的响应视为你可以固定契约的静态 Schema 是正确的;但将它们的响应内容视为固定值则不正确。将 Schema 契约(始终强制执行)与内容断言(基于样本、模糊匹配或跳过)分开。

值得一做的架构赌注

如果你从中只能内化一件事,请记住:每一次工具响应(tool response)都是一次无声的提示词变更(prompt mutation)。认为“工具调用是 I/O”的心智模型在字节层面是正确的,但在认知层面是错误的。在认知层面,你的工具返回的 schema 在规划器(planner)运行的每一步都会被融合到其工作上下文中。一次字段重命名就是一次未经批准的提示词编辑。

微服务中的契约测试(contract testing)之所以兴起,是因为违反契约的代价是客户在几秒钟内就能察觉到的 500 错误。在智能体(agent)时代,其代价是一个悄无声息地退化的答案,它以机器速度传播,即便能被发现,也是在几天之后。因此,尽管这一领域的工程规范尚不成熟,但在智能体中投资工具契约的经济动力比在微服务中更高

本周一个合理的切入点是:选择一个你的智能体最依赖的工具,记录下它实际从响应中读取的 4 到 6 个字段,生成一个 fixture,配置一个 CI 任务每晚针对线上工具回放黄金追踪(golden trace),并添加三个对抗性变异器(adversarial mutators),分别对最核心的字段进行删除、置空和类型转换。你会在用户发现之前捕获到回归(regression)。之后,对下一个工具重复此操作。你最终构建的平台看起来非常像针对智能体的 Pact,而有人会在今年结束前构建出它的开源版本。

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