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

文章来源声明: 原文作者:小林ixn; 来源站点:掘金; 原文链接:https://juejin.cn/post/7685260774626967594; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把结构化输出的选型取舍讲得很清楚:能上 tool_call 就别再抠文本,老项目或小模型用 StructuredOutputParser 兜底,适合快速定方案。

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

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

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

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

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

大模型也很乖地返回了:

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

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

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

这篇文章就从这里开始,把 LangChain 的 OutputParser 三件套讲透:**JsonOutputParser → StructuredOutputParser → tool\_call 方案**,最后回答一个灵魂拷问:有了 tool\_call,output parser 还有存在必要吗?

---

一、手动挡:正则 + 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.<span>getFormatInstructions</span>()};


跟 `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\_call 的强制结构化能力,拿到解析好的对象**。

为什么这种方式"更香"?对比一下:

<table readabilitydatatable="1"><thead><tr><th>维度</th><th>OutputParser</th><th>tool\_call</th></tr></thead><tbody><tr><td>结构约束</td><td>靠 prompt 约定,模型可能不听话</td><td>**模型原生支持的 function calling,强制遵守 schema**</td></tr><tr><td>解析步骤</td><td>去 markdown + JSON.parse</td><td>**直接拿到对象**</td></tr><tr><td>类型保证</td><td>可能返回字符串"1879"</td><td>**Schema 严格校验**</td></tr><tr><td>兼容性</td><td>所有模型都行</td><td>**需要模型支持 tool calling**</td></tr></tbody></table>

> 这才是真正的"降维打击":**不是让模型"照着格式写",而是让模型"必须填这个 schema"。**

---

五、灵魂拷问:有了 tool\_call,OutputParser 还有必要吗?
----------------------------------------

答案是:**有,但场景变了。**

**tool\_call 更适合**:

- 模型支持 function calling(OpenAI、Claude、Qwen、DeepSeek 等都支持)
- 需要强 schema 约束、类型严格的场景
- 你不在乎返回的是 tool\_calls 而不是 content

**OutputParser 更适合**:

- 使用**不支持 tool\_call 的模型**(很多开源小模型、私有化部署场景)
- 你只想要个 JSON 结果,不需要强类型校验(`JsonOutputParser` 足矣)
- **流式输出**场景:OutputParser 可以增量解析 JSON 流,tool\_call 目前对此支持一般

简单决策树:

需要结构化输出? ├─ 模型支持 tool_call → 用 bindTools + Zod schema(推荐) └─ 不支持 / 只是简单 JSON ├─ 只想要个 JSON 对象 → JsonOutputParser └─ 想要字段约束 → StructuredOutputParser.fromZodSchema


---

六、总结
----

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

1. **正则 + JSON.parse**:手搓轮子,能跑但脆
2. **JsonOutputParser / StructuredOutputParser**:LangChain 把"格式约定注入 prompt + 去 markdown + 解析" 封装成了 API
3. **tool\_call + Zod**:绕过解析这一层,直接用 function calling 的强 schema 约束

**核心心法一句话**:

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

所以,如果你的模型支持 tool\_call,**优先选它**;否则 `StructuredOutputParser.fromZodSchema` 是你的好朋友。

至于 `JsonOutputParser`?它更像是**入门的第一课**——理解了它,你才能理解 `getFormatInstructions()` 和 `parse()` 这两个接口设计的精妙之处。

</div>