用 Gemini 和 Vercel Serverless 函数搭建 AI 聊天机器人
几个月前,我用 React、Node.js 和 Vercel Serverless Functions 做了一个聊天机器人应用,用在了我的 web 应用 buildcv.makeadifference.app 上。
本教程会带你完整走一遍我的搭建过程:从调用 Google Gemini API 的后端 serverless 函数,到让 AI 回复边生成边显示的 React 聊天组件。
读完这篇教程,你将学会:
如何搭建一个 Vercel Serverless 函数,让它调用 Gemini,并以小块文本的形式把响应流式传回浏览器。
为什么这种方式比等全部回复一次性返回,能让聊天机器人感觉更快、更流畅。
如何写一个 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é 分别通过
sanitizeString和sanitizeObject过滤一遍。这两个函数会剔除一切异常或超长的内容,确保不会把未经校验的用户输入直接丢给 AI。处理 CORS:
allowCors包装函数包裹在 handler 外层,负责跨域资源共享。聊天组件可能嵌入在 Vercel 函数所在域名以外的页面上,所以我们需要显式放行该来源的请求,并处理浏览器在真正POST之前自动发出的OPTIONS预检请求。校验请求:在 handler 内部,先确认
message和resume都成功通过了清洗。如果任一字段缺失或格式不合法,直接返回 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 的输出缓冲,这样你就能在终端里看到文字逐字蹦出来,而不是一股脑全倒出来。这说明从服务端到终端的整个流式链路已经跑通了,而你此时还没写一行前端代码。
🎨 前端:读取响应流
下面是我用 React 搭的聊天组件 UI 截图:
这里不会带大家从零搭 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 在本地都正常运行后,部署只需三步:
在 Vercel 中设置环境变量。进入项目仪表盘的 Settings → Environment Variables,添加与本地相同的
GOOGLE_API_KEY值。更新 CORS 来源和 fetch 地址。后端将
allowCors的Access-Control-Allow-Origin指向真实部署域名;前端则把fetch调用改为指向该部署后的函数 URL,不再使用 localhost。执行部署,选择下方两种方式中适合你工作流的一种。
方式 A:通过 CLI 部署
npm install -g vercel # if you haven't already
vercel login
vercel --prod
vercel login 用于认证你的机器,vercel --prod 则直接从项目目录构建并发布到生产环境。适合一次性部署或需要精确控制部署时机的场景。
方式 B:通过 GitHub 集成部署
将项目推送到 GitHub 仓库(如果尚未推送)。
在 Vercel 仪表盘中点击 Add New → Project,选择你的仓库。
Vercel 会自动识别框架配置,确认无误后点击 Deploy 即可。
此后,每次向 main 分支推送代码都会自动触发重新部署。
我个人更偏好这种方式——在频繁迭代项目时尤其方便,因为你再也不用记着手动执行部署命令了。
部署完成后,Vercel 会给你一个线上 URL。用之前那条 curl 命令,把生产环境地址替换进去,确认流式输出在线上表现正常,才算真正搞定。
⚠️ 关于嵌入组件的 CORS 说明
如果你打算把这个聊天组件嵌入到和 Vercel 应用不同域名的网站上(比如把组件放在营销站点,而函数本身部署在别处),默认情况下会碰到 CORS 限制——浏览器会拦截跨域请求,除非服务器明确允许。
正确的处理方式是:你的 serverless 函数要响应浏览器在真正发送 POST 请求之前自动发出的 OPTIONS 预检请求,并把 Access-Control-Allow-Origin 响应头设置为组件实际运行所在的域名。上面后端代码里的 allowCors 封装做的正是这件事。
🎉 总结
纯文本分块流式输出是聊天机器人在 Vercel 上显得流畅的关键。用户能逐步看到回复,不用干等整段答案生成完才看到第一个字。
如果想继续迭代,几个自然的下一步可以考虑:加上速率限制防止接口被滥用;用 KV、Redis 或 Postgres 做会话持久化,避免刷新页面后聊天记录丢失;再给组件加个停止按钮,让用户随时取消正在生成的回复。
如果你用这套方案做出了什么,欢迎分享!你打算用 Gemini + Vercel 搭建什么?
祝你一周愉快!😇