两个月的生产环境观察总结:必填字段排放顺序、枚举值数量上限、schema 编写规范等未出现在官方文档的隐性行为。
我们在 Gemini 上跑文档提取 pipeline,使用的是 native responseSchema(附在请求上),而不是在 prompt 文本里写"请用 JSON 回复"。两个月内,三个独立的生产问题最终都追溯到这个 schema 相关的行为,而 Google 文档里根本没有记载。
以下是我们现在交付时携带的规则,以及每条规则背后的测量数据。业务信息已匿名化(无客户、无行业、角色代码已重命名)。所有数字、日期、模型名称和错误字符串均为真实数据。
Gemini 会先输出所有 required 属性,严格按照 required 数组中列出的顺序,可选属性紧随其后。properties 内部的声明顺序对 required 集合不起作用。
这是经验结论,不在 Google 文档里。我们在 gemini-3-flash-preview 上测了两次,换了相反的顺序,读原始响应文本:
在 v18 中,那个字段在 properties 里排第 12 位,在 required 里排第 3 位。结果它在输出中排第 3 位。properties 不是那个杠杆。
propertyOrdering 是有文档记载的旋钮,但如果你们没有设置它(我们任何地方都没设置),required 的顺序才是真正的控制因素。
模型写 JSON 时做一次前向传递。已经发出的任何内容都在上下文中。尚未发出的则不在。而且一旦发出一个 token,当后续字段与之矛盾时,无法撤回。
所以一个早早发出的字段几乎没有任何自生成证据可供判断,而一个晚发出的字段则能看到它上方的一切。
v18 把 role_code 放在 required 的第三位,仅次于 first_name 和 last_name。它的指令是一个优先级阶梯:
申请中注明的职位(在输入中,可得)
最近一条 work_history 条目的标题(晚 4 个字段才发出,不可得)
CV 抬头(在输入中,可得)
九份 CV,全部声称同一个职位。其中四份返回了 L3-OPS,一个来自另一个部门的职位。这四份全部内部自相矛盾:
{
"role_code": "L3-OPS",
"department": "Technical",
"work_history": [{ "title": "L3-TECH", "...": "..." }]
}
两个代码都是那 143 个值枚举中的合法成员,所以没有任何东西拒绝这个输出。department,在所有四份中都与正确解读一致,却排在第 11 位,远晚于那个错误 token 被提交的时刻。 本可以捕获这个错误的 consistency check 生成于错误的下游。
最先排除的因素:环境漂移(schema 字节级一致)、下游映射(错误代码已经在原始 provider 响应中)、枚举值缺失(正确代码存在,且在同一响应其他地方使用正确)、源文档歧义(操作类措辞零匹配,技术类措辞每份文档 11 到 20 个匹配)。
调整 required 顺序。其他什么都不动。不改类型、不改枚举、不改结构。
v18: first_name, last_name, role_code, contacts, nationalities, date_of_birth,
work_history, certifications, documents, education, languages, address,
home_airport, department
v19: first_name, last_name, date_of_birth, nationalities, contacts,
work_history, certifications, education, documents, languages, address,
home_airport, department, role_code
结果:9 份中 9 份正确,之前是 9 份中 5 份。role_code 的发出位置从第 3 位移到第 14 位,正如预测。
描述不得前向引用。一旦 department 挪到了 role_code 之前,其旧文本("根据 role_code 分类")就变成了一个缩小版的同样 bug。任何重排后,重新读每条描述,检查是否存在对现在排在后面的字段的引用。
告诉模型证据已经在那里了。单纯重排是沉默的。v19 的优先级 2 改为:"你已经在上面发出了 work_history 数组。读取其第一个条目的 title 并使用它。"
检查过度锚定。目标是接地,不是回声。两位候选人的最近一条 entry 比申请的职位低一级,仍然正确地发出了申请的级别,因为优先级 1 合法地高于优先级 2。如果每个派生值突然都等于 evidence[0],说明矫枉过正了。
required 对 JSON Schema 验证器来说是一个集合,重排它在语义上是无害的,所以格式化工具如果对数组排序,或者某个工具对 JSON 做了往返,就会悄无声息地还原这个行为,diff 看起来像只是空格变化,所有测试都通过了。在文件中说明这一点。
不要在收到错误的派生字段后首先加更多说明文本。v18 已经在那个字段上携带了四条正确的指导,仍然在约 44% 的时间里出错。指令并没有被违抗。它被评估时所处的 token 位置上,它的输入根本不存在。
Gemini 会拒绝超出某个未文档化上限的 schema,该上限是整个 schema 中所有枚举值的总数。Google 没有公布具体数字,只说"非常大或非常深的 schema 可能会被拒绝"。
边界在 (610, 740] 之间。我们从未二分过它。
740 的 schema 尝试了三个检查点,一个预览版和两个 GA 版本:
跨越数月的相同拒绝。这是约束解码编译器的属性,而非检查点的属性,所以等待更新的模型不是缓解方案。
你无法通过去重来钻到这个限制以下。Gemini 的子集没有 $ref 和 $defs(见规则 4),所以每个重复的列表都全额计费。一个 145 个值的列表用在三处,成本是 435,不是 145。
在大 schema 中添加任何枚举前,重新计算总数。
对于低值字段,把代码列表放在 description 里。Description 不占配额。只是要知道它们也不会被强制执行(规则 3)。
如果一个词汇表确实需要强制执行且放不下,就把提取拆成两次调用,并按数据依赖而非文档节分。彼此有派生关系的字段必须留在同一次调用中。
CI 中值得有一个粗略计数器:
// Sums every enum array in a schema, nulls included.
function countEnums(node) {
if (Array.isArray(node)) return node.reduce((n, v) => n + countEnums(v), 0);
if (node && typeof node === 'object') {
return Object.entries(node).reduce(
(n, [k, v]) => n + (k === 'enum' && Array.isArray(v) ? v.length : countEnums(v)),
0
);
}
return 0;
}
responseSchema 保证 JSON 的形状和类型。它不保证值,只有一个例外。
七月,一次 schema 变更导致约束解码在五天内停止应用。没有任何东西失败,没有任何东西变红,75 个在词汇表中不存在的值进入了系统中一个其余部分建立索引的字段。
在提取后即使附加了 schema,也要在代码中验证和规范化有界字段。
加一个金丝雀:任何超出其枚举的值都证明那次调用没有应用约束解码。
如果同一个 prompt 可能会在多个 provider 上运行,就把 schema 存成更严格的格式。Gemini 的子集是地板。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"role_code": { "$ref": "#/$defs/RoleCode" },
"start_year": { "type": "integer", "exclusiveMinimum": 1900 },
"email": { "type": ["string", "null"] },
"website": { "type": "string", "format": "uri" },
"ref": { "type": "string", "pattern": "^[A-Z]{2}-\\d{4}$" }
}
}
{
"type": "object",
"properties": {
"start_year": { "type": "integer", "minimum": 1901 },
"email": { "type": "string", "nullable": true },
"website": { "type": "string" },
"ref": { "type": "string", "description": "Two uppercase letters, hyphen, four digits" },
"role_code": { "type": "string", "enum": ["L3-TECH", "L3-OPS"] }
},
"required": ["start_year", "email", "website", "ref", "role_code"]
}
注意 role_code 在 required 中排在最后,根据规则 1。
一个格式错误的 schema 不会优雅降级。2026-08-27,一个 prompt 中的一处 "type": ["string", "null"] 联合类型导致该 prompt 的每次执行都失败,直到移除那个联合类型。
这是最耗时间的一条,因为标签把你引到了错误的系统。
Provider 实际返回的内容:
400 . Request contains an invalid argument.
没有字段指针、没有属性名、没有提及大小或枚举。每次重试、每个 key、每个服务层级都确定性发生。
操作员最终看到的是:
processing_error PROVIDER_EXHAUSTED: Provider capacity unavailable
error_reason_code PROVIDER_EXHAUSTED
model (empty)
链路是:400,然后是 API 错误,然后 key 断路器打开,然后重试阶梯耗尽,然后报告的是最后一个事件而不是第一个。它读起来像是一次瞬时容量调度。实际上是一个永久的 schema 缺陷。
真正的错误只存活在一行 WARN 里,在运行 worker 的那个副本上,而通常不是记录了提交的那个副本。grep 所有副本:
for P in $(kubectl -n <ns> get pods -o name | grep -E "^pod/ai-" | grep -v db); do
kubectl -n <ns> logs $P --since=2h \
| grep -E "invalid argument|Circuit OPEN|keys exhausted"
done
失败耗时什么也说明不了。我们见过同一个拒绝原因 30 秒和 165 秒的情况,曾短暂地把慢的那个解读为"这个模型接受了这个 schema"。它并没有。差异来自重试驻留。
如果你把解析后的响应存在一个 jsonb 列,那个列会丢失键的顺序。改读原始响应文本:
const raw = require('fs').readFileSync('raw.json', 'utf8').trim();
Object.keys(JSON.parse(raw)).forEach((k, i) => console.log(`${i + 1}. ${k}`));
与你的 required 数组对比。如果有分歧,说明排序定律对你的模型家族已经变了,需要重新测量。
$ref、无 oneOf、无类型数组、使用 nullable、不超过 5 层)。这五条规则中有两条描述的是 Google 不文档化的限制,而且都是通过打爆生产环境才发现的。如果你在任何规模上运行结构化输出,为你自己的模型家族测量它们,并记下你自己的数字。否则下个季度你会以同样的代价重新发现它们。