在传统的 HTTP 请求-响应模型中,如果你上传了一份包含 1000 行数据的 CSV 文件给 AI Agent 进行深度分析,这个过程可能需要花费 30 秒甚至更久。在这个过程中,前端只能展示一个单调的 Loading 动画,用户体验极差。
为了打破这种"黑盒"等待,并支持更现代的 AI 交互体验,我们需要在 ai-data-analyzer/backend 项目中引入流式分析 (Streaming) 、实时进度推送 (WebSocket) 以及异步任务队列。
源码地址:https://github.com/you-want/ai-data-analyzer
1. Server-Sent Events (SSE) 与大模型流式响应
当你使用 ChatGPT 时,它的回答是一个字一个字蹦出来的。这背后依赖的是流式传输技术。大语言模型(如 OpenAI、Claude、Ollama)原生都支持流式输出。
在我们的 NestJS 项目中,如果不使用 WebSocket,最轻量级的流式响应方案是 SSE (Server-Sent Events)。
1.1 在 LLM Service 中支持 Stream
首先,我们需要扩展前面定义的 ILLMService,让它支持返回流。以 OpenAI 为例:
typescript
// ai-data-analyzer/backend/src/openai/openai.service.ts
async chatStream(prompt: string): Promise<AsyncIterable<string>> {
const response = await this.openai.chat.completions.create({
model: this.configService.get<string>('OPENAI_MODEL') || 'gpt-3.5-turbo',
messages: [{ role: 'user', content: prompt }],
stream: true, // 开启流式输出
});
// 将 OpenAI 的 Stream 转换为标准的 AsyncIterable
return {
async *[Symbol.asyncIterator]() {
for await (const chunk of response) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
yield content;
}
}
}
};
}
1.2 在 Controller 中使用 @Sse 装饰器
NestJS 提供了 @Sse 装饰器,专门用于处理 Server-Sent Events:
typescript
// ai-data-analyzer/backend/src/analysis/analysis.controller.ts
import { Controller, Post, Body, Sse, MessageEvent } from '@nestjs/common';
import { Observable } from 'rxjs';
@Controller('analysis')
export class AnalysisController {
@Post('stream')
@Sse('stream') // 将该接口标记为 SSE 路由
async streamAnalysis(@Body('prompt') prompt: string): Promise<Observable<MessageEvent>> {
const stream = await this.llmService.chatStream(prompt);
return new Observable((subscriber) => {
(async () => {
try {
for await (const chunk of stream) {
// 不断向前端推送数据块
subscriber.next({ data: chunk });
}
subscriber.complete();
} catch (err) {
subscriber.error(err);
}
})();
});
}
}
前端只需要使用浏览器原生的 EventSource API,或者在 Fetch API 中解析 ReadableStream,即可实现打字机效果。
2. WebSocket 双向通信:Agent 执行进度汇报
SSE 只能实现单向(服务端到客户端)的文本流,但我们的 AI Agent 往往包含多个步骤(如:1. 理解意图 -> 2. 读取数据 -> 3. 执行工具 -> 4. 总结结果)。
对于这种复杂的交互,使用 WebSocket 进行双向通信是更好的选择。
在 NestJS 中集成 WebSocket 非常优雅,我们可以使用 @nestjs/websockets 和 socket.io。
2.1 创建 WebSocket Gateway
我们可以创建一个专门的网关来处理与前端的实时连接:
typescript
// ai-data-analyzer/backend/src/analysis/analysis.gateway.ts
import { WebSocketGateway, WebSocketServer, SubscribeMessage, MessageBody } from '@nestjs/websockets';
import { Server } from 'socket.io';
import { AnalysisService } from './analysis.service';
@WebSocketGateway({ cors: true, namespace: '/agent' })
export class AnalysisGateway {
@WebSocketServer()
server: Server;
constructor(private readonly analysisService: AnalysisService) {}
@SubscribeMessage('start_analysis')
async handleStartAnalysis(@MessageBody() payload: { data: any[]; prompt: string }): Promise<void> {
const { data, prompt } = payload;
// 模拟 Agent 的多步执行过程,并实时向前端发送进度
this.server.emit('agent_progress', { step: 1, message: '接收到任务,正在清洗数据...' });
const cleanData = this.analysisService.preprocessPayload(data);
this.server.emit('agent_progress', { step: 2, message: '数据清洗完成,正在请求大模型...' });
// 假设这里的 analyzeData 被改造成了支持回调或事件的方式
const result = await this.analysisService.analyzeData(cleanData, prompt);
this.server.emit('agent_progress', { step: 3, message: '分析完成!' });
this.server.emit('agent_result', { result });
}
}
前端只需要连接 ws://localhost:3001/agent,并监听 agent_progress 事件,就能渲染出一个非常直观的"任务执行进度条",极大地缓解了用户的等待焦虑。
3. 异步任务队列与微服务 (Microservices)
当数据量极大(例如用户上传了 10MB 的 CSV),或者平台同时涌入大量用户的分析请求时,单体 NestJS 服务器很容易因为长耗时的计算或 API 等待而被阻塞,导致其他用户的请求无响应。
解决方案是将耗时任务放入消息队列,实现异步处理和削峰填谷。
3.1 引入 BullMQ 和 Redis
在 NestJS 中,最常用的任务队列方案是基于 Redis 的 @nestjs/bullmq。
安装依赖后,我们可以在模块中配置队列:
typescript
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
BullModule.forRoot({
connection: {
host: 'localhost',
port: 6379,
},
}),
BullModule.registerQueue({
name: 'analysis-queue', // 注册一个名为 analysis-queue 的队列
}),
],
})
export class AnalysisModule {}
3.2 生产者 (Producer):将任务丢入队列
当用户通过 HTTP 接口上传文件并请求分析时,我们不再同步等待结果,而是将任务推入队列,并立即返回一个 taskId 给前端:
typescript
@Controller('analysis')
export class AnalysisController {
constructor(@InjectQueue('analysis-queue') private analysisQueue: Queue) {}
@Post('upload-async')
async uploadAndAnalyzeAsync(@Body() payload: any) {
// 1. 将数据和 prompt 推入队列
const job = await this.analysisQueue.add('analyze-job', {
data: payload.data,
prompt: payload.prompt
});
// 2. 立即响应,告知前端任务已受理
return {
message: '分析任务已提交',
taskId: job.id
};
}
}
3.3 消费者 (Consumer):后台异步处理
然后我们创建一个 Processor 来专门在后台监听队列并执行分析逻辑:
typescript
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
@Processor('analysis-queue')
export class AnalysisProcessor extends WorkerHost {
constructor(private readonly analysisService: AnalysisService) {
super();
}
async process(job: Job<any, any, string>): Promise<any> {
console.log(`正在处理任务: ${job.id}`);
const { data, prompt } = job.data;
// 执行真正的耗时分析逻辑
const result = await this.analysisService.analyzeData(data, prompt);
// 处理完成后,可以将结果存入数据库 (AnalysisResultEntity),
// 或者通过上文提到的 WebSocket 主动推送给前端
return result;
}
}
3.4 架构演进:微服务拆分
当业务进一步扩大,这个负责消费队列的 AnalysisProcessor 甚至可以被抽离成一个独立的 NestJS 微服务。
你可以启动 1 个专门处理 HTTP 请求的 API 服务,同时在后台启动 5 个运行着 AnalysisProcessor 的 Worker 服务来并行消费 Redis 队列中的分析任务。这就是标准的企业级高并发异步处理架构。
4. 测试与验证
在完成了 SSE、WebSocket 和 异步队列的代码集成后,我们可以通过以下步骤进行测试验证。
4.1 环境准备
在使用异步队列功能前,你需要确保本地运行了 Redis。如果你安装了 Docker,可以通过以下命令快速启动一个本地 Redis:
bash
docker run --name my-redis -p 6379:6379 -d redis
启动后端服务:
bash
pnpm run start:dev
4.2 测试 1:验证 SSE 流式输出
SSE 接口可以直接通过 cURL 测试,观察终端中字符是不是一段一段地输出:
bash
curl -N "http://localhost:3001/analysis/stream?prompt=test"
注意:-N 参数告诉 curl 不要缓冲输出,从而能看到实时的打字机效果。
4.3 测试 2:验证 WebSocket 连接
为了测试 WebSocket,你可以使用浏览器控制台。打开任意一个网页,按下 F12,在 Console 中输入以下代码:
javascript
// 引入 socket.io 客户端脚本
const script = document.createElement('script');
script.src = "https://cdn.socket.io/4.7.2/socket.io.min.js";
document.head.appendChild(script);
// 必须等待脚本加载完毕再执行后续逻辑!
script.onload = () => {
console.log('Socket.io script loaded!');
// 连接 WebSocket 服务
const socket = io('ws://localhost:3001/agent');
// 监听连接成功事件
socket.on('connect', () => {
console.log('Connected to WebSocket server');
// 发起分析请求
socket.emit('start_analysis', { prompt: "计算 100 + 200 的结果" });
});
// 监听进度事件
socket.on('agent_progress', (data) => {
console.log(`[进度更新] 步骤 ${data.step}: ${data.message}`);
});
// 监听结果事件
socket.on('agent_result', (data) => {
console.log(`[最终结果]:`, data.result);
});
};
你会看到控制台依次打印出"接收到任务"、"正在请求大模型"以及最终的分析结果。
4.4 测试 3:验证 BullMQ 异步队列
你可以向异步上传接口发送一个 POST 请求:
bash
curl -X POST http://localhost:3001/analysis/upload-async \
-H "Content-Type: application/json" \
-d '{
"prompt": "分析这批数据",
"data": [{"id": 1, "value": 100}, {"id": 2, "value": 200}]
}'
你会立刻收到一个类似于 {"message":"分析任务已提交","taskId":"1"} 的响应,请求没有被阻塞。
同时,观察后端控制台的日志,你会看到 AnalysisProcessor 在后台默默消费了这个任务:
[AnalysisProcessor] 正在处理后台分析任务: 1
[AnalysisService] Analyzing text: 分析这批数据
数据内容: [{"id":1,"value":100},{"id":2,"value":200}]...
[AnalysisProcessor] 任务 1 处理完成
5. 开发体验优化:解决端口占用问题 (EADDRINUSE)
在实际开发过程中,尤其是在使用 start:dev 进行热更新重启时,经常会遇到旧进程未能及时释放端口,导致新的进程启动失败并报错 Error: listen EADDRINUSE: address already in use :::3001 的问题。这会严重打断开发心流。
为了实现更自动化的开发体验,我们可以引入 kill-port 工具,在每次启动前自动清理残留进程。
5.1 安装工具
首先,在后端项目中安装 kill-port 作为开发依赖:
bash
pnpm add -D kill-port
5.2 改造 package.json 启动脚本
然后,修改 package.json 中的 scripts 节点,在启动 NestJS 服务前加入清理端口的逻辑。为了防止因为端口本来就没被占用而导致命令失败退出,我们需要加上 || true。
修改后的脚本如下:
json
{
"scripts": {
"start": "kill-port 3001 || true && nest start",
"start:dev": "kill-port 3001 || true && nest start --watch",
"start:debug": "kill-port 3001 || true && nest start --debug --watch"
}
}
5.3 测试自动化重启效果
测试步骤:
- 正常运行
pnpm run start:dev,让服务在 3001 端口跑起来。 - 此时如果你新开一个终端窗口,强行再次运行
pnpm run start:dev,原本会立刻报错EADDRINUSE。 - 但在优化后,你会看到终端首先输出
Process on port 3001 killed(杀死了旧进程),紧接着新的 NestJS 实例顺利启动,显示API listening on http://localhost:3001。
实际效果:
以后不论是因为什么原因导致的异常退出,或者频繁保存代码触发的热更新,你都不需要再手动执行 lsof -i :3001 和 kill -9 去清理僵尸进程了,极大提升了本地开发的流畅度。
下一节,我们将讨论如何保证进入和输出系统的数据质量。