🍔 给 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:

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

LLM 可能返回什么?

情况一——标准 JSON:

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

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

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

**情况三**——带前后解释的 <span>JSON</span>:

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


<span>**情况四**</span>——格式跑偏:

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


readme 总结了这个问题:

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

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

---

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

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

`</span><span>``</span>javascript
/<span>/ 使用正则提取 markdown 代码块中的 JSON 内容
/</span><span>/ const jsonMatch = response.content.match(/</span><span>``</span><span>`json\s*([\s\S]*?)\s*`</span><span>``</span>/);
<span>//</span> const jsonStr = jsonMatch ? jsonMatch[<span>1</span>] : response.content;
<span>//</span> const jsonResult = <span>JSON</span>.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

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

<span>const</span> parser = <span>new</span> <span>JsonOutputParser</span>();

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

<span>const</span> response = <span>await</span> model.<span>invoke</span>(prompt);
<span>const</span> result = <span>await</span> parser.<span>parse</span>(response.<span>content</span>);
<span>console</span>.<span>log</span>(result, result.<span>name</span>);

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 返回 namebirth_year,它可能返回 姓名出生年份——JSON 是合法的,但字段名不对。

readme 点出了升级方向:

"JsonOutputParser 格式化的升级。"

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


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

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

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

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

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

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

fromNamesAndDescriptions:字段名 + 描述

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

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

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(模式),它能验证数据是否符合这个模式。

<span>import</span> { z } <span>from</span> <span>"zod"</span>;

<span>const</span> scientistSchema = z.<span>object</span>({
    <span>name</span>: z.<span>string</span>().<span>describe</span>(<span>'科学家的姓名'</span>),
    <span>birth_year</span>: z.<span>number</span>().<span>describe</span>(<span>'出生年份'</span>),
    <span>death_year</span>: z.<span>number</span>().<span>optional</span>().<span>describe</span>(<span>'死亡年份,如果还在世则不填'</span>),
    <span>nationality</span>: z.<span>string</span>().<span>describe</span>(<span>'科学家的国籍'</span>),
    <span>fields</span>: z.<span>array</span>(z.<span>string</span>()).<span>describe</span>(<span>'研究领域列表'</span>),
    <span>awards</span>: z.<span>array</span>(
        z.<span>object</span>({
            <span>name</span>: z.<span>string</span>().<span>describe</span>(<span>'奖项名称'</span>),
            <span>year</span>: z.<span>number</span>().<span>describe</span>(<span>'获奖年份'</span>),
            <span>reason</span>: z.<span>string</span>().<span>describe</span>(<span>'获奖原因'</span>),
        })
    ).<span>describe</span>(<span>'获得的重要奖项列表'</span>),
    <span>major_achievements</span>: z.<span>array</span>(z.<span>string</span>()).<span>describe</span>(<span>'主要成就列表'</span>),
    <span>famous_theory</span>: z.<span>array</span>(
        z.<span>object</span>({
            <span>name</span>: z.<span>string</span>().<span>describe</span>(<span>'理论名称'</span>),
            <span>year</span>: z.<span>number</span>().<span>describe</span>(<span>'理论年份'</span>),
            <span>description</span>: z.<span>string</span>().<span>describe</span>(<span>'理论描述'</span>),
        })
    ).<span>describe</span>(<span>'著名理论列表'</span>),
    <span>biography</span>: z.<span>string</span>().<span>describe</span>(<span>'简短传记,100字以内'</span>),
});

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_yearnumber 不是 stringfieldsstring[] 不是 stringawards 是嵌套对象数组。

嵌套结构

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

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

fromZodSchema:把 Schema 变成 Parser

<span>const</span> parser = <span>StructuredOutputParser</span>.<span>fromZodSchema</span>(scientistSchema);

readme 注释说:

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

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

使用方式跟之前一样

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

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

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(注释)
2JsonOutputParsernormal.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 生成参数的能力来拿到结构化数据呢?

代码

<span>import</span> { z } <span>from</span> <span>"zod"</span>;

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

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

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

关键点:

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

八、完整方案选型指南

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

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


九、结构化输出的本质: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()` 一行搞定。