给 agent 装一道闸门:用 4B 小模型决定要不要叫醒它
群里绝大多数消息不值得唤醒 agent。记录一次用本地 4B 模型做前置闸门的落地:两档判定、fail-closed、不丢消息的 backlog,以及 195 毫秒的寒暄拦截与 0.917 的唤醒判定背后的取舍。
「大家早。」
群里冒出一句问候。195 毫秒后,一个 4B 的小模型给出判断:codex=0.026,nobody=0.974——不值得叫醒任何人。这条消息留在未读里,没有烧掉一轮 16K token 的 Codex。
下一条就不一样了:「请帮我检查一下构建为什么失败。」codex=0.917。两条消息合并补投,一次唤醒,一轮解决。
这个把「要不要叫醒 agent」压成一次 195 毫秒本地推理的组件,我们叫它 Jev 闸门。它要回答的问题很朴素:群消息是无限的,agent 的上下文和 token 是有限的;而问候、闲聊、转发占了大多数,它们不该触发一轮昂贵的推理。
一、贵的是「被叫醒」,不是「判断」
把 Codex 接进 IM 群,第一版通常很直接:来消息就唤起一轮。跑通之后很快会撞上两个事实。
第一,一轮的固定成本不低。首轮实测约 16K input tokens,单轮默认超时 15 分钟。第二,群里的消息密度和「值得干活的密度」完全不是一回事。一个几十人的群,一天里真正需要 agent 动手的可能只有个位数,其余是「收到」「好的」「晚上吃什么」。
于是问题从「模型能不能答」变成了「什么时候值得叫它答」。这两件事的成本结构完全不同:判断一次是毫秒级、本地、几乎免费;答一次是分钟级、联网、按 token 计费。用后者去过滤前者,等于用大炮打蚊子。
二、闸门长什么样
一条消息进来,先过三道:
- 去重与合批:按
(roomId, seq)去重,3 秒窗口内的突发消息折成一批。群里常见「在吗」「帮我看看」「构建挂了」三条连发,合批后只算一次判断。 - 分档:owner 的消息直接放行,不进模型;其他人的消息才进闸门。
- 决策:在
codex和nobody之间做一次 choice,probabilities.codex >= 0.20才唤醒。
三道里最容易被误解的是第二道。为什么 owner 不走模型?不是特权,是成本不对称:owner 的每条消息都有明确意图,误判一次的代价远高于省下的那点推理;而其他人的消息里噪音占比高,才值得先问一句「这需要 Codex 吗」。
判断完之后才是投递:把这一批消息拼成提示词,交给固定的 Codex 线程;只有在回合成功、回复发出之后,才推进已读。顺序不能反——先标记已读再跑,一旦超时或失败,消息就真丢了。
三、0.20 不是校准过的概率分界
直觉上二分类的阈值该是 0.5,这里写的是 0.20。先说明它不是什么:它不是从校准曲线里算出来的概率分界。4B 模型的输出概率没经过校准,0.20 沿用了群聊插件里唤醒群成员的同一阈值,是个经验值。
它是怎么起作用的:概率在两个候选上归一化(codex + nobody = 1),只要 codex 拿到 0.20 就叫醒。行为上这是个偏低的门槛——宁可多叫一次,也不容易漏掉活。实测里两类样本分得很开:寒暄 0.026,干活 0.917,中间地带很窄,所以这个门槛不需要精确到小数点。真要调,应该先拿真实群消息统计误唤醒率和漏唤醒率,再决定往上还是往下,而不是凭手感拍一个更「好看」的数。
另一头是 fail-closed:模型给不出信号(超时、报错、返回形状不对)时一律不叫。两个方向的取舍看起来相反,其实对应两种不同的失败:模型看见了、判断偏向干活,就让它干;模型根本没给出判断,就不拿一整轮推理去赌。晚点到,好过白烧一轮。
四、fail-closed:判不了就不叫
闸门自己也会坏:决策服务没起、超时、HTTP 错、返回结构不对、代码抛异常。这些情况下它一律返回「不唤醒」。
理由还是代价不对称。决策服务挂掉的时候,最坏的结果不该是「所有消息都去跑一轮 Codex」。错判唤醒要花钱,错判不唤醒只是延迟。所以超时给 3 秒,到点就 abort;失败只告警一次(避免每批消息刷一条日志),恢复后自动复位。
一个细节:决策器永远 resolve,永不抛。调用方不需要写 try/catch 去兜底一个「判断失败」的分支——失败就是空集合,空集合就是不唤醒。
五、不唤醒不等于丢消息
这是整套设计里最容易做错的一环。如果「不唤醒」等于「已读」,那闸门就是个漏消息的漏斗。
这里的做法是:不唤醒时不 markRead,消息在 IM 里保持未读;同时记进进程内的 backlog。下一次真的唤醒时,把 backlog 和当前这批合并成一次投递,提示词开头写 While you were away, N message(s) arrived:,让模型知道这些是「你不在的时候」积压的,而不是刚发生的。
进程重启也不丢:启动时从房间列表拿 lastReadSeq,把未读重新装回 backlog。这样「闸门判断失误」最坏只是延迟,不是丢失。
六、实测
真 IM × 真决策服务 × 真 Codex(codex-cli 0.163)跑出来的四组数据:
| 场景 | 闸门结果 | 后续 |
|---|---|---|
| owner 说话 | wake=true reason=owner,不调模型 |
首轮 16.6s 建线程,回帖 |
| 非 owner 寒暄「大家早」 | 195 毫秒,codex=0.026 / nobody=0.974 |
不唤醒,backlog=1,无 markRead |
| 非 owner 干活「检查构建为什么失败」 | 251 毫秒,codex=0.917 |
合并两条,27.2s 回帖 |
| 决策服务超时 | fail-closed | 不唤醒,消息留未读 |
195 毫秒这个数字值得单独说:它比消息合批窗口(3 秒)还短一个数量级。也就是说闸门的判断成本几乎可以忽略,真正的延迟都在「决定唤醒之后」的那一轮 Codex 上。
配套的单测覆盖了 owner 强唤醒、nobody 不丢、超时 fail-closed、去重、线程持久化与回帖截断,18/18。
七、踩过的坑
模型只吃英文。 4B 模型的训练集以英文为主,中文消息直接喂进去,判定质量会掉。解法不是接一个翻译服务(那又多一个依赖和一个延迟),而是内置一个轻量词典:按长词优先替换,覆盖寒暄、请求、工程动作这些高频词;翻不动就原样传,记一条 jev_translate_fallback,不阻断判定。词典当然不完美,但它把「常见消息」这一档的命中率拉到了可用范围。
冷启动 30 秒。 4B 模型首次加载要半分钟左右,这段时间决策服务还没就绪,闸门走 fail-closed,消息不叫醒、留在未读,等下次唤醒补投。systemd 的 Restart=always 只能兜「进程退出」这一种情况,对「进程活着但模型还没加载完」不起作用;要更稳,得给服务加就绪探针(或 systemd 的 Type=notify),让流量等它就绪。
固定线程会一直长。 Codex 自己会压缩上下文,但轮次越多成本越高。要换新线程就停桥、删掉 thread 状态文件再启动;旧线程还能 resume 找回。这不是 bug,是常驻形态的固有代价。
别信模型对自己权限的描述。 同一线程里,模型可能凭上一轮记忆说「我在只读沙箱」,而根本没尝试写文件。判断它能不能改文件,要看落地物——让它写一个文件,然后 ls -l 看那个文件在不在,不要看它的自述。
systemd 的引号。 额外参数用 JSON 数组字符串传,必须放在 EnvironmentFile 里。写进 Environment= 会被 systemd 吃掉引号,值变成 [--yolo],解析失败后静默退回空数组——日志里一切正常,模型却说自己只有只读沙箱。启动日志里回显解析结果(codex_args=)是必要的。
八、这道闸门不做什么
它不是路由:不判断「这条消息该给哪个 agent」,只判断「现在要不要叫醒这一个」。
它也不追求判断准确率。4B 模型在长上下文、多意图、跨语言上的判断会失准,这套设计不打算靠模型解决这个问题,而是靠结构兜底:owner 直连、fail-closed、backlog 不丢、阈值可调。真正需要精确分诊的场景,应该上更强的模型或者人工规则,而不是把一个 4B 模型调到过拟合。
尾声
回到开头那 195 毫秒。它真正省下的不是一次推理的钱,而是一种注意力分配:让 agent 的每一轮都发生在「有人真的需要它」的时候。
闸门的价值不在判断得多准,而在于它把昂贵的那部分,留给了少数值得的消息。