技术文章

给多个 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、排除和排名靠后、拒绝和询问 —— 这些区别在正常路径上看不出来,只在出事那天看得出来。