ai agent ---nest+langchain实现SSE推送信息

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 跑了一个叫 resourceschematic (代码生成器),把"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的步骤如下:

  1. AiService里面调用大模型,使用model.stream(prompt)
  2. Controller里面用 @Sse('chat/stream')写读取接口,利用Rxjs实现打印机形式打印。
  3. 前端用 const es = new EventSource(url); 接收信息,urlnest接口地址。

上面的module文件里面除了关联controller和service其他都不做。

优化后:

  1. controller里面写接口,依旧不变
  2. module 文件里面用useFacture工厂化生产model,
  3. service 文件里面里面的构造函数里面用 @inject注入ChatOpenAi 的实例。
相关推荐
IMPYLH17 分钟前
HTML 的 <nav> 元素
前端·html
IT_陈寒26 分钟前
Vue的computed属性差点让我加班到凌晨
前端·人工智能·后端
涛涛ing28 分钟前
一天写1000行被开除,一周写100行反被升职:2026前端的“工作量陷阱”
前端
IMPYLH31 分钟前
HTML 的 <meter> 元素
前端·html
奔跑的卡卡42 分钟前
不只校验单个接口:基于接口调用图谱的全链路请求合法性校验方案
前端·web安全
fxss1 小时前
免费儿童学习网站推荐!拼音、诗词、成语故事、认知启蒙,孩子需要的,这里全都有!
前端
某林2121 小时前
机器人收不住、转不动?执行器死区的原理与三层补偿设计
前端·网络·c++·架构·机器人
breeze jiang1 小时前
LangChain Memory 实战:用 InMemoryChatMessageHistory 管理多轮对话
前端·算法·langchain