别再用正则抠 JSON 了:LangChain 结构化输出的三种正确姿势

0 阅读5分钟

前言:LLM 输出结构化数据,为什么这么难?

做 AI 应用的时候,你会发现一个非常高频的需求:让大模型返回 JSON

不管是提取实体、解析表单、还是做下一步的业务分支判断,我们都希望 LLM 的输出是"机器可读"的,而不是一段自然语言。

但现实往往很骨感。你精心写好了 prompt:

请介绍一下爱因斯坦的信息。请以 JSON 格式返回,包含以下字段:name、birth_year...

大模型也很乖地返回了:

```json
{
  "name": "阿尔伯特·爱因斯坦",
  "birth_year": 1879,
  ...
}
```

然后你 JSON.parse(response.content) 就直接原地爆炸——因为返回的字符串被 markdown 代码块包裹了

说白了,大模型是"展示型选手",它天然倾向于把结构化数据渲染成 markdown 方便人看。但我们的代码要的是"数据",不是"展示"。

这篇文章就从这里开始,把 LangChain 的 OutputParser 三件套讲透:JsonOutputParser → StructuredOutputParser → tool_call 方案,最后回答一个灵魂拷问:有了 tool_call,output parser 还有存在必要吗?


一、手动挡:正则 + JSON.parse

在引入 LangChain 之前,很多人(包括我)第一版代码都是这么写的:

const rawContent = response.content;
const match = rawContent.match(/```(?:json)?\s*([\s\S]*?)```/);
const jsonString = match ? match[1].trim() : rawContent.trim();
const jsonResult = JSON.parse(jsonString);

思路非常朴素:

  1. 大模型喜欢用 ```json ... ``` 包裹输出
  2. 那就用正则把外层 markdown 扒掉
  3. 剩下的内容再 JSON.parse

这套方案能用,但很脆

  • 大模型有时包 ```json,有时包 ```,有时干脆不包
  • 有时会在 JSON 前后加一句"好的,以下是结果:"
  • 字段名大小写、是否缺字段、类型对不对,全靠运气

一个业务每次都要写一遍正则 + try/catch,纯属重复劳动。

LangChain 的价值就在这:把"每次调用 AI 都要处理的通用逻辑"抽成 API。


二、JsonOutputParser:官方标配的"手动挡升级版"

LangChain 给出的第一个答案就是 JsonOutputParser

import { JsonOutputParser } from '@langchain/core/output_parsers';

const parser = new JsonOutputParser();

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

const response = await model.invoke(prompt);
const jsonResult = await parser.parse(response.content);
console.log(jsonResult, jsonResult.name);

看起来很简单,但它背后做了两件事,你要理解透:

1. getFormatInstructions():往 prompt 里塞格式约束

有意思的是,JsonOutputParsergetFormatInstructions() 返回的是空字符串

为什么?因为 JSON 太常见了,模型早就被训练得"你说要 JSON 就会给 JSON",不需要额外的格式说明。

所以这一步的本质是——如果未来换一个解析器(比如带 schema 的),这个接口就是用来把格式约定注入到 prompt 里的钩子

2. parse():去 markdown + JSON.parse

parse() 内部会:

  • 去掉可能的 ```json 包裹
  • 调用 JSON.parse
  • 返回对象

你可以把它理解为官方帮你写好了正则 + try/catch,省得每次重复。

小结

  • JsonOutputParser = 通用 JSON 解析器 + 空格式提示
  • 适合"字段不多、格式不严格"的快速场景
  • 缺点:没有 schema 约束,字段类型对错全靠模型自觉

三、StructuredOutputParser:让格式约束更"严苛"

JsonOutputParser 的痛点很明确:它只能"期望"模型返回 JSON,但无法"约束"结构。

StructuredOutputParser 就是来解决这个问题的。它有两种构造方式。

方式一:fromNamesAndDescriptions(轻量版)

const parser = StructuredOutputParser.fromNamesAndDescriptions({
  name: '姓名',
  birth_year: '出生年份',
  nationality: '国籍',
  major_achievements: '主要成就, 用逗号分隔的字符串',
  famous_theory: '著名理论',
});

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

JsonOutputParser 最大的区别:这次 getFormatInstructions() 不再返回空串了

它会把每个字段名 + 描述组装成一段格式说明,注入到 prompt 里。大模型拿到这段说明后,返回的 JSON 结构会明显更稳定。

一句话总结:getFormatInstructions() 的作用 = 把"结构约定"翻译成 prompt 语言,喂给大模型。parse() 的作用 = 把"大模型乱涂的输出"翻译回结构化对象。

方式二:fromZodSchema(重装版)

字段多了、嵌套深了,fromNamesAndDescriptions 就不够用了。这时候上 Zod

import { z } from 'zod';

const scientistSchema = z.object({
  name: z.string().describe('科学家的姓名'),
  birth_year: z.number().describe('出生年份'),
  death_year: z.number().optional().describe('死亡年份,如果未死亡则不填'),
  nationality: z.string().describe('国籍'),
  field_of_study: z.string().describe('研究领域'),
  awards: z.array(
    z.object({
      award: 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({
    theory: z.string().describe('理论名称'),
    year: z.number().describe('理论年份'),
    description: z.string().describe('理论简要描述'),
  })).describe('著名理论列表'),
  biography: z.string().describe('简短传记: 100字以内'),
});

const parser = StructuredOutputParser.fromZodSchema(scientistSchema);

Zod schema 的三大好处

  1. 类型约束birth_year 必须是 number,模型不会给你返回字符串 "1879"
  2. 结构约束:数组、嵌套对象都能表达清楚
  3. 描述可读.describe() 里的内容会自动进入 getFormatInstructions(),直接指导模型

对于下游业务来说,这就很爽了——拿到 result.birth_year 时,你可以放心地当 number 用,不用写一堆 Number(...) 转换。


四、思路拐个弯:为什么不用 tool_call?

写到这里,我脑子里突然蹦出一个想法:

既然已经用 Zod schema 定义结构了,为什么不直接让模型走 tool_call?

于是有了第三种思路:

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

const modelWithTools = model.bindTools([  {    name: "extract_scientist_info",    description: "提取和结构化科学家的详细信息",    schema: scientistSchema,  }]);

const response = await modelWithTools.invoke("请介绍一下爱因斯坦");
console.log(response.tool_calls[0].args);
// { name: '阿尔伯特·爱因斯坦', birth_year: 1879, nationality: '德国/美国', fields: [...] }

请注意:这个工具我们根本没打算真的调用它。它的存在只有一个目的——借 tool_call 的强制结构化能力,拿到解析好的对象

为什么这种方式"更香"?对比一下:

维度OutputParsertool_call
结构约束靠 prompt 约定,模型可能不听话模型原生支持的 function calling,强制遵守 schema
解析步骤去 markdown + JSON.parse直接拿到对象
类型保证可能返回字符串"1879"Schema 严格校验
兼容性所有模型都行需要模型支持 tool calling

这才是真正的"降维打击":不是让模型"照着格式写",而是让模型"必须填这个 schema"。


五、灵魂拷问:有了 tool_call,OutputParser 还有必要吗?

答案是:有,但场景变了。

tool_call 更适合

  • 模型支持 function calling(OpenAI、Claude、Qwen、DeepSeek 等都支持)
  • 需要强 schema 约束、类型严格的场景
  • 你不在乎返回的是 tool_calls 而不是 content

OutputParser 更适合

  • 使用不支持 tool_call 的模型(很多开源小模型、私有化部署场景)
  • 你只想要个 JSON 结果,不需要强类型校验(JsonOutputParser 足矣)
  • 流式输出场景:OutputParser 可以增量解析 JSON 流,tool_call 目前对此支持一般

简单决策树:

需要结构化输出?
├─ 模型支持 tool_call → 用 bindTools + Zod schema(推荐)
└─ 不支持 / 只是简单 JSON
   ├─ 只想要个 JSON 对象 → JsonOutputParser
   └─ 想要字段约束 → StructuredOutputParser.fromZodSchema

六、总结

回到开头那个踩坑故事,我们其实走完了三条路线:

  1. 正则 + JSON.parse:手搓轮子,能跑但脆
  2. JsonOutputParser / StructuredOutputParser:LangChain 把"格式约定注入 prompt + 去 markdown + 解析" 封装成了 API
  3. tool_call + Zod:绕过解析这一层,直接用 function calling 的强 schema 约束

核心心法一句话

OutputParser 是"君子协定"——我在 prompt 里请求模型按格式来;
tool_call 是"合同条款"——模型必须按 schema 填,否则它自己都过不去。

所以,如果你的模型支持 tool_call,优先选它;否则 StructuredOutputParser.fromZodSchema 是你的好朋友。

至于 JsonOutputParser?它更像是入门的第一课——理解了它,你才能理解 getFormatInstructions()parse() 这两个接口设计的精妙之处。