← 文章 / AI技术
freeCodeCamp 1小时前 · 2026-09-09 17:44:46 · 1 阅读

用 Gemini 和 Vercel Serverless 函数搭建 AI 聊天机器人

几个月前,我用 React、Node.js 和 Vercel Serverless Functions 做了一个聊天机器人应用,用在了我的 web 应用 buildcv.makeadifference.app 上。

本教程会带你完整走一遍我的搭建过程:从调用 Google Gemini API 的后端 serverless 函数,到让 AI 回复边生成边显示的 React 聊天组件。

读完这篇教程,你将学会:

  1. 如何搭建一个 Vercel Serverless 函数,让它调用 Gemini,并以小块文本的形式把响应流式传回浏览器。

  2. 为什么这种方式比等全部回复一次性返回,能让聊天机器人感觉更快、更流畅。

  3. 如何写一个 React 组件,读取流式响应,并在新文本到达时实时更新聊天窗口。

在接入 UI 之前,我们会先用 curl 测试接口,最后还会讲解如何把整个项目部署到 Vercel。

这一切的核心是一种叫纯文本分块流式传输(plain-text chunk streaming)的技术。服务器不是等 AI 生成完整回复后一次性传回浏览器,而是把响应拆成一系列小块文本流式发送,每块一生成就立刻写入浏览器。

正是这种技术让聊天机器人有了你在现代 AI 聊天界面里常见的、那种流畅的"打字中"效果,而不是长时间卡顿后答案突然整段蹦出来。

本文内容:

🧩 架构概览

整个应用由两部分组成。前端是一个 React 聊天组件,用户输入消息后,AI 的回复会以流式方式逐字渲染回来。后端则是一个 Vercel serverless function(例如 api/chat),负责校验请求、调用 Gemini 并将输出流式传回浏览器。

关于 Vercel serverless functions 的更多说明,可以参考官方文档

完整的数据流是这样的:浏览器向 Vercel function 发送请求 → Vercel function 调用 Gemini API → Gemini 的响应按块(chunk)流式回传到浏览器。

实际效果是,后端在生成过程中就不断把片段推送到前端,而不是让用户干等到整条回复全部生成完才看到第一个字。

✅ 前置条件

开始之前,请确保你已具备以下条件:

  • 本地已安装 Node.js(Node 18 及以上版本)

  • 一个 Gemini API key 🔑,可从Google AI Studio获取

  • 一个Vercel 账号,并已安装Vercel CLI

  • 在函数项目中安装了 @google/genai 包,执行 npm install @google/genai 即可添加

另外,本地开发时你需要把 API key 存到环境变量 process.env.GOOGLE_API_KEY 中;部署前,还要在 Vercel 项目的 Settings → Environment Variables 里添加同一个变量,确保应用上线后能正确读取到 key。

📜 API 接口约定

在写代码之前,先花点时间弄清楚前后端之间的"接口约定"——即前端该发什么、后端该回什么。

用户在聊天组件中发送消息时,前端会向 /api/chat 发起 POST 请求,JSON 请求体包含两部分:用户刚输入的消息,以及应用已加载的简历数据(这个聊天机器人的定位是简历教练)。后端将这两部分信息结合,生成与用户简历实际相关的回复,而非泛泛的通用回答。

请求示例:

POST /api/chat
{
  "message": "How can I improve my resume summary?",
  "resume": { "name": "...", "experience": [...], "skills": [...] }
}

⚙️ 后端(Vercel Serverless Function)

这是整个应用的核心:一个 Vercel serverless function,负责接收聊天请求、校验参数、转发给 Gemini,再将 AI 的回复以流式方式逐步推送回浏览器。

下面是完整的 handler 代码,先整体过一遍,我再逐段说明各部分的作用。

const { GoogleGenAI } = require("@google/genai");
const ai = new GoogleGenAI({
    apiKey: process.env.GOOGLE_API_KEY,  // GOOGLE_API_KEY 可以在 Vercel 中配置
});
const MAX_TEXT_LENGTH = 2000;
const MAX_ARRAY_LENGTH = 50;

function sanitizeString(input = "") {
    // 在这里对输入字符串做清洗
}

function sanitizeObject(obj = {}) {
    // 在这里对简历对象做清洗
}

const allowCors = fn => async (req, res) => {

    res.setHeader('Access-Control-Allow-Origin', '<<填入你的 Web 应用地址>>');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');

    // ✅ 处理预检请求
    if (req.method === 'OPTIONS') {
        return res.status(200).end();
    }
    // 另一种写法
    res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS, POST, PUT, DELETE')
    res.setHeader(
        'Access-Control-Allow-Headers',
        'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version'
    )
    if (req.method === 'OPTIONS') {
        res.status(200).end()
        return
    }
    return await fn(req, res)
};

const handler = async (req, res) => {

    if (req.url === '/api/chat' && req.method === 'POST') {

        const { message, resume } = req.body || {};

        const safeMessage = sanitizeString(message);
        const safeResume = sanitizeObject(resume);

        if (!safeMessage) {
            return res.status(400).json({ error: "Invalid message" });
        }

        if (!safeMessage || !safeResume) {
            return res.status(400).json({ error: 'Missing message or resume data' });
        }

        try {
            const stream = await ai.models.generateContentStream({
                model: "<<填入要用的 Gemini 模型>>",
                contents: `
            你是一个专业的简历教练 AI。

            - 始终以清晰、礼貌、专业的语气回复。
            - <<根据你的需求添加具体的提示词说明>>
            - 简历数据:
${JSON.stringify(safeResume, null, 2)}

用户问题:
${safeMessage}
`,
            });
            res.setHeader("Content-Type", "text/plain; charset=utf-8");
            res.setHeader("Cache-Control", "no-cache");

            for await (const chunk of stream) {
                const text = chunk.text;
                if (text) {
                    res.write(text);
                }
            }

            res.end();
        } catch (error) {
            console.error('Gemini Error:', error);
            res.status(500).json({ error: 'AI request failed' });
        }
    }

    // 我加了一个健康检查接口,方便测试 handler
    if (req.url === '/api/chat?type=healthcheck' && req.method === 'GET') {
        res.status(200).json({ message: 'Hello from the chat endpoint!' });
    }
}

module.exports = allowCors(handler)


接下来我们逐段拆解这段代码:

  • 初始化 Gemini 客户端:在文件顶部,我们用 GOOGLE_API_KEY 环境变量中存储的 API key 创建一个 GoogleGenAI 客户端。后续整个函数中与 Gemini 的所有交互都通过这个客户端完成。

  • 清洗输入:在处理请求之前,先把 message 和 résumé 分别通过 sanitizeStringsanitizeObject 过滤一遍。这两个函数会剔除一切异常或超长的内容,确保不会把未经校验的用户输入直接丢给 AI。

  • 处理 CORS:allowCors 包装函数包裹在 handler 外层,负责跨域资源共享。聊天组件可能嵌入在 Vercel 函数所在域名以外的页面上,所以我们需要显式放行该来源的请求,并处理浏览器在真正 POST 之前自动发出的 OPTIONS 预检请求。

  • 校验请求:在 handler 内部,先确认 messageresume 都成功通过了清洗。如果任一字段缺失或格式不合法,直接返回 400 错误,避免拿一个已知有问题的请求去白白调用 Gemini。

  • 调用 Gemini 并流式返回:这是整个教程的核心。我们没有调用普通的"生成内容"接口然后等完整响应,而是调用 generateContentStream,它返回一个异步迭代器。用 for await...of 循环遍历这个流,每收到一小段文本就立刻通过 res.write(text) 写入响应。正是这一步让浏览器在 Gemini 还没生成完整个回答时就能开始逐字显示。

  • 错误处理与健康检查:如果与 Gemini 通信过程中出了任何异常,我们捕获错误、记录日志,并返回 500 响应,让前端知道出问题了。此外还提供了一个简单的 GET 健康检查端点,方便在测试聊天流程之前确认函数已正常上线。

🧪 接入 UI 前先测一下接口

动手写前端之前,最好先确认 Serverless Function 确实能按预期逐块推送(stream)数据。一条简单的 curl 命令就够了:

curl -N -X POST "https://YOUR_APP.vercel.app/api/chat?type=chat" \
  -H "Content-Type: application/json" \
  -d '{"message":"Give me 3 resume summary tips","resume":{"name":"Test"}}'

其中 -N 参数会关闭 curl 的输出缓冲,这样你就能在终端里看到文字逐字蹦出来,而不是一股脑全倒出来。这说明从服务端到终端的整个流式链路已经跑通了,而你此时还没写一行前端代码。

Streaming working

🎨 前端:读取响应流

下面是我用 React 搭的聊天组件 UI 截图:

3c726767-0fbc-4784-9ce0-24f9d94ae549

这里不会带大家从零搭 UI。布局、样式、消息列表怎么排,完全取决于你自己的设计偏好。

我们要重点讲的是真正驱动这个组件的核心函数:点击发送按钮后,把用户输入发给 Serverless Function,再读回流式响应,让组件逐段渲染 AI 的回复——就是上面截图里你看到的效果。

进入那个函数之前,先看看它所在的外壳结构,方便你了解它在组件里的位置:

function ChatWidget({ resume }) {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  async function sendMessage() {
    // ...具体实现见下文
  }

  return (
    <div className="chat-widget">
      {/* 消息列表、输入框、调用 sendMessage() 的发送按钮 */}
    </div>
  );
}

这就是函数所处的外壳:消息列表、输入框、加载状态和错误占位各一个 state。用户点发送按钮时触发的就是 sendMessage

下面是完整的 sendMessage 函数,每当用户输入内容并点击发送时就会执行。这段代码把上面的 UI 和我们刚搭建好的后端连接了起来。

async function sendMessage() {
    const messageText = input.trim();
    if (!messageText || loading) return;

    const userMessage = { role: "user", text: messageText };
    setMessages((message) => [...message, userMessage]);
    setInput("");
    setLoading(true);
    setError(null);

    try {
      const res = await fetch("<<YOUR VERCEL SERVERLESS FUNCTION URL GOES HERE>>/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: messageText, resume })
      });

      if (!res.ok) {
        throw new Error(`Failed to get response: ${res.status}`);
      }

      const reader = res.body.getReader();
      const decoder = new TextDecoder();

      let fullText = "";
      setMessages((message) => [...message, { role: "assistant", text: "" }]);

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value);
        fullText += chunk;

        setMessages((message) => {
          const updated = [...message];
          updated[updated.length - 1] = { role: "assistant", text: fullText };
          return updated;
        });
      }

    } catch (err) {
      setError("Failed to send message. Please try again.");
      setMessages((m) => [...m, {
        role: "assistant",
        text: "Sorry, I encountered an error. Please try again later."
      }]);
    } finally {
      setLoading(false);
    }
  }

下面逐步拆解这段代码的执行流程:

首先,用户点击发送后,我们拿到输入的消息,并立即把它加入 messages 数组,让它马上显示在聊天界面中。

接着,我们把消息和简历数据一起通过 POST 请求发送到 /api/chat 端点。收到响应后,调用 res.body.getReader() 获取响应体的 ReadableStreamDefaultReader,再用 TextDecoder 把每个原始字节块转换成可读文本。

之后进入循环,每次读取一个 chunk 并追加到不断增长的 fullText 字符串中,同时用最新文本更新聊天中的最后一条消息。

这个循环正是截图中"打字"效果的来源:随着新 chunk 陆续到达,助手的消息在聊天框中逐词增长,而不是一口气全部弹出。这个模式和常见的 fetch().then(res => res.json()) 写法有些不同,如果你之前没处理过流式 fetch 响应,值得稍作停顿了解一下。

🚀 部署

确认函数和 UI 在本地都正常运行后,部署只需三步:

  1. 在 Vercel 中设置环境变量。进入项目仪表盘的 Settings → Environment Variables,添加与本地相同的 GOOGLE_API_KEY 值。

  2. 更新 CORS 来源和 fetch 地址。后端将 allowCorsAccess-Control-Allow-Origin 指向真实部署域名;前端则把 fetch 调用改为指向该部署后的函数 URL,不再使用 localhost。

  3. 执行部署,选择下方两种方式中适合你工作流的一种。

方式 A:通过 CLI 部署

npm install -g vercel   # if you haven't already
vercel login
vercel --prod

vercel login 用于认证你的机器,vercel --prod 则直接从项目目录构建并发布到生产环境。适合一次性部署或需要精确控制部署时机的场景。

方式 B:通过 GitHub 集成部署

  1. 将项目推送到 GitHub 仓库(如果尚未推送)。

  2. 在 Vercel 仪表盘中点击 Add NewProject,选择你的仓库。

  3. Vercel 会自动识别框架配置,确认无误后点击 Deploy 即可。

  4. 此后,每次向 main 分支推送代码都会自动触发重新部署。

我个人更偏好这种方式——在频繁迭代项目时尤其方便,因为你再也不用记着手动执行部署命令了。

部署完成后,Vercel 会给你一个线上 URL。用之前那条 curl 命令,把生产环境地址替换进去,确认流式输出在线上表现正常,才算真正搞定。

⚠️ 关于嵌入组件的 CORS 说明

如果你打算把这个聊天组件嵌入到和 Vercel 应用不同域名的网站上(比如把组件放在营销站点,而函数本身部署在别处),默认情况下会碰到 CORS 限制——浏览器会拦截跨域请求,除非服务器明确允许。

正确的处理方式是:你的 serverless 函数要响应浏览器在真正发送 POST 请求之前自动发出的 OPTIONS 预检请求,并把 Access-Control-Allow-Origin 响应头设置为组件实际运行所在的域名。上面后端代码里的 allowCors 封装做的正是这件事。

🎉 总结

纯文本分块流式输出是聊天机器人在 Vercel 上显得流畅的关键。用户能逐步看到回复,不用干等整段答案生成完才看到第一个字。

如果想继续迭代,几个自然的下一步可以考虑:加上速率限制防止接口被滥用;用 KV、Redis 或 Postgres 做会话持久化,避免刷新页面后聊天记录丢失;再给组件加个停止按钮,让用户随时取消正在生成的回复。

如果你用这套方案做出了什么,欢迎分享!你打算用 Gemini + Vercel 搭建什么?

祝你一周愉快!😇

原始来源: freeCodeCamp

评论 (0)