一、问题:LLM 的输出你控不住
假设你让 LLM 介绍爱因斯坦,要求返回 JSON。你写了这样的 prompt:
请介绍一下爱因斯坦的信息。请以 <span>JSON</span> 格式返回,
包含以下字段:name、birth_year、nationality...
LLM 可能返回什么?
情况一——标准 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>
情况二——markdown 包裹的 JSON(最常见):
<span>```json
{"name": "阿尔伯特·爱因斯坦", "birth_year": 1879, ...}
</span>
**情况三**——带前后解释的 <span>JSON</span>:
好的,以下是爱因斯坦的信息: {"name": "阿尔伯特·爱因斯坦", ...} 希望对你有帮助!
<span>**情况四**</span>——格式跑偏:
姓名:爱因斯坦 出生年份:1879
readme 总结了这个问题:
> <span>"大模型按照我们的格式要求返回一个 JSON。失败了——json 固定格式输出,被 markdown 格式包裹,llm 输出常是 markdown 格式,这是展示的需要。"</span>
<span>LLM</span> 是语言模型,它的<span>"舒适区"</span>是自然语言和 markdown。你让它吐 <span>JSON</span>,它经常顺手包一层 <span>` `</span><span>``</span>json <span>``</span><span>` `</span>——因为它觉得这样<span>"好看"</span>。但对 <span>`JSON.parse()`</span> 来说,<span>` `</span><span>``</span>json <span>` 是非法语法,直接报错。
---
## 二、第一级:手搓正则——剥掉 markdown 外衣
`</span>normal.mjs<span>` 里有一段被注释掉的代码——这是最原始的解决方案:
`</span><span>``</span>javascript
/<span>/ 使用正则提取 markdown 代码块中的 JSON 内容
/</span><span>/ const jsonMatch = response.content.match(/</span><span>``</span><span>`json\s*([\s\S]*?)\s*`</span><span>``</span>/);
<span>//</span> const jsonStr = jsonMatch ? jsonMatch[<span>1</span>] : response.content;
<span>//</span> const jsonResult = <span>JSON</span>.parse(jsonStr);
readme 解释了这个思路:
"移除
json包裹,正则 replace 方法。prompt output 技巧 → llm 返回 markdown 格式 → 正则业务去除 md 格式 → JSON.parse()。"
三步走:
- LLM 返回 markdown 包裹的 JSON
- 正则把 ````json` 和 ````` 剥掉
JSON.parse()解析成对象
正则 /```json\s*([\s\S]*?)\s*```/ 的含义:
| 正则片段 | 含义 |
|---|---|
| ````json` | 匹配开头的标记 |
| `\s*` | 匹配可能的空白字符 |
| `([\s\S]*?)` | 捕获组——匹配中间所有内容(含换行) |
| ````` | 匹配结尾的标记 |
jsonMatch[1] 就是捕获组的内容——纯 JSON 字符串,没有 markdown 外衣。
手搓正则的问题
readme 的注释还说了:
"分组。正则业务。"
这行注释点明了——正则提取是"业务代码"。每次调 LLM 都要写一遍正则、处理异常、兜底各种格式变体。LLM 有时用 json`,有时用 JSON`,有时用 ````` 不带 json 标记——正则覆盖不全。
readme 紧接着给出了答案:
"每次调用 AI 的常见业务,langchain 提供相应的业务 API,省去开发的复杂度。"
别手搓了,LangChain 有现成的工具。
三、第二级:JsonOutputParser——LangChain 的基础解析器
normal.mjs 实际使用的方案——JsonOutputParser:
<span>import</span> { <span>JsonOutputParser</span> } <span>from</span> <span>'@langchain/core/output_parsers'</span>;
<span>const</span> parser = <span>new</span> <span>JsonOutputParser</span>();
<span>const</span> prompt = <span>`
请介绍一下爱因斯坦的信息。请以 JSON 格式返回,
包含以下字段:name(姓名)、birth_year(出生年份)、
nationality(国籍)、major_achievements(主要成就, 数组)、
famous_theory(著名理论)
<span>${parser.getFormatInstructions()}</span>
`</span>;
<span>const</span> response = <span>await</span> model.<span>invoke</span>(prompt);
<span>const</span> result = <span>await</span> parser.<span>parse</span>(response.<span>content</span>);
<span>console</span>.<span>log</span>(result, result.<span>name</span>);
readme 说的:
"JsonOutputParser——langchain 用来解析 json 结果的。约束返回格式 json,JSON.parse()。"
两个关键 API
parser.getFormatInstructions() — 生成格式约束指令,自动拼到 prompt 里。
readme 说的:
"parser.getFormatInstructions() 空,json 太常见的格式需求。"
意思是——getFormatInstructions() 返回的约束文本对 JSON 来说比较"空"(简单),因为 JSON 格式太通用了。它大概会在 prompt 里加一句类似"请返回合法的 JSON 格式"的指令。
parser.parse(response.content) — 解析 LLM 的输出。
readme 说的:
"本质就是通过 getFormatInstructions() 在 prompt 里添加对 output 的结构化格式约定,parser.parse() 去除 markdown 拿到 json。"
parse() 内部做了两件事:
- 剥掉 markdown 的
json外衣(不用你写正则了) JSON.parse()解析成对象
JsonOutputParser 的局限
JsonOutputParser 只保证"返回的是合法 JSON"——但不保证有哪些字段、字段是什么类型。你让 LLM 返回 name 和 birth_year,它可能返回 姓名 和 出生年份——JSON 是合法的,但字段名不对。
readme 点出了升级方向:
"JsonOutputParser 格式化的升级。"
需要更强的约束——不仅要是 JSON,字段名和类型也得对。
四、第三级:StructuredOutputParser — 按格填空
structured-output-parser.mjs 展示了 StructuredOutputParser 的第一种用法——fromNamesAndDescriptions:
<span>import</span> { <span>StructuredOutputParser</span> } <span>from</span> <span>'@langchain/core/output_parsers'</span>;
<span>const</span> parser = <span>StructuredOutputParser</span>.<span>fromNamesAndDescriptions</span>({
<span>name</span>: <span>'姓名'</span>,
<span>birth_year</span>: <span>'出生年份'</span>,
<span>nationality</span>: <span>'国籍'</span>,
<span>major_achievement</span>: <span>'主要成就,用逗号分隔的字符串'</span>,
<span>famous_theory</span>: <span>'著名理论'</span>,
});
<span>const</span> question = <span>`
请介绍一下爱因斯坦的信息。
<span>${parser.getFormatInstructions()}</span>
`</span>;
<span>const</span> response = <span>await</span> model.<span>invoke</span>(question);
<span>const</span> result = <span>await</span> parser.<span>parse</span>(response.<span>content</span>);
<span>console</span>.<span>log</span>(<span>`姓名:<span>${result.name}</span>`</span>);
<span>console</span>.<span>log</span>(<span>`出生年份:<span>${result.birth_year}</span>`</span>);
fromNamesAndDescriptions:字段名 + 描述
fromNamesAndDescriptions 接收一个对象——key 是字段名,value 是描述。
{
<span>name</span>: <span>'姓名'</span>, <span>// 字段名:name,描述:姓名</span>
<span>birth_year</span>: <span>'出生年份'</span>, <span>// 字段名:birth_year,描述:出生年份</span>
<span>nationality</span>: <span>'国籍'</span>,
<span>major_achievement</span>: <span>'主要成就,用逗号分隔的字符串'</span>,
<span>famous_theory</span>: <span>'著名理论'</span>,
}
getFormatInstructions() 会把这些字段名和描述转化成 prompt 约束——LLM 看到的指令大概是"你必须返回一个 JSON 对象,包含以下字段:name(姓名)、birth_year(出生年份)..."。
字段名固定了。 LLM 不能自作主张用"姓名"代替"name"——prompt 里明确要求用 name。
JsonOutputParser vs StructuredOutputParser
readme 的对比:
| 特性 | JsonOutputParser | StructuredOutputParser |
|---|---|---|
| 保证 JSON 合法 | 是 | 是 |
| 固定字段名 | 否 | 是 |
| 字段描述 | 无 | 有 |
| 类型约束 | 无 | 无(只有描述) |
fromNamesAndDescriptions 比 JsonOutputParser 进了一步——字段名固定了。但类型还是靠描述约束——"出生年份"是字符串还是数字?fromNamesAndDescriptions 只写了描述"出生年份",没说是数字。LLM 可能返回 "1879"(字符串)也可能返回 1879(数字)。
readme 注释也暗示了这一点:
"json, name, description 更靠谱。"
字段名 + 描述,比纯 JSON 靠谱——但还不够。
五、第四级:Zod Schema — 类型级别的约束
structured-output-parser2.mjs 是今天的重头戏——用 Zod Schema 做类型约束。
什么是 Zod?
Zod 是 TypeScript 生态的运行时数据验证库——你定义一个 Schema(模式),它能验证数据是否符合这个模式。
<span>import</span> { z } <span>from</span> <span>"zod"</span>;
<span>const</span> scientistSchema = z.<span>object</span>({
<span>name</span>: z.<span>string</span>().<span>describe</span>(<span>'科学家的姓名'</span>),
<span>birth_year</span>: z.<span>number</span>().<span>describe</span>(<span>'出生年份'</span>),
<span>death_year</span>: z.<span>number</span>().<span>optional</span>().<span>describe</span>(<span>'死亡年份,如果还在世则不填'</span>),
<span>nationality</span>: z.<span>string</span>().<span>describe</span>(<span>'科学家的国籍'</span>),
<span>fields</span>: z.<span>array</span>(z.<span>string</span>()).<span>describe</span>(<span>'研究领域列表'</span>),
<span>awards</span>: z.<span>array</span>(
z.<span>object</span>({
<span>name</span>: z.<span>string</span>().<span>describe</span>(<span>'奖项名称'</span>),
<span>year</span>: z.<span>number</span>().<span>describe</span>(<span>'获奖年份'</span>),
<span>reason</span>: z.<span>string</span>().<span>describe</span>(<span>'获奖原因'</span>),
})
).<span>describe</span>(<span>'获得的重要奖项列表'</span>),
<span>major_achievements</span>: z.<span>array</span>(z.<span>string</span>()).<span>describe</span>(<span>'主要成就列表'</span>),
<span>famous_theory</span>: z.<span>array</span>(
z.<span>object</span>({
<span>name</span>: z.<span>string</span>().<span>describe</span>(<span>'理论名称'</span>),
<span>year</span>: z.<span>number</span>().<span>describe</span>(<span>'理论年份'</span>),
<span>description</span>: z.<span>string</span>().<span>describe</span>(<span>'理论描述'</span>),
})
).<span>describe</span>(<span>'著名理论列表'</span>),
<span>biography</span>: z.<span>string</span>().<span>describe</span>(<span>'简短传记,100字以内'</span>),
});
Zod 的类型系统
| Zod 方法 | TypeScript 类型 | 含义 |
|---|---|---|
| `z.string()` | `string` | 字符串 |
| `z.number()` | `number` | 数字 |
| `z.array(z.string())` | `string[]` | 字符串数组 |
| `z.object({...})` | `{...}` | 对象 |
| `.optional()` | `T | undefined` | 可选 |
| `.describe('xxx')` | — | 描述(给 LLM 看的) |
对比 fromNamesAndDescriptions——那个只有"字段名 + 文字描述",Zod 有精确的类型:birth_year 是 number 不是 string,fields 是 string[] 不是 string,awards 是嵌套对象数组。
嵌套结构
<span>awards</span>: z.<span>array</span>(
z.<span>object</span>({
<span>name</span>: z.<span>string</span>().<span>describe</span>(<span>'奖项名称'</span>),
<span>year</span>: z.<span>number</span>().<span>describe</span>(<span>'获奖年份'</span>),
<span>reason</span>: z.<span>string</span>().<span>describe</span>(<span>'获奖原因'</span>),
})
).<span>describe</span>(<span>'获得的重要奖项列表'</span>),
awards 不只是字符串数组——是对象数组,每个对象有 name、year、reason 三个字段。这种复杂嵌套结构,fromNamesAndDescriptions 根本表达不了。
fromZodSchema:把 Schema 变成 Parser
<span>const</span> parser = <span>StructuredOutputParser</span>.<span>fromZodSchema</span>(scientistSchema);
readme 注释说:
"fromZodSchema 是静态工厂方法(返回 parser 实例),不能 new,且要传入 zod schema。"
fromZodSchema 是静态工厂方法——直接在类上调用,不需要 new。传入一个 Zod Schema,返回一个 parser 实例。
使用方式跟之前一样
<span>const</span> question = <span>`请介绍一下居里夫人的详细信息,
<span>${parser.getFormatInstructions()}</span>`</span>;
<span>const</span> response = <span>await</span> model.<span>invoke</span>(question);
<span>const</span> result = <span>await</span> parser.<span>parse</span>(response.<span>content</span>);
<span>console</span>.<span>log</span>(<span>`姓名:<span>${result.name}</span>`</span>);
<span>console</span>.<span>log</span>(<span>`出生年份:<span>${result.birth_year}</span>`</span>);
getFormatInstructions() + parse()——API 没变。但 getFormatInstructions() 生成的约束文本更详细了——包含了每个字段的类型信息。LLM 看到的指令大概是"返回一个 JSON 对象,birth_year 必须是数字,awards 是一个数组,每个元素包含 name(字符串)、year(数字)、reason(字符串)..."。
parse() 也不只是剥 markdown 和 JSON.parse 了——它还会用 Zod Schema 做运行时验证。如果 LLM 返回的 birth_year 是字符串 "1867" 而不是数字 1867,Zod 会报验证错误。
六、四级进化对比
| 级别 | 方案 | 字段名 | 类型约束 | 嵌套结构 | 课堂文件 |
|---|---|---|---|---|---|
| 1 | 手搓正则 | 无 | 无 | 无 | normal.mjs(注释) |
| 2 | JsonOutputParser | 无 | 无 | 无 | normal.mjs |
| 3 | fromNamesAndDescriptions | 固定 | 无(仅描述) | 无 | structured-output-parser.mjs |
| 4 | fromZodSchema | 固定 | 精确 | 支持 | structured-output-parser2.mjs |
从"啥都不保证"到"字段名固定"到"类型精确"到"嵌套结构"——每升一级,LLM 的输出就更可靠。
readme 的总结:
"下游业务用上靠谱的 JSON 输出。"
最终目的就是这八个字——下游业务能用。JSON 不是给人看的,是给程序消费的。程序要求字段名对、类型对、结构对——差一个 number vs string 就 crash。
七、第五种方案:Tool Call — 偏门但好用
tool-call-args.mjs 展示了一个"偏门"方案——用 Tool Call 实现结构化输出。
readme 第一行注释就说了灵感来源:
"从 tool-call zod schema 得到灵感,可以直接 tool-call?"
思路
Tool Call 本来是让 LLM 调用外部工具的——你给 LLM 一堆工具定义,LLM 选择合适的工具并生成参数。但"生成参数"这件事,本质上就是结构化输出——工具的参数是有 Schema 约束的。
那如果我只定义一个"工具",但不真的调用它——只利用 LLM 生成参数的能力来拿到结构化数据呢?
代码
<span>import</span> { z } <span>from</span> <span>"zod"</span>;
<span>const</span> scientistSchema = z.<span>object</span>({
<span>name</span>: z.<span>string</span>().<span>describe</span>(<span>'姓名'</span>),
<span>birth_year</span>: z.<span>number</span>().<span>describe</span>(<span>'出生年份'</span>),
<span>nationality</span>: z.<span>string</span>().<span>describe</span>(<span>'国籍'</span>),
<span>fields</span>: z.<span>array</span>(z.<span>string</span>()).<span>describe</span>(<span>'研究领域列表'</span>),
});
<span>// llm 调用的上下文</span>
<span>const</span> modelWithTool = model.<span>bindTools</span>([
{
<span>name</span>: <span>'extract_scientist_info'</span>,
<span>description</span>: <span>'提取和结构化科学家的详细信息'</span>,
<span>schema</span>: scientistSchema,
}
]);
<span>const</span> response = <span>await</span> modelWithTool.<span>invoke</span>(<span>'介绍一下爱因斯坦'</span>);
<span>console</span>.<span>log</span>(response.<span>tool_calls</span>[<span>0</span>].<span>args</span>);
关键点:
model.bindTools([...])— 给模型绑定一个"工具",工具的 schema 用 Zod 定义modelWithTool.invoke('介绍一下爱因斯坦')— 正常调用,但 LLM 会以 tool call 的形式返回response.tool_calls[0].args— 直接拿到结构化的参数对象
不需要 getFormatInstructions(),不需要 parser.parse() — LLM 直接在 tool_calls[0].args 里返回结构化数据。
为什么好用?
readme 注释解释了:
"这个工具不是为了直接调用,只做 schema 校验,而是为了方便后续的解析和处理。"
这个"工具"根本不会被执行——它的存在只是为了让 LLM 按照 schema 生成参数。Tool Call 是 LLM 原生能力,模型在训练时就学会了按 schema 输出参数,比 prompt 约束更可靠。
对比 OutputParser
readme 最后抛出了一个问题:
"这种方式 output parser 更好。output parser 模块还有存在的必要吗?"
这是个好问题。Tool Call 方案的优势:
| 特性 | OutputParser | Tool Call |
|---|---|---|
| 原理 | prompt 约束 + 后处理解析 | LLM 原生能力 |
| 可靠性 | 中等(LLM 可能不听 prompt) | 高(模型训练时就学了) |
| 额外调用 | 需要 parse 步骤 | 直接从 `tool_calls` 取 |
| 通用性 | 任何模型 | 需要模型支持 tool call |
Tool Call 更可靠、更简洁——但它依赖模型支持 tool calling 能力。大部分主流模型(GPT-4、Claude、DeepSeek)都支持了,但一些小模型可能不行。
OutputParser 的优势在于通用性——任何能返回文本的 LLM 都能用。所以两者不是替代关系,而是不同场景的选择:
- 模型支持 tool call → 用 Tool Call,更可靠
- 模型不支持 tool call → 用 OutputParser,prompt 约束
八、完整方案选型指南
需要 <span>LLM</span> 返回结构化 <span>JSON</span>?
│
├── 模型支持 <span>Tool</span> <span>Call</span>?
│ ├── 是 → bindTools + tool_calls[<span>0</span>].<span>args</span>(最可靠)
│ └── 否 → 继续 ↓
│
├── 需要精确类型和嵌套结构?
│ ├── 是 → <span>StructuredOutputParser</span> + fromZodSchema
│ └── 否 → 继续 ↓
│
├── 需要固定字段名?
│ ├── 是 → <span>StructuredOutputParser</span> + fromNamesAndDescriptions
│ └── 否 → 继续 ↓
│
└── 只需要合法 <span>JSON</span>?
└── <span>JsonOutputParser</span>
从上到下,约束越来越松——选哪个取决于你的需求精度和模型能力。
九、结构化输出的本质:prompt 约束 + 后处理
不管哪一级方案,核心逻辑都是 readme 说的这两步:
"本质就是通过 getFormatInstructions() 在 prompt 里添加对 output 的结构化格式约定,parser.parse() 去除 markdown 拿到 json。"
第一步:约束 — 在 prompt 里告诉 LLM "你必须返回什么格式"。getFormatInstructions() 自动生成这段约束文本。
第二步:解析 — LLM 返回后,剥掉 markdown 外衣,JSON.parse() 成对象。parser.parse() 自动完成。
Tool Call 方案跳过了这两步——LLM 直接在 tool_calls 里返回结构化数据,不需要 prompt 约束,也不需要后处理解析。所以 readme 才会问"output parser 模块还有存在的必要吗"——在支持 tool call 的模型上,OutputParser 确实可以省掉。
十、从个人经验到通用 API
回头看整条进化线——从手搓正则到 Tool Call,每一步都在做同一件事:让 LLM 的输出从"自由文本"变成"可靠数据"。
readme 说了一句很到位的话:
"每次调用 AI 的常见业务,langchain 提供相应的业务 API,省去开发的复杂度。"
手搓正则是"个人经验"——每个开发者自己写、自己维护、自己兜底。LangChain 把这些"常见业务"封装成了通用 API:
| 你手搓的 | LangChain 的 API |
|---|---|
| 写正则剥 markdown | `parser.parse()` 自动剥 |
| 写 prompt 约束格式 | `parser.getFormatInstructions()` 自动生成 |
| 手动 JSON.parse + try/catch | `parser.parse()` 内部处理 |
| 手动验证字段名和类型 | Zod Schema 运行时验证 |
不要重复造轮子。 LangChain 的 OutputParser 模块就是把"让 LLM 输出可靠 JSON"这个重复性工作标准化了。
PS:LLM 是个话痨——你让它吐 JSON,它非要裹一层 markdown、加一段开场白、偶尔还跑偏格式。从手搓正则到 JsonOutputParser 到 StructuredOutputParser 到 Zod Schema 到 Tool Call——五级进化,每一级都是给 AI 多加一道紧箍咒。下次 LLM 又给你裹了一层 ````json,别手搓正则了——parser.parse()` 一行搞定。
从手搓正则到 Zod 再到 Tool Call,把结构化输出的坑与演进讲得很透。适合被 JSON.parse 报错折磨过的 AI 应用开发者,按业务复杂度选级即可,不必一步到位。