大模型结构化输出怎么选:从 JSON.parse、OutputParser 到 Zod

6 阅读3分钟

大模型结构化输出怎么选:从 JSON.parse、OutputParser 到 Zod

我在 Agent 里第一次需要“让模型返回 JSON”时,做法很直接:在提示词里写一句“只返回 JSON”,然后对结果调用 JSON.parse()。很快就踩坑了——模型有时会把 JSON 包在 Markdown 代码块里,有时多解释一句,有时字段类型不对。人看起来没问题,程序却在下一步直接报错。

结构化输出(Structured Output)解决的不是排版问题,而是“如何让不稳定的自然语言进入稳定的程序流程”。我的实现经历了几次演进:手写解析、LangChain OutputParser、Zod Schema、tool call,以及流式输出和 SSE。

先说明一个容易混淆的 API:当前依赖版本中的方法是 ChatOpenAI.prototype.withStructuredOutput;withStructuralOutput 在该版本中不存在。涉及真实模型调用的示例需要自行配置 API Key。

1. 最脆弱的方案:提示词 + JSON.parse

最小实现大概是这样:

const prompt = `
请返回人物信息,只输出 JSON:
{
  "name": "姓名",
  "occupation": "职业"
}
`;

const response = await model.invoke(prompt);
const result = JSON.parse(response.content);

问题在于,模型面向人类输出时很喜欢补 Markdown:

模型可能先输出“下面是结果:”,再附带一个 Markdown json 代码围栏。此时响应不再是可以直接交给 JSON.parse() 的纯 JSON 字符串。

这时直接 JSON.parse 会失败。可以先用正则移除代码围栏,但这只是补丁:如果模型增加说明文字、漏字段或把数组写成字符串,仍然要继续补规则。

方案 A 是继续强化提示词和正则;方案 B 是让解析规则成为一个明确组件。一次性脚本可以用 A,进入多节点 Agent 流程后,我更倾向 B,因为错误必须在边界处暴露,不能带到下游。

2. OutputParser:把格式说明和解析放在一起

LangChain 的 OutputParser 把两件事合并起来:

  1. getFormatInstructions() 生成给模型看的格式约束;
  2. parse() 把模型文本转换为程序对象。

以 XML 为例:

import { XmlOutputParser } from "@langchain/output-parsers";

const parser = new XmlOutputParser();
const question = `
请提取文本中的人物信息:
爱因斯坦生于 1879 年,是一位物理学家。

${parser.getFormatInstructions()}
`;

const response = await model.invoke(question);
const result = await parser.parse(response.content);
console.log(result);

这比“自己约定格式、自己写正则”更内聚,也适合 JSON 之外的 XML 等格式。不过解析器仍然工作在“模型先生成文本,程序再解析”的路径上。模型输出不合法时,解析依然可能失败。

3. 用 Zod 把字段约束写成代码

当下游代码真正依赖字段时,我更希望约束能被类型系统和运行时共同理解。Zod 是 JavaScript/TypeScript 常用的 Schema 校验库,可以描述字段类型、数组结构和字段含义:

import { z } from "zod";

const ScientistSchema = z.object({
  name: z.string().describe("科学家的姓名"),
  birth_date: z.string().describe("出生日期"),
  nationality: z.string().describe("国籍"),
  fields: z.array(z.string()).describe("主要研究领域"),
});

当前使用的 @langchain/openai 1.5.x 提供 withStructuredOutput。把 Schema 绑定到模型后,调用结果直接是对象:

import { ChatOpenAI } from "@langchain/openai";

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

const structuredModel = model.withStructuredOutput(ScientistSchema);
const result = await structuredModel.invoke("介绍一下爱因斯坦");

console.log(result.name);
console.log(result.fields);

我一开始把方法误写成了 withStructuralOutput。通过本地原型检查后,统一改为当前版本实际提供的 withStructuredOutput。

4. OutputParser、tool call 与 withStructuredOutput 的关系

我把演进过程概括为:

演进路径可以概括为:Prompt + JSON.parse → JsonOutputParser → StructuredOutputParser + Zod → tool call 参数 → withStructuredOutput。

它们不是简单的“新 API 淘汰旧 API”。

Prompt + parser

优点是模型兼容性好,只要能输出文本就能用;缺点是可靠性取决于模型是否遵守格式,解析失败需要重试或修复。

Tool call

工具调用(Tool Calling)本来就需要模型输出工具名和参数。参数由 Schema 约束,天然适合“决定调用哪个函数”:

const weatherTool = {
  name: "get_weather",
  description: "查询指定城市天气",
  schema: z.object({
    city: z.string(),
    unit: z.enum(["celsius", "fahrenheit"]),
  }),
};

const modelWithTools = model.bindTools([weatherTool]);
const message = await modelWithTools.invoke("查一下杭州天气");

console.log(message.tool_calls?.[0]?.args);

工具调用的目标是产生“可执行动作”;结构化输出的目标是产生“符合 Schema 的数据”。二者可能使用相似的底层机制,但语义不同。只想提取人物信息时,伪装成一个永远不执行的工具可用,却不如 withStructuredOutput 清楚。

withStructuredOutput

它把底层差异藏起来,调用代码最简洁。代价是依赖具体模型适配情况,遇到不支持原生结构化输出或 tool call 的模型时,需要了解库采用了什么降级路径。

我的取舍是:

  • 业务数据提取:优先 withStructuredOutput(schema);
  • 真正要执行函数:使用 tool call;
  • 特殊文本格式或兼容旧模型:保留 OutputParser;
  • 临时一次性脚本:才考虑手写 JSON 解析。

5. 流式结构化输出不是“每个 chunk 都是完整对象”

普通流式输出(Streaming)是一段段 token;结构化流式输出则可能逐步补齐对象字段,可以使用 stream() 迭代读取:

const BiographySchema = z.object({
  name: z.string(),
  birth_date: z.string(),
  occupation: z.string(),
  famous_work: z.string(),
  biography: z.string(),
});

const structuredModel = model.withStructuredOutput(BiographySchema);
const stream = await structuredModel.stream("详细介绍莫扎特");

for await (const chunk of stream) {
  // chunk 可能是逐步形成的部分结果,不要假设每次都字段齐全
  console.log(chunk);
}

这里最容易犯的错,是把最后一个 chunk 当成所有模型、所有适配器都保证的最终对象。更稳妥的做法是先确认当前模型和 LangChain 版本的 chunk 语义;如果下游必须拿完整对象,就在流结束后再进入业务节点。

6. SSE 只负责传输,不负责数据正确

Server-Sent Events(服务器发送事件,SSE)经常用来把模型增量结果推给浏览器。它是一条服务器到浏览器的单向长连接,响应头和消息格式都很简单:

const http = require("http");

http.createServer((req, res) => {
  if (req.url !== "/stream") return res.end("not found");

  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  });

  const words = ["你", "好", ",", "SSE"];
  let index = 0;

  const timer = setInterval(() => {
    if (index >= words.length) {
      clearInterval(timer);
      return res.end();
    }
    res.write(`data: ${words[index++]}\n\n`);
  }, 1000);
}).listen(3000);

// 浏览器端使用 EventSource 接收
const source = new EventSource("/stream");

source.onmessage = (event) => {
  console.log("收到增量内容:", event.data);
};

source.onerror = () => {
  source.close();
};

SSE 与结构化输出解决的是两层问题:

  • 结构化输出:模型返回的数据是否符合程序约束;
  • SSE:这些增量数据怎样从服务端传到浏览器。

把 JSON 字符串切成很多 SSE chunk,并不会自动得到合法的“流式 JSON”。如果前端需要边收边展示,可以传文本增量;如果需要可靠业务对象,可以在服务端完成结构化校验后再发送最终事件。

7. 错误处理不能留空 catch

早期示例为了聚焦主流程使用了空的 catch (err) {},实际项目中不能保留:

try {
  const result = await structuredModel.invoke(input);
  return { ok: true, data: result };
} catch (error) {
  console.error("结构化输出失败", error);
  return {
    ok: false,
    error: "模型输出未通过结构校验",
  };
}

是否重试要看失败类型:网络错误可以有限重试;Schema 不匹配可以把校验错误反馈给模型重新生成;业务字段缺失则可能需要人工补充输入。无论哪种情况,都要设置次数上限,避免 Agent 在图里死循环。

结尾

我现在不会再把“提示词里要求 JSON”当成结构化输出的全部。可复用的选择顺序是:

  1. 先用 Zod 定义下游真正需要的字段;
  2. 数据提取优先使用 withStructuredOutput;
  3. 执行动作用 tool call;
  4. XML 等特殊格式使用对应 OutputParser;
  5. SSE 只做传输,完整对象仍在服务端校验;
  6. 所有解析失败都要有明确的错误出口和重试上限。

下一步就是把结构化结果放进 LangGraph 状态:路由节点返回固定枚举,评估节点返回 relevant/irrelevant,条件边不再解析自然语言。这也是后续 Agentic RAG 系列的基础。