LangChain 结构化输出进阶:with_structured_output 做了什么,以及它为什么流不起来

上一篇我们聊了 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_yearnumber 不是字符串------这是工具调用路线相比「让模型写 JSON 再解析」最实在的好处。

1.2 它的底层:封装了 Tool Calling

with_structured_output 不是一个独立的黑科技,它的内核就是 Tool Calling

它做的事情可以拆成三步:

  1. 把你传进去的 Schema(Zod / JSON Schema)转成一个函数的签名
  2. bindTools 把这个函数挂到模型上,让模型「调用」它;
  3. 从返回的 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() 之类会出怪象。

四、小结

把这篇压缩成几句话:

  1. with_structured_output 的本质是 Tool Calling 的封装------它不让模型「写」JSON,而是让模型「填」表单,格式由协议保证;
  2. 它有降级机制------模型不支持 Tool Calling 时,自动退回 Prompt + JSON 描述的方式,对外 API 不变;
  3. 它比 Output Parsers 更简洁可靠,但 Output Parsers 不会消失------它支持 XML/YAML/CSV 等更多格式,而且本身就是高层 API 内部的零件;
  4. JSON 是当前的事实标准,因为轻量、无闭合标签冗余、类型内建;
  5. 流式与结构化的冲突源于「校验要求对象完整」------残缺的 JSON 片段过不了 Schema 校验,所以默认实现只能等生成完再解析,表现为客户端「卡住」;
  6. 要流式就得自己动手 ------手动拼接 tool_call_chunks 并用容错解析器处理残缺 JSON,或在业务层做缓冲分帧、双通道处理。

最后记一句

结构化输出要的是准确 ,流式输出要的是及时。默认情况下 LangChain 帮你选了准确;要兼顾及时,就得自己接管解析这一环,并且接受「数据会有一段不完整的中间态」。

如果你也在做「大模型结构化输出 + 前端实时渲染」,希望这篇帮你提前把坑填上。有问题欢迎评论区交流 👋

相关推荐
hanchenxing2 小时前
自动化脚本“卡住无输出“怎么办: 一次 WebSocket 挂死的排查与自愈模板
运维·javascript·chrome·websocket·自动化·自动化爬虫
津津有味道2 小时前
Web网页写网址到424DNA标签Javascrip源码
javascript·nfc·424dna·写网址·加密提交
宁风4 小时前
JavaScript:函数体系-1
前端·javascript
oooo_z4 小时前
美股历史K线数据API接口选型指南:iTick覆盖1分钟到月线全周期
服务器·前端·javascript
mONESY5 小时前
LangChain 结构化输出三兄弟:ToolCall、OutputParser、withStructuredOutput 彻底讲透
javascript
知兀5 小时前
【前端】受控和非受控组件
前端·javascript·react
晴天165 小时前
JavaScript 与 Java 垃圾回收机制对比
java·开发语言·javascript
huali5 小时前
vue-split-screen:让 Vue Router 的导航轨迹变成两个页面
前端·javascript·vue.js
bug总结5 小时前
uniapp总结
开发语言·前端·javascript