别再用正则抠 JSON 了:LangChain 结构化输出的三种正确姿势

71 阅读10分钟

前言:LLM 输出结构化数据,为什么这么难?

做 AI 应用的时候,你会发现一个非常高频的需求:让大模型返回 JSON。

不管是提取实体、解析表单、还是做下一步的业务分支判断,我们都希望 LLM 的输出是"机器可读"的,而不是一段自然语言。

但现实往往很骨感。你精心写好了 prompt:

请介绍一下爱因斯坦的信息。请以 JSON 格式返回,包含以下字段:name、birth_year...

大模型也很乖地返回了:

```json
{
  "name": "阿尔伯特·爱因斯坦",
  "birth_year": 1879,
  ...
}
```

然后你 JSON.parse(response.content) 就直接原地爆炸——因为返回的字符串被 markdown 代码块包裹了。

说白了,大模型是"展示型选手",它天然倾向于把结构化数据渲染成 markdown 方便人看。但我们的代码要的是"数据",不是"展示"。

这篇文章就从这里开始,把 LangChain 的结构化输出方案一路讲透:JsonOutputParser → StructuredOutputParser → tool_call → withStructuredOutput,最后回答两个灵魂拷问:

  1. 有了 tool_call,output parser 还有必要吗?
  2. output_parsers 这个模块是不是可以丢了?

一、手动挡:正则 + JSON.parse

在引入 LangChain 之前,很多人(包括我)第一版代码都是这么写的:

const rawContent = response.content;
const match = rawContent.match(/```(?:json)?\s*([\s\S]*?)```/);
const jsonString = match ? match[1].trim() : rawContent.trim();
const jsonResult = JSON.parse(jsonString);

思路非常朴素:

  1. 大模型喜欢用 ```json ... ``` 包裹输出
  2. 那就用正则把外层 markdown 扒掉
  3. 剩下的内容再 JSON.parse

这套方案能用,但很脆:

  • 大模型有时包 ```json,有时包 ```,有时干脆不包
  • 有时会在 JSON 前后加一句"好的,以下是结果:"
  • 字段名大小写、是否缺字段、类型对不对,全靠运气

一个业务每次都要写一遍正则 + try/catch,纯属重复劳动。

LangChain 的价值就在这:把"每次调用 AI 都要处理的通用逻辑"抽成 API。


二、JsonOutputParser:官方标配的"手动挡升级版"

LangChain 给出的第一个答案就是 JsonOutputParser。

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

const parser = new JsonOutputParser();

const prompt = `请介绍一下爱因斯坦的信息。请以 JSON 格式返回,
包含以下字段:name(姓名)、birth_year(出生年份)、
nationality(国籍)、major_achievements(主要成就, 数组)、
famous_theory(著名理论)
${parser.getFormatInstructions()}`;

const response = await model.invoke(prompt);
const jsonResult = await parser.parse(response.content);
console.log(jsonResult, jsonResult.name);

看起来很简单,但它背后做了两件事,你要理解透:

1. getFormatInstructions():往 prompt 里塞格式约束

有意思的是,JsonOutputParser 的 getFormatInstructions() 返回的是空字符串。

为什么?因为 JSON 太常见了,模型早就被训练得"你说要 JSON 就会给 JSON",不需要额外的格式说明。

所以这一步的本质是——如果未来换一个解析器(比如带 schema 的),这个接口就是用来把格式约定注入到 prompt 里的钩子。

2. parse():去 markdown + JSON.parse

parse() 内部会:

  • 去掉可能的 ```json 包裹
  • 调用 JSON.parse
  • 返回对象

你可以把它理解为官方帮你写好了正则 + try/catch,省得每次重复。

小结:

  • JsonOutputParser = 通用 JSON 解析器 + 空格式提示
  • 适合"字段不多、格式不严格"的快速场景
  • 缺点:没有 schema 约束,字段类型对错全靠模型自觉

三、StructuredOutputParser:让格式约束更"严苛"

JsonOutputParser 的痛点很明确:它只能"期望"模型返回 JSON,但无法"约束"结构。

StructuredOutputParser 就是来解决这个问题的。它有两种构造方式。

方式一:fromNamesAndDescriptions(轻量版)

const parser = StructuredOutputParser.fromNamesAndDescriptions({
  name: '姓名',
  birth_year: '出生年份',
  nationality: '国籍',
  major_achievements: '主要成就, 用逗号分隔的字符串',
  famous_theory: '著名理论',
});

const question = `请介绍一下爱因斯坦的信息。
${parser.getFormatInstructions()}`;

跟 JsonOutputParser 最大的区别:这次 getFormatInstructions() 不再返回空串了。

它会把每个字段名 + 描述组装成一段格式说明,注入到 prompt 里。大模型拿到这段说明后,返回的 JSON 结构会明显更稳定。

一句话总结:getFormatInstructions() 的作用 = 把"结构约定"翻译成 prompt 语言,喂给大模型。parse() 的作用 = 把"大模型乱涂的输出"翻译回结构化对象。

方式二:fromZodSchema(重装版)

字段多了、嵌套深了,fromNamesAndDescriptions 就不够用了。这时候上 Zod:

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('国籍'),
  field_of_study: z.string().describe('研究领域'),
  awards: z.array(
    z.object({
      award: z.string().describe('奖项名称'),
      year: z.number().describe('获奖年份'),
      reason: z.string().describe('获奖原因'),
    })
  ).describe('获得的重要奖项列表'),
  major_achievements: z.array(z.string()).describe('科学家主要成就'),
  famous_theory: z.array(z.object({
    theory: z.string().describe('理论名称'),
    year: z.number().describe('理论年份'),
    description: z.string().describe('理论简要描述'),
  })).describe('著名理论列表'),
  biography: z.string().describe('简短传记: 100字以内'),
});

const parser = StructuredOutputParser.fromZodSchema(scientistSchema);

Zod schema 的三大好处:

  1. 类型约束:birth_year 必须是 number,模型不会给你返回字符串 "1879"
  2. 结构约束:数组、嵌套对象都能表达清楚
  3. 描述可读:.describe() 里的内容会自动进入 getFormatInstructions(),直接指导模型

对于下游业务来说,这就很爽了——拿到 result.birth_year 时,你可以放心地当 number 用,不用写一堆 Number(...) 转换。


四、思路拐个弯:为什么不用 tool_call?

写到这里,我脑子里突然蹦出一个想法:

既然已经用 Zod schema 定义结构了,为什么不直接让模型走 tool_call?

于是有了第三种思路:

const scientistSchema = z.object({
  name: z.string().describe('科学家的姓名'),
  birth_year: z.number().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);
// { name: '阿尔伯特·爱因斯坦', birth_year: 1879, nationality: '德国/美国', fields: [...] }

这段代码里,藏着三个你必须想清楚的点

① tool 参数的 schema 约束,顺手完成了 LLM 输出的格式化、结构化

我们原本是想"用工具",结果意外发现:工具参数的 schema,本身就是最好的输出格式约束。也就是说,你只要定义了工具的参数长什么样,LLM 就会按那个样子给你吐数据。

格式化不是靠"请求",而是靠"schema 契约"顺带完成的。

② 这份约束,来自 LLM 原生的工作机制

tool_call 不是 LangChain 的发明,是模型厂商在训练阶段就注入的能力(OpenAI 的 function calling、Claude 的 tool use、Qwen/DeepSeek 的 tools 参数都是)。它比 prompt 里的"请返回 JSON"要严苛且准确得多——因为模型在生成 tool_call 时,是逐 token 按 schema 填槽的,语法错误率天然更低。

③ 工具函数其实没有执行

这一点特别容易让人误解。上面那段代码里:

  • 我们只是 bindTools,把工具注册给了模型
  • 模型返回的是一个 "意图" :tool_calls[0].args,表示"我想调用这个工具,参数是这些"
  • 真正的函数体一次都没有跑

args 就是我们要的结构化数据。工具函数只是一个"诱饵",专门用来钓出 LLM 内部已经结构化的那份参数。

说白了:我们不是在调工具,我们是在借"调工具"这个动作,让 LLM 乖乖交出一份按 schema 填好的 JSON。

那这个方案是不是完胜 OutputParser?

从约束强度上看,是的。因为 tool_call 是 LLM 自身机制带来的强约束,比 prompt 里写"请返回 JSON"要硬核得多。所以我的笔记里才写了那句话:

没有必要用 output parser 了,tool calls 的参数,也能拿到结构化的数据,而且因为 tool_calls 是 LLM 自身的机制,会更严格、更好。

但是—— "更严格更好"不等于"可以无脑替代" 。下一节就来说为什么。


五、再上一台阶:model.withStructuredOutput(schema)

虽然 tool_call 好用,但每次都要 bindTools([{ name, description, schema }]) 也太啰嗦了。LangChain 显然也想到了这一点,于是给了一个语义化更好的高阶 API:

// tool call 讨巧的做法,升级为语义化更好地 WithStructuredOutput
// 底层 tool call
const structuredModel = model.withStructuralOutput(scientistSchema);
// 如果 llm 不支持 tool 会自动降级为提示词 + json 输出

const result = await structuredModel.invoke("请介绍一下爱因斯坦");
console.log(JSON.stringify(result, null, 2));

相比手写 bindTools,它有三个明显的好处:

  1. 语义清晰:withStructuredOutput(schema) 一眼就能看出"我要结构化输出"
  2. 返回值直接是对象:不用再从 response.tool_calls[0].args 里挖一层
  3. 自动降级:如果模型不支持 tool_call,它会自动退化为 Prompt + JSON 描述的方案(等价于 StructuredOutputParser 那套逻辑)

这就是 LangChain 的"托底"思维:能用 tool_call 就用,不能用就用 prompt 兜底,你只管调 API。

到这里,我们可以把整条演进路线画出来了:

JsonOutputParser
    ↓ 加了字段描述
StructuredOutputParser (fromNamesAndDescriptions)
    ↓ 加了类型约束
StructuredOutputParser (fromZodSchema)
    ↓ 换成 LLM 原生机制,约束更硬
tool_call (response.tool_calls[0].args)
    ↓ 语义化封装,自动降级
model.withStructuredOutput(schema)  ← 推荐优先使用

每一步都不是替代,而是在约束强度、可读性、通用性之间做权衡。


六、别急着删 output_parsers:XML、YAML 还得靠它

写到这里,很容易产生一个错觉: "有了 withStructuredOutput,output_parsers 模块是不是可以丢了?"

我的答案是:不可以。

原因很简单——结构化输出不只有 JSON 一种格式。XML、YAML 也是常见的结构化数据表达,而且在某些场景里还更合适:

  • XML:老一代的数据交换标准(想想 XMLHttpRequest 和早期的 Ajax),层级嵌套表达力强,很多爬虫/文档解析场景还在用
  • YAML:配置文件的事实标准,可读性比 JSON 更好

LangChain 里就有对应的 XmlOutputParser:

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

const parser = new XmlOutputParser();
const question = `
请提取一下文本中的任务信息:爱因斯坦生于1879年,是一位伟大的物理学家。
${parser.getFormatInstructions()}
`;

const response = await model.invoke(question);
console.log(response.content);
// <response>
//   <name>爱因斯坦</name>
//   <birth_year>1879</birth_year>
//   <occupation>物理学家</occupation>
// </response>

const result = parser.parse(response.content);
// { response: { name: '爱因斯坦', birth_year: '1879', occupation: '物理学家' } }

你看,getFormatInstructions() 依然在发挥作用——它会告诉模型"请用 XML 输出"。parse() 依然在发挥作用——它会帮你把 XML 转成 JS 对象。

所以最终的推荐策略是:

场景推荐方案
标准 JSON 结构化输出model.withStructuredOutput(schema) (首选)
特殊格式(XML / YAML / 自定义)对应的 OutputParser
模型不支持 tool_callwithStructuredOutput 自动降级,或手动用 StructuredOutputParser

一句话记住:JSON 走 withStructuredOutput,特殊格式走 output_parser。两者不是替代关系,是分工关系。


七、顺带说说流式下的结构化输出

withStructuredOutput 还有一个很实用的能力——流式输出:

const structuredOutput = model.withStructuredOutput(schema);

const stream = await structuredOutput.stream("详细介绍莫扎特的信息。");
for await (const chunk of stream) {
  console.log(JSON.stringify(chunk, null, 2));
}

你会发现,每来一个 chunk,返回的都是部分组装好的对象(已解析、已成型),而不是一段裸 JSON 字符串。

这背后的机制是:LangChain 在流式接收 tool_call 参数的过程中,会增量地拼接 + 增量地解析,让下游能实时拿到"半成品对象"。

对比一下普通流式(没有结构化):

const stream = await model.stream(prompt);
for await (const chunk of stream) {
  process.stdout.write(chunk.content); // 只能一段段地拼裸文本
}

一个是"边流边出对象",一个是"边流边出字符"。做前端实时渲染、进度提示、边生成边校验的业务时,结构化流式的价值就体现出来了。


八、灵魂拷问与总结

回到开头那个踩坑故事,我们其实走完了四条路线:

  1. 正则 + JSON.parse:手搓轮子,能跑但脆
  2. JsonOutputParser / StructuredOutputParser:LangChain 把"格式约定注入 prompt + 去 markdown + 解析"封装成 API
  3. tool_call + Zod:绕过解析层,直接用 function calling 的强 schema 约束,工具函数不执行,只取 args
  4. model.withStructuredOutput(schema) :语义化封装,底层自动选 tool_call 或降级 prompt 方案

核心心法两句话:

OutputParser 是"君子协定"——我在 prompt 里请求模型按格式来;
tool_call 是"合同条款"——模型必须按 schema 填,否则它自己都过不去。

最后回答两个灵魂拷问:

Q1:有了 tool_call,OutputParser 还有必要吗?

  • JSON 场景:优先 withStructuredOutput(底层就是 tool_call)
  • 非 JSON 格式(XML/YAML/自定义):仍然需要 OutputParser
  • 不支持 tool_call 的模型:withStructuredOutput 会自动降级为 prompt 方案,你甚至不用感知

Q2:output_parsers 模块可以丢了吗?

  • 不能丢。它承载了 JSON 之外的结构化格式解析,也是降级方案的地基。JsonOutputParser 更像是入门的第一课——理解了它,你才能理解 getFormatInstructions() 和 parse() 这两个接口设计的精妙之处。

最终的推荐用法:

// 90% 的场景,一行搞定
const structuredModel = model.withStructuredOutput(schema);

// 剩下的 10%,特殊格式上 output_parser
const xmlParser = new XmlOutputParser();

工具不要用来"执行",schema 才是真正的杀器。记住这句话,你的 AI 应用会稳很多。