为一切设定预算——长对话里的上下文管理: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 的做法是"精确基线 + 增量估算":

  1. 找到最近一次模型响应,用 API 返回的 usage 作为精确的基线;
  2. 在那之后新增的消息(工具结果、新的用户输入)还没有真实的数,就用本地估算补上。

本地估算也不是一个系数打天下,而是按内容类型来:

  • 普通文本大约按 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 次,就不再尝试,降级为只保留最近的若干轮原始对话。体验会差一点,但不会卡死,也不会无限地烧钱。

总结

  1. 上下文是稀缺资源,要像管理内存一样管理它,信息密度比容量更重要;
  2. 每个来源都要有单项上限,再加一个总量上限,单项防单点失控,总量防集体失控;
  3. 无关的信息在注入之前就剔掉,截断了要告诉模型,否则它会编造自己记不清的内容;
  4. 所有自动重试的流程都要有熔断器,超过上限就降级,不要崩溃,也不要无限循环;
  5. token 估算宁可高估,用精确基线加增量估算,并且按内容类型选系数,中文千万别照搬英文的经验值。

相关资料