100+ 轮对话不丢上下文:增量压缩的工程实践

📅 2026/8/24 19:42:24
100+ 轮对话不丢上下文:增量压缩的工程实践
TL;DR30 秒速览深度研究会话 100 轮上下文超过 128K token 窗口——直接截断丢历史全量发送超预算增量压缩只保留上一轮摘要 最近几轮原文每轮只处理增量不重新加载完整历史Token 估算校准压缩决策发生在 LLM 调用之前只能先估算再用真实 usage 校准合理性窗口[0.25x, 4x]尾部结构保护压缩后 tail 头部的ToolResultMessage必须和发出调用的AssistantMessage配对否则 LLM 会重新执行工具变量提取压缩前提取数值事实到会话变量不参与压缩未提取的数据由 System Prompt 引导 LLM 重新调工具获取而非凭记忆编造核心代码ContextCompactionOrchestrator371 行UsageAwareTokenEstimator206 行CompactionAgentStrategy242 行开源地址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: 选择消息范围valselectionselectMessages(messages,modelContextLength)val(prefixMessages,compactedMessages,recentMessages)selection// Step 2: 计算压缩轮次增量标识valpreviousSummaryCountcompactedMessages.count{msg-(msgas?UserMessage)?.metadata?.get(isCompactionSummary)true}valcompactionRoundpreviousSummaryCount1// Step 3: 生成摘要Agent-based strategyvalstrategyOutputstrategy.compactWithUsage(compactedMessages,context,chatModel,tokenEstimator)// Step 4: 组装结果 prefix 摘要 最近消息valsummaryMessageUserMessage(contentlistOf(TextContent(strategyOutput.summary)),metadatamapOf(isCompactionSummarytotrue))valresultMessagesprefixMessagessummaryMessagerecentMessages关键设计每轮压缩只处理上一轮摘要 新消息不重新加载完整历史增量更新如果已有摘要LLM 的任务是更新而非从头总结有界数据无论对话多长每次压缩的输入量都是可控的三种触发方式// CompactionTriggerTypesealedclassCompactionTriggerType{objectAuto:CompactionTriggerType()// token 超过阈值自动触发objectManual:CompactionTriggerType()// 用户主动点击压缩上下文dataclassOverflow(valreason:String):CompactionTriggerType()// LLM 返回上下文超长错误}触发方式阈值场景Auto模型窗口的 80%正常对话中自动触发Manual无用户感觉响应变慢主动压缩OverflowLLM 报错后紧急压缩压缩后立即重试Token 估算校准UsageAwareTokenEstimator这是本文最精细的工程组件。第一个问题为什么不直接用 LLM 返回的 token 数LLM API 每次调用都会返回真实的 usageinput/output token 数看起来很权威——但压缩决策发生在 LLM 调用之前。EasyAI 的压缩由TransformContextService在每轮请求发送前执行先判断当前上下文是否接近窗口决定要不要压缩然后才把消息发给 LLM。这意味着判断的那一刻用户最新的输入还没有发送——它是这一轮刚进来的消息从未到达 LLM本轮工具执行产生的新消息也未经 LLM 计量最新一次真实 usage 报告停留在上一轮调用完成时换句话说当前上下文有多大这个问题在发送前没有任何真实数据可以回答只能本地估算。如果只依赖 LLM 返回的 usage压缩决策永远滞后一轮——而恰恰是这一轮可能直接撞上窗口溢出。所以方案是估算为主体真实 usage 做校准——每次调用完成后usage 报告记录在对应的AssistantMessage上下一次估算时以最新的 usage 为锚点这部分是精确的只用 tokenizer 估算锚点之后新增消息的增量。这就是UsageAware这个名字的含义。第二个问题估算本身准吗开源 tokenizerjtokkit O200K_BASE的估算值和实际 LLM API 的 token 计数有偏差——不同模型使用不同的 tokenizerO200K_BASE 只是一个稳定的近似基准。偏差大了会导致压缩时机不对——太早浪费 token太晚触发溢出。这正是需要校准的原因纯估算不可信纯 usage 又滞后两者结合才是答案。方案用 LLM 的真实反馈校准// UsageAwareTokenEstimator.estimateContextTokens()overridefunestimateContextTokens(messages:ListEasyAiMessage):Int{// 1. 找到最新的有 usage 报告的 AssistantMessagevallastUsageIndexmessages.indexOfLast{msg-msgisAssistantMessagetotalInputTokens(msg.usage)0}if(lastUsageIndex0)returnestimate(messages)// 无 usage纯 tokenizer// 2. 信任最新 usage 报告valreportedtotalInputTokens(assistant.usage)assistant.usage.outputTokensvalbaselineestimate(messages.subList(0,lastUsageIndex1))// 3. 合理性校验reported 必须在 [0.25x, 4x] 范围内if(baselineMIN_BASELINE_FOR_CHECK(reportedbaseline*LOW_REPORT_RATIO||reportedbaseline*HIGH_REPORT_RATIO)){returnestimate(messages)// 不合理回退到纯 tokenizer}// 4. 校准锚点之后的新消息用 tokenizer 估算增量valdeltadeltaAfter(lastUsageIndex,messages)returnreporteddelta}为什么需要合理性窗口场景问题窗口作用Anthropicmessage_delta不包含input_tokensusage 报告不完整LOW_REPORT_RATIO 0.25拦截缓存命中input_tokens只报告非缓存部分totalInputTokens input cacheRead cacheWrite报告异常膨胀cacheRead244,992 异常值HIGH_REPORT_RATIO 4.0拦截消息选择prefix compacted recent// selectMessages() 三段式选择privatefunselectMessages(messages,modelContextLength):Tripleprefix,compacted,recent{// prefix: System 消息 第一条 UserMessagevalprefixMessagesmessages.take(firstUserIndex1)// recent: 最近 tailTurns 轮默认 2 轮但不超过 preserveRecentTokensval(recentMessages,compactedMessages)selectRecentMessages(remainingMessages,tailTurns2,maxRecentTokenspreserveRecentTokens)returnTriple(prefixMessages,compactedMessages,recentMessages)}尾部 Token 预算内的增量裁剪// selectRecentMessages() 中的增量裁剪varrecentTokenstokenEstimator.estimate(recentMessages)while(recentTokensmaxRecentTokensrecentMessages.size1){// 增量减去头部一条消息不重新估算整个 tailrecentTokens-tokenEstimator.estimate(listOf(recentMessages.first()))splitIndexrecentMessagesrecentMessages.drop(1)}O(n) 复杂度——每次减去一条消息的 token 估算不是每次重新估算整个 tail。尾部结构保护ToolResult 配对这是最容易踩坑的地方。问题如果压缩后 tail 的第一条消息是ToolResultMessage而对应的AssistantMessage发出 tool call 的被压缩掉了压缩后[Summary, ToolResultMessage(searchxxx), UserMessage, ...] ↑ 孤立的工具结果LLM 不知道谁发出了这个调用LLM 看到孤立的工具结果会重新执行相同的工具调用——浪费 token 和时间。方案结构守卫// selectRecentMessages() 末尾的结构保护// 如果 tail 头部是 ToolResult向前扩展直到包含发出调用的 Assistantwhile(splitIndex0recentMessages.firstOrNull()isToolResultMessage){splitIndex--recentMessageslistOf(messages[splitIndex])recentMessages}测试用例验证了这个行为// ContextCompactionOrchestratorTestTestfunkeeps tool-calling assistant paired with its tool result even over budget(){valbigResultr.repeat(30_000)// 超大工具结果valmessageslistOf(user1,assistant1,toolResult1,user2,assistant2,toolResult2)valresultorchestrator.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()valagentContextAgentContext(agentIdcompaction-agent,modelConfigdisableThinking(context.modelConfig),// 关闭思考模式toolslistOf(variableTool),// update_variable 工具dryRuntrue,// 不持久化)valagentAgent(contextagentContext,servicesdryRunServices)valrunnerAgentRunner(agentagent,messagesmutableListOf())摘要输出结构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 注册变量工具toolslistOf(variableTool),// update_variabledryRuntrue,// 不持久化消息只收集变量// CompactionVariableTool无副作用只把变量收集到 AtomicReferenceinternalclassCompactionVariableTool(privatevaltoolCalled:AtomicBoolean,privatevalextractedVariables:AtomicReferenceMapString,String):BaseToolDefinition(ToolMetadata(nameupdate_variable,...))System Prompt 明确要求生成摘要后必须调用一次update_variable输出完整的变量集。三个工程细节保证提取的可靠性细节做法LLM 忘记调工具CompactionVariableCompletionCheck在完成前检查未调用则发送一次 nudge 提醒再给它一次机会多轮压缩后变量过时工具语义是全量替换——输出即完整变量集保留仍有效的、更新变化的、丢弃过时的超大数据表值支持 JSON 数组/对象序列化为字符串存储过大的值溢出到文件变量里只存[file: path]指针LLM 需要时用 read 工具加载变量的取舍边界也由 Prompt 明确只存数值/数据型事实价格、比率、ID、配置值、计算结果不存分析结论和叙述——那些属于摘要的职责。例如投资分析中的 EPS、PE、市值会进入变量卡片而建议买入这类结论只留在摘要里。提取出的变量通过compaction_end事件实时推送到前端渲染为变量卡片——用户可以直观看到会话当前持有哪些关键数据这也是压缩过程可观测性的一部分。配置参数// CompactionConfigdataclassCompactionConfig(valenabled:Booleantrue,valthreshold:Double0.8,// 80% 窗口触发valreservedTokens:Int10_000,// 预留响应 tokenvaltailTurns:Int2,// 保留最近 2 轮valpreserveRecentTokensRatio:Double0.25,// 保留 25% 窗口valminMessagesForCompaction:Int10// 至少 10 条消息才检查)性能数据指标数值100 轮对话压缩后 token120K → 30K压缩比 75%压缩耗时约 3-5 秒LLM 调用压缩后任务完成质量与未压缩基线无明显下降Token 估算偏差校准后 10%未校准 30%踩坑记录坑原因解法孤立 ToolResult压缩后 tail 头部是 ToolResult对应 Assistant 被压缩结构守卫向前扩展到包含 AssistantToken 估算偏差大Gateway 少报 input_tokens缓存命中时totalInputTokens input cacheRead cacheWrite压缩后 LLM 重新执行工具工具结果和调用者分离结构守卫保证配对摘要丢失关键变量纯文本摘要会丢失/变形数值数据update_variable工具提取到不参与压缩的会话变量注入 System Prompt压缩轮次不清多次压缩后不知道是第几轮isCompactionSummary元数据标记 计数总结维度直接截断全量摘要EasyAI 增量压缩信息保留差丢失早期好好增量更新输入有界是否随对话增长是只处理增量Token 精度N/AN/A校准后 10% 偏差结构安全N/AN/AToolResult 配对保护变量提取无无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。