Skip to content

让大模型稳定吐 JSON 的两条路,一条先写死一条靠运气 ​

做分类、打标签、抽字段,这些活都要模型返回 JSON。裸调 API 拿回来的是一段文本,偶尔夹一句「好的,以下是 JSON」,JSON.parse 直接炸。这篇文章讲两套解法,事实部分核对于 2026-10-10。

一条路:把解码器写死 ​

OpenAI 的 Structured Outputs(结构化输出)是服务端层面的约束。它随 gpt-4o-2024-08-06 和 gpt-4o-mini-2024-07-18 两个快照上线(2024-08-06)。

思路是把你的 JSON Schema 编译进解码器。每生成一个 token,只允许采样那些不违反 schema 的 token,所以结构上不可能出错。

用法是把 response_format 设成 json_schema 并给 strict: true。Responses API 里参数挪到了 text.format。Batch API 和 function calling 也都支持,后者只需在函数定义里加 strict: true,覆盖面更宽(gpt-4-0613 之后的模型都行)。

老一点的 JSON mode(json_object)是另一回事。它只保证吐合法 JSON,不保证字段齐全、类型正确。我建议只要模型支持,一律用 Structured Outputs。

严格模式的三个限制 ​

第一,每个对象节点必须写 additionalProperties: false。漏一个就整个请求被拒,错误信息会指到具体路径,比如 additionalProperties must be false at #/properties/address。

第二,properties 里声明的每个字段都必须进 required,没有原生可选字段。想要可选,用 null 联合类型:"type": ["string", "null"]。

第三,schema 是 JSON Schema 的子集。oneOf、allOf、not 一直不支持。硬限制:整个 schema 最多 100 个属性、嵌套最多 5 层、enum 总共 500 个值。

还有一点容易漏。Structured Outputs 不支持并行工具调用,开了要同时设 parallel_tool_calls: false。

另一条路:AI SDK 的 Output ​

前端视角更省事的写法是 Vercel AI SDK。但要注意版本,6.0 起 generateObject 和 streamObject 标记弃用。

现在推荐 generateText 配 output: Output.object({ schema }),schema 用 zod(TypeScript 的 schema 校验库)写。

ts
import { generateText, Output } from 'ai';
import { z } from 'zod';

const { output } = await generateText({
  model: 'openai/gpt-4o-mini',
  output: Output.object({
    schema: z.object({
      sentiment: z.enum(['positive', 'negative', 'neutral']),
      keywords: z.array(z.string()),
      reason: z.string().nullable(), // 可选字段在应用层判 null
    }),
  }),
  prompt: '分析这条评论:' + comment,
});

console.log(output.sentiment);

output 直接是带类型的对象。zod 负责校验和补默认值,比如自动注入 additionalProperties: false。

流式场景用 streamText 加同样的 output,配合 partialOutputStream 能拿到逐步补全的半成品对象。

两条路怎么选 ​

我的判断是:模型支持 Structured Outputs 就让它兜底,AI SDK 的 Output 只是省你写 schema 翻译的糖。

注意 AI SDK 遇到不支持的厂商时会退化成 prompt 工程加 zod 重试。此时「保证」就变成了「大概率」。

我踩过的两个坑 ​

一个是 OpenAI 响应里的 refusal 字段。模型可能因为安全策略拒绝输出,此时 content 是 null,refusal 里有原因。

我只判了 content 就上线,用户偶尔收到一个空卡片。排查半天才发现要先看 refusal。

另一个是 null 联合类型的位置。字段标了 "type": ["string", "null"] 但它仍在 required 里,前端拿到的对象永远有这个 key,值可能是 null。

判空逻辑要写在每一处读取点。不能依赖「字段可能不存在」。

最近更新