LLM 结构化输出的契约化工程:JSON Mode、Schema 约束与重试降级

LLM 结构化输出的契约化工程:JSON Mode、Schema 约束与重试降级

大模型最擅长输出散文,而工程系统只接受结构。当你让 LLM "输出 JSON"时,真实世界会发生什么:多余一句"好的,以下是";代码块围栏 ````json`;字段名大小写漂移;数字变字符串;幻觉出 schema 里不存在的时间戳。本文系统拆解 LLM 结构化输出的工程化方案------从解码层约束、提示层约束到解析层防御、重试与降级,把"玄学采样"变成"有 SLA 的接口"。

一、失败模式清单:先知道会怎么坏

在生产环境收集到的 LLM 结构化输出失败模式,按出现频率排序:

  1. 围栏与前言:````json ... ``` ` 包裹,或开头一句 "Sure! Here's the JSON:"。
  2. 截断max_tokens 不够,JSON 断在半截------{"segments":[{"id":"a1" 然后没了。
  3. 字段漂移start 写成 startTimeduration 写成 length
  4. 类型漂移"duration": "4.2"(字符串)、"subtitleIds": "sub-1,sub-2"(拼接字符串)。
  5. 约束违反duration: -3start > sourceOut、引用了不存在的素材 ID。
  6. 幻觉实体 :编造 assetId: "asset-999"、给不存在的时间戳。
  7. 格式陷阱 :单引号、尾逗号、注释 //、NaN/Infinity 字面量。
  8. 语言混杂:要求中文文本,返回里混入英文标点或翻译腔。

每一类都需要不同的防线------没有一招通吃。

二、解码层约束:让模型"物理上"无法输出非法 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')
}

关键细节:

  • 错误要具体 :把 pathmessage 原样给模型,比笼统的"格式错了"修复成功率高一个量级。实测两轮内修复率通常 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 输出在数据层毫无区别。这就是"契约"的含义:上游可换,边界不变

七、成本与延迟的结构性优化

结构化输出天然适合两个优化:

  1. 分批流水:一个 10 分钟视频切成 30 段,与其一次生成 30 段(输出 4000+ token,截断风险高、延迟 30s+),不如按分钟分窗生成,每窗 3 段。窗口间只传"衔接上下文"(上一窗最后一段的关键词),token 成本降 70%,首段延迟降 80%。
  2. 缓存与幂等 :以 hash(视频指纹 + prompt 版本 + schema 版本) 为缓存键,同素材重复分析直接命中。prompt 一旦变更,schema 版本号联动升级,旧缓存自然失效------避免"改了 prompt 但线上还跑旧逻辑"的脏缓存。

八、小结

防线 拦截的失败模式 残余风险
解码约束(JSON Schema) 围栏、前言、类型错误、字段缺失 语义约束、引用完整性
提示工程(schema + few-shot + 负面清单) 字段漂移、格式陷阱、部分幻觉 长尾漂移
解析防御(剥围栏 + 修复 + Zod) 尾逗号、智能引号、别名漂移 深度语义错误
自修复循环(issue 回喂) 首轮各类错误 多轮仍失败的少数派
降级链(备用模型 → 规则引擎) 服务不可用、持续失败 质量下降(可感知可控)

核心思想一句话:把 LLM 当成一个"大概率可靠但偶发越界"的外部服务,用传统分布式系统的手段(契约、校验、重试、降级、缓存)来治理它。模型会换代,prompt 会调优,但这条防御纵深是不变的。

相关推荐
༄久梦༒长醉༻1 小时前
AI面试助手:本地运行,一键备战
前端·ai编程
Web4Browser1 小时前
浏览器指纹逆向到底在分析什么:从 JavaScript 采集、Canvas 与 WebGL 到环境一致性校验
java·服务器·前端
2601_958352901 小时前
不改PCB也能调拾音方向?真相在这。
前端·嵌入式硬件·回声消除·语音模块·降噪处理
zhanghaha13141 小时前
HTML系列教程:17_HTML 框架(frameset、frame、iframe)零基础详解
前端·html
tedcloud1231 小时前
emilkowalski/skills:让 AI 写出来的网页更有“设计感”
服务器·人工智能·开源·音视频·ai编程
IT_陈寒1 小时前
React的useEffect依赖项居然骗了我三年
前端·人工智能·后端
一个金牛座的前端2 小时前
AI 写前端,优化的是演示,不是交付
前端·ai·cursor
OxYGC2 小时前
[AI工程] Spring AI第一篇:2.0 到底升级了什么?从 Prompt、RAG、MCP 到 Agent 应用实战
ai·ai编程·ai-native
AINative软件工程2 小时前
LLM 应用的 Bulkhead 隔离工程实践:用舰壁模式防止一个功能的过载拖左整个 AI 系统
后端·llm·ai编程