news 2026/8/24 19:42:18

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
100+ 轮对话不丢上下文:增量压缩的工程实践

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用户感觉响应变慢,主动压缩
OverflowLLM 报错后紧急压缩,压缩后立即重试

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

这段提示词形成了完整的闭环:

  1. 先查表再回答:LLM 需要早期讨论过的数据时,必须先查变量列表,而不是依赖对话"记忆"——那个"记忆"可能已被压缩掉;
  2. 权威值:列表中的变量是唯一可信来源,禁止编造和近似;
  3. 未提取的数据引导重新获取:如果需要的数据不在列表里(压缩时未被提取),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 轮对话压缩后 token120K → 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/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。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/24 19:42:00

多智能体辩论中的记忆掩码技术:原理、实现与调优

1. 项目概述&#xff1a;当AI学会“选择性遗忘”来辩论最近在折腾多智能体&#xff08;Multi-Agent&#xff09;系统&#xff0c;特别是让多个大语言模型&#xff08;LLM&#xff09;坐在一起“开会”或“辩论”的场景。这听起来很酷&#xff0c;但实操起来&#xff0c;问题一大…

作者头像 李华
网站建设 2026/8/24 19:39:01

G-Helper风扇控制:5步调出ROG笔记本最安静的散热曲线

G-Helper风扇控制&#xff1a;5步调出ROG笔记本最安静的散热曲线 【免费下载链接】g-helper Lightweight Armoury Crate alternative for Asus laptops with nearly the same functionality. Works with ROG Zephyrus, Flow, TUF, Strix, Scar, ProArt, Vivobook, Zenbook, Exp…

作者头像 李华
网站建设 2026/8/24 19:34:27

Java技术栈面试核心:Spring Boot与AI工程化实践

1. 互联网大厂Java技术栈面试全景解析最近三年&#xff0c;头部互联网企业的Java技术面试已经形成了相对固定的考察模式。根据我参与过的近百场面试评审经验&#xff0c;现在的技术考察主要聚焦三个维度&#xff1a;基础能力&#xff08;Java核心数据结构算法&#xff09;、框架…

作者头像 李华
网站建设 2026/8/24 19:33:13

从文生3D到可探索世界:Lyra 2.0如何革新3D内容生成范式

最近在整理一些3D生成相关的项目时&#xff0c;一个名字反复出现&#xff0c;让我停下了手里的活。不是因为它宣称的参数有多高&#xff0c;也不是因为它渲染的图有多“炸裂”&#xff0c;而是它标题里一个看似不起眼的词——“Explorable”。这个词&#xff0c;在当下这个“卷…

作者头像 李华
网站建设 2026/8/24 19:32:46

Dism++系统维护实战指南:3步离线装机与磁盘清理搞定瘦身

Dism系统维护实战指南&#xff1a;3步离线装机与磁盘清理搞定瘦身 【免费下载链接】Dism-Multi-language Dism Multi-language Support & BUG Report 项目地址: https://gitcode.com/gh_mirrors/di/Dism-Multi-language C盘告急、系统越用越卡、装系统又得反复折腾&…

作者头像 李华