给多个 agent 做路由:五维打分、能力当门、以及为什么无观测取 0.5
一个任务该交给哪个 agent。打分容易,难的是让还没试过的和每次都失败的区分开,以及让一个坏掉的后端不再靠过期统计持续赢。
平台后面挂着几个编程 agent,一个任务来了要决定交给谁。这层叫路由。
打分本身不难。难的是几个不这么写就会静默出错的地方 —— 下面基本都是这类。
五个维度,来源不同
| 维度 | 从哪儿来 |
|---|---|
| 能力匹配 | agent 在配置里自己声明 |
| 成本 | 自己声明(1–5) |
| 预期延迟 | 自己声明 |
| 历史成功率 | 平台观测到的,存在记忆的长期层 |
| 当前负载 | 此刻正在跑几个回合 |
前三个是它说的,后两个是我看到的。这个区别后面会用上。
能力是门,不是权重
DEFAULT_WEIGHTS = {
"cost": 0.35,
"latency": 0.35,
"success": 0.20,
"load": 0.10,
}
四个维度,加起来正好 1.0 —— 于是免费、即时、从未失败、空闲这一组刚好得 1.0,分数能直接读出来是什么意思。
能力不在这四个里面。它是一道门:声明了自己做不了图像的 agent,不能靠又便宜又快赢下一个图像任务。要是把能力也做成权重,那条声明就是装饰品。
但打分函数照样把它报出来,带上缺哪个能力的原因 —— claude 为什么输了,这个值得一个答案。报出来和路由给它是两件事:
if capable:
reason = f"total {total:.3f}"
else:
missing = sorted(task.capabilities - descriptor.capabilities)
reason = f"missing {', '.join(missing)}"
total = 0.0
延迟只在 1 到 10 秒之间有意义
LATENCY_MIN_MS, LATENCY_MAX_MS = 1000, 10_000
1 秒或更好得 1.0,10 秒或更差得 0.0。
为什么截在 10 秒:超过十秒,这个语音回合对用户来说已经失败了。继续给 30 秒和 60 秒分级,是在度量多晚算太晚,那个刻度没有用处。
负载同理,饱和点设在 10 个并发 —— 到那个量级,排队时间已经压过它声明的延迟了。
无观测取 0.5,不取 0
这是整层里我觉得最重要的一个常数。
#: 一个还没有任何记录的 agent 得的分。故意取中间值:
#: 取 0 会让没试过的 agent 输给一个每轮都失败的 agent。
UNKNOWN_SUCCESS = 0.5
没有数据是一个断言,全部失败是另一个断言,这两个不一样。把它们都算成 0,第一次启动时的结果是:整支队伍静默塌到碰巧先跑过的那一个上面,其它 agent 再也没有机会被试一次。
供给这个数的那一端也得配合 —— 成功率统计在无观测时返回 None,不返回 0.0:
"rate": None if total == 0 else ok / total,
一个返回 0.0 的统计函数,会让下游所有消费者都失去分辨这两种情况的能力。
越界的声明要 clamp,不能外推
成本和延迟都是 agent 自己填的,所以要防它乱填:
clamped = max(COST_MIN, min(COST_MAX, descriptor.cost))
一个报 cost = -5, latency_ms = 1 的条目,不能靠撒谎拿到超过 1.0 的总分。
名字破平,不然测试会飘
return tuple(sorted(scores, key=lambda entry: (-entry.total, entry.agent)))
分数相同时按名字排。顺序如果取决于字典插入次序,同一支队伍两次规划的结果可能不一样,路由测试就会时绿时红 —— 而一个偶尔失败的测试等于没有测试。
熔断器:中间那个状态才是重点
不加熔断的话,一个开始坏掉的 agent 会靠过期的成功率继续赢得路由,而它赢的每一轮都要先付一次完整超时才失败。
三个状态:
closed—— 正常。失败累计,成功清零。open—— 被路由完全跳过,直到冷却结束。half_open—— 冷却到了,只放一个探针过去。成功就闭合,失败就重新开始冷却。
中间这个状态是关键。没有它,一个正在恢复的 agent 会在零证据的情况下直接接下健康 agent 的全部流量;而一个其实还没修好的,会让同一瞬间放进来的每个并发回合都再失败一次。
DEFAULT_THRESHOLD = 3 # 连续三次,不是一次
DEFAULT_COOLDOWN_S = 60.0
阈值取 3 不取 1:单次超时常常是宿主的问题,不是 agent 的问题,为它跳闸会让一个能用的 agent 白白歇一个冷却周期。
两个附带的决定:
- 时间是注入的,不从时钟读。一个靠
sleep验冷却的测试,又慢又飘。 - 跳闸状态不持久化。成功率活在记忆层里,能跨重启;熔断器故意不跨 —— 关机那一刻坏掉的 agent,值得在下次启动时拿到一次机会。
跳闸的 agent 是被排除,不是排名靠后。一个打开的熔断器不是一个排名,是一次移除。
计划了但没跑起来的,不算它失败
def release(self, agent):
"""归还负载槽位,但不记录任何结果。"""
如果一个 agent 被规划了,结果发现根本没有对应的适配器 —— 那是配置不匹配。把它记成一次失败,平台会永久绕开一个其实从没失败过的后端。
同理,审计写入失败要被吞掉:统计是回合的增强,不是回合的前提。一个被锁住的数据库不该有能力中断一次对话。
三种派发模式,以及为什么全都发不能做默认
single 是默认,race 宽度取 2,fanout 只在显式要求多方验证时用。
fanout 不能做默认的理由很硬:聚合必须等最慢的那个成员。首字延迟会从最快的那个退化成最差的那个,而语音交互里首字延迟就是全部体感。
race 里还有一个坑:获胜者在首个 chunk 到达时决定,不是谁先完成。按先完成判,赢的会是空流 —— 什么都不说的那个最先结束。带错误的终结 chunk 也算说话了,这时候不能静默切给输家,否则这个合并流就变成了一个隐式重试机制,而派发器会把一轮失败记成成功。
收成一句
这层里几乎每个常数都是在回答同一个问题:如果它取另一个值,会静默错成什么样。0 和 None、排除和排名靠后、拒绝和询问 —— 这些区别在正常路径上看不出来,只在出事那天看得出来。