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

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


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

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

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

LLM 可能返回什么?

情况一------标准 JSON:

json 复制代码
{"name": "阿尔伯特·爱因斯坦", "birth_year": 1879, ...}

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

markdown 复制代码
```json
{"name": "阿尔伯特·爱因斯坦", "birth_year": 1879, ...}
javascript 复制代码
**情况三**------带前后解释的 JSON:

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

markdown 复制代码
**情况四**------格式跑偏:

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

ruby 复制代码
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

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

readme 点出了升级方向:

"JsonOutputParser 格式化的升级。"

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


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

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

javascript 复制代码
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 是描述。

javascript 复制代码
{
    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 的对比:

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

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

readme 注释也暗示了这一点:

"json, name, description 更靠谱。"

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


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

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

什么是 Zod?

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

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

嵌套结构

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

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

fromZodSchema:把 Schema 变成 Parser

javascript 复制代码
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);

readme 注释说:

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

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

使用方式跟之前一样

javascript 复制代码
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(注释)
2 JsonOutputParser normal.mjs
3 fromNamesAndDescriptions 固定 无(仅描述) structured-output-parser.mjs
4 fromZodSchema 固定 精确 支持 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 生成参数的能力来拿到结构化数据呢?

代码

javascript 复制代码
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 方案的优势:

特性 OutputParser Tool 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 约束

八、完整方案选型指南

javascript 复制代码
需要 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() 一行搞定。

相关推荐
泯泷1 小时前
手搓JSVM第 11 篇:打包、模块与 CLI:把 VM 产物变成可运行文件
前端·javascript·前端框架
泯泷1 小时前
手搓JSVM第 10 篇:对象、数组与属性访问
前端·javascript·前端框架
泯泷1 小时前
手搓JSVM第 9 篇:闭包:函数如何记住外部变量
前端·javascript·前端框架
晴天162 小时前
前端 SSR、BFF 原理介绍
前端·javascript·网络·html
全栈技术负责人2 小时前
AI 效能工具建设:Agent 可观测性技能技术方案与实践
前端·ai·ai编程
梦想平凡2 小时前
百游棋牌源代码开发搭建教程(一):全端工程结构、开发环境配置与首次启动验证
前端·javascript·源代码管理
妙码生花3 小时前
两个月 59 篇 AI 开发日志 + Golang 商业级实战项目收工后,得来的 AI 使用心法-上
前端·后端·gin
vx-Biye_Design3 小时前
springboot中国传统节日宣传平台49078-计算机课程设计、毕业设计
java·前端·vue.js·spring boot·后端·课程设计·idea
妙码生花3 小时前
两个月 59 篇 AI 开发日志 + Golang 商业级实战项目收工后,得来的 AI 使用心法-下
前端·后端·go