06-输出格式约束

要点

  • 上一节讲了约束条件,这一节专门拆最常用的一类:格式约束
  • 工程场景下,模型的输出需要被代码解析——格式不稳定意味着解析失败
  • 三种格式方案:JSON Mode、Structured Output、文本模板——可靠性递增,灵活性递减
  • 即使用了 JSON Mode,仍然需要在服务端做输出校验和兜底
  • 格式约束的测试重点是稳定性——同一种输入多次调用,输出格式是否一致

1. 为什么格式约束这么重要

前面的文章里,模型的输出是给人看的。格式乱一点、多几行空行、少一个标点,人都能理解。

但工程场景下,模型的输出是给代码解析的。格式不对,JSON.parse() 直接抛异常,整条链路断掉。

来看一个真实的失败场景:

// src/services/prompt/extract.ts
const prompt = `
从用户输入中提取订单信息,输出 JSON 格式。
`
 
// 用户输入
const userInput = '我要退订单 ORD-001'
 
// 模型输出(情况一)——正常
const output1 = '{"orderId": "ORD-001", "action": "refund"}'
JSON.parse(output1) // ✅ 正常解析
 
// 模型输出(情况二)——带 Markdown 代码块
const output2 = '```json\n{"orderId": "ORD-001", "action": "refund"}\n```'
JSON.parse(output2) // ❌ SyntaxError
 
// 模型输出(情况三)——带说明文字
const output3 = '好的,以下是提取的结果:\n{"orderId": "ORD-001", "action": "refund"}'
JSON.parse(output3) // ❌ SyntaxError
 
// 模型输出(情况四)——多了逗号
const output4 = '{"orderId": "ORD-001", "action": "refund",}'
JSON.parse(output4) // ❌ SyntaxError

同一条 Prompt,四次调用,四种不同的输出格式。情况二到四都会导致 JSON.parse() 失败。

这就是格式约束要解决的问题:让模型的输出格式稳定到代码可以可靠地解析。

2. 三种格式方案

从可靠性低到高,有三种格式方案。

方案一:Prompt 里要求 JSON + response_format

OpenAI 和 Anthropic 都支持 response_format: { type: 'json_object' } 参数,强制模型输出合法 JSON。

const response = await callModelAPI({
  model: 'gpt-4o',
  messages: [
    { role: 'system', content: '从用户输入提取订单信息,输出 JSON。' },
    { role: 'user', content: '退订单 ORD-001' },
  ],
  response_format: { type: 'json_object' },
})
 
const result = JSON.parse(response.choices[0].message.content)

response_format 保证输出是合法 JSON(不会出现情况三的说明文字),但不保证:

  • JSON 的结构符合你的预期(字段名可能不对)
  • 字段类型正确(数字可能变成字符串)
  • 不会多出不存在的字段

所以即使用了 response_format,仍然需要做结构校验:

function validateExtractResult(raw: unknown): ExtractResult | null {
  if (typeof raw !== 'object' || raw === null) return null
  const obj = raw as Record<string, unknown>
 
  if (typeof obj.orderId !== 'string') return null
  if (typeof obj.action !== 'string') return null
 
  return { orderId: obj.orderId, action: obj.action }
}

方案二:Structured Output(结构化输出)

OpenAI 的 Structured Output 比 response_format 更强——它要求你提供一个 JSON Schema,模型保证输出完全符合这个 Schema。

const response = await callModelAPI({
  model: 'gpt-4o',
  messages: [
    { role: 'system', content: '从用户输入提取订单信息。' },
    { role: 'user', content: '退订单 ORD-001' },
  ],
  response_format: {
    type: 'json_schema',
    json_schema: {
      name: 'extract_result',
      strict: true,
      schema: {
        type: 'object',
        properties: {
          orderId: { type: 'string' },
          action: { type: 'string', enum: ['refund', 'query', 'cancel'] },
        },
        required: ['orderId', 'action'],
        additionalProperties: false,
      },
    },
  },
})
 
// 输出保证符合 Schema,可以直接用
const result = JSON.parse(response.choices[0].message.content) as {
  orderId: string
  action: 'refund' | 'query' | 'cancel'
}

Structured Output 是目前最可靠的格式保证方案。模型保证输出符合 Schema,不需要额外的校验逻辑。

限制:

  • 不是所有模型都支持
  • Schema 不能太复杂(嵌套层级有限制)
  • 有些字段类型可能受限

方案三:文本模板 + 正则提取

如果模型不支持 JSON Mode 或 Structured Output,可以用文本模板约束输出格式,然后用正则表达式提取。

const prompt = `
从用户输入中提取订单信息。按以下格式输出:
 
ORDER_ID: <订单号>
ACTION: <操作类型>
 
如果无法提取某项信息,填写 UNKNOWN。
`
 
// 模型输出
const output = `
ORDER_ID: ORD-001
ACTION: refund
`
 
// 正则提取
const orderIdMatch = output.match(/ORDER_ID:\s*(.+)/)
const actionMatch = output.match(/ACTION:\s*(.+)/)
 
const result = {
  orderId: orderIdMatch?.[1]?.trim() ?? 'UNKNOWN',
  action: actionMatch?.[1]?.trim() ?? 'UNKNOWN',
}

文本模板的好处是兼容所有模型,坏处是需要自己写解析逻辑,而且模型偶尔会不严格遵守模板格式。

3. 格式约束的设计原则

原则一:给模型看具体的格式示例

与其用文字描述格式,不如直接给一个示例。

// ❌ 文字描述格式
const prompt = `
输出 JSON 对象,包含 orderId 字符串字段和 action 字符串字段。
`
 
// ✅ 给格式示例
const prompt = `
输出 JSON 对象,格式如下:
 
{"orderId": "ORD-001", "action": "refund"}
 
字段说明:
- orderId: 订单号,字符串
- action: 操作类型,可选值:refund / query / cancel
`

模型更容易从示例中学到格式,而不是从文字描述中。

原则二:格式描述和任务描述分开

// ❌ 混在一起
const prompt = '从用户输入提取订单号并输出 JSON 格式包含 orderId 字段'
 
// ✅ 分开
const prompt = `
## 任务
从用户输入中提取订单号和操作类型。
 
## 输出格式
JSON 对象:
{"orderId": "string", "action": "string"}
`

格式和任务混在一句话里,模型很难同时处理两件事。分开之后,模型知道「做什么」和「怎么输出」是两件事。

原则三:处理「无法提取」的情况

模型遇到无法提取的信息时,如果没有预设的处理方式,可能会编造数据或者破坏格式。

const prompt = `
提取订单信息。如果某项信息无法从输入中提取:
- orderId 填写 "UNKNOWN"
- action 填写 "unknown"
 
不要编造不存在的订单号。
`

原则四:限制输出长度

长输出更容易出现格式问题。如果输出结构是固定的,限制长度可以减少格式偏差的概率。

const prompt = `
输出 JSON 对象,不超过 200 字符。
`

4. 在服务端做输出兜底

不管用了什么格式保证方案,服务端都需要做兜底。模型输出不完全符合预期的情况永远存在。

// src/services/prompt/output-guard.ts
 
type ParsedOutput<T> =
  | { ok: true; data: T }
  | { ok: false; error: string; raw: string }
 
/**
 * 解析模型输出,带兜底
 */
function parseModelOutput<T>(
  raw: string,
  validator: (data: unknown) => data is T
): ParsedOutput<T> {
  // 第一步:去掉可能的 Markdown 代码块标记
  let cleaned = raw.trim()
  if (cleaned.startsWith('```json')) &#123;
    cleaned = cleaned.slice(7)
  &#125; else if (cleaned.startsWith('```')) {
    cleaned = cleaned.slice(3)
  }
  if (cleaned.endsWith('```')) &#123;
    cleaned = cleaned.slice(0, -3)
  &#125;
  cleaned = cleaned.trim()
 
  // 第二步:尝试 JSON 解析
  let parsed: unknown
  try &#123;
    parsed = JSON.parse(cleaned)
  &#125; catch &#123;
    // 第三步:尝试从文本中提取 JSON
    const jsonMatch = cleaned.match(/\&#123;[\s\S]*\&#125;/)
    if (jsonMatch) &#123;
      try &#123;
        parsed = JSON.parse(jsonMatch[0])
      &#125; catch &#123;
        return &#123; ok: false, error: 'JSON_PARSE_FAILED', raw &#125;
      &#125;
    &#125; else &#123;
      return &#123; ok: false, error: 'NO_JSON_FOUND', raw &#125;
    &#125;
  &#125;
 
  // 第四步:结构校验
  if (!validator(parsed)) &#123;
    return &#123; ok: false, error: 'SCHEMA_VALIDATION_FAILED', raw &#125;
  &#125;
 
  return &#123; ok: true, data: parsed &#125;
&#125;
 
// 使用
const result = parseModelOutput(
  response.choices[0].message.content,
  isExtractResult
)
 
if (!result.ok) &#123;
  // 记录日志,返回默认值或重试
  console.error('Model output parse failed:', result.error, result.raw)
  return c.json(&#123; error: '解析失败,请重试' &#125;, 500)
&#125;
 
// result.data 类型安全
return c.json(result.data)

这个兜底逻辑覆盖了四种常见情况:

  1. 模型输出了纯 JSON——直接解析
  2. 模型输出了 Markdown 代码块包裹的 JSON——去掉标记再解析
  3. 模型在 JSON 前后加了说明文字——用正则提取 JSON 部分
  4. JSON 结构不符合预期——返回错误

5. 格式稳定性测试

格式约束写完不算完——需要验证格式的稳定性。

核心测试思路:同一个输入,调用 N 次,检查每次输出的格式是否一致。

// tests/prompt/format-stability.test.ts
import &#123; callModelAPI &#125; from '@/lib/llm'
 
describe('输出格式稳定性', () => &#123;
  const prompt = buildExtractPrompt()
  const inputs = [
    '退订单 ORD-001',
    '查一下 ORD-002 的状态',
    '取消订单 ORD-003',
  ]
 
  for (const input of inputs) &#123;
    it(`"$&#123;input&#125;" 的格式在 10 次调用中保持一致`, async () => &#123;
      const results = await Promise.all(
        Array.from(&#123; length: 10 &#125;, () =>
          callModelAPI(&#123;
            model: 'gpt-4o',
            messages: [
              &#123; role: 'system', content: prompt &#125;,
              &#123; role: 'user', content: input &#125;,
            ],
            response_format: &#123; type: 'json_object' &#125;,
          &#125;)
        )
      )
 
      for (const result of results) &#123;
        const content = result.choices[0].message.content
        // 每次输出都是合法 JSON
        expect(() => JSON.parse(content)).not.toThrow()
 
        const parsed = JSON.parse(content)
        // 每次输出都包含必需字段
        expect(parsed).toHaveProperty('orderId')
        expect(parsed).toHaveProperty('action')
        // 字段类型正确
        expect(typeof parsed.orderId).toBe('string')
        expect(typeof parsed.action).toBe('string')
      &#125;
    &#125;)
  &#125;
&#125;)

这个测试会告诉你:在当前 Prompt 和模型版本下,格式稳定性是多少。如果 10 次里有 2 次格式不对,就知道这条 Prompt 还需要调整。

6. 不同场景的格式方案选择

场景推荐方案原因
下游需要 JSON 解析Structured Output > JSON Mode可靠性最高
输出需要展示给用户文本模板 + Markdown灵活性高,人可以容忍格式偏差
模型不支持 JSON Mode文本模板 + 正则提取兜底方案
输出格式复杂(嵌套多层)Structured Output 或多次调用复杂格式一次生成容易出错
需要同时输出多种内容多次调用,每种内容一个 Prompt分而治之,格式更稳定

7. 一个完整的格式约束示例

// src/services/prompt/intent-classifier.ts
 
const INTENT_SCHEMA = &#123;
  type: 'object',
  properties: &#123;
    intent: &#123;
      type: 'string',
      enum: ['query_order', 'refund', 'cancel', 'complaint', 'other'],
    &#125;,
    confidence: &#123; type: 'number', minimum: 0, maximum: 1 &#125;,
    entities: &#123;
      type: 'object',
      properties: &#123;
        orderId: &#123; type: ['string', 'null'] &#125;,
        reason: &#123; type: ['string', 'null'] &#125;,
      &#125;,
      required: ['orderId', 'reason'],
      additionalProperties: false,
    &#125;,
  &#125;,
  required: ['intent', 'confidence', 'entities'],
  additionalProperties: false,
&#125; as const
 
const INTENT_PROMPT = `你是一个意图分类助手。
 
## 任务
分析用户输入,判断用户意图。
 
## 意图分类
- query_order: 查询订单状态、物流信息
- refund: 申请退款
- cancel: 取消订单
- complaint: 投诉
- other: 以上都不是
 
## 输出格式
JSON 对象,严格符合提供的 Schema。
 
## 示例
输入:「我的订单 ORD-001 到哪了」
输出:&#123;"intent": "query_order", "confidence": 0.95, "entities": &#123;"orderId": "ORD-001", "reason": null&#125;&#125;
 
输入:「这个商品质量太差了」
输出:&#123;"intent": "complaint", "confidence": 0.9, "entities": &#123;"orderId": null, "reason": "商品质量"&#125;&#125;
`
 
export async function classifyIntent(userInput: string) &#123;
  const response = await callModelAPI(&#123;
    model: 'gpt-4o',
    messages: [
      &#123; role: 'system', content: INTENT_PROMPT &#125;,
      &#123; role: 'user', content: userInput &#125;,
    ],
    response_format: &#123;
      type: 'json_schema',
      json_schema: &#123;
        name: 'intent',
        strict: true,
        schema: INTENT_SCHEMA,
      &#125;,
    &#125;,
    temperature: 0,
  &#125;)
 
  return JSON.parse(response.choices[0].message.content) as &#123;
    intent: 'query_order' | 'refund' | 'cancel' | 'complaint' | 'other'
    confidence: number
    entities: &#123; orderId: string | null; reason: string | null &#125;
  &#125;
&#125;

总结

回顾这一节的要点:

  • 格式约束解决的是「模型输出能被代码可靠解析」的问题
  • 三种方案:JSON Mode、Structured Output、文本模板——可靠性递增
  • 即使用了 JSON Mode,仍然需要在服务端做结构校验和兜底
  • 格式设计原则:给示例、任务与格式分开、处理「无法提取」的情况、限制长度
  • 格式稳定性测试:同一输入多次调用,检查格式一致性
  • 兜底逻辑覆盖:纯 JSON、Markdown 包裹、带说明文字、结构不符

下一篇讲 Few-shot 示例设计——怎么用示例提升模型的输出质量。