让大模型输出 JSON:从"正则剥 markdown"到"让它调个工具",一条可靠性升级之路
开篇:一个让所有"接 LLM 的程序"都头疼的事
业务系统要让大模型干活,几乎都绕不开同一个问句:
"你能不能给我一个结构化的 JSON?我要拿去继续算、入库、渲染。"
大模型原生吐的是自然语言,还是个人类看起来舒服、机器看着抓狂的 Markdown。于是每个从零接 LLM 的开发者都会先踩一遍坑,模型输出"礼貌地"把 JSON 包在一个 markdown 代码块里:
json
{
"name": "爱因斯坦",
"birth_year": 1879
}
注意:这句作为 markdown 时没问题,但真正的 json 被 ```````json```` 包裹住了 。你 JSON.parse(rawContent) 会直接炸。第一反应是上正则剥掉包裹------能用,但每次型号一变、措辞一变,正则又得改。这条路我走得很熟悉,因为它就是 output_parser 这套工具想替我们省掉的东西。
这篇文章,我把让 LLM 稳定吐 JSON 的五种手段从松到严走一遍,最后得出一个可能让你意外的结论:最优解可能根本不是写 prompt,而是让模型"发起一次工具调用"。
核心概念:输出解析的本质只有两件事
先把这事的底层想明白,后面所有手段都是这两件事的变体:
让大模型输出 JSON = 「约束格式」 + 「解析内容」。 约束得越死,可信度越高;解析得越省事,代码越稳。
大模型的输出是最最不可靠的"格式约定者",所以我们的工作就是把"格式约定"从"话术依赖"逐步升级成"结构化约束" ,同时把"内容提取"从"手写正则"逐步升级成"官方解析器"。
对照不同手段的"可靠性"阶梯,是本篇的主线:
| 手段 | 约束方式 | 解析方式 | 可靠性 |
|---|---|---|---|
| 手写正则剥 Markdown | prompt 口头要求 | match(/```json/) 人肉剥 |
★(玄学) |
JsonOutputParser |
注入"输出 JSON"格式说明 | parse() 递归解析 |
★★ |
StructuredOutputParser(Names/Descriptions) |
字段名+描述 | parse() |
★★★ |
StructuredOutputParser(Zod Schema) |
类型+嵌套+可选 | parse() 强校验 |
★★★★ |
bindTools 工具调用 |
用函数签名当 schema | 直接读 tool_calls.args |
★★★★★ |
一句话记住:prompt 是"求"模型,schema 是"规定"模型,tool call 是"逼"模型 ------ 越往后越不靠运气。
第一站:JSON.parse 之前的"玄学"------手写正则
这其实是一开始 normal.mjs 里被注释掉的思路(我刚开始写 LLM 程序时就是这么干的):
javascript
// 正则提取 markdown 代码块中的 JSON ------ 分组的"玄学"
const jsonMatch = response.content.match(/```json\s*([\s\S]*?)\s*```/);
const jsonStr = jsonMatch ? jsonMatch[1] : response.content;
const jsonResult = JSON.parse(jsonStr); // 终于拿到对象
console.log(jsonResult.name); // "爱因斯坦"
痛点 :response.content.match(/```json ... ```/) ------
- 模型偶尔把标签写成 ``````````` 没有
json字样,正则match返回null,直接跳过。 - 有时模型会输出多个 代码块、或外面再套一层解释文字,
match只取第一个,丢了别的。 - 每换一个更聪明的模型,它"讲礼貌"的方式就变一点,正则跟着失效。
这是最脆弱的一档 :不是不能用,是不可维护 。LangChain 正是为了干掉这段正则,提供了 JsonOutputParser。
第二站:JsonOutputParser ------ 官方帮你剥 Markdown
normal.mjs 的正解。JsonOutputParser 只做两件事,恰好对应核心概念的"约束 + 解析":
javascript
import { JsonOutputParser } from '@langchain/core/output_parsers';
const parser = new JsonOutputParser(); // 1. 创建解析器
const prompt = `
请介绍一下爱因斯坦的信息,请以 JSON 格式返回,包含:
name(姓名)、birth_year(出生年份)、nationality(国籍)、
major_achievements(主要成就, 数组)、famous_theory(著名理论)
${parser.getFormatInstructions()} // 2. 把格式约束注入 prompt
`;
const response = await model.invoke(prompt);
const result = await parser.parse(response.content); // 3. 解析出纯对象
console.log(result, result.name);
为什么它能干掉正则? 看它的原理(也是 readme 里总结的):
parser.getFormatInstructions()------ 在 prompt 里塞一段"请严格输出 JSON "的格式约定,约束格式(虽然它对 json 太常见反而返回空约束,但对普通格式有用)。parser.parse()------ 剥掉 ```````json```` 包裹、递归取到真正的 JSON 对象 ,解析内容。
本质就是把我手写的那段脆弱正则,收编成了官方稳定的实现。代码清一截,心智负担少一截。
但 JsonOutputParser 有个"软肋":它只保证"输出是 JSON",不保证"字段键名和类型你想要的" 。你要求 birth_year,它可能给你 birthYear,也可能给字符串 "1879"。要让"型和值"都靠谱,得上第二级。
第三站:StructuredOutputParser ------ 用名字和描述"逼"出正确字段
structured-output-parser.mjs。相比 JsonOutputParser 只约束格式,StructuredOutputParser 直接把"字段有哪些、每个是什么含义"写死,让模型每发一个字段都按你的约定来:
javascript
import { StructuredOutputParser } from '@langchain/core/output_parsers';
// 用"字段名 + 中文描述"定义你想要的 JSON 形状
const parser = StructuredOutputParser.fromNamesAndDescriptions({
name: "姓名",
birth_year: "出生年份",
nationality: "国籍",
major_achievements: "主要成就, 用逗号分隔的字符串",
famous_theory: "著名理论"
});
const question = `请介绍一下爱因斯坦的信息。\n${parser.getFormatInstructions()}`;
const response = await model.invoke(question);
const result = await parser.parse(response.content);
// 直接拿到结构化了的结果,不再担心键名漂移
console.log(result.name, result.nationality);
fromNamesAndDescriptions 会把字段定义转成一段明确的格式说明塞给模型,parse 时按这套键反过来还原 。比 JsonOutputParser 强在"键集合被约束住了 "。但它仍有个不足:类型和嵌套没法约束 ------ birth_year 可能是 1879(数字)也可能是 "1879"(字符串),major_achievements 我虽然写了"逗号分隔",它未必给你数组。
要是连类型、可选字段、嵌套数组都要严丝合缝,得再上一档。
第四站:接上 Zod ------ 用 Schema 把输出"焊死"
structured-output-parser2.mjs。这一次引入 zod,把"期望的结构"从一堆描述,升级成一张完整、可校验、带类型的 schema。看这个 schema 的"严苛"程度------嵌套的数组、可选字段一口气全定义上:
javascript
import { z } from 'zod';
import { StructuredOutputParser } from '@langchain/core/output_parsers';
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字以内'),
});
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);
const question = `请介绍一下居里夫人的详细信息,\n${parser.getFormatInstructions()}`;
const response = await model.invoke(question);
const result = await parser.parse(response.content); // 强校验,类型不对会抛错
console.log(result.name, result.nationality);
这里有个容易踩的坑 (我自己踩过):fromZodSchema 模式下,getFormatInstructions() 会产生很长的一段 JSON Schema 文本 ,直接往 prompt 里塞可能挤掉主指令。实战中常需要把格式说明和问题分开段落,并控制问题篇幅,避免上下文被他的一整段 JSON Schema 淹没。
到这一档,输出已经是"声明式、可校验、类型安全"了。你以为这就到头了?真正让我归零重想的,是第五站。
第五站:反直觉 ------ 让模型"发起工具调用",而不是"生成 JSON"
tool-call-args.mjs 抛出了全文最值得思考的视角,也是它注释里那个灵魂发问:
这种从 tool-call 的 zod schema 得到灵感的方法,可以直接 tool-call 吗?output parser 模块还有存在的必要吗?
代码是这样:不给 prompt 加任何格式约束,而是定义一个"工具",让模型去提供这个工具的调用参数:
javascript
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('研究领域列表'),
});
// "工具"的 schema = 我们想要的输出结构
const modelWithTool = model.bindTools([
{
name: "extract_scientist_info",
description: "提取和结构化科学家的详细信息",
schema: scientistSchema,
}
]);
const response = await modelWithTool.invoke("介绍一下爱因斯坦");
// 🔑 不解析任何文本,直接读模型"决定调用工具时"的参数
console.log(response.tool_calls[0].args);
它为什么更稳? 因为对模型来说,"这个函数签名就是我被要求填的字段表"是一个远比"请输出 JSON"强得多的契约:
- 模型对
tool_calls结构化输出的把握,远强于对"自由的 Markdown→JSON"的把握。 - 结果直接落在
response.tool_calls[0].args这个强类型字段 里,既不用剥 Markdown、也不用 parse、更不用正则。 .args本身就是对象------直接能用。
所以那个发问的答案是:在"我要一个结构化结果"的场景里,bindTools 确实可以部分替代 output_parser,甚至在某些 Agent 框架里它就是更主流的做法。 但 output_parser 的职责是"把模型吐的任何内容(文本/流/对话)转成结构",适用面更广。两者不是简单的替代关系。
一个贯穿全程的重要支线:流式 + SSE
很多人疑惑"output_parser"怎么和"流式/SSE"放在一起。因为同一份"输出",又有两种交付形态:一次性返回(buffer)和流式吐字(pipe) 。plan.md 用很形象的话讲了这个本质:
流式输出 = 水管 :一头接 LLM server,一头接客户端,token 源源不断流过来。 HTTP 里,同步响应是 buffer(攒够再给),流式响应是 pipe(边到边发)。
stream-normal.mjs 用 model.stream() 拿到真正的流,逐 chunk 接住并拼回全文:
javascript
const stream = await model.stream(prompt); // 🔑 stream 而非 invoke
let fullContent = '';
let chunkCount = 0;
for await (const chunk of stream) {
chunkCount++;
fullContent += chunk.content;
process.stdout.write(chunk.content); // 实时显示流式文本
}
console.log(`\n共接收 ${chunkCount} 个数据块`);
为什么用流式? 用户体感是"字一个个蹦出来"而不是"转半天一次性出现";对大模型更是 TTFT(首字延迟) 优化的标配。
让"流"真正跑到浏览器端,就是 SSE(Server-Sent Events) :服务器开一条不关闭的长连接 ,一点一点 res.write() 推 chunk,浏览器端用 EventSource 接收并触发 onmessage 实时渲染。sse-demo 完整演示:
服务端 (server.js)------ 三个头是关键,少了 text/event-stream 浏览器不认:
javascript
res.writeHead(200, {
'Content-Type': "text/event-stream", // 🔑 必须是这个
'Cache-control': 'no-cache',
'Connection': 'keep-alive', // 🔑 长连接不断
});
const timer = setInterval(() => {
if (index >= words.length) { clearInterval(timer); res.end(); return; }
res.write(`data: ${words[index]}\n\n`); // SSE 数据格式:data: xxx\n\n
index++;
}, 1000);
浏览器端 (index.html)------ 给 EventSource 一个 URL,就是建立长连接:
html
<script>
const resultEle = document.getElementById('result');
const eventSource = new EventSource("http://localhost:3000/stream");
eventSource.onmessage = (e) => { // 每来一个 chunk 触发一次
console.log(e.data);
resultEle.innerText += e.data; // 逐字追加渲染,就是"打字机"效果
}
</script>
node server.js 后打开 http://localhost:3000/,你会看到"你好,欢迎了解sse"一个字一个字蹦出来 ------这就是把"模型流式吐 token"通过 SSE 呈现给用户的完整链路。这里我也踩过一个坑 :早期版本在文件流里出错时还想 res.writeHead(500),结果报 ERR_HTTP_HEADERS_SENT------因为头已经 writeHead(200) 发过了,正确的做法是读取出错时 res.destroy(err)。记住:头只能写一次。
一张图串起全篇
scss
大模型吐"自然语言 + Markdown"
│
┌─────┴────── 交付形态 ──────┐
▼ ▼
一次性返回 (buffer) 流式吐出 (pipe)
await invoke() await stream()
│ │
▼ ▼
JSON.parse 处失败? 浏览器收到流 → SSE
│ EventSource +
│ text/event-stream + keep-alive
▼ │ onmessage 逐字渲染
┌─ 可靠性阶梯:正则 → JsonOutputParser
│ → StructuredOutputParser(Names)
│ → StructuredOutputParser(Zod)
└─ 我却推荐:model.bindTools() 读 tool_calls.args
结尾:回扣那个提问
从手写正则的"玄学",到 JsonOutputParser 的"省心",到 StructuredOutputParser + Zod 的"焊死",再到 bindTools 的"让它调个工具"------可靠性是一次比一次高,而代码却一次比一次少。
记住:想让大模型可靠地吐 JSON,prompt 是说"请你",Zod schema 是"规定你",bindTools 是"命令你"。越往下,越不赌它的心情。
下次你的程序要接入 LLM 结构化输出时的决策顺序:
- 只是要个对象?→
JsonOutputParser - 键名必须对?→
StructuredOutputParser.fromNamesAndDescriptions - 类型/嵌套/可选都得验?→
StructuredOutputParser.fromZodSchema - 想最稳、且本身就在写 Agent?→ 直接
bindTools,读tool_calls.args
一个开放问题(评论区聊聊) :output_parser 在 bindTools 大规模普及后,会不会退出历史舞台?我的看法是------它管的是"把内容变结构",tool call 管的是"让它按结构输出",两者并存,但未来的新项目里,bindTools 出现频率会越来越高。你更常用谁?