重塑实时交互:NestJS + LangChain + RxJS 打造企业级 AI Agent 流式输出新范式
摘要:在大模型应用从“玩具”走向“生产”的2026年,用户体验的核心指标已从“准确率”转向“首字延迟(TTFT)”。本文深入剖析如何结合 NestJS 的企业级架构、LangChain 的 Agent 编排能力以及 RxJS 的响应式编程模型,构建一个支持工具调用(Tool Calling)的全双工流式 SSE 接口。我们将通过一套完整的实战代码,揭示如何将异步生成器(Async Generator)优雅地转换为 Observable 数据流,解决传统 HTTP 请求在 AI 场景下的体验断层问题。
一、背景:为什么我们需要“流式”Agent?
在传统的 Web 开发中,“请求 - 响应”模式是金科玉律。然而,当后端接入大语言模型(LLM)时,这种模式遭遇了严峻挑战:
- 漫长的等待:LLM 生成内容需要时间,尤其是涉及复杂推理或外部工具调用(如查询数据库、搜索网络)时,用户往往面对长达数秒甚至数十秒的空白加载。
- 黑盒过程:用户无法感知 AI 正在“思考”还是正在“调用工具”,缺乏即时反馈导致信任感降低。
- 资源浪费:长轮询或巨大的单一响应包对服务器内存和网络带宽都不友好。
Server-Sent Events (SSE) 成为了破局的关键。它基于 HTTP 长连接,允许服务器主动向客户端推送文本流。而在 NestJS 生态中,结合 RxJS 和 LangChain 的流式 API,我们可以实现一种全新的**“边思考、边行动、边输出”**的交互范式。
二、核心架构:三位一体的技术选型
本方案采用了当前 Node.js 全栈开发的“黄金三角”:
- NestJS (
@Sse):作为网关,负责协议转换、依赖注入和生命周期管理。它自动处理Content-Type: text/event-stream等头部信息,将复杂的 HTTP 长连接细节封装为简单的装饰器。 - LangChain (
stream,bindTools):作为大脑,提供原生的流式生成能力 (stream) 和强大的 Agent 循环逻辑。它允许我们在流式过程中动态判断是否需要调用工具。 - RxJS (
from,map):作为桥梁,利用其强大的操作符将 LangChain 的AsyncIterable(异步迭代器)转换为 NestJS 所需的Observable(可观察对象),实现数据流的平滑输送。
三、深度实战:代码逐层解析
1. 定义智能工具 (Tool Definition)
首先,我们使用 zod 定义严格的参数 schema,确保 LLM 调用的准确性。这是 Agent 能够可靠执行任务的基础。
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
// 1. 定义参数校验 Schema
const queryUserArgsSchema = z.object({
userId: z.string().describe('用户ID, 例如: 001, 002, 003'),
});
// 2. 模拟数据库
const database = {
users: {
'001': { id: '001', name: '张三', email: 'zhangsan@example.com', role: 'admin' },
// ... 其他用户
},
};
// 3. 创建工具
export const queryUserTool = tool(
async ({ userId }) => {
const user = database.users[userId];
if (!user) return `用户ID ${userId} 不存在。`;
return `用户信息:姓名-${user.name}, 角色-${user.role}`;
},
{
name: 'query_user',
description: '查询数据库中的用户信息。输入用户ID,返回详细信息。',
schema: queryUserArgsSchema
}
);
2. 核心服务层:实现带工具调用的流式 Agent 循环
这是整个系统的灵魂所在。AiService 中的 runChainStream 方法不仅是一个简单的生成器,它是一个微型的 Agent 运行时。
关键逻辑拆解:
-
Agent Loop (
while(true)): AI 的推理往往不是一次完成的。它可能需要:思考 -> 调用工具 -> 观察结果 -> 再次思考 -> 输出最终答案。这个while循环确保了只要 LLM 决定调用工具,流程就会自动继续,直到得出最终结论。 -
流式拼接与过滤 (
concat&yield):for await (const chunk of stream as AsyncIterable<AIMessageChunk>) { // 累加 Chunk 以获取完整的消息状态 fullAIMessage = fullAIMessage ? fullAIMessage.concat(chunk) : chunk; // 核心过滤逻辑:判断当前是否处于“工具调用”状态 const hasToolCallChunk = !!fullAIMessage.tool_call_chunks && fullAIMessage.tool_call_chunks.length > 0; // 只有当不是工具调用且内容有值时,才推送到前端 if (!hasToolCallChunk && chunk.content) { yield chunk.content as string; } }深度分析:这段代码巧妙地解决了流式输出中的“噪音”问题。当 LLM 生成工具调用指令(通常是 JSON 格式)时,我们不希望把这些中间态暴露给用户。通过检查
tool_call_chunks,我们实现了静默执行——用户在界面上看到的是流畅的对话,而后台正在疯狂地进行数据查询。 -
工具执行与上下文注入: 当一轮流式生成结束,如果检测到
tool_calls,代码会立即执行对应的工具函数,并将结果封装为ToolMessage重新压入messages队列。这构成了完整的 ReAct (Reasoning + Acting) 闭环。
// AiService 核心片段
async *runChainStream(query: string): AsyncIterable<string> {
const messages: BaseMessage[] = [
new SystemMessage("你是一个智能助手..."),
new HumanMessage(query),
];
while(true) {
const stream = await this.modelWithTools.stream(messages);
let fullAIMessage: AIMessageChunk | null = null;
// 1. 流式消费 LLM 响应
for await (const chunk of stream as AsyncIterable<AIMessageChunk>) {
fullAIMessage = fullAIMessage ? fullAIMessage.concat(chunk) : chunk;
// 2. 过滤工具调用片段,只输出纯文本
const hasToolCallChunk = !!fullAIMessage.tool_call_chunks?.length;
if (!hasToolCallChunk && chunk.content) {
yield chunk.content as string; // <--- 推送到前端
}
}
if (!fullAIMessage) break;
// 3. 将完整消息加入历史
messages.push(fullAIMessage);
// 4. 检查是否有工具需要执行
const toolCalls = fullAIMessage.tool_calls ?? [];
if (!toolCalls.length) break; // 没有工具调用,结束循环
// 5. 执行工具并回填结果
for (const toolCall of toolCalls) {
if (toolCall.name === 'query_user') {
const args = queryUserArgsSchema.parse(toolCall.args);
const result = await queryUserTool.invoke(args);
messages.push(new ToolMessage({
content: result,
tool_call_id: toolCall.id,
name: toolCall.name
}));
}
}
// 循环继续,LLM 将基于工具结果生成下一轮回复
}
}
3. 控制层:RxJS 魔法转换
NestJS 的 @Sse 装饰器要求返回一个 Observable<MessageEvent>。而 LangChain 返回的是 AsyncIterable。这里需要 RxJS 登场。
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Sse('chat/stream')
chatStream(@Query('query') query: string): Observable<MessageEvent> {
const stream = this.aiService.runChainStream(query);
// 核心转换管道
return from(stream) // 将 AsyncIterable 转为 Observable
.pipe(
map((chunk) => ({
data: chunk // 符合 SSE 标准的 { data: ... } 格式
}))
) as Observable<MessageEvent>;
}
}
技术亮点:
- 背压处理 (Backpressure):RxJS 天然支持背压。如果前端接收速度慢于后端生成速度,Observable 会自动缓冲或根据策略处理,防止服务器内存爆炸。
- 声明式转换:通过
pipe和map,我们将业务数据(string)无缝转换为协议数据(MessageEvent),代码极其简洁且易于扩展(例如添加type字段区分普通消息和错误消息)。
四、新解法优势分析
相较于传统的 WebSocket 或简单的 HTTP 轮询,这套方案具有以下显著优势:
| 特性 | 传统 HTTP 请求 | WebSocket | NestJS + LangChain + SSE (本方案) |
|---|---|---|---|
| 首字延迟 (TTFT) | 高 (需等待全部生成) | 低 | 极低 (Token 级推送) |
| 工具调用体验 | 黑盒,长时间无响应 | 需自定义协议处理中间态 | 静默执行,流程自然流畅 |
| 实现复杂度 | 低 | 高 (需处理心跳、重连、鉴权) | 中 (基于 HTTP,原生支持重连) |
| 类型安全 | 一般 | 较弱 (通常为 any) | 强 (TS + Zod 全链路类型推断) |
| 架构耦合度 | 紧耦合 | 紧耦合 | 解耦 (Service 纯逻辑,Controller 纯协议) |
关键突破点:Agent 状态的透明化
本方案最大的创新在于在流式输出中内嵌了 Agent 的状态机逻辑。
以往的流式接口通常只能流式输出“最终文本”。如果中间触发了工具调用,往往需要中断流,等待工具执行完再开启新流,或者一次性吐出所有结果。
而本方案通过 while(true) 循环和 concat 累加判断,实现了:
- 分段流式:先流式输出“我正在查询...”(可选优化),然后静默执行工具。
- 连续上下文:工具执行结果自动注入上下文,LLM 紧接着流式输出最终答案,对用户而言,这是一次连续的、不间断的思维流。
五、结语
通过 NestJS 的模块化架构、LangChain 的智能编排以及 RxJS 的响应式流转,我们构建的不仅仅是一个 API 接口,而是一个具有“呼吸感”的 AI 智能体。
这种流式输出新解法,将大模型的“思考过程”具象化为实时的数据流,极大地消除了人机交互的隔阂。在 AI 应用日益普及的今天,掌握这种架构模式,将是全栈开发者构建下一代实时智能应用的核心竞争力。
代码已开源思路:你可以基于此模式,轻松扩展支持多模态输入、并行工具调用(Promise.all)以及更复杂的 LangGraph 状态图,开启你的 AI 全栈之旅。