大模型总是不按格式输出?LLM 结构化输出的四种解法
摘要:LLM 默认吐自由文本,但程序需要的是能
JSON.parse的对象。本文从 Prompt 约束到 Tool Calling,拆解四代结构化输出方案的原理、可靠性差异与流式表现,帮你选对方案、避开格式陷阱。
📑 目录
- 为什么大模型总是不按格式输出?
- 四代实现方式:从"求它"到"管它"
- 第一代:Prompt 约束 + JsonOutputParser
- 第二代:StructuredOutputParser 的严格校验
- 第三代:Tool Calling 借力打力
- 语义化封装:withStructuredOutput
- 特殊格式:XML 与 YAML 解析器
- 流式结构化输出:两条路径的本质差异
- 方案选型:什么时候用哪个?
- 互动讨论
为什么大模型总是不按格式输出?
LLM 默认输出自由文本,就像一个人跟你聊天,想到哪说到哪。但程序需要的是能解析的对象——字段名对、类型对、能直接 JSON.parse。
结构化输出就是给 LLM 套一个"表格模板",让它按格子填,而不是自由发挥。
但现实是:你让它输出 JSON,它可能给你一段 Markdown 代码块包裹的 JSON;你让它输出数字,它可能给你字符串 "28"。 格式约束这件事,靠模型"自觉"从来都不靠谱。
四代实现方式:从"求它"到"管它"
| 代际 | 方式 | 原理 | 可靠性 |
|---|---|---|---|
| 第一代 | Prompt 里写"请输出 JSON" | 靠模型自觉 | 低 |
| 第二代 | JSON Mode | 模型保证输出合法 JSON | 中,格式对但字段可能乱 |
| 第三代 | Tool Calling | schema 注册成工具,模型按 schema 填参数 | 高,主流方案 |
| 第四代 | 约束解码 | 推理时按 JSON Schema 屏蔽非法 token | 最高,需模型支持 |
本文重点拆解前三代的落地方式,以及它们在流式场景下的差异。
第一代:Prompt 约束 + JsonOutputParser
最朴素的方式:在 prompt 里写"请以 JSON 格式返回",然后用 JsonOutputParser 解析。
javascript
import { JsonOutputParser } from '@langchain/core/output_parsers';
const parser = new JsonOutputParser();
const prompt = `
请介绍一下爱因斯坦的信息。
包含以下字段: name(姓名)、birth_year(出生年份)、nationality(国籍)、major_achievements(主要成就,数组)、famous_theory(著名理论)
${parser.getFormatInstructions()}
`;
const response = await model.invoke(prompt);
const result = await parser.parse(response.content);
JsonOutputParser 只做一件事
文本 → JS 对象。 输入不合法就抛异常,不修复、不校验、不重试。
内部流程:去空白 → 剥离 ```json 代码块标记 → JSON.parse() → 返回对象。
关键认知:LLM 返回的是纯文本,可能混有 Markdown 代码块标记,parser 负责剥离。getFormatInstructions() 不传 schema 时,只返回"请输出合法 JSON"这类通用说明,不含字段名、类型、嵌套结构。
它不校验字段和类型
javascript
// 模型返回 {"age": "28"}(字符串)
// JsonOutputParser 照样解析成功
// 后续代码拿 age 做数学运算时才会崩
JsonOutputParser 只保证"能解析成 JS 对象",不保证字段名对、类型对。
会抛错的典型情况:
- JSON 缺括号、多逗号
- 不是合法 JSON(
name: 张三) - 前面有废话(
好的,以下是结果:{...}) - 空字符串
OutputParser 的完整能力
除了 parse,解析器作为 Runnable 体系一员,还有其他方法:
| 方法 | 作用 |
|---|---|
parse(text) | 同步解析原始文本 |
parseResult(result) | 处理多个候选生成结果 |
invoke(input) | Runnable 标准入口,链式调用用 |
getFormatInstructions() | 返回格式说明文本,注入 prompt |
aparse / aparseResult | 异步版本 |
batch(inputs) | 批量处理 |
withRetry(config) | 解析失败自动重试 |
withFallbacks([...]) | 失败切换备选方案 |
pipe(next) | 链式组合 |
第二代:StructuredOutputParser 的严格校验
JsonOutputParser 相当于只说了一句"要以 JSON 格式输出"。StructuredOutputParser 则是把每个 key 的格式说明也帮我们做了,更加严谨。
它有两个静态方法:
fromNamesAndDescriptions:最基础,所有字段都是字符串,无类型校验fromZodSchema:传入 Zod schema,解析后做 Zod 校验
fromNamesAndDescriptions
javascript
import { StructuredOutputParser } from '@langchain/core/output_parsers';
const parser = StructuredOutputParser.fromNamesAndDescriptions({
name: "姓名",
birth_year: "出生年份",
nationality: "国籍",
major_achievements: "主要成就,用逗号分隔的字符串",
famous_theory: "著名理论"
});
const question = `
请介绍一下爱因斯坦的信息。
${parser.getFormatInstructions()}
`;
getFormatInstructions() 会自动生成一段详细的 JSON Schema 说明,包括字段名、类型、描述、必填项、不允许额外属性等。这比 JsonOutputParser 的"请输出 JSON"具体得多。
fromZodSchema
javascript
import { z } from "zod";
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('出生年份'),
death_year: z.number().optional().describe('去世年份,如果还在世则不填'),
nationality: z.string().describe('科学家的国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
awards: z.array(
z.object({
name: z.string().describe('获奖名称'),
year: z.number().describe('获奖年份'),
reason: z.string().describe('获奖原因')
})
).describe('获得的重要奖项列表'),
biography: z.string().describe('简短传记,100字内')
});
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);
Zod schema 允许定义嵌套对象、数组、可选字段、数字类型等。解析后会做 Zod 校验,类型不对会抛异常。
注意:JS 版对复杂 schema 支持不完整——嵌套对象、数组、联合类型、可选字段的边界行为可能不符合预期。简单扁平对象没问题,复杂结构容易出问题。
它仍然走 Prompt 约束
StructuredOutputParser 仍走"prompt 约束 + 文本解析",不走 Tool Calling。它的价值场景是:模型不支持 Tool Calling,但你又需要 schema 校验。
第三代:Tool Calling 借力打力
这是目前最可靠的方案。核心思路:把 schema 注册成一个工具,模型在调用工具时会按 schema 填写参数——这个参数就是我们需要的结构化输出。
bindTools:底层机制
javascript
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.string().describe('出生年份'),
nationality: z.string().describe('国籍'),
fields: z.array(z.string()).describe('研究领域列表')
});
const modelWithTools = model.bindTools([
{
name: "extract_scientist_info",
description: "提取和结构化科学家的详细信息",
schema: scientistSchema
}
]);
const response = await modelWithTools.invoke("介绍一下伽利略");
console.log(response.tool_calls[0].args);
这个工具没有函数体——它的目的不是被调用,而是通过"告知模型这里有一个工具"的方式,把 schema 巧妙地交给 LLM。模型调用这个工具时,就会按照 schema 生成 tool_calls,而这个 tool_calls[0].args 正好就是我们需要的最终输出格式。
易错点:bindTools 只负责"告诉模型有哪些工具",不自动提取结果、不校验、不强制调用。不设 tool_choice,模型可能返回普通文本,根本不调工具。
手动提取 + 校验:
javascript
const args = response.tool_calls?.[0]?.args;
const parsed = scientistSchema.parse(args);
Tool Calling 与 XML 的关系
原生 Tool Calling 协议本身只认 JSON。 OpenAI 的 function calling,parameters 是 JSON Schema 对象,模型返回的 arguments 是 JSON 格式字符串。Anthropic 的 tool use 也一样。
不能通过 tool schema 实现 XML 结构化输出。XML 只能走"prompt 约束 + OutputParser"的模拟路线,可靠性低于原生 Tool Calling。
语义化封装:withStructuredOutput
bindTools 需要手动绑定工具、手动提取 args、手动 Zod 校验,代码繁琐。withStructuredOutput 把这些封装好了:
javascript
const structuredModel = model.withStructuredOutput(scientistSchema);
const result = await structuredModel.invoke("介绍一下爱因斯坦");
// result 直接是 { name: "爱因斯坦", birth_year: "1879", ... },不是 AIMessage
底层流程
- Zod schema 转成 JSON Schema
- JSON Schema 注册成一个工具定义(name、description、parameters)
- 这个工具没有实际实现函数,只存在于请求的
tools参数里 - 模型在
tool_calls里返回{ name: "extract_user", arguments: "{"name":"张三","age":28}" } - LangChain 从
tool_calls[0].args取出参数,Zod 校验,返回对象
关键点:模型并没有真的"调用"一个函数,它只是输出了一个符合工具 schema 的 JSON 结构。API 层保证这个 JSON 符合 parameters 定义。
参数控制
javascript
model.withStructuredOutput(schema, {
name: "extract_user",
method: "functionCalling", // 默认;可选 "jsonMode" / "jsonSchema"
strict: true, // 仅 OpenAI jsonSchema 模式有效
includeRaw: true, // 返回 { parsed, raw }
});
它不是绝对可靠
如果模型不调用工具、调用错工具、参数不符合 schema,都会抛异常。它只是"比 JsonOutputParser 可靠",生产环境仍需 try/catch 和重试。
也不是所有模型都支持:functionCalling 依赖模型的工具调用能力。小模型、部分开源模型不支持,会报错或退化。
自动降级:在模型不支持 tool-call 时,withStructuredOutput 会自动降级成"提示词 + JSON 格式输出"模式。双重保障。
特殊格式:XML 与 YAML 解析器
OutputParser 模块并没有被淘汰——结构化输出不只是 JSON,还有 XML、YAML 等格式。
| 解析器 | 输出类型 | 适用场景 |
|---|---|---|
XMLOutputParser | dict | 模型更擅长生成 XML 时 |
CommaSeparatedListOutputParser | List[str] | 简单列表 |
YamlOutputParser | 对象 | 比 JSON 更易读的配置类输出 |
EnumOutputParser | 枚举值 | 固定选项选择 |
XMLOutputParser 示例
javascript
import { XMLOutputParser } from "@langchain/core/output_parsers";
const parser = new XMLOutputParser();
const question = `
请提取以下文本中的人物信息: 爱因斯坦出生于1879年,是一位伟大的物理学家
${parser.getFormatInstructions()}
`;
const response = await model.invoke(question);
const result = await parser.parse(response.content);
XML 是比 JSON 更老的数据交换标准(XMLHttpRequest 时代),现在基本都改用 JSON 了。但某些模型对 XML 的生成能力更强,这时可以用 XMLOutputParser。
流程控制型解析器
| 解析器 | 作用 |
|---|---|
OutputFixingParser | 解析失败时调 LLM 修复输出 |
RetryWithErrorOutputParser | 修复时额外提供原始格式指令 |
这两个解析器在"模型输出格式错误"时,会自动调 LLM 修复,而不是直接抛异常。代价是多一次 LLM 调用。
流式结构化输出:两条路径的本质差异
这是最容易踩坑的地方。两条路径的语义完全不同。
JsonOutputParser 的流式:逐块解析部分 JSON
靠内部 parsePartialResult,模型每吐出一个 chunk,parser 就把当前累积文本拿去尝试解析,能解析出多少字段就先返回多少。
javascript
const parser = new JsonOutputParser();
const chain = prompt.pipe(model).pipe(parser);
const stream = await chain.stream({ input: "张三,28岁" });
for await (const partial of stream) {
console.log(partial);
// 可能依次输出:
// {}
// { name: "张三" }
// { name: "张三", age: 28 }
}
底层用 strictParsePartialJson,递归下降解析器,能处理未闭合的字符串、数组、对象。
withStructuredOutput 的流式:底层 token 流,结果最后一次性返回
javascript
const structuredModel = model.withStructuredOutput(schema);
const stream = await structuredModel.stream("张三,28岁");
for await (const chunk of stream) {
console.log(chunk);
// 每个 chunk 是底层的 token/消息块,
// 不是逐步补全的 { name, age } 对象
}
// 流结束后,才得到完整的结构化对象
为什么不能逐块产出结构化结果? 因为 JSON 不完整时无法通过 Zod 校验。LangChain 不会一边收一边解析 Zod 对象,它会等模型完整返回全部 JSON 参数之后,一次性校验、拼装成结构化对象返回。
易错:以为 withStructuredOutput 也能逐块产出部分结构化结果。它的流式只是底层 token 流的透传,不是结构化结果的渐进。
普通流式输出(无结构化)
javascript
const stream = await model.stream("详细介绍莫扎特的生平");
let fullContent = '';
for await (const chunk of stream) {
const content = chunk.content;
fullContent += content;
process.stdout.write(content);
}
stream 返回异步可迭代对象,for await 逐个消费 chunk。chunk 是普通对象(AIMessageChunk),异步发生在获取下一个 chunk 的过程——迭代器每次 next() 返回 Promise,for await 自动 await 它。
方案选型:什么时候用哪个?
| 层级 | 机制 | 可靠性 | 流式结构化 |
|---|---|---|---|
JsonOutputParser | prompt 格式说明 + 文本解析,不校验 | 低 | ✅ parsePartialResult 逐块补全 |
StructuredOutputParser | prompt 格式说明(含 schema)+ 文本解析 + Zod 校验 | 中 | 有限,不推荐依赖 |
withStructuredOutput | Tool Calling,API 层保证格式 | 高 | ❌ 底层 token 流,结果最后一次性返回 |
bindTools(底层) | 手动绑定工具 + 强制 tool_choice + 提取 args + 校验 | 高 | 同 withStructuredOutput |
核心区分
| 路线 | 原理 | 流式能力 | 可靠性 |
|---|---|---|---|
| OutputParser 路线 | 文本 → 解析 | 强(JsonOutputParser 能逐块补全) | 低(依赖模型生成合法文本) |
| withStructuredOutput 路线 | Tool Calling → 参数提取 | 弱(结果最后一次性返回) | 高(API 层保证格式) |
选型建议
| 场景 | 推荐方案 |
|---|---|
| 需要严格 schema 校验、模型支持 Tool Calling | withStructuredOutput |
| 需要流式渐进展示结构化结果 | JsonOutputParser 的 parsePartialResult |
| 模型不支持 Tool Calling | StructuredOutputParser.fromZodSchema |
| 需要 XML/YAML 等特殊格式 | 对应的 OutputParser |
| 解析失败需要自动修复 | OutputFixingParser / RetryWithErrorOutputParser |
一句话:要流式渐进的结构化结果,走 JsonOutputParser 的 parsePartialResult;要严格的、经过 Zod 校验的最终对象,用 withStructuredOutput。
易错:以为"流式调用"和"流式结构化结果"是一回事。前者是传输方式,后者是数据形态。withStructuredOutput 支持前者,不支持后者。
互动讨论
💬 JsonOutputParser 和 StructuredOutputParser 的核心区别是什么?
JsonOutputParser 只保证"能解析成 JS 对象",不校验字段名和类型。StructuredOutputParser 通过 fromNamesAndDescriptions 或 fromZodSchema 生成详细的格式说明注入 prompt,解析后再做校验,更严格但更可靠。
💬 withStructuredOutput 底层到底做了什么?
它把 Zod schema 转成 JSON Schema,注册成一个没有函数体的工具,模型在 tool_calls 里返回符合 schema 的 JSON 参数,LangChain 从 tool_calls[0].args 提取并做 Zod 校验,最终返回对象。
💬 为什么 withStructuredOutput 不能流式输出结构化结果?
因为 JSON 不完整时无法通过 Zod 校验。LangChain 会等模型完整返回全部 JSON 参数之后,一次性校验、拼装成结构化对象。它的流式只是底层 token 流的透传。
💬 Tool Calling 能实现 XML 结构化输出吗?
不能。原生 Tool Calling 协议只认 JSON,parameters 是 JSON Schema 对象,返回的 arguments 是 JSON 字符串。XML 只能走"prompt 约束 + OutputParser"的模拟路线。
💬 OutputParser 模块还有存在的必要吗?
有。结构化输出不只是 JSON,还有 XML、YAML、CSV 等格式。withStructuredOutput 虽然是最推荐的方式,但特殊格式仍需对应的 OutputParser 来解析。