Token 数是真的,也不能拿来筛所有模型

题记:真实数据也会撒错地方;这篇文章讲一个 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,000 | S | L 的大读数不能淘汰 S |
| L 测得 990,000 | S | 即使 L 自己超过预检阈值,也不能连坐 S |
| S 测得 980 | L | S 自己的读数超过阈值,仍应淘汰 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,以及对应的路由和执行循环测试。文中的测试结果描述指测试代码规定的断言,不是本文另行运行的基准成绩。