# 10 · Token 预算：与有限上下文窗口的持久战

> 每轮重发全部历史，而工具结果(一次文件读、一条命令输出)动辄上万 token，上下文窗口迟早撑爆。讲清为什么要估算 token、如何用 API 回报的 usage 作为地面真值、以及“警告—自动压缩—硬阻断”的分级水位线。这是整个上下文工程的起点。

- 作者：David（道雾轩）
- 专栏：从 LLM 到 Coding Agent（https://daiw.org/manual/llm-to-agent.md）
- 最后更新：2026-08-03
- 原文：https://daiw.org/manual/llm-to-agent/token-budget
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 10 · Token 预算：与有限上下文窗口的持久战

欢迎来到第五阶段。前面四个阶段，我们一直在“往 Agent 上加能力”；从这一阶段起，我们要处理一个一直在回避、却决定 Agent 生死的约束——**上下文窗口是有限的**。这是整个 Agent 工程里最难、最见功力的部分。

## 一、背景：一场必输的军备竞赛

回忆[01 篇](https://daiw.org/manual/llm-to-agent/raw-llm-api)那个残酷事实：模型无状态，所以**每一轮你都要把到目前为止的全部历史重发一次**。现在把它和 Agent 的工作方式叠加起来看：

- 一轮对话里，历史在疯长：用户消息、助手消息、`tool_use`、还有——最凶的——**工具结果**。
- 一次 `read_file` 读进来一个 2000 行的文件，可能就是上万 token。一条 `npm test` 的输出、一次 `git diff`，随随便便几千 token。
- 这些全都留在历史里，**每一轮都要重发**。

于是上下文像滚雪球一样膨胀。而窗口有硬顶(比如 20 万 token)。这是一场**你必输的军备竞赛**——只要对话够长，迟早撞顶。撞顶的后果有两种，都很糟：

1. **硬失败**：请求超过窗口，API 直接拒绝(常见的 `prompt too long` / HTTP 413),Agent 卡死。
2. **软退化**：就算没撞顶，上下文越长，模型越容易“迷失在中间”——关键信息被淹没在几万 token 的历史里，它开始遗忘、跑偏。

<Callout type="info">
  **所以上下文不是“越多越好”，而是一种需要精打细算的稀缺资源。** 第五阶段所有的技术——压缩、缓存、瘦身、注入——本质都在回答同一个问题：**在有限的窗口里，如何始终装着“模型此刻最需要的那些信息”，而把其余的挪走。** 这就是“上下文工程”(context engineering)。而一切的前提，是先能**量出**当前用了多少。
</Callout>

## 二、解决方案：先学会“量”

管理上下文的第一步，是知道它现在有多大。这里有个务实的现实：**精确计算 token 需要跑分词器(tokenizer)，开销不小，而 Agent 每一轮都要算好几次。** 所以工程上通常是“估算 + 校准”两条腿走路：

### 估算：便宜的近似

一个粗略但够用的估算：**字符数 / 4 ≈ token 数**(英文经验值；中文、代码另有系数)。它不精确，但极快，足够用来做“要不要开始警惕了”的判断。

```ts title="便宜的 token 估算"
function estimateTokens(messages: Message[]): number {
  const chars = JSON.stringify(messages).length
  return Math.ceil(chars / 4)   // 快而糙,用于水位判断
}
```

### 校准：用 API 回报的 usage 作地面真值

还记得[01 篇](https://daiw.org/manual/llm-to-agent/raw-llm-api)响应里那个 `usage` 吗？**每次 API 响应都会回报这次请求的精确 `input_tokens`**。这是权威数字。聪明的做法是：拿上一次响应的 `input_tokens` 作为“地面真值”，再叠加此后新增消息的估算量：

```ts title="用上次的精确值 + 新增的估算值"
// 上一次 API 已经告诉我们历史精确是多少 token,
// 只需估算"这之后又加了多少",两者相加,又快又准。
const contextTokens = lastResponse.usage.input_tokens + estimateTokens(newMessagesSince)
```

<Callout type="info">
  这个“精确锚点 + 增量估算”的模式很关键：纯估算会累积误差(尤其长对话)，纯精确又太慢。用 API 每轮免费回报的 `input_tokens` 当锚，把估算只用在“最近这一小段增量”上，就同时拿到了速度和准头。**善用 API 白送的 `usage`，是上下文管理的基本功。**
</Callout>

## 三、实现细节：分级水位线

量出了当前用量，接下来要根据它触发不同动作。成熟做法是设几道**水位线**，像水库泄洪一样分级响应：

| 水位 | 占窗口比例(示意) | 动作 |
| --- | --- | --- |
| 正常 | < 60% | 什么都不做 |
| **警告** | ~60-80% | 提示用户“上下文有点满了”，可考虑手动清理 |
| **自动压缩线** | ~80-90% | 触发[自动压缩](https://daiw.org/manual/llm-to-agent)，把老历史摘要掉 |
| **硬阻断线** | ~92%+ | 拒绝再塞新内容，**留出余量**让用户还能手动 `/compact` |

```ts title="根据水位决定动作"
function checkContextWater(tokens: number, windowSize: number) {
  const pct = tokens / windowSize
  if (pct >= 0.92) return { level: 'blocking' }   // 硬阻断:必须先压缩
  if (pct >= 0.80) return { level: 'autocompact' } // 触发自动压缩
  if (pct >= 0.60) return { level: 'warn' }        // 提醒用户
  return { level: 'ok' }
}
```

<Callout type="warn">
  **注意那条“硬阻断线”为什么不设在 100%，而是留一大截余量。** 因为压缩本身也要调用模型(把历史喂给它、让它写摘要)，这个操作**也需要上下文空间**。如果你等到窗口 100% 满了才想起压缩，那连“执行压缩”所需的余量都没有了——彻底死锁。所以必须**提前**在还有余量时就动手，给“抢救操作”本身预留空间。这是上下文管理里一个极其现实、又极其容易被忽略的死锁陷阱。
</Callout>

## 四、别忘了输出侧也在抢空间

一个容易被忽视的点：窗口是**输入 + 输出共享**的。你设的 `max_tokens`(允许模型这次最多生成多少)也要从窗口里扣。而且：

- `max_tokens` 设太小 → 模型话没说完就被硬截断(`stop_reason: 'max_tokens'`)，得走[恢复流程](https://daiw.org/manual/llm-to-agent)。
- 设太大 → 挤占了本可留给历史的空间。

所以 `max_tokens` 的取值，本身也是这场预算战的一部分——它是你主动让出多少窗口给“这一轮的输出”的决定。

## 五、小结：一切的起点

这一篇没有炫技，但它是第五阶段的地基：**你必须先能量出上下文用量、并据此分级响应，后面所有的上下文技术才有触发的依据。** 记住三件事：

1. 每轮重发全部历史，工具结果是膨胀的主凶。
2. 用“API 回报的 `input_tokens` 作锚 + 增量估算”来又快又准地量。
3. 设分级水位线，并**给抢救操作预留余量**——别等满了才动手。

水位线一响，该动手“减负”了。最主要的减负手段，就是[下一篇](https://daiw.org/manual/llm-to-agent/auto-compaction)的**自动压缩**：当历史太长，怎么把它安全地“折叠”成一段摘要，既腾出空间，又不丢掉模型继续工作所必需的关键信息。

不过在压缩之前，我们得先搞清楚一件更基础的事：**每一轮喂给模型的上下文，到底是由哪些部分拼起来的？** 下一篇先讲[系统提示与上下文注入](https://daiw.org/manual/llm-to-agent/context-injection)，把“上下文”这个筐里装了什么看清楚，再谈怎么给它减负。

---

**本篇对应代码**：配套仓库在循环里接入 usage 追踪与上下文水位提示(`src/agent.ts`)。`git checkout article-10`。
