在上一篇文章《推理还是行动?解析 ReAct 模式在 NestJS 后端架构中的落地实践》中,我们构建了基于 NestJS 与 LangChain 的 AI Agent 整体架构。然而,架构的稳固依赖于底层细节的精确实现。当大模型的流式输出遇上结构化的工具调用,数据流的形态会发生怎样的变化?如何保证 JSON 参数在碎片化传输中不丢失、不错乱?本文将深入代码内部,剖析 AIMessageChunk 的聚合机制、concat 方法的底层逻辑以及 Agent 循环中的状态决策过程,揭示流式 Agent 稳定运行的核心秘密。
一、引言:从架构蓝图到代码实现
上一篇文章我们解决了“做什么”的问题,确立了 ReAct 模式与 SSE 通信协议。但这篇文章我们要解决“怎么做”的问题。在实际编码中,开发者最容易踩坑的地方往往不是架构设计,而是数据流的处理细节。
大模型的流式输出并非简单的字符串拼接。当模型决定调用工具时,它输出的是一段结构化的 JSON 参数。这段 JSON 在流式传输中被切割成无数个碎片(Chunk)。如果处理不当,就会导致 JSON 解析失败,进而导致工具调用中断,整个 Agent 循环崩溃。
因此,理解 LangChain 如何处理这些碎片,以及 NestJS 如何将这些碎片转化为前端可理解的流,是实现稳定 AI 应用的关键。
二、传输层的桥梁:RxJS 与 AsyncIterable
在 NestJS 的 Controller 层,我们面临一个类型转换的问题。LangChain 的模型流式接口返回的是 ES6 标准的 AsyncIterable(异步可迭代对象),而 NestJS 的 @Sse 装饰器期望返回的是 RxJS 的 Observable(可观察对象)。
@Sse('chat/stream')
chatStream(@Query("query") query:string): Observable<MessageEvent>{
const stream = this.aiService.runChainStream(query);
// 将 AsyncIterable 转换为 Observable
return from(stream).pipe(
map((chunk) => ({ data: chunk })),
) as Observable<MessageEvent>;
}
这段代码的核心在于 from(stream)。这不仅仅是类型的适配,更是生命周期管理的需要。
AsyncIterable 类似于一个传送带,模型生成一点,它就传递一点。使用 for await...of 可以消费它,但在 HTTP 长连接场景下,如果客户端提前断开连接,原生的迭代器可能无法感知,导致后端继续生成数据,造成资源浪费。
RxJS 的 Observable 配合 NestJS 的底层机制,能够在客户端断开时自动触发取消订阅逻辑。这意味着,一旦用户关闭页面,后端的生成循环会被通知停止。这种背压(Backpressure)机制是构建高并发 AI 服务的基础设施。
此外,map 操作符将原始的数据块包装成 { data: chunk } 格式,这是为了符合 SSE 协议的标准。前端 EventSource 监听到的 message 事件,其 data 属性必须是字符串。显式的格式转换避免了前端序列化的不确定性,确保了通信契约的稳定性。
三、核心引擎:Agent Loop 中的异步生成器
服务层的 runChainStream 方法是整个系统的心脏。它被定义为一个异步生成器函数(async *),这意味着它可以多次 yield 数据,而不是一次性返回。
async *runChainStream(query:string): AsyncIterable<string>{
// ... 初始化 messages
while(true){
// 1. 获取流式响应
const stream = await this.modelWithTools.stream(messages);
let fullAIMessage : AIMessageChunk | null = null;
// 2. 消费流
for await (const chunk of stream as AsyncIterable<AIMessageChunk>){
// 聚合逻辑
fullAIMessage = fullAIMessage ? fullAIMessage.concat(chunk) : chunk;
// 推送逻辑
// ...
}
// 3. 工具执行与循环控制
// ...
}
}
这里的 while(true) 并非死循环,而是一个状态机。它的退出条件隐含在逻辑中:当模型不再返回工具调用指令时,循环自然终止。
for await...of 是处理 AsyncIterable 的标准语法。它的机制是挂起与恢复:当流中没有新数据时,代码暂停在这一行,不阻塞主线程;一旦 LLM 生成新的字符碎片,循环立即恢复执行。这种机制保证了后端能够以最低的延迟响应模型的生成进度。
四、深度剖析:concat 方法的聚合魔法
在流式处理中,最棘手的问题是数据碎片的完整性。特别是当模型调用工具时,它输出的 JSON 参数是被打散的。
1. 碎片化的现实
假设模型需要调用一个查询用户的工具,参数是 {"userId": "001"}。在流式输出中,这个 JSON 不会一次性出现,而是可能分成三个 Chunk:
- Chunk 1:
{ content: "", tool_call_chunks: [{ index: 0, name: "query", id: "call_1" }] } - Chunk 2:
{ content: "", tool_call_chunks: [{ index: 0, args: '{"user' }] } - Chunk 3:
{ content: "", tool_call_chunks: [{ index: 0, args: 'Id": "001"}' }] }
注意看,args 字段在 Chunk 2 和 Chunk 3 中是断开的。如果我们在接收到 Chunk 2 时尝试解析 JSON,必然会报错。这就是为什么我们不能直接处理单个 Chunk,而必须进行聚合。
2. concat 的字段级累加
LangChain 的 AIMessageChunk 类重写了 concat 方法。当你执行 fullAIMessage.concat(chunk) 时,它执行的是字段级的智能合并,而不是简单的对象覆盖。
- 文本内容合并:对于
content字段,它执行字符串拼接。full.content = full.content + chunk.content。 - 工具调用合并:对于
tool_call_chunks字段,它根据index索引进行匹配。如果index相同,它会将args字符串累加。同时,它会自动处理name和id字段(通常只在第一个碎片中出现)。
这种设计将碎片重组的复杂性封装在了底层库中。业务代码只需要关心“循环结束后的完整对象”,而不需要手动维护缓冲区或拼接字符串。这大大降低了出错的概率。
3. 从 chunks 到 calls 的自动转换
在代码中,你可能会注意到两个相似的属性:tool_call_chunks 和 tool_calls。
tool_call_chunks:这是流式过程中的原始碎片,参数是未解析的字符串。tool_calls:这是聚合完成后的完整对象,参数已被自动解析为 JSON 对象。
当 for await 循环结束,意味着模型完成了当前轮次的生成。此时,fullAIMessage 已经是一个完整的消息对象。LangChain 会自动根据拼接好的 tool_call_chunks 字符串,生成可以直接使用的 tool_calls 对象。
// 循环结束后,安全地获取完整的工具调用参数
const toolCalls = fullAIMessage.tool_calls ?? [];
这种自动转换机制是 LangChain 的一大亮点,它让开发者无需手动执行 JSON.parse,避免了因 JSON 格式微调导致的解析错误。
五、决策逻辑:流式推送与静默收集
在 for await 循环内部,我们需要决定:当前的数据块是应该立即推送给前端,还是留作后台处理?
const hasToolCallChunk = !!fullAIMessage.tool_call_chunks &&
fullAIMessage.tool_call_chunks.length > 0;
if(!hasToolCallChunk && chunk.content){
yield chunk.content as string;
}
这段逻辑实现了一种“过滤性流式”。
- 纯文本模式:如果
tool_call_chunks为空,说明模型正在生成自然语言。此时yield立即执行,用户能看到打字机效果,体验流畅。 - 工具模式:一旦检测到
tool_call_chunks非空,说明模型意图调用工具。此时yield被阻止。后端进入“静默积累”状态。
为什么工具调用时要静默?
- 数据一致性:如前所述,工具参数是碎片化的 JSON。在 JSON 闭合之前,推送任何内容给前端都没有意义,甚至会导致前端解析错误。
- 安全性:工具调用的内部参数(如数据库字段名、内部 ID)不应暴露给用户。
- 用户体验:用户不需要看到模型正在构建 JSON 的过程。他们只关心结果。静默执行工具,然后在下一轮循环中输出基于工具结果的自然语言总结,是更优的体验。
这种设计权衡了实时性与准确性。虽然在工具执行期间用户看不到输出,但保证了最终结果的正确性。
六、闭环与容错:Agent 循环的终止条件
Agent 循环的终止依赖于模型的行为。当 toolCalls 数组为空时,意味着模型认为任务已完成,不需要再调用工具。此时 while 循环返回,流式连接结束。
但在生产环境中,我们必须考虑异常情况。
1. 防止无限循环
如果模型陷入“调用工具 -> 报错 -> 再次调用相同工具”的死循环,后端资源将被耗尽。建议在 while 循环外设置最大迭代次数(如 5 次)。一旦超过阈值,强制终止循环并返回错误提示。这不仅是保护服务器,也是保护用户的 Token 预算。
2. 工具执行异常捕获
工具执行(如数据库查询)可能失败。我们不能让程序直接崩溃。建议在工具调用层增加 try-catch,并将错误信息封装成 ToolMessage 反馈给模型。
try {
const result = await queryUserTool.invoke(args);
// 成功反馈
} catch (error) {
// 错误反馈,让模型知道发生了什么
const errorMessage = `Error: ${error.message}`;
// 将错误信息 push 进 messages 数组
}
这样,AI 甚至能学会“自我纠错”,它会根据这个反馈告诉用户:“抱歉,我查询数据库时遇到了网络波动,请稍后再试。”这种透明度极大地提升了用户信任感。
七、前端集成:SSE 客户端的生命周期管理
后端流式管道的构建固然重要,但用户体验的最终落地取决于前端的接收与渲染。在 SSE 场景下,前端代码看似简单,实则涉及连接生命周期、并发控制与异常处理等多个工程细节。基于提供的示例代码,我们梳理出以下关键实践。
1. 连接的生命周期管理
EventSource 是浏览器提供的原生 API,用于监听服务器发送的事件。然而,它并非“即用即弃”的对象,必须严格管理其生命周期。
let es = null; // 全局变量维护连接实例
function closeEventSource() {
if (es) {
es.close();
es = null;
}
sendBtn.disabled = false;
}
为什么需要全局变量? 在一个单页应用中,用户可能会多次触发请求。如果不维护全局的 es 实例,旧的连接可能无法被引用从而无法关闭,导致服务器端残留大量挂起的连接。通过全局变量,我们确保在任何时刻最多只有一个活跃的连接,并且可以在需要时(如页面关闭、新请求开始)显式终止旧连接。
页面卸载时的清理:
window.addEventListener('beforeunload', closeEventSource);
这是防止内存泄漏的关键。如果用户直接在对话过程中关闭标签页,而没有触发 closeEventSource,服务器端的流式生成可能仍在继续,直到 HTTP 超时。监听 beforeunload 事件确保浏览器通知后端断开连接,释放服务器资源。
2. 并发控制与状态锁定
在流式响应期间,必须防止用户重复提交请求。
sendBtn.disabled = true; // 请求开始时禁用按钮
// ...
sendBtn.disabled = false; // 连接关闭后恢复
工程考量: 如果不禁用按钮,用户点击两次会导致两个并发的 EventSource 连接。这不仅会造成前端界面内容乱序(两个流同时写入同一个 outputEl),还会导致后端同时运行两个 Agent 循环,浪费计算资源和 Token 配额。通过按钮状态锁定,我们将交互模式强制变为“请求 - 响应”的串行模式,简化了状态管理的复杂度。
3. 错误处理的特殊性
SSE 协议有一个默认行为:当连接断开或发生错误时,浏览器会自动尝试重连。这对于股票行情等持续通知场景是合理的,但对于“一次对话请求”场景,自动重连往往不是我们想要的。
es.onerror = (event) => {
statusEl.textContent = '状态:连接结束或发生错误';
closeEventSource(); // 手动关闭,阻止自动重连
}
为什么手动关闭? 在后端逻辑中,当 Agent 循环结束或发生异常时,连接会正常关闭或中断。如果前端不手动调用 es.close(),浏览器可能会认为这是网络波动,尝试重新建立连接。这会导致后端接收到意外的新连接请求,或者前端状态机陷入混乱。因此,在 onerror 中显式关闭连接,是将 SSE 当作“一次性流”使用的必要操作。
八、总结与展望
通过深入剖析 concat 机制、AsyncIterable 消费以及 Agent 循环的状态决策,我们可以看到,一个稳定的流式 AI 应用背后,是精细的数据流管理。
核心要点回顾:
- 流式转换:使用 RxJS 将
AsyncIterable转换为Observable,利用其生命周期管理优势。 - 碎片聚合:依赖 LangChain 的
concat方法处理 JSON 碎片,避免手动拼接的风险。 - 状态互斥:在文本生成时实时推送,在工具调用时静默积累,保证数据一致性。
- 自动解析:利用
tool_calls属性自动完成 JSON 解析,简化业务逻辑。
未来的优化方向:
目前的实现中,工具调用是串行的(for (const toolCall of toolCalls))。如果模型同时请求查询用户和查询订单,它们是排队执行的。在高性能场景下,可优化为 Promise.all 并行处理,但需处理事务一致性问题。此外,当前的 messages 存储在内存中,生产环境需将会话状态存入 Redis,支持断线重连。
工程化的本质,是在不确定性中建立确定性。大模型的输出是概率性的,但我们的架构必须是稳健的。希望这篇关于核心循环机制的剖析,能为你在构建 AI 应用时提供一份坚实的参考。