全栈 AI Chatbot 实战:从后端流式转发到前端即时响应
在当今 AI 驱动的应用开发中,构建一个类似 ChatGPT 的对话界面已成为“Hello World”级别的必修课。然而,要实现一个丝滑的、具备打字机效果的聊天机器人,并非简单的 API 调用,而是涉及 前后端流式数据传输(Streaming) 、 服务端代理(Proxying) 以及 前端状态管理 的综合工程。
本文将通过模块化的方式,拆解一个全栈 AI Chatbot 的核心实现流程,带你深入理解数据是如何从 DeepSeek 大模型流向用户屏幕的。
后端部分(Mock 模拟)
后端的职责非常明确:它是一个 “流式代理中间件” 。
它需要同时维护两条连接:
- 左手与前端保持长连接,
- 右手与大模型保持流式连接,
并在中间进行数据的清洗和格式转换。
定义路由以及处理请求
当用户向 Chatbot 发送请求时,后端需要接收前端的请求体(包含用户消息),并将其转发给大模型。
import { config } from 'dotenv'
config();
export default [
{
url: '/api/ai/chat',
method: 'post',
rawResponse: async (req, res) => {
let body = '';
req.on('data', (chunk) => { body += chunk })
req.on('end', async () => {
try {
const { messages } = JSON.parse(body);
// ... 后续逻辑
} catch (err) {
res.end();
}
})
}
}
]
核心代码与逻辑:
-
rawResponse
使用rawResponse就像是从“自动挡”切换到了“手动挡”。
虽然你需要自己处理req.on('data')和res.end()等底层琐事,但它给了你 控制时间 的能力——让你能够决定 什么时候 发送数据,以及 分多少次 发送数据。这正是实现打字机效果的唯一途径。 -
req.on()
这就是一个监听事件:'data'事件:当请求体有数据到达时触发。这里每当有数据传输就进行拼接。'end'事件:当请求体数据接收完毕时触发。表示用户的问题已经接收完全,可以开始调用大模型了。
-
res.end()
因为我们使用rawResponse获得了绝对的控制权,所以当大模型返回完所有数据后,需要手动调用res.end()来结束响应。
通过字符串拼接,将二进制数据转化为字符串,因此可以通过
JSON.parse(body)将字符串解析为 JSON 对象。
流式调用大模型 API
这里负责调用大模型的 API,获取流式响应。
// ... 在 req.on('end') 内部
res.setHeader('Content-Type', 'text/plain;charset=utf-8')
res.setHeader('Transfer-Encoding', 'chunked')
res.setHeader('x-vercel-ai-data-stream', 'v1')
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: messages,
stream: true
})
});
核心代码与逻辑:
-
三个
res.setHeader-
Content-Type: text/plain;charset=utf-8- 作用:声明货物类型。
- 解释:告诉浏览器“我发给你的是纯文本,不是 HTML 网页,也不是图片”。如果不写
charset=utf-8,中文可能会变成乱码。
-
Transfer-Encoding: chunked- 作用:声明发货方式(分批次)。
- 解释:这是流式传输的开关。它告诉浏览器:“这个包裹太大(或者我还没生产完),我无法在发货前告诉你总重量(
Content-Length)。我会切成一块一块地发给你,直到我说发完了为止。” - 后果:如果不写这个,浏览器可能会一直等到服务器把所有数据都准备好(
res.end)才开始显示内容,那就没有打字机效果了。
-
x-vercel-ai-data-stream: v1- 作用:对暗号。
- 解释:这是前端使用的 Vercel AI SDK(
useChat)特有的自定义协议头。前端 SDK 收到响应时,会检查有没有这个头。如果有,它就知道:“哦!这是自家兄弟发的流式数据,格式我懂(0:"xxx"),我可以自动解析它。” - 后果:如果不写,前端 SDK 可能会认为这是一个普通的文本响应,导致无法正确解析流内容或者报错。
-
-
const response = await fetch(...)
调用大模型 API。
SSE 数据清洗与实时转发
将大模型回复生成的数据进行流式的清洗与转发。
// ... 接上文
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) {
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
try {
const data = JSON.parse(line.slice(6));
const content = data.choices[0]?.delta?.content || '';
if (content) {
res.write(`0:${JSON.stringify(content)}\n`)
}
} catch (err) {}
}
}
}
核心代码与逻辑:
-
const reader = response.body.getReader();
用于流式读取数据的对象。 -
const decoder = new TextDecoder();
因为大模型返回的是二进制数据,所以需要使用TextDecoder来将其解码为文本。 -
const { done, value } = await reader.read();
每次读取数据块,done表示是否读取完毕,value表示读取到的二进制数据。 -
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
将每次读取的数据块进行解码与分块。 -
for(){...}—— 数据的清洗
通过解码我们已经能够拿到数据,但是要想像聊天一样获得纯文本,就必须经过数据清洗:if (line.startsWith('data: ') && line !== 'data: [DONE]')
大模型返回的每个数据块以data:开头代表的就是我们需要的数据;当输出[data: [DONE]]时,代表大模型已经返回完所有数据。通过条件判断当前数据块是我们的有效数据。const data = JSON.parse(line.slice(6));
切割掉每条数据前的data:,将剩余的字符串解析为 JSON 对象。const content = data.choices[0]?.delta?.content || '';
拿到每次新增的内容字段(delta.content)。res.write(0:${JSON.stringify(content)}\n)
将新增的字段内容转发给前端。
前端部分
这里负责接收后端转发的流式数据,并将其显示在前端的聊天框中。
useChat
useChat 是 Vercel AI SDK 提供的一个 React 钩子函数,用于在 React 组件中调用大模型 API 并处理流式响应。
import { useChat } from '@ai-sdk/react'
export const useChatbot = () => {
return useChat({
api: '/api/ai/chat',
onError: (err) => {
console.error("Chat Error:", err);
}
})
}
它带来的便利主要体现在以下三个方面:
1. 自动化的状态管理(省去了手动维护数组)
-
痛点:
如果不使用useChat,你需要自己创建一个messages数组状态。每当用户发消息,你要手动push进去;每当 AI 回复,你又要手动更新最后一条消息。 -
便利:
useChat自动维护messages。- 用户发消息 → 自动追加到列表。
- AI 流式回复 → 自动拼接到最后一条消息里,无需你写任何拼接逻辑。
2. 封装了复杂的流式网络请求(省去了手写 fetch 和 reader)
-
痛点:
手动处理流式响应非常麻烦。你需要写fetch,获取reader,写while循环读取流,用TextDecoder解码,还要处理 JSON 解析和错误。 -
便利:
useChat内部已经写好了全套的流处理逻辑。- 你只需要配置一个
api: '/api/ai/chat'。 - 它会自动发起请求,自动监听数据流,自动解析后端发来的
0:"xxx"格式。
- 你只需要配置一个
3. 开箱即用的 UI 交互逻辑(省去了写受控组件)
-
痛点:
你需要自己处理输入框的onChange,自己处理表单的onSubmit,还要防止用户在生成过程中重复点击发送。 -
便利:
- 提供
input和handleInputChange:直接绑定到输入框,实现双向绑定。 - 提供
handleSubmit:直接绑定到表单,自动处理提交。 - 提供
isLoading:自动告诉你 AI 是否正在生成,方便你禁用按钮或显示 Loading 动画。
- 提供
UI 交互与渲染
通过 Vercel AI SDK 提供的 useChat 钩子函数,我们可以很方便地实现聊天界面的交互与渲染。我们只需要关心页面设计,其他的都交给 useChat 打理。
import { useChatbot } from '@/hooks/useChatBot';
// ... UI 组件导入
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChatbot();
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
handleSubmit(e);
}
return (
<div className="...">
{/* 消息列表区域 */}
<ScrollArea className="...">
{messages.map((m, idx) => (
<div key={idx} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`... ${m.role === 'user' ? 'bg-primary' : 'bg-muted'}`}>
{m.content}
</div>
</div>
))}
</ScrollArea>
{/* 输入区域 */}
<form onSubmit={onSubmit} className="flex gap-2">
<Input
value={input}
onChange={handleInputChange}
disabled={isLoading}
/>
<Button type="submit" disabled={isLoading}>Send</Button>
</form>
</div>
)
}
核心代码和逻辑:
- Optimistic UI(乐观更新) :
当你调用handleSubmit的瞬间,你的消息就会立即出现在messages列表中,无需等待服务器响应。这提供了极佳的流畅感。 - 数据驱动视图:
注意看{m.content}这一行。我们没有写任何定时器或手动 DOM 操作来实现打字机效果。一切都是响应式的:
后端推来一个字 → SDK 更新messages状态 → React 检测到状态变化 → 重新渲染组件 → 界面上多出一个字。 - 受控组件:
<Input>的value和onChange直接绑定了 SDK 提供的input和handleInputChange。这意味着输入框的状态管理权也完全移交给了 SDK,减少了我们自己写useState的冗余代码。
总结:数据流的全景图
构建这个 AI Chatbot,本质上是搭建了一条从用户到 AI 再回到用户的 高速数据管道:
- 触发:用户点击发送,
useChat携带历史消息发起请求。 - 中转:后端
req.on接收数据,向 DeepSeek 发起stream: true的请求。 - 生成:DeepSeek 逐个生成 Token。
- 清洗:后端
reader截获 Token,去除 SSE 外壳。 - 推送:后端
res.write将 Token 实时推回前端。 - 渲染:前端 SDK 接收 Token,更新状态,React 刷新界面。
通过这种 模块化、分层 的设计,我们不仅实现了酷炫的打字机效果,更保证了系统的可维护性和扩展性。