Skip to content

06-输出格式约束

要点

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

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

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

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

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

typescript
// 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。

typescript
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,仍然需要做结构校验:

typescript
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。

typescript
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,可以用文本模板约束输出格式,然后用正则表达式提取。

typescript
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. 格式约束的设计原则

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

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

typescript
// ❌ 文字描述格式
const prompt = `
输出 JSON 对象,包含 orderId 字符串字段和 action 字符串字段。
`

// ✅ 给格式示例
const prompt = `
输出 JSON 对象,格式如下:

{"orderId": "ORD-001", "action": "refund"}

字段说明:
- orderId: 订单号,字符串
- action: 操作类型,可选值:refund / query / cancel
`

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

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

typescript
// ❌ 混在一起
const prompt = '从用户输入提取订单号并输出 JSON 格式包含 orderId 字段'

// ✅ 分开
const prompt = `
## 任务
从用户输入中提取订单号和操作类型。

## 输出格式
JSON 对象:
{"orderId": "string", "action": "string"}
`

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

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

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

typescript
const prompt = `
提取订单信息。如果某项信息无法从输入中提取:
- orderId 填写 "UNKNOWN"
- action 填写 "unknown"

不要编造不存在的订单号。
`

原则四:限制输出长度

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

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

4. 在服务端做输出兜底

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

typescript
// 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')) {
    cleaned = cleaned.slice(7)
  } else if (cleaned.startsWith('```')) {
    cleaned = cleaned.slice(3)
  }
  if (cleaned.endsWith('```')) {
    cleaned = cleaned.slice(0, -3)
  }
  cleaned = cleaned.trim()

  // 第二步:尝试 JSON 解析
  let parsed: unknown
  try {
    parsed = JSON.parse(cleaned)
  } catch {
    // 第三步:尝试从文本中提取 JSON
    const jsonMatch = cleaned.match(/\{[\s\S]*\}/)
    if (jsonMatch) {
      try {
        parsed = JSON.parse(jsonMatch[0])
      } catch {
        return { ok: false, error: 'JSON_PARSE_FAILED', raw }
      }
    } else {
      return { ok: false, error: 'NO_JSON_FOUND', raw }
    }
  }

  // 第四步:结构校验
  if (!validator(parsed)) {
    return { ok: false, error: 'SCHEMA_VALIDATION_FAILED', raw }
  }

  return { ok: true, data: parsed }
}

// 使用
const result = parseModelOutput(
  response.choices[0].message.content,
  isExtractResult
)

if (!result.ok) {
  // 记录日志,返回默认值或重试
  console.error('Model output parse failed:', result.error, result.raw)
  return c.json({ error: '解析失败,请重试' }, 500)
}

// result.data 类型安全
return c.json(result.data)

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

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

5. 格式稳定性测试

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

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

typescript
// tests/prompt/format-stability.test.ts
import { callModelAPI } from '@/lib/llm'

describe('输出格式稳定性', () => {
  const prompt = buildExtractPrompt()
  const inputs = [
    '退订单 ORD-001',
    '查一下 ORD-002 的状态',
    '取消订单 ORD-003',
  ]

  for (const input of inputs) {
    it(`"${input}" 的格式在 10 次调用中保持一致`, async () => {
      const results = await Promise.all(
        Array.from({ length: 10 }, () =>
          callModelAPI({
            model: 'gpt-4o',
            messages: [
              { role: 'system', content: prompt },
              { role: 'user', content: input },
            ],
            response_format: { type: 'json_object' },
          })
        )
      )

      for (const result of results) {
        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')
      }
    })
  }
})

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

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

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

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

typescript
// src/services/prompt/intent-classifier.ts

const INTENT_SCHEMA = {
  type: 'object',
  properties: {
    intent: {
      type: 'string',
      enum: ['query_order', 'refund', 'cancel', 'complaint', 'other'],
    },
    confidence: { type: 'number', minimum: 0, maximum: 1 },
    entities: {
      type: 'object',
      properties: {
        orderId: { type: ['string', 'null'] },
        reason: { type: ['string', 'null'] },
      },
      required: ['orderId', 'reason'],
      additionalProperties: false,
    },
  },
  required: ['intent', 'confidence', 'entities'],
  additionalProperties: false,
} as const

const INTENT_PROMPT = `你是一个意图分类助手。

## 任务
分析用户输入,判断用户意图。

## 意图分类
- query_order: 查询订单状态、物流信息
- refund: 申请退款
- cancel: 取消订单
- complaint: 投诉
- other: 以上都不是

## 输出格式
JSON 对象,严格符合提供的 Schema。

## 示例
输入:「我的订单 ORD-001 到哪了」
输出:{"intent": "query_order", "confidence": 0.95, "entities": {"orderId": "ORD-001", "reason": null}}

输入:「这个商品质量太差了」
输出:{"intent": "complaint", "confidence": 0.9, "entities": {"orderId": null, "reason": "商品质量"}}
`

export async function classifyIntent(userInput: string) {
  const response = await callModelAPI({
    model: 'gpt-4o',
    messages: [
      { role: 'system', content: INTENT_PROMPT },
      { role: 'user', content: userInput },
    ],
    response_format: {
      type: 'json_schema',
      json_schema: {
        name: 'intent',
        strict: true,
        schema: INTENT_SCHEMA,
      },
    },
    temperature: 0,
  })

  return JSON.parse(response.choices[0].message.content) as {
    intent: 'query_order' | 'refund' | 'cancel' | 'complaint' | 'other'
    confidence: number
    entities: { orderId: string | null; reason: string | null }
  }
}

总结

回顾这一节的要点:

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

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

基于 MIT 协议开源