AI 助手 2.0:多轮对话、卡片化与文章页感知
用了四个月后集中修一遍:多轮上下文、卡片化回复、文章页感知,顺带挖出几个一直在丢数据的 bug。
前言
六月份的时候给博客装了一个首页 AI 小助手,记录在 基于 Node.js + 大模型的 AI 客服系统 里:Express 起一个 5004 端口的服务,抓 RSS 建文章索引,SSE 流式把大模型的回复吐给前端,前端在右下角挂一个对话窗。
用了四个月,功能没坏,但体验上的毛病越攒越多。这一轮集中修了一遍,顺手把几个"其实一直在丢数据"的 bug 也挖出来了。这篇按问题拆开写,代码都是线上跑着的版本。

一次对话暴露的六个问题
随便找一段真实对话,问题就很密集:
- 问「有推荐的文章吗」,正文里又列了一遍文章清单,而卡片区已经展示了同样三篇,重复;
- 清单里还夹着一坨
<a href="...">标题</a>字面量 ------ 前端不转义,模型输出的标签直接被当成文本显示出来; - 问「有推荐的友链吗」,答「没找到和友链相关的文章」------ 它只会在文章库里找,压根没有友链这个概念;
- 回复结尾全是 emoji,开头还莫名其妙是个逗号「,我这边暂时没有...」;
- 问「这篇讲什么」,它不知道你在看哪篇;
- 换一篇文章再问,前面那篇的上下文还在,答非所问。
第 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 条问题的直接来源。改成两个分支:有匹配文章时,明确告诉模型"链接由卡片展示,正文别列清单";没有匹配时,禁止编造文章标题。
js
function buildSystemPrompt(articlesContext) {
const hasArticles = Boolean(articlesContext && articlesContext.trim());
const articleRules = hasArticles
? `- 相关文章会由页面底部的「相关文章推荐」卡片自动展示,所以正文里不要罗列文章标题清单,也不要输出链接、Markdown 链接或 <a> 标签
- 只有读者明确问起某篇具体文章时,才可以自然提一次标题,但不要附带链接
- 读者问某篇文章讲了什么时,直接用上面给出的「内容摘要」概括 1-2 句`
: `- 当前没有可引用的博客文章,就用自己的知识回答,但不要编造或罗列博客里并不存在的文章标题`;
return `你是${config.BOT_NAME}... ${articlesContext} ...${articleRules}`;
}
emoji 和开头标点属于"模型偶尔不听话"的类型,提示词之外还得有兜底。前端拿到文本先过一道 tidyReply():
js
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(友链)。前端两个构造函数:
js
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 回传,后端据此还原出带摘要的文章对象,这样「第一篇讲什么」「这篇再细说」这类自己匹配不到关键词的追问也能答。
js
// 前端
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
})
js
// 后端:历史上限 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 条)。
js
// 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 里存建站日,答案里用占位符,返回时实时替换:
js
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>)。所以机器人不需要重新生成一份:
- 前端把
location.pathname带上,形如/2024/09/04/43948a6a/; - 后端取最后一段当 abbrlink,在 RSS 文章表里定位;
- 调
http://127.0.0.1:3000/api/summary?key=p:<abbrlink>拿现成摘要,内网失败回落公网域名; - 都没命中就退回 RSS 正文前 180 字,文案从「的摘要」改成「大致讲了」,不假装有摘要。
js
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 '';
}
文章页打开对话框时,欢迎气泡会多一句「你正在看《...》,想看内容概要就点「本文摘要」」,快捷按钮也多一个「本文摘要」。首页没有这个按钮。

不在文章页时如果有人点(比如浏览器还缓存着旧页面),也不会掉进模型乱答,后端直接回一句兜底话术。
换页就清空
本站是 pjax,站内跳转不重载页面,对话窗是全局固定元素,所以状态会跨页残留:在 A 文章页打开、跳到 B,气泡里还写着「你正在看《A》」。
改法是记住渲染时的路径,pjax:complete / pjax:success / pjax:end 和 popstate 都挂上同步逻辑:路径变了就清空对话(保留欢迎气泡)+ 清空 chatHistory / lastArticles,再按新页面重铺欢迎块。
js
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 必然抛错被丢掉------表现就是回复里少几个字,肉眼很难发现。
修法是按字节缓冲、只在遇到换行符时才取整行:
js
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 中间和汉字中间),旧逻辑必丢字,新逻辑三个分片全对:
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])就会每轮对话崩一次。
修法是收尾幂等:
js
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)。
兼容与上线
前端更新、后端先上线,中间有一段时间是"新后端 + 老前端"。所以加了版本号:
js
// 前端
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') 这种写法,在压缩流面前基本必挂。