很多团队第一次做 AI 应用时,都会把“结构化输出”理解成一个很小的实现细节:无非就是让模型别返回大段自然语言,改成 JSON 就行了。
这个理解太浅了。
在真实系统里,结构化输出解决的不是“格式好不好看”,而是一个更核心的问题:如何把模型生成的自然语言,转成业务系统可以直接消费、校验、存储和执行的数据契约。
如果这个契约不稳定,后面的数据库写入、工具调用、工作流编排、前端展示,都会变得脆弱。你今天也许只是想做一个“智能录入”,明天就会发现同一套能力其实还支撑着 Agent 的工具调用、流式预览、自动化执行这些更复杂的链路。
这篇文章我想讲清楚一个主结论:
在大多数业务开发里,
withStructuredOutput应该是结构化提取的默认方案;而当你需要一边生成、一边展示工具参数时,才应该下沉到output parser。
围绕这个结论,本文用两个实战场景来展开:
- 把一段自然语言整理成可入库的联系人数据,也就是“智能录入”。
- 把一个带工具调用的代码 Agent 改造成流式反馈版本,也就是“流式 mini-cursor”。
两件事表面上差异很大,一个偏业务系统,一个偏 Agent 交互,但底层是同一个主题:让模型输出从“可读”变成“可执行”。
为什么 AI 应用一定会遇到“输出控制”问题
模型最擅长的是生成自然语言,但业务系统最不需要的往往也是自然语言。
数据库要的是字段。
工具系统要的是参数。
前端状态机要的是确定结构。
工作流引擎要的是稳定节点输入。
这就是为什么很多“演示能跑”的 AI Demo,一接业务就开始失真。因为 Demo 阶段只要人能看懂就行,工程阶段要求的是:
- 字段必须完整
- 缺失值必须有约定
- 类型必须稳定
- 异常必须可兜底
- 输出必须能被下游直接消费
举个最典型的例子。
如果销售把一段聊天记录贴给系统:
张琳,女,杭州人,现在在阿里云做解决方案架构师,电话 13800138000,微信 zhanglin_arch。她说 1994 年出生,上个月刚换了工作。
对人来说,这段话完全能读懂;但对 CRM 系统来说,它不是数据,只是待解释文本。
你至少要回答这些问题:
- 联系人到底是一条还是多条
- 哪些字段是必填,哪些允许为空
- 出生日期没有精确到日时,要不要猜
- 公司和职位是当前信息还是历史信息
- 识别失败时,是返回空数组、部分成功,还是整批报错
所以“输出控制”本质上不是一个模型技巧,而是 AI 系统与业务系统之间的接口设计。
先把几个概念放到同一条链路里看
很多文章会把 tool calling、JSON Schema、output parser 拆开讲,读者看完还是不知道该怎么选。更好的理解方式,是把它们放回同一条调用链路。
1. 自然语言输出
这是模型最原生的能力,适合对话、写作、解释,但不适合直接驱动业务逻辑。
2. 结构化输出
这是在模型输出层加一层“约束”,让返回结果符合预设结构,比如对象、数组、枚举、嵌套字段。
它的目标不是限制模型表达,而是让结果能进入程序世界。
3. Tool Calling
当模型不是“回答你”,而是“调用工具”时,它本质上输出的也是结构化数据,只不过结构变成了:
- 调哪个工具
- 工具参数是什么
所以从工程视角看,tool calling 也是结构化输出的一种。
4. JSON Schema
这是一种更原生、更严格的约束方式。某些模型供应商会直接在模型层支持 json_schema,模型返回时就会尽量满足这个 schema。
它的优势是约束强,输出纯净;代价是供应商差异更明显,兼容性和迁移成本要额外考虑。
5. Output Parser
Parser 的作用不是“让模型更聪明”,而是把模型输出转换成程序更容易消费的结果。
在非流式场景下,它常常像一个兜底解析层。
在流式场景下,它还有一个非常重要的角色:把中间状态逐步还原成可观察的数据。
这也是本文第二个实战的重点。
默认方案怎么选:不要一上来就手写 Parser
如果只说功能,三种方案都能做结构化输出;但从工程默认值来看,它们并不等价。
| 方案 | 适合场景 | 优点 | 代价 |
|---|---|---|---|
withStructuredOutput | 大多数信息提取、表单回填、知识整理 | API 简洁,跨模型更友好,默认值最好 | 底层细节相对被封装 |
原生 json_schema | 对字段严格性要求极高,且模型明确支持 | 约束强,输出通常更“干净” | 供应商耦合更强 |
直接使用 output parser | 流式预览、工具参数增量解析、定制解析链路 | 灵活度最高 | 代码复杂度明显上升 |
我的建议很明确:
- 做信息提取,先上
withStructuredOutput。 - 需要模型原生严格 schema,再考虑
json_schema。 - 只有当你真的要处理流式 chunk、工具参数增量显示、复杂解析逻辑时,再自己下沉到
output parser。
很多人一看到 Parser 就兴奋,觉得“更底层、更灵活”。但在业务开发里,灵活不是默认目标,稳定才是默认目标。能用高层抽象解决,就不要主动把系统做复杂。
实战一:把“聊天记录”变成可入库联系人,这才是智能录入的本质
先把场景改造得更像真实业务一点。
不是“好友录入”,而是一个常见得多的企业场景:销售或运营把聊天记录、会议纪要、名片 OCR 文本贴进系统,AI 自动抽取联系人信息并写入 CRM。
这个场景比教学 Demo 更接近工程现实,因为它天然包含三个约束:
- 输入文本是非结构化的,而且可能一次出现多个人。
- 输出结果要直接进入数据库,不能只给人看。
- 缺失字段很多,系统必须定义“不确定时怎么办”。
这里最关键的设计,不是提示词,而是 Schema 设计。
先定数据边界,再让模型提取
很多 Demo 喜欢让模型“帮忙补全”字段,比如根据“30 出头”推算生日。这在课堂演示里很顺,但在业务系统里风险很大。
因为一旦入库,猜出来的数据就会伪装成事实。
所以我的建议是:
- 可以提取,就提取
- 明确缺失,就返回
null - 不要为了结构完整去伪造业务事实
下面这段 Schema 写法,比“能跑通”更接近可上线版本:
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().min(1).describe("联系人姓名"),
gender: z.enum(["男", "女"]).nullable().describe("无法判断时返回 null"),
birthDate: z.string().nullable().describe("格式 YYYY-MM-DD,未知时返回 null"),
company: z.string().nullable().describe("当前任职公司,未知返回 null"),
title: z.string().nullable().describe("当前职位,未知返回 null"),
phone: z.string().nullable().describe("手机号,未知返回 null"),
wechat: z.string().nullable().describe("微信号,未知返回 null"),
}).strict();
export const contactsSchema = z.array(contactSchema).describe("待入库联系人列表");
这里有几个点值得专门解释。
为什么要用数组 Schema
因为业务输入天然可能是一对多。
你今天导入一段名片文字,可能只有一个人;明天导入会议纪要,可能同一段文本里出现三位参会人。把顶层直接设计成数组,系统就不用为了“单人 / 多人”做两套分支逻辑。
为什么大量字段要允许 null
这是工程里非常关键的一步。
如果你把字段全写成必填,模型为了满足结构,很容易开始编造。表面上你得到了“完整数据”,实际上你是在给数据库注入低质量事实。
结构化输出的目标不是让字段填满,而是让不确定性被显式表达。
为什么要 .strict()
因为数据库和业务系统最怕“多出来的字段悄悄混进来”。
严格模式可以防止模型顺手返回你没定义的键,避免下游逻辑在字段演进时变得不可控。
用 withStructuredOutput 做提取,重点不在“能生成”,而在“能入库”
抽取层可以这样写:
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
import { contactsSchema } from "./schema.js";
const llm = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const extractor = llm.withStructuredOutput(contactsSchema);
export async function extractContacts(rawText) {
return extractor.invoke([
new SystemMessage(
"你是 CRM 录入助手。只提取文本中明确出现的信息;不确定时返回 null,不要猜测。"
),
new HumanMessage(rawText),
]);
}
这段代码表面很短,但每个参数背后都有原因。
temperature: 0 为什么重要
结构化提取不是创作任务,它要的是稳定,不是发散。温度越高,模型越容易在边界字段上“自由发挥”。做写作可以高一点,做提取最好保守。
为什么系统提示词要强调“不确定返回 null”
因为 Schema 只能约束“长什么样”,不能完全约束“值是不是胡猜的”。
也就是说,结构约束和事实约束不是一回事。前者靠 schema,后者还得靠提示词和业务校验。
withStructuredOutput 到底帮你省了什么
它省掉的不是几十行代码,而是一整层“让模型按结构说话”的胶水逻辑。底层到底是 tool calling、原生 json_schema,还是 parser 兜底,由框架和模型能力去决定。
这也是我推荐它作为默认方案的原因:你在写业务约束,不是在手搓协议适配器。
入库阶段不要直接信模型,至少做两层收口
提取成功,不代表可以无脑写库。
我更推荐的链路是:
- 模型按 schema 返回结构化结果
- 服务端做二次归一化和校验
- 批量写入数据库
例如:
import mysql from "mysql2/promise";
import { extractContacts } from "./extractor.js";
const db = await mysql.createConnection({
host: "localhost",
port: 3306,
user: "root",
password: "admin",
});
export async function importContacts(rawText) {
const contacts = await extractContacts(rawText);
const rows = contacts
.filter((item) => item.name)
.map((item) => [
item.name.trim(),
item.gender,
item.birthDate,
item.company,
item.title,
item.phone,
item.wechat,
]);
if (rows.length === 0) return { inserted: 0 };
await db.query("USE crm;");
await db.query(
`
INSERT INTO contacts (
name, gender, birth_date, company, title, phone, wechat
) VALUES ?;
`,
[rows]
);
return { inserted: rows.length };
}
这里真正重要的不是 SQL 本身,而是两个工程动作。
第一,过滤和归一化
哪怕模型已经被 schema 限制过,也仍然建议在服务端做:
- 去掉空姓名
- 统一手机号格式
- 统一日期格式
- 把空字符串转成
null
因为数据库面对的是长期数据质量,不是单次 Demo 成功率。
第二,批量写入而不是逐条写入
批量写入不仅性能更好,也更方便做一次性事务控制、失败回滚、批次审计。
如果这是生产系统,我还会再加两项:
phone或wechat的唯一索引 / 去重策略source_text、import_batch_id之类的审计字段
这样你以后才能追溯“这条记录是谁、在什么文本里、通过哪次导入生成的”。
下面这张图就是结构化提取后写库的结果示意:

withStructuredOutput 底层不只一种实现
很多人用久了会有一个误解:好像 withStructuredOutput 就等于“LangChain 帮我把 JSON parse 一下”。
不是。
它底层可能走的是不同机制,常见可以理解成三类:
- 模型原生支持结构化输出,比如
json_schema - 模型通过 tool calling 返回结构化参数
- 框架在必要时借助 output parser 进行结果约束
这意味着一个很实用的判断:
平时你不需要纠结底层到底用了哪一种,只需要关心当前模型是否能稳定满足你的 schema。
只有当你对“实时中间态”本身有需求时,比如要在流式过程中逐步显示工具参数,Parser 才会从幕后走到台前。
这就进入第二个场景了。
实战二:为什么很多 Agent 看起来“卡死了”,其实只是你没把中间结果露出来
很多代码 Agent 或 mini-cursor 类应用,第一次做出来都会有一个糟糕体验:
你输入一个任务,界面沉默几十秒,然后突然文件写完了。
用户会以为系统卡住了,实际上模型可能一直在生成,只是你没有把中间过程展示出来。
这里最容易踩的误区是:以为“流式输出”只等于把 content 逐字打印出来。
对普通聊天这没问题。
但对带工具调用的 Agent,不够。
因为这时模型真正重要的输出,经常不在自然语言正文里,而在 tool_call 参数里。比如:
- 要写入哪个文件
- 文件内容是什么
- 要执行什么命令
- 命令参数是什么
如果你只流式打印 content,但工具参数还藏在内部结构里,用户依然看不到关键进展。
流式 Agent 的两个真正难点
这部分是整个文章最值得记住的地方。
当你把非流式 Agent 改成流式之后,核心变化只有两个,但每个都很关键。
难点一:你拿到的不再是完整 AIMessage,而是一串 AIMessageChunk
非流式模式下,模型一次性返回完整消息,你直接:
- 存入 memory
- 读取
tool_calls - 执行工具
流式模式下,这个完整消息被拆成很多 chunk。你必须先把它们重新拼起来,才能得到最终可执行的 AIMessage。
这一步通常用 concat 完成。
难点二:工具参数在流式阶段往往是不完整片段
也就是说,你中途看到的不是一个完整 JSON,而是一段一段的参数碎片。
这时候如果你自己手写字符串拼接和 JSON 解析,不是不能做,而是很容易把代码写烂。
更稳妥的做法,是使用 JsonOutputToolsParser 去解析已经累积到当前阶段的结果,让它帮你把零碎的 tool_call_chunks 逐步还原成可观察的参数对象。
这张图能直观看到 parser 把片段逐步还原后的效果:

一个可落地的流式 mini-cursor 写法
下面是核心代码,我把它压缩成最值得理解的部分:
import { JsonOutputToolsParser } from "@langchain/core/output_parsers/openai_tools";
let mergedMessage = null;
const previewOffsets = new Map();
const toolParser = new JsonOutputToolsParser();
for await (const chunk of rawStream) {
mergedMessage = mergedMessage ? mergedMessage.concat(chunk) : chunk;
let parsedTools = [];
try {
parsedTools = await toolParser.parseResult([{ message: mergedMessage }]);
} catch {
// 参数还没完整,继续累积即可
}
if (parsedTools.length > 0) {
for (const call of parsedTools) {
if (call.type !== "write_file" || !call.args?.content) continue;
const key = call.id ?? call.args.filePath ?? "default";
const previous = previewOffsets.get(key) ?? 0;
const current = String(call.args.content);
if (current.length > previous) {
process.stdout.write(current.slice(previous));
previewOffsets.set(key, current.length);
}
}
continue;
}
if (typeof chunk.content === "string") {
process.stdout.write(chunk.content);
}
}
这段代码要分成三层来理解。
第一层:concat 的职责是还原最终消息
mergedMessage 不是为了打印,而是为了在流结束后得到完整的 AIMessage。只有完整消息才能安全进入 memory,也只有它身上的 tool_calls 才适合真正执行。
也就是说:
concat是为了“可执行”- 不是只为了“可显示”
第二层:Parser 的职责是做“中间态可视化”
JsonOutputToolsParser 在这里的价值,不是替代最终执行,而是把原本不可读的 chunk 变成“此刻已经累积到哪一步了”的预览结果。
对于 write_file 这种工具,这特别有用。因为用户最关心的往往不是“模型正在思考”,而是“代码已经写到哪里了”。
第三层:增量打印必须记录偏移量
如果不记录上一次已经打印到哪里,每轮都会把整段内容重新输出一遍。
所以这里用 Map 存储每个工具调用已经打印过的长度,下一次只打印新增部分。这个思路很简单,但它决定了最终体验是不是“像打字机”,还是“像日志刷屏”。
预览归预览,真正执行工具仍然要用完整消息
这是流式 Agent 最容易被写错的地方。
很多人看到 Parser 已经能解析出一部分工具参数,就想边解析边执行。这通常不是一个好主意。
正确顺序应该是:
- 流式阶段只做展示和预览
- chunk 全部结束后,得到完整
AIMessage - 把完整消息存入 memory
- 再读取完整的
tool_calls去执行工具
原因很简单:中间态参数不是最终态参数。
对于 write_file 这种工具尤其如此。你可以实时预览文件内容,但不要在内容还没结束时就开始写盘,否则非常容易得到半截代码、损坏文件或者反复覆盖。
一个更安全的执行骨架大概是这样:
await history.addMessage(mergedMessage);
for (const toolCall of mergedMessage.tool_calls ?? []) {
const tool = tools.find((item) => item.name === toolCall.name);
if (!tool) continue;
const result = await tool.invoke(toolCall.args);
await history.addMessage(
new ToolMessage({
content: result,
tool_call_id: toolCall.id,
})
);
}
这也是为什么我前面说,output parser 不应该是默认方案。
不是它不好,而是它处理的是更底层、更接近协议层的问题。一旦进入这里,你就得自己处理 chunk 拼接、解析时机、异常吞吐、增量显示、最终执行这些复杂性。
智能录入和流式 Agent,本质上是同一件事
如果你把两个实战连起来看,会发现它们底层完全一致。
智能录入解决的是:
- 如何把自然语言变成数据库可以接受的字段集合
流式 Agent 解决的是:
- 如何把自然语言生成过程变成用户可观察、系统可执行的工具参数
一个面向“存储”,一个面向“执行”,但本质上都在做:
把语言模型输出变成程序世界里的确定结构。
这也是为什么结构化输出在 AI 应用里不是边缘能力,而是核心基础设施。
常见误区:这些做法看起来合理,实际上很危险
误区一:为了入库完整,允许模型脑补缺失字段
这是最常见的问题。比如根据“30 多岁”反推出生日期,根据“在大厂做技术”猜职位级别。
这类数据一旦入库,后面很难区分它到底是事实还是推测。更好的做法是返回 null,把“不确定”显式保存下来。
误区二:把 Parser 当默认方案
Parser 很强,但不应该滥用。只做结构化提取时,优先上 withStructuredOutput,维护成本更低。
误区三:流式阶段直接执行工具
中间参数只是“目前为止拼出来的样子”,不是最终参数。展示可以提前,执行不要提前。
误区四:把结构正确等同于业务正确
模型返回了合法 JSON,不代表数据就可信。手机号格式、日期合法性、重复联系人、公司名称标准化,这些仍然要靠服务端规则兜住。
误区五:只关心模型成功率,不关心链路可观测性
在 Agent 场景里,用户体验经常不是死在最终结果上,而是死在“中间什么都看不见”。流式预览的价值,很多时候比再提高一点点模型准确率还大。
工程上怎么选,给一个可直接执行的判断
如果你正在做 AI 应用,我建议按下面这个顺序决策:
场景一:文本抽取、表单回填、智能录入
默认选择 withStructuredOutput。
原因是它足够稳,表达能力也足够覆盖大多数对象 / 数组提取需求。重点放在 schema 和服务端校验,不要沉迷底层实现细节。
场景二:供应商明确支持原生严格 schema,且你非常在意输出纯净度
可以考虑 json_schema。
但要先接受一个事实:你在换取更强约束的同时,也在增加模型能力绑定。只要涉及多模型切换,这个成本就会出现。
场景三:带工具调用的流式 Agent、需要实时预览工具参数
这时再用 output parser,尤其是 JsonOutputToolsParser 这类面向工具参数的解析器。
它解决的不是“结构化提取”本身,而是“结构化输出的中间态可视化”。
最后总结
Output Parser 真正重要的地方,不是它能不能把字符串 parse 成 JSON,而是它把大模型输出接回了工程系统。
在智能录入里,它所在的这一整层能力,让自然语言可以稳定地落进数据库。
在流式 Agent 里,它让工具参数不再是黑盒,用户终于能看到系统到底在生成什么、准备执行什么。
所以更准确地说:
withStructuredOutput解决的是“让模型结果能直接进入业务链路”output parser解决的是“让结构化结果在流式过程中也能被观察和利用”
如果你只记住一句话,我希望是这句:
先用高层抽象拿到稳定结构,再在确实需要中间态的时候,才下沉到 Parser。
这才是大多数 AI 应用里更合理的工程默认值。