导语:
在开发 AI 应用时,最糟糕的用户体验莫过于点击发送后,屏幕一片死寂,直到模型“憋”出几百个字才瞬间弹出。真正的 AI 交互应该是像打字机一样,逐字逐句地流淌出来。
今天,我们将结合 NestJS 的优雅架构与 LangChain 的强大能力,深度剖析如何构建一个企业级的流式对话服务。我们将核心业务逻辑与传输协议彻底解耦,实现高性能、高可维护的 AI 接口。
1. 核心痛点与架构设计
🤔 为什么需要流式(Streaming)?
大模型(LLM)的本质是一个巨大的神经网络,它生成文本的方式是**自回归(Autoregressive)**的——即基于上一个 Token 预测下一个 Token。
- 传统模式:等待所有 Token 生成完毕(Output),一次性返回 HTTP 响应。用户等待时间长,体验差。
- 流式模式:模型每生成一个 Token,后端立即通过 HTTP Chunked 或 SSE 推送给前端。实现“边算边给”。
🏗️ 架构分层
为了保证代码的健壮性,我们将项目分为三层:
- DTO 层:数据校验,把好入口关。
- Controller 层:协议处理,负责 HTTP 流(SSE)的建立与写入。
- Service 层:核心业务,负责与 LLM 交互并处理 Token 流。
2. DTO 层:强类型校验的基石
在 NestJS 中,我们利用 class-validator 确保前端传来的数据是规范的。这是防止脏数据进入系统的第一道防线。
代码切片解析:
// dto/chat.dto.ts
export class Message {
@IsString()
@IsNotEmpty()
role: string; // 'user' | 'assistant'
@IsString()
@IsNotEmpty()
content: string;
}
export class ChatDto {
@IsString()
@IsNotEmpty()
id: string; // 对话唯一ID
@IsArray()
@ValidateNested({ each: true })
@Type(() => Message)
messages: Message[]; // 对话历史数组
}
💡 核心点:
- 使用
@ValidateNested和@Type装饰器,NestJS 会自动将 JSON 请求体反序列化为Message对象数组,无需手动JSON.parse类型断言。
3. Service 层:业务逻辑的核心(LangChain 魔法)
这是最核心的一层。我们将 AI 的业务逻辑完全剥离出来,使其不依赖于 HTTP 协议。Service 只关心“模型怎么调用”和“数据怎么流转”。
代码切片解析:
// ai.service.ts
@Injectable()
export class AIService {
private chatModel: ChatDeepSeek;
constructor() {
// 1. 初始化 DeepSeek 模型
this.chatModel = new ChatDeepSeek({
configuration: {
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.DEEPSEEK_BASE_URL
},
model: 'deepseek-chat',
temperature: 0.7,
streaming: true // 🔥 关键开关:开启流式传输
});
}
// 2. 核心 Chat 方法:接受消息和回调函数
async chat(messages: Message[], onToken: (token: string) => void) {
const langChainMessages = convertToLangChainMessages(messages);
// 3. 获取流式响应
const stream = await this.chatModel.stream(langChainMessages);
// 4. 监听每一个 Token 块
for await (const chunk of stream) {
const content = chunk.content as string;
if (content) {
onToken(content); // 🚀 将生成的 Token 通过回调抛出
}
}
}
}
💡 核心点:
streaming: true:这是让模型“边生成边吐数据”的关键。for await...of:因为流是异步生成器(Async Generator),必须用此语法监听。onToken回调:这是解耦的关键。Service 层不直接操作 HTTP 响应,而是通过回调函数将数据“扔”给上层(Controller)处理。
4. Controller 层:SSE 协议的守门人
Controller 层的任务非常明确:建立 HTTP 长连接,将 Service 层产生的 Token 转换成浏览器能识别的 SSE 格式。
代码切片解析:
// ai.controller.ts
@Post('chat')
async chat(@Body() chatDto: ChatDto, @Res() res) {
// 1. 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
// 2. 调用 Service,并传入“写入响应”的逻辑
await this.aiService.chat(chatDto.messages, (token: string) => {
// 3. 按照特定格式写入流
// 格式:0:{content}\n
res.write(`0:${JSON.stringify(token)}\n`);
});
res.end(); // 结束
} catch (err) {
res.status(500).end();
}
}
💡 核心点:
- SSE 头部:
text/event-stream告诉浏览器这是一个流,不要缓存。 res.write:这是 Node.js 原生的流式写入方法。每生成一个 Token,就调用一次 write,数据立即推送到前端。- 格式约定:
0:${JSON.stringify(token)}\n。这里的0:是为了兼容某些前端 SDK(如 Vercel AI SDK)的解析规范,\n是流的分隔符。
5. 全链路数据流复盘
让我们串一下整个流程,感受一下数据是如何“流动”的:
- 用户请求 -> HTTP POST
/ai/chat(携带 messages) - DTO 校验 -> NestJS 自动校验 JSON 格式。
- Controller 接手 -> 设置好 HTTP 响应头,准备“接水”。
- Service 发力 -> LangChain 调用 DeepSeek API,开启
streaming模式。 - Token 生成 -> 模型生成第一个 Token "Hello"。
- 回调触发 -> Service 调用传入的
onToken回调。 - 写入响应 -> Controller 的
res.write立即将 "Hello" 推送给浏览器。 - 循环往复 -> 重复 5-7 步,直到模型生成结束。
6. 总结与思考
这种架构最大的优势在于解耦。AIService 根本不知道 HTTP 的存在,它只负责“生成 Token”。这意味着:
- 可测试性:你可以单独测试 Service,传入一个 Mock 的回调函数即可。
- 可扩展性:如果你想把 HTTP 换成 WebSocket,只需要改 Controller,Service 代码一行都不用动。
面试加分项:
如果被问到“如何处理流式输出中的错误?”,你可以回答:在 Controller 层使用 try-catch 包裹,并在 req.on('close') 中监听客户端断开连接,及时释放后端资源,防止内存泄漏。