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

0 阅读7分钟

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


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

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

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

所以我们需要一条链路:

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

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

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

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


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

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

const jsonStr = response.content;
const jsonResult = JSON.parse(jsonStr);

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

SyntaxError: Unexpected token '`' ...

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

```json
{
  "name": "爱因斯坦",
  "birth_year": 1879
}
```

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

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

// 使用正则提取 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"封装好了。

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 后,打印出来是这样的:

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

它返回的是空字符串。

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

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


🥇 二级火箭:StructuredOutputParser

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

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

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(姓名)…"🙋 手写
StructuredOutputParsergetFormatInstructions() 生成🤖 自动

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

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

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

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

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 的

下游业务,终于能用上靠谱的 JSON 输出了 ✅

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

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

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

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

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

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

为什么它比 output parser 更好?

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

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

更好用的封装:withStructuredOutput()

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

LangChain 把它包装成了高阶 API:

// 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 那条路。


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

   约束在 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

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️⃣ 格式特殊时,还是得靠它

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

📌 小结

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

演进路线

JSONOutputParser ─▶ StructuredOutputParser ─▶ tool call(args) ─▶ withStructuredOutput(schema)
   (解析)            (解析 + 约束)           (约束交给模型)      (约束交给模型 + 语义化)