# Step 22: Token 预算控制

一句话导读:给引擎装一个 TokenBudget 计数器,把「无限对话」变成「有硬上限的对话」——累计消耗逼近上限时先警告、超限时直接拦下一次请求,从源头防止上下文塞爆和账单失控。


# 一、这一步做了什么(What)

新增 utils/budget.ts 里的 TokenBudget 类,并把它接进主循环:

  1. 每次 API 调用返回后,把本轮 input + output 的 token 数 add() 进累计值;
  2. 用量达到上限 80% 时发一次(且仅一次)警告;
  3. 用量达到上限时 isExceeded() 为真,在下一次请求前直接拦截;
  4. 提供 /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 };
  }
}
1
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% 才提示
  ↓
下一轮循环
1
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 + '%');
1
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 数换算成实打实的美元。