先说这篇解决什么问题问题Agent 的对话历史越积越长必须压缩到模型上下文窗口里。现在主流做法是把历史写成事件日志用户消息、工具调用、工具结果、最终回复压缩时保留最近一段。社区里正在形成的共识是裁剪点必须对齐到轮次turn边界绝不能把一次工具调用和它的结果拆开。这个说法对不对对一半。它在什么条件下失效、失效时代价是什么没人给过数字。这篇就是补这个数字。适合谁读用 Java 写 Agent 后端、自己实现过或正准备实现会话记忆 / 上下文压缩 / 事件日志持久化的同学被模型收到孤儿 tool result 直接报错或prompt token 莫名超预算困扰过的同学。能收获什么一套可直接复制运行的确定性测台固定 seed、单机、纯 JDK、零第三方依赖5000 万条工具事件、1088 万次压缩窗口、三套 JDK 输出一致四种带边界条件的裁剪策略在同一批数据上的量化对比三个反直觉结论——轮次感知压缩 90.1% 的预算越界率、按墙上时钟排序如何把轮次边界打成 7719 条孤儿结果、会话键坍缩后每轮都变成跨会话编号以及一个关于追加日志帧格式的可量化取舍。近期背景为什么这个话题重新被讨论Spring 官方博客的 Agentic Patterns 系列把会话记忆描述成事件溯源event-sourced的短期记忆层并明确写了设计承诺压缩策略按轮次粒度工作保留窗口总是落在用户消息边界上因此模型永远不会看到孤儿工具结果或被拆开的交互spring.ioSpring AI Agentic Patterns Part 7: Session API。这个社区项目随后被并入 Spring AI 2.1 的版本节奏JavaRubberDuck 在 2026-10-25 的 Java 生态周报里整理了 Spring AI 2.1.0-RC1 的发布说明其中会话记忆append-only 事件日志 轮次感知的压缩 TurnCountTrigger与工具调用确认是两条主要新增腾讯云开发者社区在 2026-10-24 也有一篇中文解读。上述两篇是第三方整理具体类名与参数语义我以 spring.io 原文为准未核实到的实现细节一律不在正文里当作事实使用。承诺写得越干净越值得拿测台压一遍。下面是我自己的实测。一、实测环境与口径环境版本全部本机实采不是从别处复制的项实测值JDK主java version 17.0.18build 17.0.188-LTS-264JDK对拍java version 21.0.10build 21.0.108-LTS-217JDK对拍java version 1.8.0_481build 1.8.0_481-b10os.name / os.version / os.archWindows 11 / 10.0 / amd64file.encodingJDK 8、JDK 17 为 GBKJDK 21 为 UTF-8可用核数16随机种子主 seed20261030时间戳扰动 seed20261041触发器 seed20261037生成规模60000 会话 × 50 轮 300 万轮三种流形分别 1679.9 万 / 1664.0 万 / 1625.6 万事件压缩窗口数全部实验累计约 1088 万次换环境哪些数字会变所有costtoken 估算、keptCost、越界倍数、空窗率都依赖 token 估算口径换真实 BPE 分词器后一定变orphanRes、orphanCall、misplaced、dupCallId、turnSrcMix这类结构指标与 token 口径无关只依赖事件流形状理论上可跨环境复现。绝对耗时ms 列受本机负载影响不作为结论。复现判定看第十一节的命令与三条判定标准判定只看结构指标不看耗时。三套 JDK8 / 17 / 21上除耗时列外全部结果行逐字节一致。二、事件模型与四种裁剪策略我把会话写成 append-only 事件流一次工具密集的轮次长这样USER(用户消息) CALL(工具调用, cid101) RESULT(工具结果, cid101) CALL(工具调用, cid102) RESULT(工具结果, cid102) TERM(最终回复)cid是工具调用的配对标识。压缩的目标给定预算budgettoken 单位保留该会话日志的一段后缀使其成本尽量不超过预算。四种策略只在裁剪点落在哪上不同编号策略裁剪规则M0 朴素窗口事件粒度后缀从尾部累加超预算即切断不管落在轮次内部哪里M1 轮次感知整轮扩展把裁剪点向前推到所覆盖轮次的起点保证调用/结果不被拆开M2 轮次感知头部修补M1 之后若窗口首事件是无主的RESULT就丢弃若首轮内存在无结果的CALL补一条占位结果M3 只留完整轮整轮丢弃头部那半截轮次直接丢掉绝不保留部分轮次M4 整轮预算回退先装完整轮从尾部装能装下的整轮若最近一轮本身就超预算回退到 M0 的事件切点token 用估算而非真实分词器这是本文最大的口径限制第八节会量化它带来的误差// 中文约 1.5 字符/tokenASCII 约 4 字符/token只是估算不是 BPE static int costOf(int chars, boolean isCjk) { return isCjk ? (int) Math.round(chars / 1.5) : (int) Math.round(chars / 4.0); }三类关键指标的定义必须写清楚否则后面的数字无法解释orphanRes窗口内存在RESULT但它的CALL不在窗口内 —— 模型会收到没有调用的结果属于结构非法。orphanCall窗口内存在CALL但RESULT不在窗口内 —— 可能是裁剪造成也可能是数据源本来就没写结果工具超时、进程被杀后者是压缩层修不了的。pairSplit被裁掉的最后一个事件是CALL而它的RESULT留在了窗口里即调用/结果被切点劈开。生成器自检同一个 seed 跑三遍事件数、各类型计数、校验和完全一致16640219 / calls5401237 / results5238982 / danglingGen162255校验和三次相同。三种流形用p_result工具结果是否成功落库区分1.00无悬挂调用、0.9716.2 万条悬挂调用占调用数 3.0%、0.9053.95 万条占 10.0%。三、朴素窗口确实漏孤儿结果但比例远低于直觉p_result1.00数据完全干净只是被裁剪时裁剪把调用和结果拆开的概率随预算变化预算M0 orphanResM0 pairSplit窗口数40060660660000160011331133600004000117411746000080001161116160000两个可以直接写的结论。第一在数据干净p_result1.00的全部配置上orphanRes与pairSplit完全相等这时结果失去调用只可能由切点劈开调用/结果造成不存在第二种来源——这条对上了才说明测台没漏统计。第二绝对量级不大每 6 万次压缩里几百到一千多次命中率 1%2%但它是结构性非法一次命中就是上游一次请求可能失败不会因为平均率低就变得可接受。数据源不干净时orphanCall随悬挂调用率放大而压缩策略对它无能为力。同一份预算 4000 下的对比流形悬挂调用 / 调用数M0 orphanCallM1 orphanCallM0 orphanResM1 orphanResp_result1.0000011740p_result0.97162255 / 54012373.0%7989961311160p_result0.90539520 / 539778510.0%282413345911600这里就是第一个容易读错的地方orphanCall不是裁剪造成的孤儿它是数据本身缺结果工具超时、进程被杀、结果写回失败。而且 M1 的orphanCall比 M0更高3.0% 时 9613 vs 798910.0% 时 33459 vs 28241——把整轮拉回窗口时那些天生没有结果的调用也一起被拉回来了。结论很实际想让孤儿调用归零靠压缩层做不到只能在写入侧保证每个CALL最终有落库的RESULT或显式落一条失败结果。四、把裁剪点对齐轮次之后孤儿结果归零越界率 90.1%同一批数据p_result1.00、预算 4000 tokenM1 对比 M0指标M0 朴素窗口M1 轮次感知orphanRes孤儿结果11740pairSplit调用/结果被劈开11740overBudget越界窗口 / 6 万05406190.1%keptCost总保留 token2.067 亿3.223 亿×1.56越界倍数保留量 / 预算上限总额0.8611.343单窗口最大成本400011316承诺兑现了模型确实再也看不到孤儿结果。代价是预算在 90.1% 的窗口里被突破而且不是小幅超过——预算翻到 8000越界窗口数仍是 54075/6000090.1%预算压到 400 时M1 的总保留成本是 M0 的 9.8 倍1.305 亿 vs 0.133 亿越界倍数 5.439×单窗口最大 7807 token而预算只有 400。原因是确定的不是巧合裁剪点前推到轮次起点后头部那一轮是整轮塞进来的与预算无关。只要一轮里有一次大结果工具返回一大段网页、一次长日志检索这一轮的成本就固定大于预算M1 必然溢出。于是 M2头部修补的表现值得单独看在p_result0.97、预算 4000 这一格M2 修补了 3512 次头轮内悬挂调用为它们补了占位结果但窗口越界数没有下降54006 vs M1 的 53996多出的 10 次正是补进去的占位把窗口推过预算。修补解决的是结构合法性解决不了预算合法性而且不是免费的。这两件事必须分开承诺。五、绝不留半截轮次的极端形态空窗与回退M3只留完整轮看起来最严格、最安全实测代价最反直觉p_result1.00预算M3 空窗数 / 6 万空窗率M3 丢弃半截轮窗口数M3 keptCost4004286571.4%50870372 万16003059151.0%560552157 万4000650210.8%540611.418 亿预算 400 时七成以上的会话压完是空窗口模型收到一段什么都没有的历史而它在零越界、零孤儿的指标表里看起来是完美的。这就是只报越界率 0的评测会撒谎的地方。M4先装能装下的整轮装不下就回退到事件切点是唯一同时把两类失败压低的形态。预算 4000、p_result1.00越界 0、空窗 0、孤儿结果 191、回退 6502 次。也就是说要结构性合法就要么接受越界M1要么接受空窗M3要么接受回退后仍有少量孤儿M4。三者是可选的代价不是能同时要的零。把三种流形放在预算 4000 这一格上横向看M4 的稳定性更清楚流形M4 越界窗口M4 空窗M4 孤儿结果M4 回退到事件切点p_result1.00001916502p_result0.97001696118p_result0.90001445664二维拆解比总量更能说明问题把三列并起来看预算p_result1.00M0 孤儿结果M1 越界窗口M3 空窗400606508704286516001133560553059140001174540616502M1 的越界数几乎不随预算变化5.09 万 → 5.61 万 → 5.41 万因为它取决于轮次成本分布而不是预算大小M0 的孤儿结果同样几乎不随预算变化606 → 1133 → 1174因为它只取决于切点落进轮次内部的概率。这两列才是本篇的核心结论总保留 token 增加了多少只是它们的副产品。六、把事件按墙上时钟排序之后轮次感知当场失效真实系统里一次压缩前往往有一句按时间取最近 N 条。我做了一个最小扰动以 5% 的概率把事件的时间戳对齐到整十模拟并发写入者读到同一个时钟值共 84.0 万个事件时间戳相同然后比较三种排序键。预算 4000M1 轮次感知排序键misplaced逆序相邻对mispair结果早于调用orphanResorphanCall越界窗口追加序号 seq000054061时间戳 seq 兜底稳定0133727719811139252只有时间戳相等交给排序实现70963133727719811139252结论值得加粗轮次感知压缩的正确性前提是日志顺序 写入顺序。一旦按墙上时钟重排同一轮的事件被拆散M1 的孤儿结果从 0 涨到 7719比朴素窗口 M0 在同一排序下的 7200 还好不了多少。换句话说turn-aware这个保证是被排序键决定的不是被策略名字决定的。顺带两个细节。其一只按时间戳排序时84 万个相等时间戳产生了 70963 处逆序相邻对换成时间戳 序号兜底后这一列归零——排序比较器里那行 tie-breaker 才是真正做事的地方。其二时钟排序下越界窗口从 54061 降到 39252因为窗口不再装整轮反而更容易塞进预算。所以越界变好了不是改进是同一条保证被破坏了以后剩下的副作用。看单指标会得出反向结论这正是二维拆解比总量有用的原因。七、会话键坍缩每轮都变成跨会话的轮次号Spring AI Session 那篇原文特意强调每次调用必须显式传 session id没有默认会话避免多用户掉进同一段上下文。我把这个约束反向压了一次6 万个会话的写入仍然各自有序但存储键取session % 256相当于 60000 个会话坍缩到 256 个键上轮次号用会话内的序号跨会话必然相同工具调用 id 从一个只有 32 个取值的池里取。键方式压缩窗口数混入多会话的窗口同轮号跨会话同窗口内重复调用 id每会话一列对照6000000060000 会话 → 256 键25624696.1%4625933三点解读。第一96.1% 的压缩窗口混着多个来源会话的事件——键一坍缩这段上下文是谁的就不可回答了。第二dupCallId933同一个窗口里出现两次相同的调用 id如果下游用Mapcid, 来源记幂等第二次写入静默覆盖第一次这就是幂等键没生效的机制成因而不是概率问题。第三最危险的是turnSrcMix4625轮次编号在不同会话间完全重合于是任何按轮号对齐边界的实现都会把不同会话的事件认成同一轮轮次感知的裁剪逻辑在这种存储上不是失效而是主动制造错配。作为对照把会话数缩到 256每会话一个键、无坍缩时上述三列全部为 0。这排除了是裁剪逻辑本身有问题的可能——问题在键空间。八、事件日志的帧格式换行分帧与长度前缀CRC的真实差距压缩依赖事件日志可恢复。我造了 20 万条记录的 append-only 文件分别在写完第 26000 / 94000 / 158000 条时故意只写入半条记录后停笔模拟掉电再用两种读取方式恢复。下表取截断在第 94000 条那一格帧格式读到的行数恢复出的完整记录撕裂记录检出说明换行分隔 JSON94001940001靠最后一行不是合法 JSON半截尾部粘在上一条记录的行尾长度前缀(4B)CRC3294000940001靠声明长度不足/CRC 不匹配与记录内容无关不需要 JSON 解析器三个截断点位26000 / 94000 / 158000的换行分帧读数分别是 26001 行 / 94001 行 / 158001 行恢复记录数 26000 / 94000 / 158000strictReject全部为 1半截尾巴总是并进上一条记录的行里所以行数会比完整记录数多一行而多出来的那一行不是合法 JSON。字节数同样截断在第 94000 条的序列换行分帧落盘 5,614,527 B长度前缀CRC 落盘 6,272,531 B多 11.7%。真正的差距不在能不能发现损坏两种方式都发现了而在发现损坏的前置条件换行分帧要求解析器真的去做一次 JSON 合法性判定只做line - 取字段的宽松读法会把合法记录 半截尾巴当成一整条放过。长度前缀 CRC 的判定只依赖声明长度够不够、校验和对不对得上不依赖内容语义。有一个猜想我没能让它成立如实记在这里我以为撕裂恰好停在}前面会让换行分帧完全无法察觉。实测这个点位表中 TAIL-BRACE 构造最后一行同样多出一个尾巴、行数 94001仍然表现为行内容与完整记录数不一致我的严格校验器照样拒绝strictReject1suffixGarbage0。也就是说我构造的场景不足以复现那个盲区这条不作为结论只在边界一节里标明未定位清楚。九、压缩触发器的检查-执行间隙峰值可到 maxTurns 的 5.9 倍TurnCountTrigger(20)这类触发器是读计数 → 判断 → 删最老的 N 个。如果读和写之间有间隙异步执行、批量提交、跨线程间隙里到达的轮次不会被计入。模拟 18 组配置、每组 20 万次到达-判定迭代maxTurns8除末行外每周期到达轮次上限间隙以到达槽位计峰值轮次数峰值 / maxTurns1081.01291.115121.545273.48081.082232.985475.98maxTurns165543.4maxTurns约束的是每轮压缩的删除量不是活动上下文的上界。并发写入者越多、压缩在途时间越长峰值越高——按这张表8 个写入者 5 个槽位的间隙就能让标称 8 轮的窗口实际跑到 47 轮。要拿到硬上界只能把追加和计数判断放进同一个临界区或用带版本号的 CAS 写入不能靠调低maxTurns。十、token 估算口径会带走多少结论本篇唯一不是结构性的自变量是 token 估算。把中文口径从1.5 字符/token改成2.5 字符/token同一份 1679.9 万事件、预算 4000策略越界倍数1.5 口径越界倍数2.5 口径孤儿结果1.5孤儿结果2.5空窗1.5空窗2.5M0 朴素0.8610.9171174182800M1 轮次感知1.3431.2200000M3 只留完整轮0.5910.752006502114M4 整轮回退0.6770.7541911200越界倍数和空窗率是口径敏感的换真实分词器必须重测而轮次感知让孤儿结果归零、按时间排序打破轮次边界、会话键坍缩导致轮号跨会话重合这三条与口径无关。引用本篇时请只引用后一类。十一、适用边界与风险提示本篇的成立范围单机、单进程、纯 JDK 实现的确定性测台没有接入任何模型服务也没有真实业务会话。所有孤儿结果的下游后果请求被拒、计费异常都是结构推断不是端到端实测。事件流形态由生成器给出每轮 04 次调用、结果长度 2003000 字符、10% 概率无工具调用。换一种轮次成本分布越界率和空窗率的具体数值一定变方向轮次成本尾部主导应不变。token 用字符数估算见第十节不是 BPE。时间戳扰动是人为注入的时钟粒度问题5% 事件对齐到整十。真实系统里等价成因包括同毫秒并发写入、跨节点时钟回拨我没有分别定位这些成因各占多少。会话键实验里轮次号取会话内序号、调用 id 取 32 个值的小池子是构造出来的坍缩条件用来回答键错了会怎样不代表任何真实存储会这样配置。帧格式实验只做了尾部截断掉电语义没有实现写入中途进程被 kill 后残留半条 之后继续写新记录的交错场景后者才是换行分帧最坏的情况我没测。触发器实验是模型推演把在途间隙抽象成固定槽位数不是多线程真实调度下的测量。第八、九两节的机制结论可信数值只作量级参考。未单独实验、只作为方向提示的点占位结果M2 里补的那条对模型后续回答质量的影响跨分支多 Agent场景下的轮次边界定义JDK 对象数组排序在相等键上的实际稳定性行为我只观测到结果没去读 TimSort 实现来定性。风险操作提示本篇所有写入都在工作区临时目录内事件日志用File.createTempFile用完即删没有修改任何 JDK 安装目录、信任库或系统配置。测台的 token 估算与流形参数如果照抄到你自己的生产数据上请先只跑结构指标、不要据此改线上预算。AI 辅助说明本文代码Exp1.java与全部实验均在本机实跑所有数字直接取自三次运行JDK 8 / 17 / 21的输出文件未手工填写或推算任何预期结果结论与数据不一致的地方例如第八节那个未能复现的盲区猜想按实际输出写明。十二、完整复现命令与判定标准# 目录结构exp1/src/Exp1.java输出到 stdout # Windows Git BashJDK 路径按本机替换 mkdir -p out17 out21 out8 D:/installSoftware/jdk-17.0.18/bin/javac -encoding UTF-8 -d out17 src/Exp1.java D:/installSoftware/jdk-21.0.10/bin/javac -encoding UTF-8 -d out21 src/Exp1.java D:/installSoftware/jdk1.8.0_481/bin/javac -encoding UTF-8 -d out8 src/Exp1.java D:/installSoftware/jdk-17.0.18/bin/java -XX:PerfDisableSharedMem -cp out17 Exp1参数都可用-D覆盖-Dsessions60000 -Dturns50 -Dmaxcalls4 -Dwalrec200000 -Dgroups256 -Didspace32主 seed 固定为20261030。判定标准对不上先怀疑测台不要怀疑结论①生成器自检连跑三次R|runevents / calls / results / danglingGen / checksum必须逐字节相同本机为16640219 / 5401237 / 5238982 / 162255。若不同先检查是否在多个线程里共享了同一个Random。②M0 一致性数据干净p_result1.00时orphanRes必须恒等于pairSplit。本机预算 400/4000/8000 分别为 606/1174/1161 且两列相等。若两列不等说明结果失去调用存在第二种来源测台统计口径有误。③M1 的两条同时成立p_result1.00下orphanRes0且pairSplit0同时overBudget落在 6 万窗口中的 5.4 万上下本机 54061。若orphanRes0而overBudget接近 0几乎一定是裁剪点扩展写丢了只对齐到了轮次的中间而不是起点。④跨 JDK三套 JDK 的输出除ms列外应逐行一致本机用diff去掉注释行与ms字段后确认完全相同。若不一致最可能是排序实现的 tie-breaker 差异回看第六节。十三、带走三条判断第一对齐轮次边界从来不是一个零代价的选项它把结构性非法孤儿结果换成了预算性非法90% 窗口越界。写方案时要明确你在向下游承诺哪一件别在文档里同时承诺两件。第二轮次感知的正确性挂在排序键上不挂在策略名上。任何按时间取最近 N 条的读写路径都会让轮次边界统计上失效比较器里必须留seq兜底。第三会话键和轮次号是同一件事的两层键坍缩时轮次号会在不同会话之间重合此时按轮号做的对齐不是无效而是有害。一个具体自检动作把你现在生产环境的会话事件表拉出来跑一条 SQL——select session_key, turn_no, count(distinct user_id) from events group by 1,2 having count(distinct user_id) 1再看结果行数。非零就说明你的轮次标识不唯一第六、七节的失效路径对你成立。再抛两个我到现在还没定下来的问题如果你有实践欢迎评论区讨论你们的压缩是在写入路径上同步做还是异步做异步的话计数与删除之间的间隙用什么收口我目前只想到把两者放进同一临界区但这会拖慢高频追加。用整轮扩展压缩时你怎么处理单轮本身就超预算的尾部我的三个选项各有硬伤暂时选了带事件级回退的那一个但它保不住零孤儿这条承诺。如果你也在做 Agent 的上下文层这篇的测台单文件、纯 JDK、固定 seed、三套 JDK 输出一致可以直接改造成你的流形参数——把第十一节的边界和第十二节的判定标准一起收藏会比只收藏结论更有用。