🔍 结构化输出:从 `JSON.parse` 到 `withStructuredOutput` 的三级火箭

目标:搞清 JsonOutputParserStructuredOutputParserwithStructuredOutput 三者的关系,以及这个模块到底该不该"丢掉"。


🧩 问题:大模型只会说"人话"

LLM 返回的永远是一个字符串。但下游业务要的是能点出来的对象:

js 复制代码
result.name       // ✅ 能这样用,业务才能继续跑
result.birth_year // ❌ 字符串里没有这种属性

所以我们需要一条链路:

复制代码
prompt 约束  ──▶  LLM 按格式返回  ──▶  解析成对象  ──▶  业务继续执行

这一条链路上有两个可调的位置:

  • 📝 约束:怎么让模型乖乖按格式输出?
  • 🔍 解析:拿到的字符串怎么安全变成对象?

下面的"三级火箭",本质就是约束的位置在不断升级


🥉 第 0 层:手写 JSON.parse(),然后翻车

最朴素的做法:在 prompt 里写清楚字段,然后 JSON.parse()

js 复制代码
const jsonStr = response.content;
const jsonResult = JSON.parse(jsonStr);

跑起来大概率是这个结果:

javascript 复制代码
SyntaxError: Unexpected token '`' ...

为什么?因为大模型很爱用 Markdown 输出,这是它展示信息的天性。你问它要 JSON,它给你:

json 复制代码
```json
{
  "name": "爱因斯坦",
  "birth_year": 1879
}
```

JSON 是"对"的,但被 ```````json```` 包裹住了,JSON.parse() 认不出。

于是有了第一版土办法 ------ 用正则把外壳剥掉:

js 复制代码
// 使用正则提取 markdown 代码块中的 json 字符串
// 分组
const jsonMatch = response.content.match(/```json\s*([\s\S]*?)```/);
// 正则业务逻辑
const jsonStr = jsonMatch ? jsonMatch[1].trim() : response.content;
const jsonResult = JSON.parse(jsonStr);

它能跑通,但问题很明显:

隐患 说明
🤕 没有代码块就崩 jsonMatchnull 时才兜底,模型多加一句话就完蛋
🤕 正则要自己维护 各种 json / JSON / 前后有解释文字的变体
🤕 每个项目重写一遍 纯重复劳动

💡 每次 AI 调用都会遇到这种常见业务,所以 LangChain 提供了对应的 API,省去开发复杂度。


🥈 一级火箭:JsonOutputParser

LangChain 的 JsonOutputParser 就是把上面那段"正则 + parse"封装好了。

js 复制代码
import { JsonOutputParser } from '@langchain/core/output_parsers';

const parser = new JsonOutputParser(); // json 输出解析器

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

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

上面那三行手写正则,被最后一行 parser.parse() 完全替代了 ------ 自动剥离 Markdown 包裹,拿到 JSON 字符串再 JSON.parse()

🤨 但有个奇怪的地方

parser.getFormatInstructions() 拼进 prompt 后,打印出来是这样的:

scss 复制代码
请介绍一下爱因斯坦的信息,通过 JSON 格式返回,
包含以下字段:name(姓名),birth_year(出生年份)...
                                    ← 这里是空的!

它返回的是空字符串。

原因也很直白:JSON 太常见了 ,格式约定已经写在 prompt 正文里("包含以下字段:name..."),解析器没必要再啰嗦一遍。JsonOutputParser 没有重写 getFormatInstructions(),所以沿用了基类的空实现。

📌 这说明 JsonOutputParser偏解析的:约束靠你自己在 prompt 里写,它只负责把结果抠出来。


🥇 二级火箭:StructuredOutputParser

JsonOutputParser 的升级版。它的核心变化是:getFormatInstructions() 不再为空了

方式一:fromNamesAndDescriptions() ------ 用「字段 + 描述」描述结构

js 复制代码
import { StructuredOutputParser } from '@langchain/core/output_parsers';

// json, name, description 更靠谱
// JsonOutputParser 格式化的升级
const parser = StructuredOutputParser.fromNamesAndDescriptions({
  name: '姓名',
  birth_year: '出生年份',
  nationality: '国籍',
  major_achievements: '主要成就,用逗号分隔的字符串',
  famous_theory: '著名的理论'
});

const question = `
请介绍一下爱因斯坦的信息
${parser.getFormatInstructions()}   // ← 这里会自动生成格式说明
`;

对比一看就明白了:

prompt 里的格式约束 谁来写
JsonOutputParser "包含以下字段:name(姓名)..." 🙋 手写
StructuredOutputParser getFormatInstructions() 生成 🤖 自动

字段描述(description)越具体,输出越靠谱 ------ 比如"主要成就,用逗号分隔的字符串",就是在用自然语言约束数据形态。

⚠️ 小提醒:readme 里记的是 fromNameAndDescription(),实际 API 是 fromNamesAndDescriptions()(带 s),以代码为准。

方式二:fromZodSchema() ------ 用 Zod Schema 严苛约束

字段一多、结构一深,自然语言描述就不够用了,这时候上 Schema

js 复制代码
import { z } from 'zod';

// output 结构化输出,再严苛一点,用 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('科学家的主要成就'),
  biography: z.string().describe('简短传记,100字以内'),
});

const parser = StructuredOutputParser.fromZodSchema(scientistSchema);

这一步能表达的东西丰富多了:

  • 🔢 类型string / number / array
  • 🪆 嵌套awards 是"对象数组",每个对象里还有三个字段
  • 🚫 可选death_year.optional() 标记

.describe() 依然要写 ------ 因为最终它还是要被翻译成自然语言提示塞进 prompt 的

javascript 复制代码
下游业务,终于能用上靠谱的 JSON 输出了 ✅

🚀 三级火箭:tool call ------ 约束从 prompt 搬进了模型

上面的方案,无论 Schema 写得多漂亮,约束的本质都是"求模型配合":格式说明写在 prompt 里,模型愿不愿意照做,全看它心情。

tool call 换了个思路 ------ 借模型的工具调用机制,把参数 schema 直接交给模型

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

// 将工具绑定到模型上
const modelWithTool = model.bindTools([
  {
    name: 'extract_scientist_info',
    description: '提取和结构化科学家的详细信息',
    schema: scientistSchema
  }
]);

const response = await modelWithTool.invoke('请介绍以下爱因斯坦的详细信息');
console.log(response.tool_calls[0].args); // ← 结构化数据在这里

😱 灵魂拷问:工具函数根本没有执行?

是的,它压根就没打算执行

我们绑定的这个 extract_scientist_info 没有对应的真实实现,它存在的意义只有一个:提供一份参数 schema

css 复制代码
question ──▶ LLM ──▶ 决定调用工具 ──▶ 按 schema 生成参数 ──▶ tool_calls[0].args
                     (工具要解决什么问题)    ↑
                                        我们要的就是这一步的结构化产物

数据是从 tool_calls[0].args 里拿的,而不是从 response.content 里。工具是假的,参数是真的。

为什么它比 output parser 更好?

对比项 output parser tool call
约束位置 prompt 里的文字说明 模型原生的 function calling 机制
校验强度 靠模型自觉 训练时就学会了严格按 schema 填参数
出错表现 少个引号、多段废话 args 稳定是合法结构
解析成本 需要正则 / 剥离 Markdown 直接就是对象,不需要解析

💡 没必要用 output parser 模块,tool-calls 的参数也能拿到结构化数据,而且因为 tool-calls 是 LLM 自身的工作机制,会更严格、更好。

更好用的封装:withStructuredOutput()

bindTools 属于偏门写法 ------ 每次都要 .tool_calls[0].args 取一层,语义上也不清晰(我要的是数据,不是调用工具)。

LangChain 把它包装成了高阶 API:

js 复制代码
// tool call 讨巧的做法,升级为 withStructuredOutput 方法
// 底层还是 tool call
const structuredModel = model.withStructuredOutput(scientistSchema);

const result = await structuredModel.invoke('请介绍以下爱因斯坦的详细信息');
console.log(JSON.stringify(result, null, 2)); // 直接就是对象

没有 tool_calls,没有 args,返回的就是业务要的数据。

内部实现依然是 tool call 机制;如果遇到**不支持 tool call 的模型,会自动降级为「Prompt + JSON 描述」**的方案 ------ 也就退回到了前面 output parser 那条路。


🎯 三者的关系:一张图看懂

scss 复制代码
   约束在 prompt 里(靠模型配合)              约束在模型机制里
   ◀───────────────────────────────────▶    ◀──────────────
   
   JsonOutputParser ──▶ StructuredOutputParser ──▶ tool call ──▶ withStructuredOutput
        │                      │                      │                  │
        │                      │                      │                  └─ 语义化封装
        │                      │                      └─ 模型原生机制,最严格
        │                      └─ getFormatInstructions() 自动生成格式说明
        └─ getFormatInstructions() 为空,只负责解析
    
   解析能力:★★★★★ ────────────────────────▶ 不再需要解析(直接是对象)
   约束能力:★☆☆☆☆ ────────────────────────▶ ★★★★★

三者不是相互替代,而是"约束的责任在往上游移动":

  1. 🥈 JsonOutputParser ------ 只管解析,约束你自己在 prompt 写
  2. 🥇 StructuredOutputParser ------ 把约束自动化(描述 / Schema ➜ 格式说明),解析依然靠它
  3. 🚀 withStructuredOutput ------ 把约束交给模型,解析这一步直接消失

它们共享同一套抽象:getFormatInstructions()(约束)+ parse()(解析),只是各自实现的"分量"不同。


🤔 那 output parser 模块是不是可以丢了?

不可以。 两个理由:

1️⃣ 格式化输出不只有 JSON

还有 XML、YAML 等格式。比如 XMLOutputParser

js 复制代码
import { XMLOutputParser } from '@langchain/core/output_parsers';

const parser = new XMLOutputParser();
const question = `
请提取以下文本中的任务信息:爱因斯坦生于1879年,是一位伟大的物理学家。
${parser.getFormatInstructions()}
`;

XML 是老一代的数据交换标准 (想想 XMLHttpRequest,AJAX 时代的产物);如今前后端交互的事实标准早就变成了 JSON ------ fetch 一个后端 API,返回 JSON。但在某些场景下(长文本包裹、标签语义清晰),XML 依然是更合适的选择。

2️⃣ 格式特殊时,还是得靠它

lua 复制代码
✅ 推荐:model.withStructuredOutput(schema)   ← 常规结构化输出
✅ 备选:output parser 模块                   ← 格式特殊 / 模型不支持 tool call

📌 小结

方案 约束方式 拿到数据的路径
手写正则 + JSON.parse match(/```json([\s\S]*?)```/)
JsonOutputParser prompt 手写 parser.parse(content)
StructuredOutputParser getFormatInstructions() 生成 parser.parse(content)
bindTools + tool call 模型原生 tool call tool_calls[0].args
withStructuredOutput 模型原生 tool call(可降级) 直接返回对象

演进路线

scss 复制代码
JSONOutputParser ─▶ StructuredOutputParser ─▶ tool call(args) ─▶ withStructuredOutput(schema)
   (解析)            (解析 + 约束)           (约束交给模型)      (约束交给模型 + 语义化)
相关推荐
YHL1 小时前
🚰流式输出:把 LLM 的每个字,像水一样送到浏览器
前端·javascript
秋天的一阵风1 小时前
⚡2026 年了,十万级表格还只会「虚拟滚动」?难怪你的页面照样卡顿
前端·javascript·面试
秋天的一阵风1 小时前
🤖 AI写代码越跑越快,项目组件却越来越乱?一套工程闭环根治重复造轮子 ⚡
前端·面试·ai编程
恋猫de小郭2 小时前
Flutter GSoC 2026 提案进度解读,补上 DevTools、FFI 和原生平台的关键缺口
android·前端·flutter
尘世中一位迷途小书童2 小时前
我为什么在 Windows 上做了一个翻译软件:SnapLingo
前端·人工智能·机器学习
志尊宝2 小时前
Vue3 零基础每日笔记(029):插槽 slot 三连——默认、具名、作用域一次讲透
前端·javascript·vue.js·vue·前端开发
aixingpan2 小时前
aixingpan.cn API开发文档:api_docs_bichart_natalvstertiary2接口指南
前端·php
vicky05172 小时前
Anaconda管理
深度学习
铁皮饭盒2 小时前
网页端, 40mb离线模型, 自动抠图, 不用 Python,不用服务器,不用 API Key, 不用显卡
前端·javascript·后端