题记:真实数据也会撒错地方;这篇文章讲一个 token 读数如何越过了自己的适用范围。

1. 数字没错,结论错了⌗

多模型 Agent 有一个很诱人的优化:在请求发出去之前,看看上下文水位,把装不下的候选模型过滤掉。

实现甚至相当克制:不用字符数除以某个常数,不在本地猜 tokenizer,只相信 API 返回的真实输入 token 数。

但这里仍然藏着一个错误:API 返回了真实数字,不等于这个数字对路由链上的所有模型都有效。

这次修复之前,路由接口接收的是:

knownPromptTokens?: number

调用者把一个可信读数交给路由器,路由器拿它计算所需窗口,然后逐个筛选候选。读数的真实性保住了,读数的来源却丢了。

于是,「某个 provider 测到了这么多 token」悄悄变成了「所有候选都需要容纳这么多 token」。后半句没有证据。

这不是测得不准,而是用得太宽。

2. 修复的关键不是换算法,是给数字补主语⌗

落地代码把接口改成了一个很小的结构:

export interface KnownPromptUsage {
  providerId: string
  tokens: number
}

knownPromptUsage?: KnownPromptUsage

容量公式没有变。路由器仍然按代码中的 ROLLOVER_THRESHOLD_PERCENT = 0.95 计算:

所需窗口 = ceil(已测输入 token 数 / 0.95)

这个阈值与主动 rollover 的阈值对齐,并不是本次新加的余量。

真正改变判断范围的是下面这一行。以下为删去日志与异常文案的逻辑摘录:

const { providerId: measuredOn, tokens } = knownPromptUsage
const requiredContextWindow = Math.ceil(tokens / ROLLOVER_THRESHOLD_PERCENT)

const contextFiltered = available.filter(p => {
  if (p.id !== measuredOn) return true
  const cw = this.lookupProviderShape(p.id)?.contextWindow
  if (cw === undefined || cw === null) return true
  return cw >= requiredContextWindow
})

p.id !== measuredOn 时直接保留,不代表那个模型一定装得下。它表达的是:这份读数不能作为淘汰它的依据。

候选与读数的关系容量预检怎么做
候选 ID 等于读数来源,且配置了窗口用读数与窗口比较
候选 ID 不等于读数来源不用这份读数筛掉它
没有读数,或 token 数不是有限数跳过这项预检
对应候选没有配置窗口沿用兼容行为,不因本项预检淘汰

这里匹配的是 provider ID,不是模型家族,也不是「看起来差不多的 tokenizer」。实现没有建立跨 provider 的等价关系,自然也不假定存在这种关系。

3. 测试最有价值的部分,是让“小模型”留下来⌗

src/llm/router.test.ts 新增的测试构造了一条链:小窗口候选在前,大窗口候选在后。窗口分别是 1,000 和 1,000,000;provider 调用被 mock,消息只是极短的测试输入。

下面为便于阅读,把两个候选记作 S 和 L。数值与期望行为来自测试代码,不是线上测量。

已知读数测试期望的回答者要证明的规则
L 测得 900,000SL 的大读数不能淘汰 S
L 测得 990,000S即使 L 自己超过预检阈值,也不能连坐 S
S 测得 980LS 自己的读数超过阈值,仍应淘汰 S

第一行最容易让人不舒服:九十万 token,居然让一千窗口的模型先试?

测试没有证明一千窗口能装九十万 token。它证明的是,不能把 L 的九十万说成 S 的九十万。

第二行进一步拆开了两个判断:L 不满足自己的容量预检;S 没有自己的读数。这两件事可以同时成立。

第三行则防止修复滑向另一个极端——干脆关掉容量预检。自己的有效读数仍然有约束力。

测试还检查了调用次数:这三次路由后,S 被调用两次,L 被调用一次。它不只检查最后返回的字符串,也检查谁真的进入了调用路径。

相比只测「数字太大就报错」,这种测试钉住的是证据边界。

4. 数据库分开存,还不够⌗

如果读数在存储时按 provider 隔离,到了函数接口却重新变成裸数字,前面的隔离就没能保护后面的决策。

本次改动补的是传递链:

直接执行循环
  有效 provider + 已知 token 数
                 │
                 ▼
         KnownPromptUsage
                 │
                 ▼
       路由器只预检同 ID 候选

缓存前缀摘要
  前缀 provider + 前缀 token 数
                 │
                 └──────► 同一个路由接口

直接执行循环先取一次 knownPromptTokens(),有读数时,将它与 effectiveProvider 一起传给 callStream。

对应的 src/runtime/direct-loop-host.test.ts 捕获路由参数,断言收到的是带 provider ID 的 4,321,而不是一个裸数字。它覆盖的是交接点:路由器做对了还不够,调用者也必须把来源交过来。

缓存前缀摘要的调用点则用 prefix.providerId 与 prefix.promptTokens 构造同一个结构。这个路径本来就要求在前缀所属 provider 上继续生成摘要,调用中有 modelRequired: prefix.providerId;现在容量读数的归属与模型约束保持一致。

摘要路径自己的空间检查也没有删:它仍检查前缀占用加上摘要预留和安全余量是否超过窗口。带来源的读数修复归属问题,不替代摘要任务的容量预算。

5. “不知道”为什么不直接判失败⌗

这个设计选择了一个明确的取舍:没有对应测量时,容量预检放行,让上游 API 对实际请求作最终判断。

放行只是跳过这一项预检,不会取消视觉能力、模型选择等其他约束,更不是保证调用成功。

代价也很具体:一个没有自身读数的候选,可能在发出请求后才被上游拒绝。仅靠本地预检,无法省掉这次尝试。

另一条路是把别人的读数当成保守上界。但这份代码没有证明它真的是上界。一个未经证明的上界,足以把本来可能成功的候选提前删掉。

两种错误的区别是:

策略可能发生的错误
跨 provider 复用读数在未请求候选之前,就错误宣布它装不下
只用对应来源的读数保留一个后来可能被上游拒绝的候选

本次修复选择后者。这不是「多试试总能成功」,而是拒绝让优化层依据不适用的数据提前裁决。

与继续打磨 token 估算公式相比,这个改动更值得借鉴的地方是:先限定一个数有资格回答什么问题,再讨论它有多精确。

6. 来源补上了,时间没有倒流⌗

这个结构也不是完整的测量证明。

第一,providerId 是调用者填写的字符串。类型让来源不能再被无意省略,却不能验证标签一定正确。直接执行循环仍依赖一个前提:取到的读数确实属于它传入的有效 provider。

第二,读数通常来自上一次 API 调用。下一次调用可能增加消息、工具结果或摘要指令。即使 provider 相同,上一次的占用也不等于当前请求的精确大小。

第三,这个对象没有携带请求版本、配置版本或测量时间。本次改动解决的是跨 provider 误用,没有解决所有读数陈旧问题,也没有声称解决。

因此,准确的评价不是「现在容量判断可靠了」,而是:现在路由器少做了一类没有依据的容量判断。

这已经是很实在的进步。Agent 系统里,很多错误不是没有拿到真值,而是真值穿过几层接口之后,忘了自己原本在描述谁。


代码依据:落地版本 ccbb463 的 src/llm/types.ts、src/llm/router.ts、src/runtime/direct-loop-host.ts、src/memory/compaction.ts,以及对应的路由和执行循环测试。文中的测试结果描述指测试代码规定的断言,不是本文另行运行的基准成绩。