🧀 让大模型的答案"流"出来:NestJS AI 模块 + SSE + RxJS 实战

一篇把 CHAT_MODEL 工厂、异步生成器、RxJS 和 @Sse 串成一条流水线的实战笔记

为什么要"流"出来

上一篇给 Agent 装好了耳朵和嘴巴,但有个角色一直没露面------"流式返回"。

道理很简单。如果大模型的答案要等全部生成完再一次性返回,用户盯着屏幕要干等好几秒,体验极差。而流式输出能做到第一个字几乎立刻出现,后面一段一段往外蹦------就像有人在对面打字回答你,而不是等一封写完整封信再寄过来。

笔记里对这个模块的定位也说得很清楚:

ai 模块是基础模块 /chat/stream 流式文本

所以今天这篇,我们就用 NestJS 从零搭一个"支持流式输出的 AI 模块",把 模块 → 服务 → 控制器 三层捋一遍,然后重点搞懂三个"流"的核心玩家:异步生成器、RxJS、SSE。

第一步:用 CLI 把模块生出来

NestJS 的 CLI 是真强大,一条命令把三件套(module / controller / service)全给你生好:

bash 复制代码
nest g module|controller|service ai --no-spec

--no-spec 是"别给我生成测试文件",追求干净利落。笔记里还对比了另一种生成方式:

nest g resource xxx --no-spec 是资源的增删改查(表 → CRUD),而 ai 模块没有 CRUD,所以用 nest g module|controller|service ai --no-spec。

一句话记住区别 :要标准 CRUD 就用 nest g resource;像 AI 模块这种"只有接口没有表"的,用三件套一条龙更干净。

环境变量这边,用 @nestjs/config 统一管理:

bash 复制代码
pnpm i @nestjs/config

别小看这一步,它是后面"工厂模式读配置"的伏笔。

第二步:把 LLM 做成一个"自定义 Provider"

这段是整个 AI 模块里最有设计感的部分。先看 ai.module.ts:

typescript 复制代码
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { ConfigService } from '@nestjs/config';
import { ChatOpenAI } from '@langchain/openai';

@Module({
  controllers: [AiController],
  // 配置模块可以使用的服务
  providers: [
    // 引入
    AiService,
    // 自定义
    {
      provide: 'CHAT_MODEL', // llm 和service 藕解 好维护,方便切换模型
      // 工厂模式
      useFactory: (configService: ConfigService) => {
        console.log(configService.get('MODEL_NAME'))
        return new ChatOpenAI({
          modelName: configService.get('MODEL_NAME'),
          apiKey: configService.get('OPENAI_API_KEY'),
          configuration: {
            baseURL: configService.get('OPENAI_BASE_URL'),
          }
        })
      },
      // 依赖.env ,注入 ConfigService
      inject: [ConfigService],
    }
  ]
})
export class AiModule {}

为什么要费劲写成"自定义 provider",而不是直接在 Service 里 new ChatOpenAI()?注释里那句就是答案:

llm 和 service 解耦,好维护,方便切换模型。

把 LLM 实例的创建"外包"给模块,好处有两个:

  • 统一管理:全局就一个地方管模型怎么建,改配置只改一处;
  • 方便切换:哪天想把 OpenAI 换成别的模型,Service 代码一行不用动。

这里的写法,正好是 NestJS 依赖注入里最经典的三个概念,一个个说:

① provide: 'CHAT_MODEL' ------ 给这个 provider 起了个"身份证号"(一个字符串 token)。以后谁想用它,就报这个号(后面 Service 里的 @Inject('CHAT_MODEL') 就跟它对接)。

② useFactory ------ 工厂模式。它的意思是"这个 provider 的值,由这个函数执行后的返回值决定 "。也就是说,函数 return 出来的那个 ChatOpenAI 实例,就是 'CHAT_MODEL' 对应的东西。

③ inject: [ConfigService] ------ 告诉 Nest "我这个工厂函数需要一个 ConfigService 参数,请帮我注入进来"。而 ConfigService 读的正是 .env 里的 MODEL_NAME、OPENAI_API_KEY、OPENAI_BASE_URL。

useFactory + inject 是天生一对 :工厂负责"造对象",inject 负责"喂原料"。所以那段 console.log(configService.get('MODEL_NAME')) 也不是多余的------项目一起来看不到这一行,你都不知道模型名到底读没读对。

第三步:Service 把"题"和"模型"串成链

Provider 备好了,看 ai.service.ts 怎么用:

typescript 复制代码
import { Injectable, Inject } from '@nestjs/common';
import { ChatOpenAI } from '@langchain/openai';
import type { Runnable } from '@langchain/core/runnables';
import { PromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';

@Injectable()
export class AiService {
    private readonly chain: Runnable;
    // 自动注入 ChatOpenAI 实例 ,用于调用模型
    constructor(
        @Inject('CHAT_MODEL') model: ChatOpenAI
    ) {
        const prompt = PromptTemplate.fromTemplate(`
        请回答以下问题:\n\n {query}    
        `);
        this.chain = prompt.pipe(model).pipe(new StringOutputParser());
    }

两个关键点:

① 用 @Inject('CHAT_MODEL') 把模型取进来。 前面 provide 里起的那个号,这里原样对上。@Inject 是"按 token 注入",普通 provider 类型可以直接靠类型推断,但字符串 token 必须显式写 @Inject。

② 用 .pipe() 把三段拼成一条链。 这是 LangChain 的"管道"写法,读起来像乐高:

复制代码
prompt(套模板) → model(问模型) → StringOutputParser(把输出转成纯字符串)

最终结果是一条 Runnable 链,存进 this.chain。{query} 是模板里的占位符,一会儿真正提问时会被填进去。

真正"流"起来的是这个生成器函数:

typescript 复制代码
    // 生成器函数 Generator Function
    // async 函数只会返回一次
    // 多次返回,不断地返回
    async *streamChain(query: string) { // * 代表异步生成器函数
        // 流式输出
        const stream = await this.chain.stream({ query });
        console.log(stream);
        for await (const chunk of stream) {
            yield chunk; // 可以用yield(产出) 一段一段地返回数据分片
        }
    }
}

注意那个星号 async *streamChain------这就是"异步生成器函数"。它和普通函数的区别,注释里一句话点破:

async 函数只会返回一次;生成器可以多次返回,不断地返回。

关键在 yield 这个词("产出"):函数执行到这里,先把这一小段数据"吐"出去,然后暂停 ,等外界再要,再继续往下吐。配合 for await 遍历模型的流式输出,就实现了"模型蹦一个字,我转手就吐一个字"。

插播:到底什么是"异步生成器"

streamChain 能一段段返回的底气,来自 JS 的生成器。有个最简单的小例子(ytest.js)能把它讲明白:

javascript 复制代码
// 生成器函数
async function* produce() {
    yield "第一段";
    await new Promise(r => setTimeout(r, 1000));
    yield "第二段";
    await new Promise(r => setTimeout(r, 1000));
    yield "第三段";
}

(async () => {
    // 可迭代的生成器对象
    // const item = produce();
    // 每次可以获取到一个值
    // 比async/await 只能一次返回
    // console.log(await item.next());
    // console.log(await item.next());
    // console.log(await item.next());
    // console.log(await item.next());
    for await (const item of produce()) {
        console.log(item);
    }
})()

produce() 里连着三个 yield,每句之间 await 睡 1 秒。它有两种"消费"姿势:

  • 手动 .next()(代码里注释掉的那几行):一次要一个,像手动拧水龙头,拧一下出一滴;
  • for await...of(正式用的):自动一个个取,直到取完为止。

这就是"流式"最朴素的实现 :不是把一堆数据攒成一个数组一次性给你,而是要一个给一个。大模型的流式输出,本质就是这么回事。

第四步:RxJS 登场,把生成器"翻译"成可观察流

NestJS 的 @Sse 要的返回值不是生成器,而是 RxJS 的 Observable。所以我们还得请出 RxJS 来当"翻译官"。

先看它的定位(rxjs-demo.mjs):

javascript 复制代码
// RxJS 就是一套用来**优雅管理各种异步数据流**的工具
// 网络请求(文本流)、定时器(setTimeout,setInterval)、事件流(mousemove,点击、滚动等)、
import {
    from, // 从可迭代对象创建 Observable 观察流式处理对象
    map, // 映射操作符,用于对每个数据进行进行转换
    tap, // 侧效果操作符,用于在数据流中执行一些 side effect 操作
} from 'rxjs'

RxJS 管的就是各种异步数据流:网络请求、定时器、鼠标移动/点击/滚动这类事件流......凡是"会随时间陆续产生一堆数据"的东西,它都管。

最小可运行例子:

javascript 复制代码
// 可迭代表示多次执行
// 可观察的流
const stream = from([1, 2, 3]) // rxjs 将数据变成流式处理对象
// 订阅流
// stream.subscribe(v => console.log(v))
// 一系列的操作
stream
    .pipe(
        map(x => x * 2)
    )
    .pipe(
        map(x => x * x)
    )
    .subscribe(v => console.log(v))

三个概念要记牢:

  • from([1, 2, 3]) ------ 把普通数组/可迭代对象,变成一个 Observable(可观察的流);
  • .pipe(map(...)) ------ 对流转过的每个数据做变换;这里先 x * 2 再 x * x,所以 1,2,3 会变成 4,16,36;
  • .subscribe(v => ...) ------ 订阅。RxJS 是"冷"的,不订阅就不干活,一订阅数据才开始流、回调才开始跑。

顺便一提,tap 也是个好用的操作符------它只做"副作用"(比如打日志),不改变数据本身,专门用来偷看流里流的是什么。

第五步:Controller 用一个 @Sse 把一切收口

最后一步,看 ai.controller.ts 怎么把 Service 的生成器和 RxJS 接起来:

typescript 复制代码
import { Controller, Sse, Query } from '@nestjs/common';
import { AiService } from './ai.service';
import {
  from,
  map,
  Observable
} from 'rxjs'

@Controller('ai')
export class AiController {
    // 自动注入 AiService 实例 ,用于调用服务层的方法
    constructor(private readonly aiService: AiService) {}
    // 装饰器模式,定义路由/ai/chat/stream,用于处理流式输出
    @Sse('chat/stream') // 长连接
    chatStream(@Query('query') query: string) {
        // 持续输出
        console.log(query);
        // streamChain 是aiService中的生成器函数
        // 执行后得到生成器对象 可迭代对象
        // rxjs 将可迭代对象 转换为Observable 观察流式处理的对象
        return from(this.aiService.streamChain(query))
            .pipe(
                // sse 和前端约定的格式
                map(chunk => ({ data: chunk }))
            )
    }
}

这段不长,但每一行都卡在关键位置上:

① @Sse('chat/stream') ------ NestJS 对 SSE(Server-Sent Events)的封装,路由就是 /ai/chat/stream。注释写的是"长连接",很准确:SSE 本质就是一条服务器→客户端的持久连接。

② chatStream(@Query('query') query) ------ 用 @Query 取 URL 上的查询词,所以前端请求长这样:/ai/chat/stream?query=你好。

③ from(this.aiService.streamChain(query)) ------ 这里是关键的"翻译"动作 :streamChain(query) 执行后得到的是一个生成器对象(可迭代对象) ,而 RxJS 的 from 正好能把可迭代对象转成 Observable 。一个 from,就把上一节的生成器和这一节的 RxJS 接上了。

④ .pipe(map(chunk => ({ data: chunk }))) ------ SSE 跟前端有约定的数据格式(注释里写"sse 和前端约定的格式 "),得把每个 chunk 包装成 { data: chunk } 再推出去。前端 EventSource 收到时,event.data 就是这段内容。

把整条链路串起来看

现在把三层串起来,一次请求的完整旅程是这样的:

环节 角色 干了什么
前端 EventSource 请求 /ai/chat/stream?query=...,保持长连接
Controller @Sse('chat/stream') 接收请求,from(...) + map(...) 转成 SSE 格式
Service streamChain() 生成器 chain.stream() 调模型,for await + yield 一段段吐出分片
模型链 prompt → model → parser 套模板、问模型、把输出转成纯字符串

整条链路最妙的地方在于三层各司其职,谁也不越界:

  • 模块负责"造模型"(工厂模式 + 配置注入);
  • 服务负责"管流程"(生成器 + 链式调用);
  • 控制器 负责"接接口"(@Sse + RxJS 转换)。

而把这几层缝在一起的,恰好是三个"流"的概念:生成器负责"产"数据,RxJS 负责"转"数据,SSE 负责"送"数据。

小结

到这儿,一个支持流式输出的 NestJS AI 模块就搭完了。回头看,其实就三件事:

  • 模型不用你在 Service 里手动 new ,交给 provide: 'CHAT_MODEL' + useFactory + inject 工厂三件套,解耦又省心;
  • 数据要一段段出 ,靠 async * 生成器 + yield + for await;
  • 接口要持续推 ,靠 @Sse 装饰器 + RxJS 的 from / map。

再结合上一篇,你就能拼出完整的语音 Agent 全景图了:

语音走 WebSocket(二进制友好),文字走 SSE(文本友好);ASR 负责听,TTS 负责说,生成器负责"流",RxJS 和 @Sse 负责"送"。

这套"听得见、说得清、还能源源不断往外蹦字"的组合拳,差不多就是现代 AI 应用后端的基本盘了。

相关推荐
Teable任意门互动1 小时前
优锘科技北京客户管理数字化落地案例|HAP明道云搭建客户管理平台-任意门互动科技(HAP 官方实施服务商)
大数据·人工智能·科技·信息可视化·信息与通信
mbshqqb1 小时前
TypeScript基础教程之同步生成器
前端·javascript·typescript
Smoothcloud润云1 小时前
PyTorch推理请求超时与取消处理实战
人工智能·pytorch·python·深度学习·机器学习·gpu算力·gpu
阿里云大数据AI技术1 小时前
在 EMR Serverless Spark 的 Ray 作业中使用增强自适应弹性扩容机制
人工智能·spark
虾..1 小时前
计算机图形学:光栅化
人工智能
ACME20441 小时前
48㎡、57㎡、109㎡:三间铺分别适合开什么店
人工智能
豆包MarsCode1 小时前
✍️ TRAE 双端融合,全面升级|征文活动开启,赢京东卡、TRAE 精美周边好礼!
人工智能
weixin_448119941 小时前
Datawhale计算机中的数学#MCS #Mathematics #Maths #Yibo #翊博 #翊博在这里 #菲尔兹奖 #yibohere
人工智能