几个月前,我使用 React、Node.js 和 Vercel Serverless Functions 构建了一个聊天机器人应用,并在我的网页应用 buildcv.makeadifference.app 中使用了它。
在本教程中,我将一步步带你了解我是如何构建它的——从与 Google Gemini API 交互的后端无服务器函数,到实时显示 AI 响应的 React 聊天小部件。
学完本教程后,你将了解:
如何设置一个调用 Gemini 并以小文本块形式将响应流式传回浏览器的 Vercel 无服务器函数。
为什么这种方法能让聊天机器人感觉更快、更灵敏,而不是等待完整回复一次性返回。
如何构建一个读取该流式响应并在新文本到达时实时更新聊天窗口的 React 组件。
我们还将在连接 UI 之前使用 curl 测试该端点,并介绍如何将整个应用实际部署到 Vercel。
所有这一切都围绕一种称为 纯文本块流式传输 的技术。与其等待 AI 生成完整响应后一次性发回浏览器,不如让无服务器函数将响应以一系列小文本块流式输出,每当块准备好时立即写入浏览器。
这正是让聊天机器人具有现代 AI 聊天界面中常见的响应式“打字”感的原因,而不是先长时间停顿,然后一次性显示完整答案。
我们将涵盖的内容:
🧩 架构概览
该应用由两个主要部分构成。UI 部分是一个 React 聊天小部件,用户在此输入消息,AI 的响应会在流式传输过程中实时渲染回显给用户。后端是一个 Vercel 无服务器函数(例如 api/chat),它负责验证传入请求、调用 Gemini,并将输出流式传回浏览器。
您可以在他们的官方文档中了解更多有关 Vercel 无服务器函数的信息。
以下是从开始到结束的整个流程:浏览器向 Vercel 函数发送请求,Vercel 函数调用 Gemini API,Gemini 的响应以块的形式流式返回到浏览器。
实际上,这意味着后端会在数据就绪时以小块的形式将响应发送到前端,而不是让用户等待整个响应生成完毕才看到任何内容。
✅ 前置条件
在开始之前,请确保您已具备以下条件:
本地已安装 Node.js(Node 18 或更高版本)
来自 谷歌 AI 工作室 的 Gemini API 密钥 🔑
在您的函数项目中已安装
@google/genai包,您可以通过运行npm install @google/genai来添加。
您还希望将密钥存储在名为 process.env.GOOGLE_API_KEY 的环境变量中,以用于本地开发;并在部署前将同一变量添加到您的 Vercel 项目的控制面板(位于 Settings 然后 Environment Variables)中,以便在应用托管后能正确获取 API 密钥。
📜 API 合同
在深入代码之前,值得花一点时间来了解前端和后端之间的“合同”——换句话说,前端应发送什么,以及后端期望接收到什么。
当用户在聊天小部件中发送消息时,前端会向 /api/chat 发送一个 POST 请求,其 JSON 主体包含两部分:用户刚刚输入的消息,以及已经加载到应用中的简历数据(因为此聊天机器人正担任简历教练的角色)。然后后端将这两部分信息结合起来,生成与该用户简历相关的响应,而不是一个通用答复。
请求:
POST /api/chat
{
"message": "How can I improve my resume summary?",
"resume": { "name": "...", "experience": [...], "skills": [...] }
}
⚙️ 后端(Vercel 无服务器函数)
这是应用的核心:一个单一的 Vercel 无服务器函数,负责接收聊天请求、进行验证,然后将其转发给 Gemini,并在生成时将 AI 的响应流式返回给浏览器。
下面是完整的处理函数,我会在您浏览完整代码后,逐部分说明每一部分的作用。
const { GoogleGenAI } = require("@google/genai");
const ai = new GoogleGenAI({
apiKey: process.env.GOOGLE_API_KEY, // GOOGLE_API_KEY can be configured in Vercel
});
const MAX_TEXT_LENGTH = 2000;
const MAX_ARRAY_LENGTH = 50;
function sanitizeString(input = "") {
// sanitize your input string here
}
function sanitizeObject(obj = {}) {
// sanitize your resume object
}
const allowCors = fn => async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '<>');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// ✅ Handle preflight request
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
// another option
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: "<>",
contents: `
You are a professional Resume Coach AI.
- Always respond clearly, politely, and professionally.
- <>
- Resume data:
${JSON.stringify(safeResume, null, 2)}
User question:
${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' });
}
}
// I have added a health check for testing the 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 密钥创建一个GoogleGenAI客户端。之后的函数中,我们会使用这个客户端与 Gemini 进行实际通信。清理输入: 在处理传入请求之前,我们会将消息和简历分别通过
sanitizeString和sanitizeObject进行清理。这些函数会剔除任何意外或过大的内容,确保在未经检查的情况下不会将不可信的用户输入直接传递给 AI。处理 CORS: 包装函数
allowCors围绕我们的处理程序,负责跨域资源共享(CORS)。由于聊天小部件可能嵌入在与 Vercel 函数不同的域名下,我们需要显式允许来自该域的请求,并处理浏览器在真正的POST请求之前自动发送的OPTIONS预检请求。验证请求: 在处理程序内部,我们会检查
message和resume是否都成功通过了清理。如果其中任意一个缺失或无效,我们会立即返回 400 错误,而不是对已知有问题的请求浪费一次对 Gemini 的调用。调用 Gemini 并流式传输响应: 这是整个教程的关键部分。我们不再调用常规的 "generate content" 方法并等待完整响应返回,而是调用
generateContentStream,它返回一个异步可迭代对象。我们使用for await...of循环遍历该流,每当有新的文本块到达时,立即通过res.write(text)将其写入响应。正是这种机制让浏览器能够在 Gemini 完成完整答案生成之前就开始接收文本。错误处理和健康检查: 如果在与 Gemini 通信过程中出现任何问题,我们会捕获错误、记录日志,并返回 500 响应,以便前端知道出现了故障。此外,还有一个简单的
GET健康检查端点,您可以访问它来确认函数已正常运行,然后再开始测试实际的聊天流程。
🧪 在连接 UI 之前测试端点
在开始构建前端之前,先确认无服务器函数确实按照您的预期流式传输数据块是个好主意。您可以使用简单的 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。布局、样式和消息列表完全取决于你以及你的设计偏好。
我们将介绍的正是驱动它的函数:发送按钮背后的代码,它会获取用户输入的内容,发送到我们的无服务器函数,并读取流式响应,以便小部件能够在响应到达时逐块显示 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() {
// ...actual function is added below
}
return (
{/* message list, input box, and a send button that calls sendMessage() */}
);
}
这是函数所在的外壳:消息列表的状态、输入框、加载标志以及错误槽。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("<>/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 将每个原始字节块转换为可读文本。
在此基础上,我们循环读取每个块,并将其追加到不断增长的 fullText 字符串中;每次迭代时,我们用该文本更新聊天中的最后一条消息。
这个循环正是上述截图中你看到的“打字”效果的来源:助手的消息在聊天小部件中会随着新块的到来逐字逐词地可见增长,而不是一下子全部出现。这种模式与你可能习惯的典型 fetch().then(res => res.json()) 调用略有不同,因此如果你之前没有处理过流式 fetch 响应,值得停下来看看。
🚀 部署
在您确认函数和 UI 在本地均可正常工作后,部署只需三个步骤:
在 Vercel 中设置环境变量。 在您的项目仪表板中,进入 Settings(设置),然后是 Environment Variables(环境变量),添加
GOOGLE_API_KEY,其值与您在本地使用的相同。更新 CORS 源和 fetch URL。 在后端,将
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。
此后,每次向主分支推送都会触发自动重新部署。
我一直更倾向于这种方式,尤其在您正积极迭代项目时更为方便,因为您无需自己记住运行部署命令。
部署完成后,Vercel 会为您的应用提供一个实时 URL。使用之前相同的 curl 命令,替换为您的生产 URL 进行测试,以确认在生产环境中流式传输正常工作,然后再认为任务完成。
⚠️ 嵌入式小部件的 CORS 注意事项
如果您计划将此聊天小部件嵌入到与您的 Vercel 应用所在域不同的网站上(例如,将其作为小部件嵌入到营销站点,而函数本身部署在其他地方),默认情况下会遇到 CORS 限制。浏览器会阻止跨源请求,除非服务器显式允许。
为了正确处理,您的无服务器函数需要响应浏览器在真实 POST 请求之前自动发送的 OPTIONS 预检请求,并将 Access-Control-Allow-Origin 头设置为与您的小部件实际运行的域匹配。这正是上面后端代码中的 allowCors 包装器所为您完成的工作。
🎉 结论
纯文本块流式传输正是让聊天机器人在 Vercel 上感觉响应迅速的原因。由于用户可以看到响应逐渐出现,他们不必等待完整答案生成后才看到任何内容。
如果您想继续在此基础上开发,可以考虑以下几个自然的下一步:添加速率限制以防止端点被滥用;使用 KV、Redis 或 Postgres 等方式添加对话持久化,以免刷新时丢失聊天记录;在小部件中添加停止按钮,让用户可以取消正在生成的响应。
如果您使用这种方法构建了某些东西,我很想听到您的分享!您计划用 Gemini 和 Vercel 构建什么?
希望您有一个祝福的周!😇