🛡️ AI 全栈学习第十一天:第一个 AI 功能——手写流式聊天机器人 (AI Chatbot)

63 阅读6分钟

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

还记得我们在 Vue 3 实现流式输出 那篇文章里聊过的“流式输出”吗?那时候我们只是简单地演示了前端如何接收流。今天,我们要把这个技术真正应用到我们的全栈项目中,打造我们的第一个 AI 核心功能——智能聊天机器人 (Chatbot)!🤖

现在的 AI 应用,如果不能像 ChatGPT 那样“打字机”式地蹦字,那简直是没有灵魂的。为什么?

  1. 大模型特性:LLM(大语言模型)生成内容是基于 Token 的,像神经网络在“脑补”下一个字,这本身就是个流式的过程。
  2. 用户体验:百亿参数的模型思考是需要时间的。如果等它全部想好再返回,用户可能早就以为网断了。流式输出能让用户立刻看到反馈,体验极佳!🚀

今天,我们将使用业界最权威的 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 可能会报错或者无法正确拼接。所以,我们在后端做一个简单的“包装”,就能完美适配前端的高级组件!


🎉 总结

今天我们完成了一次跨越:

  1. 原理层面:理解了 LLM 的 Token 生成机制和 HTTP 流式传输。
  2. 前端层面:使用 useChat 极速构建了响应式聊天界面。
  3. 后端层面:手写了流式数据的接收、解析、转换和转发。

现在的你,已经拥有了一个能像 ChatGPT 一样逐字吐出真言的 AI 助手了!虽然现在用的是 Mock,但只要把这段代码搬到 NestJS 或 Node.js 服务中,原理是完全一样的。

这就是全栈开发的魅力,从 UI 交互到底层网络流,尽在掌握!💪

下期预告:我们的 AI 只能聊天未免太单调了。下节课,我们将探索如何让 AI 生成结构化数据,甚至帮你写代码、画图!敬请期待!

Happy Coding! 🚀