一文搞懂 LLM 结构化输出:从"抠 JSON"到"吐 JSON"的进化之路

16 阅读7分钟

LLM 返回自由文本,但你的业务需要 JSON。本文从 Output Parser 讲到 Tool Calling,带你理解结构化输出的完整进化路径,附完整代码和面试高频问。


前言

用 LLM 做业务时,你一定遇到过这个问题:大模型返回的是一段话,但你要的是一个 JSON 对象。

比如让它"介绍一下爱因斯坦",它返回的是:

爱因斯坦是德裔物理学家,出生于1879年......

但你的代码需要的是:

{"name": "爱因斯坦", "birth_year": 1879, "nationality": "德国"}

怎么让 LLM 按格式输出?输出了又怎么解析?本文带你搞懂从 Output Parser 到 Tool Calling 的完整进化路径

你将会收获:

  • 理解 Output Parser 的三种方案及其局限
  • 理解 Tool Calling 为什么是结构化输出的终极方案
  • 掌握 model.withStructuredOutput() 的用法和原理
  • 面试中遇到"LLM 结构化输出"不再慌

技术栈: Node.js + LangChain + OpenAI API + Zod


一、Output Parser 三件套:从文本中"抠"出 JSON

1.1 问题的根源

LLM 的输出本质是自由文本,不是结构化数据。即使你让它"以 JSON 格式返回",它也可能返回:

```json
{"name": "爱因斯坦", "birth_year": 1879}

前面多了 `` ```json ``,后面多了 `` ``` ``,直接 `JSON.parse()` 会报错。

### 1.2 方案一:正则提取(最原始)

```js
const jsonMatch = response.content.match(/```json\s*([\s\S]*?)\s*```/);
const jsonStr = jsonMatch ? jsonMatch[1] : response.content;
const result = JSON.parse(jsonStr);

问题:每次手写正则,LLM 格式一变就挂。

1.3 方案二:JsonOutputParser(LangChain 封装)

import { JsonOutputParser } from '@langchain/core/output_parsers';

const parser = new JsonOutputParser();
const prompt = `介绍一下爱因斯坦的信息。
${parser.getFormatInstructions()}`;  // 追加格式说明

const response = await model.invoke(prompt);
const result = await parser.parse(response.content);  // 自动提取 + JSON.parse

两个关键方法

  • getFormatInstructions() → 在 prompt 末尾追加格式约束
  • parser.parse() → 正则提取 markdown + JSON.parse

1.4 方案三:StructuredOutputParser(约束字段结构)

JsonOutputParser 只管"返回 JSON",不管"JSON 里面有什么字段"。升级方案:

import { StructuredOutputParser } from '@langchain/core/output_parsers';
import { z } from 'zod';

const schema = z.object({
  name: z.string().describe('科学家的姓名'),
  birth_year: z.number().describe('出生年份'),
  nationality: z.string().describe('国籍'),
  fields: z.array(z.string()).describe('研究领域列表'),
});

const parser = StructuredOutputParser.fromZodSchema(schema);
const prompt = `介绍一下爱因斯坦。${parser.getFormatInstructions()}`;

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

1.5 Output Parser 的本质

prompt 约束(源头)+ 正则提取 + JSON.parse(兜底)
     ↑                    ↑
  getFormatInstructions()  parser.parse()

💡 一句话记住:Output Parser = 从文本中"抠"出 JSON,是事后解析


二、Tool Calling:结构化输出的终极方案

2.1 换个思路

Output Parser 的问题是:先让模型自由发挥,再从文本里提取结构。这就像从一篇作文里找答案,总有遗漏的风险。

但 LLM 有一个原生能力:Tool Calling(工具调用)。工具调用的参数本身就是结构化的 JSON。

Output ParserLLM → 自由文本 → 解析文本 → 提取 JSON(有损耗)
Tool CallingLLM → 直接返回结构化参数 → 天然就是 JSON(零损耗)

2.2 关键洞察:工具不需要真的执行

const modelWithTool = model.bindTools([{
  name: 'extract_scientist_info',
  description: '提取和结构化科学家的详细信息',
  schema: scientistSchema,
}]);

const response = await modelWithTool.invoke('介绍一下爱因斯坦');
console.log(response.tool_calls[0].args);
// 直接拿到结构化数据,工具根本没有执行!

我们不是要调用工具,而是借用 Tool Calling 的结构化能力。 这是 Tool Calling 最巧妙的用法。

2.3 为什么比 Output Parser 更严格?

Output ParserTool Calling
数据来源从文本中提取模型原生输出
约束方式prompt 暗示(模型可能不遵守)schema 强制(模型必须遵守)
解析风险JSON 可能不合法天然结构化,无需解析
类型校验依赖 Zod 事后校验模型参数自带类型
嵌套支持需要 Zod 定义原生支持

💡 一句话记住:Tool Calling 是模型的原生结构化输出能力,Output Parser 只是从文本中抢救数据

2.4 代码演示

import { ChatOpenAI } from '@langchain/openai';
import { z } from 'zod';

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 scientistSchema = z.object({
  name: z.string().describe('科学家的姓名'),
  birth_year: z.number().describe('出生年份'),
  nationality: z.string().describe('国籍'),
  fields: z.array(z.string()).describe('研究领域列表'),
});

// 方式一:手动绑定工具
const modelWithTool = model.bindTools([{
  name: 'extract_scientist_info',
  description: '提取和结构化科学家的详细信息',
  schema: scientistSchema,
}]);

const response = await modelWithTool.invoke('介绍一下爱因斯坦');
console.log(response.tool_calls[0].args);
// { name: "爱因斯坦", birth_year: 1879, nationality: "德国", fields: ["物理学"] }

三、withStructuredOutput:LangChain 的终极封装

3.1 手动 bindTools 的问题

上面的方式虽然好用,但有两个问题:

  1. 需要手动定义工具的 name、description、schema(重复劳动)
  2. 调用后还要从 tool_calls[0].args 里取数据(样板代码)

3.2 一步到位:withStructuredOutput

const scientistSchema = z.object({
  name: z.string().describe('科学家的姓名'),
  birth_year: z.number().describe('出生年份'),
  nationality: z.string().describe('国籍'),
  fields: z.array(z.string()).describe('研究领域列表'),
});

// 一行搞定:绑定 + 解析 + 校验
const structuredModel = model.withStructuredOutput(scientistSchema);

const result = await structuredModel.invoke('介绍一下爱因斯坦');
console.log(result);
// { name: "爱因斯坦", birth_year: 1879, nationality: "德国", fields: ["物理学"] }

对比一下:

// 手动 Tool Calling(繁琐)
const modelWithTool = model.bindTools([{ name: '...', description: '...', schema }]);
const response = await modelWithTool.invoke(prompt);
const result = response.tool_calls[0].args;

// withStructuredOutput(简洁)
const structuredModel = model.withStructuredOutput(schema);
const result = await structuredModel.invoke(prompt);

3.3 底层原理

withStructuredOutput 做了什么?

1. 把 Zod schema 转成 tool definition
2. 绑定到模型(等价于 bindTools)
3. 设置 tool_choice 为强制调用
4. 从 tool_calls 中提取 args
5. 用 Zod schema 校验返回结果

本质还是 Tool Calling,只是包装成语义更好的 API。

3.4 兼容性:不支持 Tool Call 怎么办?

有些模型(老模型或某些国产模型)不支持 Tool Calling。withStructuredOutput自动降级

支持 Tool Calling  → 用原生能力(效果最好)
不支持             → 降级为 prompt 引导 + JSON 解析(兜底)

这也是它比手动 bindTools 更健壮的原因——bindTools 遇到不支持的模型直接报错,withStructuredOutput 会降级处理。


四、进化路径总结

JsonOutputParserStructuredOutputParserTool Calling → withStructuredOutput
     ↓                    ↓                      ↓                ↓
  只管 JSON 格式      加字段约束           模型原生能力       终极封装
  从文本提取          从文本提取           零损耗             零损耗 + 自动降级

核心演进逻辑

阶段方案约束方式本质
1.0JsonOutputParserprompt 暗示事后解析文本
2.0StructuredOutputParser + Zodprompt + schema 描述事后解析文本
3.0Tool Calling + bindTools模型原生 schema 约束原生结构化输出
4.0withStructuredOutput模型原生 + 自动封装原生 + 降级兜底

五、Output Parser 还有必要存在吗?

有,但场景变窄了。

推荐用 withStructuredOutput 的场景

  • 需要 JSON 格式的结构化数据(90% 的场景)
  • 需要严格的类型校验
  • 需要支持不支持 Tool Call 的模型(自动降级)

Output Parser 仍然有用的场景

  • 需要 XML 格式输出(XmlOutputParser
  • 需要 YAML 格式输出
  • 模型不支持 Tool Call 且你不想依赖 LangChain 的降级逻辑
// XML 格式 → 用 XmlOutputParser
import { XmlOutputParser } from 'langchain/output_parsers';
const parser = new XmlOutputParser();
const result = await parser.parse(response.content);

// JSON 格式 → 用 withStructuredOutput(推荐)
const structuredModel = model.withStructuredOutput(schema);
const result = await structuredModel.invoke(prompt);

💡 一句话记住:JSON 结构化用 withStructuredOutput,非 JSON 格式(XML/YAML)用 Output Parser。


六、面试高频问

Q1:LLM 结构化输出有哪些方案?

四种,按进化顺序:

  1. 正则提取:prompt 约束 + 正则 + JSON.parse(最弱)
  2. JsonOutputParser:LangChain 封装,自动处理 markdown 包裹
  3. StructuredOutputParser + Zod:约束字段名、类型、嵌套结构
  4. Tool Calling / withStructuredOutput:模型原生结构化输出(最强)

推荐用 withStructuredOutput,它是 Tool Calling 的高级封装,兼容性最好。

Q2:Tool Calling 为什么比 OutputParser 更靠谱?

OutputParser 是从 LLM 返回的文本中提取 JSON,有截断、格式错误等风险。Tool Calling 是 LLM 的原生结构化输出能力,返回的工具调用参数本身就是合法 JSON,不依赖文本解析。

类比:OutputParser 是"从作文里找答案",Tool Calling 是"直接填表格"。

Q3:withStructuredOutput 和 bindTools 的区别?

withStructuredOutputbindTools 的高级封装:

  • bindTools:手动定义工具 → 调用 → 手动取 tool_calls[0].args
  • withStructuredOutput:传入 Zod schema → 直接返回结构化对象

底层都是 Tool Calling,withStructuredOutput 省去了样板代码,还支持自动降级(模型不支持 Tool Call 时用 prompt 引导)。

Q4:如果模型不支持 Tool Calling 怎么办?

withStructuredOutput 会自动降级为 prompt 引导 + JSON 解析。而手动 bindTools 会直接报错。这也是推荐用 withStructuredOutput 的原因之一。

Q5:getFormatInstructions() 做了什么?

在 prompt 末尾追加一段格式说明,告诉 LLM "请按以下 JSON schema 返回"。这是 Output Parser 的核心——通过在 prompt 中嵌入格式要求,引导 LLM 按指定结构输出。

但这种方式只是"暗示",模型可能不遵守。Tool Calling 的 schema 约束才是"强制"。

Q6:Output Parser 还有必要学吗?

有必要,但不是重点。了解其原理(prompt 约束 + 事后解析)有助于理解结构化输出的演进。实际项目中,JSON 结构化优先用 withStructuredOutput,只有处理 XML/YAML 等非 JSON 格式时才用 Output Parser。


总结

核心概念速查表

概念一句话
Output Parser从 LLM 文本输出中提取结构化数据(事后解析)
Tool CallingLLM 原生结构化输出能力,参数天然就是 JSON
withStructuredOutputLangChain 对 Tool Calling 的高级封装,一步到位
getFormatInstructions()在 prompt 里追加格式约束说明(Output Parser 专用)
bindTools手动绑定工具到模型(底层 API)

一句话总结

结构化输出的进化:从文本中"抠"JSON → 让模型直接"吐"JSON。
推荐用 withStructuredOutput,底层是 Tool Calling,兼容性最好。

核心代码骨架

// 推荐方案:withStructuredOutput(Tool Calling 的高级封装)
const schema = z.object({
  name: z.string().describe('姓名'),
  age: z.number().describe('年龄'),
});
const structuredModel = model.withStructuredOutput(schema);
const result = await structuredModel.invoke('介绍一下xxx');
// result 直接就是结构化对象,不用解析

希望这篇文章对你有帮助!有问题欢迎在评论区交流 🔥