从“让聊天模型顺便选表情”到独立分类器:我给 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 来说,这种克制比让模型"自由发挥"重要得多。

相关推荐
无责任此方_修行中1 小时前
面对 300+ 仓库的重复劳动,我用 Guideline 优化了 Vibe Coding 工作流
后端·ai编程·vibecoding
蛋挞大王0011 小时前
精简版 MyBatis
后端
952362 小时前
RabbitMQ - 高级特性
java·spring boot·分布式·后端·rabbitmq
IT_陈寒2 小时前
为什么我的React状态更新总是不按套路出牌?
前端·人工智能·后端
一路向北North2 小时前
Spring Security OAuth2.0(22):分布式系统授权-搭建网关
java·后端·spring
存在的五月雨2 小时前
SpringBoot后端限制重复提交
java·spring boot·后端
paopaokaka_luck3 小时前
基于Springboot3+Vue3的宠物殡葬管理系统(webSocket实时通讯、接入腾讯地图、支付宝沙盒支付、Echarts图形化分析)
java·开发语言·网络·后端
卷无止境3 小时前
Python 中基于 Qt 的 GUI 库授权方式全解析
后端·python
程序员爱钓鱼3 小时前
Rust 注释与文档注释详解:从代码说明到 cargo doc
后端·rust