二次元绘画创作
56.21M · 2026-02-04
哈喽,掘金的各位全栈练习生们! 欢迎回到 AI 全栈项目实战的第十二天!
昨天的文章里,我们像个“手工艺人”一样,用 Node.js 原生代码手搓了一个流式响应的 Mock 接口。虽然那是理解原理的必经之路,但在企业级开发中,如果我们还那样写代码,估计会被同事“甚至想打人” 。
今天,我们要穿上“钢铁侠战衣”——使用 NestJS 后端框架配合 LangChain 这一 AI 领域的“瑞士军刀”,来重构我们的 Chatbot 后端!我们将看到,如何用优雅的设计模式和强大的工具库,把昨天的“几十行面条代码”变成结构清晰、易于维护的企业级架构。
话不多说,Let's Code!
在开始硬核后端之前,让我们快速回顾一下前端部分。得益于 Vercel 的 @ai-sdk/react,我们在前端几乎是“自动驾驶”状态。
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);
}
})
}
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 绑定到表单上
)
}
复习完毕!前端已经准备就绪,嗷嗷待哺,只等后端的奶...啊不,数据流了!
现在,让我们切换到后端视角。我们将按照 Module -> DTO -> Controller -> Service 的经典 NestJS 开发链路来构建功能。
文件坐标: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 { }
文件坐标: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[];
}
文件坐标: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();
}
}
}
文件坐标:backend/posts/src/ai/ai.service.ts
这里是真正的“魔法”发生地。我们将引入 LangChain 来对接 DeepSeek 模型。LangChain 帮我们抹平了不同大模型 API 之间的差异。
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}`);
}
})
}
// 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);
}
}
// 循环结束意味着流结束了
}
}
让我们回顾一下昨天那个 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。任何一步出错,程序就崩了。content。代码量减少了 80%,可读性提升了 100%。今天我们完成了一次华丽的蜕变:
stream 方法和 for await 循环,极简地实现了大模型的流式对接。现在的 Chatbot 已经具备了“企业级”的雏形。不仅仅是能跑,而且跑得稳、代码靓!
明天,我们将继续深入,探索如何让 AI 拥有“记忆”,也就是历史对话的持久化存储。敬请期待!
思考题: 如果我想在流式输出的过程中,突然让 AI 停止生成(比如用户点了“停止”按钮),后端该如何处理这个中断信号呢?欢迎在评论区留下你的想法!
Happy Coding!