TL;DR(30 秒速览)
- 深度研究会话 100+ 轮,上下文超过 128K token 窗口——直接截断丢历史,全量发送超预算
- 增量压缩:只保留"上一轮摘要 + 最近几轮原文",每轮只处理增量,不重新加载完整历史
- Token 估算校准:压缩决策发生在 LLM 调用之前,只能先估算再用真实 usage 校准,合理性窗口
[0.25x, 4x]- 尾部结构保护:压缩后 tail 头部的
ToolResultMessage必须和发出调用的AssistantMessage配对,否则 LLM 会重新执行工具- 变量提取:压缩前提取数值事实到会话变量,不参与压缩;未提取的数据由 System Prompt 引导 LLM 重新调工具获取,而非凭"记忆"编造
- 核心代码:
ContextCompactionOrchestrator(371 行)+UsageAwareTokenEstimator(206 行)+CompactionAgentStrategy(242 行)- 开源地址:github.com/haibingzhao/easyai
前情提要:上一篇我们讲了 AI 创造 AI——一句话生成配置——AgentLoop + 分块提交 + validate-fix 循环。今天的问题是:Agent 运行 100+ 轮后,上下文超出窗口怎么办?
核心矛盾
一个深度研究会话进行了 100+ 轮,上下文已经超过了模型的 128K token 窗口。
直接截断丢关键历史,全量发送超预算被拒,怎么办?
| 方案 | 优点 | 缺点 |
|---|---|---|
| 直接截断 | 简单 | 丢失早期关键决策 |
| 全量发送 | 不丢信息 | 超出窗口,API 报错 |
| 增量压缩 | 信息保留 + 有界输入 | 需要 LLM 调用做摘要 |
EasyAI 的方案:增量上下文压缩——只保留"上一轮摘要 + 最近几轮原文"。
增量压缩策略
压缩前:[msg1, msg2, ..., msg50, msg51, ..., msg60] ↑ 最近几轮保留原文 压缩后:[Summary(msg1~msg50), msg51, ..., msg60] ↑ 上一轮摘要(增量更新)核心代码在ContextCompactionOrchestrator.executeCompaction():
// Step 1: 选择消息范围valselection=selectMessages(messages,modelContextLength)val(prefixMessages,compactedMessages,recentMessages)=selection// Step 2: 计算压缩轮次(增量标识)valpreviousSummaryCount=compactedMessages.count{msg->(msgas?UserMessage)?.metadata?.get("isCompactionSummary")=="true"}valcompactionRound=previousSummaryCount+1// Step 3: 生成摘要(Agent-based strategy)valstrategyOutput=strategy.compactWithUsage(compactedMessages,context,chatModel,tokenEstimator)// Step 4: 组装结果 = prefix + 摘要 + 最近消息valsummaryMessage=UserMessage(content=listOf(TextContent(strategyOutput.summary)),metadata=mapOf("isCompactionSummary"to"true"))valresultMessages=prefixMessages+summaryMessage+recentMessages关键设计:
- 每轮压缩只处理"上一轮摘要 + 新消息",不重新加载完整历史
- 增量更新:如果已有摘要,LLM 的任务是"更新"而非"从头总结"
- 有界数据:无论对话多长,每次压缩的输入量都是可控的
三种触发方式
// CompactionTriggerTypesealedclassCompactionTriggerType{objectAuto:CompactionTriggerType()// token 超过阈值自动触发objectManual:CompactionTriggerType()// 用户主动点击"压缩上下文"dataclassOverflow(valreason:String):CompactionTriggerType()// LLM 返回上下文超长错误}| 触发方式 | 阈值 | 场景 |
|---|---|---|
| Auto | 模型窗口的 80% | 正常对话中自动触发 |
| Manual | 无 | 用户感觉响应变慢,主动压缩 |
| Overflow | LLM 报错后 | 紧急压缩,压缩后立即重试 |
Token 估算校准:UsageAwareTokenEstimator
这是本文最精细的工程组件。
第一个问题:为什么不直接用 LLM 返回的 token 数?
LLM API 每次调用都会返回真实的 usage(input/output token 数),看起来很权威——但压缩决策发生在 LLM 调用之前。
EasyAI 的压缩由TransformContextService在每轮请求发送前执行:先判断"当前上下文是否接近窗口",决定要不要压缩,然后才把消息发给 LLM。这意味着判断的那一刻:
- 用户最新的输入还没有发送——它是这一轮刚进来的消息,从未到达 LLM
- 本轮工具执行产生的新消息也未经 LLM 计量
- 最新一次真实 usage 报告,停留在上一轮调用完成时
换句话说,"当前上下文有多大"这个问题,在发送前没有任何真实数据可以回答,只能本地估算。如果只依赖 LLM 返回的 usage,压缩决策永远滞后一轮——而恰恰是这一轮,可能直接撞上窗口溢出。
所以方案是:估算为主体,真实 usage 做校准——每次调用完成后,usage 报告记录在对应的AssistantMessage上;下一次估算时,以最新的 usage 为锚点(这部分是精确的),只用 tokenizer 估算锚点之后新增消息的增量。这就是UsageAware这个名字的含义。
第二个问题:估算本身准吗?
开源 tokenizer(jtokkit O200K_BASE)的估算值和实际 LLM API 的 token 计数有偏差——不同模型使用不同的 tokenizer,O200K_BASE 只是一个稳定的近似基准。偏差大了会导致压缩时机不对——太早浪费 token,太晚触发溢出。
这正是需要"校准"的原因:纯估算不可信,纯 usage 又滞后,两者结合才是答案。
方案:用 LLM 的真实反馈校准
// UsageAwareTokenEstimator.estimateContextTokens()overridefunestimateContextTokens(messages:List<EasyAiMessage>):Int{// 1. 找到最新的有 usage 报告的 AssistantMessagevallastUsageIndex=messages.indexOfLast{msg->msgisAssistantMessage&&totalInputTokens(msg.usage)>0}if(lastUsageIndex<0)returnestimate(messages)// 无 usage,纯 tokenizer// 2. 信任最新 usage 报告valreported=totalInputTokens(assistant.usage)+assistant.usage.outputTokensvalbaseline=estimate(messages.subList(0,lastUsageIndex+1))// 3. 合理性校验:reported 必须在 [0.25x, 4x] 范围内if(baseline>MIN_BASELINE_FOR_CHECK&&(reported<baseline*LOW_REPORT_RATIO||reported>baseline*HIGH_REPORT_RATIO)){returnestimate(messages)// 不合理,回退到纯 tokenizer}// 4. 校准锚点之后的新消息用 tokenizer 估算增量valdelta=deltaAfter(lastUsageIndex,messages)returnreported+delta}为什么需要合理性窗口
| 场景 | 问题 | 窗口作用 |
|---|---|---|
Anthropicmessage_delta | 不包含input_tokens,usage 报告不完整 | LOW_REPORT_RATIO = 0.25拦截 |
| 缓存命中 | input_tokens只报告非缓存部分 | totalInputTokens= input + cacheRead + cacheWrite |
| 报告异常膨胀 | cacheRead=244,992 异常值 | HIGH_REPORT_RATIO = 4.0拦截 |
消息选择:prefix + compacted + recent
// selectMessages() 三段式选择privatefunselectMessages(messages,modelContextLength):Triple<prefix,compacted,recent>{// prefix: System 消息 + 第一条 UserMessagevalprefixMessages=messages.take(firstUserIndex+1)// recent: 最近 tailTurns 轮(默认 2 轮),但不超过 preserveRecentTokensval(recentMessages,compactedMessages)=selectRecentMessages(remainingMessages,tailTurns=2,maxRecentTokens=preserveRecentTokens)returnTriple(prefixMessages,compactedMessages,recentMessages)}尾部 Token 预算内的增量裁剪
// selectRecentMessages() 中的增量裁剪varrecentTokens=tokenEstimator.estimate(recentMessages)while(recentTokens>maxRecentTokens&&recentMessages.size>1){// 增量:减去头部一条消息,不重新估算整个 tailrecentTokens-=tokenEstimator.estimate(listOf(recentMessages.first()))splitIndex++recentMessages=recentMessages.drop(1)}O(n) 复杂度——每次减去一条消息的 token 估算,不是每次重新估算整个 tail。
尾部结构保护:ToolResult 配对
这是最容易踩坑的地方。
问题
如果压缩后 tail 的第一条消息是ToolResultMessage,而对应的AssistantMessage(发出 tool call 的)被压缩掉了:
压缩后:[Summary, ToolResultMessage(search="xxx"), UserMessage, ...] ↑ 孤立的工具结果!LLM 不知道谁发出了这个调用LLM 看到孤立的工具结果,会重新执行相同的工具调用——浪费 token 和时间。
方案:结构守卫
// selectRecentMessages() 末尾的结构保护// 如果 tail 头部是 ToolResult,向前扩展直到包含发出调用的 Assistantwhile(splitIndex>0&&recentMessages.firstOrNull()isToolResultMessage){splitIndex--recentMessages=listOf(messages[splitIndex])+recentMessages}测试用例验证了这个行为:
// ContextCompactionOrchestratorTest@Testfun`keeps tool-calling assistant paired with its tool result even over budget`(){valbigResult="r".repeat(30_000)// 超大工具结果valmessages=listOf(user1,assistant1,toolResult1,user2,assistant2,toolResult2)valresult=orchestrator.compact(agentContext,messages,...)// assistant2 和 toolResult2 必须同时保留在 tail 中assertTrue(assistant2.idinresultIds)assertTrue(toolResult2.idinresultIds)// 且必须相邻assertEquals(resultIds.indexOf(assistant2.id)+1,resultIds.indexOf(toolResult2.id))}压缩策略:Agent-based 摘要
CompactionAgentStrategy用一个轻量级 Agent 做摘要,而非简单的 LLM 调用:
// CompactionAgentStrategy.executeAgentCompaction()valagentContext=AgentContext(agentId="compaction-agent",modelConfig=disableThinking(context.modelConfig),// 关闭思考模式tools=listOf(variableTool),// update_variable 工具dryRun=true,// 不持久化)valagent=Agent(context=agentContext,services=dryRunServices)valrunner=AgentRunner(agent=agent,messages=mutableListOf())摘要输出结构
System Prompt 要求 LLM 输出结构化的摘要:
Goal: 用户的目标 Constraints: 约束条件 Progress: - Done: 已完成的工作 - In Progress: 进行中的工作 - Blocked: 阻塞的问题 Key Decisions: 关键决策 Next Steps: 下一步计划 Critical Context: 关键上下文 Relevant Files: 相关文件变量提取:压缩的数据保险机制
这是本文最有创新性的设计,值得单独展开。
问题:压缩是有损的,数值数据的丢失最致命
摘要天然是有损压缩。目标、进展、决策这些叙述性内容可以用自然语言概括,但数值型事实——EPS 是 170.69、PE 是 80.28、市值 2.3 万亿——在摘要中极易丢失或变形:
- LLM 做摘要时可能把数字当噪声省略掉,或把 170.69 概括成"约 170"
- 多轮增量压缩后,数字被反复"稀释",最终完全消失
- 更危险的是:数字丢失后,LLM 会凭"记忆"回忆——而这个"记忆"早已被压缩掉,它只能编造一个近似值,用户几乎无法察觉
在投资分析、数据研究这类场景里,一个被篡改的数字可能让整个后续分析的结论全部作废。摘要可以丢细节,数据不能丢。
设计:压缩前把数据提取到消息流之外保存
核心思路:在压缩发生的同时,把所有数值/数据型事实提取成会话变量(Session Variables),存储在不参与压缩的地方。变量不在消息历史里,压缩多少轮都不会丢失,并持久化到 DB,跨请求可恢复。
消息流(会被压缩): [..., "EPS 是 170.69", ...] ──压缩──▶ 摘要(数字可能丢失) 会话变量(不参与压缩):{ "eps": "170.69", "pe_ttm": "80.28" } ──▶ 永久保留,注入 System Prompt每轮请求构建 System Prompt 时,变量以## Session Variables段落无条件追加:
## Session Variables The following data was extracted during context compaction and persists across compaction rounds. IMPORTANT: When you need data that might have been discussed earlier, ALWAYS check this list first before relying on your memory of the conversation. Use these values as authoritative — do NOT fabricate or approximate them. For variables marked [file: path], use the read tool to load the full content. - eps: 170.69 - pe_ttm: 80.28这段提示词形成了完整的闭环:
- 先查表再回答:LLM 需要早期讨论过的数据时,必须先查变量列表,而不是依赖对话"记忆"——那个"记忆"可能已被压缩掉;
- 权威值:列表中的变量是唯一可信来源,禁止编造和近似;
- 未提取的数据引导重新获取:如果需要的数据不在列表里(压缩时未被提取),System Prompt 的引导使 LLM 不会凭"记忆"硬编一个值,而是重新调用工具获取——摘要里保留了工具名和调用背景,重新获取的成本远低于错误数据带来的风险。
实现:让摘要 Agent 自己调工具上报变量
提取不是另一个独立的 LLM 调用,而是由压缩 Agent 在生成摘要的同时完成——给它注册一个专用的update_variable工具:
// CompactionAgentStrategy:为压缩 Agent 注册变量工具tools=listOf(variableTool),// update_variabledryRun=true,// 不持久化消息,只收集变量// CompactionVariableTool:无副作用,只把变量收集到 AtomicReferenceinternalclassCompactionVariableTool(privatevaltoolCalled:AtomicBoolean,privatevalextractedVariables:AtomicReference<Map<String,String>>):BaseToolDefinition(ToolMetadata(name="update_variable",...))System Prompt 明确要求:生成摘要后必须调用一次update_variable,输出完整的变量集。三个工程细节保证提取的可靠性:
| 细节 | 做法 |
|---|---|
| LLM 忘记调工具 | CompactionVariableCompletionCheck在完成前检查,未调用则发送一次 nudge 提醒再给它一次机会 |
| 多轮压缩后变量过时 | 工具语义是全量替换——输出即完整变量集:保留仍有效的、更新变化的、丢弃过时的 |
| 超大数据表 | 值支持 JSON 数组/对象(序列化为字符串存储);过大的值溢出到文件,变量里只存[file: path]指针,LLM 需要时用 read 工具加载 |
变量的取舍边界也由 Prompt 明确:只存数值/数据型事实(价格、比率、ID、配置值、计算结果),不存分析结论和叙述——那些属于摘要的职责。例如投资分析中的 EPS、PE、市值会进入变量卡片,而"建议买入"这类结论只留在摘要里。
提取出的变量通过compaction_end事件实时推送到前端,渲染为变量卡片——用户可以直观看到会话当前持有哪些关键数据,这也是压缩过程可观测性的一部分。
配置参数
// CompactionConfigdataclassCompactionConfig(valenabled:Boolean=true,valthreshold:Double=0.8,// 80% 窗口触发valreservedTokens:Int=10_000,// 预留响应 tokenvaltailTurns:Int=2,// 保留最近 2 轮valpreserveRecentTokensRatio:Double=0.25,// 保留 25% 窗口valminMessagesForCompaction:Int=10// 至少 10 条消息才检查)性能数据
| 指标 | 数值 |
|---|---|
| 100 轮对话压缩后 token | 120K → 30K(压缩比 75%) |
| 压缩耗时 | 约 3-5 秒(LLM 调用) |
| 压缩后任务完成质量 | 与未压缩基线无明显下降 |
| Token 估算偏差 | 校准后 < 10%(未校准 > 30%) |
踩坑记录
| 坑 | 原因 | 解法 |
|---|---|---|
| 孤立 ToolResult | 压缩后 tail 头部是 ToolResult,对应 Assistant 被压缩 | 结构守卫:向前扩展到包含 Assistant |
| Token 估算偏差大 | Gateway 少报 input_tokens(缓存命中时) | totalInputTokens= input + cacheRead + cacheWrite |
| 压缩后 LLM 重新执行工具 | 工具结果和调用者分离 | 结构守卫保证配对 |
| 摘要丢失关键变量 | 纯文本摘要会丢失/变形数值数据 | update_variable工具提取到不参与压缩的会话变量,注入 System Prompt |
| 压缩轮次不清 | 多次压缩后不知道是第几轮 | isCompactionSummary元数据标记 + 计数 |
总结
| 维度 | 直接截断 | 全量摘要 | EasyAI 增量压缩 |
|---|---|---|---|
| 信息保留 | 差(丢失早期) | 好 | 好(增量更新) |
| 输入有界 | 是 | 否(随对话增长) | 是(只处理增量) |
| Token 精度 | N/A | N/A | 校准后 < 10% 偏差 |
| 结构安全 | N/A | N/A | ToolResult 配对保护 |
| 变量提取 | 无 | 无 | update_variable工具 |
EasyAI 的ContextCompactionOrchestrator用 371 行 Kotlin 代码实现了完整的增量上下文压缩——消息选择、增量摘要、Token 校准、结构保护、变量提取。
核心思想:好的压缩不是从头总结,而是增量更新——每一轮只处理上一轮摘要 + 新消息。
下一篇:从 Kotlin Channel 到 SSE——Agent 事件流的全链路设计
Agent 执行过程中有 15+ 种事件类型(thinking、tool 执行、权限请求、压缩、子 Agent 转发……),怎么实时推送到前端?从 Kotlin Channel → Flow → Reactor Flux → SSE,全链路解耦,刷新页面不丢状态。
开源地址:https://github.com/haibingzhao/easyai
欢迎 Star、Issue 和 PR。