# API schema 变化：用契约测试让智能体更安全

智能体把一次小小的 API 变化变成真实操作错误的速度，可能比人工操作的客户端还快。重命名某个响应字段，可能让它选择所有资源，而不是其中一个。新加入的默认值可能扩大查询范围。状态对象发生变化后，可能看起来像是允许重试，而这次重试又可能重复扣款、部署或删除请求。

危险之处在于，这些故障在传统监控中往往看起来一切正常。提供商返回 HTTP 200，客户端没有崩溃，智能体也给出了看似合理的解释。契约测试需要检查智能体赋予 API 数据的含义，而不能只检查 JSON 是否能解析。

## 智能体的决策让兼容性成为安全属性

人工使用的 API 客户端在响应变化后，通常会明显失败。按钮显示空表格，表单出现错误，然后有人会在采取下一步操作前进行调查。智能体则常常把响应直接转换成另一个请求。在任何人看到对话记录前，它可能已经完成了多次转换。

考虑一个清理智能体。它调用 `GET /projects?state=inactive`，读取每个项目的 `owner` 字段，并在归档不在允许列表中的项目之前请求批准。之后，提供商把 `owner` 改成了 `owner_id`，但保留旧端点和状态码。宽松的解析器把缺失的 `owner` 映射为空字符串。如果智能体的规则把空的 owner 视为未分配，它就会准备一个范围大得多的归档请求。

这不是身份验证失败，也不是提示词失败，而是 API 边界处的解释失败。纠正措施也应该放在同一个边界上。

凡是会影响以下决策的字段，都应视为安全契约的一部分：

- 智能体可以操作哪个对象
- 对象是否符合操作条件
- 操作的范围、金额或目标
- 上一次操作已完成、失败，还是需要重试
- 智能体是否应在继续之前询问人员

这一点很重要，因为传输兼容性远弱于行为兼容性。服务可以保留端点、方法、身份验证方案和 JSON 语法，却破坏响应之后的决策。团队常常因为现有 SDK 仍能完成反序列化，就把这种变化称为非破坏性变化。对于自主调用者来说，这个标签可能危险地不完整。

建立一张小表，列出所有会传给操作选择器、授权决策或重试分支的响应值。不要从一份庞大的 API 规范中的每个属性开始。先关注那些一旦被错误解释，就会改变智能体行为的值。

## Schema 差异无法证明行为兼容

Schema 差异工具能捕获有用的变化，却无法告诉你某个变化对特定智能体是否安全。它们比较的是声明，而智能体依赖的语义往往并不在这些声明中。

假设提供商把 `limit` 从一个默认值为 100 的可选参数改成默认值为 1000 的可选参数。标准 OpenAPI 差异可能显示必需属性没有变化。然而，省略 `limit` 的智能体现在可以检查十倍数量的对象，并可能对所有对象发起批量操作。

OpenAPI Specification 将 Schema Object 描述为 JSON Schema 词汇的超集，并说明其属性为请求和响应负载提供信息。这对文档和验证很有用，但它不会说明 `state: "pending"` 是否允许重试，也不会说明省略 `limit` 后是否仍限制为 100。这些属于工作流断言，测试必须用清晰的语言写出来。

同样，JSON Schema 的 `default` 注解并不会强制验证器或客户端插入某个值。很多开发者以为它会这样做。JSON Schema 文档把 `default` 视为注解数据，而不是改变实例的命令。如果安全性依赖某个值，就让客户端明确发送它，并测试准确的出站请求。不要指望 schema 注解来弥补一次省略。

把差异工具当作警报。然后根据每个变化可能影响的操作路径进行分类：

- 重命名标识符可能改变选中的目标。
- 新增枚举值可能让解析器进入未经测试的分支。
- 默认值变化可能在请求代码不变的情况下扩大范围。
- 表示方式变化可能颠倒完成或失败的含义。

反过来也同样重要。差异工具可能报告新增了一个任何智能体都不读取的描述字段。这需要审查，但不值得冻结生产发布。当兼容性审查追踪数据如何进入决策，而不是把 schema 的每一行都视为同等风险时，审查质量会更好。

## 契约测试必须固定请求和决策

有用的契约测试包含两部分：先验证智能体实际发送的请求，再验证它读取提供商响应后提出的操作。只测试其中一部分，会留下很大的盲区。

对于默认值，应在本地测试服务器或提供商沙盒中捕获真实 HTTP 请求。下面的 Python 示例使用 `httpx.MockTransport` 检查出站请求。它能避免一种常见故障：客户端默默依赖提供商默认值来执行破坏性操作。

```python
import httpx

seen = []

def handler(request: httpx.Request) -> httpx.Response:
    seen.append({
        "method": request.method,
        "path": request.url.path,
        "query": dict(request.url.params),
    })
    return httpx.Response(200, json={"items": []})

client = httpx.Client(transport=httpx.MockTransport(handler))

response = client.get(
    "https://api.example.test/projects",
    params={"state": "inactive", "limit": "100"},
)

assert response.status_code == 200
assert seen == [{
    "method": "GET",
    "path": "/projects",
    "query": {"state": "inactive", "limit": "100"},
}]
```

重要的断言不是 200 响应，而是明确的 `limit`。如果重构删除了这个参数，测试会在提供商的新默认值扩大选择范围之前失败。

接着测试决策。让智能体的规划函数与执行 HTTP 调用的代码分开，这样测试就能检查拟议操作，而不会真的执行它。

```python
from dataclasses import dataclass

@dataclass
class ArchivePlan:
    project_ids: list[str]
    requires_approval: bool

def plan_archives(items: list[dict], allowed_owners: set[str]) -> ArchivePlan:
    targets = []
    for item in items:
        owner = item.get("owner")
        if owner is None:
            raise ValueError("provider response lacks owner")
        if item["state"] == "inactive" and owner in allowed_owners:
            targets.append(item["id"])
    return ArchivePlan(targets, requires_approval=bool(targets))

items = [
    {"id": "p17", "state": "inactive", "owner": "team-a"},
    {"id": "p18", "state": "inactive", "owner": "team-b"},
]

plan = plan_archives(items, {"team-a"})
assert plan.project_ids == ["p17"]
assert plan.requires_approval is True
```

这个测试做出了宽松代码常常回避的选择：缺失 `owner` 就抛出错误。对于用于选择操作的字段，应采取默认拒绝。返回空字符串、`None` 或猜测出来的备用值，也许能让流程继续，但它会把可检测的集成故障替换成可能不安全的计划。

让测试夹具保持足够小，确保审查者能看出每个对象为什么出现。包含一千个对象的夹具也许更像生产环境，却会掩盖你真正想保护的条件。

## 重命名字段需要明确的失败行为

字段重命名后，除非你在明确规定的迁移期间有意支持两个名称，否则应停止相关操作路径。演示中无声的备用处理看起来很有韧性，在生产环境却会制造未经审查的语义。

最糟糕的写法如下：

```python
owner = item.get("owner", "")
if owner not in blocked_owners:
    archive(item["id"])
```

当 `owner` 消失时，每个对象看起来都有一个不在阻止列表中的 owner。解析器完全按照代码要求工作。写下这段代码的工程师很可能只是想避免 `KeyError`。这个小便利却把数据缺失转换成了执行操作的许可。

为三种情况编写测试：预期字段、临时兼容承诺中的旧字段，以及两个字段都不存在。第三个测试应明确智能体是停止、跳过对象，还是请求澄清。对于目标身份、授权状态和操作范围，通常应该停止。

如果支持别名，应让优先级清晰，并明确它只是暂时的：

```python
def read_owner(item: dict) -> str:
    if "owner" in item:
        return item["owner"]
    if "owner_id" in item:
        return item["owner_id"]
    raise ValueError("owner identity missing")
```

这段代码还需要测试矛盾数据。如果两个字段同时到达且值不一致，不要静默地优先使用其中一个。应抛出错误，让提供商解决歧义。兼容层应该保留已知的旧含义，而不是为不一致的记录凭空决定优先级。

团队有时会说，宽松解析可以防范提供商的演进。忽略未知字段时，它确实能应对无害的新增字段。但它无法应对决定操作的字段缺失。此时应采取相反的行为：默认接受额外信息，但拒绝缺失的必要含义。

## 默认值和省略需要分开测试

省略属性、明确传入 null 和明确传入某个值，是三种不同的请求。通用序列化器也经常把它们混为一谈，因此智能体很容易受到影响。

请求构建器可能在内部值为 `None` 时省略 `dry_run`。提供商可能把省略解释为 `false`。之后的版本可能把省略改成「使用账户设置」，而某个租户的账户设置恰好是 false，另一个租户则是 true。智能体代码没有变化，操作却变化了。

将会影响决策的选项分成两类。对于已知安全行为的选项，每次都发送明确值。对于需要操作员选择的选项，在构建请求前必须完成选择。对于破坏性操作或对外可见的操作，不要设置第三类「让服务器决定」。

使用一组精确用例测试序列化。重点是检查线路上的表示，而不只是序列化前的语言对象。

| 意图 | 出站表示 | 预期的提供商含义 |
| --- | --- | --- |
| 读取未激活项目 | `state=inactive&limit=100` | 有边界的选择范围 |
| 模拟归档 | `{"dry_run": true}` | 不会执行归档 |
| 归档一个项目 | `{"project_ids":["p17"],"dry_run": false}` | 只有 p17 可以发生变化 |
| 没有操作员对模式的选择 | 请求在本地被拒绝 | 提供商不会收到任何内容 |

分页也要同样严格。如果响应新增 `next_cursor`，可能会诱使智能体自动获取所有页面。对于报告来说，这也许合理；对于操作规划器来说，却可能不负责任。测试规划器最多可以考虑多少个对象，以及允许请求第二页的条件。游标是继续获取的机制，不是无限扩大范围的许可。

响应中的提供商默认值也很重要。如果 API 开始在 `archivable` 为 false 时省略该字段，类似 `if item.get("archivable", True)` 的代码就会朝不安全的方向改变行为。对于授予权限的字段，应使用 `item.get("archivable") is True` 这样的明确比较。它不那么简洁，却更容易审计。

## 响应验证必须保留含义，而不只是结构

响应验证应区分格式错误的数据和陌生但无害的数据。无差别的严格性会在提供商新增字段时破坏客户端；无差别的宽松性则会把证据缺失变成猜测。

围绕会影响下一步操作的值定义一个狭窄的响应模型。对每个值规定类型、允许的状态，以及缺失时是否停止工作流。项目标识符不应只有 `string` 这一要求，智能体还需要一个非空且稳定的标识符，并且它必须与稍后发送到归档请求中的标识符一致。状态也不应只有 `string` 这一要求，智能体需要一个枚举状态，并为每个成员规定操作。

例如，下面的解析器处理了状态表示变化，却不会擅自授予重试权限：

```python
ALLOWED_STATES = {"queued", "running", "succeeded", "failed"}

def retryable(job: dict) -> bool:
    status = job.get("status")
    if status not in ALLOWED_STATES:
        raise ValueError(f"unknown job status: {status!r}")
    return status == "failed" and job.get("retry_allowed") is True
```

如果提供商把 `status` 从字符串改成类似 `{"phase":"failed"}` 的对象，这段代码会停止。直到有人决定新表示如何映射到旧工作流之前，中断是正确的。如果提供商新增 `cancelled`，在团队决定取消是终止、可重试，还是需要人工处理之前，停止同样是正确的。

对于只读显示，不要把每个未知枚举值都当作紧急事件。后果应与操作相匹配。报告型智能体可以标记陌生状态并继续；会重试计费任务或删除资源的智能体，则必须在处理未知状态前停止。

还要测试字段之间的关系。响应在结构上可能有效，却包含自相矛盾的组合，例如 `status: "succeeded"` 和 `retry_allowed: true`。Schema 验证通常无法表达所有业务不变量。契约测试应断言，成功的任务不会产生重试计划，不论是否出现了错误的布尔值。

## 测试完整的操作路径，而不是方便的模拟

解析器的单元测试必不可少，但它们无法证明已部署的智能体会通过真实的凭据和执行路径发送预期请求。序列化库、包装器、工具适配器和重试中间件都可能改变行为，而直接调用函数无法揭示这些变化。

在 CI 中运行本地契约服务器，记录请求并返回有版本的夹具。将智能体的工具配置指向该服务器。测试应驱动一条真实的指令，等待拟议计划或操作记录，然后断言记录的完整顺序：方法、路径、查询参数、可安全检查的请求头、请求体和操作次数。

不要在这个环境中放入真实密钥。使用没有任何权限的测试凭据，并验证智能体不会在提示词、工具结果、异常文本或追踪信息中收到它。粗心记录请求头的测试，可能重新制造它原本要防止的凭据泄露。

对于通过 Sallyport 发起外部 HTTP 或 SSH 调用的智能体，操作路径可以在让凭据远离智能体的同时保留可检查的结果。但这个边界无法修复对响应的错误解释，因此在允许外部操作前，仍应先运行 schema 契约和决策契约。

不要只加入黄金响应，也要加入失败夹具。输入提供商在切换期间确实会产生的条件：缺失选择字段、新枚举成员、带有继续游标的空结果、内容类型变化，以及包含错误对象的 200 响应。较旧的 API 中，200 错误体尤其常见。如果解析器假设每个 200 响应都包含成功结构，它可能制造一个空计划，或重试一个其实已经成功的请求。

测试重试时，断言幂等行为。让契约服务器先记录请求，然后在响应超时；接着对重试返回第二个响应。测试必须证明，在 API 支持的情况下客户端会发送幂等令牌；如果无法知道第一次操作是否完成，则会停止并请求确认。重试读取通常无害，但重试转账、邮件、部署或删除请求并非如此。

## 发布门禁应阻止语义破坏

无论是 API 提供方还是智能体消费者，都应在变更路径中运行 schema 差异、提供商契约测试和智能体决策测试。只有部署后才运行它们的发布流程，会把测试变成事故文档。

对于提供商变更，应要求一份审查记录，回答四个具体问题：哪些消费者假设发生变化，旧行为是什么，两种行为会同时支持多久，以及哪个夹具展示了新行为。相比在日志不完整的生产回归中争论，这需要的文书工作更少。

对于智能体变更，合并前运行现有的提供商夹具套件。如果智能体开始使用新字段，就为字段缺失和偏离正常路径的值添加夹具。提示词措辞变化也可能改变工具参数，因此要测试完整智能体发出的工具调用，不要假设规划器会继续选择相同参数。

有版本的夹具能让审查变得切实可行。将类似 `projects-list-v1` 的标识符与预期请求和响应对一起保存。当提供商有意引入 `projects-list-v2` 时，在迁移政策结束前保留旧夹具。不要覆盖旧 JSON 后声称测试仍是最新的。这样会丢失你放弃了哪些兼容性的证据。

有用的门禁应使用运营语言报告失败。「缺少预期字段 owner，归档规划已停止」能告诉审查者发生了什么。「路径 items.0 处出现 ValidationError」虽然比什么都没有好，却会迫使审查者在发布期间自行重建风险。

## 审批页面无法纠正误导性的计划

人工审批仍是外部操作的良好控制，但如果智能体根据变化后的契约构建了错误计划，审批来得太晚。看到「归档 847 个未激活项目」的人可能会拒绝它；但看到「归档项目 p17」的人无法判断 p17 是来自缺失的 owner 字段、扩大的默认值，还是把 `cancelled` 混同为 `failed` 的解析器。

让审批记录包含值得人工检查的决策输入：目标标识符、数量、请求模式，以及使目标符合条件的响应字段。记录应保持简洁。把原始 JSON 倾倒给审批者，只是把解析任务从代码转移给了疲惫的人。

另外保留一条能让工程师重建操作的追踪记录。捕获提供商响应或其受保护的摘要、解析器版本、契约夹具版本、生成的请求和最终响应。防篡改审计轨迹有助于事后调查，但它应指向决策边界，而不只是记录发生过一次 HTTP 调用。

下次 API 团队说某个响应变化只是外观变化时，让他们针对这个变化运行智能体契约套件。如果套件失败，说明这个变化带有实际行为。应先修复它，再让一个礼貌的 200 响应变成不安全的操作。
