Technical Writing

Routing across several agents: five scored axes, capability as a gate, and why no data means 0.5

Which agent should take a task. Scoring is the easy part; the hard part is telling “never tried” apart from “fails every time”, and stopping a broken backend from winning on stale statistics.

Only the Chinese original exists for this one. The text below is that original.

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