# Step 22: Token 预算控制
一句话导读:给引擎装一个
TokenBudget计数器,把「无限对话」变成「有硬上限的对话」——累计消耗逼近上限时先警告、超限时直接拦下一次请求,从源头防止上下文塞爆和账单失控。
# 一、这一步做了什么(What)
新增 utils/budget.ts 里的 TokenBudget 类,并把它接进主循环:
- 每次 API 调用返回后,把本轮
input + output的 token 数add()进累计值; - 用量达到上限 80% 时发一次(且仅一次)警告;
- 用量达到上限时
isExceeded()为真,在下一次请求前直接拦截; - 提供
/budget命令查看/设置/重置,/info和启动横幅都显示当前百分比。
默认上限 64000 tokens(对齐真实源码 constants/toolLimits.ts)。这一步不改变模型行为,只是给对话套了一个「油量表 + 熔断器」。
# 二、面试官视角:为什么要做预算控制?(Why)
面试题:模型自己有上下文窗口上限,超了 API 会报错——那我在应用层再维护一个 Token 预算,不是多此一举吗?
不是。模型的窗口上限和应用的预算是两件事,各管一段:
| 维度 | 只靠模型窗口 | 应用层 TokenBudget |
|---|---|---|
| 触发时机 | 塞满了才 400 报错,事后才知道 | 逼近上限就警告,事前可控 |
| 控制粒度 | 只有「行/不行」一个死线 | 可自定义上限(比窗口更小),tight 场景收紧 |
| 关心的事 | 只关心「装不装得下」 | 还关心「花多少钱」——token 就是钱 |
| 失败方式 | 硬报错,用户懵 | 主动熔断 + 提示,可预期 |
核心动机有两个:上下文窗口是稀缺资源,长对话会悄悄逼近窗口,与其等 API 抛 400,不如自己先画一条更保守的红线;token 就是账单,一个没有预算感的 Agent 可以在一次跑飞的循环里烧掉几十美元。TokenBudget 就是把这条红线显式化、可观测化、可熔断化。
# 三、原理:它是怎么工作的(How)
# 完整实现(真实代码,仅 24 行)
export class TokenBudget {
private max: number;
private used = 0;
private warned = false;
constructor(maxTokens = 64000) { this.max = maxTokens; }
add(tokens: number) { this.used += tokens; }
isExceeded(): boolean { return this.used >= this.max; }
shouldWarn(): boolean {
if (this.warned) return false; // 只警告一次
if ((this.used / this.max) >= 0.8) { this.warned = true; return true; }
return false;
}
setMax(n: number) { this.max = n; }
reset() { this.used = 0; this.warned = false; }
stats() {
return { used: this.used, max: this.max, remaining: this.max - this.used,
percent: Math.round((this.used / this.max) * 1000) / 10 };
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
三个字段就是全部状态:max(上限)、used(累计消耗)、warned(是否已警告过,防刷屏)。
# 数据流:它在主循环里怎么被调用
用户输入
↓
if (budget.isExceeded()) → 拦截,提示 "budget exceeded",不发请求
↓ 未超限
callWithTools(...) → { inT, outT }
↓
budget.add(inT + outT) // 累加本轮消耗
↓
if (budget.shouldWarn()) → info("budget: " + percent + "%") // 首次过 80% 才提示
↓
下一轮循环
2
3
4
5
6
7
8
9
10
11
对应 index.ts 里的真实接线:
if (budget.isExceeded()) { showErr('budget exceeded'); continue; }
// ...拿到 result 之后
budget.add(result.inT + result.outT);
if (budget.shouldWarn()) info('budget: ' + budget.stats().percent + '%');
2
3
4
# 三个设计细节
| 细节 | 做法 | 为什么 |
|---|---|---|
| 警告只发一次 | warned 标志位,触发后置 true | 每轮都在 80% 以上会刷屏,一次提醒足够 |
| 熔断放在请求前 | 循环开头先 isExceeded() | 超限后连请求都不发,省下这一次的钱和风险 |
| 百分比预先算好 | stats() 里 Math.round(...*1000)/10 | 保留一位小数(如 82.5%),UI 直接用 |
# 四、深入追问(面试常见 follow-up)
Q:add() 是在请求返回后才累加,那超限判断岂不是「慢一拍」——本轮其实已经超了?
A:是的,这是「事后记账 + 事前熔断」的组合。本轮请求已经发出去了,add 记的是既成事实;但下一轮开头的 isExceeded() 会用更新后的 used 拦住,所以最多超一轮的量。要做到「事前精确不超」得先估算本轮 token 再决定发不发,成本更高、这一版没做。
Q:为什么阈值选 80% 而不是 90% 或 95%?
A:留缓冲。80% 警告时用户还有约 20% 的窗口可以从容处理——保存会话、/compact 压缩、或换更大预算。等到 95% 才提醒,往往已经来不及在超限前做完补救动作。
Q:used 累加的是 input+output,但下一轮请求真正占窗口的是「历史消息总量」,两者不是一回事吧?
A:对,这里 used 是「累计吞吐量」而非「当前上下文占用」。它更贴近「一共花了多少钱/烧了多少 token」这个账单视角。真正的「当前上下文有多大」由后面的对话压缩(step24)去管。两个指标解决两个问题,不冲突。
Q:/budget set 能动态调上限,如果调到比 used 还小会怎样?
A:setMax 只改 max,不动 used。下一次 isExceeded() 立刻为真,直接熔断——相当于「手动踩刹车」。这是合理行为:用户主动收紧预算,系统就该立刻停下来。
# 五、踩坑 / 设计权衡
- 计数器 vs 精确 token 化:真实 Claude Code 用 tokenizer 精确算每条消息的 token;我们直接信任 API 返回的
usage,简单但只能事后知道。对学习版够用,生产版需要「发送前预估」才能做到严格不超。 - 单一全局预算 vs 分项预算:这里所有消耗共用一个池子。真实版还有 taskBudget(子任务预算)等更细的分账,避免某个子任务偷偷吃光整体预算。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
utils/budget.ts(24 行计数器) | utils/tokenBudget.ts(74 行) |
| 默认 64000,对齐常量 | constants/toolLimits.ts 的窗口/预算常量 |
add(inT+outT) 事后累加 | 结合 tokenizer 做更精确的预算核算 |
| 单一全局预算池 | 分层预算(总预算 + 子任务预算) |
| 80% 一次性警告 | 分级提示 + 逼近时触发压缩 |
# 七、一句话总结
TokenBudget = 对话的油量表 + 熔断器:三个字段(max/used/warned)撑起「事后记账、80% 警告一次、超限前熔断」的最小闭环,把「无限对话」变成「有硬上限、可观测、能刹车」的对话,为后面的成本追踪和自动压缩铺好底座。
# 下一节预告
预算管的是「用了多少 token」,但用户更想知道「花了多少钱」。step23 在预算之上叠加 CostTracker,把 token 数换算成实打实的美元。