一篇把
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 应用后端的基本盘了。