Agent 实战(四):上下文管理,压缩历史为什么会让缓存失效

这是整个项目最难的一节。

前面三节都有”正确答案”:SSE 该怎么解析、工具循环该怎么写、权限该怎么分层。 这一节没有。压多了模型失忆,压少了等于白压。 你会第一次遇到纯粹的设计取舍题。


0. 本节目标与验收标准

新增/修改的文件

src\main\java\dev\learn\agent\
├── Main.java                          ✏️ --budget / --keep 参数,显示用量
├── agent\
│   ├── Agent.java                     ✏️ 每轮发请求前检查并压缩
│   └── AgentListener.java             ✏️ 加 3 个上下文相关的回调
└── context\                           ✨ 全部新增
    ├── TokenEstimator.java            估算 token
    ├── ContextBudget.java             什么时候压
    └── Compactor.java                 ★ 怎么压

验收标准

跑一次多轮对话,你会看到这样的输出:

[上下文] 估算 4827 / 4000 token(上次实测 5030,估算误差 -203)

[压缩] 正在把 6 条历史消息总结成一段……
[压缩] 6 条 → 1 条摘要;消息部分估算 3621 → 500 token(省 86%)

[压缩完成] 原始消息已被一段摘要替换
...
[轮次结束 END_TURN  in=1894 out=67 cache=1536]

✅ 用量超过阈值时自动压缩 ✅ 压缩后 in= 大幅下降 ✅ 模型知道有内容被摘要了,不会假装记得 ✅ 压缩后对话能正常继续,不报错


1. 问题:历史只增不减

先看 M3 结束时的一次真实运行数据。三轮对话,in= 是每轮请求的输入 token:

轮次in=变化
第 1 轮1547—
第 2 轮1604+57
第 3 轮1699+95
第 4 轮1751+52

看起来还好?因为对话还短。 让它跑 20 轮、每轮读一个大文件:

轮次in=
第 1 轮1,500
第 5 轮8,000
第 10 轮25,000
第 20 轮80,000

为什么涨得这么快? 因为每一轮都要把完整历史重新发一遍。

第 n 轮发给 API 的东西 = [系统提示] + [工具定义] + [第1轮全部消息] + [第2轮...] + ... + [第n轮]

第 n 轮的成本 ≈ 前 n 轮所有内容的总和。这是一个 O(n²) 的成本曲线。

而且更糟的是:大部分内容是无关的。

第 15 轮的时候,“第 3 轮读了 Calculator.java 的完整内容”这件事,99% 的概率已经没用了。 但你还得每次都把它发过去,每次都付钱。

类比:这是一个只进不出的房间

想象一个房间,你说的每句话都被记在墙上。房间只有 100 平方米。

前 20 分钟还好。一小时后,墙上写满了字,你连自己刚才说了什么都找不到了—— 不是因为忘了,是因为墙上太乱,重要的信息被淹没在无关细节里。

上下文管理就是定期”擦掉墙上的流水账,只留下会议纪要”。


2. 概念:上下文是预算,不是容器

新手容易把上下文窗口想成一个容器:“1M token,装得下就没事。”

这是个危险的思维。正确的模型是预算:

上下文窗口是”最多能装多少”,而预算关心的是”该装多少”。

这两个数是完全不同的。deepseek-flash 的窗口是 1M token, 但我们的默认预算是 24,000 —— 只有上限的 2.4%。

为什么要设得这么保守? 三个理由,一个比一个重要:

  1. 钱。 每一轮都要重发全部历史。24K 的对话跑 20 轮 ≈ 50 万 token 输入; 240K 的对话跑 20 轮 ≈ 500 万。差 10 倍的钱。
  2. 速度。 输入越长,首字延迟越高。用户体感上这是”卡不卡”的区别。
  3. 质量。 ⚠️ 这条最容易被忽视:模型在长上下文里会变笨。 无关内容会稀释真正重要的信息。塞满 1M 上下文的表现, 通常不如只放 30K 的相关内容。

💡 “上下文不是免费的,也不是越多越好”——这是这一节最该带走的一句话。


3. 概念:压缩 = 逐字记录 → 会议纪要

类比:开了一场两小时的会,你要写纪要。

❌ 逐字记录(不压缩)✅ 会议纪要(压缩)
“张三说……”(200 字)决定采用方案 B
“李四说……”(300 字)理由:A 的性能测试没达标
“张三又说……”(150 字)待办:张三下周三前出原型
……(共 5 万字)……(共 300 字)

关键在”保留什么、丢弃什么”:

必须保留可以丢弃
目标是什么寒暄铺垫
做了哪些决定、为什么试过但放弃的路径的细节
查明的事实(文件名、行号、结论)冗长的工具原始输出
当前进行到哪一步中间的推理过程
还没做完的事重复的表达

压缩做的就是这件事。 而 M4 的全部难度,就在于设计这份”保留清单”。


4. 三个必须想清楚的问题

在写代码之前,先把设计问题摊开。这三个问题没有正确答案。

4.1 压什么?(保留策略)

我们的方案:保留头尾,压缩中间。

[system 系统提示]        ← 永远保留
[很早的 user/assistant]  ← 压缩掉
[很早的 user/assistant]  ← 压缩掉
[一条摘要]               ← 替换品
[最近的 user/assistant]  ← 保留
[最近的 user/assistant]  ← 保留

为什么保留最近几条? 因为**“正在做的事”绝不能丢**。 模型正在改一个文件,你把它上一步读到的文件内容压没了,它就抓瞎了。

一句话:压缩丢掉的是”已经做完的事”,绝不能丢掉”正在做的事”。

4.2 什么时候压?(触发时机)

太早压:白白花一次摘要调用,还可能丢掉还用得上的内容。 太晚压:可能撞上上下文上限,请求直接失败。

我们的方案:用量达到预算的 60% 时触发。

为什么是 60% 而不是 95%? 因为压缩本身也要发一次请求, 而这次请求会读取全部历史——如果等到 95% 才压,这次摘要调用自己就快撑爆了。

留出的那 40% 是给压缩操作本身用的。

4.3 压多狠?(压缩比)

这是最难的。我们的取舍是:

选择后果
压到 10%省得多,但模型几乎肯定失忆
压到 50%省得少,很快又要压一次
看情况✅ 唯一诚实的答案

我们在提示词里明确列了”保留清单”,让模型自己判断压缩到什么程度。

⚠️ 这一节的代码写完之后,你不会得到一个”正确”的方案。 你会得到一个可调的方案,然后根据你的任务类型去调它。 改代码的任务要保住文件细节,聊需求的任务可以压得狠。 “没有标准答案”就是这一节要教给你的东西。


5. 第一步:TokenEstimator.java

package dev.learn.agent.context;

import dev.learn.agent.provider.Message;
import dev.learn.agent.provider.ToolCall;
import dev.learn.agent.provider.ToolSpec;

import java.util.List;

/**
 * 粗略估算 token 数。
 *
 * <p><b>先说清楚:这东西不准。</b>真正的 token 数只有模型服务端知道,
 * 每次都从响应的 usage 里拿才是权威。但我们需要一个"在发请求<b>之前</b>"的判断依据 ——
 * 因为"该不该压缩"这个决定必须在请求发出前做出来。
 *
 * <p>所以这是一个<b>保守估计器</b>而不是精确计数器。它只需要满足一个条件:
 * <b>宁可估高,不要估低</b>。估高了顶多过早压缩(浪费一次摘要调用),
 * 估低了会撞上下文上限(请求直接失败)。
 *
 * <p>换算比例来自 DeepSeek 官方文档:
 * <pre>
 *   1 个中文字符 ≈ 0.6 token
 *   1 个英文字符 ≈ 0.3 token
 * </pre>
 */
public final class TokenEstimator {

    private static final double CJK_TOKENS_PER_CHAR = 0.6;
    private static final double OTHER_TOKENS_PER_CHAR = 0.3;

    /** CJK 字符的起始码点。取一个够用的近似边界。 */
    private static final int CJK_START = 0x2E80;

    /** 每条消息的结构开销(role 字段、分隔符等)。 */
    private static final int PER_MESSAGE_OVERHEAD = 8;

    private TokenEstimator() {
    }

    public static int estimate(String text) {
        if (text == null || text.isEmpty()) {
            return 0;
        }
        int cjk = 0;
        int other = 0;
        for (int i = 0; i < text.length(); i++) {
            if (text.charAt(i) >= CJK_START) {
                cjk++;
            } else {
                other++;
            }
        }
        return (int) Math.ceil(cjk * CJK_TOKENS_PER_CHAR + other * OTHER_TOKENS_PER_CHAR);
    }

    /**
     * 估算整个对话历史的 token 数。
     *
     * <p>⚠️ 注意把 reasoningContent 也算进去 —— 开启思考模式后它会被回传,
     * 同样是计费的输入。漏算它会导致估算严重偏低(思考内容常常比正文长好几倍)。
     */
    public static int estimate(List<Message> messages) {
        int total = 0;
        for (Message m : messages) {
            total += PER_MESSAGE_OVERHEAD;
            total += estimate(m.content());
            total += estimate(m.reasoningContent());
            if (m.toolCalls() != null) {
                for (ToolCall call : m.toolCalls()) {
                    total += estimate(call.name()) + estimate(call.arguments()) + 12;
                }
            }
        }
        return total;
    }

    /**
     * 估算工具定义的 token 数。
     *
     * <p>⚠️ 这一项经常被忘掉,但它是<b>每轮都要重新发一遍的固定开销</b>。
     * 我们这个项目的六个工具,光定义就一千多个 token ——
     * 也就是说每次请求还没说话,就先付了这份钱。
     */
    public static int estimateTools(List<ToolSpec> tools) {
        int total = 0;
        for (ToolSpec spec : tools) {
            total += estimate(spec.name()) + estimate(spec.description());
            total += estimate(spec.parametersSchema().toString());
            total += 30;
        }
        return total;
    }
}

关于 estimateTools 的那条注释,值得单独说:

我们的实测里,每一轮请求的 in= 从 1547 起步。而这轮对话的正文才几十个字。

那 1500 是从哪来的? 系统提示 + 六个工具的定义。

输入 = 工具定义(≈1000) + 系统提示(≈150) + 消息(≈400)

工具定义是固定开销,每轮都付,而且和对话长度无关。

💡 这解释了一件事:工具不是免费的。 你注册 50 个工具, 每个 200 token,那就是每轮 1 万 token 的固定成本——哪怕一个都没用到。 真实产品(包括 Claude Code)会用”工具动态加载”来对付这个问题: 平时只给模型看工具的名字,它需要时才把完整定义塞进去。


6. 第二步:ContextBudget.java

package dev.learn.agent.context;

/**
 * 上下文预算:什么时候该压缩。
 *
 * <p>这里只有三个数字,但每一个都要想清楚为什么是它。
 *
 * @param tokenBudget       我们允许对话历史涨到多大。<b>注意这不是模型的上下文上限</b>
 *                          (deepseek-flash 是 1M),而是我们自愿设的一个"舒适区上限"。
 * @param compactAtRatio    用量达到预算的百分之多少就触发压缩。留出的余量是给
 *                          压缩操作本身用的 —— 它也要读一遍历史。
 * @param keepRecentMessages 最近多少条消息不参与压缩。
 *                          <b>压缩丢掉的是"已经做完的事",绝不能丢掉"正在做的事"。</b>
 */
public record ContextBudget(int tokenBudget, double compactAtRatio, int keepRecentMessages) {

    /**
     * 默认预算。
     *
     * <p>⚠️ 这里刻意设成 24000 而不是接近 1M 上限。两个原因:
     * <ol>
     *   <li>1M 的上下文跑满一次要花不少钱,学习项目没必要;</li>
     *   <li><b>只有设小了,你才能在一次对话里看到压缩真的发生。</b>
     *       真实的压缩可能几十轮才触发一次,那样你什么都观察不到。</li>
     * </ol>
     * 真实产品里这个值会接近模型上限的 60%~80%。
     */
    public static final ContextBudget DEFAULT = new ContextBudget(24_000, 0.6, 8);

    public boolean shouldCompact(int estimatedTokens) {
        return estimatedTokens >= tokenBudget * compactAtRatio;
    }

    public int triggerThreshold() {
        return (int) (tokenBudget * compactAtRatio);
    }
}

💡 那个”把默认值设小”的决定,是专门为了让你能观察到现象。 这不是偷懒,是教学设计:一个几十轮才触发一次的机制,你没法验证它是对的。


7. 第三步:Compactor.java ★

package dev.learn.agent.context;

import dev.learn.agent.provider.ChatRequest;
import dev.learn.agent.provider.Message;
import dev.learn.agent.provider.Provider;
import dev.learn.agent.provider.StreamEvent;
import dev.learn.agent.provider.ToolCall;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

/**
 * 压缩器:把旧的历史总结成一段摘要。
 *
 * <p><b>类比</b>:像开会。开了一个小时之后,你不需要把前面六十分钟的每一句原话都留着 ——
 * 你需要的是"我们决定用方案 B,因为 A 的性能不达标,张三负责下周三前出原型"。
 * 议程、寒暄、来回争论的过程都可以丢,<b>结论和待办不能丢</b>。
 */
public final class Compactor {

    /**
     * 单条消息在摘要输入里的最大字符数。
     *
     * <p>⚠️ 为什么要截断?因为压缩的<b>输入本身也是要计费的</b>。
     * 如果我们把 5 万 token 的历史原样丢给模型让它总结,
     * 这一次调用的成本可能比不压缩还高。
     *
     * <p>代价是:被截断的细节,模型总结时看不到了。
     * 又是那个取舍 —— 这次是"摘要质量"换"摘要成本"。
     */
    private static final int MAX_CHARS_PER_MESSAGE = 4000;

    private final Provider provider;
    private final String model;
    private final int keepRecentMessages;

    /**
     * 一次压缩的结果。
     *
     * @param messages        压缩后的新历史
     * @param tokensBefore    压缩前估算 token(仅消息部分)
     * @param tokensAfter     压缩后估算 token(仅消息部分)
     * @param messagesRemoved 减少了多少条消息
     * @param actualBefore    压缩前<b>实测</b>的 prompt_tokens(可能为 0)
     */
    public record Result(List<Message> messages,
                         int tokensBefore,
                         int tokensAfter,
                         int messagesRemoved,
                         int actualBefore) {

        public int savedRatioPercent() {
            if (tokensBefore == 0) {
                return 0;
            }
            return (int) Math.round(100.0 * (tokensBefore - tokensAfter) / tokensBefore);
        }
    }

    public Compactor(Provider provider, String model, int keepRecentMessages) {
        this.provider = provider;
        this.model = model;
        this.keepRecentMessages = keepRecentMessages;
    }

    /**
     * 压缩对话历史。
     *
     * <p>策略:
     * <ol>
     *   <li><b>保留</b>第 0 条系统提示词;</li>
     *   <li><b>保留</b>最近 keepRecentMessages 条;</li>
     *   <li>中间的部分交给模型总结成一段;</li>
     *   <li>用一条 system 消息把摘要插回中间。</li>
     * </ol>
     *
     * <p>⚠️ <b>切割点必须落在 user 消息上。</b>
     * 因为助手消息里的 tool_calls 和后面的 tool 结果消息是一对,
     * 从中间切开会出现"有工具结果但找不到对应的调用",
     * API 会直接报错。从 user 边界切,整个工具调用序列就是完整的一坨。
     */
    public Result compact(List<Message> conversation, int actualPromptTokens,
                          Consumer<String> logger) {

        int tokensBefore = TokenEstimator.estimate(conversation);

        // 至少要有 system + 几条 + 保留的几条,否则没什么可压的
        if (conversation.size() <= keepRecentMessages + 2) {
            return new Result(conversation, tokensBefore, tokensBefore, 0, actualPromptTokens);
        }

        Message systemPrompt = conversation.get(0);

        // 从后往前数 keepRecentMessages 条,再往前退到最近的 user 边界
        int cut = conversation.size() - keepRecentMessages;
        while (cut > 1 && !"user".equals(conversation.get(cut).role())) {
            cut--;
        }
        if (cut <= 1) {
            return new Result(conversation, tokensBefore, tokensBefore, 0, actualPromptTokens);
        }

        List<Message> toSummarize = conversation.subList(1, cut);
        logger.accept(String.format("%n[压缩] 正在把 %d 条历史消息总结成一段……%n", toSummarize.size()));

        String summary;
        try {
            summary = summarize(toSummarize);
        } catch (Exception e) {
            // ⚠️ 压缩失败绝不能毁掉历史。
            //    这在设计上叫 fail-safe:出问题时保持原状,而不是用残缺的东西继续。
            logger.accept("[压缩] 失败,保持原样继续:" + e.getMessage() + "\n");
            return new Result(conversation, tokensBefore, tokensBefore, 0, actualPromptTokens);
        }

        if (summary.isBlank()) {
            logger.accept("[压缩] 摘要为空,保持原样继续\n");
            return new Result(conversation, tokensBefore, tokensBefore, 0, actualPromptTokens);
        }

        List<Message> compacted = new ArrayList<>();
        compacted.add(systemPrompt);
        compacted.add(Message.system(
                "[以下是更早对话的摘要。原始消息已被省略以节省上下文,"
                        + "但其中的事实、决定和结论都已保留在这里]\n\n" + summary));
        compacted.addAll(conversation.subList(cut, conversation.size()));

        int tokensAfter = TokenEstimator.estimate(compacted);
        // ⚠️ 这里报的是"仅消息部分",不含工具定义。所以它比 Agent 报的
        //    "上下文用量"小 —— 那个数字里还包含了每轮都要重发的工具 schema。
        logger.accept(String.format(
                "[压缩] %d 条 → 1 条摘要;消息部分估算 %d → %d token(省 %d%%)%n%n",
                toSummarize.size(), tokensBefore, tokensAfter,
                tokensBefore == 0 ? 0 : Math.round(100.0 * (tokensBefore - tokensAfter) / tokensBefore)));

        return new Result(compacted, tokensBefore, tokensAfter,
                toSummarize.size(), actualPromptTokens);
    }

    /**
     * 调用模型生成摘要。
     *
     * <p>⚠️ 注意这里构造的请求有三个刻意的选择,每一个都有原因:
     * <ol>
     *   <li><b>不带工具</b> —— 摘要任务不需要工具,而且不带 tools 就不会触发铁律 6
     *       (reasoning_content 必须回传),省掉一堆麻烦。</li>
     *   <li><b>关思考模式</b> —— 摘要不需要深度推理,开思考只是白花钱和时间。</li>
     *   <li><b>把历史拍平成纯文本</b> —— 不原样转发消息列表。因为历史里可能有
     *       tool_calls / tool 结果的配对关系,转发过去容易踩协议坑。
     *       拍平成文本是最省事也最稳的做法。</li>
     * </ol>
     */
    private String summarize(List<Message> history) {
        StringBuilder transcript = new StringBuilder();
        for (Message m : history) {
            transcript.append('[').append(m.role()).append("] ");
            if (m.content() != null && !m.content().isEmpty()) {
                transcript.append(clip(m.content()));
            }
            if (m.toolCalls() != null) {
                for (ToolCall call : m.toolCalls()) {
                    transcript.append(" <调用工具 ").append(call.name())
                            .append('(').append(clip(call.arguments())).append(")>");
                }
            }
            transcript.append('\n');
        }

        String instruction = """
                下面是一段 AI 编码助手与用户的对话历史。请把它压缩成一段简明的中文摘要。

                必须保留:
                - 用户的原始目标,以及当前正在做的任务
                - 已经做过的关键决定,以及为什么这么做
                - 已经查明的重要事实:文件名、类名、方法名、行号、报错信息、结论
                - 当前进展到哪一步了
                - 还没做完、待验证、待确认的事

                可以丢弃:寒暄、重复的尝试、冗长的工具输出细节、中间的推理过程。

                要求:条目式,紧凑,直接输出摘要,不要任何开场白或结束语。

                === 对话历史开始 ===
                """ + transcript + "\n=== 对话历史结束 ===";

        // 用一个数组当可变容器:lambda 里不能给局部变量赋值
        StringBuilder out = new StringBuilder();
        String[] failure = {null};

        provider.stream(
                new ChatRequest(model, List.of(Message.user(instruction)), List.of(), false),
                event -> {
                    switch (event) {
                        case StreamEvent.TextDelta t -> out.append(t.text());
                        case StreamEvent.Failure f -> failure[0] = f.message();
                        case StreamEvent.Done d -> { }
                        case StreamEvent.ToolUseStart s -> { }
                        case StreamEvent.ToolUseArgsDelta a -> { }
                        case StreamEvent.ReasoningDelta r -> { }
                    }
                });

        if (failure[0] != null) {
            throw new IllegalStateException(failure[0]);
        }
        return out.toString().strip();
    }

    private static String clip(String s) {
        if (s.length() <= MAX_CHARS_PER_MESSAGE) {
            return s;
        }
        return s.substring(0, MAX_CHARS_PER_MESSAGE)
                + "\n…(此处省略 " + (s.length() - MAX_CHARS_PER_MESSAGE) + " 字符)";
    }
}

四个关键设计点

① 切割点必须落在 user 消息上 ⚠️

这是最容易出 bug 的地方。看这段消息序列:

user:      "改一下 Calculator"
assistant: tool_calls=[edit_file(...)]
tool:      "已修改 Calculator.java"
assistant: "改好了"

如果你在中间切开,剩下的可能是:

[摘要]
tool:      "已修改 Calculator.java"     ← ⚠️ 孤立的工具结果!

API 会报错:“工具结果找不到对应的工具调用”。因为 tool_call_id 对应的 assistant 消息被你压掉了。

从 user 边界切,整个工具调用序列就是完整的一坨,永远不会断。

② 摘要用 system 角色插在系统提示词之后

DeepSeek 的 OpenAI 兼容接口支持在对话中间插入 system 消息(但不支持插入 tool_calls)。 用 system 而不是 user,语义上更准:“这是背景信息”,模型不会把它当成一个新的提问。

位置选在系统提示词之后,还有一个额外好处——见第 10 节。

③ 摘要调用刻意不带工具、关思考

三个原因中的第一个尤其重要:不带 tools 就不会触发铁律 6。 如果带了工具,历史里所有 assistant 消息都必须回传 reasoning_content, 而我们的历史里可能有早期没记录的思考内容——那就会 400。

用一个没有工具副作用的干净请求做摘要,是刻意的规避。

④ 压缩失败时保持原状

} catch (Exception e) {
    logger.accept("[压缩] 失败,保持原样继续:" + e.getMessage() + "\n");
    return new Result(conversation, tokensBefore, tokensBefore, 0, actualPromptTokens);
}

这是 fail-safe 原则的又一次应用(M3 的审批失败时也是 default deny)。 摘要调用可能因为网络问题失败。如果失败时你”尽力而为”地用空字符串替换历史, 模型就彻底失忆了。宁可这次不压,也不能用残缺的东西继续。


8. 第四步:接进 Agent

Agent 里加了三样东西:

    private final ContextBudget budget;
    private final Compactor compactor;
    private int lastPromptTokens = 0;      // 上次请求实测的输入 token

循环开头插入一步:

        for (int round = 1; round <= MAX_ROUNDS; round++) {

            // ── 0. 上下文管理:发请求之前先看看是不是该压缩了 ──
            maybeCompact(listener);

            // ── 1. 调模型 ──
            ...
    /**
     * 检查上下文用量,必要时压缩。
     *
     * <p>⚠️ 注意判断用的是<b>估算值</b>,因为决定必须在请求发出<b>之前</b>做出来。
     * 但我们同时保留了 {@code lastPromptTokens}(上一次的实测值),
     * 用来在日志里对照 —— 你会看到估算和实测能差多少。
     */
    private void maybeCompact(AgentListener listener) {
        if (compactor == null || budget == null) {
            return;
        }

        int estimated = TokenEstimator.estimate(conversation)
                + TokenEstimator.estimateTools(tools.specs());

        listener.onContextUsage(estimated, budget.tokenBudget(), lastPromptTokens);

        if (!budget.shouldCompact(estimated)) {
            return;
        }

        Compactor.Result result = compactor.compact(
                conversation, lastPromptTokens, listener::onCompactionLog);

        if (result.messagesRemoved() > 0) {
            conversation.clear();
            conversation.addAll(result.messages());
            listener.onCompacted(result);
        }
    }

另外在收到响应时记录实测值:

            // 记下这次的实测输入长度,下次判断压缩时用它校准
            if (turn.usage().promptTokens() > 0) {
                lastPromptTokens = turn.usage().promptTokens();
            }

💡 注意 maybeCompact 放在 for 循环内部,不是放在 run() 开头。 因为一轮 <tool_use> 内部的子轮次也会让上下文增长(工具结果很大), 必须在每一次发请求之前都检查,而不只是每次用户提问时。


9. 跑起来 —— 实测数据

先造一个大点的文件,然后跑一次会触发压缩的对话:

# 造一个 285 行的文件(脚本见仓库)
# 然后:
printf 'Read BigService.java fully and tell me how many methods it has.\nWhat does compute7 return for input 3?\nHow many lines is BigService.java?\nWhat was the VERY FIRST question I asked you?\n\n' \
  | java ... dev.learn.agent.Main --yes --budget=4000 --keep=4

这是我实际跑出来的完整结果:

上下文预算:4000 token(超过 2400 触发压缩,保留最近 4 条)

你> Read BigService.java fully and tell me how many methods it has.
⚙ glob {"pattern": "**/BigService.java"}
  ← src/main/java/demo/BigService.java
⚙ read_file {"path": "src/main/java/demo/BigService.java"}
  ← 1	package demo;
40 methods: `compute1` through `compute40` ...
[轮次结束 END_TURN  in=4922 out=46 cache=1664]

你> What does compute7 return for input 3?
[上下文] 估算 4644 / 4000 token(上次实测 4922,估算误差 -278)
`compute7(3)`: base = 3*7 = 21, shifted = 21<<2 = 84, return 84+7 = **91**.
[轮次结束 END_TURN  in=4982 out=36 cache=4736]

你> How many lines is BigService.java?
[上下文] 估算 4694 / 4000 token(上次实测 4982,估算误差 -288)
284 lines of content, 285 with the trailing newline ...
[轮次结束 END_TURN  in=5030 out=90 cache=4864]

你> What was the VERY FIRST question I asked you?
[上下文] 估算 4827 / 4000 token(上次实测 5030,估算误差 -203)

[压缩] 正在把 6 条历史消息总结成一段……
[压缩] 6 条 → 1 条摘要;消息部分估算 3621 → 500 token(省 86%)
[压缩完成] 原始消息已被一段摘要替换

That's not in my context anymore — the earliest messages were summarized,
and the summary only records the topic (reading BigService.java and counting
methods), not your literal first wording. So I can't recover the exact first
question verbatim.

Best I can say: it was about reading `BigService.java` / counting its methods.
[轮次结束 END_TURN  in=1894 out=67 cache=1536]

这张表要盯住看

压缩前压缩后
输入 token(in=)50301894(↓ 62%)
缓存命中(cache=)48641536(↓ 68%)
消息条数138

输入省了 62%,这是压缩的收益。 但注意缓存命中掉了一大截——那是代价,第 10 节讲。

最重要的观察:模型的最后那句话

“That’s not in my context anymore — the earliest messages were summarized, and the summary only records the topic, not your literal first wording. So I can’t recover the exact first question verbatim.”

请把这句话读三遍。 这是这一节最有价值的产出。

模型没有编造,也没有装糊涂。它准确地报告了:

  1. 我知道有东西被压缩了(因为它看到了那条 [以下是更早对话的摘要…] 消息);
  2. 摘要保住了主题(“reading BigService.java and counting methods”);
  3. 摘要没保住逐字措辞;
  4. 所以我不能给你确切的原文。

这就是压缩的效果和代价,一次说清楚了:

  • ✅ 保住了”我们在干什么”
  • ❌ 丢掉了”你当时具体怎么说的”

对于编码 agent 来说,这个取舍通常是对的——你不需要记住用户的原话, 你需要记住”改了哪些文件、查明了什么”。

⚠️ 但如果你的任务需要精确引用(比如法律文档审阅、逐字校对), 这个取舍就是错的。那时候你该做的不是调压缩参数,而是换一种方案 (比如把原文存到外部存储,需要时用工具查回来,而不是塞在上下文里)。

这就是”没有标准答案”的具体含义。

关于”估算误差”那一列

你会看到估算误差在 -278 到 -288 之间,也就是估算比实测低约 6%。 这个偏差是合理的:我们的字符比例是官方给的近似值。

但注意有一次显示 +2890——那不是估算错了。 那是因为 read_file 刚返回了一个大文件,历史一下涨了 3000 token, 而”上次实测”是读文件之前的值。拿现在的大小去比过去的实测,本来就不对等。

💡 这是个很好的教训:在日志里放一个数字很容易,放一个有意义的数字很难。 这个 估算误差 字段的设计其实有缺陷——它只有在”历史没变化”的时候才可比。 真实产品里应该标注清楚,或者干脆分两行显示。


10. 意外收获:压缩会让缓存失效

回头看那张表:

压缩前压缩后
输入 token50301894
缓存命中48641536

输入省了 62%,但缓存命中也掉了 68%。 这不是巧合,是必然。

为什么?

DeepSeek 的上下文缓存是按前缀匹配的:

每个用户请求都会触发磁盘缓存的构建。如果后续请求和之前的请求有重叠的前缀, 重叠部分就直接从缓存读,算作”缓存命中”。

而压缩做的事情,恰恰是替换掉历史的前半部分:

压缩前的请求前缀: [系统提示][工具定义][第1轮消息][第2轮消息][第3轮消息]...
                                 ↑ 这些全部命中缓存
压缩后的请求前缀: [系统提示][工具定义][摘要]      [第3轮消息]...
                                 ↑ 从这里开始就不一样了 → 全部未命中

前缀一旦改变,从改变点往后全部失效。

那为什么还有 1536 是命中的?

因为这 1536 是摘要插入点之前的部分:

[系统提示][工具定义] ← 这一段没变,仍然命中(≈1536 token)
[摘要]              ← 从这里开始变了
[第3轮消息]...

这就是为什么我们把摘要插在系统提示词之后,而不是最前面。

如果把摘要放在最前面(或者改掉系统提示词),那连这 1536 都没了。

💡 一个不显眼但很值钱的推论: 永远不要修改历史的前缀部分。 想追加信息就往后追加,别回头改。 想调整模型行为,用末尾的 user 消息,而不是改开头。 这是”让缓存命中率保持高位”的第一原则。

所以压缩到底划不划算?

算一笔账(价格来自第 00 节的表):

压缩前压缩后
输入 token50301894
其中缓存命中48641536
其中缓存未命中166358
未命中部分成本($0.15/1M)0.0000250.000054
命中部分成本($0.003/1M)0.00001460.0000046
本次请求输入成本≈ $0.0000396≈ $0.0000586

这次请求,压缩之后反而更贵了!

因为压缩制造的”未命中”(358 token 全价)超过了他省下的”命中”(便宜 50 倍)。

⚠️ 但这是一次性的。 从下一次请求开始,新的前缀就会被缓存, 后面每一轮都由 1894 起步而不是 5030。 压缩是”付一次钱,换之后每一轮都更便宜”。

这个账必须算跨多轮,不能只看一次。 如果你只压一次就结束对话, 那这次压缩是亏的。这就是为什么真实产品的压缩阈值不会设得太低—— 压得太勤,每次都在为那一次性的缓存失效买单。


11. 上下文缓存(KV cache)机制详解

这一节把 DeepSeek 的缓存机制讲透,因为它直接影响你的成本,而且不需要写任何代码。

11.1 它是自动的

DeepSeek API 的硬盘上下文缓存对所有用户默认开启,无需修改代码。

你什么都不用做。只要你的请求前缀和之前的请求有重叠,就自动命中。

这也是为什么我们的 ToolRegistry.specs() 要排序(M2 写过)—— 排序保证工具定义的 JSON 字节稳定,前缀才不会莫名其妙地变化。

11.2 什么时候会”持久化”一个缓存前缀单元

这是最关键的机制。缓存不是”任意前缀都能命中”,而是必须先被持久化成独立的缓存前缀单元:

时机说明
请求边界每次请求都会在用户输入结束和模型输出结束两个位置产生缓存单元
公共前缀检测系统发现多个请求共享某段前缀时,会把这段前缀持久化成独立单元
固定 token 间隔对长输入/长输出,系统每隔固定 token 数切一个缓存单元

11.3 两个例子(来自官方文档,但值得想一遍)

例 1:多轮对话(能命中)

第 1 轮请求: A + B
第 2 轮请求: A + B + C        ← 完整匹配了 A+B 这个单元 → 命中

这正是我们的 agent 循环一直在做的事。 每次追加消息,前缀不变,所以命中。 这也解释了我们实测里 cache= 一直在涨。

例 2:长文本问答(前三轮不命中)

第 1 轮请求: [系统提示][超长财报内容] + "请总结"
第 2 轮请求: [系统提示][超长财报内容] + "请分析盈利能力"
第 3 轮请求: [系统提示][超长财报内容] + "请分析收支比"

第 1、2 轮不命中——因为后半段(问题)不同,不构成完整的前缀单元匹配。 但系统会检测到两轮共享前面这段,把它持久化成单元。 第 3 轮就能命中了。

💡 这个例子的启示:缓存是”学习”出来的。 如果你每次都用不同的系统提示词、不同的工具定义, 系统永远检测不出稳定的公共前缀,你就永远命中不了。 稳定的前缀是缓存命中率的前提。

11.4 怎么检查命中情况

响应里有两个字段:

prompt_cache_hit_tokens   命中缓存的输入 token 数
prompt_cache_miss_tokens  未命中的输入 token 数

我们的 Usage 记录了前者,每轮都显示。盯住这个数字,它是你成本的主要变量。

11.5 重要警示

缓存系统是 “best-effort” 的,不保证 100% 命中率。

缓存构建需要几秒钟。缓存不再被使用后会自动清除,通常几小时到几天。

所以不要把它当成能依赖的保证。 它是”能省就省”,不是”一定会省”。 你的成本设计不能建立在”缓存必然命中”的假设上。


12. 自检清单

  • mvn -q compile 无错误
  • 用 --budget=4000 --keep=4 跑几轮对话,能看到 [压缩] 输出
  • 压缩后 in= 明显下降
  • 压缩后对话能继续,不报 400 / 不报”工具结果找不到调用”
  • 问”我最开始问了你什么”,模型的回答承认信息已丢失(而不是编造)
  • 看到 cache= 在压缩后下降(缓存失效)
  • 把 --budget 调到很大,压缩不再触发(说明阈值生效)

可以自己动手改的练习

  1. 把 keepRecentMessages 从 4 改成 1,然后问一个刚才读过文件的问题。 看模型会不会因为”刚读到的东西被压掉了”而重新去读一遍。这就是”正在做的事不能压”的实证。
  2. 改摘要提示词,把它改成只保留”待办事项”。 跑一遍你会发现模型完全不知道之前做了什么——体会一下提示词里的”保留清单”有多关键。
  3. 把摘要插到消息列表最前面(而不是系统提示词之后),对比 cache= 的变化。 应该会掉到接近 0。这是”别动前缀”的反面教材。
  4. 加一个”外部记忆”工具:把摘要写进一个 NOTES.md 文件, 压缩时清空上下文,需要时让模型自己 read_file 读回来。 这就是 Claude Code 的 CLAUDE.md 和 memory 机制的思路—— 把”该记住什么”的决定权从压缩算法交还给模型。

13. 踩坑速查

症状原因修法
400 “工具结果找不到对应的调用”切割点落在了工具调用序列中间切割点必须在 user 消息上
400 reasoning_content must be passed back摘要请求带了 tools摘要请求不带工具(List.of())
压缩后模型完全失忆摘要提示词没写”保留清单”明确列出必须保留什么
压缩后立刻又触发压缩摘要本身太长,或者 keepRecentMessages 太小调小摘要长度要求 / 调大保留条数
摘要调用比不压缩还贵单条消息截断阈值太大调小 MAX_CHARS_PER_MESSAGE
压缩失败后对话乱了失败时用了残缺的结果失败必须原样返回(fail-safe)
cache= 一直很低前缀不稳定(系统提示词里有时间戳、工具顺序变了)保证前缀字节稳定,specs() 要排序
估算和实测差很多漏算了 reasoning_content 或工具定义检查 TokenEstimator

14. 本节你实际学到了什么

概念你做了什么
上下文是预算主动设了远低于上限的预算,而不是”能装就装”
O(n²) 成本认识到每轮重发历史的成本结构
压缩策略保留头尾、压缩中间、“正在做的事不能压”
触发时机60% 阈值,给压缩操作本身留余量
协议的坑切割点必须落在 user 边界,否则 tool 消息会孤立
fail-safe压缩失败保持原状,绝不用残缺结果继续
估算 vs 实测请求前只能估算,请求后必须校准
缓存的前缀匹配理解为什么”改前缀”是最贵的操作
压缩的成本账压缩会让缓存失效,这笔账要按多轮算
没有标准答案第一次遇到纯粹的设计取舍

最重要的一条:这一节没有给你一个”正确的压缩算法”。 它给你的是一个可调的框架,和一堆必须自己权衡的取舍。

如果你以后看到某篇文章说”上下文管理就该这么做”,请保持怀疑。 答案取决于任务:改代码、写文档、做研究、审合同——四种任务的最优压缩策略完全不同。


15. 下一节预告(M5)

M4 结束时,你的 agent 功能已经完整了:能读、能写、能跑命令、有人把关、上下文不会爆炸。

但它有一个从未被验证过的假设:

Provider 抽象真的成立吗?还是只是”看起来能扩展”?

M5 要验证它。做法很干脆:

  1. 写第二个 Provider —— 用 Anthropic 的消息格式实现同一个接口
  2. 关键:接口、Agent、Tool、PermissionManager、Compactor 一行都不改
  3. 只改 Main 里的一行构造代码,整个 agent 就换了一套协议运行
  4. 然后看看哪里真的漏了——那才是这一节最有价值的部分

以及 MCP:它是怎么在不动 agent 循环的前提下,给它接上一整个外部工具生态的。

M5 不是加功能,是回头检验前面的设计。