Structured Outputs 最容易翻车的地方,不是“模型不听话”,而是你把 schema 当成了永远不变的圣旨。
线上一旦进入版本演进期,最常见的事故就是:字段新增后老消费端崩、枚举值扩展后校验误杀、坏样本把整条链路拖死,最后只能半夜回滚,像在给自己写惊悚片。
先给结论:保守上线靠 4 道闸门
- Schema 要版本化,不要直接原地改。
- 解析要双轨兜底,严格失败后能降级到兼容路径。
- 灰度要按比例放量,别 100% 一把梭。
- 回滚要预先可执行,而不是出事后现编命令。
如果你现在已经在 Go 服务里接 OpenAI Responses Structured Outputs,最稳的做法不是追求“最严”,而是追求可演进、可观测、可回退。
事故通常怎么来的
典型链路长这样:
- 你把
required字段从 4 个加到 6 个 - 你新增了一个枚举值,老逻辑没认出来
- 你把
steps从字符串改成对象数组 - 你假设模型输出永远满足最新版 schema
- 你把严格校验失败直接当 500 返回
于是结果就很朴素:
- 新 schema 刚上线,老流量立刻报错
- 重试把坏样本放大成风暴
- 日志里只有一句
invalid json schema output - 最后你发现问题不在模型,在你自己太自信
一、Schema 演进别做“原地爆改”
推荐做法:每次结构性变更都带版本号。
一个够实用的返回结构:
{
"schema_version": "v2",
"ticket_type": "billing",
"priority": "high",
"summary": "duplicate charge after retry",
"actions": [
"check idempotency key",
"review payment callback logs"
]
}
Go 侧不要只定义一个“永恒结构体”,而是显式保留版本模型:
type TicketV1 struct {
SchemaVersion string `json:"schema_version"`
Type string `json:"ticket_type"`
Summary string `json:"summary"`
}
type TicketV2 struct {
SchemaVersion string `json:"schema_version"`
Type string `json:"ticket_type"`
Priority string `json:"priority"`
Summary string `json:"summary"`
Actions []string `json:"actions"`
}
上线前先把 schema 做归一化 diff,别靠肉眼找区别:
jq -S . schema/ticket_v1.json > /tmp/ticket_v1.norm.json
jq -S . schema/ticket_v2.json > /tmp/ticket_v2.norm.json
diff -u /tmp/ticket_v1.norm.json /tmp/ticket_v2.norm.json
你真正要关心的,不是“schema 变了没有”,而是:
- 是否新增了
required - 是否缩窄了 enum
- 是否改变了字段类型
- 是否删除了旧字段
前两类最容易把线上打碎。
二、坏样本兜底:严格失败后,走兼容解析
保守策略很简单:
- 第一层:按当前版本严格解析
- 第二层:失败后尝试兼容版本或宽松结构
- 第三层:再失败就降级成人工可读摘要,而不是整个请求报废
示例代码:
func decodeTicket(raw []byte) (any, error) {
var peek struct {
SchemaVersion string `json:"schema_version"`
}
if err := json.Unmarshal(raw, &peek); err != nil {
return nil, fmt.Errorf("peek version: %w", err)
}
switch peek.SchemaVersion {
case "v2":
var out TicketV2
if err := json.Unmarshal(raw, &out); err == nil {
return out, nil
}
// fallback to tolerant path
return decodeTicketCompat(raw)
case "v1":
var out TicketV1
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return out, nil
default:
return decodeTicketCompat(raw)
}
}
兼容路径不要悄悄吞错,至少要记录:
schema_version_detecteddecode_mode=strict|compat|summaryfallback_reasonmodelprompt_version
快速做一个坏样本回放目录,值回票价:
mkdir -p /Users/wow/dev/book/mengboy/tmp/structured-output-samples/bad
cp bad_case.json /Users/wow/dev/book/mengboy/tmp/structured-output-samples/bad/
go test ./... -run TestDecodeTicketCompat -v
三、灰度发布:别全量冲,按比例放量
Structured Outputs 的问题,很多只有在真实长尾输入里才会冒出来。
所以灰度不要做成“开发环境没报错 = 生产全量”。那是事故邀请函。
推荐分 4 档:
- 5%:只看解析成功率和坏样本分布
- 20%:开始看业务字段正确率
- 50%:观察成本、延迟、回退比例
- 100%:连续稳定后再切主路径
最笨但非常有用的放量方式:直接走环境变量。
export STRUCTURED_OUTPUT_SCHEMA_VERSION=v2
export STRUCTURED_OUTPUT_CANARY_PERCENT=20
export STRUCTURED_OUTPUT_COMPAT_FALLBACK=1
./bin/api
验证当前服务配置:
curl -s http://127.0.0.1:8080/debug/config | jq '.structured_output'
当你发现 compat 路径占比持续升高时,不要骗自己说“还能跑”。那通常意味着:
- prompt 和 schema 已经漂了
- 某些样本触发了模型输出边界
- 你以为的必填字段,模型并不总能稳定给出
四、灰度回滚:回滚命令要先写好
真正有用的回滚,不是 Git 里有历史版本,而是5 分钟内能恢复线上。
我建议至少准备两种回滚:
- 配置回滚:把
v2切回v1 - 逻辑回滚:保留
v2生成,但消费端退回compat
最小回滚清单:
export STRUCTURED_OUTPUT_SCHEMA_VERSION=v1
export STRUCTURED_OUTPUT_CANARY_PERCENT=0
export STRUCTURED_OUTPUT_COMPAT_FALLBACK=1
./bin/api
如果你有 systemd 或容器编排,就把它做成明确动作,不要靠记忆:
kubectl set env deploy/agent-api \
STRUCTURED_OUTPUT_SCHEMA_VERSION=v1 \
STRUCTURED_OUTPUT_CANARY_PERCENT=0 \
STRUCTURED_OUTPUT_COMPAT_FALLBACK=1
五、指标别只看“成功率”
Structured Outputs 线上最坑人之处,在于“HTTP 200 但业务结构已烂”。
至少打这些指标:
structured_decode_total{mode,version}structured_decode_fail_total{reason}structured_fallback_total{from,to}structured_summary_degrade_totalstructured_field_missing_total{field}structured_enum_unknown_total{field,value}
排障时先查这三件事:
grep -R "decode_mode=compat" /var/log/agent-api | tail -n 50
grep -R "fallback_reason" /var/log/agent-api | sort | uniq -c
grep -R "schema_version=v2" /var/log/agent-api | tail -n 100
如果 summary 降级在涨,说明你不是“更严格了”,而是“更脆了”。这两者差得很远。
六、一个够保守的 Go 落地顺序
如果你要在一周内把 Structured Outputs 稳定落地,我的推荐顺序是:
- 先加
schema_version - 再做严格 + 兼容双解析
- 然后补坏样本回放测试
- 最后才做全量切换
别反过来。先全量再补兜底,这种操作属于给未来的自己挖坑。
常见错误与排障
1)新增 required 字段后成功率骤降
优先检查:
- prompt 是否明确要求该字段
- 历史样本里是否本来就经常缺失
- compat 路径是否还能接受旧结构
2)模型返回了未知枚举值
处理建议:
- 先落到
unknown - 记录原始值到日志或审计字段
- 不要因为一个新枚举把整个请求打死
3)灰度期间线上有 200,但下游报空指针
这通常不是接口成功,而是解码成功但语义没成功。
要补:
- 字段级业务校验
- 默认值策略
- 下游消费前的非空断言日志
总结
Structured Outputs 真正难的不是“生成 JSON”,而是在 schema 持续变化时,系统还能稳稳活着。
保守目标下,我的建议很直接:
- schema 带版本
- 消费端双解析
- 灰度按比例
- 回滚命令提前写好
最小可行方案(MVP)
如果你今天只能补一轮:
- 给输出加
schema_version - Go 里增加
strict -> compat -> summary三段解码链 - 配
v2只放 5% 灰度 - 监控
structured_fallback_total
先把系统做成不轻易炸,再去追求结构多漂亮。线上系统不是艺术展,稳才值钱。