Hexo 博客 AI 问答助手 ByteBot 优化实录:Markdown 渲染迁移与踩坑记

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

但这只是治标不治本——AI 输出的 HTML 格式变化多端,总有力所不及的边界情况。

2.3 块级元素周围的冗余 <br>

自研渲染器的行处理逻辑是逐行扫描,遇到空行就插入 <br>。问题在于块级元素(<h3><ul><ol><hr><blockquote>)周围也会被插入 <br>

1
2
3
4
5
6
7
原始 Markdown:
### 标题
- 列表项 1
- 列表项 2

渲染结果:
<br><h3>标题</h3><br><br><ul><li>列表项 1</li><li>列表项 2</li></ul><br>

四个 <br> 叠加导致了大量空洞间距,AI 回复看起来像是”充满了空气”。

修复方案:在生成 <br> 前检测当前行和下一行的类型,块级元素周围跳过 <br>

1
2
3
4
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/ 所在行被渲染为:

1
- [2023年鱼获](/fish/img2023/)

但有时 AI 会拼接出带空格的 URL,自研的正则遇到空格就截断了:

1
2
// 裸 URL 正则
var url = text.match(/https?:\/\/[^\s>"]+/);

空格被视为 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
2
3
4
5
// 处理链接的正则
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:

1
2
<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 分发:

1
2
3
<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 同样满足。替换后,所有调用方(addMsghandleStreamrestoreMessagesToDOM)无需任何修改。

迁移回滚也极其简单——只需注释两行 script 标签,恢复旧的 render 函数定义即可。

四、marked 定制与精细化控制

4.1 自定义 Renderer

marked 提供了 renderer 定制能力,针对聊天面板场景做了以下调整:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 标签和属性,需要显式声明允许的标签:

1
2
3
4
5
6
7
8
9
10
11
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 解析前对文本做预处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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

1
2
3
4
function appendToken(text) {
fullReply += text;
botDiv.innerHTML = render(fullReply);
}

一个 200 token 的回复会触发 200 次 DOM 更新。这导致:

  1. 布局抖动:浏览器频繁重排重绘,CPU 占用高
  2. 中间态碎片render(fullReply) 在 token 未完整时渲染,可能产生不完整的 HTML。例如 URL https://www.bytefisher.top/csharp/ 被分成了 5 个 chunk,每个 chunk 触发一次渲染,用户会看到链接逐个字符跳出来
  3. 链接误判marked.link 中的 isInternal 检测依赖完整的 href。中间态时 href 不完整,可能被错误地加上 target="_blank"

5.2 方案:requestAnimationFrame 批处理

将逐 token 渲染改为按帧批量渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 语法单元,不再出现破碎的 HTML
  • scrollTop 滚动:每帧只滚动一次,配合 async/await 不再撕裂视图

5.4 边缘情况处理

1
2
3
4
5
6
7
8
9
10
11
12
13
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 可能输出绝对 URL
  • V4:检测 bytefisher.top 域名 → 本地开发(localhost)和 127.0.0.1 兼容
  • V5:marked renderer 中 href.replace(/^https?:\/\/[^\/]+/, '') || '/' → 裸域名兜底

最终方案在 marked 的 link renderer 中处理:

1
2
3
4
5
6
7
8
9
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 容器上使用事件委托:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 - 基础定义

1
2
你是一个博客助手,帮助访客了解 ByteFisher 博客。
作者:淡水鱼(Unity 游戏开发者 + 钓鱼爱好者)

V2 - 注入分类信息

1
2
3
4
5
博客概况:
- 文章:共 95 篇
- 小游戏:6 款(贪吃蛇、俄罗斯方块...)
- 钓鱼视频:45 个(收录在抖音专栏)
- 钓鱼相册:5 个

V3 - 加入真实 URL 链接

1
2
3
4
博客功能页面(复制这些 Markdown 链接):
- [游戏合集](/ai-games/):6 款小游戏
- [抖音专栏](/douyin/):45 个钓鱼视频
- [钓鱼相册](/fish/):5 个相册

V4 - 文章列表+系列信息

1
2
3
4
可推荐的文章(与用户问题相关):
- [Unity3D正交相机视野控制](/unity-orthographic-camera/)
所属系列:Unity基础教程
包含章节:视口控制、缩放实现、边界限制

V5 - 强制格式指令

1
2
3
4
5
6
回答格式要求(重要):
- 用 `## 小标题` 分隔不同板块
- 用 `---` 分隔不同的功能或分类模块
- 用 `- ` 无序列表罗列条目
- 推荐文章/页面/相册时,**必须**使用 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 只需复制粘贴即可:

1
2
- [游戏合集](/ai-games/):6 款小游戏
- [抖音专栏](/douyin/):45 个钓鱼视频

这比描述”请用 Markdown 链接格式输出”有效得多。

7.3 RAG 关键词库扩容

关键词库从最初的 30+ 扩展到 90+,覆盖更多垂直领域:

1
2
3
4
5
6
7
8
9
10
11
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 属性的脚本在每次页面导航后重新执行。这导致:

1
2
3
首次加载 → ai-assistant.js 执行 → addEventListener('click', send)
PJAX 导航 → ai-assistant.js 重新执行 → addEventListener('click', send)(第二次)
PJAX 导航 → ai-assistant.js 重新执行 → addEventListener('click', send)(第三次)

每次导航都新增一个监听器,用户发送一次消息会触发 N 次请求。

修复方案:在绑定前先移除旧监听器:

1
2
3
4
window.removeEventListener('resize', adjustBtnPosition);
window.addEventListener('resize', adjustBtnPosition);
document.removeEventListener('pjax:complete', adjustBtnPosition);
document.addEventListener('pjax:complete', adjustBtnPosition);

以及 init() 函数的防重复创建守卫:

1
2
3
4
function init() {
if (document.getElementById('ai-assistant-btn')) return;
// ...
}

8.2 按钮位置累积偏移

adjustBtnPosition() 检测与其他 position: fixed 元素的重叠并自动垫高按钮位置。但在 PJAX 导航后,style.bottom 在上一次计算的值基础上再次叠加:

1
2
3
首次加载 → bottom: 80px(无重叠)
PJAX 导航 → bottom: 80px + 30px(检测到重叠) = 110px
PJAX 导航 → bottom: 110px + 30px = 140px

多次导航后按钮越跑越高。

修复方案:每次重新计算前先重置 bottom

1
2
btn.style.bottom = '';
btn.style.removeProperty('bottom');

8.3 Waline 访客计数双倍请求

评论区的 Waline 访客计数在 body-end.swig 中有两个独立的 fetchCounts() 调用块,PJAX 导航后两者都会重新执行,导致同一篇文章发送两次计数请求。

修复方案:合并为一个统一的 updateCounts() 函数,移除冗余的 MutationObserver,通过 2 秒延时兜底确保数据更新。

8.4 会话持久化

PJAX 切换页面后,messages[] 数组被重置,对话历史丢失。解决方案是 sessionStorage

1
2
3
4
5
6
7
8
9
10
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 经验教训

  1. 不要重新发明轮子 — 自研 Markdown 渲染器在初期看似轻量优雅,但真实流量的多样性远超预期。成熟库的投入产出比远超自研,尤其是在安全性和边界情况处理上。

  2. 流式渲染要考虑中间态 — 逐 token 更新 DOM 不仅性能差,还会出现破碎的 UI。requestAnimationFrame 批处理是流式 UI 的标配方案。

  3. AI 输出不可控 — System Prompt 可以用”必须”和”严禁”来约束,但不能完全信赖。渲染层必须做兜底处理(preLinkArticles、DOMPurify 过滤、URL 清洗)。

  4. PJAX 兼容需要全局视角 — 事件监听器重复绑定、位置累积偏移、会话丢失,每个问题看似独立,实则都源于 PJAX 的特性。data-pjax 不是银弹,需要配合守卫检查、状态持久化等策略。

  5. 国内用户的 CDN 问题 — jsdelivr 等公共 CDN 在国内不稳定,面向国内用户的站点尽量本地部署静态资源。

10.3 后续方向

  • 向量检索 — 当前 RAG 基于关键词匹配,语义理解有限。可引入 pgvectorCloudflare Vectorize 实现向量搜索,从关键词匹配升级为语义检索
  • 对话分支 — 用户可以在对话中选择不同的回答方向,类似决策树的交互体验
  • 实时反馈 — 在输入框下方显示已匹配的文章数和关键词命中情况

本文所有代码基于 ByteFisher 博客(www.bytefisher.top)的实际生产环境实现,30+ 次提交记录可在 GitHub 查看。

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