大模型结构化输出怎么选:从 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
最小实现大概是这样:
js
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 把两件事合并起来:
getFormatInstructions()生成给模型看的格式约束;parse()把模型文本转换为程序对象。
以 XML 为例:
js
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 校验库,可以描述字段类型、数组结构和字段含义:
js
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 绑定到模型后,调用结果直接是对象:
js
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 约束,天然适合"决定调用哪个函数":
js
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() 迭代读取:
js
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)经常用来把模型增量结果推给浏览器。它是一条服务器到浏览器的单向长连接,响应头和消息格式都很简单:
js
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) {},实际项目中不能保留:
js
try {
const result = await structuredModel.invoke(input);
return { ok: true, data: result };
} catch (error) {
console.error("结构化输出失败", error);
return {
ok: false,
error: "模型输出未通过结构校验",
};
}
是否重试要看失败类型:网络错误可以有限重试;Schema 不匹配可以把校验错误反馈给模型重新生成;业务字段缺失则可能需要人工补充输入。无论哪种情况,都要设置次数上限,避免 Agent 在图里死循环。
结尾
我现在不会再把"提示词里要求 JSON"当成结构化输出的全部。可复用的选择顺序是:
- 先用 Zod 定义下游真正需要的字段;
- 数据提取优先使用
withStructuredOutput; - 执行动作用 tool call;
- XML 等特殊格式使用对应 OutputParser;
- SSE 只做传输,完整对象仍在服务端校验;
- 所有解析失败都要有明确的错误出口和重试上限。
下一步就是把结构化结果放进 LangGraph 状态:路由节点返回固定枚举,评估节点返回 relevant/irrelevant,条件边不再解析自然语言。这也是后续 Agentic RAG 系列的基础。