LangChain 加持:后端 AI 流式对话的优雅实现

LangChain 加持:后端 AI 流式对话的优雅实现

从 mock 到后端:为什么要把 AI 调用搬到服务端

上一篇文章我们聊了通过 mock 文件在 Vite 开发服务器里转发 DeepSeek 请求。但那只是开发阶段的权宜之计。真正上线时,AI 调用必须放在后端,原因很直接:

  1. API Key 绝不能被带到浏览器。mock 文件跑在 Node.js 里,Key 是安全的;但如果前端直接调 DeepSeek,Key 就暴露在了每个用户的浏览器里
  2. 前端不应该关心 AI 模型的具体实现。换模型、调参数、加缓存,这些都是后端的事
  3. 真正的业务逻辑需要在服务端做。比如对话历史存库、内容审核、用量计费

所以我们在 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-validatorclass-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 的消息格式是两套体系。虽然它们长得很像(都有 rolecontent),但 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 的区别在于:每次迭代之间可以"等一等"。控制流是这样的:

  1. 第一次循环:DeepSeek 还没返回任何数据 → for await 挂起等待
  2. DeepSeek 生成了"你" → for await 拿到 { content: "你" } → 执行循环体
  3. 第二次循环:DeepSeek 还在算下一个字 → for await 再次挂起等待
  4. DeepSeek 生成了"好" → for await 拿到 { content: "好" } → 执行循环体
  5. ...直到 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 流式对话,本质上是三个设计决策的组合:

  1. LangChain 做抽象层ChatDeepSeek 屏蔽了 API 细节,stream() 返回异步迭代器让消费 token 变得像遍历数组一样自然
  2. 回调做解耦:Service 只管"取到 token 就传出去",Controller 只管"收到 token 就写响应",各自职责清晰
  3. for await...of 做流消费:不需要手动管理 buffer、处理半行、解码字节------这些脏活 LangChain 内部已经做了,你只需要在循环里处理每个干净的 token

如果是面试,面试官大概率会问:"stream() 返回的是什么?为什么能用 for await?回调为什么不用 return?"这三个问题,看完这篇文章你应该能从容回答了。

相关推荐
子兮曰2 小时前
Bun v1.3.14 深度解析:Image API、HTTP/3、全局虚拟存储与五十项变革
前端·后端·bun
ltl3 小时前
Self-Attention:让序列自己看自己
后端
楼兰公子3 小时前
buildroot 在编译rust时裁剪平台类型数量的方法
开发语言·后端·rust
吴声子夜歌3 小时前
Go——并发编程
开发语言·后端·golang
释怀°Believe3 小时前
Spring解析
java·后端·spring
Cosolar4 小时前
大模型应用开发面试 • 每日三题|Day 003|多Agent系统中的通信协议、冲突解决和一致性保障
人工智能·后端·面试
汪汪大队u4 小时前
续:从 Docker Compose 到 Kubernetes(2)—— 服务优化与排错
网络·后端·物联网·struts·容器
无风听海5 小时前
MapStaticAssets()深度解析:ASP.NET Core 静态资源交付的现代范式
后端·asp.net
geovindu6 小时前
go: Lock/Mutex Pattern
开发语言·后端·设计模式·golang·互斥锁模式