我博客的评论区用的是某评论系统,一直是纯手工回复。写多了有两个问题:一是慢,二是有些重复问题(“这篇讲的是啥”、“有没有开源”)得反复打一遍。

于是我做了个东西:让 AI 以固定人设接管评论区。读者留言,AI 用固定昵称这个身份当场回。

但做下来最花心思的不是"调模型生成回复"这一步——那是一行 llm.stream() 的事。真正难的是怎么让这个 AI 不胡说八道,以及怎么让它别把自己搞死。

一、事件怎么送达:把公网入口掐到只剩一个

评论系统是第三方服务,它不知道我本地跑着什么。要让它把事件推给我,只能配 webhook 或者类似机制。

我这套是这么走的:

评论系统 → 公网域名 → 公网隧道 → 本机网关 → DSH 插件

关键在网关那一层。我没有把 DSH 的端口直接指向隧道,原因是 DSH 那个 Web 界面是带工具执行权限的 agent 控制台——直接暴露到公网等于把控制台送人。

所以网关是个硬白名单,只放行一个路径一个方法:

const ALLOW = ['/hook-path']
if (!ALLOW.includes(url.pathname)) return respond404()
if (req.method !== 'POST') return respond405()

其余一切路径直接 404。公网能碰到的只有"收评论事件"这一个端点,它还有自己独立的 token 校验,不依赖浏览器的登录会话——因为服务器推送根本不是浏览器来的。

面板用的那些 /api/* 走 DSH 同源,受浏览器会话保护,公网压根到不了。

这一层是整个方案的安全地基。如果偷懒直接把隧道指向 DSH,后面所有代码写得再对也没意义。

二、最难的一步:把事件落到真实评论上

评论系统推给我的事件里没有评论 ID。它只告诉我"某人在某个 URL 留了某段话"。

但回复必须挂到正确的楼层上——它的提交接口要 pid(根评论)和 rid(被回复评论)两个 ID,填错就是回复到了别的帖子里去。

没有 ID 怎么办?只能回头去把评论树拉下来,用特征反查。我定了三个条件,必须同时满足:

  1. 昵称完全相等(trim 后)
  2. 正文前缀相等(取前 40 字符 startsWith,因为 HTML 可能被转义)
  3. 评论创建时间和事件接收时间之差在时间窗内(默认 10 分钟)
if (nodeNick === wantNick
    && nodeText.startsWith(wantText.slice(0, 40))
    && Math.abs(created - recvAtMs) <= windowMs) {
  found.push(node)
}

三个条件一起收窄,是为了防"同一时间好几个人评论"或者"同一个人发了一模一样的话"这类误匹配。多条同时命中时取时间最早的那条——先发生的先回复。

反查失败还有一条回退路径:扫最近若干篇文章,找全局最新且没处理过的评论。这个兜底主要是应对事件延迟或推送丢失。

实测下来精确匹配命中率很高,回退路径偶尔才用上。

三、AI 只能依据站内文章作答

这是我最在意的一环。AI 回评论最容易出的问题是它会顺着你的话说,你问"这个接口支持批量吗"它能给你编一个听起来很真的答案。

我的做法是给模型划死边界:

  • 把整站文章的目录(标题 + slug + 摘要)拼进 system prompt,让它知道"站里有哪些东西可以引用"
  • 把被评论的那篇文章正文拼进 user prompt
  • 再粗筛出 3 篇相关文章作为引用候选

system prompt 里写死三条硬规则:

- 你只能依据下面列出的站内文章内容回答。站里没有写过的内容,
  就明确说「站里没写到」,绝不臆造接口、版本号、报错。
- 需要指向某篇文章时,只输出标记 [[slug]],不要自己拼网址。
- 一次回复最多引用 2 篇。

第三条防的是它突然列一串"相关阅读"。

相关文章的粗筛:中文分词的土办法

找相关文章我没上向量库——语料才几十篇,用不着。

难点是中文没有空格分词。我的做法是按标点切一遍,然后对长串再做 2-gram 拆解:

for (const seg of segs) {
  words.add(seg)
  if (seg.length > 4 && /[\u4e00-\u9fff]/.test(seg)) {
    for (let i = 0; i + 2 <= seg.length; i += 2) words.add(seg.slice(i, i + 2))
  }
}

“模型接口"会被拆出"模型”、“型接”、“接口"这些二字片段。命中标题/摘要/标签各加 1 分,标签在评论正文里也出现再额外加 2 分。粗但够用。

四、链接不让模型自己写

如果让模型直接输出 URL,它会拼错、会写出不存在的文章。更麻烦的是我没法校验它到底指向哪。

所以模型只输出 [[slug]] 标记,真实 URL 由代码替换:

text.replace(/\[\[([^\]]+)\]\]/g, (_, raw) => {
  const post = bySlug.get(raw.trim().toLowerCase())
  if (!post) { dropped.push(raw); return '\u0000' }
  used.push(raw)
  return `[《${post.title}》](${siteUrl}/blog/${post.slug}/)`
})

这里有个细节我花了点时间:模型偶尔会引用一篇不存在的文章(幻觉出一个 slug)。直接删掉整个标记会留下半截话,所以我用占位符 \u0000 标记,再按标点把句子切成分句,只删掉承载坏标记的那一个分句,主句和其它分句都留着:

const clauses = line.split(/(?<=[,,;;。!?])/)
const kept = clauses.filter((c) => !c.includes('\u0000'))

删完还得收拾悬空的逗号顿号、重复的句号,不然读起来像被机器嚼过。

另外模型有时把引用单独放一行,读起来像尾巴,我把它并回上一句,补个"详见”。

五、AI 回复自己的评论 = 无限循环

这个坑我踩了才想明白。

AI 发的回复也是一条评论,同样会被推送事件给我。如果不拦,AI 会回复自己的回复,然后回复自己的回复的回复……

我做了两层保险:

第一层(快):事件进来时,UA 含特定前缀或者昵称等于人设昵称,直接丢弃。

if (event.ua.includes('BotMarker') || event.nick === liveCfg.persona.nick) {
  return writeJson(res, 200, { ok: true, ignored: 'self' })
}

第二层(准):台账里查评论 ID 在不在。

第一层快但可能漏(UA 被改了、昵称改了),第二层慢但准。两层都要。

六、幂等:同一件事不能干两次

推送可能重投,插件重启也可能重跑。这里两级去重:

事件指纹——把 url|nick|comment|recvAtMs 拼起来算个 31 进制哈希,同一份提交被推两次能认出来。事件表只保留最近 500 条,不然无限涨。

台账——以评论 ID 为主键,记录这条评论处理过了、结果如何、回复内容、引用了哪些文章。落盘 JSON,重启不丢。

台账还兼做统计面板的数据源:已回复多少、失败多少、跳过多少。

七、别让它刷屏

AI 回复不设限的话,遇到有人刷评论它就真的一条条回,每条都是真金白银的模型调用。

我加了三重限额:

  • 每日最多回复 N 条
  • 同一篇文章每小时最多 M 条
  • 两次回复最小间隔(默认 30 秒)

另外整个处理是串行队列——同一时刻只处理一条评论。理由不只是控制并发:同一篇文章多人评论时并发回复会互相引用错乱,而且模型调用的峰值也该压平。

还有个 dry-run 模式,只生成不提交,回复内容会记进台账但不真发出去。改提示词、调人设的时候特别需要,不然每调一次就真在评论区发一次。

八、模型选择:别让用户手打 provider 和 model

一开始我是在面板里放了两个文本框让人填 provider 和 model。用起来很蠢——谁记得住 provider 名字,而且填错了要等回复失败才发现。

所以我改成了枚举宿主里所有可路由的模型,做成下拉框。服务端把每个 provider 和它下面的模型列出来返回:

const providers = llm.listProviders()
groups = await Promise.all(providers.map(async (provider) => {
  const models = await llm.listModels(provider.id)
  return { id: provider.id, name: provider.name, models }
}))

前端按 provider 分组渲染成 <select> + <optgroup>。选项值用 JSON 编码 [provider, model] 数组,这样 id 里带任何分隔符都不会解析歧义;空字符串表示"跟随默认"。

有两个细节值得说:

单个 provider 列模型失败只跳过它,不影响其余选项——不然一个渠道的网络抖动就能让整个下拉框空掉。

已存但目录里消失的路由仍然渲染成一个可选项。比如某渠道下线了、模型从列表里消失了,用户之前选的那个不能被静默弹回"跟随默认",得让人看见"你选的这个没了"。

拉不到目录时自动退回自由输入,不把功能整个锁死。

九、几个实测出来的坑

接评论系统这类第三方服务,有几个地方文档不会写、只能自己撞:

评论数接口不可用。这个版本的实例上直接返回错误码,提示要更新云函数。最后评论数只能从"拉评论树"的返回里取。

管理员密码在库里是 md5,拿不到明文。所以默认走匿名身份提交——反正匿名提交能用。想要"博主徽章"效果才需要管理员身份。

拉回来的评论对象里没有 URL 字段。那是数据库行内的字段,不在返回结构里。必须用"我这次查询用的那个 URL"回填,否则提交时后端会报参数不合法。

URL 形态有好几种历史遗留。现行的是 /blog/<slug>/,但老评论里可能还是 /blog/<slug>.html/ 甚至带年份目录的。解析函数得把这几种都覆盖,否则老评论定位不到文章。

十、回头看

这套东西跑下来,最有价值的其实不是"回复得多快",而是它不乱说。

我给它的约束写得很死:只依据站内文章回答、站里没写过的直说不知道、引用必须用标记不许自己拼链接。这几条加上站内索引,基本堵住了 AI 在评论区胡扯的主要路径。

如果只有"能自动回复"而没有"不许乱说",那这个东西在我这里是负资产——博客评论区胡说的可信度损失,比没人回更严重。

下一步想试的是把引用做得更准一点,现在只引用两篇且是粗筛的,有时候读者问的正好对应一篇我没捞出来的文章。

# 整套是本地脚本 + 一个 DSH 插件,没有额外的常驻服务
node gateway/proxy.mjs   # 公网入口网关,只放行一个端点