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')) {
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)这个兜底逻辑覆盖了四种常见情况:
- 模型输出了纯 JSON——直接解析
- 模型输出了 Markdown 代码块包裹的 JSON——去掉标记再解析
- 模型在 JSON 前后加了说明文字——用正则提取 JSON 部分
- JSON 结构不符合预期——返回错误
5. 格式稳定性测试
格式约束写完不算完——需要验证格式的稳定性。
核心测试思路:同一个输入,调用 N 次,检查每次输出的格式是否一致。
// 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. 一个完整的格式约束示例
// 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 示例设计——怎么用示例提升模型的输出质量。