从“让聊天模型顺便选表情”到独立分类器:我给 QQ AI Chatbot 做了一套本地动态表情包系统

最近我给自己的 QQ AI Chatbot 增加了一项看起来很轻量的能力:

text 复制代码
机器人回复文字以后,偶尔再发一张符合语境的本地表情包。

最开始我以为这只是三个步骤:

text 复制代码
让聊天模型判断要不要发表情。
从文件夹里挑一张图片。
调用 OneBot 发出去。

真正做下去才发现,这三步里几乎每一步都有边界问题:

text 复制代码
聊天模型输出控制标记,会不会污染正常回复?
远程模型能不能看到本地路径和表情 ID?
GIF 是否真的有动画,而不是只有一个容器后缀?
候选图被替换以后,旧审批还能不能继续生效?
连续聊天时会不会疯狂发表情、浪费分类 token?
图片发送失败后,要不要换一张重试?
主人不喜欢某张图时,应该删除还是撤销?

最后,这个功能变成了一条完整的本地资产链路:

text 复制代码
候选扫描
  -> 安全检查
  -> 初步标注
  -> 主人逐项批准
  -> 规范化正式库
  -> 独立远程语义分类
  -> 本地候选选择
  -> 发送前完整复核
  -> QQ 单次发送
  -> 成功后提交频率状态

这一篇记录我为什么放弃"让 ChatAgent 顺便输出表情控制信息",以及如何把远程语义判断、本地资产选择、频率控制和 QQ 发送拆成互不越权的几个部分。

第一版思路:在 ChatAgent 回复末尾加一个控制标记

最直接的方案,是让正常聊天模型返回类似:

text 复制代码
今天也要开心一点。

<sticker attach="true" mood="playful" scene="acting_cute" confidence="0.95" />

程序负责:

text 复制代码
从正文末尾解析控制标记。
把标记从用户可见回复中移除。
根据 mood/scene 选择本地表情。

这个方案的优点很明显:

text 复制代码
只调用一次模型。
聊天模型知道完整上下文。
正文与表情意图可以一起生成。

但真实 QQ live 很快证明,它并不稳定。

有时模型完全不输出标记;有时在简短测试 Prompt 里能输出,换成完整系统提示、角色卡和长期上下文后又消失。为了提高稳定性,我又尝试过让 ChatAgent 返回固定 JSON envelope:

json 复制代码
{
  "reply": "正常聊天正文",
  "sticker": {
    "attach": true,
    "mood": "playful",
    "scene": "acting_cute",
    "confidence": 0.95
  }
}

最小探针看起来很好,但进入真实会话后,普通聊天开始频繁得到固定兜底文本。也就是说,为了给一个可选表情增加结构化控制,我破坏了最重要的主链:

text 复制代码
正常聊天正文。

这时我决定回退,而不是继续堆 Prompt。

最终原则是:

表情是正文成功后的附加能力,不能反过来要求正文服从它的结构。

于是旧的 ChatAgent marker/JSON 方案保持关闭,ChatAgent 恢复只输出普通角色文本。

新链路:正文先成功,再调用独立分类器

新的时序是:

text 复制代码
ChatAgent 生成普通正文
  -> QQ 成功发送正文
  -> 本地频率预检
  -> 独立 DeepSeek 分类器判断是否适合附带
  -> 本地选择表情
  -> 发送前资产复核
  -> QQ 最多发送一张图片

这里最关键的是顺序:

text 复制代码
正文发送成功以后,才允许分类。

如果 ChatAgent 失败,不会为了表情重试正文。

如果分类器失败,不会修改、替换或撤回已经发送的正文。

如果图片发送失败,也不会影响文字消息。

从产品语义上看,表情包被降级成了真正的附件:

text 复制代码
正文是主结果。
表情是可失败的增强结果。

独立分类器到底能看到什么

为了避免远程模型越过本地边界,我没有让它直接选择表情文件。

分类器只接收:

text 复制代码
当前这一轮有限长度的用户文本
已经成功发送的有限长度正文

它不接收:

text 复制代码
聊天历史
QQ 号或群号
角色卡
长期记忆
RAG 内容
图片本身
本地路径
文件名
SHA-256
sticker ID
候选表情列表

模型只能返回固定五个字段:

json 复制代码
{
  "attach": true,
  "mood": "playful",
  "intensity": "medium",
  "scene": "acting_cute",
  "confidence": 0.95
}

其中所有字符串都必须属于本地枚举。远程模型没有"选择第几张图片"的权力,它只能提供一个受限语义标签。

本地选择器再根据:

text 复制代码
mood
intensity
scene
confidence
正式库 enabled 状态
本地匹配规则
最近发送状态

决定是否存在可用候选。

这形成了一个很重要的分权:

text 复制代码
远程模型:判断语义。
本地代码:决定资格。
本地正式库:决定有哪些资产。
OneBot adapter:只发送最终通过复核的一张图。

为什么分类失败必须 fail closed

分类器没有重试,也没有兜底猜测。

下面这些情况都会直接变成"不附带表情":

text 复制代码
超时
连接失败
鉴权失败
429 限流
额度不足
模型不存在
空响应
非法 JSON
字段缺失
枚举越界
confidence 非法

这样做可能少发几张表情,但不会出现这些更糟的行为:

text 复制代码
分类器失败后让 ChatAgent 重写正文。
解析失败时默认 attach=true。
为了"提高成功率"连续消耗多次分类额度。
把模型返回的任意字符串当成本地文件名。

对可选增强能力来说,少做通常比猜着做更合理。

本地表情不能只是"把图片扔进目录"

语义分类只是半条链路。另一半问题是:怎样相信本地图片?

我的本地目录分成候选区和正式库。候选图不会因为出现在文件夹里就自动参与发送,而是经过:

text 复制代码
候选扫描
图片格式识别
MIME 检查
尺寸和字节预算
静态/动画识别
帧数和总时长检查
完整 SHA-256
初步语义标注
批准草稿
主人精确批准

批准草稿不是正式库。它只是把候选的安全元数据和初步语义整理出来,等待主人明确选择。

主人批准时,系统不会相信"昨天扫描过的结果"。它会重新读取候选文件并核对:

text 复制代码
候选仍然存在
完整 SHA-256 没有变化
MIME 没有变化
尺寸没有变化
字节数没有变化
帧数没有变化
总时长没有变化
metadata 和动画属性仍满足预算
批准草稿仍然匹配
正式 sticker ID 没有冲突
来源没有重复批准
目标文件没有被占用

这避免了一个典型的 TOCTOU 问题:

text 复制代码
扫描时看的是文件 A。
批准前候选被替换成文件 B。
系统却沿用 A 的旧审查结果批准 B。

在当前实现里,任何不一致都会 fail closed。

正式资产为什么还要重新编码

即使候选通过检查,我也没有直接把原文件复制进正式库。

正式图片会重新解码并规范化编码:

text 复制代码
静态图去除不必要 metadata,统一输出规范 PNG。
GIF/APNG/WebP 动画重新解码、合成和编码。
重新计算最终 MIME、尺寸、字节、帧数和总时长。
保存规范化文件自己的最终 SHA-256。

动画处理有一个容易误解的细节:连续相同画面可能在规范化时被合并。

例如源 GIF 有很多重复帧,重编码后可能只剩几帧,但每帧 duration 被合并,总时长和可见变化仍然保留。因此正式 manifest 保存的是:

text 复制代码
规范化产物的真实帧数
规范化产物的真实总时长

而不是强制复制源容器里的原始帧计数。

正式文件和 manifest 都采用临时文件、flush/fsync 与原子替换,避免写到一半留下可见半成品。

manifest 为什么需要 schema 和 revision

正式库不是"文件名列表",而是一份带版本的 manifest:

text 复制代码
schema = 2
revision 递增
每个 sticker 有稳定 ID
enabled/disabled 状态
最终哈希
MIME
尺寸
字节
动画类型
帧数
总时长
语义标签

每次批准或撤销都会生成新的 revision。

运行选择路径在每次分类后重新加载 manifest,所以批准新表情后不需要为了资产数据单独重启 Bot。代码和配置变化仍然需要重启,但纯资产 revision 不需要。

当前正式库里有一批经过主人逐项批准的表情,其中大多数是动态 GIF,少量是静态图;未批准候选仍留在 inbox 和草稿中,不移动、不删除,也不参与自动发送。

撤销不是删除

主人看到某张图以后,可能觉得:

text 复制代码
这张不适合自动发送。

我的撤销行为不是删除文件,而是:

text 复制代码
enabled=false
revision + 1
保留规范化文件
保留 manifest 记录
保留审计证据

之后选择器会排除它,显式预览也会拒绝 disabled 资产。

这样做的好处是:

text 复制代码
撤销可审计。
误操作可调查。
不会因为"删除失败"留下 manifest 悬挂记录。
不会静默让同一来源重新变成新候选。

重新启用仍然需要单独设计和明确授权,不会因为文件还在就自动恢复。

自动发送前为什么还要再检查一次文件

正式库批准通过,不等于以后每次发送都盲目信任磁盘。

发送前会重新加载 manifest,并再次核对最终文件的:

text 复制代码
路径必须位于固定正式库
文件不能是符号链接逃逸
SHA-256 必须匹配
MIME 必须匹配
尺寸和字节必须匹配
动画属性必须匹配
帧数和总时长必须匹配
enabled 必须为 true
manifest 不能有 invalid issue

任一不一致都不发送。

本地 sticker ID 也不会直接来自远程模型,而是本地选择器从已复核正式库中产生。

先做频率预检,再调用分类器

一晚 QQ live 后,功能本身没有出现明显问题,但我发现了一个成本优化点:

text 复制代码
既然冷却期间肯定不能发表情,为什么还要调用一次表情分类模型?

于是链路又调整了一次:

text 复制代码
正文成功发送
  -> 本地频率预检
  -> 只有可能发送时才调用远程分类器

当前自动附带限制包括:

text 复制代码
仅主人私聊
每次最多一张
至少 120 秒冷却
至少间隔 4 条消息
每小时最多 6 张
附件链熔断时禁止发送

只要命中任一条件:

text 复制代码
cooldown
message_gap
hourly_cap
attachment_suspended

就直接跳过分类 API。

这里有一个容易写错的地方:消息间隔仍然需要随着正文发送推进。即使因为冷却没有调用分类器,本轮正文也应该算作一条新消息,否则"至少间隔 4 条消息"的状态会停住。

这个优化的本质是:

把确定性的本地"不可能发送"判断放到付费或限额的远程调用之前。

多张同类表情如何避免重复

如果同一个语义标签有多张候选,完全随机可能连续抽到同一张。

我使用了 shuffle-bag:

text 复制代码
把当前同类可用 ID 打乱放进 bag。
每次取出一个。
bag 用完以后再重新洗牌。
尽量避免新一轮第一张与上一轮最后一张相同。

但 shuffle-bag 状态不能在"准备发送"时就提交。

正确顺序是:

text 复制代码
选择候选
  -> 复核文件
  -> 尝试 QQ 发送
  -> 发送成功
  -> 提交 cooldown/hourly/shuffle-bag 状态

如果图片发送失败:

text 复制代码
不重试
不换图
不增加小时计数
不提交冷却
不消费 shuffle-bag
不影响正文

如果图片已经发出,但状态提交失败,则进入熔断,防止后续频率控制失真后继续自动发送。

为什么图片发送失败不换图重试

"换一张再发"看起来很贴心,但会引入新的不确定性:

text 复制代码
第一张可能其实已经发出,只是客户端超时。
换图后用户收到两张。
连续重试绕过了每次最多一张的语义。
不同图片可能对应不同强度和动作。
失败风暴会放大 OneBot 或网络问题。

因此自动发送是严格的 at-most-once 尝试:

text 复制代码
一次选择。
一次 send_private_msg。
失败就结束。

主人如果想检查具体资产,可以使用单独的显式预览命令。预览同样只允许主人私聊、精确 sticker ID、一次发送,不调用 ChatAgent、MainAgent、RAG、Tavily 或分类模型。

真实 QQ live 验证了什么

这条链路最后通过了一次完整 live:

text 复制代码
主人发送明确的卖萌请求。
先收到正常角色文字。
随后收到并播放动态 GIF。
独立分类器输出受限标签和高置信度。
本地选择器命中正式库资产。
OneBot 单次图片发送成功。

这次 live 能证明:

text 复制代码
正文和附件顺序正确。
动态 GIF 在 QQ 中可播放。
远程标签能驱动本地选择。
发送成功后状态可以提交。

它不能证明:

text 复制代码
所有语境都适合发表情。
所有候选的审美都符合主人偏好。
群聊和非主人应该开放自动发送。
分类器永远不会失败。

事实上,主人不喜欢第一张自动发送的 GIF,这反而验证了撤销机制的必要性:资产被设置为 disabled,文件保留,不再进入选择。

测试边界

本轮测试覆盖了:

text 复制代码
候选扫描与预算
静态图和动画识别
源哈希重新匹配
重复 ID 和重复来源拒绝
批准草稿一致性
规范化编码
manifest schema/revision
原子写入
撤销
预览
远程分类器非法配置、超时、鉴权、限流和非法 JSON
本地标签选择
频率预检
shuffle-bag
发送前完整性复核
QQ 单次 sender
发送失败零提交
成功后状态提交

最新完整回归为:

text 复制代码
607 tests OK

真实表情图片、manifest、批准草稿和本地报告不进入 Git。

最后

这个功能最初只是"聊天后面加一张图",最后却让我重新确认了几条很通用的 Agent 工程原则:

text 复制代码
可选增强能力不能破坏主结果。
远程模型只做它擅长的语义判断,不直接掌握本地资源句柄。
模型输出必须经过本地枚举和策略校验。
候选资产不等于正式资产。
审批前后的文件必须重新核对,避免 TOCTOU。
发送前仍要复核正式文件,不能永久信任磁盘。
确定性本地门控应该放在远程调用之前。
失败不重试、不换图,有时比"尽量成功"更可靠。
状态只在副作用真正成功以后提交。
撤销优先于物理删除。

现在这套表情系统的边界很明确:

text 复制代码
ChatAgent 负责把话说好。
独立分类器只判断语义。
本地代码负责资格、选择和频率。
正式库负责资产真实性。
OneBot 只发送最终通过复核的一张图。

对一个运行在真实 QQ 会话里的 AI Chatbot 来说,这种克制比让模型"自由发挥"重要得多。

相关推荐
陈随易3 小时前
FFmpeg 9.0 发布,代号 Lei,音视频处理再升级
前端·后端·程序员
凌虚6 小时前
面向 MySQL 用户的 PostgreSQL 快速上手指南
数据库·后端·架构
IT_陈寒8 小时前
Vite静态资源引用这个坑我踩得有点疼
前端·人工智能·后端
前端开发张小七8 小时前
Java 学习笔记 · 第一课:基础语法与核心概念
java·后端
海盗12348 小时前
微软技术周报 ——2026-08-03
后端·python·microsoft·c#·.netcore
沙湖遇雨8 小时前
6. Pipeline 的生命周期
后端
青青子衿悠悠我心8 小时前
TRAE Work让财务月底对账3小时变5分钟
后端
创安电气研究所9 小时前
用TypeScript给Modbus设备做一层适配器
后端
妙码生花9 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(五十):增加管理员角色组管理
前端·后端·ai编程
妙码生花9 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(五十一):管理员和角色组的关联
前端·后端·ai编程