现在越来越多的博客开始接入 AI 功能,为读者提供智能摘要和交互式问答。但很多时候现成的插件难以满足高度定制化的需求,或者在免费 Serverless 平台上部署时因为大模型的响应太慢而经常引发 504 Gateway Timeout。
今天我将分享一篇从零开始打造博客专属 AI 聊天助手的完整实战教程!这套方案基于 Netlify Serverless Functions,并在前后端彻底实现了 SSE (Server-Sent Events) 流式输出,完美解决请求超时问题,带来顺滑的打字机对话体验!
1. 架构思路与准备工作
由于直接在前端页面发起 AI API 请求会暴露敏感的 API Key,我们必须通过服务端的 Serverless 云函数作为中转。
而大多数 Serverless 的免费额度限制为每次请求最多 10 秒,一旦大模型思考超过 10 秒,链接就会被强行切断导致报错。因此,核心解法是利用 HTTP 流(Stream),让后端在拿到 AI 的第一个字时就开始往前端吐数据,从而保持 HTTP 连接常开。
本教程需要用到的工具:
- 前端页面: 任何静态博客(Hexo / Hugo 等均可,提供原生的 HTML / CSS / JS 代码)
- 中间层: Netlify Functions
- 大模型 API: 支持 OpenAI 接口格式的 AI 平台(本教程以注册即送额度且部分模型免费的 SiliconFlow 为例)
API 选择建议
对于个人博客而言,如果我们只是为了让 AI 回答一些博客内的简单问题,完全不需要花钱。
强烈推荐大家去注册 SiliconFlow (硅基流动)。它原生兼容 OpenAI 的接口格式,接入极其方便,而且平台上的 Qwen/Qwen3-8B、DeepSeek-R1-0528-Qwen3-8B 的较小参数等知名大模型都是永久免费无限调用的!本教程就是在这个免费模型的基础上跑起来的,响应速度也是秒级,用来做知识问答和摘要绰绰有余。
2. 后端:开发 Netlify Serverless 函数
首先,在博客所在的根目录下安装所需的依赖包:
npm install @netlify/functions node-fetch
新建文件夹和文件 netlify/functions/chat.js,填入以下代码,其中使用了 @netlify/functions 的 stream 方法:
const fetch = require('node-fetch');
const { stream } = require('@netlify/functions');
const DEFAULT_API_URL = 'https://api.siliconflow.cn/v1/chat/completions'; // 你也可以换成其他支持 OpenAI 接口的模型提供商的 API 地址
const DEFAULT_MODEL = 'Qwen/Qwen3-8B'; // 你也可以换成其他支持 OpenAI 接口的模型
function buildHeaders() {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Methods': 'POST, OPTIONS'
};
}
function buildSystemPrompt(articleContextMessage) {
const basePrompt = '你是本博客的专属 AI 助手。请用中文简洁地回答问题。';
if (!articleContextMessage || !articleContextMessage.content) return basePrompt;
return [basePrompt, articleContextMessage.content].join('\n\n');
}
exports.handler = stream(async function(event) {
if (event.httpMethod === 'OPTIONS') {
return { statusCode: 204, headers: buildHeaders(), body: '' };
}
const API_KEY = process.env.SILICONFLOW_API_KEY;
const API_URL = process.env.SILICONFLOW_API_URL || DEFAULT_API_URL;
const MODEL = process.env.SILICONFLOW_API_MODEL || DEFAULT_MODEL;
try {
const requestBody = JSON.parse(event.body || '{}');
const messages = requestBody.messages || [];
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: buildSystemPrompt(requestBody.articleContext) },
...messages
],
stream: true,
})
});
if (!response.ok) {
return { statusCode: response.status, headers: buildHeaders(), body: await response.text() };
}
// 关键所在:直接将 node-fetch 的 Readable Stream 返给前端
return {
statusCode: 200,
headers: {
...buildHeaders(),
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
},
body: response.body
};
} catch (error) {
return { statusCode: 500, headers: buildHeaders(), body: JSON.stringify({ error: error.message }) };
}
});
注意:部署代码前,请务必登录 Netlify 后台配置环境变量 SILICONFLOW_API_KEY。
3. 前端 UI:Tool Island 悬浮窗
在我的主题中,聊天框被集成在了右下角的悬浮工具岛 (Tool Island) 内。我们利用了主题的全局变量 SUPER_AI_CONFIG。在 EJS 模板库(如 layout/_partials/tool_island.ejs)添加类似如下的 HTML 结构:
<div class="tool-island">
<div class="ai-content">
<div class="ai-messages" id="ai-messages">
<!-- 消息将被插入这里 -->
</div>
<div class="ai-input-area">
<input type="text" id="ai-input" placeholder="发送消息..." onkeypress="if(event.key==='Enter') sendAiMessage()">
<button onclick="sendAiMessage()"><i class="fa-solid fa-angle-up"></i></button>
</div>
</div>
</div>
<script>
// 从 _config.yml 解析出我们在后端的配置(千万不要暴露 API KEY!)
window.SUPER_AI_CONFIG = {
enable: <%= theme.ai ? theme.ai.enable : false %>,
api_url: "<%= theme.ai ? theme.ai.api_url : '' %>",
model: "<%= theme.ai ? theme.ai.model : 'Qwen/Qwen3-8B' %>"
};
</script>
4. 前端逻辑:本地存储历史记录与 SSE 打字机流式解析
我们需要拦截用户的输入,带上博客上下文,请求我们刚刚开发的 Serverless 函数。收到响应后,解析 ReadableStream 逐字吐出字符,并将记录存到 localStorage 里实现断点记忆。
在 main.js 核心逻辑如下:
const AI_HISTORY_KEY = "gogei_ai_chat_history";
// 处理聊天记录保存逻辑
function appendMessage(role, text, save = true) {
const container = document.getElementById("ai-messages");
const div = document.createElement("div");
div.className = `message ${role === "user" ? "user-message" : "ai-message"}`;
div.innerText = text;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
if (save) {
const history = JSON.parse(localStorage.getItem(AI_HISTORY_KEY) || "[]");
history.push({ role, content: text });
if (history.length > 50) history.shift();
localStorage.setItem(AI_HISTORY_KEY, JSON.stringify(history));
}
}
// 核心发送功能
async function sendAiMessage() {
const input = document.getElementById("ai-input");
const text = input.value.trim();
if (!text) return;
// 清空输入框并显示用户消息
input.value = "";
appendMessage("user", text, true);
// 提前放入一个占位气泡装 AI 回答,不存进 history(等回答完整再存)
appendMessage("assistant", "", false);
const container = document.getElementById("ai-messages");
const msgs = container.querySelectorAll(".ai-message:not(.user-message)");
const contentEl = msgs.length > 0 ? msgs[msgs.length - 1] : container.lastElementChild;
try {
// 构造带上下文的请求
const rawHistory = JSON.parse(localStorage.getItem(AI_HISTORY_KEY) || "[]").slice(-10);
const contextMessages = rawHistory.map(msg => ({ role: msg.role, content: msg.content }));
// 提取当前文章文本交作为上下文
const articleContext = {
content: document.querySelector('.post-content')?.innerText.substring(0, 3000) || ""
};
const response = await fetch("/.netlify/functions/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: contextMessages, articleContext })
});
if (!response.body) throw new Error("您的浏览器不支持 ReadableStream");
// 开始流式解析 SSE (Server-Sent Events)
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let fullReply = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.trim() === '' || line.trim() === 'data: [DONE]') continue;
if (line.startsWith('data: ')) {
try {
const parsed = JSON.parse(line.slice(6));
if (parsed.choices && parsed.choices[0].delta && parsed.choices[0].delta.content) {
fullReply += parsed.choices[0].delta.content;
// 实时逐字更新到界面
contentEl.innerText = fullReply;
container.scrollTop = container.scrollHeight;
}
} catch (e) {
// 包可能被截断导致 JSON 无法解析,暂时忽略等待下一个完整包
}
}
}
}
// 回答完毕,一次性把历史拼回去存进 localStorage
const history = JSON.parse(localStorage.getItem(AI_HISTORY_KEY) || "[]");
history.push({ role: "assistant", content: fullReply });
localStorage.setItem(AI_HISTORY_KEY, JSON.stringify(history));
// 使用 Marked 将最终文本渲染为漂亮的 Markdown
if (typeof marked !== 'undefined') {
contentEl.innerHTML = marked.parse(fullReply);
}
} catch (err) {
contentEl.innerText = "出错了:" + err.message;
}
}
5. 总结
在这套设计中,AI 的逻辑被深度融合到了 tool_island.ejs 和 main.js 里,不仅解决了大模型生成速度慢导致的 504 崩溃问题由于有了真正的流式传输,在左下方唤起聊天时便能无缝打字,同时也利用了 localStorage 长久保存你的聊天上下文记录。
跟着上述教程,你也可以亲自改造出一个属于你的定制博客专属 AI 小助手。
for (const line of lines) {
if (line.trim() === "" || line.trim() === "data: [DONE]") continue;
if (line.startsWith("data: ")) {
try {
// 剥离 data: 前缀,解析纯 JSON
const parsed = JSON.parse(line.slice(6));
if (parsed.choices && parsed.choices[0].delta && parsed.choices[0].delta.content) {
fullReply += parsed.choices[0].delta.content;
// 实时更新到正在输出的 DIV 中
aiMsgEl.innerText = fullReply;
document.getElementById("ai-messages").scrollTop = document.getElementById("ai-messages").scrollHeight;
}
} catch (e) {
// 注意:偶尔有切包不完整的状况导致 JSON 无法解析,这里必须 catch 忽略以等待下一个完整的包
}
}
}
}
// 4. 全部接收完毕后,我们可以利用 marked.js 将回复渲染为丰富的 Markdown 加持格式(代码高亮/表格等)
if (typeof marked !== "undefined") {
aiMsgEl.innerHTML = marked.parse(fullReply);
}
} catch (err) {
aiMsgEl.innerText = "出错了:" + err.message;
}
}
---
## 5. 总结
至此,你已经拥有了一个完整、定制化且具备完美打字机交互体验的博客 AI 助手。这套架构带来了这三个好处:
1. **防止白嫖:** 前端抓不到 API Key;
2. **极佳体验:** 原生 SSE 数据流,和商业大模型网页没有任何区别;
3. **结合博客内容:** 虽然没有花大价钱做知识库,我们也可以巧妙地通过读取 `document.querySelector` 强行投喂了当页文章内容,简单粗暴且好用。
赶快按照这套教程,开启你的博客智能化之旅吧!如果有任何疑问,欢迎留言交流。