版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式,也不对任何收益结果作承诺。转载请注明出处。
现场:一次只加必填参数的变更,七次调用全挂
周四上午十点,工具定义里多了一个参数:days,整数,标成必填。发布说明只有一行——「新增天数参数,支持多日预报」。
十点三十四分,告警响了。三个还在跑旧版工具定义的调用方各发 7 次请求,21 次全部返回 400:missing required property: days。回滚在十一点零二分完成,中间这 28 分钟,这个天气工具对这批账号完全不可用。
上面这段是按下面那张版本矩阵还原出来的推演场景,不是某一次线上事故的原始日志——时间、告警文案按常见工单形态写,但每一次失败都能用文中的脚本复算出来。
字段是新增的,老客户端从来没听说过它,而服务端把它列进了 required。JSON Schema 里 required 的语义是「这个键必须出现」,不是「这个键可以没有值」;一个从没被传过的键,和传了 null 是两回事。
这次变更其实叠了五步:v1 初版只有 city 和 unit;v2 加可选 days;v3 把 unit 改名 unit_system;v4 把 days 改成必填;v5 把 unit_system 的取值从 {C, F} 收紧成 {C}。任何一步单独看,都像一个「顺手加的字段」或「顺手收的范围」。
🧪 实测:5 个客户端版本 × 5 个服务端版本,各 7 次调用
环境:Python 3.13.12 / macOS arm64 / 仅标准库。脚本把上面五步写成一张版本表,每个客户端版本对每个服务端版本各发 7 次调用,校验器只判四件事:必填缺失、多余字段、类型不符、枚举越界。
V = {
1: (["city"], {"city": ("s",), "unit": ("s", ("C", "F"))}),
2: (["city"], {"city": ("s",), "unit": ("s", ("C", "F")), "days": ("i",)}),
3: (["city"], {"city": ("s",), "unit_system": ("s", ("C", "F")), "days": ("i",)}),
4: (["city", "days"], {"city": ("s",), "unit_system": ("s", ("C", "F")), "days": ("i",)}),
5: (["city", "days"], {"city": ("s",), "unit_system": ("s", ("C",)), "days": ("i",)}),
}
NAME = {1: "v1 初版", 2: "v2 加可选 days", 3: "v3 unit 改名", 4: "v4 days 变必填", 5: "v5 枚举收紧"}
PY = {"s": str, "i": int}
def check(ver, args):
req, props = V[ver]
err = ["缺必填 " + k for k in req if k not in args]
for k, v in args.items():
if k not in props:
err.append("多余字段 " + k)
continue
if not isinstance(v, PY[props[k][0]]):
err.append("类型错 " + k)
if len(props[k]) > 1 and v not in props[k][1]:
err.append("枚举越界 %s=%s" % (k, v))
return err
def calls(ver):
key = "unit" if ver <= 2 else "unit_system"
out = []
for u in ["C", "C", "C", "F", "F", None, None]:
a = {"city": "Shanghai"}
if u:
a[key] = u
if ver >= 4:
a["days"] = 3
out.append(a)
return out
print("失败次数矩阵(行=客户端版本,列=服务端当天版本,各 7 次调用)")
print(" " + "".join("%9s" % ("服务端v%d" % s) for s in range(1, 6)))
tot = 0
for c in range(1, 6):
row = "客户端v%d " % c
for s in range(1, 6):
b = sum(1 for a in calls(c) if check(s, a))
tot += b
row += "%9d" % b
print(row)
print(" 合计 %d/175 次调用失败(%.0f%%)" % (tot, 100 * tot / 175))
print()
print("老客户端撞上新服务端,首个错误是什么")
for c, s in [(1, 2), (1, 3), (1, 4), (2, 3), (3, 4), (4, 5)]:
b = sum(1 for a in calls(c) if check(s, a))
print(" 客户端v%d -> 服务端v%d %d/7 首错:%s"
% (c, s, b, (check(s, calls(c)[0]) or ["无"])[0]))
失败次数矩阵(行=客户端版本,列=服务端当天版本,各 7 次调用)
服务端v1 服务端v2 服务端v3 服务端v4 服务端v5
客户端v1 0 0 5 7 7
客户端v2 0 0 5 7 7
客户端v3 5 5 0 7 7
客户端v4 7 5 0 0 2
客户端v5 7 5 0 0 2
合计 90/175 次调用失败(51%)
老客户端撞上新服务端,首个错误是什么
客户端v1 -> 服务端v2 0/7 首错:无
客户端v1 -> 服务端v3 5/7 首错:多余字段 unit
客户端v1 -> 服务端v4 7/7 首错:缺必填 days
客户端v2 -> 服务端v3 5/7 首错:多余字段 unit
客户端v3 -> 服务端v4 7/7 首错:缺必填 days
客户端v4 -> 服务端v5 2/7 首错:无
矩阵里有两处值得停下来看。
v1、v2 两行在服务端 v3 那一列是 5 而不是 7。这 7 次调用里有 2 次根本没传单位,没有 unit 这个键,也就谈不上「多余字段」。失败次数不是「客户端数 × 调用次数」,它取决于这批调用里有多少次带着会被判错的那个键。
v4 那一列,所有老客户端都是 7/7。缺 days 和客户端传了什么无关,只要这个键不在请求里,每一次都判不过。这就是「加必填」和其余几类改动在量上的区别:它打的是全部。
对角线全是 0,说明每个版本和自己自洽;自洽不代表兼容。要看两个三角:右上角是「老客户端撞新服务端」,左下角是「回滚后新客户端撞旧服务端」,两边都不是 0。
第一道:只加可选字段——加字段不等于改契约
加字段本身不破坏契约,破坏契约的是「加完之后,服务端怎么对待没传这个字段的请求」。这道闸门拦的,是把新增字段顺手塞进 required 这个动作。
🧪 数据实测:把五次改动按破坏面重排,得到下面这张表。口径先说清——7 次调用 = 3 次传 C、2 次传 F、2 次不传单位;校验器只检查 required、多余字段、类型、枚举四项,不模拟真实服务端可能存在的其他校验。
| 加可选字段(v1→v2) | 请求形状不变,服务端按缺省值处理 | 0 |
| 收紧枚举(v4→v5) | 传 F 的调用被判枚举越界 | 2 |
| 字段改名(v2→v3) | 旧字段名被判成多余字段 | 5 |
| 加必填字段(v3→v4) | 所有调用都缺这个键 | 7 |
排序反直觉的地方在第三、四行:改名挂 5 次,加必填挂 7 次。改名的破坏面取决于「有多少调用带了那个键」,加必填的破坏面等于全部。发布方对两者的预期恰好相反——改名被当成大事反复讨论,加必填被当成小事,一句「新增字段」就发了。
一道「只加可选」的闸门要同时满足三个条件:新字段不出现在 required;服务端对缺失有确定的缺省行为,并把这件事写进响应,比如 {"days_used": 1, "days_defaulted": true};新字段不参与任何跨字段约束。第三条最容易漏——字段本身可选,可一旦被放进 oneOf 的某个分支,它在那条分支里就变成必填了,等于绕过前两条。
{
"type": "object",
"properties": {
"city": { "type": "string" },
"unit": { "type": "string", "enum": ["C", "F"] },
"days": { "type": "integer", "minimum": 1, "maximum": 7 }
},
"required": ["city"],
"additionalProperties": false
}
这里还有一层容易混淆:工具定义层的「可选」和模型生成层的「必填」不是同一个开关。
截至 2026-10-30,OpenAI 结构化输出文档对这项能力的描述是 Structured Outputs is a feature that ensures the model will always generate responses that adhere to your supplied JSON Schema, so you don't need to worry about the model omitting a required key, or hallucinating an invalid enum value. 同一页的严格模式示例里,每个对象都带 "additionalProperties": false,所有字段都列进 required;想表达「可以不填」,写法是把类型写成 "type": ["string", "null"] 但仍留在 required 里。模型侧没有「缺省键」这个状态,只有「键在、值为 null」。
同一页还有一句关于延迟的:the first request you make with any schema will have additional latency as our API processes the schema, but subsequent requests with the same schema will not have additional latency. 哪怕是最安全的这类改动,也有一次性代价——schema 变了,之前处理过的那份结果用不上,下一次重新付一次。所以 schema 变更要和流量高峰错开。
没有这道闸门会怎样:要么任何字段新增都走版本升级、让调用方改代码;要么把「加字段」统统当成无害操作,迟早走到 v3→v4 那一步。
第二道:字段改名必须双写别名
改名是这几类改动里唯一一类「可能不报错、但在语义上出错」的。这道闸门拦的,就是把 unit 原地重命名成 unit_system。本文的校验器把旧字段名判成多余字段,所以 5 次明确报错,看起来还算温和;真实服务端更常见的处理是忽略未知字段,请求照收,unit 被悄悄丢掉,服务端按默认的 C 返回。
于是出现一个很别扭的局面:失败率归零,错答率上升,两个指标朝相反方向走,而错的那一半在日志里没有痕迹。调用方拿到 200,拿到一个格式完全正确的响应,只是温度单位不是他要的那个。
为什么是 5 而不是 7,原因和上一章一样:那 2 次没传单位的调用通过了,它们本来就没有 unit 可以丢。报错的那 5 次反而是幸运的,至少它们知道自己错了。
双写别名是这道闸门的做法,三条要求:服务端同时接受新旧两个字段名,旧名映射到新名;两个都传且值不一致时报错,不要猜,猜错比报错难查得多;响应里回显实际生效的字段名和来源,比如 {"applied": {"unit_system": "F", "source": "alias:unit"}}。
{
"type": "object",
"properties": {
"city": { "type": "string" },
"unit": { "type": "string", "enum": ["C", "F"], "deprecated": true },
"unit_system": { "type": "string", "enum": ["C", "F"] },
"days": { "type": "integer", "minimum": 1, "maximum": 7 }
},
"required": ["city"],
"additionalProperties": false
}
deprecated 只是给人看的注释,不改变校验语义。真正让别名生效的,是 properties 里同时留着两个名字——配合 additionalProperties: false,没列进去的键才会被判成多余字段。
别名期该多长,不能用日历估。依据是观测:连续 7 天旧名字命中次数归零,且这段时间没有新的旧版本客户端冒出来,才可以删。删之前留一段 source: "alias:unit" 的计数。
⚠️ 待验证:补上这层别名再跑同一组调用,预期是 0/21、0/28、0/35(3、4、5 个客户端版本各 7 次)。这一组属于待验证——本文脚本只跑到第 5 版服务端,没有单独起带兼容层的版本去测,请以你本地补完后的实际输出为准。
没有这道闸门会怎样:改名只剩两条路,等所有调用方升级(不知道等到哪天),或者接受一段静默丢参的窗口(不知道丢了什么)。前者拖住发布,后者把错误推进调用方的业务结果里,两边都不会有人主动来认。
这份资料是什么:把工具定义从「一张 schema」演进到「一套带版本矩阵的契约」,中间要补的封装、评估与灰度环节,整理成了一份按顺序看的清单。放在资料包里,扫码即可获取:

第三道:新必填先给默认值
必填字段没有「平滑」的加法,只有「先当可选、再收紧」,中间那段时间靠服务端的默认值撑着。这道闸门拦的,是把新增字段直接标成 required 然后发布。
它比改名更狠,矩阵写得很清楚:改名只打中「传了旧名字」的那部分调用,加必填打中全部,v4 那一列五个客户端版本齐刷刷 7/7。发布方的直觉在这里失效得最彻底——他们眼里这是「加了个字段」,调用方眼里这是「契约变了,而且我不知情」。
做法分三步,每一步都留下可查的痕迹。
字段按可选发布,缺省值写在服务端,并把「用了缺省值」写进响应:{"days_used": 1, "days_defaulted": true}。少了这个回显,默认值就成了一个看不见的分支,谁都说不清某次调用用的是调用方给的值还是服务端补的值。
按客户端版本统计 days_defaulted 的比例。这是一个能直接查的指标,不需要去问接入方「你们升级了吗」。
当某个版本最近 7 天 days_defaulted = 0,且这段时间调用次数不少于 200,才把它从兼容范围里移出、标成必填。门槛按你们的调用量调,它挡住一种误判:某版本一周只调了 3 次,恰好都没触发缺省,就被当成「适配完成」——样本太少时,「没出现」和「不会出现」是两件事。
默认值不能只写在文档里。工具定义是给模型看的,模型不读 changelog;参数是人写的代码拼的,人也常不读。写在响应里,两边都能看见。
还有个边界。官方文档对不完整响应有专门说明——if for example you reach a max tokens limit and the response is incomplete。严格模式保证的是「形状符合 schema」,它不保证「一定完整走到结束」。所以服务端在消费模型返回时,schema 校验通过之后还要再看一眼结束原因。
没有这道闸门会怎样:加必填只能在两个坏选项里挑一个,发布即崩,或者永远不加、在服务端把值硬编码死。前者就是 7/7 那个样子,后者会让工具悄悄失去一半表达力,而且没人会为一个没加上的参数写事故报告。
第四道:收紧枚举要在服务端换算
枚举是值域上的契约,收紧它等于宣布一批昨天还合法的值今天非法。这道闸门拦的,是在服务端直接删掉一个枚举值。
v4→v5 的失败面是 2/7,不是 7/7,原因还是那 7 次调用的构成:3 次 C、2 次 F、2 次不传。被收紧掉的只有 F 那两次。
这个数字小,容易让人放松,但有两件事要同时看到。它打中的是「用了被收紧那个值」的调用,而这批调用往往集中在少数几个接入方身上——对他们来说不是 2/7,是全部。另外,枚举收紧经常和「新增一个更精确的值」一起做,新增那个值又会回到第一道闸门,两次改动叠在一次发布里,破坏面不是相加而是相乘。
服务端换算的做法是:接受旧值,内部映射到新值,响应里同时回显 requested 和 returned。单位换算是确定性映射,C 和 F 之间可以无损往返(浮点数除外),这一层站得住——调用方要 F,你给他 C 再加一句「这是按 C 算的」,他能自己换回去。
换算层有个前提:新旧值必须是同一个量的不同刻度。如果枚举值之间不可换算,比如 mode: fast / accurate / cheap,三者不是同一把尺子的不同档位,换算就不成立,只能走版本化——新开参数名,旧参数保留,让调用方自己选。硬做映射等于替调用方猜意图。
没有这道闸门会怎样:收紧枚举变成单方面删值。指望「发公告让调用方改」在工具场景里效果有限,参数是模型在运行时按 schema 挑的,不是人写死的常量,调用方甚至不知道自己用到了那个被删的值。
这套闸门的最小基建:一张版本矩阵加一组契约测试
四道闸门的顺序是契约层、命名层、默认值层、取值层,这个顺序不能换。
每一道都给后一道提供前提。第四道在值域上做收缩,它默认字段名和必填性都稳定;先做第四道再做第二道,你会同时收到「字段被丢弃」和「值被拒绝」两类错误,日志里长得完全不同,根因却是同一次演进,排查成本翻倍。第一道是其余三道的地基:这条路走不通,后面三道全部退化成版本升级,调用方必须改代码。
要落地只需要两件东西:一张版本矩阵,一组契约测试。矩阵回答「谁和谁组合会出事」,契约测试回答「这次改动有没有让某个格子从 0 变成非 0」。
| 服务端 schema 版本号 | 工具定义负责人 | 每次改 properties / required / enum | 工具定义负责人 |
| 客户端版本登记表 | 各接入方 | 新接入、下线、强制升级时 | 接入方 |
| 兼容矩阵结果 | CI 每晚跑,结果入库 | 每次服务端发布前 | 当周值班 |
| 契约测试用例 | 服务端与接入方共同 | 每新增字段、每收紧枚举 | 服务端负责人 |
契约测试要放在服务端的 PR 门禁上,不是放在集成测试里。放在集成测试里,它跑在合并之后,发现问题时改动落在主干上;放在门禁上,schema 文件一动,CI 就拿登记在册的客户端样本跑一遍矩阵,任何一个格子从 0 变成非 0,这个 PR 合不进去。
#!/usr/bin/env bash
set -euo pipefail
SERVER="${SERVER:-http://127.0.0.1:8080}"
FIXTURES="tests/contract/fixtures"
fail=0
total=0
for client_dir in "$FIXTURES"/client_v*; do
client="$(basename "$client_dir")"
for payload in "$client_dir"/*.json; do
total=$((total + 1))
code="$(curl -s -o /tmp/contract_body.json -w '%{http_code}' \\
-X POST "$SERVER/tools/get_weather" \\
-H 'content-type: application/json' \\
-H "x-client-version: ${client#client_}" \\
–data-binary "@$payload")"
if [ "$code" != "200" ]; then
fail=$((fail + 1))
printf 'FAIL %s %s -> %s\\n' "$client" "$(basename "$payload")" "$code"
fi
done
done
printf 'contract: %d/%d passed\\n' "$((total – fail))" "$total"
[ "$fail" -eq 0 ]
这段脚本的骨架就是上面那张矩阵:外层遍历客户端版本,内层遍历请求样本,任何一个样本拿不到 200,整个 job 以非零退出。接进 CI 之后,「这次改动会不会打破谁」当场就有答案。
强制升级要有可执行的判断,不能只有「评估影响面」这句话。三条同时满足才允许强制:该版本最近 30 天调用占比低于 1%(前提是按版本埋了点,没有埋点就无从判断);该版本最后一次调用距今超过 90 天;针对该版本发出过两次书面通知,间隔不少于 14 天。缺一条,就只能留在矩阵里,兼容层继续保留。
保留是要花钱的。每多留一个版本,矩阵多一行,CI 多跑一轮,兼容层多一个分支,分支之间还会互相干扰。所以版本矩阵该设「退役条件」而不是「保留几个版本」——按数量保留,一次大版本之后就堆出十几个分支,谁也不敢删。
这份资料是什么:工具定义演进、schema 版本管理与契约测试的落地顺序,连同几个可以直接对照的写法样例,打包在资料包里,扫码即可获取:

附表 A:本文引用事实与出处对照表
| 结构化输出保证响应遵循所给 JSON Schema,不漏必填键、不编造非法枚举值(原文逐字引用) | https://developers.openai.com/api/docs/guides/structured-outputs (取数 2026-10-30) | 第一道 |
| 严格模式下每个对象都带 "additionalProperties": false,全部字段列入 required,可空字段写成 "type": ["string", "null"] 并留在 required | https://developers.openai.com/api/docs/guides/structured-outputs (取数 2026-10-30) | 第一道 |
| 同一 schema 的首次请求会因 schema 处理增加延迟,后续同 schema 请求不再增加(原文逐字引用) | https://developers.openai.com/api/docs/guides/structured-outputs (取数 2026-10-30) | 第一道 |
| 响应可能因达到 max tokens 上限而不完整,文档对此有单独说明(原文逐字引用) | https://developers.openai.com/api/docs/guides/structured-outputs (取数 2026-10-30) | 第三道 |
| required 表示列出的键必须出现;additionalProperties 控制未声明字段是否允许出现 | https://json-schema.org/understanding-json-schema/reference/object (取数 2026-10-30) | 现场、第二道 |
| 5 个客户端版本 × 5 个服务端版本、各 7 次调用的失败矩阵,合计 90/175 | 本文实测,脚本见现场章的 python 代码块(2026-10-30) | 现场 |
| 五类改动按破坏面排序:加可选 0/7、收紧枚举 2/7、改名 5/7、加必填 7/7 | 本文实测,口径为 3 次 C、2 次 F、2 次不传单位(2026-10-30) | 第一道 |
写在最后:这篇用到的资料
写这篇文章时,把相关的官方文档和源码又翻了一遍,顺手也整理了几份配套的东西:
- 大模型学习路线图:从零基础到能自己动手做 Agent,按阶段说明每一步该学什么、哪些可以先跳过
- 《LangChain + LangGraph + MCP 智能体开发实战》视频课:7 个模块,从私有化部署、Embedding+RAG 到 MCP+Agent 全流程
- AI 大模型知识库(在线可查):Agent Skills 从入门到落地、Claude Skills 完全指南等专题,按目录浏览即可
- 640 套 AI 大模型行业报告 + 经典 PDF 书籍:看行业落地案例和别人怎么做的时候用得上
- 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多
资料是我自己整理的,放在下面这个码上,扫码即可获取:






添加时备注「AI」,优先通过。
资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。
网硕互联帮助中心






评论前必须登录!
注册