ByteBot 从第一版上线至今已经过 30+ 次提交迭代,从最初的自研 70 行 Markdown 渲染器到如今的 marked@4 + DOMPurify 专业方案,中间踩过的坑、走过的弯路值得记录。本文以前两篇为基础,聚焦渲染引擎迁移、链接修复、流式防抖、System Prompt 工程等核心优化。
一、引言
ByteBot 的前两次迭代分别在 Hexo 博客集成 AI 问答助手:DeepSeek + Vercel + 悬浮球聊天面板 和 Hexo 博客 AI 问答助手 ByteBot 全栈实践:从流式响应到 RAG 增强 中有详细记录。
总结一下 ByteBot 在第三次迭代前的状态:
自研 ~70 行正则 Markdown 渲染器,支持 8 种语法(代码块、列表、表格、链接、粗体、标题、引用、裸 URL)
全链路 SSE 流式响应,逐 token 更新 DOM
RAG 关键词匹配,95+ 篇文章中检索 Top-5 注入 prompt
暗色模式、移动端适配、输入保护、使用统计埋点
看起来功能完备,但上线后真实流量暴露了自研方案的诸多问题:
AI 模型输出不稳定,时常携带 HTML 碎片
正则匹配在边界情况下频繁出错
流式逐 token 渲染导致中间态问题
链接识别在本地开发和生产环境表现不一致
本文将完整记录从发现问题到迁移重构的全部过程,涵盖 6 大优化方向、30+ 次代码提交的实践经验。
二、自研 Markdown 引擎的崩溃边缘
2.1 问题暴露
自研渲染器在最初的上线阶段表现良好,但随着更多用户使用和 AI 回复多样性增加,问题开始逐一暴露。
2.2 AI 输出 HTML 碎片
ByteBot 的 System Prompt 要求 AI 输出 Markdown 格式链接,但 DeepSeek 模型有时会输出不完整的 HTML:
这是文章链接:<a href="https://www.bytefisher.top/csharp-delegate/">C# 委托详解</a>甚至更糟糕的情况:
推荐阅读:<a href="/unity-basics/">Unity 入门教程缺少 </a> 闭合标签,导致剩余所有回复都被包裹在链接中。
初版修复:在 render 函数入口剥离 HTML 标签:
.replace(/<a\s[^>]*>/gi, '')
.replace(/<\/a>/gi, '')
.replace(/\s*target="[^"]*"/gi, '')
.replace(/\s*rel="[^"]*"/gi, '')
.replace(/打开链接\s*/g, '')但这只是治标不治本——AI 输出的 HTML 格式变化多端,总有力所不及的边界情况。
2.3 块级元素周围的冗余 `
`
自研渲染器的行处理逻辑是逐行扫描,遇到空行就插入 <br>。问题在于块级元素(<h3>、<ul>、<ol>、<hr>、<blockquote>)周围也会被插入 <br>:
原始 Markdown:
### 标题
- 列表项 1
- 列表项 2
渲染结果:
<br><h3>标题</h3><br><br><ul><li>列表项 1</li><li>列表项 2</li></ul><br>四个 <br> 叠加导致了大量空洞间距,AI 回复看起来像是"充满了空气"。
修复方案:在生成 <br> 前检测当前行和下一行的类型,块级元素周围跳过 <br>:
if (/^<\/?(?:h[1-6]|ul|ol|li|blockquote|hr|table|pre|tr|th|td)/i.test(nextLine) ||
/^<\/?(?:h[1-6]|ul|ol|li|blockquote|hr|table|pre|tr|th|td)/i.test(currentLine)) {
// skip <br>
}2.4 URL 中的空格截断
AI 输出的链接可能包含文件名中的空格,例如 /fish/img2023/ 所在行被渲染为:
- [2023年鱼获](/fish/img2023/)但有时 AI 会拼接出带空格的 URL,自研的正则遇到空格就截断了:
// 裸 URL 正则
var url = text.match(/https?:\/\/[^\s>"]+/);空格被视为 URL 结束符,导致 href 截断。
初版尝试移除空格,结果路径 404。最终改为 encodeURI 保留空格为 %20。
2.5 连续空行
AI 在组织回复时喜欢用多个空行分隔段落,但自研渲染器将每个 \n 都变成 <br>,多个空行导致大片空白:
\n\n\n\n → <br><br><br><br>修复方案:在渲染前将连续的 \n{3,} 折叠为 \n\n,只保留最多一个空行。
2.6 正则复杂度失控
自研渲染器从最初的 20 行增长到约 70 行,加上各种补丁函数合计超过 150 行。正则表达式越来越复杂:
// 处理链接的正则
text = text.replace(
/(^|[\s>])(https?:\/\/[^\s<>"'\]\)]+(?:\/[^\s<>"'\]\)]*)?)/gi,
function(m, prefix, url) { ... }
);更糟糕的是,这些正则之间互相影响——链接正则处理完后再处理加粗,会把链接文本中的 ** 也匹配上,导致 HTML 结构破坏。
每次修改都像是"按下葫芦浮起瓢",修复一个 bug 引入两个新 bug。
三、架构决策:为什么选择 marked@4 + DOMPurify
3.1 选项评估
在决定替换自研渲染器之前,评估了以下方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| marked@4 | 轻量(49KB)、CommonMark 兼容、支持 renderer 定制 | 本身不防 XSS |
| showdon | 更轻量(20KB) | 功能太少,扩展性差 |
| markdown-it | 生态丰富,插件多 | 体积偏大(70KB+) |
| remark | 基于 AST,功能强大 | 太重量级(100KB+) |
| marked@4 + DOMPurify | 各司其职、体积适中、安全可控 | 需额外 26KB 依赖 |
最终选择了 marked@4 + DOMPurify@3 组合:
marked 负责标准 Markdown 解析,无需维护正则
DOMPurify 提供 XSS 防护,白名单机制精确控制可渲染的标签和属性
两者都是久经考验的成熟库,社区广泛使用
3.2 本地部署 vs CDN
这是一个重要的决策点。初始实现时使用了 CDN:
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>但在测试中发现 jsdelivr 在中国部分地区的访问不稳定,时有超时。ByteFisher 博客的主要受众是国内开发者,CDN 失败将导致 AI 助手完全无法渲染。
最终方案:本地部署。将 marked.min.js 和 purify.min.js 存放在 source/js/lib/ 目录,与博客其他静态资源一起通过 GitHub Pages 分发:
<script src="/js/lib/marked.min.js" data-pjax></script>
<script src="/js/lib/purify.min.js" data-pjax></script>
<script src="/js/ai-assistant.js" data-pjax></script>两个文件合计 75KB(未压缩),gzip 后约 20KB,加上浏览器缓存和 Service Worker,首次加载后完全离线可用。
3.3 向后兼容
迁移的关键要求是 现有代码零改动。自研渲染器的接口是 render(text) 返回 HTML string,marked 同样满足。替换后,所有调用方(addMsg、handleStream、restoreMessagesToDOM)无需任何修改。
迁移回滚也极其简单——只需注释两行 script 标签,恢复旧的 render 函数定义即可。
四、marked 定制与精细化控制
4.1 自定义 Renderer
marked 提供了 renderer 定制能力,针对聊天面板场景做了以下调整:
marked.use({
renderer: {
heading: function(text, level) {
// 降低标题层级:h1/h2 → h3, h3+ → h4
var tag = level <= 2 ? 'h3' : 'h4';
return '<' + tag + '>' + text + '</' + tag + '>';
},
link: function(href, title, text) {
// 站内链接转相对路径
if (href.indexOf('bytefisher.top') !== -1) {
href = href.replace(/^https?:\/\/[^\/]+/, '') || '/';
}
var isInternal = href.indexOf('/') === 0;
return '<a href="' + href + '"' +
(isInternal ? '' : ' target="_blank" rel="noopener noreferrer"') +
'>' + text + '</a>';
}
},
gfm: true,
breaks: true
});Heading 降级:聊天面板的消息区域以对话气泡呈现,不需要 h1~`h6的完整层级。将##映射为h3、###映射为h4`,在视觉上更协调,不会显得标题过大。
内部链接处理:link renderer 中检测 bytefisher.top 域名,将绝对 URL 转为相对路径。特别处理了裸域名情况(https://www.bytefisher.top → /),确保首页链接也能正确识别。
4.2 DOMPurify 白名单
DOMPurify 默认会移除所有 HTML 标签和属性,需要显式声明允许的标签:
var html = DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'h3', 'h4', 'p', 'br', 'hr',
'strong', 'b', 'em', 'i',
'code', 'pre', 'a',
'ul', 'ol', 'li',
'blockquote',
'table', 'thead', 'tbody', 'tr', 'th', 'td'
],
ALLOWED_ATTR: ['href', 'target', 'rel']
});只允许 18 种标签和 3 种属性。这比自研渲染器的安全措施更严格——DOMPurify 会在 AST 层面分析,即使 AI 输出 <img src=x onerror=alert(1)> 也能安全过滤。
4.3 preLinkArticles:文章标题自动链接
虽然 marked 能正确渲染 Markdown 链接,但 ByteBot 的 AI 有时会只输出文章标题文本而不带链接格式。preLinkArticles() 函数在 marked 解析前对文本做预处理:
function preLinkArticles(text) {
// 按标题长度降序排序,长标题优先匹配
var sorted = postsIndexCache.posts.slice()
.sort(function(a, b) { return b.title.length - a.title.length; });
var lines = text.split('\n');
for (var li = 0; li < lines.length; li++) {
var line = lines[li].trim();
if (!line || line.indexOf('[') === 0) continue; // 已有链接则跳过
var normLine = line.replace(/\s/g, '');
for (var pi = 0; pi < sorted.length; pi++) {
var title = sorted[pi].title;
var normTitle = title.replace(/\s/g, '');
if (normLine === normTitle || normLine.indexOf(normTitle) === 0) {
lines[li] = '[' + title + '](' + sorted[pi].url + ')' +
line.substring(title.length);
break;
}
}
}
return lines.join('\n');
}关键设计:
去空格归一化匹配:文章标题是
C#学习笔记【委托Delegate】,AI 输出可能是C# 学习笔记【委托Delegate】,去除所有空格后再比较,提高匹配率长标题优先:排序后优先匹配长标题,避免短标题被错误匹配
跳过已有链接的行:AI 已经输出 Markdown 链接格式的行不处理,避免双重嵌套
这一方案作为 System Prompt 的补充兜底。即使 AI 无视了"必须使用 Markdown 链接"的指令,preLinkArticles 也能在渲染层自动补上链接。
五、流式渲染优化:requestAnimationFrame 帧级防抖
5.1 问题
ByteBot 使用 SSE 流式接收 AI 回复,最初的实现是每收到一个 token chunk 就调用一次 innerHTML:
function appendToken(text) {
fullReply += text;
botDiv.innerHTML = render(fullReply);
}一个 200 token 的回复会触发 200 次 DOM 更新。这导致:
布局抖动:浏览器频繁重排重绘,CPU 占用高
中间态碎片:
render(fullReply)在 token 未完整时渲染,可能产生不完整的 HTML。例如 URLhttps://www.bytefisher.top/csharp/被分成了 5 个 chunk,每个 chunk 触发一次渲染,用户会看到链接逐个字符跳出来链接误判:
marked.link中的isInternal检测依赖完整的href。中间态时href不完整,可能被错误地加上target="_blank"
5.2 方案:requestAnimationFrame 批处理
将逐 token 渲染改为按帧批量渲染:
var pending = '';
var rafId = null;
function flush() {
rafId = null;
if (!botDiv) {
botDiv = createBotMessageDiv();
hideTyping();
}
fullReply += pending;
pending = '';
botDiv.innerHTML = render(fullReply);
var container = document.getElementById('ai-msgs');
container.scrollTop = container.scrollHeight;
}
function queue(text) {
pending += text;
if (rafId === null) {
rafId = requestAnimationFrame(flush);
}
}每次收到 chunk 时将内容追加到 pending 缓冲区,如果当前帧没有待处理的渲染任务则通过 requestAnimationFrame 调度下一次渲染。同一帧内的多个 chunk 自动合并为一次渲染。
5.3 效果
渲染次数:从 N 次(200+)降低到 ~30 次(60fps 屏幕约 30 帧)
布局抖动:大幅减少,用户几乎感知不到渲染过程
中间态消除:
requestAnimationFrame天然等待当前宏任务队列清空后才执行,此时积累的 token 足够形成完整的 Markdown 语法单元,不再出现破碎的 HTMLscrollTop 滚动:每帧只滚动一次,配合
async/await不再撕裂视图
5.4 边缘情况处理
function endStream() {
if (rafId !== null) {
cancelAnimationFrame(rafId); // 取消未执行的帧
rafId = null;
}
flush(); // 强制刷出剩余缓冲区
if (fullReply) {
messages.push({ role: 'assistant', content: fullReply });
saveSession();
}
isLoading = false;
showStopBtn(false);
}流结束时需要 cancelAnimationFrame 取消还未执行的帧任务,然后调用 flush() 将缓冲区剩余的 token 全部渲染。如果直接返回而不 flush,最后几个 token 会丢失。
六、链接系统全面修复
6.1 内外链接判定演进
ByteBot 的链接系统经历了 5 次迭代:
V1:所有链接加
target="_blank"新窗口打开 → 用户反馈内部页面应当前窗口导航V2:检测
location.hostname判断内外链接 → 本地开发时localhost与生产环境域名不匹配V3:改用
href.indexOf('/') === 0相对路径判断 → AI 可能输出绝对 URLV4:检测
bytefisher.top域名 → 本地开发(localhost)和127.0.0.1兼容V5:marked renderer 中
href.replace(/^https?:\/\/[^\/]+/, '') || '/'→ 裸域名兜底
最终方案在 marked 的 link renderer 中处理:
link: function(href, title, text) {
if (href.indexOf('bytefisher.top') !== -1) {
href = href.replace(/^https?:\/\/[^\/]+/, '') || '/';
}
var isInternal = href.indexOf('/') === 0;
return '<a href="' + href + '"' +
(isInternal ? '' : ' target="_blank" rel="noopener noreferrer"') +
'>' + text + '</a>';
}6.2 点击拦截与 PJAX 导航
内部链接的点击行为拦截在 ai-msgs 容器上使用事件委托:
document.getElementById('ai-msgs').addEventListener('click', function(e) {
var link = e.target.closest('a');
if (!link) return;
var href = link.getAttribute('href');
if (!href || href.indexOf('javascript:') === 0) return;
if (link.getAttribute('target') === '_blank') return; // 外链不拦截
var isInternal = href.indexOf('/') === 0 || href.indexOf('bytefisher.top') !== -1;
if (!isInternal) return;
e.preventDefault();
if (window.pjax && typeof window.pjax.loadUrl === 'function') {
window.pjax.loadUrl(href);
} else {
location.href = href;
}
});三个过滤条件:排除 javascript: 伪协议、排除外链(target="_blank")、排除非站内链接。最终通过 PJAX 的 loadUrl 实现无刷新导航,保留聊天面板的对话状态。
6.3 六个链接相关 Bug 修复记录
| 问题 | 现象 | 修复 |
|---|---|---|
| URL 空格截断 | 链接在空格处断开 | 空格编码为 %20 |
| HTML 属性泄漏 | AI 输出 <a href="..."> 片段 | marked 统一解析,DOMPurify 过滤 |
| 裸 URL 不可点击 | AI 输出 https://... 纯文本 | marked 的 gfm: true 自动链接 |
| 站内链接新窗口 | 内部页面在新标签打开 | href 检测 + PJAX loadUrl |
[] 标题解析 | [C#]委托 被识别为 Markdown 链接 | AI 输出 \[ 转义,marked 正确解析 |
| 裸域名无路径 | https://www.bytefisher.top → 空 href | ` |
七、System Prompt 迭代工程
7.1 从纯文本到结构化 prompt
ByteBot 的 System Prompt 从最初的 10 行简单文本,演进到包含博客元数据、文章目录、强约束规则的 40+ 行结构化 prompt。
V1 - 基础定义:
你是一个博客助手,帮助访客了解 ByteFisher 博客。
作者:淡水鱼(Unity 游戏开发者 + 钓鱼爱好者)V2 - 注入分类信息:
博客概况:
- 文章:共 95 篇
- 小游戏:6 款(贪吃蛇、俄罗斯方块...)
- 钓鱼视频:45 个(收录在抖音专栏)
- 钓鱼相册:5 个V3 - 加入真实 URL 链接:
博客功能页面(复制这些 Markdown 链接):
- [游戏合集](/ai-games/):6 款小游戏
- [抖音专栏](/douyin/):45 个钓鱼视频
- [钓鱼相册](/fish/):5 个相册V4 - 文章列表+系列信息:
可推荐的文章(与用户问题相关):
- [Unity3D正交相机视野控制](/unity-orthographic-camera/)
所属系列:Unity基础教程
包含章节:视口控制、缩放实现、边界限制V5 - 强制格式指令:
回答格式要求(重要):
- 用 `## 小标题` 分隔不同板块
- 用 `---` 分隔不同的功能或分类模块
- 用 `- ` 无序列表罗列条目
- 推荐文章/页面/相册时,**必须**使用 Markdown 链接格式
- **严禁**只写纯文本标题而不带链接7.2 迭代经验
经验 1:越具体的指令效果越好
早期 prompt 只说"推荐相关文章",AI 经常只写标题不带链接。改为"必须使用 Markdown 链接格式 - [标题](URL)"后,链接输出率从约 60% 提升到 90%+。加上 preLinkArticles 兜底后接近 100%。
经验 2:不要相信 AI 的"知识"
AI 有时会自己编造系列名称。例如博客观存在"Unity 基础教程"系列,但 AI 可能输出"Unity 入门到精通"这种不存在的系列名。
修复方案:通过 seriesSummary 数据将真实系列信息注入 prompt,并明确标注"只能从以下列表中推荐,不要编造不存在的系列"。
经验 3:给 AI 提供"抄作业"模板
在 prompt 中给出完整的 Markdown 链接模板,AI 只需复制粘贴即可:
- [游戏合集](/ai-games/):6 款小游戏
- [抖音专栏](/douyin/):45 个钓鱼视频这比描述"请用 Markdown 链接格式输出"有效得多。
7.3 RAG 关键词库扩容
关键词库从最初的 30+ 扩展到 90+,覆盖更多垂直领域:
var TECH_KEYWORDS = [
'unity', 'c#', 'lua', 'python',
'委托', 'delegate', '异步', 'async',
'协程', '线程', '性能', '优化',
'linq', '泛型', '反射', '特性',
'ai', 'deepseek', 'opencode', 'rag',
'vercel', 'docker', 'waline', 'tidb',
'seo', 'pwa', 'cdn', 'pjax',
'抖音', '微信', 'github', '图床',
// ... 共 90+ 个关键词
];八、PJAX 兼容性深坑
8.1 事件监听器重复绑定
Next 主题使用 PJAX 实现无刷新页面切换,data-pjax 属性的脚本在每次页面导航后重新执行。这导致:
首次加载 → ai-assistant.js 执行 → addEventListener('click', send)
PJAX 导航 → ai-assistant.js 重新执行 → addEventListener('click', send)(第二次)
PJAX 导航 → ai-assistant.js 重新执行 → addEventListener('click', send)(第三次)每次导航都新增一个监听器,用户发送一次消息会触发 N 次请求。
修复方案:在绑定前先移除旧监听器:
window.removeEventListener('resize', adjustBtnPosition);
window.addEventListener('resize', adjustBtnPosition);
document.removeEventListener('pjax:complete', adjustBtnPosition);
document.addEventListener('pjax:complete', adjustBtnPosition);以及 init() 函数的防重复创建守卫:
function init() {
if (document.getElementById('ai-assistant-btn')) return;
// ...
}8.2 按钮位置累积偏移
adjustBtnPosition() 检测与其他 position: fixed 元素的重叠并自动垫高按钮位置。但在 PJAX 导航后,style.bottom 在上一次计算的值基础上再次叠加:
首次加载 → bottom: 80px(无重叠)
PJAX 导航 → bottom: 80px + 30px(检测到重叠) = 110px
PJAX 导航 → bottom: 110px + 30px = 140px多次导航后按钮越跑越高。
修复方案:每次重新计算前先重置 bottom:
btn.style.bottom = '';
btn.style.removeProperty('bottom');8.3 Waline 访客计数双倍请求
评论区的 Waline 访客计数在 body-end.swig 中有两个独立的 fetchCounts() 调用块,PJAX 导航后两者都会重新执行,导致同一篇文章发送两次计数请求。
修复方案:合并为一个统一的 updateCounts() 函数,移除冗余的 MutationObserver,通过 2 秒延时兜底确保数据更新。
8.4 会话持久化
PJAX 切换页面后,messages[] 数组被重置,对话历史丢失。解决方案是 sessionStorage:
function restoreSession() {
var saved = sessionStorage.getItem('ai_messages');
if (saved) messages = JSON.parse(saved);
if (sessionStorage.getItem('ai_open') === '1') isOpen = true;
}
function saveSession() {
sessionStorage.setItem('ai_messages', JSON.stringify(messages));
sessionStorage.setItem('ai_open', isOpen ? '1' : '0');
}每次 messages 变化(用户发送、AI 回复完成)都调用 saveSession(),PJAX 导航后 restoreSession() 恢复对话。同时记录面板开闭状态——如果 PJAX 前面板是打开的,导航后自动恢复展开。
九、性能与体积分析
9.1 网络加载
| 资源 | 体积(未压缩) | gzip | 缓存策略 |
|---|---|---|---|
| marked.min.js | 49KB | ~14KB | 强缓存 + Service Worker |
| purify.min.js | 26KB | ~6KB | 强缓存 + Service Worker |
| ai-assistant.js | 25KB | ~7KB | 强缓存 + Service Worker |
| 总计 | 100KB | ~27KB | 首次加载后完全离线 |
两个库脚本放在 ai-assistant.js 之前加载,确保 render 函数调用时 marked 和 DOMPurify 已就绪。同时也按照 <script> 标签的顺序加载,没有使用 defer/async,避免竞态条件。
9.2 渲染性能对比
| 指标 | 自研正则引擎 | marked@4 + DOMPurify | 改善 |
|---|---|---|---|
| 渲染 200 token 的 DOM 操作次数 | ~200 次 | ~30 次(帧级防抖) | 85%↓ |
| 解析完整 Markdown 耗时 | ~2ms | ~5ms | 略增但可忽略 |
| 边界情况处理 | 20+ 正则补丁 | CommonMark 标准 | 全面覆盖 |
| XSS 防护 | 手工正则 HTML 转义 | DOMPurify AST 级过滤 | 安全提升 |
| 代码维护量(render 相关) | ~150 行 | ~50 行(+2 个库) | 67%↓ |
解析耗时从 2ms 增到 5ms,但对流式渲染来说完全无感(帧级防抖将渲染频率限制在 30fps,单次 5ms 远低于 33ms 的帧预算)。
9.3 成本
新增两个库文件没有增加额外运营成本——它们和博客其他静态资源一样通过 GitHub Pages 分发,完全免费。
十、总结
10.1 迭代里程碑
ByteBot 从上线至今经历了三个阶段的迭代:
| 阶段 | 时间 | 核心主题 | 涉及文件 |
|---|---|---|---|
| P0 基础 | 5 月初 | 流式响应、对话历史、暗色模式 | ai-assistant.js (初始 167 行) |
| P1 完整 | 5 月中 | RAG 增强、Markdown 渲染、移动适配 | ai-assistant.js (~400 行) |
| P2 优化 | 5 月底 | 渲染引擎迁移、链接修复、PJAX 兼容 | ai-assistant.js (692 行) + lib/ 库 |
10.2 经验教训
不要重新发明轮子 — 自研 Markdown 渲染器在初期看似轻量优雅,但真实流量的多样性远超预期。成熟库的投入产出比远超自研,尤其是在安全性和边界情况处理上。
流式渲染要考虑中间态 — 逐 token 更新 DOM 不仅性能差,还会出现破碎的 UI。
requestAnimationFrame批处理是流式 UI 的标配方案。AI 输出不可控 — System Prompt 可以用"必须"和"严禁"来约束,但不能完全信赖。渲染层必须做兜底处理(
preLinkArticles、DOMPurify 过滤、URL 清洗)。PJAX 兼容需要全局视角 — 事件监听器重复绑定、位置累积偏移、会话丢失,每个问题看似独立,实则都源于 PJAX 的特性。
data-pjax不是银弹,需要配合守卫检查、状态持久化等策略。国内用户的 CDN 问题 — jsdelivr 等公共 CDN 在国内不稳定,面向国内用户的站点尽量本地部署静态资源。
10.3 后续方向
向量检索 — 当前 RAG 基于关键词匹配,语义理解有限。可引入
pgvector或Cloudflare Vectorize实现向量搜索,从关键词匹配升级为语义检索对话分支 — 用户可以在对话中选择不同的回答方向,类似决策树的交互体验
实时反馈 — 在输入框下方显示已匹配的文章数和关键词命中情况
本文所有代码基于 ByteFisher 博客([www.bytefisher.top](https://www.bytefisher.top))的实际生产环境实现,30+ 次提交记录可在 [GitHub](https://github.com/grj1981/BlogCode/commits/master/source/js/ai-assistant.js) 查看。
分类:Hexo博客搭建、AI
标签:series:Hexo博客搭建、Hexo、AI、DeepSeek、博客开发

夜雨聆风