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

93 阅读14分钟

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

导语

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

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

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

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

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


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

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

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

image.png

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

1.2 withStructuredOutput 的流式陷阱

来看这段代码:

javascript

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

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

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

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


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

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

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

javascript

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

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

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

image.png

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

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

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

javascriptJavaScript

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

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

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

2.3 withStructuredOutput 只是"语义化封装"

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

javascriptJavaScript

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

// 语义化写法(简洁但黑盒)
const result = await model.withStructuredOutput(schema).invoke(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 必须是 INT,name 必须是 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

{"name": "莫
{"name": "莫扎特", "birth
{"name": "莫扎特", "birth_year": 1756}

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

javascript

import { JsonOutputParser } from '@langchain/core/output_parsers';

const parser = new JsonOutputParser({ schema: scientistSchema });

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

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

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

jsx

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

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

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

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

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

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

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

XMLOutputParser 的适用场景:

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

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

6.3 YAMLOutputParser

javascript

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

const parser = new YAMLOutputParser({ schema: scientistSchema });

YAML 的优势:

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

6.4 完整方案对比表

方案格式支持流式支持约束强度推荐场景
withStructuredOutputJSON❌最强非流式 + 模型支持 tools
JsonOutputParserJSON✅ 增量中流式 + 卡片填充
XMLOutputParserXML✅ 增量中XML 格式需求
YAMLOutputParserYAML✅ 增量中YAML 格式需求
StructuredOutputParserJSON❌中兼容旧场景

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


七、避坑指南

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

javascript

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

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

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

javascript

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

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

坑 3:忘记 Tool Call 降级问题

javascript

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

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

javascript

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

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

坑 5:混淆 withStructuredOutput 和 bindTools 的返回值

javascript

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

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

八、面试高频考点

考点 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 不会消失,只是战场转移到特殊格式 + 流式场景

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