本文内容可能已经过时,相关配置或版本可能已失效,请以最新文档为准。

AI 助手 2.0:多轮对话、卡片化与文章页感知

前言

六月份的时候给博客装了一个首页 AI 小助手,记录在

里:Express 起一个 5004 端口的服务,抓 RSS 建文章索引,SSE 流式把大模型的回复吐给前端,前端在右下角挂一个对话窗。

用了四个月,功能没坏,但体验上的毛病越攒越多。这一轮集中修了一遍,顺手把几个”其实一直在丢数据”的 bug 也挖出来了。这篇按问题拆开写,代码都是线上跑着的版本。

文章页打开对话框:提示「你正在看《…》」,快捷按钮跟着变成「本文摘要 / 文章推荐 / 关于我」

一次对话暴露的六个问题

随便找一段真实对话,问题就很密集:

  1. 问「有推荐的文章吗」,正文里又列了一遍文章清单,而卡片区已经展示了同样三篇,重复;
  2. 清单里还夹着一坨 <a href="…">标题</a> 字面量 —— 前端不转义,模型输出的标签直接被当成文本显示出来;
  3. 问「有推荐的友链吗」,答「没找到和友链相关的文章」—— 它只会在文章库里找,压根没有友链这个概念;
  4. 回复结尾全是 emoji,开头还莫名其妙是个逗号「,我这边暂时没有…」;
  5. 问「这篇讲什么」,它不知道你在看哪篇;
  6. 换一篇文章再问,前面那篇的上下文还在,答非所问。

第 4 条的开头逗号最迷惑,排查后发现不是模型的问题,是前端丢了一个 SSE 分片:第一个 chunk 只用于显示、没累加进 fullReply,第二个 chunk 到达时用 fullReply 覆盖,首位就被吃掉了。模型说的其实是「抱歉呀,我这边暂时…」,显示成了「,我这边暂时…」。

改法总览

问题 改法 落点
正文罗列文章清单 提示词禁止,清单交给卡片 server.js buildSystemPrompt()
<a> 标签字面量 渲染前先剥 HTML 标签 ai-chatbot.js renderMarkdown()
emoji / 开头标点 提示词约束 + 前端 tidyReply() 兜底 同上
友链问不到 link.yml 生成 flinks.json,固定话术 ai_bot/tools/gen_flinks.js
不知道在看哪篇 前端传 location.pathname,后端找文章取摘要 buildCurrentReply()
换页串台 pjax / popstate 时清空会话 resetConversation()

让回复像人:口径要写死

原来的系统提示词里有一句「推荐文章时必须使用 HTML 超链接格式:<a href="文章链接">文章标题</a>」——这就是第 1、2 条问题的直接来源。改成两个分支:有匹配文章时,明确告诉模型”链接由卡片展示,正文别列清单”;没有匹配时,禁止编造文章标题。

1
2
3
4
5
6
7
8
9
10
11
function buildSystemPrompt(articlesContext) {
const hasArticles = Boolean(articlesContext && articlesContext.trim());

const articleRules = hasArticles
? `- 相关文章会由页面底部的「相关文章推荐」卡片自动展示,所以正文里不要罗列文章标题清单,也不要输出链接、Markdown 链接或 <a> 标签
- 只有读者明确问起某篇具体文章时,才可以自然提一次标题,但不要附带链接
- 读者问某篇文章讲了什么时,直接用上面给出的「内容摘要」概括 1-2 句`
: `- 当前没有可引用的博客文章,就用自己的知识回答,但不要编造或罗列博客里并不存在的文章标题`;

return `你是${config.BOT_NAME}… ${articlesContext} …${articleRules}`;
}

emoji 和开头标点属于”模型偶尔不听话”的类型,提示词之外还得有兜底。前端拿到文本先过一道 tidyReply():

1
2
3
4
5
6
7
8
9
function tidyReply(text) {
// 去掉 emoji / 符号表情
var out = String(text).replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}\u{200D}]/gu, '');
// 每行行首的标点(列表的 "-" 要保留)
out = out.split('\n').map(function (line) {
return line.replace(/^[\s,。、;:,.!?!?;:~~…—]+/, '');
}).join('\n');
return out.replace(/[ \t]{2,}/g, ' ').trim();
}

实测四种输入:,我这边暂时没有…😄 所以没法介绍 → 我这边暂时没有… 所以没法介绍;😅 好的,下面说说:「技术」 → 好的,下面说说:「技术」; ~ 直接回答,没问题。 正常;列表行 - [Bensz](…) 的 - 保留。

卡片化:文章和友链共用一套样式

后端不再把清单塞进正文,改成结构化字段:relevant_articles(文章)和 relevant_links(友链)。前端两个构造函数:

1
2
3
4
5
6
7
8
9
10
11
function buildArticleCards(articles, title) {
var html = '<div class="ai-chatbot-articles"><div class="ai-chatbot-articles-title">' + (title || '相关文章推荐:') + '</div>';
articles.forEach(function (a) {
html += '<a class="ai-chatbot-article-card" href="' + a.url + '" target="_blank">'
+ '<div class="ai-chatbot-article-body">'
+ '<div class="ai-chatbot-article-title">' + a.title + '</div>'
+ (a.tags && a.tags.length ? '<div class="ai-chatbot-article-tags">' + tags(a.tags) + '</div>' : '')
+ '</div></a>';
});
return html + '</div>';
}

这里踩了个小坑:标签 <span> 之前是卡片的直接子元素,而卡片是 display:flex; align-items:stretch,于是每个标签被拉成一根竖条(#Hexo 竖着排)。包一层 .ai-chatbot-article-tags 才恢复成横排小胶囊。

首页点「文章推荐」,三张卡片带着标签横排出来:

首页点「文章推荐」:最新三篇以卡片渲染,标签横排

多轮对话

前端在内存里记最近 3 轮,随请求发 history;同时把上一轮卡片里的文章 url 回传,后端据此还原出带摘要的文章对象,这样「第一篇讲什么」「这篇再细说」这类自己匹配不到关键词的追问也能答。

1
2
3
4
5
6
7
8
// 前端
body: JSON.stringify({
message: message,
history: chatHistory.slice(-HISTORY_TURNS * 2),
context_articles: lastArticles.map(function (a) { return { url: a.url }; }),
page: location.pathname || '',
client: 2
})
1
2
3
4
5
6
7
8
9
10
11
// 后端:历史上限 8 条、每条 600 字
function normalizeHistory(history) {
return (Array.isArray(history) ? history : [])
.filter(h => h && (h.role === 'user' || h.role === 'assistant') && typeof h.content === 'string')
.map(h => ({ role: h.role, content: h.content.slice(0, 600) }))
.slice(-8);
}

// 当前句子没匹配到文章时,回落到上一轮卡片里的文章
const matched = findRelevantArticles(message);
const relevantArticles = matched.length ? matched : resolveContextArticles(contextArticles);

友链:别让模型编链接

友链数据本来就在博客的 source/_data/link.yml 里,写个脚本转成 ai_chatbot/flinks.json:跳过「失联」分组和本站自己,recommend: true 的排前面(共 11 条)。

1
2
3
4
5
6
7
8
// ai_bot/tools/gen_flinks.js
for (const group of groups) {
if (!group || group.lost_contact) continue;
for (const item of group.link_list || []) {
if (item.link.replace(/\/+$/, '') === selfUrl) continue; // 不推荐自己
links.push({ name: item.name, url: item.link, descr: item.descr, recommend: item.recommend === true });
}
}

命中 友链|友情链接|flink|推荐博主 这类问法时不调模型,直接返回前 3 条真实链接,标题走卡片。这样既省钱又不可能编错。

点「友链推荐」:三张友链卡片,名字 + 一句话描述,整张可点

自我介绍也做了点小工程

「关于我」是一段固定文案,但里面「建站 1583 天」这种数字写死就会过期。做法是在 faq.json 里存建站日,答案里用占位符,返回时实时替换:

1
2
3
4
5
6
7
8
9
10
11
function siteDays() {
const launch = faqData.site.launch; // 2022-07-01
return Math.max(0, Math.floor((Date.now() - new Date(launch + 'T00:00:00+08:00')) / 86400000));
}

function renderAnswer(text) {
const days = siteDays();
return text
.replace(/\{days\}/g, () => String(days))
.replace(/\{years\}/g, () => String(Math.max(1, Math.floor(days / 365))));
}

现在文案里写的是「它已经陪了我 {years} 年」,今天算出 4,明年自动变 5。

文章页感知:直接复用已有的 AI 摘要

文章页本来就有一块 AI 摘要(另一套服务生成,存在 Redis 里,键是 ai:summary:p:<abbrlink>)。所以机器人不需要重新生成一份:

  1. 前端把 location.pathname 带上,形如 /2024/09/04/43948a6a/;
  2. 后端取最后一段当 abbrlink,在 RSS 文章表里定位;
  3. 调 http://127.0.0.1:3000/api/summary?key=p:<abbrlink> 拿现成摘要,内网失败回落公网域名;
  4. 都没命中就退回 RSS 正文前 180 字,文案从「的摘要」改成「大致讲了」,不假装有摘要。
1
2
3
4
5
6
7
8
9
10
11
async function fetchAiSummary(abbr) {
const hit = summaryCache.get(abbr);
if (hit && Date.now() - hit.at < SUMMARY_CACHE_TTL) return hit.text; // 10 分钟内存缓存
for (const base of SUMMARY_ENDPOINTS) {
try {
const resp = await axios.get(base, { params: { key: 'p:' + abbr }, timeout: 1500 });
if (resp.data && resp.data.summary) return resp.data.summary.trim();
} catch (e) { /* 换下一个源 */ }
}
return '';
}

文章页打开对话框时,欢迎气泡会多一句「你正在看《…》,想看内容概要就点「本文摘要」」,快捷按钮也多一个「本文摘要」。首页没有这个按钮。

点「本文摘要」:直接复用文章页那份 AI 摘要,末尾附文章卡片

不在文章页时如果有人点(比如浏览器还缓存着旧页面),也不会掉进模型乱答,后端直接回一句兜底话术。

换页就清空

本站是 pjax,站内跳转不重载页面,对话窗是全局固定元素,所以状态会跨页残留:在 A 文章页打开、跳到 B,气泡里还写着「你正在看《A》」。

改法是记住渲染时的路径,pjax:complete / pjax:success / pjax:end 和 popstate 都挂上同步逻辑:路径变了就清空对话(保留欢迎气泡)+ 清空 chatHistory / lastArticles,再按新页面重铺欢迎块。

1
2
3
4
5
6
7
8
9
function syncIntroToPage() { if (chatbotOpen) loadIntro(); }

function loadIntro() {
var pagePath = currentPath();
if (introLoaded && introPage === pagePath) return; // 同页反复开合,不重复请求
if (introPage && introPage !== pagePath) resetConversation(); // 换页:清空上一篇的对话
introPage = pagePath;
// …fetch intro、渲染 hint + chips
}

两个真会咬人的 bug

1. 流式解析把半截 JSON 丢了

错误日志里攒了 56 条 Parse stream error,最早能追到 7 月。原因:上游是 br 压缩流,data 事件的分片边界不按行切,而代码对每个 chunk 直接 split('\n') 再 JSON.parse,半截 JSON 必然抛错被丢掉——表现就是回复里少几个字,肉眼很难发现。

修法是按字节缓冲、只在遇到换行符时才取整行:

1
2
3
4
5
6
7
8
9
10
11
let rawBuf = Buffer.alloc(0);

response.data.on('data', (chunk) => {
rawBuf = Buffer.concat([rawBuf, chunk]);
let idx;
while ((idx = rawBuf.indexOf(10)) !== -1) { // 0x0A
const line = rawBuf.subarray(0, idx).toString('utf8');
rawBuf = rawBuf.subarray(idx + 1);
handleSseLine(line); // 只处理完整行
}
});

验证方式是写了个假上游,把 SSE 内容 brotli 压缩后按 7 字节一片发出去(边界故意落在 JSON 中间和汉字中间),旧逻辑必丢字,新逻辑三个分片全对:

1
2
chunk events: 3
assembled: 你好,这是完整回复,没有丢字。

2. [DONE] 之后再写一次,进程直接崩

/api/chat/stream 在收到 [DONE] 时 res.end(),随后上游连接 end 事件又 res.write 一次,抛 ERR_STREAM_WRITE_AFTER_END。这是未捕获的 error 事件,node 进程当场退出,pm2 只是把它拉起来。

商汤那档不发 [DONE],所以线上一直没炸;但面板里把模型切到 DeepSeek(OpenAI 兼容、会发 [DONE])就会每轮对话崩一次。

修法是收尾幂等:

1
2
3
4
5
6
7
let clientEnded = false;
const endClient = (payload) => {
if (clientEnded) return;
clientEnded = true;
try { if (payload) res.write(`data: ${JSON.stringify(payload)}\n\n`); res.end(); }
catch (e) { console.error('SSE close error:', e.message); }
};

本地连打 3 次流式请求,进程存活(AI Chatbot Service Running)。

兼容与上线

前端更新、后端先上线,中间有一段时间是”新后端 + 老前端”。所以加了版本号:

1
2
3
4
// 前端
client: 2
// 后端
const wantsCards = Number(req.body.client) >= 2;

老前端(不带 client)拿到的是带 markdown 链接的文本清单,新前端拿到 relevant_links 渲染成卡片,不会出现「只剩一句引导、下面什么都没有」。

上线节奏是:scp 文件 → node --check → 备份 .bak-<时间戳> → pm2 restart ai-chatbot --update-env;只改数据文件(faq.json、flinks.json)时用 GET /api/reload 热加载,不重启、不断服务。

别的踩坑记录

  • 改 .styl 必须 hexo clean:hexo 按入口 index.styl 的 mtime 判缓存,只跑 generate 不会重编 CSS。这次”清空按钮跑到第二行”就是 CSS 没进包导致的,排查时先看了一眼编译产物里有没有 .ai-chatbot-header-left 就定位了。
  • 图标字体里没有 trash:eucalyptus 字体只有 arrows-rotate、circle-xmark 这类,没有垃圾桶,所以清空按钮改成内联 SVG(stroke: currentColor 跟着 hover 变色)。
  • 不同字形的墨迹大小不一样:一开始用 icon-arrows-rotate,视觉上比全屏图标大一圈。用 fontTools 量了字形包围盒才确认——16px 时旋转箭头墨迹 15.5×14.5,而 maximize 只有 13.5×11.5,所以单独把那个图标降到 13px 才对齐。
  • pjax 事件名各家不统一:pjax:complete、pjax:success、pjax:end 全挂上,重复触发反正幂等。

小结

这次没有换模型、没有换框架,纯粹是把”提示词、数据结构、状态管理、流式解析”这四层各修了一遍。效果最明显的三个变化:回复不再自我重复、追问能接住上一句、文章页问摘要能答对。

最值钱的反而不是功能,而是那两个 bug —— 一直在悄悄丢字的解析器和一颗随时会引爆的崩溃炸弹。功能是看得见的,这种是看不见的,只有去翻错误日志才会发现:56 条报错静静地躺在那里四个月。

如果你也在博客上挂了类似的小助手,建议去翻一眼流式解析那段:chunk.toString().split('\n') 这种写法,在压缩流面前基本必挂。