Hexo 博客 AI 问答助手 ByteBot 全栈实践:从流式响应到 RAG 增强

ByteBot 是 ByteFisher 博客的 AI 问答助手,基于 DeepSeek API + Vercel Serverless 构建,为博客访问者提供智能问答服务。本文详细记录从最初的基础问答到全功能生产级助手的完整技术实现过程。

一、为什么需要 AI 助手

ByteFisher 博客运行在 Hexo + Next 主题之上,经过多年积累已有 95+ 篇文章,涵盖 Unity3D、C#、Lua、Python、钓鱼技巧等多个领域。信息量大了之后,访客很难快速找到想要的内容:

  • 想了解 C# 委托机制 → 需要先知道有一篇《C# 学习笔记【委托Delegate】》
  • 想看 2023 年钓鱼照片 → 需要知道相册路径是 /fish/img2023/ 而不是 /fish/2023/
  • 想玩五子棋小游戏 → 需要知道游戏合集在 /ai-games/

传统搜索(站内关键词搜索、标签浏览)能满足基本需求,但对自然语言提问的支持不够友好。ByteBot 的定位就是:用自然语言对话的方式,帮助访客快速找到博客中的内容

经过多轮迭代,ByteBot 目前已完成 10 项优化需求:

优先级 功能 说明
P0 流式响应、对话历史、暗色模式、精确错误提示 核心体验闭环
P1 Markdown 渲染增强、输入保护、移动端全适配 功能完整度提升
P2 Vercel 超时优化、RAG 上下文增强、使用统计 锦上添花

二、系统架构

ByteBot 的整体架构是一个典型的三层结构:

1
2
3
4
5
6
7
┌─────────────────┐     SSE Stream      ┌──────────────────┐     POST SSE     ┌──────────────┐
│ Browser │ ◄────────────────── │ Vercel Proxy │ ──────────────►│ DeepSeek │
│ ai-assistant.js│ │ api/chat.js │ ◄──────────────│ API │
│ │ messages[] │ │ SSE chunks │ │
│ posts-index │ ──────────────────► │ timeout: 30s │ │ │
│ (local cache) │ └──────────────────┘ └──────────────┘
└─────────────────┘

数据流:用户在浏览器输入问题 → ai-assistant.jsmessages[] 历史 + 系统提示词发给 api/chat.js → Vercel Serverless 函数转发到 DeepSeek API → SSE 流式返回 → 前端逐 token 渲染。

关键技术选型:

决策项 选型 理由
流式方案 fetch + ReadableStream 兼容性优于 EventSource,支持 POST 方法
Markdown 渲染 自研增强版(~70 行) 避免额外依赖,精确控制 XSS 安全
对话存储 session 级内存数组 + sessionStorage 页面刷新不持久化,符合预期
暗色模式 @media (prefers-color-scheme: dark) 纯 CSS,零运行时开销
Vercel 超时 maxDuration: 30 超过 Hobby 默认 10s,无需 Pro 计划

三、核心交互体验

3.1 流式响应(SSE)

问题:最初 ByteBot 等待 DeepSeek 返回完整的 max_tokens: 2000 响应后才一次性渲染,用户平均等待 3-8 秒无任何反馈,体验极差。

方案:前后端全链路流式转发。

服务端api/chat.js)使用 response.body.getReader() 边读边写 SSE:

1
2
3
4
5
6
7
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) { res.end(); break; }
res.write(decoder.decode(value, { stream: !done }));
}

前端ai-assistant.js)的 handleStream() 函数逐行解析 data: 前缀的 SSE 消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
function handleStream(response) {
var reader = response.body.getReader();
var buffer = '';
var fullReply = '';
var botDiv = null;
// appendToken() 实时调用 render() 更新 DOM
function appendToken(text) {
if (!botDiv) { botDiv = createBotMessageDiv(); hideTyping(); }
fullReply += text;
botDiv.innerHTML = render(fullReply);
}
// readChunk() 递归读取,逐 chunk 解析 delta.content
}

停止生成AbortController 实现。用户点击「停止」按钮 → abortController.abort() → fetch 连接断开 → 前端丢弃后续 chunk。注意:Vercel 函数仍会跑完,DeepSeek API 仍会计费。

3.2 对话历史支持

问题:每次请求只发送 system + 当前问题,AI 无法记忆前文。

方案:前端维护 messages[] 数组,每次请求一并发送历史消息。

1
2
3
4
5
6
7
8
9
10
11
var messages = [];
var maxHistoryTurns = 6;

function send() {
// ...
messages.push({ role: 'user', content: text });
messages = truncateMessages(messages);
// 构建请求消息列表
var msgs = [{ role: 'system', content: buildSystemPrompt(index, topArticles) }];
for (var i = 0; i < messages.length; i++) msgs.push(messages[i]);
}

由于前端没有精确 tokenizer,采用近似策略估算 token 数:

1
2
3
4
5
function estimateTokens(text) {
var chinese = (text.match(/[\u4e00-\u9fff]/g) || []).length;
var other = text.length - chinese;
return Math.ceil(chinese / 1.5 + other / 4);
}

truncateMessages() 从后往前扫描,累积 token 超过 3000 上限时丢弃最早的消息。

3.3 暗色模式适配

问题:聊天面板硬编码白色背景(#fff),在博客暗色模式下刺眼不协调。

方案@media (prefers-color-scheme: dark) 纯 CSS 媒体查询适配,无需 JavaScript 干预。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// styles.styl — 亮色模式
#ai-assistant-panel .ai-messages .ai-message
&.ai-message-bot
background: #f0f4f8
color: #333
a
color: $ai-primary // #37c6c0
&:hover
color: darken($ai-primary, 10%)

@media (prefers-color-scheme: dark)
#ai-assistant-panel
background: #2d2d2d
border-color: #444
#ai-assistant-panel .ai-messages .ai-message
&.ai-message-bot
background: #3d3d3d
color: #e0e0e0
a
color: lighten($ai-primary, 20%)

覆盖范围包括面板背景、气泡、文字、输入框、按钮、阴影、Toast、链接颜色。切换系统主题时即时响应,无需刷新。

3.4 精确错误提示

问题:所有异常统一显示”抱歉没理解”,用户无法判断是网络问题还是服务端问题。

方案classifyError() 函数按优先级顺序判定错误类型:

1
2
3
4
5
6
7
8
9
10
function classifyError(err, response) {
if (err && err.name === 'AbortError') return null;
if (!response) return '网络连接失败,请检查网络后重试';
var s = response.status;
if (s === 429) return '服务忙,请稍后再试 🐟';
if (s === 504) return '回答超时了,请简化问题后重试';
if (s === 401 || s === 403) return '服务配置异常,请联系站长';
if (s >= 500) return '服务暂时不可用,请稍后重试';
return '抱歉没理解,换个问法试试?';
}

错误提示以 Toast 浮层展示,3 秒后自动消失,不阻塞用户操作。

3.5 输入保护

  • 输入框 maxlength="2000",超出时字符计数器变红
  • 防抖:调用 send() 时检查 Date.now() 差值,间隔不足 1 秒则丢弃
1
2
3
var now = Date.now();
if (now - lastSentTime < CONFIG.debounceInterval) return;
lastSentTime = now;

四、自研 Markdown 渲染引擎

为什么不自研

ByteBot 的 AI 回复需要在前端实时渲染为 HTML。常见的方案有:

  1. marked.js(约 10KB gzip)— 功能完整,但无法精确控制 AI 可能输出的危险 HTML
  2. DOMPurify + 任意 Markdown 库 — 需要额外 6KB 依赖
  3. 自研 — 针对 AI 回复场景定制,精确控制安全边界

选择了方案 3:ByteBot 的 AI 回复格式相对有限(代码块、列表、链接、偶尔表格),不需要完整 CommonMark 支持。自研渲染器约 70 行代码,零外部依赖,且可以内置 AI 输出碎片的预清理逻辑。

支持的语法

render() 函数的处理流程:

1
2
3
原始文本 → HTML 碎片预清理 → 保护代码块 → inline code
→ Markdown 链接 → 粗体 → 标题 → 引用 → 列表
→ 列表归组 → 表格 → 裸 URL → 恢复代码块 → \n 换行

支持 8 种语法:

语法 输入 输出
代码块 ```code``` <pre><code>
内联代码 `code` <code>
粗体 **text** <strong>
链接 [text](url) <a href="url">
无序列表 - item <ul><li>
有序列表 1. item <ol><li>
标题 ### title <h4>
引用 > quote <blockquote>
表格 |a|b| <table><tr><td>
裸 URL https://... <a href="...">

安全措施

危险协议过滤

1
if (/^(?:javascript|data|vbscript|file):/i.test(url)) return txt;

HTML 碎片预清理:AI 有时会输出不完整的 HTML 标签(如 <a href="..."> 末尾缺少 </a>),在 Markdown 处理前先剥离:

1
2
3
4
5
.replace(/<a\s[^>]*>/gi, '')
.replace(/<\/a>/gi, '')
.replace(/\s*target="[^"]*"/gi, '')
.replace(/\s*rel="[^"]*"/gi, '')
.replace(/打开链接\s*/g, '')

链接安全:所有 <a> 统一加 target="_blank" rel="noopener noreferrer",不渲染 <img> 标签。

URL 清理:AI 有时会在 URL 后追加 HTML 属性(如 https://...">text),需精确保留 URL 中的 & 查询参数:

1
2
// 正确:保留 & 参数,只移除尾部 HTML 属性
var href = url.replace(/&amp;/g, '&').replace(/['">\s].*$/, '');

裸 URL 正则兼容性:将 lookbehind (?<!<a [^>]*>) 改为 (^|[\s>]) 前缀捕获,兼容旧版 Safari。

链接样式:亮色模式使用主题色 #37c6c0,暗色模式使用 lighten($ai-primary, 20%),hover 时去掉下划线。

五、System Prompt 工程与 RAG 增强

5.1 博客知识注入

ByteBot 的知识来源是 scripts/generators/ai-index.js 生成的 /api/posts-index.json。构建时自动采集:

1
2
3
4
5
6
7
8
9
10
11
12
13
// posts-index.json 的数据结构
{
posts: [
{ title, url, date, tags, categories, excerpt }
],
meta: {
totalPosts, categories, tags,
albums: { count, dirs: ['img2022','img2023',...] },
games: { count, names: ['贪吃蛇','俄罗斯方块',...] },
videos: { count, years },
fishing: { spotsCount, spotTypes, species, bestSpots }
}
}

buildSystemPrompt() 将这些元数据转化为自然语言描述注入到 system prompt 中,让 AI 了解博客的全貌。

5.2 真实路径修复

最初,system prompt 只告诉 AI “收录在抖音专栏” 或 “2023年鱼获”,没有给出实际 URL。AI 自行猜测路径,导致错误的链接:

用户举报的问题 AI 猜测的路径 正确路径
视频链接错误 /video/ /douyin/
2023 相册链接错误 /fish/2023/ /fish/img2023/

修复方案:在 system prompt 中使用 Markdown 链接模板,AI 直接复制即可:

1
2
- 钓鱼视频:45 个(收录在[抖音专栏](/douyin/))
- 钓鱼相册:5 个([2023年鱼获](/fish/img2023/)、[2024年鱼获](/fish/img2024/)...)

同时新增「博客功能页面」专区,列出所有关键页面的 Markdown 链接:

1
2
3
4
5
6
7
8
博客功能页面(复制这些 Markdown 链接推荐给用户):
- [游戏合集](/ai-games/):6 款小游戏
- [抖音专栏](/douyin/):45 个钓鱼视频
- [钓鱼相册](/fish/):5 个相册
- [钓鱼地图](/fish/map/):N 个钓点
- [教程系列](/tutorials/)
- [关于博主](/about/)
- [留言互动](/guestbook/)

5.3 RAG 关键词匹配

问题:博客有 95+ 篇文章,但 system prompt 只包含最近 10 篇。用户问旧文章内容时 AI 无从知晓。

方案:关键词提取 + 相关性排序的轻量 RAG。

关键词提取extractKeywords):

  • 去停用词(”的、了、吗、是、我、有” 等中文虚词)
  • 分词后去重
  • 60+ 技术关键词辅助匹配(unity、lua、委托、异步、tidb、vercel 等)

相关性排序rankArticles):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function rankArticles(question, posts) {
var keywords = extractKeywords(question);
// 对每篇文章打分
var scored = posts.map(function(post) {
var score = 0;
var text = post.title + ' ' + post.excerpt + ' ' + post.tags.join(' ') + ' ' + post.categories.join(' ');
for (var i = 0; i < keywords.length; i++) {
if (title.match(keyword)) score += 3; // 标题命中权重最高
else if (tags.match(keyword)) score += 2; // 标签次之
else if (excerpt.match(keyword)) score += 1; // 摘要/分类最低
}
return { post, score };
});
// 取 Top-5
return scored.filter(s => s.score > 0).sort((a,b) => b.score - a.score).slice(0, 5).map(s => s.post);
}

没有关键词匹配时,回退最近 5 篇文章作为兜底。

数据流

1
2
用户输入问题 → extractKeywords → rankArticles(question, 95+ posts)
→ Top-5 相关文章 → buildSystemPrompt(index, topArticles) → 注入 prompt

六、移动端全适配

移动端交互是 ByteBot 的薄弱环节,共做了 4 项优化:

6.1 软键盘处理

利用 visualViewport API 检测键盘弹出:

1
2
3
4
5
6
7
8
9
10
function handleMobileKeyboard() {
if ('visualViewport' in window) {
window.visualViewport.addEventListener('resize', function() {
var diff = window.innerHeight - window.visualViewport.height;
if (diff > 100) { // 键盘弹出了
panel.style.bottom = (diff + 10) + 'px';
}
});
}
}

6.2 按钮重叠检测

浮动按钮 bottom: 80px 可能与「回到顶部」「暗色切换」等固定元素重叠。adjustBtnPosition() 遍历所有 position: fixed 元素,检测矩形重叠后自动垫高按钮:

1
2
3
4
5
6
7
8
9
10
11
12
13
function adjustBtnPosition() {
var fixedEls = document.querySelectorAll('*');
for (var i = 0; i < fixedEls.length; i++) {
if (el === btn) continue;
var style = window.getComputedStyle(el);
if (style.position !== 'fixed') continue;
// 检测矩形重叠
if (rect.bottom > btnRect.top && rect.top < btnRect.bottom &&
rect.right > btnRect.left && rect.left < btnRect.right) {
// 有重叠,向上移动
}
}
}

创建按钮后延迟 100ms 调用一次,同时监听 window.resize 实时更新。

6.3 滑动关闭手势

在面板 header 和输入区监听触摸事件,下滑超过 80px 自动关闭:

1
2
3
4
5
6
7
panel.addEventListener('touchstart', function(e) {
touchStartY = e.touches[0].clientY;
}, { passive: true });
panel.addEventListener('touchmove', function(e) {
var dy = e.touches[0].clientY - touchStartY;
if (dy > 80) toggle(); // 下滑关闭
}, { passive: true });

6.4 响应式布局

480px 以下全屏展示:

1
2
3
4
5
6
7
8
@media (max-width: 480px)
#ai-assistant-panel
width: calc(100% - 24px)
height: calc(100vh - 100px)
bottom: 70px
right: 12px
.ai-input-area
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px))

七、部署运维与使用统计

7.1 Vercel 配置

vercel.json 设置 Serverless 函数超时:

1
2
3
4
5
6
7
{
"functions": {
"api/*.js": {
"maxDuration": 30
}
}
}

CORS 白名单(api/chat.js):

1
2
3
4
5
function getCorsOrigin(origin) {
if (origin === 'https://www.bytefisher.top' ||
origin.indexOf('localhost') !== -1) return origin;
return 'https://www.bytefisher.top';
}

7.2 使用统计埋点

localStorage 存储三个计数器:

1
2
3
4
5
6
7
function trackEvent(type) {
var stats = JSON.parse(localStorage.getItem('ai_stats')) || { opens: 0, sends: 0, errors: 0 };
if (type === 'open') stats.opens++;
else if (type === 'send') stats.sends++;
else if (type === 'error') stats.errors++;
localStorage.setItem('ai_stats', JSON.stringify(stats));
}

触发点:打开面板 → 'open',发送消息 → 'send',捕获异常 → 'error'

数据可在浏览器 DevTools → Application → Local Storage 中查看。

7.3 已知问题

问题 影响
PJAX 切换页面后面板状态重置 低,用户可重新点击打开
posts-index.json 体积随文章增长 中,可考虑按需加载
使用统计仅前端 localStorage 低,清除浏览器数据会丢失

八、总结

ByteBot 从最初只有基础问答功能的原型,经过三个轮次的迭代优化,完成了全部 10 项需求:

  • P0 核心体验(流式响应、对话历史、暗色模式、错误提示)— 让助手”好用”
  • P1 功能增强(Markdown 渲染、输入保护、移动端适配)— 让助手”完整”
  • P2 锦上添花(超时优化、RAG 增强、使用统计)— 让助手”聪明”

后续优化方向

  1. 语义向量检索 — 当前 RAG 基于关键词匹配,语义理解有限。可引入 pgvectorCloudflare Vectorize 实现向量搜索
  2. 后端分析面板 — 当前埋点仅前端 localStorage,后续可上报到后端,可视化分析助手活跃度
  3. PJAX 状态恢复 — 修复 Next 主题 PJAX 切换页面后助手状态重置问题

本文所有代码基于 ByteFisher 博客(www.bytefisher.top)的实际生产环境实现,持续迭代中。

ByteFisher
分享编程技术 · 记录钓鱼乐趣
扫码关注
▸ 扫码关注 ◂
分享: