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:
1 | 这是文章链接:<a href="https://www.bytefisher.top/csharp-delegate/">C# 委托详解</a> |
甚至更糟糕的情况:
1 | 推荐阅读:<a href="/unity-basics/">Unity 入门教程 |
缺少 </a> 闭合标签,导致剩余所有回复都被包裹在链接中。
初版修复:在 render 函数入口剥离 HTML 标签:
1 | .replace(/<a\s[^>]*>/gi, '') |
但这只是治标不治本——AI 输出的 HTML 格式变化多端,总有力所不及的边界情况。
2.3 块级元素周围的冗余 <br>
自研渲染器的行处理逻辑是逐行扫描,遇到空行就插入 <br>。问题在于块级元素(<h3>、<ul>、<ol>、<hr>、<blockquote>)周围也会被插入 <br>:
1 | 原始 Markdown: |
四个 <br> 叠加导致了大量空洞间距,AI 回复看起来像是”充满了空气”。
修复方案:在生成 <br> 前检测当前行和下一行的类型,块级元素周围跳过 <br>:
1 | if (/^<\/?(?:h[1-6]|ul|ol|li|blockquote|hr|table|pre|tr|th|td)/i.test(nextLine) || |
2.4 URL 中的空格截断
AI 输出的链接可能包含文件名中的空格,例如 /fish/img2023/ 所在行被渲染为:
1 | - [2023年鱼获](/fish/img2023/) |
但有时 AI 会拼接出带空格的 URL,自研的正则遇到空格就截断了:
1 | // 裸 URL 正则 |
空格被视为 URL 结束符,导致 href 截断。
初版尝试移除空格,结果路径 404。最终改为 encodeURI 保留空格为 %20。
2.5 连续空行
AI 在组织回复时喜欢用多个空行分隔段落,但自研渲染器将每个 \n 都变成 <br>,多个空行导致大片空白:
1 | \n\n\n\n → <br><br><br><br> |
修复方案:在渲染前将连续的 \n{3,} 折叠为 \n\n,只保留最多一个空行。
2.6 正则复杂度失控
自研渲染器从最初的 20 行增长到约 70 行,加上各种补丁函数合计超过 150 行。正则表达式越来越复杂:
1 | // 处理链接的正则 |
更糟糕的是,这些正则之间互相影响——链接正则处理完后再处理加粗,会把链接文本中的 ** 也匹配上,导致 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:
1 | <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> |
但在测试中发现 jsdelivr 在中国部分地区的访问不稳定,时有超时。ByteFisher 博客的主要受众是国内开发者,CDN 失败将导致 AI 助手完全无法渲染。
最终方案:本地部署。将 marked.min.js 和 purify.min.js 存放在 source/js/lib/ 目录,与博客其他静态资源一起通过 GitHub Pages 分发:
1 | <script src="/js/lib/marked.min.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 定制能力,针对聊天面板场景做了以下调整:
1 | marked.use({ |
Heading 降级:聊天面板的消息区域以对话气泡呈现,不需要 h1~`h6的完整层级。将##映射为h3、###映射为h4`,在视觉上更协调,不会显得标题过大。
内部链接处理:link renderer 中检测 bytefisher.top 域名,将绝对 URL 转为相对路径。特别处理了裸域名情况(https://www.bytefisher.top → /),确保首页链接也能正确识别。
4.2 DOMPurify 白名单
DOMPurify 默认会移除所有 HTML 标签和属性,需要显式声明允许的标签:
1 | var html = DOMPurify.sanitize(html, { |
只允许 18 种标签和 3 种属性。这比自研渲染器的安全措施更严格——DOMPurify 会在 AST 层面分析,即使 AI 输出 <img src=x onerror=alert(1)> 也能安全过滤。
4.3 preLinkArticles:文章标题自动链接
虽然 marked 能正确渲染 Markdown 链接,但 ByteBot 的 AI 有时会只输出文章标题文本而不带链接格式。preLinkArticles() 函数在 marked 解析前对文本做预处理:
1 | function preLinkArticles(text) { |
关键设计:
- 去空格归一化匹配:文章标题是
C#学习笔记【委托Delegate】,AI 输出可能是C# 学习笔记【委托Delegate】,去除所有空格后再比较,提高匹配率 - 长标题优先:排序后优先匹配长标题,避免短标题被错误匹配
- 跳过已有链接的行:AI 已经输出 Markdown 链接格式的行不处理,避免双重嵌套
这一方案作为 System Prompt 的补充兜底。即使 AI 无视了”必须使用 Markdown 链接”的指令,preLinkArticles 也能在渲染层自动补上链接。
五、流式渲染优化:requestAnimationFrame 帧级防抖
5.1 问题
ByteBot 使用 SSE 流式接收 AI 回复,最初的实现是每收到一个 token chunk 就调用一次 innerHTML:
1 | function appendToken(text) { |
一个 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 渲染改为按帧批量渲染:
1 | var pending = ''; |
每次收到 chunk 时将内容追加到 pending 缓冲区,如果当前帧没有待处理的渲染任务则通过 requestAnimationFrame 调度下一次渲染。同一帧内的多个 chunk 自动合并为一次渲染。
5.3 效果
- 渲染次数:从 N 次(200+)降低到 ~30 次(60fps 屏幕约 30 帧)
- 布局抖动:大幅减少,用户几乎感知不到渲染过程
- 中间态消除:
requestAnimationFrame天然等待当前宏任务队列清空后才执行,此时积累的 token 足够形成完整的 Markdown 语法单元,不再出现破碎的 HTML - scrollTop 滚动:每帧只滚动一次,配合
async/await不再撕裂视图
5.4 边缘情况处理
1 | function endStream() { |
流结束时需要 cancelAnimationFrame 取消还未执行的帧任务,然后调用 flush() 将缓冲区剩余的 token 全部渲染。如果直接返回而不 flush,最后几个 token 会丢失。
六、链接系统全面修复
6.1 内外链接判定演进
ByteBot 的链接系统经历了 5 次迭代:
- V1:所有链接加
target="_blank"新窗口打开 → 用户反馈内部页面应当前窗口导航 - V2:检测
location.hostname判断内外链接 → 本地开发时localhost与生产环境域名不匹配 - V3:改用
href.indexOf('/') === 0相对路径判断 → AI 可能输出绝对 URL - V4:检测
bytefisher.top域名 → 本地开发(localhost)和127.0.0.1兼容 - V5:marked renderer 中
href.replace(/^https?:\/\/[^\/]+/, '') || '/'→ 裸域名兜底
最终方案在 marked 的 link renderer 中处理:
1 | link: function(href, title, text) { |
6.2 点击拦截与 PJAX 导航
内部链接的点击行为拦截在 ai-msgs 容器上使用事件委托:
1 | document.getElementById('ai-msgs').addEventListener('click', function(e) { |
三个过滤条件:排除 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 - 基础定义:
1 | 你是一个博客助手,帮助访客了解 ByteFisher 博客。 |
V2 - 注入分类信息:
1 | 博客概况: |
V3 - 加入真实 URL 链接:
1 | 博客功能页面(复制这些 Markdown 链接): |
V4 - 文章列表+系列信息:
1 | 可推荐的文章(与用户问题相关): |
V5 - 强制格式指令:
1 | 回答格式要求(重要): |
7.2 迭代经验
经验 1:越具体的指令效果越好
早期 prompt 只说”推荐相关文章”,AI 经常只写标题不带链接。改为”必须使用 Markdown 链接格式 - [标题](URL)“后,链接输出率从约 60% 提升到 90%+。加上 preLinkArticles 兜底后接近 100%。
经验 2:不要相信 AI 的”知识”
AI 有时会自己编造系列名称。例如博客观存在”Unity 基础教程”系列,但 AI 可能输出”Unity 入门到精通”这种不存在的系列名。
修复方案:通过 seriesSummary 数据将真实系列信息注入 prompt,并明确标注”只能从以下列表中推荐,不要编造不存在的系列”。
经验 3:给 AI 提供”抄作业”模板
在 prompt 中给出完整的 Markdown 链接模板,AI 只需复制粘贴即可:
1 | - [游戏合集](/ai-games/):6 款小游戏 |
这比描述”请用 Markdown 链接格式输出”有效得多。
7.3 RAG 关键词库扩容
关键词库从最初的 30+ 扩展到 90+,覆盖更多垂直领域:
1 | var TECH_KEYWORDS = [ |
八、PJAX 兼容性深坑
8.1 事件监听器重复绑定
Next 主题使用 PJAX 实现无刷新页面切换,data-pjax 属性的脚本在每次页面导航后重新执行。这导致:
1 | 首次加载 → ai-assistant.js 执行 → addEventListener('click', send) |
每次导航都新增一个监听器,用户发送一次消息会触发 N 次请求。
修复方案:在绑定前先移除旧监听器:
1 | window.removeEventListener('resize', adjustBtnPosition); |
以及 init() 函数的防重复创建守卫:
1 | function init() { |
8.2 按钮位置累积偏移
adjustBtnPosition() 检测与其他 position: fixed 元素的重叠并自动垫高按钮位置。但在 PJAX 导航后,style.bottom 在上一次计算的值基础上再次叠加:
1 | 首次加载 → bottom: 80px(无重叠) |
多次导航后按钮越跑越高。
修复方案:每次重新计算前先重置 bottom:
1 | btn.style.bottom = ''; |
8.3 Waline 访客计数双倍请求
评论区的 Waline 访客计数在 body-end.swig 中有两个独立的 fetchCounts() 调用块,PJAX 导航后两者都会重新执行,导致同一篇文章发送两次计数请求。
修复方案:合并为一个统一的 updateCounts() 函数,移除冗余的 MutationObserver,通过 2 秒延时兜底确保数据更新。
8.4 会话持久化
PJAX 切换页面后,messages[] 数组被重置,对话历史丢失。解决方案是 sessionStorage:
1 | function restoreSession() { |
每次 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)的实际生产环境实现,30+ 次提交记录可在 GitHub 查看。