最近我给自己的 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 来说,这种克制比让模型"自由发挥"重要得多。