哈喽,掘金的各位全栈练习生们!👋 欢迎回到 AI 全栈项目实战的第十一天!
还记得我们在 Vue 3 实现流式输出 那篇文章里聊过的“流式输出”吗?那时候我们只是简单地演示了前端如何接收流。今天,我们要把这个技术真正应用到我们的全栈项目中,打造我们的第一个 AI 核心功能——智能聊天机器人 (Chatbot)!🤖
现在的 AI 应用,如果不能像 ChatGPT 那样“打字机”式地蹦字,那简直是没有灵魂的。为什么?
- 大模型特性:LLM(大语言模型)生成内容是基于 Token 的,像神经网络在“脑补”下一个字,这本身就是个流式的过程。
- 用户体验:百亿参数的模型思考是需要时间的。如果等它全部想好再返回,用户可能早就以为网断了。流式输出能让用户立刻看到反馈,体验极佳!🚀
今天,我们将使用业界最权威的 Vercel AI SDK 配合 React 前端,以及在后端(这里暂时用 Mock 模拟)手写一个能够对接 DeepSeek 大模型的流式转发服务。
话不多说,Let's Code! 💻
🧰 一、工欲善其事:引入 Vercel AI SDK
在 AI Web 开发领域,Vercel 绝对是绕不开的名字(毕竟是 Next.js 的亲爹)。他们推出的 ai SDK 几乎成了标准,封装了复杂的流式处理逻辑,让我们能专注于业务。
首先,给我们的前端项目装上这个“核武器”:
pnpm i @ai-sdk/react@1.2.12
有了它,我们就不需要手写复杂的 fetch 循环读取流了,SDK 会帮我们搞定一切。✨
🧩 二、前端实现:只需几行代码的魔法
前端部分,我们将使用 useChat 这个超级 Hook,它帮我们管理了所有的状态:输入框的值、聊天记录数组、加载状态等等。
2.1 封装 Hook:useChatBot.ts
为了让组件代码更干净,我们先封装一个自定义 Hook。
// frontend/notes/src/hooks/useChatBot.ts
import {
useChat
} from '@ai-sdk/react';
export const useChatbot = () => {
// 💡 useChat 是 Vercel AI SDK 提供的核心 Hook
// 它会自动管理 messages (聊天记录), input (输入框), isLoading (加载状态)
return useChat({
// 指定后端处理对话的 API 地址
api: '/api/ai/chat',
// 错误处理
onError: (err) => {
console.error('chatbot error', err);
}
})
}
你看,就是这么简单!我们告诉 SDK:“嘿,我的后端接口在 /api/ai/chat,请帮我打理好一切。”
2.2 聊天界面:Chat.tsx
接下来是 UI 部分。利用 shadcn/ui 的组件,我们可以快速搭建一个漂亮的聊天窗口。
import Header from '@/components/Header';
import { useChatbot } from '@/hooks/useChatBot'; // 引入我们要刚才写的 hook
import { ScrollArea } from '@/components/ui/scroll-area';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
export default function Chat() {
// ✨ 一键解构出所有我们需要的能力
const {
messages, // 聊天记录数组 [{ role: 'user', content: 'hi' }, ...]
input, // 输入框当前的值
handleInputChange, // 输入框 onChange 绑定的函数
handleSubmit, // 表单提交函数,会自动发送请求并处理流式响应
isLoading, // 是否正在等待 AI 回复
} = useChatbot();
// 提交表单时的简单校验
const onSubmit = (e:React.FormEvent) => {
e.preventDefault();
if(!input.trim()) return;
handleSubmit(e); // 🚀 发射!
}
return (
<div className="flex flex-col h-screen max-w-4xl mx-auto px-4 pb-2">
<Header title="DeepSeek Chat" showBackBtn={true}/>
{/* 📜 使用 ScrollArea 替代原生滚动条,体验更丝滑 */}
<ScrollArea className="flex-1 border rounded-lg p-4 mb-4 bg-background">
{
messages.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
Start a conversation with DeepSeek...
</div>
): (
<div className="space-y-4">
{/* 🔄 遍历渲染每一条消息 */}
{
messages.map((m, idx) => (
<div
key={idx}
// 根据角色调整气泡位置:用户在右,AI 在左
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-2 ${
m.role === 'user'
? 'bg-primary text-primary-foreground' // 用户气泡样式
: 'bg-muted' // AI 气泡样式
}`}
>
{m.content}
</div>
</div>
))
}
{/* ⏳ 加载时的思考动画 */}
{ isLoading && (
<div className="flex justify-start">
<div className="bg-muted rounded-lg px-4 py-2">
<span className="animate-pulse">...</span>
</div>
</div>
)}
</div>
)
}
</ScrollArea>
{/* ⌨️ 输入区域 */}
<form onSubmit={onSubmit} className="flex gap-2">
<Input
value={input}
onChange={handleInputChange} // SDK 帮我们自动绑定
placeholder='Type your message...'
disabled={isLoading}
className="flex-1"
/>
<Button type="submit" disabled={isLoading || !input.trim()}>
Send
</Button>
</form>
</div>
)
}
前端部分完成!是不是感觉像搭积木一样?复杂的流式数据拼接、状态更新,SDK 全在后台默默扛下了。❤️
⚙️ 三、后端实战:硬核手写流式转发
现在到了最硬核的部分。虽然我们暂时用 Mock 来模拟后端,但这里的逻辑和真实的 Node.js 服务 完全一致。我们要手动处理 HTTP 请求流,对接 DeepSeek API,并按照 Vercel AI SDK 能听懂的“方言”返回数据。
打开 frontend/notes/mock/chat.js,我们要用 rawResponse 来接管底层的 HTTP 响应。
3.1 接收数据与设置响应头
import { config } from 'dotenv';
config();
export default [
{
url: '/api/ai/chat',
method: 'post',
// ⚠️ 使用 rawResponse 才能访问底层的 req, res 对象,实现流式输出
rawResponse: async (req, res) => {
let body = '';
// 1. 📦 接收前端传来的数据
// req 是一个流,数据是一块块传来的
req.on('data', (chunk) => { body += chunk });
req.on('end', async () => {
try {
// 解析前端发来的聊天历史
const { messages } = JSON.parse(body);
// 2. 📝 设置关键响应头
res.setHeader('Content-Type', 'text/plain;charset=utf-8');
// 告诉浏览器:我要开始分块发送数据了,别急着断开
res.setHeader('Transfer-Encoding', 'chunked');
// 🚨 Vercel AI SDK 专属暗号!
// 只有带上这个头,前端 SDK 才会认为这是一个合法的流式响应
res.setHeader('x-vercel-ai-data-stream', 'v1');
// ... (接下文)
3.2 调用大模型与流式处理
接下来,我们要向 DeepSeek 发起请求,并做一个“中间人”,把 DeepSeek 的流翻译给前端。
// ... (接上文)
// 3. 🚀 呼叫 DeepSeek 大模型
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'post',
headers: {
'content-type': 'application/json',
'Authorization': `Bearer ${process.env.VITE_DEEPSEEK_API_KEY}`
},
body: JSON.stringify({
model: 'deepseek-chat',
messages,
stream: true // 🌊 关键!开启流式模式
})
});
if (!response.body) throw new Error('No response body');
// 4. 🔧 准备好管道工具
const reader = response.body.getReader(); // 读取器
const decoder = new TextDecoder(); // 解码器
while (true) {
// 一块块读取 DeepSeek 返回的数据
const { done, value } = await reader.read();
if (done) break;
// 解码二进制数据
const chunk = decoder.decode(value);
// DeepSeek 可能一次返回多行数据,按换行符切割
const lines = chunk.split('\n');
for (let line of lines) {
// 🔍 过滤出有效数据行
if (line.startsWith('data:') && line !== 'data: [DONE]') {
try {
// 去掉 "data: " 前缀,拿到 JSON
const data = JSON.parse(line.slice(6));
// 提取核心内容
const content = data.choices[0]?.delta?.content || '';
if (content) {
// 5. 📤 转发给前端
// 💡 这里的 '0:' 是 Vercel AI SDK 的协议格式
// '0' 代表这是一个文本块 (Text Part)
res.write(`0:${JSON.stringify(content)}\n`);
}
} catch (err) {
console.log('解析错误:', err);
}
}
}
}
// 👋 结束响应
res.end();
} catch (err) {
console.error('API Error:', err);
res.end();
}
});
}
}
]
🔍 深度解析:为什么要写 0:${...}?
你可能注意到了 res.write('0:' + JSON.stringify(content) + '\n') 这一行。
这是 Vercel AI Data Stream Protocol 的一部分。前端的 useChat 并不是普通的文本接收器,它期待特定的格式:
0: "Hello"-> 代表文本流的一部分。e: "error"-> 代表错误信息。d: {...}-> 代表元数据。
如果我们直接返回纯文本,SDK 可能会报错或者无法正确拼接。所以,我们在后端做一个简单的“包装”,就能完美适配前端的高级组件!
🎉 总结
今天我们完成了一次跨越:
- 原理层面:理解了 LLM 的 Token 生成机制和 HTTP 流式传输。
- 前端层面:使用
useChat极速构建了响应式聊天界面。 - 后端层面:手写了流式数据的接收、解析、转换和转发。
现在的你,已经拥有了一个能像 ChatGPT 一样逐字吐出真言的 AI 助手了!虽然现在用的是 Mock,但只要把这段代码搬到 NestJS 或 Node.js 服务中,原理是完全一样的。
这就是全栈开发的魅力,从 UI 交互到底层网络流,尽在掌握!💪
下期预告:我们的 AI 只能聊天未免太单调了。下节课,我们将探索如何让 AI 生成结构化数据,甚至帮你写代码、画图!敬请期待!
Happy Coding! 🚀