让大模型输出 JSON:从"正则剥 markdown"到"让它调个工具",一条可靠性升级之路

让大模型输出 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.mjsmodel.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 结构化输出时的决策顺序

  1. 只是要个对象?→ JsonOutputParser
  2. 键名必须对?→ StructuredOutputParser.fromNamesAndDescriptions
  3. 类型/嵌套/可选都得验?→ StructuredOutputParser.fromZodSchema
  4. 想最稳、且本身就在写 Agent?→ 直接 bindTools,读 tool_calls.args

一个开放问题(评论区聊聊) :output_parser 在 bindTools 大规模普及后,会不会退出历史舞台?我的看法是------它管的是"把内容变结构",tool call 管的是"让它按结构输出",两者并存,但未来的新项目里,bindTools 出现频率会越来越高。你更常用谁?


相关推荐
全栈弄潮儿3 小时前
AI 编程进阶实战:我为什么要做这个专栏
chatgpt·openai·ai编程
uncle_ll3 小时前
智能客服实践:微调+RAG双引擎架构落地
llm·agent·智能客服·rag·llamaindex
晴天小庭3 小时前
Skynet AI 论坛邀请你参加一场AI觉醒实验
aigc·openai·ai编程
桃西西呀4 小时前
同一个模型 30% 到 100%?拆解 Harness 工程的 5 个机制,附 8 个坑的自检清单
人工智能·llm·ai编程
Behavior4 小时前
Meta 发布 Muse Glimmer:30B 参数、一张 24GB 显卡就能常驻的本地 Agent 模型
llm·aigc·ai编程
吴佳浩4 小时前
为什么现在越来越多的开源模型,都“毕业“于 Qwen?
人工智能·llm·ai编程
CoderJia程序员甲6 小时前
GitHub 热榜项目 - 周榜(2026-09-06)
ai·大模型·llm·github·ai教程
梦想的颜色6 小时前
【AI速览】GPT‑6 Astra 硬核深度解析:不是噱头 AGI,而是面向端到端 Agent 工作流的前沿旗舰
gpt·大模型·openai·agent·deepseek·gpt6‑astra·大模型横评
夫子3967 小时前
【第三部分:第一个 Agent 应用】12. 使用状态机开发一个可控 Agent
langchain·llm·agent