流式背后的状态机:深入解析 AI Agent 的核心循环机制

7 阅读12分钟

在上一篇文章《推理还是行动?解析 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 字符串累加。同时,它会自动处理 nameid 字段(通常只在第一个碎片中出现)。

这种设计将碎片重组的复杂性封装在了底层库中。业务代码只需要关心“循环结束后的完整对象”,而不需要手动维护缓冲区或拼接字符串。这大大降低了出错的概率。

3. 从 chunkscalls 的自动转换

在代码中,你可能会注意到两个相似的属性:tool_call_chunkstool_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 被阻止。后端进入“静默积累”状态。

为什么工具调用时要静默?

  1. 数据一致性:如前所述,工具参数是碎片化的 JSON。在 JSON 闭合之前,推送任何内容给前端都没有意义,甚至会导致前端解析错误。
  2. 安全性:工具调用的内部参数(如数据库字段名、内部 ID)不应暴露给用户。
  3. 用户体验:用户不需要看到模型正在构建 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 应用背后,是精细的数据流管理。

核心要点回顾:

  1. 流式转换:使用 RxJS 将 AsyncIterable 转换为 Observable,利用其生命周期管理优势。
  2. 碎片聚合:依赖 LangChain 的 concat 方法处理 JSON 碎片,避免手动拼接的风险。
  3. 状态互斥:在文本生成时实时推送,在工具调用时静默积累,保证数据一致性。
  4. 自动解析:利用 tool_calls 属性自动完成 JSON 解析,简化业务逻辑。

未来的优化方向:

目前的实现中,工具调用是串行的(for (const toolCall of toolCalls))。如果模型同时请求查询用户和查询订单,它们是排队执行的。在高性能场景下,可优化为 Promise.all 并行处理,但需处理事务一致性问题。此外,当前的 messages 存储在内存中,生产环境需将会话状态存入 Redis,支持断线重连。

工程化的本质,是在不确定性中建立确定性。大模型的输出是概率性的,但我们的架构必须是稳健的。希望这篇关于核心循环机制的剖析,能为你在构建 AI 应用时提供一份坚实的参考。