全栈实现流式 AI 聊天机器人:从后端代理到前端实时渲染

16 阅读4分钟

全栈 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

    1. Content-Type: text/plain;charset=utf-8

      • 作用:声明货物类型。
      • 解释:告诉浏览器“我发给你的是纯文本,不是 HTML 网页,也不是图片”。如果不写 charset=utf-8,中文可能会变成乱码。
    2. Transfer-Encoding: chunked

      • 作用:声明发货方式(分批次)。
      • 解释:这是流式传输的开关。它告诉浏览器:“这个包裹太大(或者我还没生产完),我无法在发货前告诉你总重量(Content-Length)。我会切成一块一块地发给你,直到我说发完了为止。”
      • 后果:如果不写这个,浏览器可能会一直等到服务器把所有数据都准备好(res.end)才开始显示内容,那就没有打字机效果了。
    3. 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. 封装了复杂的流式网络请求(省去了手写 fetchreader
  • 痛点
    手动处理流式响应非常麻烦。你需要写 fetch,获取 reader,写 while 循环读取流,用 TextDecoder 解码,还要处理 JSON 解析和错误。

  • 便利
    useChat 内部已经写好了全套的流处理逻辑。

    • 你只需要配置一个 api: '/api/ai/chat'
    • 它会自动发起请求,自动监听数据流,自动解析后端发来的 0:"xxx" 格式。

3. 开箱即用的 UI 交互逻辑(省去了写受控组件)
  • 痛点
    你需要自己处理输入框的 onChange,自己处理表单的 onSubmit,还要防止用户在生成过程中重复点击发送。

  • 便利

    • 提供 inputhandleInputChange:直接绑定到输入框,实现双向绑定。
    • 提供 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>valueonChange 直接绑定了 SDK 提供的 inputhandleInputChange。这意味着输入框的状态管理权也完全移交给了 SDK,减少了我们自己写 useState 的冗余代码。

总结:数据流的全景图

构建这个 AI Chatbot,本质上是搭建了一条从用户到 AI 再回到用户的 高速数据管道

  1. 触发:用户点击发送,useChat 携带历史消息发起请求。
  2. 中转:后端 req.on 接收数据,向 DeepSeek 发起 stream: true 的请求。
  3. 生成:DeepSeek 逐个生成 Token。
  4. 清洗:后端 reader 截获 Token,去除 SSE 外壳。
  5. 推送:后端 res.write 将 Token 实时推回前端。
  6. 渲染:前端 SDK 接收 Token,更新状态,React 刷新界面。

通过这种 模块化、分层 的设计,我们不仅实现了酷炫的打字机效果,更保证了系统的可维护性和扩展性。