LangChain 流式输出与结构化输出深度解耦:一次生成,两条管道消费

文章来源声明: 原文作者:玉宇夕落; 来源站点:掘金; 原文链接:https://juejin.cn/post/7684484529650155572; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

修正了“能用 withStructuredOutput 就别用 Parser”的片面结论,给出双通道解耦架构与三种落地方案对比,适合既要流式体验又要数据可靠落库的开发者。

> 副标题:Tool Call 底层机制、卡片填充实战与 OutputParser 的第二春

导语

上一篇文章发出后,评论区炸了。一位读者的追问直接戳中了 withStructuredOutput 的最大短板:

"我用 withStructuredOutput 做流式输出,stream() 出来的 chunk 全是空对象,最后才拿到完整结果。这怎么解决?"

这个问题逼着我重新审视上一篇文章的结论—— "能用 withStructuredOutput 就别用 Parser"这句话,需要打一个大大的补丁。

顺着这个问题往下挖,我发现它牵扯出的是一个更大的架构命题:流式输出和结构化输出,本来就是两件完全不同的事,强行绑定会出大问题。

今天这篇文章,我们把 Tool Call 机制、流式 vs 结构化的矛盾、卡片填充实战、以及最终的"双通道解耦架构"一次性讲透。


一、先修正上一篇的一个结论

1.1 上一篇文章说错了什么?

上一篇文章我给出了这个决策流程:

image.png

在非流式场景下,这个结论是对的。 但在流式场景下,withStructuredOutput 会给你一记闷棍。

1.2 withStructuredOutput 的流式陷阱

来看这段代码:

javascript

<span>const</span> structuredModel = model.<span>withStructuredOutput</span>(scientistSchema);
<span>const</span> stream = <span>await</span> structuredModel.<span>stream</span>(<span>'详细介绍莫扎特的信息'</span>);

<span>for</span> <span>await</span> (<span>const</span> chunk <span>of</span> stream) {
    <span>console</span>.<span>log</span>(chunk);
    <span>// 输出:{} {} {} {} ...</span>
    <span>// 💥 全是空对象!最后才拿到完整结果</span>
}

为什么全是空对象? 因为 withStructuredOutput 内部依赖的是工具调用的参数流,而工具参数的流式传输方式和普通文本流完全不同。

核心原因withStructuredOutput 使用的解析器必须验证完整消息后才会产出结果,它不会产出部分对象——结构化结果只有在模型完成生成、解析器校验完整响应之后才会输出。


二、Tool Call 机制才是底层真相

2.1 工具调用的 Schema 顺手完成了结构化

先理解一个关键洞察:Tool Call 的参数定义本身就带 Schema 约束,这个约束顺手就完成了 LLM 输出的格式化和结构化。

javascript

<span>// 传统方式:手动写 Prompt 约束输出格式</span>
<span>const</span> <span>prompt</span> = `请以 JSON 格式返回,包含字段 name, birth_year...`

<span>// Tool Call 方式:Schema 定义就是约束</span>
<span>const</span> <span>modelWithToolCall</span> = model.<span>bindTools</span>([{
    <span>name</span>: <span>'extract_scientist_info'</span>,
    <span>description</span>: <span>'提取和结构化科学家的详细信息'</span>,
    <span>schema</span>: scientistSchema,  // 👈 Schema 就是格式约束
}])

为什么 Tool Call 比 Prompt 约束更可靠? 因为 Function Calling 是 LLM 原生的工作机制,走的是模型训练过的通道,而非在 Prompt 里"求着"模型输出 JSON。

image.png

2.2 工具函数"没有执行"才是关键

很多初学者会困惑:我绑定了一个工具,模型返回了 tool_calls,那这个工具到底执行了没有?

答案是:没有执行。 工具函数只是一个"Schema 载体"。

javascriptJavaScript

<span>const</span> response = await modelWithToolCall.<span>invoke</span>(<span>'介绍一下爱因斯坦'</span>);
console.<span>log</span>(response.tool_calls[<span>0</span>].args);
<span>// { name: '爱因斯坦', birth_year: 1879, ... }</span>

<span>// 💡 注意:extract_scientist_info 这个函数根本没被调用!</span>
<span>// 它只是告诉模型"我要这样的结构",模型按这个结构返回了参数</span>

这个设计非常精妙:工具的 Schema 定义了输出格式,但工具本身不产生副作用。 你拿到 args 后,想怎么用就怎么用。

2.3 withStructuredOutput 只是"语义化封装"

搞懂了 Tool Call 机制,再看 withStructuredOutput 就一目了然了:

javascriptJavaScript

<span>// 底层写法(啰嗦但透明)</span>
<span>const</span> modelWithToolCall = model.<span>bindTools</span>([{ name, description, schema }])
<span>const</span> response = await modelWithToolCall.<span>invoke</span>(prompt)
<span>const</span> result = response.tool_calls[<span>0</span>].args

<span>// 语义化写法(简洁但黑盒)</span>
<span>const</span> result = await model.<span>withStructuredOutput</span>(schema).<span>invoke</span>(prompt)

withStructuredOutput 内部做的事

  1. 把 Zod Schema 转成 Tool 定义
  2. 调用 bindTools
  3. 调用模型
  4. tool_calls[0].args 提取结果
  5. 用 Zod 校验

关键陷阱:当模型不支持原生 Tool Calling 时,LangChain 会降级为 Prompt + JSON 描述的方式。这时结构化输出的可靠性会大幅下降。


三、核心矛盾:流式体验 vs 结构化可靠性

3.1 一个经典的"不可能三角"

你观察到的现象背后,是大模型应用开发的一个经典矛盾:

image.png

这个矛盾的根源,在于 "校验的完整性""生成的渐进性" 天然对立:

  • 更好的格式化(如 withStructuredOutput :底层走 Tool Call,要求模型输出参数必须完整且严格符合 Schema。只有全部参数生成完毕,Zod 才能校验通过。
  • 流式输出:本质是模型自回归生成的中间态。在生成完成前,产出的永远是"半截 JSON"或"残缺的参数片段"。

如果你强行在流式过程中做严格校验,结果必然是大量报错;如果你等待完整再输出,流式就变成了"假流式"。

3.2 本质认知:流式和结构化是两件正交的事

现在我们需要一个更清晰的认知框架:

流式输出管"体验",格式化管"可用"。

维度流式输出格式化/结构化
**解决的问题**内容什么时候到、怎么展示内容长什么样、程序能不能用
**层次**传输层 + 呈现层数据层 + 语义层
**核心诉求**快、实时、让用户看到"正在思考"准、全、严格符合 Schema
**消费者**前端浏览器(人眼)数据库 / 下游服务(程序)

两者是正交的,可以组合,也可以分开。

用户看到的"打字机效果",本质是模型逐 token 生成 + SSE 推送 + 前端逐块渲染。这时候内容是不是 JSON、有没有 Schema,不影响它能不能流式展示。纯文本可以流,Markdown 可以流,甚至半截 JSON 也可以流——只是展示出来可能是乱码。

而格式化,解决的是:

  • 我要把这个数据存数据库
  • 我要把这个字段传给下游服务
  • 我要用这个对象渲染一张卡片
  • 我要让 Agent 根据结构化结果做决策

3.3 那 JsonOutputParser 真的能"两全其美"吗?

不能完美存储,它只能做到"边流边给你半成品"。

假如你要把爱因斯坦的信息存入数据库,数据库要求:birth_year 必须是 INTname 必须是 VARCHAR 且不能为空。

如果你用 JsonOutputParser 流式解析:

  • 第 1 秒,你拿到 { name: "爱因" } —— 缺字段,不能存
  • 第 2 秒,你拿到 { name: "爱因斯坦", birth_year: 1 } —— 年份错了,不能存
  • 第 3 秒,你拿到 { name: "爱因斯坦", birth_year: 1879, nationality: "德国" } —— 终于完整了,可以存了

你会发现,流式过程中解析出来的对象,永远是"残血版"的。 只有等模型完全生成结束,最后一次解析出的对象,才是完整的、可以用于存储的。

所以 JsonOutputParser 的真正价值,是让前端在流式过程中提前拿到"部分字段"去渲染 UI,而不是为了存储。


四、卡片填充:JsonOutputParser 的最佳实战场景

4.1 什么是"卡片填充"?

"卡片填充"(Progressive Card Filling)是一种前端 UI 渲染策略:大模型边生成数据,前端边把数据"填"进一个预先设计好的卡片组件里,让卡片从空到满,逐步"长"出来。

你可以把它想象成一张简历卡片的生成过程:

  • 0秒:页面先出现一个带骨架屏的空卡片(头像、姓名、年份都是灰色占位符)
  • 第1秒:模型吐出 {"name": "莫扎特"} → 卡片上的名字瞬间点亮,显示"莫扎特"
  • 第2秒:模型吐出 {"name": "莫扎特", "birth_year": 1756} → 出生年份位置亮起
  • 第3秒:模型吐出 {"famous_theory": ["费加罗的婚礼"]} → 作品列表出现第一条数据
  • 结束:卡片完全成型

4.2 为什么要做卡片填充?

核心是为了"体验"和"感知速度"。

如果等大模型全部生成完(比如 5 秒)再一次性渲染卡片,用户会盯着转圈圈 5 秒。如果让用户盯着纯文本的 JSON 流,用户看不懂,感觉很原始。

卡片填充把这两种缺点都规避了:用户既看到了实时的进度,又看到了结构化排版的美观。

4.3 技术实现

模型流式返回的 chunk 是这样的(不是合法 JSON):

text

<span>{</span><span>"name"</span><span>:</span> <span>"莫
{"</span>name<span>": "</span>莫扎特<span>", "</span>birth
<span>{</span><span>"name"</span><span>:</span> <span>"莫扎特"</span><span>,</span> <span>"birth_year"</span><span>:</span> <span>1756</span><span>}</span>

直接 JSON.parse 会崩溃,但 JsonOutputParser 配合 partial-json 可以容错解析

javascript

<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>schema</span>: scientistSchema });

<span>// 关键:用 pipe 把 parser 接到流式模型后面</span>
<span>const</span> stream = <span>await</span> model.<span>pipe</span>(parser).<span>stream</span>(prompt);

<span>for</span> <span>await</span> (<span>const</span> chunk <span>of</span> stream) {
    <span>// chunk 可能是:{ name: '莫扎特' } 或 { name: '莫扎特', birth_year: 1756 }</span>
    <span>// 直接通过 SSE 推给前端</span>
    <span>sendToFrontend</span>(chunk);
}

前端框架收到数据后,直接驱动视图:

jsx

// 前端:卡片组件
<span><<span>Card</span>></span>
    <span><<span>h1</span>></span>{data.name ?? '...'}<span></<span>h1</span>></span>
    <span><<span>p</span>></span>出生年份: {data.birth_year ?? '...'}<span></<span>p</span>></span>
    <span><<span>ul</span>></span>
        {data.famous_theory?.map(t => <span><<span>li</span> <span>key</span>=<span>{t}</span>></span>{t}<span></<span>li</span>></span>) ?? '...'}
    <span></<span>ul</span>></span>
<span></<span>Card</span>></span>

数据变一点,卡片变一点。

4.4 卡片填充和数据存储的界限

回到"两件事"的架构:

  • 通道一(流式展示) :使用 JsonOutputParser 增量解析,拿到的"残血版"对象,直接扔给前端做卡片填充。前端不关心数据完整不完整,有就渲染,没有就占位。
  • 通道二(数据存储) :等待流结束,拿到完整文本,用 withStructuredOutput严格 Zod 校验,通过后写数据库。

卡片填充是"流式解析"在 UI 层面的具体应用场景,绝不能拿它的半成品去直接落库。


五、双通道解耦架构:一次生成,两条管道消费

5.1 正确的工程架构

"分开"并不意味着要调用两次大模型(那太浪费了),而是一次生成,两条管道消费

image.png

5.2 三种落地做法

做法 1:前端流式,后端存储(最推荐)

  • 前端:接收 SSE 文本流,直接用纯文本渲染打字机效果(或者用 JsonOutputParser 增量解析做卡片填充)
  • 后端:监听同一个流,在内存里把文本拼起来,等流结束,拿到完整文本,执行一次 withStructuredOutput,校验通过后入库
  • 优点:只调用一次模型,兼顾体验和可靠

做法 2:流式生成,异步结构化(更解耦)

  • 主链路:只做流式输出,给用户看
  • 旁路链路:流结束后,把完整文本丢进消息队列,由后台 Worker 去做结构化入库
  • 优点:主链路极快,存储失败不影响用户体验(可以重试)

做法 3:为了绝对可靠,调用两次模型(最稳妥)

  • 第一次调用:只为了流式展示给用户看(纯文本)
  • 第二次调用:等第一次结束后,再调一次 withStructuredOutput().invoke(),专门为了拿干净的数据入库
  • 缺点:贵(双倍 Token),慢(多一次往返)
  • 适用:对数据准确性要求极高,且预算充足的场景

5.3 三种做法对比

方案Token 成本用户体验数据可靠性推荐度
双通道消费⭐⭐⭐⭐⭐
异步结构化极高⭐⭐⭐⭐
调用两次极高⭐⭐⭐

六、OutputParser 的"第二春":XML 和 YAML

6.1 上一篇文章的结论要再打补丁

上一篇文章我说"能用 withStructuredOutput 就别用 Parser"。这句话在 JSON 场景下是对的,但格式不只有 JSON。

格式化输出,不只有 JSON 格式,xml、YAML 等。

withStructuredOutput 只能处理结构化对象(JSON Schema 能描述的格式) 。如果你的输出格式是 XML 或 YAML,它无能为力。

6.2 XMLOutputParser 实战

javascript

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

<span>const</span> parser = <span>new</span> <span>XMLOutputParser</span>();
<span>const</span> question = <span>`
请提取以下文本中的任务信息:爱因斯坦生于1879年,是一位伟大的物理学家。
<span>${parser.getFormatInstructions()}</span>
`</span>;

<span>const</span> res = <span>await</span> model.<span>invoke</span>(question);
<span>console</span>.<span>log</span>(res.<span>content</span>);
<span>// <response></span>
<span>//   <name>爱因斯坦</name></span>
<span>//   <birth_year>1879</birth_year></span>
<span>//   <occupation>物理学家</occupation></span>
<span>// </response></span>

<span>const</span> result = <span>await</span> parser.<span>parse</span>(res.<span>content</span>);
<span>console</span>.<span>log</span>(result);
<span>// { name: "爱因斯坦", birth_year: "1879", occupation: "物理学家" }</span>

XMLOutputParser 的适用场景

  • 输出需要人类可读性(XML 比 JSON 更易读)
  • 历史系统对接(老系统用 XML 做数据交换)
  • 需要层级标签语义(HTML 生成场景)

在 LLM 场景下,XML 的价值在于:模型对 XML 标签的"边界感知"往往比 JSON 的大括号更清晰,尤其在处理长文本嵌套时。

6.3 YAMLOutputParser

javascript

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

<span>const</span> parser = <span>new</span> <span>YAMLOutputParser</span>({ <span>schema</span>: scientistSchema });

YAML 的优势

  • 比 JSON 更简洁(无引号、无大括号)
  • 支持注释
  • 人类可读性最强

6.4 完整方案对比表

方案格式支持流式支持约束强度推荐场景
`withStructuredOutput`JSON最强非流式 + 模型支持 tools
`JsonOutputParser`JSON✅ 增量流式 + 卡片填充
`XMLOutputParser`XML✅ 增量XML 格式需求
`YAMLOutputParser`YAML✅ 增量YAML 格式需求
`StructuredOutputParser`JSON兼容旧场景

OutputParser 模块不会消失,只是它的战场从"JSON 结构化"转移到了"特殊格式 + 流式场景"。


七、避坑指南

坑 1:误以为 withStructuredOutput 支持流式增量

javascript

<span>// ❌ 错误期望</span>
<span>const</span> stream = <span>await</span> structuredModel.<span>stream</span>(prompt);
<span>for</span> <span>await</span> (<span>const</span> chunk <span>of</span> stream) {
    <span>console</span>.<span>log</span>(chunk); <span>// 期望看到部分对象,实际是空对象</span>
}

<span>// ✅ 正确姿势</span>
<span>// 流式用 JsonOutputParser</span>
<span>const</span> stream = <span>await</span> model.<span>pipe</span>(<span>new</span> <span>JsonOutputParser</span>({ schema })).<span>stream</span>(prompt);

坑 2:拿流式解析的"半成品"直接入库

javascript

<span>// ❌ 灾难写法</span>
<span><span>for</span> <span>await</span> (<span><span>const</span> chunk of stream</span>)</span> {
    <span>await</span> db.insert(chunk); <span>// 💥 半成品数据,脏库预警</span>
}

<span>// ✅ 正确姿势</span>
<span>let</span> buffer = <span>''</span>;
<span><span>for</span> <span>await</span> (<span><span>const</span> chunk of stream</span>)</span> {
    buffer += chunk;
    sendToFrontend(chunk); <span>// 前端卡片填充</span>
}
<span>// 流结束后,严格校验再入库</span>
<span>const</span> finalResult = <span>await</span> model.withStructuredOutput(schema).invoke(prompt);
<span>await</span> db.insert(finalResult);

坑 3:忘记 Tool Call 降级问题

javascript

// 如果模型不支持 Function Calling,withStructuredOutput 会降级为 Prompt + JSON 描述
// 可靠性大幅下降,建议先检测模型能力
const <span>model</span> = new ChatOpenAI({ modelName: <span>'gpt-4o'</span> })<span>; // ✅ 支持</span>
// const <span>model</span> = new ChatOpenAI({ modelName: <span>'some-small-model'</span> })<span>; // ⚠️ 可能不支持</span>

坑 4:流式 + Zod 校验的组合陷阱

javascript

<span>// ❌ 在流式过程中做 Zod 校验</span>
<span><span>for</span> <span>await</span> <span>(<span>const</span> chunk of stream)</span> </span>{
    scientistSchema.<span>parse</span>(chunk); <span>// 💥 半截数据必然校验失败</span>
}

<span>// ✅ 只在最终结果做校验</span>
<span>const</span> finalResult = await structuredModel.<span>invoke</span>(prompt);
scientistSchema.<span>parse</span>(finalResult); <span>// ✅</span>

坑 5:混淆 withStructuredOutputbindTools 的返回值

javascript

// withStructuredOutput:直接返回对象
const <span>result</span> = await model.withStructuredOutput(schema).invoke(prompt)<span>;</span>
result.name<span>; // ✅</span>

// bindTools:需要手动取 tool_calls
const <span>response</span> = await model.bindTools([{ name, schema }]).invoke(prompt)<span>;</span>
response.tool_calls<span>[0]</span>.args.name<span>; // ✅</span>


八、面试高频考点

考点 1:withStructuredOutput 为什么不支持流式输出?

回答要点

  1. 底层机制决定:它依赖 Tool Call 的参数,而工具参数需要完整才能通过 Zod 校验
  2. 校验时机:内部解析器在模型完成生成、校验通过后才产出结果,不会产出部分对象
  3. 替代方案JsonOutputParser 支持流式增量解析(产出部分 JSON 对象),或分离文本流和结构化结果

考点 2:Tool Call 和 OutputParser 的结构化输出,本质区别是什么?

回答要点

  1. Tool Call:走模型原生 Function Calling 通道,Schema 就是约束,不需要 Prompt 注入格式指令
  2. OutputParser:走 Prompt 注入格式指令 + 手动解析的路径
  3. 可靠性:Tool Call 更高(原生支持),OutputParser 依赖模型"听话"程度
  4. 流式:OutputParser 的 JsonOutputParser 支持增量解析,Tool Call 不支持
  5. 格式:Tool Call 只能处理 JSON Schema 能描述的格式,OutputParser 还支持 XML、YAML

考点 3:为什么说"工具函数没有执行"?

回答要点

  1. 工具函数在 bindTools 中只是一个 Schema 载体,告诉模型"我要什么结构"
  2. 模型返回的 tool_calls 只包含参数,不包含函数执行
  3. 实际的函数执行需要开发者手动处理(或在 Agent 中由框架执行)
  4. 这正是"用 Tool Call 做结构化输出"的技巧——借 Schema 做约束,不执行函数

考点 4:流式输出和结构化输出,在架构上应该怎么解耦?

回答要点

  1. 本质认知:流式管"体验",结构化管"可用",两者正交

  2. 一次生成,两条管道消费

    • 通道一:前端展示(打字机 + 卡片填充,用 JsonOutputParser 增量解析)
    • 通道二:数据存储(流结束后完整文本 + withStructuredOutput 严格校验)
  3. 三种落地做法:双通道消费(推荐)、异步结构化、调用两次模型

  4. 核心原则:绝不拿流式解析的"半成品"直接入库

九、总结

一句话记忆

  • 流式输出管"看得见",格式化管"用得上",两者正交
  • withStructuredOutput = Tool Call 的语义化封装,最可靠,但不支持流式
  • JsonOutputParser = 流式场景的正解,用于卡片填充,不能用于存储
  • 正确的架构 = 一次生成,两条管道消费,展示与存储解耦
  • OutputParser 不会消失,只是战场转移到特殊格式 + 流式场景

流式是体验,结构化是契约,两者解耦,各司其职。