Agent 调用工具时,看起来只是模型生成一段 JSON,服务端解析参数后执行函数。很多团队因此把 Tool Schema 当成提示词附件:字段不够就加一个,名称不顺眼就改掉,返回值多包一层也觉得调用方"应该能理解"。真正上线后,Schema 却是一份跨越模型、编排器、工具网关和业务服务的协议。它一旦发布,就会被缓存、写进追踪记录、固化在回归样本里,还可能被多个版本的客户端同时使用。
最典型的事故是给查询订单工具增加一个必填的 region。新编排器知道这个字段,预发布环境也能通过;线上仍有旧会话、旧 Worker 或缓存的工具定义,它们继续发送只有 order_id 的请求,于是请求集中失败。更麻烦的是,模型有时会"聪明地"猜一个地区,让错误从显式失败变成查错数据。修复重点不是再写一句"请一定提供地区",而是承认工具已经形成协议,并用版本、校验和契约测试管理它。
本文用一个订单查询工具贯穿完整链路:定义兼容边界,比较新旧 JSON Schema,保存消费者契约,运行提供方验证,灰度观测,再决定扩大流量或回滚。示例使用 Python 3.11 及 jsonschema 4.23;版本只是可复现环境,不是文章结论的前提。创建虚拟环境后执行 python -m pip install "jsonschema[format]==4.23.0" 即可运行本文契约脚本,其中 format 额外依赖用于真正检查 date-time,而不只是把它当作注解。
1. 一次工具调用经过哪些边界
工具调用并非"模型直接执行函数"。通常至少经过工具清单发布、模型选工具、参数生成、编排器校验、网关鉴权、业务服务执行和结果回传七个阶段。任何一层缓存了旧定义,都可能形成新旧协议并存。只升级业务函数而不升级工具描述,会让模型按旧契约生成;只升级工具描述而不兼容旧请求,会让存量调用失败。
#mermaid-svg-shWvUzUtzJqESejI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-shWvUzUtzJqESejI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-shWvUzUtzJqESejI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-shWvUzUtzJqESejI .error-icon{fill:#552222;}#mermaid-svg-shWvUzUtzJqESejI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-shWvUzUtzJqESejI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-shWvUzUtzJqESejI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-shWvUzUtzJqESejI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-shWvUzUtzJqESejI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-shWvUzUtzJqESejI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-shWvUzUtzJqESejI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-shWvUzUtzJqESejI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-shWvUzUtzJqESejI .marker.cross{stroke:#333333;}#mermaid-svg-shWvUzUtzJqESejI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-shWvUzUtzJqESejI p{margin:0;}#mermaid-svg-shWvUzUtzJqESejI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-shWvUzUtzJqESejI .cluster-label text{fill:#333;}#mermaid-svg-shWvUzUtzJqESejI .cluster-label span{color:#333;}#mermaid-svg-shWvUzUtzJqESejI .cluster-label span p{background-color:transparent;}#mermaid-svg-shWvUzUtzJqESejI .label text,#mermaid-svg-shWvUzUtzJqESejI span{fill:#333;color:#333;}#mermaid-svg-shWvUzUtzJqESejI .node rect,#mermaid-svg-shWvUzUtzJqESejI .node circle,#mermaid-svg-shWvUzUtzJqESejI .node ellipse,#mermaid-svg-shWvUzUtzJqESejI .node polygon,#mermaid-svg-shWvUzUtzJqESejI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-shWvUzUtzJqESejI .rough-node .label text,#mermaid-svg-shWvUzUtzJqESejI .node .label text,#mermaid-svg-shWvUzUtzJqESejI .image-shape .label,#mermaid-svg-shWvUzUtzJqESejI .icon-shape .label{text-anchor:middle;}#mermaid-svg-shWvUzUtzJqESejI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-shWvUzUtzJqESejI .rough-node .label,#mermaid-svg-shWvUzUtzJqESejI .node .label,#mermaid-svg-shWvUzUtzJqESejI .image-shape .label,#mermaid-svg-shWvUzUtzJqESejI .icon-shape .label{text-align:center;}#mermaid-svg-shWvUzUtzJqESejI .node.clickable{cursor:pointer;}#mermaid-svg-shWvUzUtzJqESejI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-shWvUzUtzJqESejI .arrowheadPath{fill:#333333;}#mermaid-svg-shWvUzUtzJqESejI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-shWvUzUtzJqESejI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-shWvUzUtzJqESejI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-shWvUzUtzJqESejI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-shWvUzUtzJqESejI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-shWvUzUtzJqESejI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-shWvUzUtzJqESejI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-shWvUzUtzJqESejI .cluster text{fill:#333;}#mermaid-svg-shWvUzUtzJqESejI .cluster span{color:#333;}#mermaid-svg-shWvUzUtzJqESejI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-shWvUzUtzJqESejI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-shWvUzUtzJqESejI rect.text{fill:none;stroke-width:0;}#mermaid-svg-shWvUzUtzJqESejI .icon-shape,#mermaid-svg-shWvUzUtzJqESejI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-shWvUzUtzJqESejI .icon-shape p,#mermaid-svg-shWvUzUtzJqESejI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-shWvUzUtzJqESejI .icon-shape .label rect,#mermaid-svg-shWvUzUtzJqESejI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-shWvUzUtzJqESejI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-shWvUzUtzJqESejI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-shWvUzUtzJqESejI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Schema + 版本
tool_call JSON
本地校验
鉴权与限流
结构化结果
结果进入下一轮
验证旧请求
验证新响应
Tool Registry
模型或规划器
Agent 编排器
工具网关
订单服务
契约测试
Schema 至少包含输入和输出两部分。输入约束模型能提交什么,输出约束后续节点能消费什么。只管理输入而让输出自由变化同样危险:把 status: "paid" 改成 status: {"code": "paid"},HTTP 仍然成功,下游判断却可能全部走到未知分支。工具描述也属于契约,因为模型会据此决定是否调用,但描述变化通常是行为兼容性问题,不能只靠结构校验发现。
2. 兼容性不是"JSON 还能解析"
兼容要从消费者视角判断。旧消费者面对新提供方仍能工作,叫向后兼容;新消费者仍能调用旧提供方,叫向前兼容。发布 Tool Schema 时最常要求的是前者:已经在运行的 Agent、历史会话和第三方集成不能立刻升级,所以新服务必须继续接受旧请求,并返回旧消费者能理解的结果。
输入 Schema 与普通 API 响应有一个容易混淆的方向。对输入增加可选字段通常兼容,因为旧请求仍合法;增加必填字段、缩窄枚举、提高最小值、禁止此前允许的附加字段,通常破坏兼容。输出则相反:新增消费者会忽略的可选字段通常安全,但删除字段、改变类型、把既有枚举值改名都会破坏消费方。若消费者使用严格反序列化,输出新增字段也可能失败,因此契约测试必须记录真实消费者行为,不能只凭理论判定。
下面这张表适合作为代码评审的第一道门:
| 变更 | 输入兼容性 | 输出兼容性 | 建议 |
|---|---|---|---|
| 新增非必填字段 | 通常兼容 | 通常兼容 | 小版本,仍跑契约测试 |
| 新增必填字段 | 破坏 | 不适用 | 新主版本或服务端提供默认值 |
| 删除字段 | 可能兼容但模型可能仍发送 | 破坏 | 先弃用,再分阶段移除 |
| string 改 object | 破坏 | 破坏 | 新版本并行 |
| 扩大输入枚举 | 服务端兼容 | 旧服务不兼容 | 确认路由和回滚能力 |
| 缩小输入枚举 | 破坏 | 不适用 | 拒绝原地发布 |
| 修改字段语义 | Schema 无法判断 | Schema 无法判断 | 契约样例与业务断言 |
3. 先把工具定义变成可版本化制品
Schema 不应散落在提示词、Python 类型和网关配置三处分别维护。选择一个事实源,构建时生成或校验其他表示,并给制品计算摘要。注册表至少保存工具稳定标识、协议版本、Schema 方言、输入输出 Schema、描述、负责人、发布时间和弃用日期。工具名不要塞入不断变化的内部部署编号,否则模型会把 get_order_v17 当成另一项能力。
JSON Schema 必须显式声明方言。例如 Draft 2020-12 的 $schema URI 可以防止校验器按旧草案猜测关键词含义。对象边界建议明确 additionalProperties 或 unevaluatedProperties 策略:内部高风险工具可严格拒绝未知字段;面向多版本消费者的公共工具,则要谨慎评估严格模式是否阻碍渐进升级。
json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/tools/get-order/input/1-1-0",
"type": "object",
"properties": {
"order_id": {"type": "string", "pattern": "^ORD-[0-9]{8}$"},
"include_items": {"type": "boolean", "default": false},
"region": {"type": "string", "enum": ["cn", "sg"], "default": "cn"}
},
"required": ["order_id"],
"additionalProperties": false
}
这里 region 有默认值但没有进入 required。要注意,JSON Schema 的 default 通常只是注解,校验器不会自动把值写进实例。真正兼容旧请求,需要服务端适配层显式补默认值,或者业务函数本身将缺省值解释为稳定语义。把希望寄托在 default 关键词自动生效,会造成测试环境和生产网关行为不同。
4. 语义版本如何映射到工具协议
可以借用 Semantic Versioning 的主版本、次版本和补丁版本,但必须先写清本团队对"公共 API"的定义。主版本用于消费者必须修改才能继续工作的变更;次版本用于向后兼容的能力增加;补丁版本用于不改变契约的修复。它不是看到字段数量变化就机械加数字,而是表达兼容承诺。
Tool Schema 的语义还受模型行为影响。给描述新增一句"优先查询最近订单"虽然没有改 JSON,却可能显著改变调用频率和参数分布。这类变更至少应升级制品版本、运行离线轨迹回放并灰度,不能以"只是文案"绕过发布流程。模型版本、系统提示和 Schema 版本应同时写入 trace,事故发生后才能回答某次调用看到的究竟是哪份定义。
建议把工具稳定 ID 与版本分开:urn:company:tool:get-order 表示能力,1.1.0 表示契约制品。客户端声明自己接受的主版本,注册表返回该主版本下最新兼容版本。若发布 2.0,应让 1.x 与 2.x 在迁移期并存,并分别统计调用量;不要让同一个 URL 在没有协商的情况下突然改变字段含义。
5. 用代码做保守的 Schema 差异检查
完整判断两个 JSON Schema 的包含关系很复杂,组合关键词、引用和条件分支都会让问题接近通用逻辑推理。生产上可以采用"保守检查器":能确定安全的变更放行,已知破坏变更阻断,无法判断的变更要求人工评审和契约测试。它宁可多拦一次,也不要给出虚假的兼容保证。
下面的脚本覆盖对象工具最常见的必填字段、字段删除、类型、枚举和附加属性变化。它不处理 $ref、oneOf 与条件 Schema,因此遇到这些关键词应转人工,而不是假装通过。
python
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class Change:
path: str
level: str
message: str
def compare_input(old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
changes: list[Change] = []
unsupported = {"$ref", "oneOf", "anyOf", "allOf", "if", "then", "else"}
if unsupported.intersection(old) or unsupported.intersection(new):
return [Change("$", "review", "存在组合或引用关键词,需要人工评审")]
old_required = set(old.get("required", []))
new_required = set(new.get("required", []))
for name in sorted(new_required - old_required):
changes.append(Change(f"$.{name}", "breaking", "新增必填字段"))
old_props = old.get("properties", {})
new_props = new.get("properties", {})
for name, old_rule in old_props.items():
if name not in new_props:
changes.append(Change(f"$.{name}", "breaking", "删除已发布输入字段"))
continue
new_rule = new_props[name]
if old_rule.get("type") != new_rule.get("type"):
changes.append(Change(f"$.{name}", "breaking", "字段类型变化"))
old_enum = set(old_rule.get("enum", []))
new_enum = set(new_rule.get("enum", []))
if old_enum and new_enum and not old_enum.issubset(new_enum):
changes.append(Change(f"$.{name}", "breaking", "输入枚举被缩窄"))
if old.get("additionalProperties", True) and not new.get(
"additionalProperties", True
):
changes.append(Change("$", "breaking", "开始拒绝未知字段"))
return changes
def demo() -> None:
old = {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"],
}
new = {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"region": {"type": "string"},
},
"required": ["order_id", "region"],
}
result = compare_input(old, new)
assert any(item.level == "breaking" for item in result)
if __name__ == "__main__":
demo()
差异检查应运行在拉取请求和发布流水线两处。拉取请求给开发者快速反馈;发布流水线重新从制品仓库读取上一个线上版本,避免分支比较基准过旧。检查结果也要存档,这样一次紧急豁免不是口头决定,而是有负责人、原因和到期时间的审计事件。
6. 契约测试测试的到底是什么
单元测试回答"提供方是否按自己的实现工作",契约测试回答"提供方是否仍满足真实消费者依赖"。消费者契约不应把整个响应做脆弱快照,而要明确记录自己依赖的字段、类型、状态和错误语义。例如客服 Agent 只依赖 order_id、status 和 updated_at,就不应因为响应多了 warehouse_note 而失败。
一份有效契约包含请求前置条件、具体请求、期望响应结构和业务断言。错误路径同样是契约:订单不存在返回稳定的 ORDER_NOT_FOUND,权限不足返回 FORBIDDEN,超时是否允许重试。若提供方把所有错误改成 TOOL_ERROR,JSON 仍符合 Schema,但消费者无法做正确决策。
契约来源有三类。第一类是代码中由消费者维护的例子,清楚但可能滞后;第二类是生产 trace 脱敏后的代表样本,真实但必须治理隐私;第三类是产品规则生成的边界样本,覆盖高风险条件。三者组合比"随机让模型调用一百次"可靠,因为随机调用很难稳定覆盖旧版本和罕见错误。
7. 一个可运行的消费者契约测试
下面不引入专用平台,只用 jsonschema 和一个提供方函数展示核心机制。契约固定消费者真正依赖的响应子集,并验证旧请求在新实现上仍成功。实际项目可以把 provider 换成 FastAPI TestClient 或预发布地址。
python
from __future__ import annotations
from typing import Any
from jsonschema import Draft202012Validator
RESPONSE_SCHEMA = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"order_id": {"type": "string"},
"status": {"type": "string", "enum": ["created", "paid", "shipped"]},
"updated_at": {"type": "string", "format": "date-time"},
},
"required": ["order_id", "status", "updated_at"],
}
def provider(arguments: dict[str, Any]) -> dict[str, Any]:
order_id = arguments["order_id"]
region = arguments.get("region", "cn")
if region not in {"cn", "sg"}:
return {"error": {"code": "REGION_NOT_SUPPORTED", "retryable": False}}
return {
"order_id": order_id,
"status": "paid",
"updated_at": "2026-09-28T08:00:00Z",
"region": region,
}
def verify_consumer_contract() -> None:
old_request = {"order_id": "ORD-20260928"}
response = provider(old_request)
validator = Draft202012Validator(
RESPONSE_SCHEMA,
format_checker=Draft202012Validator.FORMAT_CHECKER,
)
errors = sorted(validator.iter_errors(response), key=lambda item: list(item.path))
assert not errors, [item.message for item in errors]
assert response["status"] in {"created", "paid", "shipped"}
assert response["region"] == "cn"
if __name__ == "__main__":
verify_consumer_contract()
注意响应 Schema 没有把 additionalProperties 设为 false,因为这个消费者允许提供方增加字段。若消费者语言的反序列化器默认拒绝未知字段,测试必须使用同一个反序列化器,而不是在 Python 中构造一个更宽松的替身。契约测试的价值来自贴近真实消费路径,而不是测试文件数量。
8. 模型参与后,契约测试还缺一层行为验证
结构测试只能证明 JSON 合法,不能证明模型会在正确场景选择正确工具。描述改动、字段示例、枚举名称甚至工具排列顺序,都可能改变模型选择。因此需要保存一组脱敏对话轨迹,固定模型版本与温度,对比升级前后的工具选择率、必填参数完整率、无关调用率和高风险误调用率。
行为评测不能要求每次自然语言完全一致。断言应落在可观察动作上:是否调用 get_order,参数中的订单号是否来自用户,缺少订单号时是否追问,而不是臆造;若用户明确只问退款政策,就不应读取具体订单。对于非确定模型,至少重复运行若干次并给关键指标设置置信区间,避免一次偶然通过掩盖退化。
还要防止评测数据进入系统提示或微调集。否则模型可能记住固定题目,线上新表达仍然失败。保留一部分从未进入开发环境的隐藏契约样本,并定期从真实失败中补充新样本。公开测试负责快速反馈,隐藏测试负责防止团队围绕已知样例过拟合。
9. 发布顺序决定迁移是否安全
兼容升级应按"先提供能力,再发布声明,最后使用能力"的顺序。先让服务端接受可选 region 并稳定默认值;再发布带该字段的新 Schema;观察模型能正确生成后,客户端才开始依赖它。回滚顺序相反:先停止消费者依赖新字段,再撤销 Schema,最后才移除服务端能力。
若字段最终必须变成必填,不要一步到位。第一阶段可选且服务端记录缺失率;第二阶段新消费者主动提供,监控旧调用占比;第三阶段对仍缺失的内部调用发告警;确认存量清零后,在新的主版本中设为必填。迁移窗口的长度由会话寿命、Worker 更新周期和第三方 SLA 决定,不由开发者希望多快上线决定。
灰度单元不能只按服务器比例。更有意义的是按租户、Agent 版本或会话稳定分桶,确保同一会话不会前后看到两份不兼容 Schema。每个桶记录 tool_id、Schema 摘要、模型版本、调用结果和错误码,出现问题才能准确回放。
10. 双版本并存时如何路由
主版本变化时,最简单可靠的办法是两个明确端点或两个工具版本并行,例如稳定能力 ID 加 major=1 与 major=2。网关根据客户端声明路由,不根据参数"猜版本"。如果看到 region 就当 v2、没看到就当 v1,一旦字段可选或模型漏填,路由会不可预测。
适配器只应做可证明无损的转换。把 v1 缺失地区补成业务长期定义的默认区是可接受的;根据订单号前缀猜地区,若没有强约束,就会把协议错误变成静默数据错误。无法无损转换时应返回明确错误并要求消费者升级。
双写或双读也要谨慎。查询类工具可以在影子流量中同时调用新旧实现并比较结果,但不能把两份包含敏感数据的响应都写进普通日志。写操作默认不能影子执行,否则可能发两封邮件或创建两笔退款。对写工具应使用干运行接口、隔离沙箱或只比较校验阶段。
11. 可观测性必须能回答"谁看到了哪份 Schema"
工具错误率升高时,只知道工具名不够。一次 trace 至少记录稳定工具 ID、Schema 版本与摘要、Agent 发布版本、模型版本、请求参数的脱敏摘要、校验阶段、提供方版本、耗时和稳定错误码。Schema 正文可以放制品仓库,trace 保存内容摘要,既能关联又避免重复存储敏感描述。
指标需要按版本切分。总体成功率可能被大流量旧版本稀释,而新版本已经大面积失败。关键指标包括参数校验失败率、未知字段率、缺失字段率、工具选择率、业务失败率、P95 延迟和旧主版本调用占比。对于高风险工具,还应统计需要人工确认的比例和策略拒绝率。
告警阈值不要只看 HTTP 500。Schema 不兼容经常表现为 400、422 或模型根本不发起调用。若升级后调用率突然下降而用户意图不变,同样可能是描述或参数变复杂导致的回归。把离线轨迹中的意图标签与线上工具调用率关联,才能发现这种"安静失败"。
12. 常见失败模式与处理方式
第一种失败是模型仍发送被删除字段。服务端若立即严格拒绝,旧会话失败;若永久忽略,团队永远不知道迁移未完成。更好的做法是在兼容窗口内接受并记录弃用字段,向内部调用方返回弃用元数据,到期后仅在新主版本移除。
第二种失败是枚举扩展后旧消费者遇到未知值。例如状态新增 partially_shipped,旧 Agent 可能把它当异常。提供方可以在旧主版本映射到较粗的 shipped 或 processing,新主版本返回精确值;消费者也应有明确的未知枚举分支,不能默认当成功。
第三种失败是参数合法但语义改变。amount 从"元"改成"分",Schema 都是整数,差异工具无法发现。单位必须体现在字段名或描述中,契约样例用边界值断言结果,最好采用 {value, currency, scale} 这类明确结构。语义变更原则上是主版本变化。
第四种失败是重试造成副作用重复。Schema 升级可能增加网络跳转,使超时增多;Agent 自动重试写工具时会重复创建资源。写工具必须接受幂等键,服务端按调用主体与幂等键保存结果。契约测试应模拟响应丢失后的重复请求,验证返回同一业务结果。
第五种失败是回滚只回了服务端。模型仍缓存新 Schema,继续发送旧服务不认识的字段。回滚包必须同时描述注册表版本、服务版本、编排器缓存失效策略和会话路由。发布系统要能一键把流量切回上一套完整组合,而不是只替换一个容器镜像。
13. 安全边界:Schema 也是攻击面
参数通过 Schema 校验不代表安全。字符串满足格式后仍可能包含提示注入、路径穿越、SQL 片段或恶意 URL。工具层要继续做业务授权、资源归属检查、允许列表和输出编码;数据库使用参数化查询,文件路径在受控根目录解析,网络请求防 SSRF。模型生成的参数与普通外部输入一样不可信。
Schema 描述也可能泄露内部信息。不要把数据库表名、内网地址、秘密字段或绕过规则写进公开工具描述。注册表写权限应最小化,制品签名或摘要应在编排器加载时验证,防止攻击者替换工具描述诱导模型调用恶意端点。
输出必须再次校验和裁剪。提供方被攻破或依赖返回异常时,结构化结果中可能夹带指令,诱导模型泄露信息或调用下一工具。编排器把工具结果标记为不可信数据,限制长度与媒体类型;高风险动作依据确定性策略和用户授权,而不是因为上一个工具输出"已批准"就自动执行。
契约样本往往来自生产 trace,其中可能包含订单号、姓名和地址。采集时先做字段级脱敏,只保留验证语义所需内容,并设置访问控制与保留期限。不要为了提高测试真实性把整段对话复制进普通 Git 仓库。
14. 一条最小但完整的 CI/CD 门禁
提交阶段先校验 Schema 自身合法,再运行保守差异检查。发现明确破坏变更时,要求新主版本;发现无法判断的组合规则时,要求负责人评审。随后运行提供方单元测试、全部活跃消费者契约和历史行为轨迹。构建制品后计算摘要并签名,预发布环境从同一制品启动,避免测试一份、上线另一份。
部署阶段先上兼容服务,再更新注册表,最后放量消费者。灰度期间自动比较新旧桶的校验失败率、业务成功率、延迟和工具选择率。任何关键指标越界,停止扩量并回滚注册表与消费者;服务端兼容能力可以暂时保留,因为删除它并不能修复事故。
发布完成不等于迁移结束。持续观察旧版本调用量,通知负责人,达到清零条件后才能移除适配器和旧端点。清理也要经过一次反向契约检查,确认没有长会话、离线任务或第三方仍依赖旧版本。
#mermaid-svg-N5xTo1vjrd0l2nS7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-N5xTo1vjrd0l2nS7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-N5xTo1vjrd0l2nS7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-N5xTo1vjrd0l2nS7 .error-icon{fill:#552222;}#mermaid-svg-N5xTo1vjrd0l2nS7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-N5xTo1vjrd0l2nS7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-N5xTo1vjrd0l2nS7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-N5xTo1vjrd0l2nS7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-N5xTo1vjrd0l2nS7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-N5xTo1vjrd0l2nS7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-N5xTo1vjrd0l2nS7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-N5xTo1vjrd0l2nS7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-N5xTo1vjrd0l2nS7 .marker.cross{stroke:#333333;}#mermaid-svg-N5xTo1vjrd0l2nS7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-N5xTo1vjrd0l2nS7 p{margin:0;}#mermaid-svg-N5xTo1vjrd0l2nS7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-N5xTo1vjrd0l2nS7 .cluster-label text{fill:#333;}#mermaid-svg-N5xTo1vjrd0l2nS7 .cluster-label span{color:#333;}#mermaid-svg-N5xTo1vjrd0l2nS7 .cluster-label span p{background-color:transparent;}#mermaid-svg-N5xTo1vjrd0l2nS7 .label text,#mermaid-svg-N5xTo1vjrd0l2nS7 span{fill:#333;color:#333;}#mermaid-svg-N5xTo1vjrd0l2nS7 .node rect,#mermaid-svg-N5xTo1vjrd0l2nS7 .node circle,#mermaid-svg-N5xTo1vjrd0l2nS7 .node ellipse,#mermaid-svg-N5xTo1vjrd0l2nS7 .node polygon,#mermaid-svg-N5xTo1vjrd0l2nS7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-N5xTo1vjrd0l2nS7 .rough-node .label text,#mermaid-svg-N5xTo1vjrd0l2nS7 .node .label text,#mermaid-svg-N5xTo1vjrd0l2nS7 .image-shape .label,#mermaid-svg-N5xTo1vjrd0l2nS7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-N5xTo1vjrd0l2nS7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-N5xTo1vjrd0l2nS7 .rough-node .label,#mermaid-svg-N5xTo1vjrd0l2nS7 .node .label,#mermaid-svg-N5xTo1vjrd0l2nS7 .image-shape .label,#mermaid-svg-N5xTo1vjrd0l2nS7 .icon-shape .label{text-align:center;}#mermaid-svg-N5xTo1vjrd0l2nS7 .node.clickable{cursor:pointer;}#mermaid-svg-N5xTo1vjrd0l2nS7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-N5xTo1vjrd0l2nS7 .arrowheadPath{fill:#333333;}#mermaid-svg-N5xTo1vjrd0l2nS7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-N5xTo1vjrd0l2nS7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-N5xTo1vjrd0l2nS7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-N5xTo1vjrd0l2nS7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-N5xTo1vjrd0l2nS7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-N5xTo1vjrd0l2nS7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-N5xTo1vjrd0l2nS7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-N5xTo1vjrd0l2nS7 .cluster text{fill:#333;}#mermaid-svg-N5xTo1vjrd0l2nS7 .cluster span{color:#333;}#mermaid-svg-N5xTo1vjrd0l2nS7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-N5xTo1vjrd0l2nS7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-N5xTo1vjrd0l2nS7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-N5xTo1vjrd0l2nS7 .icon-shape,#mermaid-svg-N5xTo1vjrd0l2nS7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-N5xTo1vjrd0l2nS7 .icon-shape p,#mermaid-svg-N5xTo1vjrd0l2nS7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-N5xTo1vjrd0l2nS7 .icon-shape .label rect,#mermaid-svg-N5xTo1vjrd0l2nS7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-N5xTo1vjrd0l2nS7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-N5xTo1vjrd0l2nS7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-N5xTo1vjrd0l2nS7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 明确破坏
兼容或人工批准
指标越界
指标稳定
修改 Tool Schema
Schema 自校验
兼容差异检查
创建新主版本
消费者契约测试
行为轨迹回放
先部署兼容服务
发布注册表制品
稳定分桶灰度
回滚制品与消费者
扩大流量并观察旧版
15. 验收清单
上线前可以用一份短清单阻止大多数事故:Schema 是否声明方言和稳定 ID;输入与输出是否都有约束;变更是否按消费者视角分类;旧请求是否在新提供方上运行;错误码和幂等语义是否受契约保护;模型轨迹是否验证选择与追问;灰度是否保持会话稳定;trace 是否记录 Schema 摘要;回滚是否覆盖注册表、服务和缓存;生产样本是否脱敏。
还应明确负责人。工具平台负责制品与门禁,提供方负责实现和错误语义,消费者负责维护真实依赖,安全团队负责高风险策略,但最终发布必须有一个可追责的 owner。若所有人都只负责"自己那一段",跨层协议最容易在缝隙中失控。
16. 边界与取舍
小型单体项目只有一个 Agent 和一个同步部署的工具函数时,不必立刻建设庞大注册平台。把 Schema 放入版本库、运行差异脚本和两三个真实契约,已经比口头约定可靠。只有当团队、租户和发布节奏增多时,再引入中央注册、签名、消费者矩阵与自动弃用流程。
契约测试也不能证明业务绝对正确。它保护已知依赖,却可能固化错误行为;因此需要产品规则、线上指标和安全测试补充。差异检查更不是形式化证明,遇到组合 Schema 与语义变化时应承认"不知道",用人工评审和真实回放取得证据。
最重要的原则是:工具升级不是替换一段描述,而是迁移一份正在被消费的协议。只要旧调用仍存在,就必须保留兼容路径;只要新行为未被观测,就不能把结构校验通过当成发布成功。
17. 错误响应也要有稳定契约
团队经常认真设计成功响应,却让异常由框架自由生成。有时返回字符串,有时返回对象;有时 HTTP 200 内嵌 error,有时直接抛出 500。Agent 无法可靠判断该重试、追问还是停止,最后只能把错误文本交给模型猜。工具错误至少应包含稳定代码、是否可重试、面向用户的安全说明和内部关联标识。内部堆栈、SQL、密钥以及上游原始响应不能进入模型上下文。
错误分类要与动作一致。参数缺失由编排器在调用前拦截;业务资源不存在通常无需重试;依赖超时可以在幂等保证下有限重试;权限拒绝不能通过换一种提示绕过;需要用户补充的信息应转成明确追问,而不是失败循环。消费者契约应覆盖每一类,并验证代码而非自然语言消息。消息可以优化措辞,代码语义不得在补丁版本中改变。
错误兼容同样存在方向。提供方新增一个错误码,旧消费者可能落入未知分支,因此消费者必须有安全默认:未知错误不自动执行副作用,记录关联标识并升级人工。服务端不能把过去的"订单不存在"改成通用失败而不升级,因为这会改变消费者是否提醒用户核对订单号。对写操作,响应超时属于结果未知,不等于执行失败;查询幂等记录确认结果后才能决定是否重试。
18. 用影子校验发现真实请求中的破坏
静态差异与测试样本都可能遗漏真实分布。发布新 Schema 前,可以在旧服务路径旁挂一个只校验不执行的影子验证器:线上请求仍由旧版本完成,同时复制脱敏后的参数到新校验器,统计会被新规则拒绝的比例和原因。影子校验不得调用真实写工具,也不能延长主请求时延;异步队列拥塞时宁可丢弃低风险样本,也不能拖垮业务。
采样必须保持租户和版本分层。整体只有极少拒绝,不代表某个仍运行旧 Agent 的租户没有全部失败。报告按消费者版本、工具主版本和错误关键词分组,但敏感参数只保留类型、长度、枚举命中与哈希。发现未知字段时先确认它来自合法旧消费者、模型偶发幻觉还是攻击流量,再决定放宽 Schema;不能看到拒绝就自动允许所有附加字段。
影子结果通过后,继续做双解析:同一响应分别交给旧消费者解析器和新消费者解析器,比较结构化结果。查询工具还可以对新旧提供方发送经过批准的样本并比较业务字段。涉及实时库存、时间和随机结果时,应定义允许差异,不能用完整 JSON 相等制造误报。任何比较都不应复制超出测试所需的个人信息。
19. 管理弃用,而不是在文档里写一句"即将删除"
弃用需要可执行时间线。注册表给字段标记首次弃用版本、替代字段、计划移除的主版本和负责人;编排器加载后可在开发环境发出警告,线上则按消费者身份记录使用量。只有调用量归零并经过至少一个约定观察窗口,才能进入删除评审。若存在无法联系的外部消费者,应延长旧主版本或明确终止服务流程,而不是静默破坏。
迁移期间不要同时维护两套独立业务逻辑。旧端点和新端点应尽量进入同一核心服务,在边界完成参数转换与响应降级,避免修复只落到其中一版。适配层有明确到期条件,并由指标证明何时可删;没有到期条件的兼容代码会永久积累,最终谁也不敢修改。
最后做一次灾难演练:让注册表发布错误 Schema、让新提供方返回未知枚举、让缓存无法及时失效,确认告警能定位版本组合,回滚能恢复完整链路。纸面上的回滚步骤如果从未执行,事故时往往才会发现旧镜像已删除、旧 Schema 摘要找不到或数据库已经做了不可逆迁移。
小结
Tool Schema 把模型的不确定输出压缩成可验证协议,也把工具提供方与 Agent 消费方绑定在同一兼容承诺上。可靠升级需要四道防线:保守的 Schema 差异检查发现显式破坏,消费者契约保护真实依赖,行为轨迹验证模型选择和参数生成,灰度与可观测性确认线上分布没有偏移。
不要原地增加必填字段,不要让网关猜版本,也不要只回滚业务服务。先部署兼容能力,再发布 Schema,最后让消费者依赖;主版本并行直到旧流量清零。这样,工具数量和 Agent 数量增长后,升级才不会变成每次都靠运气的线上实验。