10 · Token 预算:与有限上下文窗口的持久战
每轮重发全部历史,而工具结果(一次文件读、一条命令输出)动辄上万 token,上下文窗口迟早撑爆。讲清为什么要估算 token、如何用 API 回报的 usage 作为地面真值、以及“警告—自动压缩—硬阻断”的分级水位线。这是整个上下文工程的起点。
10 · Token 预算:与有限上下文窗口的持久战
欢迎来到第五阶段。前面四个阶段,我们一直在“往 Agent 上加能力”;从这一阶段起,我们要处理一个一直在回避、却决定 Agent 生死的约束——上下文窗口是有限的。这是整个 Agent 工程里最难、最见功力的部分。
一、背景:一场必输的军备竞赛
回忆01 篇那个残酷事实:模型无状态,所以每一轮你都要把到目前为止的全部历史重发一次。现在把它和 Agent 的工作方式叠加起来看:
- 一轮对话里,历史在疯长:用户消息、助手消息、
tool_use、还有——最凶的——工具结果。 - 一次
read_file读进来一个 2000 行的文件,可能就是上万 token。一条npm test的输出、一次git diff,随随便便几千 token。 - 这些全都留在历史里,每一轮都要重发。
于是上下文像滚雪球一样膨胀。而窗口有硬顶(比如 20 万 token)。这是一场你必输的军备竞赛——只要对话够长,迟早撞顶。撞顶的后果有两种,都很糟:
- 硬失败:请求超过窗口,API 直接拒绝(常见的
prompt too long/ HTTP 413),Agent 卡死。 - 软退化:就算没撞顶,上下文越长,模型越容易“迷失在中间”——关键信息被淹没在几万 token 的历史里,它开始遗忘、跑偏。
所以上下文不是“越多越好”,而是一种需要精打细算的稀缺资源。 第五阶段所有的技术——压缩、缓存、瘦身、注入——本质都在回答同一个问题:在有限的窗口里,如何始终装着“模型此刻最需要的那些信息”,而把其余的挪走。 这就是“上下文工程”(context engineering)。而一切的前提,是先能量出当前用了多少。
二、解决方案:先学会“量”
管理上下文的第一步,是知道它现在有多大。这里有个务实的现实:精确计算 token 需要跑分词器(tokenizer),开销不小,而 Agent 每一轮都要算好几次。 所以工程上通常是“估算 + 校准”两条腿走路:
估算:便宜的近似
一个粗略但够用的估算:字符数 / 4 ≈ token 数(英文经验值;中文、代码另有系数)。它不精确,但极快,足够用来做“要不要开始警惕了”的判断。
function estimateTokens(messages: Message[]): number {
const chars = JSON.stringify(messages).length
return Math.ceil(chars / 4) // 快而糙,用于水位判断
}校准:用 API 回报的 usage 作地面真值
还记得01 篇响应里那个 usage 吗?每次 API 响应都会回报这次请求的精确 input_tokens。这是权威数字。聪明的做法是:拿上一次响应的 input_tokens 作为“地面真值”,再叠加此后新增消息的估算量:
// 上一次 API 已经告诉我们历史精确是多少 token,
// 只需估算"这之后又加了多少",两者相加,又快又准。
const contextTokens = lastResponse.usage.input_tokens + estimateTokens(newMessagesSince)这个“精确锚点 + 增量估算”的模式很关键:纯估算会累积误差(尤其长对话),纯精确又太慢。用 API 每轮免费回报的 input_tokens 当锚,把估算只用在“最近这一小段增量”上,就同时拿到了速度和准头。善用 API 白送的 usage,是上下文管理的基本功。
三、实现细节:分级水位线
量出了当前用量,接下来要根据它触发不同动作。成熟做法是设几道水位线,像水库泄洪一样分级响应:
| 水位 | 占窗口比例(示意) | 动作 |
|---|---|---|
| 正常 | < 60% | 什么都不做 |
| 警告 | ~60-80% | 提示用户“上下文有点满了”,可考虑手动清理 |
| 自动压缩线 | ~80-90% | 触发自动压缩,把老历史摘要掉 |
| 硬阻断线 | ~92%+ | 拒绝再塞新内容,留出余量让用户还能手动 /compact |
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' }
}注意那条“硬阻断线”为什么不设在 100%,而是留一大截余量。 因为压缩本身也要调用模型(把历史喂给它、让它写摘要),这个操作也需要上下文空间。如果你等到窗口 100% 满了才想起压缩,那连“执行压缩”所需的余量都没有了——彻底死锁。所以必须提前在还有余量时就动手,给“抢救操作”本身预留空间。这是上下文管理里一个极其现实、又极其容易被忽略的死锁陷阱。
四、别忘了输出侧也在抢空间
一个容易被忽视的点:窗口是输入 + 输出共享的。你设的 max_tokens(允许模型这次最多生成多少)也要从窗口里扣。而且:
max_tokens设太小 → 模型话没说完就被硬截断(stop_reason: 'max_tokens'),得走恢复流程。- 设太大 → 挤占了本可留给历史的空间。
所以 max_tokens 的取值,本身也是这场预算战的一部分——它是你主动让出多少窗口给“这一轮的输出”的决定。
五、小结:一切的起点
这一篇没有炫技,但它是第五阶段的地基:你必须先能量出上下文用量、并据此分级响应,后面所有的上下文技术才有触发的依据。 记住三件事:
- 每轮重发全部历史,工具结果是膨胀的主凶。
- 用“API 回报的
input_tokens作锚 + 增量估算”来又快又准地量。 - 设分级水位线,并给抢救操作预留余量——别等满了才动手。
水位线一响,该动手“减负”了。最主要的减负手段,就是下一篇的自动压缩:当历史太长,怎么把它安全地“折叠”成一段摘要,既腾出空间,又不丢掉模型继续工作所必需的关键信息。
不过在压缩之前,我们得先搞清楚一件更基础的事:每一轮喂给模型的上下文,到底是由哪些部分拼起来的? 下一篇先讲系统提示与上下文注入,把“上下文”这个筐里装了什么看清楚,再谈怎么给它减负。
本篇对应代码:配套仓库在循环里接入 usage 追踪与上下文水位提示(src/agent.ts)。git checkout article-10。