结构化输出落地时,Schema 设计比模型选择更关键
补档说明:本文属于「AI 工程落地周记」系列,计划发布时间为 2025-04-02 14:30。当前先保留为草稿,后续补充真实案例、代码片段和复盘细节后再发布。
如果只讲原则,这篇文章还是会显得空。所以我直接拿一个真实得不能再真实的例子来说:退款工单分诊。
这类任务的目标通常是把用户自然语言输入转成一个后续系统能继续处理的对象,例如:
- 这是不是退款请求
- 订单号有没有识别到
- 用户给出的理由是什么
- 是否要转人工审核
团队最早的做法很常见:先让模型直接输出一段 JSON,大概长这样:
{
"title": "用户申请退款",
"summary": "用户表示重复支付,希望退款",
"tags": ["退款", "重复支付"]
}
看上去没毛病,前端也能展示,测试样本甚至都能过。但真正进系统后,这种 Schema 很快就会把问题暴露出来:它描述了内容,却没有描述动作边界。
真正让系统难受的不是模型会不会吐 JSON,而是下游根本不知道怎么用这个结果继续执行。
先看一个失败版本为什么不够用
上面那份 Schema 最大的问题,不是字段太少,而是它没有回答下游最关心的 5 个问题:
- 这到底是不是退款请求?
- 订单号是否识别成功?
- 理由是不是可执行分类,而不是一句摘要?
- 这次结果置信度高不高?
- 是否必须转人工?
你会发现,title、summary、tags 这些字段都更偏“展示层”,它们对前端写卡片有帮助,但对系统推进流程帮助很小。
于是实际问题就会出现:
- 有时候
reason被藏在summary里,后端还得二次解析 - 有时候模型没识别到订单号,但仍然生成了一段看似完整的摘要
- 有时候应该转人工,但因为结果“长得像样”,系统误以为可以自动继续
这不是模型不够努力,而是 Schema 压根没把系统边界定义清楚。
一个更像系统契约的版本应该是什么样
后来我们把分诊对象改成下面这个样子:
{
"intent": "refund_request",
"confidence": 0.83,
"order_id": "A12345",
"reason_code": "duplicate_payment",
"reason_text": "用户表示重复支付,希望退回其中一笔",
"missing_fields": [],
"needs_human_review": false,
"risk_flags": [],
"explanation": "识别到明确退款意图,包含订单号与可归类理由"
}
这个版本为什么更有用?因为它终于开始像“系统输入”而不是“内容展示”了。
1. intent 让流程分支可执行
系统可以据此决定接下来走退款、投诉还是咨询路径。
2. confidence 让不确定性被显式表达
它不一定完美,但至少系统不会再把所有结果都当成一样可信。
3. reason_code 和 reason_text 分开
一个给机器分流,一个给人阅读。之前把这两件事混在一起,是很多补丁的来源。
4. missing_fields 给了失败出口
没识别到订单号时,不必硬编,也不必让后端猜。
5. needs_human_review 给了人工接管的明确边界
这比后端靠猜更稳,也比在 Prompt 里写“高风险请谨慎”更可执行。
我后来总结出来的 4 个 Schema 设计原则
1. 先为机器设计,再为人设计
如果结果要进入系统流程,就先保证机器能准确消费,再考虑界面如何展示。
2. 不确定性必须有位置
很多系统的问题不是“模型完全胡说”,而是“不够确定但也没有表达自己不确定”。所以 confidence、missing_fields、risk_flags 这类字段不是装饰,而是边界表达。
3. 例外路径必须被编码
不能只定义“正常情况下会返回什么”,还要定义:
- 缺字段怎么办
- 多候选怎么办
- 冲突信息怎么办
- 是否需要人工审核
4. 失败后的动作要和 Schema 一起设计
结构化输出不是“先吐一个对象,再看看能不能用”,而是“这个对象如果不合法,系统下一步做什么”。
一个坏 Schema 和一个好 Schema 的区别,不在字段多少
很多人做结构化输出,会不自觉地把问题理解成“字段够不够全”。其实更关键的是字段是否对应系统决策。
看下面这个对比:
坏 Schema
{
"title": "退款申请",
"summary": "用户希望退款",
"priority": "high"
}
问题在于:
priority的语义不清- 没有明确的流程意图
- 没有缺失信息出口
- 没有人工审核边界
更好的 Schema
{
"intent": "refund_request",
"confidence": 0.83,
"order_id": null,
"reason_code": "unknown",
"missing_fields": ["order_id"],
"needs_human_review": true
}
它看起来没那么“好看”,但系统更容易接住。
真正落地时,校验代码必须跟着 Schema 一起存在
只定义 Schema 而不做严格校验,等于没定义。
后来我们的校验逻辑大致是这样:
const RefundTriageSchema = z.object({
intent: z.enum(['refund_request', 'complaint', 'consultation']),
confidence: z.number().min(0).max(1),
order_id: z.string().nullable(),
reason_code: z.enum([
'duplicate_payment',
'wrong_item',
'late_delivery',
'unknown',
]),
missing_fields: z.array(z.string()),
needs_human_review: z.boolean(),
explanation: z.string(),
})
function validateOrFallback(payload: unknown) {
const parsed = RefundTriageSchema.safeParse(payload)
if (!parsed.success) {
return {
ok: false,
action: 'fallback_to_human',
reason: parsed.error.flatten(),
}
}
if (parsed.data.missing_fields.length > 0 || parsed.data.needs_human_review) {
return {
ok: true,
action: 'human_review',
data: parsed.data,
}
}
return {
ok: true,
action: 'continue',
data: parsed.data,
}
}
真正让系统稳下来的,不是“模型终于懂 JSON 了”,而是:
- Schema 定义了边界
- 校验把边界真正执行了
- fallback 让不合法结果不会继续污染下游
为什么我说 Schema 比模型选择更关键
因为如果 Schema 本身就模糊,你换再强的模型,也只是让一个模糊契约被更稳定地执行。结果当然可能好一点,但不会从根上变对。
而一旦 Schema 定义清楚,很多模型其实都能在合理范围内完成任务,剩下的才是成本、延迟、稳定性和供应商差异的比较。
顺序一定不能反:
- 先定义对象长什么样
- 再定义失败怎么处理
- 最后才比较谁更适合产出它
最后给一张能直接拿走的清单
只要结果要进入系统流程,我现在会强制过这 6 个问题:
- 这个字段是给机器用,还是给人看?
- 缺字段时系统怎么做?
- 不确定性是否有明确表达位?
- 是否需要人工审核标志?
- 校验失败后是重试、降级还是转人工?
- 下游是否真的只依赖 Schema 里的字段,而不再去猜正文?
总结
结构化输出落地时,模型当然重要,但真正决定系统能不能稳定跑起来的,是 Schema 有没有把系统边界讲清楚。
模型负责“尽量按要求输出”,Schema 负责“什么才算合格结果”。谁把这两者的责任搞反了,后面就一定会用大量补丁来还债。
