上下文压缩算法与策略

我们的 My Claude Code 已经实现了很多实用的功能,但有个问题一直没解决:模型的上下文窗口是有限的。

前面 上下文是什么?token 怎么计费? 说过,Agent 每一轮都会把完整的对话历史发给模型。对话越长,请求越贵、响应越慢,等上下文窗口真被写满,API 会直接报错,这个会话就没法继续了。

Coding Agent 尤其容易撞这个天花板,读几个源码文件、跑几轮工具调用,几万 token 就进去了。

Claude Code 提供一个手动的 /compact 命令,把当前对话压缩成一份摘要,同时也有一套自动机制,上下文快满时会自动压缩,避免撑爆上下文上限。

压缩的核心思路在 如何实现上下文压缩 里已经拆解过了:用一次 LLM 调用把整段对话总结成结构化摘要,再用摘要替换掉原对话。这一篇就把这套机制真正装进我们的 My Claude Code。

先跑起来看看

完整代码如下,下载到本地可以直接运行:

依赖和前面的项目一样,配好 API Key 就能启动:

export API_KEY="你的 DeepSeek API Key"
python main.py

假设你已经和 Agent 干了一阵活,让它把一个 Flask 项目的路由拆分到独立模块,中间读写了好几个文件。先用 /status 看看上下文用掉多少:

My Claude Code
/status
模型: deepseek-v4-flash
权限模式: default
历史消息条数: 58
当前上下文占用(估算):64,120 / 101,072 tokens(63%)
累计输入 tokens:312450
累计输出 tokens:8933

已经用掉六成多了,可以输入 /compact 手动压缩:

My Claude Code
/compact
正在压缩上下文,可能需要一会儿...
✻ 压缩完成:压缩前上下文 64,120 tokens,已重建为一份摘要 + 3 个最近读过的文件
完整对话记录已存档:~/.my-claude-code/projects/-Users-tom-web-demo/compact-history/9b3fc1e2-...-20260721-153042.jsonl
1. 主要请求和意图:把 Flask 应用 app.py 里的路由拆分到独立的 routes.py 模块...
(摘要其余部分省略)

此时对话历史已经被替换成一份历史会话的摘要,压缩完再看 /status,上下文占用就只剩几千 tokens 了,有大量空余的上下文预算让 Agent 继续完成后面的工作。

就算你不手动使用 /compact 命令,但对话攒得够长,临近上下文上限时,你也会看到类似这样的输出:

My Claude Code
再帮我把配置项抽到 config.py
上下文接近上限(101,893 / 101,072 tokens),自动压缩中...
✻ 压缩完成:压缩前上下文 101,893 tokens,已重建为一份摘要 + 5 个最近读过的文件
完整对话记录已存档:~/.my-claude-code/projects/-Users-tom-web-demo/compact-history/9b3fc1e2-...-20260721-160217.jsonl
(摘要输出省略)
● assistant
好,我来把配置项抽到 config.py。
tool_call
edit_file(config.py)

压缩完之后,Agent 会继续刚才未完成的工作,好像什么事情都没发生一样。

为了实现压缩功能,这次新增和修改了这些代码:

coding-agent-compact/
├── compact.py            # ← 新增:压缩全流程(压缩 prompt、水位线、重建历史)
├── session.py            # ← 改动:新增 archive_session,重写会话文件前存档完整对话
├── main.py               # ← 改动:命令参数分发;发请求前检查上下文水位
├── agent/
│   └── file_state.py     # ← 改动:新增 paths(),压缩后恢复文件时要用
└── ui/
    └── commands.py       # ← 改动:新增 /compact 命令,/status 显示上下文占用

压缩的流程

自动压缩和手动触发 /compact 命令底层的压缩流程是一样的,只是触发时机不同而已,所以我们先以 /compact 命令为例讲解。

/compact 敲下去之后,一共发生五件事:

  1. 带着全部对话历史,让一个不带任何工具的 summarizer agent 跑压缩 prompt,产出结构化摘要
  2. 把压缩前的会话文件完整存档一份,Agent 需要时可以浏览完整的历史对话
  3. 把摘要包装成一条普通的用户消息,作为新历史的第一条
  4. 把最近读过的文件重新「读」进来,跟在摘要消息后面
  5. 用这份新历史整体替换 state.history,并重写会话文件

为什么需要这几步?后面会逐个讲解。

这五步的代码实现在 compact.py里:

async def run_compact(state, custom_instructions: str = ""):
    prompt = COMPACT_PROMPT
    extra = custom_instructions.strip()
    if extra:
        prompt += "\n\n补充要求:\n" + extra

    # 一次 LLM 调用:带着全部历史跑压缩 prompt,产出摘要
    result = await summarizer.run(prompt, message_history=state.history)
    summary = extract_summary(result.output)

    # 重写会话文件之前先存档完整对话记录
    transcript_path = session.archive_session(state.session_id)

    # 刷新文件读写的 readFileState
    old_state = state.read_file_state
    state.read_file_state = ReadFileState()
    # 重建历史:一条摘要消息 + 恢复最近读过的文件
    restored = restore_file_messages(old_state, state.read_file_state)
    state.history = [build_summary_message(summary, transcript_path)] + restored
    # 重写会话文件,仅保留压缩后的内容
    session.rewrite_messages(state.session_id, state.history)

    # 旧检查点的 history_index 指向已被压缩掉的消息,整体丢弃
    fh = state.file_history
    if fh is not None and fh.checkpoints:
        fh.drop_from(fh.checkpoints[0])

命令入口很简单, 只是把 run_compact 包了层错误提示。稍微特别的是 /compact 支持带补充指令,比如 /compact 重点保留文件改动,从 run_compact 开头能看到,这段指令会作为用户的「补充要求」拼到压缩 prompt 尾部。

为此 Command 结构加了个 handle_command 分发时把命令名后面的整段文本传给它:

cmd_name, _, args = user_input[1:].partition(" ")
command = COMMANDS.get(cmd_name)
# ...
if command.takes_args:
    result = command.handler(state, args.strip())
else:
    result = command.handler(state)

流程有了框架,下面把其中两个关键环节展开:摘要怎么来的,以及为什么摘要之外还要恢复文件。

摘要是怎么来的

压缩质量完全取决于那一次 LLM 调用,这里有两个讲究:给谁跑,用什么 prompt。

跑摘要的 是单独创建的 Agent,用同一个模型,但一个工具都不注册:

# 压缩专用 agent:不注册任何工具,模型想调也调不了
summarizer = Agent(model)

原理篇 解释过原因:压缩时发给模型的对话历史里全是工具调用记录,模型看着看着就容易「顺手再调一个」。prompt 头尾再各强调一遍「只输出纯文本」是软约束,不注册工具是硬约束,算是双保险。

的主体是任务说明加六段摘要结构:

COMPACT_PROMPT = (
    "重要:只输出纯文本,不要调用任何工具。\n\n"
    # ...省略几条禁止调用工具的具体规则...
    "你的任务是为到目前为止的对话写一份详细摘要,重点关注用户的明确请求和你已经做过的事情。摘要要保留足够的技术细节,让后续工作能基于它无缝继续。\n"
    "这条压缩指令本身不属于要总结的对话——任何段落都不要提到或引用它,不要把它算作用户消息,也不要把写摘要说成当前工作。\n\n"
    "写正式摘要之前,先在 <analysis> 标签里打草稿整理思路:按顺序过一遍对话,核对用户的请求、你的处理方式、关键决策、文件名和代码片段、报错和修复过程。\n\n"
    "然后在 <summary> 标签里输出正式摘要,包含以下几段:\n\n"
    "1. 主要请求和意图:详细记录用户的所有明确请求和意图\n"
    "2. 关键技术概念:列出涉及的重要技术概念、框架和方案\n"
    "3. 文件和代码:列出查看过、修改过、新建过的具体文件和代码段,附上关键代码片段\n"
    "4. 错误与修复:列出遇到过的报错和修复方法,特别注意用户要求你换种做法的反馈\n"
    "5. 全部用户消息:一字不改地列出所有用户消息(工具结果和这条压缩指令都不算),不要转述——它们是理解用户反馈和意图变化的关键\n"
    "6. 当前工作与下一步:精确描述写摘要前正在做的事情和紧接着的下一步,逐字引用最近几条对话原文,确保接续时不跑偏\n\n"
    "再次提醒:不要调用任何工具。只输出纯文本——先 <analysis> 块,后 <summary> 块。"
)

原理篇讲过的几个设计点在这里都能对上:结构化的分段模板、先在 <analysis> 里打草稿再输出正式摘要、第 5 和第 6 段要求逐字引用而不是转述。

还有一条容易被忽略但很关键的要求:让模型把这条压缩指令本身排除在摘要之外。压缩指令也是作为一条用户消息发给模型的,不做排除的话,模型可能会把 /compact 命令当成「最后一条用户消息」抄进第 5 段,还会在第 6 段把「当前工作」描述成「正在写摘要」。

模型的回复是 <analysis> 草稿加 <summary> 正文,正式摘要要用 抠出来:

def extract_summary(text: str) -> str:
    text = re.sub(r"<analysis>.*?</analysis>", "", text, flags=re.DOTALL)
    match = re.search(r"<summary>(.*?)</summary>", text, re.DOTALL)
    text = match.group(1) if match else text
    # 正文里残留的标签字样一并清掉
    return re.sub(r"</?(analysis|summary)>", "", text).strip()

注意要先把 <analysis> 草稿整块剥掉,再匹配 <summary>,因为草稿里可能字面提到这个标签,直接匹配容易截错位置。

自动注入最近读过的文件

假设压缩只留一份摘要,Agent 接着干活时的第一件事,多半是把刚才那几个文件原原本本重新 read_file 一遍。

原因有两个:

一是摘要里的代码片段是不完整的,改文件需要精确的原文。

二是 可靠的文件编辑工具 里给 edit_file 加过先读后写的校验,没读过的文件不允许编辑。而压缩把 readFileState 也重置了,所以 Agent 必须重新读取相关文件,才能成功编辑文件。

最近读过的文件,大概率和当前手头的工作直接相关,压缩完之后模型多半还得把它们重新读一遍,白白多花几轮请求来回,也多消耗 token。

所以完成压缩摘要之后,干脆由自动把最近读过的文件直接注入上下文,模型就不用再调工具去读了。

哪些文件算「最近读过」?readFileState 本来就按先后顺序登记着本会话读写过的文件, 从最新的往回挑,最多 5 个,总大小不超过 30KB。这两个上限是防止用力过猛,别刚把上下文腾出来,又被一堆大文件填回去:

def restore_file_messages(old_state, new_state) -> list:
    picked, used = [], 0
    for path in reversed(old_state.paths()):
        try:
            size = os.path.getsize(path)
        except OSError:
            continue
        if len(picked) >= RESTORE_MAX_FILES or used + size > RESTORE_MAX_CHARS:
            break
        picked.append(path)
        used += size
    # 恢复顺序保持原来的读取顺序
    picked.reverse()
    # 和 @ 引用同一套机制:伪装成 read_file 调用塞进历史,顺带登记进新 readFileState
    return build_mention_messages(picked, new_state)

注入方式完全复用 用 @ 引用文件build_mention_messages:把每个文件伪装成一次 read_file 调用和返回塞进历史,模型看到的效果就像自己刚读过这些文件,同时它们也被登记进新的 readFileState,先读后写的校验会直接放行。

什么时候自动压缩

手动 /compact 通了,自动压缩只剩两个问题:怎么知道当前上下文用了多少,多满算「快满」。

估算占用不需要自己去数 token,因为每条模型响应都带着 usage 字段,其中 input_tokens 是那一轮发给模型的全部内容的大小:system prompt、完整对话历史、工具定义都算在内;output_tokens 是模型生成的部分。

input_tokensoutput_tokens 相加,就是模型视角里当前对话的真实体积。

为啥要相加呢?因为本轮模型的输出(output_tokens)在下一轮也会作为历史消息的一部分计入下一轮的输入,和用户的新消息一同发给模型。所以本轮 input_tokens 加上 output_tokens 才是下一轮请求中历史消息占用的 token 数量。

绕过来这个弯就容易写代码了,只需要找到最近的一条带有 usage 字段的请求,即可计算当前上下文 token 有多少。看

def context_tokens(history) -> int:
    # 从后往前找
    # 寻找最近的一条带有 usage 的请求
    for msg in reversed(history):
        if msg.kind == "response" and msg.usage.input_tokens:
            # input + output 是总的上下文 token 数
            return msg.usage.input_tokens + msg.usage.output_tokens
    return 0

这个估算有一点点滞后,它不包含用户即将发出的下一条输入。没关系,水位线本来就留了余量,不差这一点。

水位线的算法是从窗口上限往回退两段:

# 模型的上下文窗口上限(需要根据不同模型进行调整)
CONTEXT_WINDOW = 131_072

# 给 summarizer agent 调用预留的空间
# 我们之前写的 COMPACT_PROMPT,以及摘要输出
# 都要占用上下文,所以必须额外预留一些上下文
COMPACT_OUTPUT_RESERVE = 20_000

# 再留一些安全余量
AUTO_COMPACT_BUFFER = 10_000

def compact_threshold() -> int:
    return CONTEXT_WINDOW - COMPACT_OUTPUT_RESERVE - AUTO_COMPACT_BUFFER

131072 是 DeepSeek 模型的上下文窗口。COMPACT_OUTPUT_RESERVE 是留给压缩调用自己的:触发压缩时上下文已经很满,而摘要还要占用输出空间,不预留的话压缩调用自己就会因为撑爆上下文而失败。

AUTO_COMPACT_BUFFER 再退一段安全余量,最终触发线是 101072 tokens,/status 里显示的分母就是它。

检查时机放在每次发请求之前。main.pyon_submit 处理完命令后、创建检查点之前,插一行

async def on_submit(user_input):
    # 前面处理完 / 开头的命令...

    # 发请求前检查上下文水位,越过阈值就先自动压缩再继续
    await compact.auto_compact_if_needed(state)

    # 之后才是创建检查点、@ 引用注入、Agent 循环
    state.file_history.make_checkpoint(len(state.history), user_input)
    ...

放在创建检查点之前是有讲究的:压缩会把 history 换成全新的短列表,先压缩再记检查点,检查点里的 history_index 记的才是新历史的下标。

的逻辑就是检测自动压缩阈值:

async def auto_compact_if_needed(state):
    if state.compact_failures >= MAX_COMPACT_FAILURES:
        return
    used = context_tokens(state.history)
    threshold = compact_threshold()
    if used < threshold:
        return
    console.print(f"上下文接近上限({used:,} / {threshold:,} tokens),自动压缩中\n")
    try:
        await run_compact(state)
        state.compact_failures = 0
    except Exception:
        state.compact_failures += 1
        # 失败只提示一句,不阻断本轮对话
        console.warn(f"自动压缩失败\n")

压缩调用是可能失败的,网络抖一下、API 限流都有可能,后台压缩任务不能影响用户的正常输入,所以失败只提示一句就放行,这一轮对话照常进行。

但如果 API 一直出问题,我们的做法是用 compact_failures 计数,连续失败 3 次就停止重试,本会话不再尝试自动压缩。

因为失败的调用也可能照样计费,放任它在后台一轮轮静默重试,会积出一笔不小的调用费用。所以我们的策略是停止重试,大不了上下文最终撑爆,用户重开一个会话;而放任静默重试,会不断消耗真金白银。

手动 /compact 不受这个限制,用户主动要求的操作,失败了报个错就好。

压缩前的对话去哪了

摘要毕竟是有损压缩。六段结构里逐字保住的只有用户消息和最近几条对话,中间某一轮的报错原文、模型当时的完整分析,压缩之后就找不回来了。

用 Claude Code 时你可能就遇到过:压缩后问一句「你还记得刚才那个报错的具体内容吗」,模型只能含糊其辞。

我们的补救办法很直接:重写会话文件之前,先把它完整拷贝一份存档。session.py 里新增的 就干这一件事:

def archive_session(session_id: str) -> Path:
    archive_dir = project_dir() / "compact-history"
    archive_dir.mkdir(parents=True, exist_ok=True)
    # 历史对话记录文件名带有时间戳,防止覆盖
    path = archive_dir / f"{session_id}-{datetime.now():%Y%m%d-%H%M%S}.jsonl"
    shutil.copy2(session_file(session_id), path)
    return path

存档放进 compact-history/ 子目录,而不是和会话文件混在一起,是因为 持久化聊天记录 里的 /resume 会把项目目录下所有 .jsonl 文件都当作可恢复的会话列出来,挪进子目录,存档就不会混进 /resume 列表。

光存下来还不够,得让模型知道有这份存档。摘要的包装消息 里附上了存档路径:

SUMMARY_WRAPPER = (
    "本会话由一段因上下文写满而被压缩的对话延续而来,以下是之前对话的摘要:\n\n"
    "{summary}\n\n"
    "如果摘要里缺少你需要的细节(具体代码片段、报错原文等),"
    "可以用 read_file 读取压缩前的完整对话记录:{transcript}\n"
    "请基于这份摘要继续工作。不要向用户复述摘要内容,直接从对话中断的地方接着干。"
)

负责把摘要和存档路径填进这两个占位符,再包装成一条普通的用户消息,它就是压缩后新历史的第一条消息:

def build_summary_message(summary: str, transcript_path) -> ModelRequest:
    content = SUMMARY_WRAPPER.format(summary=summary, transcript=transcript_path)
    return ModelRequest(parts=[UserPromptPart(content=content)])

对模型来说,压缩后的对话开局看到的就是这条消息:先是摘要,然后被告知「细节不够就去读这个文件」。这样摘要就成了索引,完整对话记录是兜底,摘要够用时模型正常干活;细节不够时,它自己会拿 read_file 去翻旧的对话记录。

如果压缩后让模型复述压缩前的某条用户消息,它就会先调 read_file 读取存档文件,然后一字不差地找回原话。

一个会话可能被压缩不止一次,但因为存档文件名带着时间戳,压缩几次就存几份,不会互相覆盖。

多个存档之间还会自动连成一条链:从第二个压缩存档开始,每个存档的第一条消息包含了压缩的原始对话文档。如果模型想找更早的细节,顺着这条链一层层读回去,一路能追溯到会话最开头。

总结

到这里,My Claude Code 就有了完整的上下文压缩能力。

每次发请求前,用最近一条响应的 usage 估算上下文占用,越过水位线(上下文窗口上限去掉摘要预留和安全余量)就自动压缩,连续失败几次就停止重试。

压缩本身是一次不带工具的 LLM 调用,按六段结构产出摘要,作为全新会话的初始消息;手动 /compact 走的也是同一套流程,只是多了自定义指令。