LangChain 加持:后端 AI 流式对话的优雅实现
从 mock 到后端:为什么要把 AI 调用搬到服务端
上一篇文章我们聊了通过 mock 文件在 Vite 开发服务器里转发 DeepSeek 请求。但那只是开发阶段的权宜之计。真正上线时,AI 调用必须放在后端,原因很直接:
- API Key 绝不能被带到浏览器。mock 文件跑在 Node.js 里,Key 是安全的;但如果前端直接调 DeepSeek,Key 就暴露在了每个用户的浏览器里
- 前端不应该关心 AI 模型的具体实现。换模型、调参数、加缓存,这些都是后端的事
- 真正的业务逻辑需要在服务端做。比如对话历史存库、内容审核、用量计费
所以我们在 NestJS 后端用 LangChain 框架封装了 AI 对话能力。LangChain 在这里扮演的角色是"万能插座"------今天接 DeepSeek,明天换成 OpenAI 或 Claude,业务代码一行不用改。
文件结构一览
bash
backend/posts/src/ai/
├── ai.module.ts # NestJS 模块注册
├── ai.controller.ts # 路由 + SSE 响应头 + 格式转换
├── ai.service.ts # LangChain 调用核心逻辑
└── dto/
└── chat.dto.ts # 请求参数校验
数据流方向:
markdown
浏览器 → NestJS Controller → Service → LangChain → DeepSeek
│
SSE 流返回
│
浏览器 ← Controller(边收边写) ← Service(回调传 token)←┘
第一层:DTO ------ 把请求体关进"笼子"里
任何外部输入都不能信任。DTO 就是给请求体套上的一层校验笼子:
typescript
// dto/chat.dto.ts
import { IsNotEmpty, IsString, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
// 单条消息的结构约束
export class Message {
@IsString()
@IsNotEmpty()
role: string; // 'user' | 'assistant' | 'system'
@IsString()
@IsNotEmpty()
content: string; // 消息文本内容
}
// 完整请求体的结构约束
export class ChatDto {
@IsString()
@IsNotEmpty()
id: string; // 对话会话 ID,用于关联多轮对话
@IsArray()
@ValidateNested({ each: true }) // 数组中每个元素都要用 Message 规则校验
@Type(() => Message) // class-transformer 把普通对象转成 Message 实例
messages: Message[];
}
class-validator 和 class-transformer 的组合是 NestJS 的标配。@ValidateNested 确保 messages 数组里每个元素都符合 Message 的校验规则------如果有人发了一个 role 字段为空的请求,NestJS 会在 Controller 执行前就直接返回 400 错误,根本不会进业务逻辑。
第二层:Module ------ 依赖注入的装配线
typescript
// ai.module.ts
@Module({
controllers: [AIController],
providers: [AiService],
})
export class AiModule {}
NestJS 的模块系统像一个零件装配厂。你把 AIController 声明为 controller,把 AiService 声明为 provider,框架会自动把 Service 实例注入到 Controller 的构造函数里。不需要手动 new AiService(),不需要传参,NestJS 的 IoC 容器帮你搞定一切。
然后在 app.module.ts 里把这个模块装上去:
typescript
// app.module.ts
import { AiModule } from './ai/ai.module';
@Module({
imports: [PostsModule, PrismaModule, UserModule, AuthModule, AiModule],
// ...
})
export class AppModule {}
第三层:Service ------ 流式调用的心脏
这是整个后端 AI 模块最核心的代码。打开 ai.service.ts,我们逐段拆解:
3.1 模型初始化:只创建一次
typescript
@Injectable()
export class AiService {
private chatModel: ChatDeepSeek;
constructor() {
this.chatModel = new ChatDeepSeek({
configuration: {
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.DEEPSEEK_BASE_URL,
},
model: 'deepseek-v4-flash', // DeepSeek 最快的模型
temperature: 0.7, // 控制回答的随机性
streaming: true // 开启流式输出模式
});
}
}
ChatDeepSeek 是 LangChain 提供的 DeepSeek 适配器。这里有几个重要决策:
为什么在 constructor 里创建而不是每次请求时创建? 因为 ChatDeepSeek 实例是无状态的------它不存储对话历史,只是一个"调用接口的管道"。创建一次、全局复用,省去了每次请求的初始化开销。NestJS 的 Service 默认是单例的,所以这个实例在整个应用生命周期内只存在一个。
streaming: true 的作用 :这个参数告诉 LangChain,"当后续调用 stream() 方法时,真正走流式模式"。如果不设这个,stream() 方法在内部会退化成先把全部结果缓冲到内存、再一次性返回的一个伪流式------行为上和非流式一样,只是 API 名字叫 stream。
temperature: 0.7 控制生成文本的多样性。0 意味着每次回答完全一样(适合代码生成),1 意味着每次回答差异最大(适合创意写作)。0.7 是一个兼顾准确性和多样性的中间值。
3.2 消息格式转换:跨过"方言"鸿沟
前端 @ai-sdk/react 的消息格式和 LangChain 的消息格式是两套体系。虽然它们长得很像(都有 role 和 content),但 LangChain 需要的是类实例 而不是普通对象。
typescript
// ai.service.ts
export function convertToLangChainMessages(messages: Message[])
: (HumanMessage | AIMessage | SystemMessage)[] {
return messages.map(msg => {
switch (msg.role) {
case 'user':
return new HumanMessage(msg.content);
case 'assistant':
return new AIMessage(msg.content);
case 'system':
return new SystemMessage(msg.content);
default:
throw new Error(`Unsupported role: ${msg.role}`);
}
});
}
这个函数做了两件关键的事:
第一,类型实例化 。new HumanMessage(content) 不只是存储数据,它创建了一个 LangChain 能识别的消息对象。LangChain 内部根据消息的类型(Human/AI/System)来决定如何处理------比如 SystemMessage 会被放在对话的最前面作为系统指令。
第二,边界防护 。default 分支抛异常,防止未知的 role 值(比如前端版本升级新增了 role 类型)静默地导致错误行为。"fail fast" 原则:遇到不理解的数据,宁可报错也不要猜一个行为。
3.3 核心流式调用:回调解耦
typescript
async chat(messages: Message[], onToken: (token: string) => void) {
// 第一步:格式转换
const langchainMessages = convertToLangChainMessages(messages);
// 第二步:获取异步迭代器
const stream = await this.chatModel.stream(langchainMessages);
// 第三步:逐 token 消费
for await (const chunk of stream) {
const content = chunk.content as string;
if (content) {
onToken(content); // 立刻通过回调把 token 传出去
}
}
}
这段代码是整个流式输出的核心,每一行都有讲究。
this.chatModel.stream(langchainMessages)
LangChain 的 stream() 方法返回的不是普通数组,而是一个异步可迭代对象(AsyncIterable) 。它不是一个 Promise,不能 await 一次就拿结果。它是一个"承诺会一批一批给你数据"的东西。每一批就是一个 chunk,chunk 里的 content 字段通常只包含一个字或一个标点。
这里的 await 容易让人困惑:await this.chatModel.stream(...) ------ 为什么 stream() 返回的不是 Promise 却可以用 await?因为 LangChain 内部真正发起 HTTP 请求是在第一次迭代时才做的,stream() 返回的只是设置了请求参数的"准备就绪"的迭代器。await 在这里确保 stream 对象完全准备好后再进入循环。
for await (const chunk of stream)
这是 JavaScript 处理异步迭代器的标准语法。它和普通的 for...of 的区别在于:每次迭代之间可以"等一等"。控制流是这样的:
- 第一次循环:DeepSeek 还没返回任何数据 →
for await挂起等待 - DeepSeek 生成了"你" →
for await拿到{ content: "你" }→ 执行循环体 - 第二次循环:DeepSeek 还在算下一个字 →
for await再次挂起等待 - DeepSeek 生成了"好" →
for await拿到{ content: "好" }→ 执行循环体 - ...直到 DeepSeek 输出结束信号 → 循环退出
挂起不等于阻塞。 JavaScript 的事件循环在 for await 等待时可以去处理其他请求,这就是 Node.js 异步模型的优势。
回调模式 onToken(content)
为什么用回调而不是 return 一个数组?因为如果是 return,这个函数必须等所有 token 生成完才能返回,那就不是流式了。回调模式让 Service 层即收即传------收到一个字,立刻告诉调用方。
Service 不关心调用方拿这个字去干什么。Controller 想用 res.write() 发给浏览器也行,想存进 Redis 也行,想推给 WebSocket 也行。这种关注点分离是 Service 层应有的设计。
第四层:Controller ------ 把 token 灌进 HTTP 管道
typescript
// ai.controller.ts
@Controller('ai')
export class AIController {
constructor(private readonly aiService: AiService) {}
@Post('chat')
async chat(@Body() chatDto: ChatDto, @Res() res) {
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
await this.aiService.chat(chatDto.messages, (token) => {
// 每个 token 到达时立即写入响应
res.write(`0:${JSON.stringify(token)}\n`);
});
res.end();
} catch (err) {
res.status(500).end();
}
}
}
Controller 的职责很纯粹:把 HTTP 请求体的 messages 传给 Service,把 Service 回调传来的每个 token 包装成 AI SDK Data Stream 格式,res.write() 写回浏览器。
这里和 mock 文件用的是同一套格式------0:${JSON.stringify(token)}\n。这就是我们说"mock 和后端本质上做的是同一件事"的意思。数据格式一致,切换时前端一行代码都不用改。
关于三个响应头:
-
Content-Type: text/event-stream告诉浏览器这是 SSE 流,而不是普通的 HTTP 响应。不过这里其实用的是 AI SDK Data Stream 格式,而不是标准 SSE。用text/plain也一样能工作。text/event-stream更语义化一些。 -
Cache-Control: no-cache禁止浏览器和中间代理缓存响应。AI 每次的回答都不一样,缓存没有意义,还会导致用户看到旧数据。 -
Connection: keep-alive保持 TCP 连接不关闭。流式对话可能需要几秒甚至几十秒,如果连接中途断开就前功尽弃。
对比:mock 实现 vs 后端实现
| 维度 | mock/chat.js | ai.service.ts |
|---|---|---|
| 运行环境 | Vite 开发服务器 | NestJS 生产服务器 |
| AI 调用方式 | 原生 fetch | LangChain ChatDeepSeek |
| 消息格式转换 | 不需要(直接透传) | convertToLangChainMessages |
| 流消费方式 | response.body.getReader() |
for await (const chunk of stream) |
| 格式转换位置 | mock 内部 | Controller 里 |
| 可测试性 | 无 | Service 可单独单元测试 |
| 模型切换成本 | 改 fetch URL + body | 改一行 import |
后端实现最核心的优势是可测试和可维护 。Service 的 chat 方法接收一组消息和一个回调函数,不依赖任何 HTTP 概念。你在单元测试里传一组假消息和一个收集 token 的数组,就能完整验证流式逻辑,不需要启动服务器。
总结
用 LangChain 在后端封装 AI 流式对话,本质上是三个设计决策的组合:
- LangChain 做抽象层 :
ChatDeepSeek屏蔽了 API 细节,stream()返回异步迭代器让消费 token 变得像遍历数组一样自然 - 回调做解耦:Service 只管"取到 token 就传出去",Controller 只管"收到 token 就写响应",各自职责清晰
for await...of做流消费:不需要手动管理 buffer、处理半行、解码字节------这些脏活 LangChain 内部已经做了,你只需要在循环里处理每个干净的 token
如果是面试,面试官大概率会问:"stream() 返回的是什么?为什么能用 for await?回调为什么不用 return?"这三个问题,看完这篇文章你应该能从容回答了。