上一篇我们聊了 Output Parser 的四层进化,最后一层是「与其让模型写 JSON,不如让它调函数」。
这一篇接着往下讲:LangChain 把那一层封装成了一个高阶 API------
with_structured_output。它一行代码就能拿到结构化对象,但也带来一个绕不开的问题:它和流式输出天然冲突。本文讲清三件事:这个 API 怎么用、它的降级机制和优势、以及「流式 + 结构化」这对矛盾到底卡在哪、怎么破。
一、with_structured_output:一行拿到结构化对象
1.1 它解决什么问题
上一篇的最后一层,我们是这样拿结构化数据的:
js
// 手动 bindTools
const modelWithTool = model.bindTools([
{ name: "extract", description: "提取科学家信息", schema: scientistSchema }
]);
const response = await modelWithTool.invoke("介绍一下爱因斯坦");
console.log(response.tool_calls[0].args); // 自己从 tool_calls 里掏
能用,但要自己读 tool_calls[0].args,还得记住那一堆字段名。with_structured_output 把这步封起来了:
js
const structuredModel = model.withStructuredOutput(scientistSchema, {
name: "extract",
});
const result = await structuredModel.invoke("介绍一下爱因斯坦");
console.log(result);
输出:
js
{
name: '阿尔伯特·爱因斯坦',
birth_year: 1879,
nationality: '德国→瑞士→美国(归化)',
fields: [ '理论物理学', '哲学', '科学哲学' ]
}
调用方式和你平时用 model.invoke() 完全一样,只是拿回来的不再是字符串,而是对象 。birth_year 是 number 不是字符串------这是工具调用路线相比「让模型写 JSON 再解析」最实在的好处。
1.2 它的底层:封装了 Tool Calling
with_structured_output 不是一个独立的黑科技,它的内核就是 Tool Calling。
它做的事情可以拆成三步:
- 把你传进去的 Schema(Zod / JSON Schema)转成一个函数的签名;
- 用
bindTools把这个函数挂到模型上,让模型「调用」它; - 从返回的
tool_calls里把args取出来,直接返回给你。
用一句话概括:
它不是让模型「写」一个 JSON,而是让模型「填」一份表单。
区别很关键。「写 JSON」意味着模型在用自然语言生成文本,格式对不对全看它自觉;「填表单」则是模型在协议层面按你给的字段名和类型组织参数,格式由协议保证。
这也解释了为什么它比 Output Parser 稳------因为校验发生的层次不同。Output Parser 是拿到文本之后再校验,属于事后补救;Tool Calling 是模型生成时就受 Schema 约束,属于事前约束。
1.3 降级机制:模型不支持 Tool Calling 怎么办
不是所有模型都支持原生 Tool Calling。老模型、纯文本模型,或者通过某些网关接入时,tool_calls 这个能力可能压根不存在。
with_structured_output 对此有降级方案:
当模型不支持原生 Tool Calling 时,它会退回到 Prompt 工程 + JSON 格式描述的方式------把 Schema 转成一段「请按这个结构输出 JSON」的提示词塞进 prompt,模型老老实实吐 JSON 文本,再由解析器解析出来。
也就是说,同一个 API 调用,底层可能走两条完全不同的路:
| Tool Calling 路线 | 降级路线(Prompt + JSON) | |
|---|---|---|
| 模型要求 | 支持原生 Function Calling | 无要求 |
| 格式保证 | 协议层保证 | 提示词约束,靠模型自觉 |
| 解析方式 | 取 tool_calls[0].args |
解析文本 JSON |
| 可靠性 | 高 | 中 |
对外你写的代码一模一样,API 帮你把兼容性兜住了 。这是它相比「自己手写 bindTools」最有价值的隐藏福利------你不用为了兼容一个老模型而写两套分支。
1.4 相比 Output Parsers 的优势
| 维度 | Output Parsers 模块 | with_structured_output |
|---|---|---|
| 代码量 | 建解析器 + 拼 prompt + parse | 一行 |
| 语义化 | 你要理解 getFormatInstructions() 这套机制 |
invoke 进去、对象出来 |
| 校验时机 | 拿到文本之后校验 | 模型生成之时已受约束 |
| 可靠性 | 依赖模型「自觉」写对格式 | 协议层保证 |
| 兼容性 | 需要自己处理模型差异 | 内置降级机制 |
一句话:语义化更强、代码更短、结果更可靠。
但请注意------这不代表 Output Parsers 模块可以被丢掉了,下一节就说为什么。
二、JSON 还是 XML?顺带聊聊 Output Parsers 模块的价值
2.1 为什么 JSON 成了事实标准
XML 曾经是数据交换的通用格式,但它的表达方式太重:
xml
<scientist>
<name>阿尔伯特·爱因斯坦</name>
<birth_year>1879</birth_year>
<fields>
<field>理论物理学</field>
<field>哲学</field>
</fields>
</scientist>
对比 JSON:
json
{
"name": "阿尔伯特·爱因斯坦",
"birth_year": 1879,
"fields": ["理论物理学", "哲学"]
}
差距很明显:
- 没有闭合标签------每个字段不用写两遍名字,token 消耗直接少一半;
- 数组是原生语法------XML 里得靠重复标签模拟;
- 类型是内建的 ------
1879是数字,不用额外约定; - JS 原生可解析 ------
JSON.parse()就是浏览器自带的,XML 得拉个 DOM 解析器。
所以当前前后端通信的事实标准就是 JSON,这没什么争议。LLM 场景下更明显:token 就是钱,XML 那种冗余是纯浪费。
2.2 那 Output Parsers 模块还有存在的必要吗
这是很自然的一个疑问:with_structured_output 这么好用,output_parsers 模块是不是可以退休了?
不能,至少有两个理由。
理由一:它支持 JSON 之外的格式。
现实世界不只有 JSON。有些老系统只认 XML,有些场景你就是想让模型列个清单:
| Parser | 解析目标 |
|---|---|
JsonOutputParser |
JSON(含 Markdown 代码块包裹) |
StructuredOutputParser |
按 name + description 描述的扁平结构 |
XMLOutputParser |
XML |
CommaSeparatedListOutputParser |
逗号分隔的列表 |
MarkdownListOutputParser / NumberedListOutputParser |
Markdown 列表 / 有序列表 |
JsonOutputToolsParser / JsonOutputKeyToolsParser |
工具调用结果 |
with_structured_output 只解决「我要一个 JSON 对象」这一种情况,超出这个范围就得回到 parser 层。
理由二(更关键):高层 API 内部用的就是它。
with_structured_output 不是绕开了解析,而是把解析藏起来了 。它的降级路线里,模型吐出来的 JSON 文本最终还是交给 JsonOutputParser / StructuredOutputParser 来解析的;它的 Tool Calling 路线里,tool_calls 的解析同样由 output_parsers 下的解析器完成。
所以两者的关系是:
with_structured_output是封装好的成品,output_parsers是组装它的零件。成品用起来省事,但遇到成品不支持的场景,你还是得回到零件层自己搭。
三、流式输出与结构化解析的冲突
这是全文的重点,也是最容易踩坑的地方。
3.1 现象:客户端「卡住」了
流式输出大家都熟:模型生成一个 token 就推一个 chunk,前端逐字渲染,首字延迟大幅降低。
但如果你把流式和 with_structured_output 放在一起:
js
const structuredModel = model.withStructuredOutput(scientistSchema, { name: "extract" });
for await (const chunk of await structuredModel.stream("介绍一下爱因斯坦")) {
console.log(chunk); // 你以为会一段一段来
}
结果是:前端什么都不显示,一直转圈,直到整段生成完毕才「啪」地一下全部出现。
流式开开关关,看起来像是失效了。用户体感上就是页面卡住了------首字延迟等于总耗时,流式的意义荡然无存。
3.2 原因:校验需要「完整」的对象
根本原因在于校验的原子性要求。
结构化输出的最后一步是 Schema 校验(Pydantic、Zod 等)。而校验有个硬性前提:必须拿到一个完整的 JSON 对象。
问题在于,流式传输中每个 chunk 拿到的都是残缺的 JSON 片段:
css
chunk 1: {"name": "阿尔
chunk 2: 伯特·爱因斯坦", "birth
chunk 3: _year": 1879}
把任何一段单独拿去做校验,都会失败------{"name": "阿尔 不是合法 JSON,更不可能通过 Schema 校验。
于是框架面临一个选择:
- 要么,为了流式而放宽解析,容忍残缺数据 → 但这样校验就没有意义了;
- 要么 ,为了保证校验严谨而放弃流式,等所有 token 生成完再统一解析 → 这就是默认行为。
LangChain 选择了后者。所以 stream() 在这里实际上是「假流式」:它确实在底层接收流式数据,但在交给你的那一刻,已经是攒好的完整对象了 ------你收到的是 chunks = 1。
一句话:结构化输出的严谨性和流式输出的实时性,在默认实现下是互斥的。
3.3 解决方案
如果你确实需要「边生成边渲染」的结构化输出,有两条路。
方案一:手动处理原生 Tool Call 流
绕开高层 API 的封装,自己处理底层的工具调用流。
思路是:不等待完整对象,而是自己拼接 JSON 片段,并用容错解析器处理残缺的 JSON。
js
const modelWithTool = model.bindTools([
{ name: "extract", description: "提取科学家信息", schema: scientistSchema }
]);
let argsBuffer = "";
for await (const chunk of await modelWithTool.stream("介绍一下爱因斯坦")) {
const toolCallChunk = chunk.tool_call_chunks?.[0];
if (!toolCallChunk?.args) continue;
argsBuffer += toolCallChunk.args; // 拼接 JSON 片段
// 关键:用能解析"残缺 JSON"的解析器,而不是 JSON.parse
const partial = parsePartialJson(argsBuffer);
if (partial) render(partial); // 解析出多少就渲染多少
}
两个要点:
| API | 作用 |
|---|---|
chunk.tool_call_chunks[0].args |
原始的 JSON 字符串片段,需要自己拼接 |
parsePartialJson(str) |
容错解析器,能解析被截断的 JSON,返回当前能解析出的部分对象 |
parsePartialJson 从 @langchain/core/output_parsers 导出(来自 @langchain/core/utils/json,注意主入口才有导出)。它是个递归下降的解析器,{"name": "爱因 这种半截字符串也能还原成 { name: '爱因' }。
这样每次 chunk 到达,你都能拿到一个逐步完善的快照:
js
{}
{ name: '' }
{ name: '爱因斯坦' }
{ name: '爱因斯坦', birth_year: 1879 }
{ name: '爱因斯坦', birth_year: 1879, fields: ['物理学'] }
方案二:在业务层做更复杂的异步处理
如果不想动底层的流,也可以在业务层解决,思路有两种:
缓冲 + 分帧 :把 chunk 先攒起来,每当解析出一个「完整字段」就推一次前端。比如检测到当前 buffer 里已经有完整的 "name": "...",就先把 name 渲染出来。
双通道:一个通道走流式拿原始文本用于展示,另一个通道等结构化结果出来后再做数据绑定。适合「展示」和「数据」诉求分离的场景------比如聊天界面先逐字显示文本,等生成完了再解析出结构化字段去更新右侧的表单。
⚠️ 消费端要注意的事
不管用哪种方案,你拿到的都是可能缺字段的部分对象,消费端必须能容忍中间态:
js
let latest = {};
for await (const partial of stream) {
latest = { ...latest, ...partial };
render(latest); // 缺的字段显示占位符,别直接 .toFixed()
}
两个具体的坑:
- 别假设第一个 chunk 就有你要的字段 ------
birth_year可能很晚才出现; - 别对字符串做字符级操作 ------流式下
name会先出现半截("爱因"),甚至先出现空串,substring()之类会出怪象。
四、小结
把这篇压缩成几句话:
with_structured_output的本质是 Tool Calling 的封装------它不让模型「写」JSON,而是让模型「填」表单,格式由协议保证;- 它有降级机制------模型不支持 Tool Calling 时,自动退回 Prompt + JSON 描述的方式,对外 API 不变;
- 它比 Output Parsers 更简洁可靠,但 Output Parsers 不会消失------它支持 XML/YAML/CSV 等更多格式,而且本身就是高层 API 内部的零件;
- JSON 是当前的事实标准,因为轻量、无闭合标签冗余、类型内建;
- 流式与结构化的冲突源于「校验要求对象完整」------残缺的 JSON 片段过不了 Schema 校验,所以默认实现只能等生成完再解析,表现为客户端「卡住」;
- 要流式就得自己动手 ------手动拼接
tool_call_chunks并用容错解析器处理残缺 JSON,或在业务层做缓冲分帧、双通道处理。
最后记一句:
结构化输出要的是准确 ,流式输出要的是及时。默认情况下 LangChain 帮你选了准确;要兼顾及时,就得自己接管解析这一环,并且接受「数据会有一段不完整的中间态」。
如果你也在做「大模型结构化输出 + 前端实时渲染」,希望这篇帮你提前把坑填上。有问题欢迎评论区交流 👋