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

AI 助手 2.0:多轮对话、卡片化与文章页感知
Eucalyptus前言
六月份的时候给博客装了一个首页 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 条问题的直接来源。改成两个分支:有匹配文章时,明确告诉模型”链接由卡片展示,正文别列清单”;没有匹配时,禁止编造文章标题。
1 | function buildSystemPrompt(articlesContext) { |
emoji 和开头标点属于”模型偶尔不听话”的类型,提示词之外还得有兜底。前端拿到文本先过一道 tidyReply():
1 | function tidyReply(text) { |
实测四种输入:,我这边暂时没有…😄 所以没法介绍 → 我这边暂时没有… 所以没法介绍;😅 好的,下面说说:「技术」 → 好的,下面说说:「技术」; ~ 直接回答,没问题。 正常;列表行 - [Bensz](…) 的 - 保留。
卡片化:文章和友链共用一套样式
后端不再把清单塞进正文,改成结构化字段:relevant_articles(文章)和 relevant_links(友链)。前端两个构造函数:
1 | function buildArticleCards(articles, title) { |
这里踩了个小坑:标签 <span> 之前是卡片的直接子元素,而卡片是 display:flex; align-items:stretch,于是每个标签被拉成一根竖条(#Hexo 竖着排)。包一层 .ai-chatbot-article-tags 才恢复成横排小胶囊。
首页点「文章推荐」,三张卡片带着标签横排出来:
多轮对话
前端在内存里记最近 3 轮,随请求发 history;同时把上一轮卡片里的文章 url 回传,后端据此还原出带摘要的文章对象,这样「第一篇讲什么」「这篇再细说」这类自己匹配不到关键词的追问也能答。
1 | // 前端 |
1 | // 后端:历史上限 8 条、每条 600 字 |
友链:别让模型编链接
友链数据本来就在博客的 source/_data/link.yml 里,写个脚本转成 ai_chatbot/flinks.json:跳过「失联」分组和本站自己,recommend: true 的排前面(共 11 条)。
1 | // ai_bot/tools/gen_flinks.js |
命中 友链|友情链接|flink|推荐博主 这类问法时不调模型,直接返回前 3 条真实链接,标题走卡片。这样既省钱又不可能编错。
自我介绍也做了点小工程
「关于我」是一段固定文案,但里面「建站 1583 天」这种数字写死就会过期。做法是在 faq.json 里存建站日,答案里用占位符,返回时实时替换:
1 | function siteDays() { |
现在文案里写的是「它已经陪了我 {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 字,文案从「的摘要」改成「大致讲了」,不假装有摘要。
1 | async function fetchAiSummary(abbr) { |
文章页打开对话框时,欢迎气泡会多一句「你正在看《…》,想看内容概要就点「本文摘要」」,快捷按钮也多一个「本文摘要」。首页没有这个按钮。
不在文章页时如果有人点(比如浏览器还缓存着旧页面),也不会掉进模型乱答,后端直接回一句兜底话术。
换页就清空
本站是 pjax,站内跳转不重载页面,对话窗是全局固定元素,所以状态会跨页残留:在 A 文章页打开、跳到 B,气泡里还写着「你正在看《A》」。
改法是记住渲染时的路径,pjax:complete / pjax:success / pjax:end 和 popstate 都挂上同步逻辑:路径变了就清空对话(保留欢迎气泡)+ 清空 chatHistory / lastArticles,再按新页面重铺欢迎块。
1 | function syncIntroToPage() { if (chatbotOpen) loadIntro(); } |
两个真会咬人的 bug
1. 流式解析把半截 JSON 丢了
错误日志里攒了 56 条 Parse stream error,最早能追到 7 月。原因:上游是 br 压缩流,data 事件的分片边界不按行切,而代码对每个 chunk 直接 split('\n') 再 JSON.parse,半截 JSON 必然抛错被丢掉——表现就是回复里少几个字,肉眼很难发现。
修法是按字节缓冲、只在遇到换行符时才取整行:
1 | let rawBuf = Buffer.alloc(0); |
验证方式是写了个假上游,把 SSE 内容 brotli 压缩后按 7 字节一片发出去(边界故意落在 JSON 中间和汉字中间),旧逻辑必丢字,新逻辑三个分片全对:
1 | chunk events: 3 |
2. [DONE] 之后再写一次,进程直接崩
/api/chat/stream 在收到 [DONE] 时 res.end(),随后上游连接 end 事件又 res.write 一次,抛 ERR_STREAM_WRITE_AFTER_END。这是未捕获的 error 事件,node 进程当场退出,pm2 只是把它拉起来。
商汤那档不发 [DONE],所以线上一直没炸;但面板里把模型切到 DeepSeek(OpenAI 兼容、会发 [DONE])就会每轮对话崩一次。
修法是收尾幂等:
1 | let clientEnded = false; |
本地连打 3 次流式请求,进程存活(AI Chatbot Service Running)。
兼容与上线
前端更新、后端先上线,中间有一段时间是”新后端 + 老前端”。所以加了版本号:
1 | // 前端 |
老前端(不带 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') 这种写法,在压缩流面前基本必挂。










