让大模型「听话」输出 JSON:LangChain OutputParser 结构化输出实战,从手写正则到 Zod Schema 四层进化

大模型只会吐文本,而且张口就是 Markdown------可下游业务代码要的是 JSON.parse() 之后的对象。为了拿到靠谱的 JSON,你手写过正则吗?被「明明要了 JSON 却包了一层 ```json 代码块」坑过吗?

本文用一个完整 demo 讲清一件事:如何让 LLM 的输出从「人能看的文本」变成「程序能直接用的结构化数据」 。技术栈:LangChain.js + @langchain/openai + Zod,会顺着从手写正则到原生 Function Calling 的升级路线走一遍,每一层解决什么问题一目了然。

一、先看问题:LLM 输出为什么「不听话」

调用大模型,一句话就回来了:

javascript 复制代码
请介绍一下爱因斯坦的信息,以 JSON 返回,包含 name、birth_year、nationality、major_achievements...

LLM 的回答大概率长这样:

markdown 复制代码
好的,以下是爱因斯坦的相关信息:

```json
{
  "name": "阿尔伯特·爱因斯坦",
  "birth_year": 1879,
  "nationality": "德国",
  "major_achievements": ["狭义相对论", "质能方程 E=mc²", "1921 年诺贝尔物理学奖"],
  "famous_theory": "相对论"
}

希望对你有帮助!

javascript 复制代码
问题全在这一坨里:

1. **它是给「人展示」的 Markdown**,前后有寒暄语、外面还包着一层 ` ```json ` 代码块;
2. 你想 `JSON.parse()`,它**先得把壳剥掉**才能 parse;
3. 万一这次 LLM 心情不好没包代码块、字段名写错、顺序乱了、字符串里带了换行...... `JSON.parse()` 直接抛异常。

**本质矛盾**:LLM 是文本生成模型,它的原生输出是「给人读的字符串」;而下游业务(存库、渲染、当函数参数)要的是「给机器读的结构化数据」。

Output Parser(输出解析器)就是来解决这个矛盾的。**它做两件事:在 prompt 里注入「格式要求」,在拿到回复后把 JSON 解析出来。** 下面从最笨的手写方式开始,一层层进化到生产可用的方案。

---

## 二、前置背景:流式输出与 SSE(输出为什么是「流」)

讲解析之前,先补一个前置概念------为什么我们常听说 LLM 的输出是「流」?这决定了我们拿到内容的方式。

### 2.1 `invoke` 同步 vs `stream` 流式

```js
// stream-normal.mjs(节选)
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";

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 prompt = `详细介绍莫札特的信息。`;

// invoke:同步,一口气等完整答案
// const response = await model.invoke(prompt);

// stream:流式,像水管一样,token 一段一段往客户端流
const stream = await model.stream(prompt);

理解 stream 最好的比喻是 水管

  • 一头接着 LLM server,一头是客户端;
  • LLM 生成一个 token 就顺着管子流过来一个 chunk(数据块);
  • 客户端这边用 for await...of 逐个接住,收到一个字先展示一个字,不用等整篇生成完。
js 复制代码
let fullContent = '';
let chunkCount = 0;

for await (const chunk of stream) {
  chunkCount++;
  fullContent += chunk.content;
  process.stdout.write(chunk.content); // 边收边打字,这就是「打字机效果」
}
console.log(`\n\n共接收 ${chunkCount} 个数据块`);

流式的价值:首字延迟(TTFT)大幅下降,用户不用盯着空白转圈。ChatGPT / 各种 Chat 界面的逐字输出,本质都是这么实现的。

2.2 HTTP 响应不是只能同步返回------还有 SSE

那服务器怎么把内容「分多次」推给浏览器?这就要提到 SSE(Server-Sent Events,服务器推送事件)

传统 HTTP 是「请求 → 响应 → 断开」的简单同步模型,一次只能答一次:

arduino 复制代码
Content-Type: text/plain / text/html

而 SSE 换了一套响应头,服务器单向、持续地往浏览器推消息,可以推很多次,连接不断开

yaml 复制代码
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

三行头,每一行都有讲究------尤其是 Cache-Control: no-cache:

响应头 作用 不写会怎样
Content-Type: text/event-stream 告诉浏览器「这是事件流」,按 SSE 协议逐条解析后续 data: 消息 浏览器当成普通文本/HTML,EventSource 无从解析
Cache-Control: no-cache 关键:告诉浏览器和中间代理「这条响应别缓存、别缓冲」 见下面详述 👇
Connection: keep-alive 明确要求「连接别用完就断」,这条长连接要一直复用 某些代理/网关可能主动掐断空闲连接

no-cache 为什么是 SSE 的命根子?SSE 和普通响应最大的区别是:它是一条长期不关闭的连接,数据是一个一个 chunk 到达的 。浏览器和中间的 CDN / 网关都有「响应缓冲」的本能------如果允许缓存,它们可能把收到的数据整个攒起来,直到连接关闭才一口气吐给你。对普通页面这没影响,但对 SSE 就是灾难:

makefile 复制代码
普通响应(可缓存):   服务器 ──攒到结束──► 代理 ──一次性──► 浏览器
SSE 响应(要 no-cache): 服务器 ──chunk到达即转发──► 浏览器 ← 逐字实时

设了 no-cache,代理和浏览器就不敢滞留数据 ,每个 chunk 到达即转发给页面------「打字机逐字输出」的效果就是这么保住的。如果去掉它,你看到的很可能不再是逐字蹦字,而是攒了十几秒后整段闪出来 ,实时性荡然无存。(某些场景还要加 no-transformX-Accel-Buffering: no 来防 nginx 层缓冲,本质同一个问题。)

用 Node 内置 http 模块就能写一个迷你 SSE 服务(sse-demo/server.js):

js 复制代码
const http = require('http');

const server = http.createServer((req, res) => {
  if (req.url === '/stream') {
    res.writeHead(200, {
      'Content-Type': 'text/event-stream', // 关键:事件流
      'Cache-control': 'no-cache',
      'Connection': 'keep-alive',
    });

    let words = ["你", "好", ", ", "欢", "迎", "了", "解", "sse"];
    let index = 0;
    const timer = setInterval(() => {
      if (index >= words.length) { clearInterval(timer); return res.end(); }
      // SSE 数据格式:每条消息以 data: 开头,两个换行结尾
      res.write(`data: ${words[index]}\n\n`);
      index++;
    }, 1000);
  }
});

server.listen(3000, () => console.log('server is running on port 3000'));

SSE 每条消息的格式data: <内容>\n\n。一次连接里可以 write 无数次,每两秒蹦一个字出去。

2.3 浏览器端:EventSource,白嫖一个长连接

浏览器有个原生类 EventSource,专门用来连 SSE------给它一个 URL 就行

html 复制代码
<!-- sse-demo/index.html -->
<div id="result"></div>
<script>
  const resultEle = document.getElementById('result');
  // 连上服务器 /stream,长连接建立
  const eventSource = new EventSource("http://localhost:3000/stream");

  // 服务器每推来一个 data,就触发一次 onmessage
  eventSource.onmessage = (e) => {
    console.log(e.data);
    resultEle.innerText += e.data;  // 一个字一个字往上拼
  };
</script>

跑法:在 sse-demo/node server.js,浏览器打开 http://localhost:3000,就能看到「你好, 欢迎了解sse」一个字一个字蹦出来。

SSE 不止 LLM 用------股票行情、实时通知、日志推送都是它的主场。一句话:HTTP 的响应可以是「流式的」,SSE 是其中最简单的一种服务端推送协议。


三、第 0 层:手写正则,把 JSON 从 Markdown 里抠出来

回到结构化输出。最原始的做法,LLM 吐出来的内容直接 JSON.parse 是会炸的,因为外面包了 Markdown 代码块。所以第一步是剥壳:

js 复制代码
const response = await model.invoke(prompt);

// 手写正则:把 ```json ... ``` 中间的部分抠出来
// 用分组 (...) 捕获代码块内容
const jsonMatch = response.content.match(/```json\s*([\s\S]*?)\s*```/);
const jsonStr = jsonMatch ? jsonMatch[1] : response.content;

// 剥完壳,才是真的 JSON 字符串,可以 parse 了
const jsonResult = JSON.parse(jsonStr);
console.log(jsonResult.name);

为什么不能省掉剥壳这一步? 因为 LLM 输出 JSON 时习惯性地包一层 Markdown 代码块------这本质上是它「展示给人类看」的惯性,对程序却是纯负担。

这层正则会遇到一堆边界情况:

  • 字符串内容里恰好有 ``` 符号?
  • LLM 没包代码块(直接裸 JSON)?→ 正则匹配不到,走 else 分支,还行;
  • 万一它包了但前面还带一段寒暄文字 ?→ 匹配能中,但你要的是干净的结果;
  • 字段顺序、缺失字段、中文引号...... JSON.parse 一个都忍不了。

手写正则能跑,但它把「LLM 生成的不可控文本」硬塞进「必须严格合法的 JSON」这条缝里------每调一次 AI,都要赌一次运气

想一想:每次调用都要做「约束格式 → 等它输出 → 剥 Markdown → parse」这套流程,太常见了。LangChain 把它封装成了开箱即用的 Output Parser,省掉重复开发的复杂度。


四、第 1 层:JsonOutputParser,把「约束 + 解析」外包给框架

js 复制代码
// normal.mjs(节选)
import { JsonOutputParser } from "@langchain/core/output_parsers";

// 1. 建解析器
const parser = new JsonOutputParser();

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

// 3. 让模型回答
const response = await model.invoke(prompt);

// 4. 直接 parse:内部自动剥掉 Markdown 壳,返回 JS 对象
const result = await parser.parse(response.content);
console.log(result, result.name);

JsonOutputParser 就干两件事,正好对应我们第 0 层手写的活:

方法 干什么 对应手写代码
getFormatInstructions() 在 prompt 里注入「请只输出 JSON」的结构化约定 你自己写的「请以 JSON 返回...」
parse() 剥掉 ```json 包裹 → JSON.parse() 那段正则 + parse

注意第 20 行,我们自己在 prompt 里用中文描述了一遍字段 ------这依然是必要的。JsonOutputParsergetFormatInstructions() 输出很简(毕竟 JSON 是最常见需求),真正定义「字段有哪些、各自是啥含义」的是我们在 prompt 里写的约束。

这一层解决了「剥壳 + parse」的重复劳动,但还有两个大隐患

  1. 字段类型全靠嘴说 。你说 birth_year 是出生年份,LLM 可能给你字符串 "1879" 而不是数字 1879
  2. parse 出来的东西没有结构保证result 是个自由对象,result.major_achievements 可能压根不存在。

想要更「靠谱」的 JSON,进入第 2 层。


五、第 2 层:StructuredOutputParser,每个字段都配 name + description

js 复制代码
// structured-output-parser.mjs(节选)
import { StructuredOutputParser } from "@langchain/core/output_parsers";

// 把「字段清单」结构化地交给 parser:name + description
const parser = StructuredOutputParser.fromNamesAndDescriptions({
  name: "姓名",
  birth_year: "出生年份",
  nationality: "国籍",
  major_achievements: "主要成就, 用逗号分隔的字符串",
  famous_theory: "著名理论",
});

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

const response = await model.invoke(question);
console.log(response.content);

const result = await parser.parse(response.content);
console.log(`姓名:${result.name}`);
console.log(`国籍:${result.nationality}`);

JsonOutputParser 的差别就一个:你给每个字段声明了名字(name)和含义(description)

于是 getFormatInstructions() 生成的 prompt 指令不再是空泛的「输出 JSON」,而是带着字段名、字段含义、字段类型的明确 JSON 结构要求。这比光靠中文自然语言约束强得多------模型更不容易自由发挥。

这一层你其实在用「对象结构」描述期望输出,让模型照着填:

json 复制代码
{
  "name": "阿尔伯特·爱因斯坦",
  "birth_year": 1879,
  "nationality": "德国",
  "major_achievements": "狭义相对论,质能方程",
  "famous_theory": "相对论"
}

注意上面对 major_achievements 我们描述成了「用逗号分隔的字符串 」------因为 fromNamesAndDescriptions 只能描述扁平的 key/value,表达不了「数组」「嵌套对象」。字段一复杂就露怯了。

想要真正严格的类型约束?上 Zod。


六、第 3 层:Zod Schema,类型化、嵌套、可选一个都不能少

js 复制代码
// structured-output-parser2.mjs(节选)
import { StructuredOutputParser } from "@langchain/core/output_parsers";
import { z } from "zod";

// 用 Zod 定义一套「严苛的 Schema」来约束输出
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("获得的重要奖项列表"),
  major_achievements: z.array(z.string()).describe("主要成就列表"),
  famous_theory: z.array(z.object({
    name: z.string().describe("理论名称"),
    year: z.number().describe("提出年份"),
    description: z.string().describe("理论简要描述"),
  })).describe("著名理论列表"),
  biography: z.string().describe("简短传记, 100字以内"),
});

// 由 Zod Schema 构建解析器
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);

const question = `请介绍一下居里夫人的详细信息,
${parser.getFormatInstructions()}`;

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

console.log(`姓名:${result.name}`);
console.log(`国籍:${result.nationality}`);

对比第 2 层,Zod 带来了几个质的提升:

fromNamesAndDescriptions fromZodSchema(Zod)
字段类型 不能声明,靠描述文字猜 z.number() 写死
数组 / 嵌套对象 ❌ 做不到 z.array(z.object(...))
可选字段 death_year.optional()
运行时校验 parse 只剥壳 parse 时校验类型,不符合就报错

parse() 不再只是「剥壳」,它真的在运行时校验结构 :如果模型把 birth_year 返回成字符串,Zod 会直接抛解析错误,绝不会把脏数据悄悄交给下游------出错要早,别带病上线

到这一层,你能描述任意复杂的输出结构,交给下游的 JSON 也足够靠谱了。但故事还没完------demo 的最后一个文件抛出了一个更「狠」的思路。


七、第 4 层:杀手锏------与其让 LLM 写 JSON,不如让它「调函数」

js 复制代码
// tool-call-args.mjs(节选)
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";

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

// bindTools:给模型挂上一个「工具」
const modelWithTool = model.bindTools([
  {
    name: "extract_scientist_info",
    description: "提取和结构化科学家的详细信息",
    schema: scientistSchema,
  },
]);

const response = await modelWithTool.invoke("介绍一下爱因斯坦");

// 关键:不用 parse,模型自己把参数按 Schema 填好返回
console.log(response.tool_calls[0].args);
// => { name: "阿尔伯特·爱因斯坦", birth_year: 1879, nationality: "德国", fields: [...] }

思维反转 :前面几层都是「让模型以文本形式 一个 JSON,我们再费劲去解析」。但很多模型(GPT、DeepSeek、豆包、Kimi、通义......)本身支持 原生 Function Calling / Tool Calling ------你给它声明一个函数签名,它会在协议层面 按这个 Schema 生成结构化的 arguments

于是模型不再是「在文本里写一个 JSON 让你猜」,而是「直接调用 extract_scientist_info 这个工具,参数已经按 Schema 填好」,参数格式由 API 协议保证 ,天然就是干净的结构化对象,不需要任何剥壳、parse、赌运气

js 复制代码
response.tool_calls[0].args  // 直接就是结构化对象

可靠性对比,一目了然:

javascript 复制代码
写文本 JSON(OutputParser 路线):
  prompt 约束 → LLM 自由发挥写文本 → 剥 Markdown 壳 → JSON.parse → 校验
                      ↑ 全程不可控,全靠模型「自觉」

Function Calling(bindTools 路线):
  Schema 声明工具 → LLM 按协议填参数 → 直接拿结构化 args
                      ↑ 格式由协议强制保证,稳定可靠

output_parser 模块还有存在的必要吗?------ 这是 demo 作者留在代码里的灵魂发问。

我的看法 :如果你的模型支持 Function Calling,优先用 Tool Calling,它又快又稳。但 Output Parser 依然有存在价值:

  1. 很多纯文本模型 / 老模型、或通过某些网关接入时不支持 function calling
  2. 你在做的是「让模型生成一段要落库/渲染的数据」,不想为了拿个 JSON 就引入工具调用的心智。

两者不是替代关系,而是同一目标的两条路线:协议层支持就抄近道,不支持就用 parser 兜底。


八、四层横向对比与选型建议

手段 原理 可靠性 表达能力 适用场景
手写正则 自己约束 + 剥壳 + parse ⭐ 随缘 一次性脚本、最不济的兜底
JsonOutputParser 自动注入「输出 JSON」+ 自动剥壳 parse ⭐⭐ 只要一个「大 JSON」,字段不挑剔
StructuredOutputParser.fromNamesAndDescriptions 每个字段声明 name + description ⭐⭐⭐ 中(扁平结构) 字段固定的简单表单类输出
StructuredOutputParser.fromZodSchema Zod Schema 定义类型/嵌套/可选 ⭐⭐⭐⭐ 高(任意嵌套) 复杂、需要运行时校验的业务结构
model.bindTools() Function Calling 按协议填函数参数 ⭐⭐⭐⭐⭐ 高(Schema 同样强大) 模型支持时首选,最稳

选型口诀

  1. 能用 Function Calling 就别用 Parser------协议层保证的格式,比提示词和正则靠谱一个量级;
  2. 下游只是要个对象、结构不复杂JsonOutputParser 足够轻;
  3. 下游要强类型、嵌套、要防止脏数据落库fromZodSchema 配上 Zod,parse 即校验。

九、踩过的坑 & 关键点复盘

9.1 LLM 输出的 JSON 常被 Markdown 包裹,别直接 parse

这是最容易炸的一步。模型默认「给人展示」,习惯输出:

markdown 复制代码
```json
{ ... }
```

Output Parser 的 parse() 之所以能稳,就是内置了剥壳逻辑。自己写正则时,一定要处理「没包代码块」和「代码块前后有寒暄语」两种分支。

9.2 getFormatInstructions() 到底是个啥?

不是魔法,就是返回一段 prompt 文本------把它拼到你的问题后面,等于在提示词里注入格式约定。想看清它说了什么,直接打印:

js 复制代码
console.log(parser.getFormatInstructions());

你会发现它生成的是类似「你的响应应该是一个 JSON 对象,结构为 ...,只输出 JSON,不要其他文本」的指令。理解这一点,你就明白 Output Parser = 提示词注入器 + 响应清洗器

9.3 流式 + 结构化:注意缓冲与输出流

如果开了 streamchunk一块块 的,一个完整 JSON 往往被拆成几十块。别在每一块上单独 parse,要先在内存里拼 fullContent等流结束后再交给 parser:

js 复制代码
let fullContent = "";
for await (const chunk of stream) {
  fullContent += chunk.content;   // 先累积
}
const result = await parser.parse(fullContent);  // 流结束了再解析

9.4 拉高稳定性的几个小习惯

  • temperature: 0:结构化抽取场景下,别让模型「发挥」;

  • 统一走 baseURL 指向你的模型网关(DeepSeek / 豆包 / Kimi / 通义......只要是 OpenAI 兼容接口都能用 ChatOpenAI 接):

    env 复制代码
    # .env
    OPENAI_API_KEY=你的key
    OPENAI_BASE_URL=https://你的模型服务地址/v1
    MODEL_NAME=你的模型名
  • 生产要求零容错(落库、扣费、发指令)时,别依赖文本 JSON,直接用 Function Calling + Zod 双保险。


十、总结

一句话串起全文:

LLM 原生只吐「给人看的 Markdown」,程序要的是「能直接用的结构」。Output Parser 负责在 prompt 里注入格式约定、在响应里把壳剥掉;而当模型支持 Function Calling 时,连「让模型写 JSON」这步都可以省掉,直接按 Schema 拿结构化参数。

四个层级的升级脉络:

  1. 手写正则 ------ 明白了痛点:剥壳 + parse 的重复劳动;
  2. JsonOutputParser ------ 约束和解析外包,但字段类型无保证;
  3. StructuredOutputParser ------ 字段配 name + description,更听话了;
  4. fromZodSchema ------ 类型、嵌套、可选、运行时校验,严苛到极致;
  5. bindTools Function Calling(隐藏关)------ 别让模型写 JSON,让它填函数参数,最稳。

记住两句

  • Output Parser = getFormatInstructions()(注入约束) + parse()(剥壳解析),理解这一点,换任何框架都不慌;
  • 能调函数就别写文本 JSON------让模型「干活」而不是让它「写字」,结构化输出的尽头是 Function Calling。

如果你也正在为「大模型返回的 JSON 又脏又乱」头疼,希望这篇能帮你把「解析靠运气」变成「格式靠协议」。有问题欢迎评论区交流 👋

相关推荐
研☆香2 小时前
js中使用的正则表达式
开发语言·javascript·正则表达式
开开心心就好2 小时前
免费桌签打印工具,支持批量导入名字
前端·javascript·人工智能·docker·jupyter·智能手机·语音识别
CappuccinoRose2 小时前
Web Components 基础
前端·javascript·交互·web component·shadow dom
imgsq2 小时前
MapLibre GL JS v6 正式发布:v5 之后八个月的第一个大版本(编译)
开发语言·javascript·ecmascript
lytao1232 小时前
90% 覆盖率不等于没 Bug:用风险配置测试组合
前端·javascript·bug·软件工程
mayaairi2 小时前
Vue2 组件通讯(一):Props传值完全指南
前端·javascript·vue.js
码艺-Alimjan2 小时前
Tauri 2.x + Vue 3 桌面应用开发实战:从踩坑到完美落地
前端·javascript·vue.js·rust·typescript·go
qq_570398572 小时前
Three.js基础使用-案例
开发语言·javascript·ecmascript
程序员蜡笔熊3 小时前
Vite 8 换芯实测:Rolldown 替掉双引擎,构建快 3.19 倍
前端·javascript·vue