为一切设定预算——长对话里的上下文管理:token估算、上下文卫生与熔断
这个月公司成立了 AI 小组,开始正式做我们自己的 Agent 底座,第一个要支撑的是陪伴类的产品。
陪伴产品和 coding agent 很不一样。coding agent 做完一个任务,会话也就结束了;陪伴产品的一次会话能聊上百轮,同一个用户会持续聊好几个月。人格设定、长期记忆、知识库检索、最近的对话,全都要往上下文里塞。
原型阶段聊十几轮就结束,这些问题都没暴露出来。现在一下子全冒了出来:上下文涨得飞快,模型对前面说过的事越来越"心不在焉",偶尔还会因为超长直接报错。
所以这次系统地梳理了一遍上下文管理。主要参照的还是 Claude Code 的思路。
一、上下文是稀缺资源
一个 200K 的窗口听起来很大,真用起来比想象中快得多。系统提示词就要占掉一两万 token,一次工具调用少则几千、多则几万,读几轮文件就用掉一半了。
但比容量更要紧的,是信息密度。窗口里塞满了过期的工具结果、早就聊完的话题,模型的注意力就会被稀释,该记住的反而记不住。这就是我们看到的"心不在焉"。
所以 Claude Code 对上下文的态度是:像管理内存一样管理它。
这个比喻对写 Java 的人来说再熟悉不过了。JVM 的堆要设上限,要有回收策略,要有 OOM 的保护。上下文也一样:每个来源分配多少要有上限,满了要有回收(压缩)的策略,回收失败也不能把整个程序拖死。
二、六条原则
Claude Code 的上下文管理可以归纳成六条原则。
1. 为一切设定预算
每一个往上下文里放东西的来源,都要有一个单项上限,然后再加一个总量上限:
| 来源 | 预算 |
|---|---|
| 单个工具结果 | 50K 字符,超出写盘、返回预览 |
| 单条消息里所有工具结果 | 200K 字符,防止并行调用一起撑爆 |
| 读文件 | 默认 2000 行,需要再往后翻 |
| 技能列表 | 上下文的 1% |
| 压缩后恢复的文件 | 最多 5 个,每个 5K |
注意第二行。单个工具结果有上限还不够,因为模型可能一次并行发出十个调用,每个都没超,加起来就超了。单项上限防单点失控,总量上限防集体失控。
2. 上下文卫生
无关的、成本高的信息,在注入之前就剔掉,不要等它进来了再靠压缩去清理。
Claude Code 里一个典型的例子是:派生只读的搜索类子 agent 时,会省掉 CLAUDE.md 和 git status。提交规范、PR 规则这些对搜代码没用,过期的 git status 还可能有几十 KB。
给子 agent 的上下文,按"它会用到什么"来裁剪,而不是按"父级有什么"来全量继承。
3. 保留重要内容
压缩之后,Claude Code 会从磁盘重新读入最近编辑过的几个文件,而不是指望摘要里还保留着文件的内容。
但重新读多少也有讲究:最多 5 个,每个 5K。恢复太多等于没压缩,恢复太少等于压缩过头。
4. 告知,而不是隐藏
截断了,就要告诉模型。
Claude Code 截断工具结果时,返回的是这样一段话:结果太大了(多少 KB),完整内容存在了哪个路径,下面是前 2KB 的预览。模型知道自己看到的不全,也知道去哪找全的。
如果静默截断,模型会以为自己看到的就是全部,然后去编造它记不清的那部分。
5. 熔断失控的循环
任何会自动重试的流程,都要有熔断器。
Claude Code 在这上面交过一次学费:自动压缩失败以后会在下一轮再试,有的会话连续失败了几千次,算下来全球每天浪费了大约 25 万次 API 调用。后来加了一条规则,连续失败 3 次就停掉这个会话的自动压缩。
失败的原因往往是系统性的,重试一百次也改变不了结果。正确的做法是设一个合理的上限,超过之后降级到一个安全但功能受限的状态,而不是崩溃,也不是无限循环。
6. 保守估算
客户端算 token 永远只是估算,只有服务端的 tokenizer 给出的数才是准的。
既然是估算,就要往安全的方向偏。高估最坏的结果是早一点压缩,低估最坏的结果是请求直接报错。两害相权取其轻。
三、token 到底怎么算
保守估算这条展开说一下,因为我们在这里踩了坑。
Claude Code 的做法是"精确基线 + 增量估算":
- 找到最近一次模型响应,用 API 返回的 usage 作为精确的基线;
- 在那之后新增的消息(工具结果、新的用户输入)还没有真实的数,就用本地估算补上。
本地估算也不是一个系数打天下,而是按内容类型来:
- 普通文本大约按 4 个字符一个 token;
- JSON 按 2 个字符一个 token,因为 JSON 里大量的引号、括号、冒号都是单独的 token;
- 图片和文档直接按固定的 2000 token 算。如果按 base64 的长度去估,一个 1MB 的 PDF 会被估成几十万 token。
问题出在第一条。4 个字符一个 token,是对英文的经验值。中文完全不是这样,一个汉字往往就接近一个 token。我们一开始照搬了这个系数,结果中文对话的估算值严重偏低,压缩触发得太晚,偶尔就会直接超长报错。
后来改成了这样:
def estimate_tokens(text: str) -> int:
"""保守估算,宁可高估"""
cjk = sum(1 for ch in text if "一" <= ch <= "鿿")
others = len(text) - cjk
# 汉字按 1 个字 1 个 token,其余按 4 个字符 1 个 token
return cjk + (others + 3) // 4
def context_tokens(history: list[Message]) -> int:
# 1. 找到最近一条带真实 usage 的模型响应作为基线
for i in range(len(history) - 1, -1, -1):
if history[i].usage is not None:
base = history[i].usage.total_tokens
# 2. 之后新增的消息用估算补上
return base + sum(estimate_tokens(m.text) for m in history[i + 1 :])
return sum(estimate_tokens(m.text) for m in history)
如果对精度要求更高,也可以直接用模型对应的 tokenizer 去算。我们的模型是经 LiteLLM 统一接入的,它自带一个 token_counter,按模型选择对应的 tokenizer。不过要注意,这一步本身也有开销,放在每轮的热路径上要掂量一下。
四、陪伴场景下的落地
照着六条原则,底座里的上下文管理目前是这么做的。
1. 按来源分预算
先把往上下文里放东西的来源列出来,每个给一个上限:
# 示意:各来源的预算,具体数值随模型窗口调整
CONTEXT_BUDGET = {
"persona": 4_000, # 人格与规则,稳定,放在最前面
"memory": 2_000, # 长期记忆,只注入和当前话题相关的几条
"retrieval": 3_000, # 知识库检索结果,超出就截断并告知
"summary": 2_000, # 更早对话的滚动摘要
"recent": 12_000, # 最近的原始对话
}
可以看到,真正留给"最近对话"的空间并不多。这也逼着我们去想清楚,每一类内容到底需要多少。
2. 跨会话的滚动压缩
最近的对话超过预算时,就把最早的那一段压成摘要,和之前的摘要合并。会话结束时也做一次,下次用户回来,带上的是摘要而不是几百轮的原始记录。
摘要的模板参照了 Claude Code 的压缩模板。它有一条要求我觉得对陪伴场景尤其重要:用户说过的所有话都要保留,工具结果可以丢,用户的原话不能丢。coding agent 里,这是为了不丢掉用户提过的需求;陪伴场景里,用户说过的话本身就是最重要的内容。
3. 检索结果截断要告知
知识库检索回来的内容超过预算,就截断,并且在末尾加一句"以上为部分结果,共命中 N 条"。不让模型以为它看到的就是全部。
4. 压缩的熔断
摘要调用连续失败 3 次,就不再尝试,降级为只保留最近的若干轮原始对话。体验会差一点,但不会卡死,也不会无限地烧钱。
总结
- 上下文是稀缺资源,要像管理内存一样管理它,信息密度比容量更重要;
- 每个来源都要有单项上限,再加一个总量上限,单项防单点失控,总量防集体失控;
- 无关的信息在注入之前就剔掉,截断了要告诉模型,否则它会编造自己记不清的内容;
- 所有自动重试的流程都要有熔断器,超过上限就降级,不要崩溃,也不要无限循环;
- token 估算宁可高估,用精确基线加增量估算,并且按内容类型选系数,中文千万别照搬英文的经验值。