🚀 Agent 开发进阶:用 RxJS 优雅实现大模型流式输出!

3 阅读12分钟

Hello 各位热爱 AI 和 Agent 开发的小伙伴们!👋 欢迎来到今天的硬核代码探险之旅!

在之前的学习中,我们探讨过几种大模型流式输出(Streaming)的实现方式。有的同学可能借用了强大的 ai-sdk 提供的现成配置,有的同学则是纯手工打造,老老实实地去监听 data 事件,然后一个 chunk(数据块)接着一个 chunk 地拼接,最后再一点点通过 HTTP 响应写回给前端。

这些方法当然都能跑通,但是……作为追求优雅的程序员,我们怎么能止步于此呢?😎

今天,我们要来学习一种更加现代、更加工程化、也更加优雅的实现方式——结合 RxJSNestJS 的 SSE(Server-Sent Events)特性,来实现大模型的流式输出!

这篇文章专为正在进阶 Agent 开发的你量身打造。我们会从 ES6 的底层特性聊起,过渡到 RxJS 的响应式魔法,最后再一头扎进 NestJS 的工程实践中。准备好了吗?系好安全带,发车啦!🚌💨


🛠️ 冒险前传(一):重拾 ES6 生成器与迭代器

在我们正式召唤 RxJS 之前,必须要先打牢地基。流式输出的本质,其实就是“一次不把话说完,一点一点往外蹦”。而在 JavaScript 中,天生为了这种“挤牙膏”式输出而生的特性,就是生成器(Generator)

让我们来看一段基础代码:

// 通过在 function 后面加 * 号,我们定义了一个生成器函数
function *fruitGenerator() {
    console.log("开始生成水果");
    yield '苹果'; // 运行到 yield,函数会暂停,并交出控制权
    console.log('继续生成');
    yield '香蕉';
    console.log("生成介绍");
    return '没有水果了'; // 彻底结束
}

// 调用生成器函数,并不会立即执行里面的代码,而是返回一个生成器对象(它也是一个迭代器)
const fruitMachine = fruitGenerator();

// 每次调用 next(),代码才会往下走一步
console.log(fruitMachine.next()); 
console.log(fruitMachine.next()); 
console.log(fruitMachine.next()); 

🧐 这里的魔法是什么?

  1. 星号 * 标记:只要在 function 后面加个 *,这个函数就脱胎换骨,变成了生成器函数。
  2. 神奇的 yield:它就像是一个暂停键⏸️。代码执行到 yield 时,会把后面的值打包成一个对象抛出来,然后原地冻结
  3. 返回的对象结构:每次调用 next(),都会得到一个长这样的对象:{ value: '苹果', done: false }
    • value 就是你 yield 出来的值。
    • donefalse 代表后面还有货;如果为 true,代表这个函数已经彻底执行完毕(遇到了 return 或者走到了函数末尾)。

大模型的流式输出其实和这个过程非常像:生成一段字,yield 出来,再生成一段字,再 yield 出来……理解了生成器,你就理解了流式输出的灵魂!👻


🌊 冒险前传(二):RxJS 响应式编程初探

如果说生成器是“拉(Pull)”模式(你调用 next() 它才给你数据),那么 RxJS 则是更强大的“推(Push)”模式。RxJS 是响应式编程(Reactive Programming)的利器,专门用来处理异步数据流。

让我们通过三个小 Demo 来快速上手 RxJS。

1. 纯手工打造 Observable

来看这段代码:

import { Observable } from 'rxjs';

// 1. 创建了一个 Observable(可观察对象)
// 参数是一个回调函数,回调函数接受一个 subscriber(观察者对象)
const stream = new Observable((subscriber) => {
    // next 方法用于源源不断地发送数据
    subscriber.next('hello');
    subscriber.next('world');
    // complete 告诉观察者:数据发完了,收工!
    subscriber.complete();
});

// 2. 订阅数据流
stream.subscribe((data) => {
    // 这里是观察者函数,每当上面的 next 被调用,这里就会触发
    console.log(data);
});

这就是经典的观察者模式Observable 就像是一个源源不断往外吐水的水龙头,而 subscribe 则是你拿着水桶在下面接水。水龙头每次 next(),你的水桶里就多一点水。这不就是流式输出吗?!💧

2. 快捷工具 from

每次都 new Observable 有点麻烦,RxJS 提供了许多操作符,比如 from

import { from } from 'rxjs';

// from 可以将数组、Promise、甚至是迭代器转换成 Observable 对象
const stream = from([1, 2, 3]);

// 订阅就会持续返回数组里的数据
stream.subscribe(v => console.log(v));

3. 数据流的加工厂 pipemap

流出来的数据如果格式不对怎么办?我们可以加一截“过滤管道”!:

import { from, map } from 'rxjs';

from([1, 2, 3])
    // pipe 就像下水道管子,把各种操作符串联起来
    .pipe(
        // map 拦截流过的数据,处理后再放行
        map(x => x * 2) 
    )
    .subscribe(v => console.log(v)); // 输出 2, 4, 6

这里的 pipemap 简直是神作!后面我们在 NestJS 中把大模型的原始 chunk 转换成前端需要的 SSE 格式时,全靠它们了!🔧


🚀 正式启航:NestJS 与大模型的奇妙碰撞

拥有了生成器和 RxJS 的前置知识,我们的流式输出冒险之旅正式开始!这次我们不仅要实现功能,还要把它写得符合 NestJS 的工程规范。

🏗️ 更加优雅的全局配置

以前我们总是习惯用 process.env.XXX 满天飞地去拿环境变量。但在规范的 NestJS 项目中,我们应该怎么做?看这里:

// app.module.ts
ConfigModule.forRoot({
  isGlobal: true, // 设置为全局可用,其他模块就不用再 import ConfigModule 啦
  envFilePath: '.env' // 指定环境变量文件路径
})

通过在根模块配置 ConfigModule,我们可以优雅地把环境变量管理起来,后续配合 ConfigService 享受类型提示,简直美滋滋。🌟

💉 依赖注入:模型的实例化与管理

以前我们可能会在 Service 的代码里直接 new ChatOpenAI()。但这样耦合度太高了!在这次的代码中,模型被提取到了 Module 中进行实例化:

{
  provide: 'CHAT_MODEL',
  // 使用 useFactory 工厂模式!就像造车一样,可以动态装配
  useFactory: (configService: ConfigService) => {
    return new ChatOpenAI({
      model: configService.get('MODEL_NAME'),
      apiKey: configService.get('OPENAI_API_KEY'),
      configuration: {
        baseURL: configService.get('OPENAI_API_BASE_URL'),
      }
    })
  },
  // 注入 ConfigService 供工厂函数使用
  inject: [ConfigService]
}

这样做的好处是巨大的:控制反转。在其他地方,我们只需要声明“我需要一个模型”,而不需要关心模型是怎么造出来的。这使得我们的代码极具可测试性和扩展性。🏭


📡 控制器层:拥抱 @Sse 装饰器,告别手动 Header

在以往手写流式接口时,我们要在 Controller 里拿到原生的 res(Response)对象,然后苦逼地设置各种头:

Content-Type: text/event-stream // 设置数据类型为streaam
Cache-Control: no-cache // 让浏览器不要缓存,还有数据
Connection: keep-alive // 保持连接
Transfer-Encoding: chunked // 按块传输

但在最新的 NestJS 实践中,这一切都被一个神奇的装饰器搞定了!

import { Controller, Get, Query, Sse, MessageEvent } from '@nestjs/common';
import { Observable, from } from 'rxjs';
import { map } from 'rxjs/operators';

@Controller('ai')
export class AiController {
  constructor(private readonly aiService: AiService) {}

  // 装饰器模式的胜利!@Sse 帮我们自动配置了所有流式输出所需的响应头!🎉
  @Sse('chat/stream')
  chatSystem(@Query('query') query: string): Observable<MessageEvent> {
    
    // 1. 调用 Service 层的生成器函数,拿到异步流 stream
    const stream = this.aiService.runChainStream(query);
    
    // 2. 将异步生成器 (AsyncIterable) 转换为 RxJS 的 Observable 对象!
    return from(stream)
      .pipe(
        // 3. 将大模型吐出来的纯文本 chunk,包装成前端 SSE 规范要求的格式
        map((chunk) => ({
          data: chunk // 前端通常监听 event.data
        }))
      ) as Observable<MessageEvent>; // 4. 强制类型推断,返回可观测的信息事件类型
  }
}

深度解析: 这里的代码虽然短小,但蕴含了极高的技术密度!

  • @Sse 是 NestJS 提供的专门用于 Server-Sent Events 的装饰器,只要接口返回的是一个 RxJS 的 Observable,它就会自动按照 SSE 的协议把数据一块一块地推给前端。
  • from(stream):这里的 stream 是一个异步生成器(AsyncIterable)。RxJS 的 from 太强大了,它不仅能转换数组,还能把异步生成器完美转换为 Observable!
  • map:大模型吐出来的只是一段段字符串,但 SSE 协议要求数据包格式通常带有 data: 前缀。通过 map 将其包装成 { data: chunk } 对象,NestJS 底层会自动帮我们序列化并加上正确的协议格式。

优雅!太优雅了!🎩✨


🧠 服务层:硬核 Agent 的核心引擎

真正的重头戏在 Service 层!这里包含了大模型的调用、工具(Tool)的绑定,以及多轮对话的核心循环(Agent Loop)。

1. 严谨的类型约束

在与大模型交互时,输入参数的验证至关重要。我们采用了 Zod 结合 TypeScript 的双重保险:

import { z } from 'zod';

// Zod schema:用于运行时校验大模型传递给工具的参数
const queryUserArgsSchema = z.object({
    userId: z.string().describe('用户ID,例如:001,002,003') // 这里的 describe 也是给大模型看的 prompt
});

// TS type:用于编译时的类型推导
type QueryUserArgs = {
    userId: string;
}

2. 定义工具(Tool)与模拟数据

Agent 之所以聪明,是因为它会使用工具。我们定义了一个查询用户的工具:

// 假的数据库,模拟真实环境
const database = {
  users: {
    '001': { id: '001', name: '张三', email: 'zhangsan@example.com', role: 'admin' },
    // ...省略其他
  },
};

// 使用 LangChain 的 tool 函数包裹我们的业务逻辑
const queryUserTool = tool(
    async ({ userId }: QueryUserArgs) => {
        const user = database.users[userId];
        if(!user) return `用户 ${userId} 不存在`;
        return `用户 ${user.id} 的姓名是 ${user.name}, 邮箱是 ${user.email}, 角色是 ${user.role}`;
    },
    {
        name: 'query_user', // 工具名称,大模型靠这个名字来调用
        description: '查询数据库中的用户信息。输入用户ID,返回该用户的详细信息(姓名、邮箱、角色)',
        schema: queryUserArgsSchema // 把前面定义的 Zod schema 喂给大模型
    }
)

3. 将工具绑定到模型

在类的构造函数中,我们将依赖注入进来的纯净大模型,与我们的工具进行了绑定(来自 :

@Injectable()
export class AiService {
    // 明确类型约束:输入是 BaseMessage 数组,输出是 AIMessage
    private readonly modelWithTools: Runnable<BaseMessage[], AIMessage>;

    // 将大模型实例从模块层注入进来,实现逻辑与 LLM 实例的分离
    constructor(@Inject('CHAT_MODEL') model: ChatOpenAI) {
        // 给大模型装上“手臂”!
        this.modelWithTools = model.bindTools([queryUserTool]);
    }
    // ...
}

4. 终极 Boss:异步生成器函数与 Agent Loop

接下来是全场最核心、也是最烧脑的代码——如何在一个异步生成器中,处理多轮对话的流式输出以及工具调用。

让我们把它拆解开来细细品味:

步骤一:初始化会话上下文

    // 这是一个异步生成器函数(Async Generator),返回 AsyncIterable
    async *runChainStream(query: string): AsyncIterable<string> {
        // 初始化消息历史数组
        const messages: BaseMessage[] = [
            new SystemMessage(`你是一个智能助手,可以在需要时调用工具(如 query_user)
                来查询用户信息,再用结果回答用户的问题。 
            `),
            new HumanMessage(query) // 用户的提问
        ];

步骤二:开启 Agent Loop(死循环) 大模型可能需要多次调用工具才能得出最终答案,所以我们需要一个 while(true) 循环。

        // agent loop
        while(true) {
            // 调用 stream 方法流式生成响应
            const stream = await this.modelWithTools.stream(messages);
            
            // 声明一个变量,用于把散碎的 chunk 拼接成完整的一句话
            let fullAIMessage: AIMessageChunk | null = null;

步骤三:流式消费与实时输出(Yield) 使用 for await...of 遍历异步可迭代对象。这里就是真正“流式”发威的地方!

            // 遍历流出来的每一个数据块(chunk)
            for await (const chunk of stream as AsyncIterable<AIMessageChunk>) {
                // 将每一次的 chunk 拼接累加起来,为了之后判断大模型完整的意图
                fullAIMessage = fullAIMessage ? fullAIMessage.concat(chunk) : chunk;
                
                // 判断当前的输出是否包含了工具调用(Tool Call)
                const hasToolCallChunk = !!fullAIMessage.tool_call_chunks &&
                    fullAIMessage.tool_call_chunks.length > 0;
                
                // 【核心逻辑】:如果不涉及调用工具,且 chunk 里面有内容
                if(!hasToolCallChunk && chunk.content) {
                    // 把内容 yield 出去!Controller 里的 RxJS 马上就会接住它发给前端!
                    yield chunk.content as string;
                }
            }

步骤四:检查与处理工具调用 当上面的 for await 循环结束时,说明大模型这一次的回答(或者思考)结束了。

            if(!fullAIMessage) {
                return; // 兜底保护
            }
            
            // stream 结束,此时 fullAIMessage 是一条完整的 AIMessage,必须把它加入上下文,否则大模型会“失忆”
            messages.push(fullAIMessage);

            // 空值合并运算符获取工具调用列表
            const toolCalls = fullAIMessage.tool_calls ?? [];
            
            // 如果大模型没有要求调用任何工具,说明它已经给出了最终答案,退出死循环!
            if(!toolCalls.length) {
                return ;
            }

步骤五:执行工具并继续下一轮循环

            // 发现有工具调用请求!依次处理
            for (const toolCall of toolCalls) {
                const toolCallId = toolCall.id || '';
                const toolName = toolCall.name;
                
                if(toolName === 'query_user') {
                    // 1. 解析并验证大模型给出的参数
                    const args = queryUserArgsSchema.parse(toolCall.args);
                    // 2. 真正执行我们在本地写的 Node.js 函数,查询数据库
                    const result = await queryUserTool.invoke(args);
                    
                    // 3. 将工具执行的结果包装成 ToolMessage 加入消息数组!
                    messages.push(new ToolMessage({
                        content: result, // 查询到的真实用户信息
                        tool_call_id: toolCallId, // 必须携带 ID 对应起来
                        name: toolName
                    }));
                }
            }
            // 循环回到开头!大模型将带着刚刚加入的 ToolMessage 再次思考和输出...
        }
    }

🎉 总结:万变不离其宗

看到这里,长舒一口气!😮‍💨

回顾整个代码实现,你会发现虽然它看起来挺复杂的,融合了 NestJS 的依赖注入、SSE、Zod 类型校验、Agent 工具调用……但仔细想想,这其实和我们之前学过的知识有着千丝万缕的重复与升华

唯一的也是最惊艳的变量,就是我们引入了 RxJS

通过 async * 异步生成器,我们在 Service 层完美地将大模型的文字片段按时间顺序 yield 出来;然后通过 RxJS 的 from 将这个异步迭代器平滑过渡成了响应式的 Observable 数据流;最后再经过 map 的管道加工,交由 @Sse 装饰器,一切都是那么的水到渠成,没有任何手动拼接 Response 的脏代码。

这就是工程化代码的魅力所在!模块之间职责分明,控制器只管 HTTP 协议(SSE),服务层只管业务逻辑和 LLM 交互。

希望这篇超过 4000 字的“保姆级+硬核”解析,能让你在 Agent 开发的道路上更进一步。如果你觉得有收获,别忘了点赞、收藏、转发给需要的小伙伴哦!我们下期再见!🚀🍻