哈喽,掘金的各位全栈练习生们! 欢迎回到 AI 全栈项目实战的第十二天!

昨天的文章里,我们像个“手工艺人”一样,用 Node.js 原生代码手搓了一个流式响应的 Mock 接口。虽然那是理解原理的必经之路,但在企业级开发中,如果我们还那样写代码,估计会被同事“甚至想打人” 。

今天,我们要穿上“钢铁侠战衣”——使用 NestJS 后端框架配合 LangChain 这一 AI 领域的“瑞士军刀”,来重构我们的 Chatbot 后端!我们将看到,如何用优雅的设计模式和强大的工具库,把昨天的“几十行面条代码”变成结构清晰、易于维护的企业级架构

话不多说,Let's Code!


前情回顾:前端的“自动驾驶”

在开始硬核后端之前,让我们快速回顾一下前端部分。得益于 Vercel 的 @ai-sdk/react,我们在前端几乎是“自动驾驶”状态。

1. 核心引擎:useChatBot Hook

文件坐标:frontend/notes/src/hooks/useChatBot.ts

这个 Hook 是我们 Chatbot 的“大脑”。它把复杂的流式数据处理、UI 状态管理全部封装了起来。

// frontend/notes/src/hooks/useChatBot.ts
import { useChat } from '@ai-sdk/react';

export const useChatbot = () => {
    //  useChat 是 Vercel AI SDK 提供的神级 Hook
    // 它帮我们做的事情:
    // 1. 自动维护 messages 数组(你的聊天记录)
    // 2. 自动处理 input 输入框的双向绑定
    // 3. 自动处理 isLoading 加载状态
    // 4. 最重要的:自动建立 SSE 连接,解析流式响应!
    return useChat({
        // 指向我们今天的后端战场(记得改成你的真实后端地址哦)
        api: 'http://localhost:3000/api/ai/chat',
        onError: (err) => {
            console.error('chatbot error', err);
        }
    })
}

2. 颜值担当:Chat.tsx

文件坐标:frontend/notes/src/pages/Chat.tsx

UI 层只需要负责“貌美如花”,逻辑全交给上面的 Hook。

// frontend/notes/src/pages/Chat.tsx
// ... (imports)

export default function Chat() {
  //  一键解构!业务逻辑与 UI 完美分离
  const { 
    messages, 
    input, 
    handleInputChange, 
    handleSubmit, 
    isLoading 
  } = useChatbot();

  return (
     // ... 渲染部分
     // 这里的代码我们在之前的文章详细讲过,核心就是遍历 messages 渲染气泡
     // 以及把 handleSubmit 绑定到表单上
  )
}

复习完毕!前端已经准备就绪,嗷嗷待哺,只等后端的奶...啊不,数据流了!


️ 后端重构:NestJS + LangChain 进阶之路

现在,让我们切换到后端视角。我们将按照 Module -> DTO -> Controller -> Service 的经典 NestJS 开发链路来构建功能。

第一步:基础设施搭建 (Module)

文件坐标:backend/posts/src/ai/ai.module.ts

在 NestJS 中,万物皆 Module。我们先创建一个 AIModule,把 Controller 和 Service 注册进去。

import { Module } from '@nestjs/common';
import { AIController } from './ai.controller';
import { AIService } from './ai.service';

@Module({
    controllers: [AIController], //  注册控制器,负责接待路由请求
    providers: [AIService],      //  注册服务,负责干脏活累活
})
export class AIModule { }

️ 第二步:守门员 (DTO)

文件坐标:backend/posts/src/ai/dto/chat.dto.ts

这是非常重要但容易被初学者忽略的一步!前端传来的数据是不可信的,我们需要用 DTO (Data Transfer Object) 配合 class-validator 来进行严格的安检。

这里有个高级技巧:嵌套验证。因为我们的聊天记录是一个数组,数组里又是对象,怎么验证呢?

import { IsString, IsArray, ValidateNested, IsNotEmpty } from 'class-validator';
import { Type } from 'class-transformer';

// 1. 先定义单条消息的结构
export class Message {
    @IsString()
    @IsNotEmpty()
    role: string; // 'user' | 'assistant' | 'system'

    @IsString()
    @IsNotEmpty()
    content: string; // 消息内容
}

// 2. 定义整体的请求体结构
export class ChatDto {
    // 会话 ID,用于上下文关联(虽然我们今天暂时没用到,但这是企业级标配)
    @IsString()
    @IsNotEmpty()
    id: string;

    @IsArray() // 必须是数组
    @ValidateNested({ each: true }) //  重点!告诉 NestJS 深入数组内部去验证每一个元素
    @Type(() => Message) //  重点!配合 class-transformer,将普通 JSON 对象转换成 Message 类的实例
    messages: Message[]; 
}

第三步:调度中心 (Controller)

文件坐标:backend/posts/src/ai/ai.controller.ts

Controller 的任务是:接收请求 -> 校验数据 -> 设置响应头 -> 呼叫 Service -> 结束响应。

对于流式输出(SSE),这里的响应头设置是关键!

import { Controller, Post, Body, Res } from '@nestjs/common';
import { AIService } from './ai.service';
import { ChatDto } from './dto/chat.dto';

@Controller('ai')
export class AIController {
  constructor(private readonly aiService: AIService) {}

  @Post('chat')
  // ️ 注意:这里我们需要直接操作底层 Response 对象,所以用了 @Res()
  // 但请注意,NestJS 中一旦使用了 @Res(),你就脱离了标准框架模式,需要自己负责响应的结束 (res.end)
  async chat(@Body() chatDto: ChatDto, @Res() res) {
    
    //  1. 设置 SSE 专用响应头
    res.setHeader('Content-Type', 'text/event-stream'); // 告诉浏览器:我是流,不是普通 JSON
    res.setHeader('Cache-Control', 'no-cache');         // 别缓存,我要最新的
    res.setHeader('Connection', 'keep-alive');          // 保持连接,别挂电话

    try {
      //  2. 调用 Service,传入回调函数
      // 这里的设计很有趣:我们把“写入响应”这个动作封装成回调传给 Service
      // 这样 Service 就不需要知道 HTTP 响应的具体细节,保持了业务逻辑的纯粹性
      await this.aiService.chat(chatDto.messages, (token: string) => {
        // Vercel AI SDK 约定的格式: "0: " + JSON字符串 + "n"
        res.write(`0: ${JSON.stringify(token)}n`);
      });

      //  3. 聊完了,挂电话
      res.end();
    } catch (err) {
      console.log(err);
      // 出错了也要优雅地断开
      res.status(500).end();
    }
  }
}

第四步:最强大脑 (Service)

文件坐标:backend/posts/src/ai/ai.service.ts

这里是真正的“魔法”发生地。我们将引入 LangChain 来对接 DeepSeek 模型。LangChain 帮我们抹平了不同大模型 API 之间的差异。

4.1 消息格式转换器

LangChain 有自己的消息格式 (HumanMessage, AIMessage, SystemMessage),我们需要写个小工具把前端传来的 JSON 转一下。

// backend/posts/src/ai/ai.service.ts (部分)
import { HumanMessage, AIMessage, SystemMessage } from "@langchain/core/messages";
// ... imports

export function convertToLangchainMessages(messages: Message[]) {
  return messages.map(msg => {
    switch(msg.role) {
      case 'user':
        return new HumanMessage(msg.content);     // 用户说的话
      case 'assistant':
        return new AIMessage(msg.content);        // AI 回复的话
      case 'system':
        return new SystemMessage(msg.content);    // 系统设定
      default:
        throw new Error(`Unknown role: ${msg.role}`);
    }
  })
}

4.2 核心业务逻辑

// backend/posts/src/ai/ai.service.ts
import { ChatDeepSeek } from "@langchain/deepseek"; // 假设这是 DeepSeek 的 LangChain 封装
// ... 其他 import

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-chat',
      temperature: 0.7, // 创造性参数
      streaming: true   //  关键!开启流式输出模式
    })
  }

  //  对话方法
  // 接收消息列表和通过回调函数 onToken 实时吐出数据
  async chat(message: Message[], onToken: (token: string) => void) {
    // 1. 转换格式
    const langchainMessages = convertToLangchainMessages(message);

    // 2. 发起流式请求
    // this.chatModel.stream 返回的是一个 AsyncIterable (异步可迭代对象)
    const stream = await this.chatModel.stream(langchainMessages);

    // 3. 消费流
    // for await...of 是处理异步流的神器!
    // 它会等待每个 chunk 数据到达后再执行循环体
    for await (const chunk of stream) {
      // LangChain 返回的 chunk 包含很多信息,我们只需要 content
      const content = chunk.content as string; // 类型断言,断定content是string类型
      
      if(content) {
        //  触发回调,把数据推回给 Controller -> 前端
        onToken(content);
      }
    }
    // 循环结束意味着流结束了
  }
}

巅峰对决:框架 vs 原生

让我们回顾一下昨天那个 mock/chat.js 里的原生实现(部分代码):

//  昨天的原生写法
const reader = response.body.getReader();
const decoder = new TextDecoder();
while(true) {
    const { done, value } = await reader.read();
    if(done) break;
    const chunk = decoder.decode(value);
    const lines = chunk.split('n');
    for(let line of lines) {
       // ... 一堆复杂的字符串解析逻辑 ...
       // ... 还要处理 JSON.parse 异常 ...
    }
}

对比现在的 LangChain 写法:

//  今天的 LangChain 写法
const stream = await this.chatModel.stream(langchainMessages);
for await (const chunk of stream) {
  onToken(chunk.content);
}

完胜!

  • 原生写法:你需要自己处理二进制流 (Uint8Array),自己解码 (TextDecoder),自己切分字符串 (split), 自己解析 JSON。任何一步出错,程序就崩了。
  • LangChain:它把底层的脏活全干了,直接给你吐出干净的字符串 content。代码量减少了 80%,可读性提升了 100%。

总结

今天我们完成了一次华丽的蜕变:

  1. NestJS DTO:用装饰器优雅地完成了复杂的数据校验,保证了接口的健壮性。
  2. SSE 响应头:理解了流式输出必须的 HTTP 协议头设置。
  3. Callback 模式:在 Service 层通过回调函数解耦了业务逻辑和 HTTP 响应。
  4. LangChain 集成:利用 stream 方法和 for await 循环,极简地实现了大模型的流式对接。

现在的 Chatbot 已经具备了“企业级”的雏形。不仅仅是能跑,而且跑得稳、代码靓!

明天,我们将继续深入,探索如何让 AI 拥有“记忆”,也就是历史对话的持久化存储。敬请期待!


思考题: 如果我想在流式输出的过程中,突然让 AI 停止生成(比如用户点了“停止”按钮),后端该如何处理这个中断信号呢?欢迎在评论区留下你的想法!

Happy Coding!

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com