🍔 给 AI 发一张结构化表格(下):从"自由发挥"到"按格填空"的输出驯服指南

文章来源声明: 原文作者:默_笙; 来源站点:掘金; 原文链接:https://juejin.cn/post/7684195084155076617; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

从手搓正则到 Zod 再到 Tool Call,把结构化输出的坑与演进讲得很透。适合被 JSON.parse 报错折磨过的 AI 应用开发者,按业务复杂度选级即可,不必一步到位。

> 写在前面:上篇讲了流式输出——AI 怎么"边说边听"。下篇换个话题——AI 说出来的东西怎么**按规矩说**。LLM 默认输出是自由文本——你问爱因斯坦的信息,它能给你写一篇散文,也能给你列一个列表,还能给你一段 markdown。但下游业务需要的是**结构化 JSON**——`name` 字段是字符串,`birth_year` 是数字,`achievements` 是数组。如果 LLM 返回的格式不对,`JSON.parse()` 直接炸。今天的课程从最原始的手搓正则,讲到 LangChain 的 `JsonOutputParser`、`StructuredOutputParser`,再到 Zod Schema 约束和 Tool Call 取巧方案——四级进化,逐步把"自由发挥的 AI"驯服成"按格填空的好学生"。以下所有代码均来自课堂真实文件。

一、问题:LLM 的输出你控不住

假设你让 LLM 介绍爱因斯坦,要求返回 JSON。你写了这样的 prompt:

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

LLM 可能返回什么?

情况一——标准 JSON:

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

情况二——markdown 包裹的 JSON(最常见):

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


**情况三**——带前后解释的 JSON:

好的,以下是爱因斯坦的信息: {"name": "阿尔伯特·爱因斯坦", ...} 希望对你有帮助!


**情况四**——格式跑偏:

姓名:爱因斯坦 出生年份:1879


readme 总结了这个问题:

> "大模型按照我们的格式要求返回一个 JSON。失败了——json 固定格式输出,被 markdown 格式包裹,llm 输出常是 markdown 格式,这是展示的需要。"

LLM 是语言模型,它的"舒适区"是自然语言和 markdown。你让它吐 JSON,它经常顺手包一层 ` ```json ``` `——因为它觉得这样"好看"。但对 `JSON.parse()` 来说,` ```json ` 是非法语法,直接报错。

---

## 二、第一级:手搓正则——剥掉 markdown 外衣

`normal.mjs` 里有一段被注释掉的代码——这是最原始的解决方案:

```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);

readme 解释了这个思路:

"移除 json 包裹,正则 replace 方法。prompt output 技巧 → llm 返回 markdown 格式 → 正则业务去除 md 格式 → JSON.parse()。"

三步走:

  1. LLM 返回 markdown 包裹的 JSON
  2. 正则把 ````json` 和 ````` 剥掉
  3. JSON.parse() 解析成对象

正则 /```json\s*([\s\S]*?)\s*```/ 的含义:

正则片段含义
````json`匹配开头的标记
`\s*`匹配可能的空白字符
`([\s\S]*?)`捕获组——匹配中间所有内容(含换行)
`````匹配结尾的标记

jsonMatch[1] 就是捕获组的内容——纯 JSON 字符串,没有 markdown 外衣。

手搓正则的问题

readme 的注释还说了:

"分组。正则业务。"

这行注释点明了——正则提取是"业务代码"。每次调 LLM 都要写一遍正则、处理异常、兜底各种格式变体。LLM 有时用 json`,有时用 JSON`,有时用 ````` 不带 json 标记——正则覆盖不全。

readme 紧接着给出了答案:

"每次调用 AI 的常见业务,langchain 提供相应的业务 API,省去开发的复杂度。"

别手搓了,LangChain 有现成的工具。


三、第二级:JsonOutputParser——LangChain 的基础解析器

normal.mjs 实际使用的方案——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 result = await parser.parse(response.content);
console.log(result, result.name);

readme 说的:

"JsonOutputParser——langchain 用来解析 json 结果的。约束返回格式 json,JSON.parse()。"

两个关键 API

parser.getFormatInstructions() — 生成格式约束指令,自动拼到 prompt 里。

readme 说的:

"parser.getFormatInstructions() 空,json 太常见的格式需求。"

意思是——getFormatInstructions() 返回的约束文本对 JSON 来说比较"空"(简单),因为 JSON 格式太通用了。它大概会在 prompt 里加一句类似"请返回合法的 JSON 格式"的指令。

parser.parse(response.content) — 解析 LLM 的输出。

readme 说的:

"本质就是通过 getFormatInstructions() 在 prompt 里添加对 output 的结构化格式约定,parser.parse() 去除 markdown 拿到 json。"

parse() 内部做了两件事:

  1. 剥掉 markdown 的 json 外衣(不用你写正则了)
  2. JSON.parse() 解析成对象

JsonOutputParser 的局限

JsonOutputParser 只保证"返回的是合法 JSON"——但不保证有哪些字段、字段是什么类型。你让 LLM 返回 name 和 birth_year,它可能返回 姓名 和 出生年份——JSON 是合法的,但字段名不对。

readme 点出了升级方向:

"JsonOutputParser 格式化的升级。"

需要更强的约束——不仅要是 JSON,字段名和类型也得对。


四、第三级:StructuredOutputParser — 按格填空

structured-output-parser.mjs 展示了 StructuredOutputParser 的第一种用法——fromNamesAndDescriptions:

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

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

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

const response = await model.invoke(question);
const result = await parser.parse(response.content);
console.log(`姓名:${result.name}`);
console.log(`出生年份:${result.birth_year}`);

fromNamesAndDescriptions:字段名 + 描述

fromNamesAndDescriptions 接收一个对象——key 是字段名,value 是描述。

{
    name: '姓名',           // 字段名:name,描述:姓名
    birth_year: '出生年份',  // 字段名:birth_year,描述:出生年份
    nationality: '国籍',
    major_achievement: '主要成就,用逗号分隔的字符串',
    famous_theory: '著名理论',
}

getFormatInstructions() 会把这些字段名和描述转化成 prompt 约束——LLM 看到的指令大概是"你必须返回一个 JSON 对象,包含以下字段:name(姓名)、birth_year(出生年份)..."。

字段名固定了。 LLM 不能自作主张用"姓名"代替"name"——prompt 里明确要求用 name。

JsonOutputParser vs StructuredOutputParser

readme 的对比:

特性JsonOutputParserStructuredOutputParser
保证 JSON 合法是是
固定字段名否是
字段描述无有
类型约束无无(只有描述)

fromNamesAndDescriptions 比 JsonOutputParser 进了一步——字段名固定了。但类型还是靠描述约束——"出生年份"是字符串还是数字?fromNamesAndDescriptions 只写了描述"出生年份",没说是数字。LLM 可能返回 "1879"(字符串)也可能返回 1879(数字)。

readme 注释也暗示了这一点:

"json, name, description 更靠谱。"

字段名 + 描述,比纯 JSON 靠谱——但还不够。


五、第四级:Zod Schema — 类型级别的约束

structured-output-parser2.mjs 是今天的重头戏——用 Zod Schema 做类型约束。

什么是 Zod?

Zod 是 TypeScript 生态的运行时数据验证库——你定义一个 Schema(模式),它能验证数据是否符合这个模式。

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('科学家的国籍'),
    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字以内'),
});

Zod 的类型系统

Zod 方法TypeScript 类型含义
`z.string()``string`字符串
`z.number()``number`数字
`z.array(z.string())``string[]`字符串数组
`z.object({...})``{...}`对象
`.optional()``T | undefined`可选
`.describe('xxx')`—描述(给 LLM 看的)

对比 fromNamesAndDescriptions——那个只有"字段名 + 文字描述",Zod 有精确的类型:birth_year 是 number 不是 string,fields 是 string[] 不是 string,awards 是嵌套对象数组。

嵌套结构

awards: z.array(
    z.object({
        name: z.string().describe('奖项名称'),
        year: z.number().describe('获奖年份'),
        reason: z.string().describe('获奖原因'),
    })
).describe('获得的重要奖项列表'),

awards 不只是字符串数组——是对象数组,每个对象有 name、year、reason 三个字段。这种复杂嵌套结构,fromNamesAndDescriptions 根本表达不了。

fromZodSchema:把 Schema 变成 Parser

const parser = StructuredOutputParser.fromZodSchema(scientistSchema);

readme 注释说:

"fromZodSchema 是静态工厂方法(返回 parser 实例),不能 new,且要传入 zod schema。"

fromZodSchema 是静态工厂方法——直接在类上调用,不需要 new。传入一个 Zod Schema,返回一个 parser 实例。

使用方式跟之前一样

const question = `请介绍一下居里夫人的详细信息,
${parser.getFormatInstructions()}`;

const response = await model.invoke(question);
const result = await parser.parse(response.content);
console.log(`姓名:${result.name}`);
console.log(`出生年份:${result.birth_year}`);

getFormatInstructions() + parse()——API 没变。但 getFormatInstructions() 生成的约束文本更详细了——包含了每个字段的类型信息。LLM 看到的指令大概是"返回一个 JSON 对象,birth_year 必须是数字,awards 是一个数组,每个元素包含 name(字符串)、year(数字)、reason(字符串)..."。

parse() 也不只是剥 markdown 和 JSON.parse 了——它还会用 Zod Schema 做运行时验证。如果 LLM 返回的 birth_year 是字符串 "1867" 而不是数字 1867,Zod 会报验证错误。


六、四级进化对比

级别方案字段名类型约束嵌套结构课堂文件
1手搓正则无无无normal.mjs(注释)
2JsonOutputParser无无无normal.mjs
3fromNamesAndDescriptions固定无(仅描述)无structured-output-parser.mjs
4fromZodSchema固定精确支持structured-output-parser2.mjs

从"啥都不保证"到"字段名固定"到"类型精确"到"嵌套结构"——每升一级,LLM 的输出就更可靠。

readme 的总结:

"下游业务用上靠谱的 JSON 输出。"

最终目的就是这八个字——下游业务能用。JSON 不是给人看的,是给程序消费的。程序要求字段名对、类型对、结构对——差一个 number vs string 就 crash。


七、第五种方案:Tool Call — 偏门但好用

tool-call-args.mjs 展示了一个"偏门"方案——用 Tool Call 实现结构化输出。

readme 第一行注释就说了灵感来源:

"从 tool-call zod schema 得到灵感,可以直接 tool-call?"

思路

Tool Call 本来是让 LLM 调用外部工具的——你给 LLM 一堆工具定义,LLM 选择合适的工具并生成参数。但"生成参数"这件事,本质上就是结构化输出——工具的参数是有 Schema 约束的。

那如果我只定义一个"工具",但不真的调用它——只利用 LLM 生成参数的能力来拿到结构化数据呢?

代码

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('研究领域列表'),
});

// llm 调用的上下文
const modelWithTool = model.bindTools([
    {
        name: 'extract_scientist_info',
        description: '提取和结构化科学家的详细信息',
        schema: scientistSchema,
    }
]);

const response = await modelWithTool.invoke('介绍一下爱因斯坦');
console.log(response.tool_calls[0].args);

关键点:

  1. model.bindTools([...]) — 给模型绑定一个"工具",工具的 schema 用 Zod 定义
  2. modelWithTool.invoke('介绍一下爱因斯坦') — 正常调用,但 LLM 会以 tool call 的形式返回
  3. response.tool_calls[0].args — 直接拿到结构化的参数对象

不需要 getFormatInstructions(),不需要 parser.parse() — LLM 直接在 tool_calls[0].args 里返回结构化数据。

为什么好用?

readme 注释解释了:

"这个工具不是为了直接调用,只做 schema 校验,而是为了方便后续的解析和处理。"

这个"工具"根本不会被执行——它的存在只是为了让 LLM 按照 schema 生成参数。Tool Call 是 LLM 原生能力,模型在训练时就学会了按 schema 输出参数,比 prompt 约束更可靠。

对比 OutputParser

readme 最后抛出了一个问题:

"这种方式 output parser 更好。output parser 模块还有存在的必要吗?"

这是个好问题。Tool Call 方案的优势:

特性OutputParserTool Call
原理prompt 约束 + 后处理解析LLM 原生能力
可靠性中等(LLM 可能不听 prompt)高(模型训练时就学了)
额外调用需要 parse 步骤直接从 `tool_calls` 取
通用性任何模型需要模型支持 tool call

Tool Call 更可靠、更简洁——但它依赖模型支持 tool calling 能力。大部分主流模型(GPT-4、Claude、DeepSeek)都支持了,但一些小模型可能不行。

OutputParser 的优势在于通用性——任何能返回文本的 LLM 都能用。所以两者不是替代关系,而是不同场景的选择:

  • 模型支持 tool call → 用 Tool Call,更可靠
  • 模型不支持 tool call → 用 OutputParser,prompt 约束

八、完整方案选型指南

需要 LLM 返回结构化 JSON?
  │
  ├── 模型支持 Tool Call?
  │     ├── 是 → bindTools + tool_calls[0].args(最可靠)
  │     └── 否 → 继续 ↓
  │
  ├── 需要精确类型和嵌套结构?
  │     ├── 是 → StructuredOutputParser + fromZodSchema
  │     └── 否 → 继续 ↓
  │
  ├── 需要固定字段名?
  │     ├── 是 → StructuredOutputParser + fromNamesAndDescriptions
  │     └── 否 → 继续 ↓
  │
  └── 只需要合法 JSON?
        └── JsonOutputParser

从上到下,约束越来越松——选哪个取决于你的需求精度和模型能力。


九、结构化输出的本质:prompt 约束 + 后处理

不管哪一级方案,核心逻辑都是 readme 说的这两步:

"本质就是通过 getFormatInstructions() 在 prompt 里添加对 output 的结构化格式约定,parser.parse() 去除 markdown 拿到 json。"

第一步:约束 — 在 prompt 里告诉 LLM "你必须返回什么格式"。getFormatInstructions() 自动生成这段约束文本。

第二步:解析 — LLM 返回后,剥掉 markdown 外衣,JSON.parse() 成对象。parser.parse() 自动完成。

Tool Call 方案跳过了这两步——LLM 直接在 tool_calls 里返回结构化数据,不需要 prompt 约束,也不需要后处理解析。所以 readme 才会问"output parser 模块还有存在的必要吗"——在支持 tool call 的模型上,OutputParser 确实可以省掉。


十、从个人经验到通用 API

回头看整条进化线——从手搓正则到 Tool Call,每一步都在做同一件事:让 LLM 的输出从"自由文本"变成"可靠数据"。

readme 说了一句很到位的话:

"每次调用 AI 的常见业务,langchain 提供相应的业务 API,省去开发的复杂度。"

手搓正则是"个人经验"——每个开发者自己写、自己维护、自己兜底。LangChain 把这些"常见业务"封装成了通用 API:

你手搓的LangChain 的 API
写正则剥 markdown`parser.parse()` 自动剥
写 prompt 约束格式`parser.getFormatInstructions()` 自动生成
手动 JSON.parse + try/catch`parser.parse()` 内部处理
手动验证字段名和类型Zod Schema 运行时验证

不要重复造轮子。 LangChain 的 OutputParser 模块就是把"让 LLM 输出可靠 JSON"这个重复性工作标准化了。


PS:LLM 是个话痨——你让它吐 JSON,它非要裹一层 markdown、加一段开场白、偶尔还跑偏格式。从手搓正则到 JsonOutputParser 到 StructuredOutputParser 到 Zod Schema 到 Tool Call——五级进化,每一级都是给 AI 多加一道紧箍咒。下次 LLM 又给你裹了一层 ````json,别手搓正则了——parser.parse()` 一行搞定。