LLM 结构化输出的契约化工程:JSON Mode、Schema 约束与重试降级
大模型最擅长输出散文,而工程系统只接受结构。当你让 LLM "输出 JSON"时,真实世界会发生什么:多余一句"好的,以下是";代码块围栏 ````json`;字段名大小写漂移;数字变字符串;幻觉出 schema 里不存在的时间戳。本文系统拆解 LLM 结构化输出的工程化方案------从解码层约束、提示层约束到解析层防御、重试与降级,把"玄学采样"变成"有 SLA 的接口"。
一、失败模式清单:先知道会怎么坏
在生产环境收集到的 LLM 结构化输出失败模式,按出现频率排序:
- 围栏与前言:````json ... ``` ` 包裹,或开头一句 "Sure! Here's the JSON:"。
- 截断 :
max_tokens不够,JSON 断在半截------{"segments":[{"id":"a1"然后没了。 - 字段漂移 :
start写成startTime,duration写成length。 - 类型漂移 :
"duration": "4.2"(字符串)、"subtitleIds": "sub-1,sub-2"(拼接字符串)。 - 约束违反 :
duration: -3、start > sourceOut、引用了不存在的素材 ID。 - 幻觉实体 :编造
assetId: "asset-999"、给不存在的时间戳。 - 格式陷阱 :单引号、尾逗号、注释
//、NaN/Infinity 字面量。 - 语言混杂:要求中文文本,返回里混入英文标点或翻译腔。
每一类都需要不同的防线------没有一招通吃。
二、解码层约束:让模型"物理上"无法输出非法 JSON
最可靠的方案是把校验前移到解码过程:
Structured Output / JSON Schema 约束解码
主流推理服务支持传入 JSON Schema,推理引擎(vLLM/outlines/ guided decoding)在每一步 token 采样时屏蔽会让输出违反 schema 的 token:
ts
const response = await client.chat.completions.create({
model: '...',
messages: [...],
response_format: {
type: 'json_schema',
json_schema: {
name: 'video_segments',
strict: true,
schema: zodToJsonSchema(aiSegmentsResponseSchema),
},
},
})
效果:字段名、类型、必填性、枚举值在解码层就锁死,围栏/前言/尾逗号物理消失。局限:
strict: true模式下多数实现不支持additionalProperties,复杂正则 pattern 支持有限。- 语义约束仍管不住 :schema 能锁
duration: number,锁不住duration >= 0;能锁 ID 是字符串,锁不住 ID 真实存在。引用完整性必须留在应用层。 - schema 太复杂(深层嵌套 + 大量 anyOf)会让约束自动机退化,输出质量下降。生产经验:给 LLM 的 schema 要比内部 schema "矮一层"------扁平、字段少、枚举值有限。
Function Calling 的契约本质
Function calling / tool use 本质同上:工具的 parameters 就是 schema,模型被迫产出符合签名的调用。区别在语义层------模型知道"这是在调用一个工具",Few-shot 和描述质量对字段语义准确性影响更大。
三、提示层约束:Prompt 工程的四个铁律
即便有解码约束,提示词仍决定内容质量。结构化输出的 prompt 有四条铁律:
1. Schema 即提示
把目标 JSON 的骨架直接贴进 prompt,逐字段注释语义与取值域:
text
输出严格符合以下结构的 JSON(不要任何其他文字):
{
"segments": [
{
"id": "seg-1", // 唯一 ID,seg-N 递增
"scriptText": "...", // 这一段的口播稿,中文,15~60 字
"startSeconds": 0.0, // 原素材中建议起点,非负
"durationSeconds": 4.0, // 时长,0.5 ~ 15
"keywords": ["..."] // 2~5 个内容关键词
}
]
}
2. 用 One-shot 或 Few-shot 示例锚定格式
一个完整、边界清晰的示例胜过十行描述。示例要覆盖"容易错"的情形------比如时长必须保留一位小数、关键词必须是数组而非顿号分隔字符串。
3. 显式声明负面清单
text
禁止:markdown 代码块、JSON 前后的解释文字、注释、尾逗号、
单引号、NaN、负数时长、引用未提供的素材 ID。
听起来像复读机,但在没有解码约束的模型上,负面清单能把格式错误率砍半。
4. 让模型"只补齐不发明"
上下文里给出素材清单(ID + 简述),并明确:"assetId 只能从下列清单中选择,禁止发明新 ID"。这直接对冲幻觉实体问题------模型无法引用没见过的东西(概率上)。
四、解析层防御:把"文本"变成"数据"的安全通道
无论上游多可靠,解析层必须假设输入是敌意的。一个生产级的解析器分四步:
ts
function parseLlmJson<T>(raw: string, schema: z.ZodType<T>): T {
// 1. 剥围栏与前言:取第一个 { 或 [ 到最后一个 } 或 ]
const start = raw.search(/[{[]/)
const end = Math.max(raw.lastIndexOf('}'), raw.lastIndexOf(']'))
if (start < 0 || end <= start) throw new LlmFormatError('no json found')
const body = raw.slice(start, end + 1)
// 2. 宽容修复:尾逗号、智能引号、NaN
const cleaned = body
.replace(/,\s*([}\]])/g, '$1') // 尾逗号
.replace(/[\u201c\u201d]/g, '"') // 智能引号
.replace(/\bNaN\b/g, 'null')
// 3. JSON.parse
let data: unknown
try {
data = JSON.parse(cleaned)
} catch (e) {
throw new LlmFormatError('json parse failed', { cause: e })
}
// 4. Zod 校验 + 常见漂移的字段别名归一化
return schema.parse(normalizeAliases(data))
}
function normalizeAliases(data: any): any {
// startTime → start, length → duration...... 上线前统计 top 漂移别名建映射表
if (Array.isArray(data)) return data.map(normalizeAliases)
if (data && typeof data === 'object') {
const out: any = {}
for (const [k, v] of Object.entries(data)) {
out[ALIAS_MAP[k] ?? k] = normalizeAliases(v)
}
return out
}
return data
}
safeParse 失败时,把 Zod issues 序列化回 prompt 再喂给模型------这是下一节的重试机制。
五、重试与自修复:错误反馈闭环
第一次输出失败不该直接报错给用户。工程上有一个高效的自修复循环:
ts
async function generateWithRepair<T>(
basePrompt: string,
schema: z.ZodType<T>,
maxAttempts = 3,
): Promise<T> {
let messages: ChatMessage[] = [{ role: 'user', content: basePrompt }]
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const raw = await llm.chat(messages)
try {
return parseLlmJson(raw, schema)
} catch (e) {
if (e instanceof ZodError) {
// 校验失败:把 issue 精确回喂
messages = [
{ role: 'user', content: basePrompt },
{ role: 'assistant', content: raw },
{
role: 'user',
content:
`你上一轮的输出未通过校验,错误如下:\n` +
e.issues.map(i => `- 路径 ${i.path.join('.')}: ${i.message}`).join('\n') +
`\n请修正后重新输出完整 JSON,仍然不要任何额外文字。`,
},
]
} else if (attempt < maxAttempts) {
continue // 解析失败:简单重试(temperature 可降 0.2)
} else {
throw e
}
}
}
throw new LlmFormatError('exceeded max attempts')
}
关键细节:
- 错误要具体 :把
path和message原样给模型,比笼统的"格式错了"修复成功率高一个量级。实测两轮内修复率通常 90%+。 - 温度策略 :首轮
temperature: 0.7保创意,修复轮降到0.2保格式。 - 截断要单独处理 :检测到 finish_reason 是
length时,不要重试同样参数------要么增大max_tokens,要么改用分批生成(每次只生成 N 个分段,循环多次),后者还顺带解决超长输出的质量衰减。
六、降级链:LLM 不可用时的优雅退化
生产系统必须假设 LLM 会超时、限流、输出持续不合格。降级链示例:
yaml
Tier 1: 主模型 + 解码约束 + 自修复循环
↓ 失败
Tier 2: 备用模型(不同厂商)+ 同 schema
↓ 失败
Tier 3: 规则引擎兜底------按固定时长均匀切段,用 ASR 标点断句生成 scriptText
↓ 始终可用
Tier 4: 空草稿 + 引导文案("AI 分析暂不可用,已为你创建空白工程")
规则引擎兜底的价值常被低估:标点断句 + 均匀分段能覆盖 60% 的"够用"场景,用户甚至感知不到 AI 掉线。降级不是失败,是产品设计的一部分。
关键约束:无论哪一层产出,最终都要过同一套 Zod schema 校验------Tier 3 的规则输出和 Tier 1 的 LLM 输出在数据层毫无区别。这就是"契约"的含义:上游可换,边界不变。
七、成本与延迟的结构性优化
结构化输出天然适合两个优化:
- 分批流水:一个 10 分钟视频切成 30 段,与其一次生成 30 段(输出 4000+ token,截断风险高、延迟 30s+),不如按分钟分窗生成,每窗 3 段。窗口间只传"衔接上下文"(上一窗最后一段的关键词),token 成本降 70%,首段延迟降 80%。
- 缓存与幂等 :以
hash(视频指纹 + prompt 版本 + schema 版本)为缓存键,同素材重复分析直接命中。prompt 一旦变更,schema 版本号联动升级,旧缓存自然失效------避免"改了 prompt 但线上还跑旧逻辑"的脏缓存。
八、小结
| 防线 | 拦截的失败模式 | 残余风险 |
|---|---|---|
| 解码约束(JSON Schema) | 围栏、前言、类型错误、字段缺失 | 语义约束、引用完整性 |
| 提示工程(schema + few-shot + 负面清单) | 字段漂移、格式陷阱、部分幻觉 | 长尾漂移 |
| 解析防御(剥围栏 + 修复 + Zod) | 尾逗号、智能引号、别名漂移 | 深度语义错误 |
| 自修复循环(issue 回喂) | 首轮各类错误 | 多轮仍失败的少数派 |
| 降级链(备用模型 → 规则引擎) | 服务不可用、持续失败 | 质量下降(可感知可控) |
核心思想一句话:把 LLM 当成一个"大概率可靠但偶发越界"的外部服务,用传统分布式系统的手段(契约、校验、重试、降级、缓存)来治理它。模型会换代,prompt 会调优,但这条防御纵深是不变的。