1.SSE是什么?
SSE(Server-Sent Events,服务器发送事件) 是一种基于 HTTP 的服务器向浏览器单向推送技术。 SSE 让服务器可以通过一个持久的 HTTP 连接,主动向客户端持续发送数据。
SSE 是 HTML5 标准的一部分,允许浏览器通过 EventSource API 与服务器建立一条长连接,服务器可以:
- 多次发送数据
- 自动重连
- 以文本流形式推送更新
典型场景:
- 实时通知
- 股票行情
- 日志推送
- AI 流式输出(如 ChatGPT 打字效果)
优点
| 特性 | 说明 |
|---|---|
| 基于 HTTP | 无需额外协议,兼容性好 |
| 单向通信 | 服务器 → 客户端 |
| 自动重连 | 浏览器原生支持 |
| 简单轻量 | 比 WebSocket 简单 |
| 支持断点续传 | 通过 Last-Event-ID |
2.SSE vs WebSocket vs 轮询
| 对比项 | SSE | WebSocket | 轮询 |
|---|---|---|---|
| 方向 | 单向(服务器→客户端) | 双向 | 单向 |
| 协议 | HTTP | WS | HTTP |
| 连接 | 长连接 | 长连接 | 短连接 |
| 复杂度 | 低 | 中 | 低 |
| 自动重连 | ✅ | ❌ | ❌ |
| 实时性 | 高 | 很高 | 低 |
3.使用方式
1️⃣ 客户端(浏览器)
js
const source = new EventSource('http://localhost:3000/sse');
source.onmessage = (event) => {
console.log('收到消息:', event.data);
};
source.onerror = (err) => {
console.log('连接出错:', err);
};
2️⃣ 服务端(Node.js / NestJS 示例)
js
@Get('sse')
sse(): Observable<MessageEvent> {
return new Observable((subscriber) => {
subscriber.next({ data: 'Hello' });
setTimeout(() => {
subscriber.next({ data: 'World' });
subscriber.complete();
}, 2000);
});
}
结果

4.引入nest
4.1安装项目
js
npm install -g @nestjs/cli@latest
nest new hello-nest-langchain
nest g res book --no-spec --skip-install
npm install @nestjs/config
nest g res book --no-spec 这一条命令,本质是 Nest CLI 跑了一个叫 resource 的 schematic (代码生成器),把"book 这个业务模块 "的整套 REST CRUD 骨架一次性生成好,并且不生成单元测试文件。

在controller里面接受http请求,暴露接口@Controller('book')--测试前端接口就是/book。他的作用是调用service,然后返回结果。
在service里面你要写业务逻辑(增删改查数据库),数据处理,异常处理,写好被controller调用。
在module里面你写controller和service的关联信息。它相当于是controller和service的连接器,代码基本固定。

4.2配置常量
npm install @nestjs/config 是用来配置nest常量的,就是之前在dotenv里面配置的,现在都可以放到 @nestjs/config里面。
js
pnpm install @nestjs/config
创建.env文件,在文件里面配置常量
js
PORT=4000
DASHSCOPE_API_KEY=sk-你的真实key
DATABASE_HOST=localhost
DATABASE_PORT=5432
4.2.1 在 main.ts 全局注入。
import { ConfigService } from '@nestjs/config';
const config = app.get(ConfigService);
const port = config.get('PORT', 3000)
js
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = app.get(ConfigService);
const port = config.get<number>('PORT', 3000); // 没配就默认 3000
await app.listen(port);
}
bootstrap();
4.2.2 在src/app.module.ts里面挂载常量
不要在业务代码里到处写 process.env.XXX,而是注入 ConfigService。
js
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 全局可用,不用每个模块再 import
envFilePath: '.env', // 可省略,默认就是根目录 .env
// envFilePath: ['.env.local', '.env'], // 多文件时这样写,靠前优先
}),
],
})
export class AppModule {}
4.2.3 在普通的 service 文件使用。直接从class的构造函数里面拿。
js
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AiService {
constructor(private config: ConfigService) {}
someMethod() {
const key = this.config.get<string>('DASHSCOPE_API_KEY');
}
}
5.包装config
如果你不包装config,你就会写大量的 const key = this.config.get('OPENAI_API_KEY');这样的代码,很累,怎么办?
就是把所有的常量都拿出来,分装到一个对个对象里面使用。
5.1 创建文件夹 src/config/app-config.service.ts
js
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AppConfigService {
constructor(private readonly config: ConfigService) {}
get port(): number {
return this.config.get<number>('PORT', 3000);
}
get apiKey(): string {
return this.config.get<string>('OPENAI_API_KEY') || '';
}
get baseURL(): string {
return this.config.get<string>('OPENAI_BASE_URL') || '';
}
get modelName(): string {
return this.config.get<string>('MODEL_NAME') || '';
}
get gaodeMapApikey(): string {
return this.config.get<string>('GAODE_MAP_API_KEY') || '';
}
get embeddingsModelName(): string {
return this.config.get<string>('EMBEDDINGS_MODEL_NAME') || '';
}
// 一次性全拿出来
getAll() {
return {
port: this.port,
dashscopeApiKey: this.apiKey,
dashscopeBaseURL: this.baseURL,
modelName: this.modelName,
gaodeMapApikey: this.gaodeMapApikey,
embeddingsModelName: this.embeddingsModelName,
};
}
}
5.2 AiModule 里面注册
js
providers: [AppConfigService, AiService],
5.3 在AiService 里使用
js
constructor(private readonly appConfig: AppConfigService) {}
someMethod() {
const { apiKey, baseURL } = this.appConfig.getAll();
}
6.在项目里面使用@langchain
6.1在ai.service里面调用大模型
js
import { Injectable } from '@nestjs/common';
import { ChatOpenAI } from '@langchain/openai';
import { PromptTemplate } from '@langchain/core/prompts';
import type { Runnable } from '@langchain/core/runnables';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { AppConfigService } from '../config/app-config.service.js';
@Injectable()
export class AiService {
private readonly chain: Runnable;
constructor(private readonly appConfig: AppConfigService) {
const prompt = PromptTemplate.fromTemplate('请回答以下问题:\n\n{query}');
const config = this.appConfig.getAll();
const model = new ChatOpenAI({
modelName: 'qwen-max',
apiKey: config.apiKey,
temperature: 0,
configuration: {
baseURL: config.baseURL,
},
});
this.chain = prompt.pipe(model).pipe(new StringOutputParser());
}
async runChain(query: string): Promise<string> {
return this.chain.invoke({ query });
}
}
6.2 在ai.controller里面写接口
js
import { Controller, Get, Query } from '@nestjs/common';
import { AiService } from './ai.service.js';
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Get('chat')
async chat(@Query('query') query: string) {
const answer = await this.aiService.runChain(query);
return { answer };
}
}
6.3 启动项目
js
npm run start:dev

在浏览器访问接口

7 rxjs是什么?
RxJS(Reactive Extensions for JavaScript) 是一个处理异步事件流的库。
RxJS 让你用"流"的方式处理异步数据------把事件、Promise、定时器、用户输入等,统一当成"可观察的流"来操作。
Nest 底层基于 Express/Fastify,但它的很多抽象(Interceptor、Guard、SSE、WebSocket、微服务)都围绕 Observable 设计。所有Nest 大量依赖 RxJS。例如:
js
@Sse('chat/stream')
chatStream(@Query('query') query: string): Observable<{ data: string }> {
return from(this.aiService.streamChain(query)).pipe(
map((chunk) => ({ data: chunk })),
);
}
这里 @Sse() 装饰器要求返回 Observable,不是 Promise,所以必须用 RxJS 把异步流转成 Observable。
7.1RxJS 的核心概念
1️⃣ Observable = "可被订阅的流"
- Promise:只能发一个值,然后结束
- Observable:可以发多个值,可以永不结束(比如 SSE 流)
2️⃣ from() = 把其他异步类型转成 Observable
3️⃣ pipe(map(...)) = 对流的每个值做转换

js
LangChain streamChain()
↓ yield '你' / '好' / '!'(AsyncIterable)
from()
↓ Observable<'你'> → Observable<'好'> → Observable<'!'>
pipe(map(chunk => ({ data: chunk })))
↓ Observable<{data:'你'}> → Observable<{data:'好'}> → ...
@Sse() 自动序列化
↓ SSE 格式推给浏览器
data: 你
data: 好
data: !
7.2 为什么不使用promise,async/await要使用Rxjs?
因为因为 SSE 的本质是"持续推送多个值" ,而 async 函数只能 return 一次。
7.3 Rxjs的api汇总

7.4 总结
RxJS 是处理"异步数据流"的工具库。在 Nest 里,SSE / WebSocket / Interceptor 等场景需要返回 Observable,所以要用 from() 把 LangChain 的流转成 Observable,再用 pipe(map()) 加工每个 token,最后交给 @Sse() 推给前端。
8.实现SSE
8.1 在调用大模型为什么要使用SSE?
LangChain 负责"拿到流式 token",SSE 负责"把这些 token 推给浏览器"。
因为大模型里面的chain.stream() 拿到的是模型服务商返回的 token 流,这样用SSE就能够在浏览器上实现打字机的效果了。
大模型一个token,一个token往外蹦,浏览器渲染的时候也就一个一个往外写。
服务端返回的 Content-Type 是 text/event-stream,这是一个流,可以多次返回内容。


nest的SSE案例用@Sse() 装饰器
8.2 在项目里面使用SSE
8.2.1在ai.service里面添加streamChain方法。
js
import { Injectable } from '@nestjs/common';
import { ChatOpenAI } from '@langchain/openai';
import { PromptTemplate } from '@langchain/core/prompts';
import type { Runnable } from '@langchain/core/runnables';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { AppConfigService } from '../config/app-config.service.js';
@Injectable()
export class AiService {
private readonly chain: Runnable;
constructor(private readonly appConfig: AppConfigService) {
const prompt = PromptTemplate.fromTemplate('请回答以下问题:\n\n{query}');
const config = this.appConfig.getAll();
const model = new ChatOpenAI({
modelName: 'qwen-max',
apiKey: config.apiKey,
temperature: 0,
configuration: {
baseURL: config.baseURL,
},
});
this.chain = prompt.pipe(model).pipe(new StringOutputParser());
}
async runChain(query: string): Promise<string> {
return this.chain.invoke({ query });
}
async *streamChain(query: string): AsyncGenerator<string> {
const stream = await this.chain.stream({ query });
for await (const chunk of stream) {
yield chunk;
}
}
}
利用es生成器的语法,写一个方法streamChain,让这个方法能够异步给我具体的结果:chunk。
8.2.2在ai.controller里面添加方法
js
import { Controller, Get, Query, Sse } from '@nestjs/common';
import { AiService } from './ai.service.js';
import { Observable, from } from 'rxjs';
import { map } from 'rxjs/operators';
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Get('chat')
async chat(@Query('query') query: string) {
const answer = await this.aiService.runChain(query);
return { answer };
}
@Sse('chat/stream')
chatStream(@Query('query') query: string): Observable<{ data: string }> {
return from(this.aiService.streamChain(query)).pipe(
map((chunk) => ({ data: chunk })),
);
}
}
上面的 @Sse('chat/stream')里面就用到了Rxjs里面的form、pipe、map方法处理大模型给我的信息。
8.2.3测试 启动项目
js
npm run start:dev
下面就是打印机效果,每次只返回一点信息,最后拼接成一条信息。 
8.2.3写前端文件
js
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSE 流式接口测试</title>
</head>
<body>
<h1>SSE 流式接口测试</h1>
<label for="apiUrl">API 地址</label>
<input
type="text"
id="apiUrl"
value="http://localhost:3000"
placeholder="http://localhost:3000"
/>
<label for="query">问题</label>
<input
type="text"
id="query"
placeholder="例如: 什么是 LangChain?"
value="什么是 LangChain?"
/>
<button type="button" id="btn" class="primary">开始流式请求</button>
<p class="status" id="status"></p>
<div class="output" id="output"></div>
<script>
const apiUrlInput = document.getElementById('apiUrl');
const queryInput = document.getElementById('query');
const btn = document.getElementById('btn');
const output = document.getElementById('output');
const status = document.getElementById('status');
let es = null;
function closeEventSource(reason) {
if (es) {
es.close();
es = null;
}
btn.disabled = false;
if (reason) {
status.textContent = reason;
status.classList.add('error');
}
}
function setStatus(text, isError = false) {
status.textContent = text;
status.classList.toggle('error', isError);
}
btn.addEventListener('click', () => {
if (es) {
es.close();
es = null;
}
const baseUrl = apiUrlInput.value.trim().replace(/\/$/, '');
const q = queryInput.value.trim();
if (!q) {
setStatus('请输入问题', true);
return;
}
const url = `${baseUrl}/ai/chat/stream?query=${encodeURIComponent(q)}`;
output.textContent = '';
btn.disabled = true;
setStatus('连接中...');
es = new EventSource(url);
es.onopen = () => {
setStatus('已连接,接收中...');
};
es.onmessage = ({ data }) => {
output.textContent += data;
setStatus('接收中...');
};
es.addEventListener('done', () => {
closeEventSource('完成');
});
es.onerror = () => {
// readyState: 0 CONNECTING, 1 OPEN, 2 CLOSED
if (es && es.readyState === EventSource.CLOSED) {
// 正常结束或连接被关闭,恢复按钮
btn.disabled = false;
if (output.textContent) {
setStatus('连接已结束');
} else {
setStatus('连接失败/无数据,请检查后端接口、CORS 或认证', true);
}
es = null;
return;
}
setStatus('连接错误', true);
btn.disabled = false;
if (es) {
es.close();
es = null;
}
};
});
</script>
</body>
</html>
8.2.4 html文件访问nest服务
1.安装@nestjs/serve-static
@nestjs/serve-static 是 NestJS 官方提供的静态文件服务模块,本质是把 Express/Fastify 的 serve-static 中间件包成了一层 Nest 风格的 Module,让你不用手写中间件就能让 Nest 直接对外吐静态资源。言外之意就是利用live-server或者vite启动html文件后,能够访问nest服务。
js
pnpm install @nestjs/serve-static
在app.module的import里面添加ServeStaticModule,如下:
js
@Module({
imports: [
// Distributed tracing, auto-correlated logs, request/job metrics, error
// telemetry, alarms, and more --- out of the box. Sign up at https://observe.nestjs.com
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'public'),
}),
ConfigModule.forRoot({
isGlobal: true, // 全局可用
}),
BookModule,
AiModule,
],
controllers: [AppController],
providers: [AppService],
})
8.2.5启动代码
在全局安装live-server
js
npm install -g live-server
npx live-server public
如果出现跨域问题就在main文件里面添加cors的代码
js
import { NestFactory } from '@nestjs/core';
import { AppModule, ObserveInstrument } from './app.module.js';
import { ConfigService } from '@nestjs/config';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
instrument: ObserveInstrument,
});
app.enableCors();
const config = app.get(ConfigService);
const port = config.get<number>('PORT', 3000); // 没配就默认 3000
await app.listen(port);
}
await bootstrap();
之后你的页面就能访问页面如图所示:

9.优化代码
上面的代码我们是把大模型的调用放在了ai.service文件里面,如果我要调用其他模型就要重新再从初始化一次。这样做模型和业务就会绑死,怎么才能解耦呢?

解决办法:把这段代码放到ai.module.ts里面,然后用 useFactory 动态创建一个模型实例
js
// ai.module.ts
@Module({
controllers: [AiController],
providers: [
AiService,
{
provide: 'CHAT_MODEL',
useFactory: (configService: ConfigService) => {
return new ChatOpenAI({
modelName: configService.get('MODEL_NAME'),
apiKey: configService.get('OPENAI_API_KEY'),
configuration: {
baseURL: configService.get('OPENAI_BASE_URL'),
},
});
},
inject: [ConfigService],
},
],
imports: [ConfigModule],
})
export class AiModule {}
在ai.service.ts里面你直接在构造函数里面用@inject引入就好了
js
@Injectable()
export class AiService {
private readonly chain: Runnable;
constructor(@Inject('CHAT_MODEL') model: ChatOpenAI) {
const prompt = PromptTemplate.fromTemplate('请回答以下问题:\n\n{query}');
this.chain = prompt.pipe(model).pipe(new StringOutputParser());
}
async runChain(query: string): Promise<string> {
return this.chain.invoke({ query });
}
async *streamChain(query: string): AsyncGenerator<string> {
const stream = await this.chain.stream({ query });
for await (const chunk of stream) {
yield chunk;
}
}
}
9.总结
这一节主要讲了sse是什么,如何在创建nest项目,nest里面如何引入全局常量,如何在nest项目里面调用大模型,如何在前端调用nest接口,并利用Rxjs实现打印机效果,最后一步就是优化nest项目里面调用大模型的方式。
在 nest 项目里面使用SSE的步骤如下:
- 在
AiService里面调用大模型,使用model.stream(prompt) - 在
Controller里面用@Sse('chat/stream')写读取接口,利用Rxjs实现打印机形式打印。 - 前端用 const es = new EventSource(url); 接收信息,
url是nest接口地址。
上面的module文件里面除了关联controller和service其他都不做。
优化后:
- 在controller里面写接口,依旧不变
- 在module 文件里面用useFacture工厂化生产model,
- 在service 文件里面里面的构造函数里面用 @inject注入ChatOpenAi 的实例。