1. 为什么要做上下文管理模型是没有记忆的。每次请求你都要把系统提示词 全部历史消息 工具定义等内容全部发过去。而模型的窗口是有限的即它一次能看见的内容是有限的一旦超过了窗口大小请求会直接失败。这就是为什么需要上下文管理消息历史可以无限增长但单次请求不能。上下文不是均匀膨胀的。构成上下文的内容中工具输出的体积是最大的。一轮“读 20 个文件再改代码”的任务中工具输出能占 90% 以上。所以压缩策略的重点不是平均用力而是盯着工具输出。此外现在的主流服务都有前缀缓存请求前缀不变就便宜。这意味着任何改动历史前缀的操作都有隐形代价不只是变短了就省钱还有缓存断了要全价重算。这个事实直接塑造了我的 harness 的防火墙和驱逐设计甚至导致一次失败的实验。2. 请求前的检查用户输入先过一道前置检查validate_user_input确认单条用户消息本身能放进预算放不下直接拒收并提示 “存成文件发路径” 而不是截断用户需求try:context_manager.validate_user_input(prompt)exceptUserInputTooLargeasexc:screen.add_entry(tool,fInput too large ({exc.estimated_tokens}/{exc.max_tokens}tokens). Save large text as a workspace file and send its path instead.,)screen.restore_submitted_draft()return然后在 AgentLoop.run() 的循环里每次请求模型前都会调用build_context组装要发送给模型的上下文forforce_compactionin(False,True):ifbuild_contextisnotNone:context_resultawaitbuild_context(context,force_compaction)request_messagescontext_result.messagestry:...请求模型exceptAgentErrorasexc:ifexc.category!context_overfloworforce_compaction:raise3. 压缩记录假设 JSONL 里存了 500 条消息。某次请求前估算发现全发会超窗口于是把前 400 条压成一段摘要实际只发摘要 后 100条。但 10 分钟后又要请求一次模型。此刻问题来了ContextManager怎么知道前 400 条已经被摘要掉了模型服务器不记得任何事你这次发什么它就看什么。如果没人记住上次压缩的结论这次就得把 500 条重新估一遍、重新摘要一遍每次请求都白花一次摘要的钱而且摘要结果每次还不一样。所以压缩的结论必须存下来。存下来的那个东西就是压缩记录CompactionRecord一共三个字段dataclass(frozenTrue)classCompactionRecord:summary:str# 摘要文本first_kept_message_index:int# 从完整历史的第几条开始保留原文比如 400tokens_before:int# 压缩前估算的 token 数用于界面展示它作为一行带type: compaction的 JSON 追加进会话文件。注意它不是一条消息是一行备注记着“从第 400 条起才有原文前面那些的内容在 summary 里”。我展示一下我真实的压缩记录具体内容太多了用省略号省略{ type: compaction, summary: ## Goal\n- 原始任务...\n- 用户背景...\n- 用户痛点...\n\n ## Progress\n...\n\n ## Key Decisions\n...\n\n ## Next Steps\n1. ...\n\n ## Critical Context\n...\n\n read-files\n- ...\n/read-files\n\n modified-files\n\n/modified-files, first_kept_message_index: 85, tokens_before: 84672 }下次请求时压缩记录怎么被用每次请求前build_for_model_result在build_context中调用是组装上下文的核心都拿到两样东西完整历史消息 压缩记录列表。然后调_apply_latest_compactionlatestcompactions[-1]# 取最新一条压缩记录kept_messagesmessages[latest.first_kept_message_index:]# 要保留的原文returnsystem[Conversation summary:\n{latest.summary}]kept_messages输出就是这次真正发给模型的列表。模型看到的是“system消息 1 条摘要 保留的原文”。追问历史越来越长first_kept_message_index 会跟着变吗会变而且每次压缩都重新算。这里要先建立一个正确的因果first_kept_message_index不是决定保留多少原文的输入而是选完保留段之后记下来的结果决定保留多少的是keep_recent_tokens 20000这个预算。压缩时先按预算选出要保留的原文怎么选见第 6 节选中段的起点在完整历史里的下标才被写进压缩记录。接下来用时间线看两次压缩之间发生了什么。假设第一次压缩就是上面真实发生的那次第 1 次压缩历史 93 条估算 84,672 84,000触发。从最新往回装满 20k选中下标 85~92 这 8 条 → 落盘记录 Afirst_kept 85。之后每次请求视图 摘要 A 下标 85 之后的全部原文。此后每新增一条消息视图就长一点。历史涨到 200 条时视图 摘要 A 下标 85~199 共 115 条原文。115 条涨到估算超 84,000假设 115 条触发压缩 → 触发第 2 次压缩。第 2 次压缩还是同一个函数从最新往回装满 20k。这时候选只有下标 85~19985 之前的早就被摘要 A 吃掉了比如选中了 172~199 这 28 条 → 落盘记录 Bfirst_kept 172。生成 B 的摘要时A 的摘要会作为输入的一部分传进去_summary_source累计摘要所以 85 之前的信息仍在 B 的摘要里延续。从此以后每次请求视图 摘要 B 下标 172 之后的原文。A 那条记录从此不再参与组装。每次只用compactions[-1]也就是最新的一条。A 留在文件里只当存档。新记录的起点只会往后挪第 2 次压缩时可选的对话全部来自旧起点 85 之后新起点必然 ≥ 85单调递增是结构保证的。所以无论历史涨到 1 万条还是 10 万条模型任何时刻看到的都是“一段摘要 最近约 20k 的原文”永远装得进窗口。一句话总结压缩记录是压缩结论的持久化备注。历史永不改写每次组装都是从“全量历史 最新一条备注”现场重放出的视图。4. 一个 Turn 内的组装与周转一个 Turn 里AgentLoop通常会请求模型很多次每次工具调用完再请求每一次请求前都要把即将发给模型的消息进行组装由注入的build_context回调完成上文说过new_compactions[]# 本轮运行中新生成、还没写入文件的压缩记录asyncdefbuild_context(messages,force_compaction:bool):resultawaitcontext_manager.build_for_model_result(client_holder.client,messages,[*session.get_compactions(),*new_compactions],# ← 关键行...)ifresult.compactionisnotNone:new_compactions.append(result.compaction)# ← 关键行returnresultcontext_manager.build_for_model_result的输入输出输入完整历史 messages 压缩记录列表 驱逐记录列表 输出ContextBuildResult .messages → 这次发给模型的消息列表system 摘要 保留原文 .compaction → 本次新产生的压缩记录如果发生了压缩否则 None .eviction → 本次新产生的驱逐记录如果触发它内部按顺序做五件事原始历史 已有压缩记录 已有驱逐记录 ↓ 1. 回放视图_apply_latest_compaction ∘ _apply_evictions 当前模型可见视图 ↓ 2. 驱逐判定默认关闭 ↓ 3. build()估算 ≤ 阈值→ 直接返回 ↓ 超了 → 4. 压缩摘要 ↓ 摘要失败 → 5. 规则裁剪兜底按结果可以概括成三个分支没超预算绝大多数情况加上系统提示词直接返回不压缩超了走压缩流程——先选出保留段然后把被摘掉的旧消息另发起一次独立的模型请求去生成摘要generate_context_summary这就是唯一真正“交给模型压缩”的动作它是一个很小的请求摘要提示词 序列化成文本的旧消息成功后返回“摘要 保留原文”摘要失败规则裁剪兜底再看代码里标出的两个关键行第二个参数把文件里已存的旧压缩记录 本轮内存里的新压缩记录拼在一起传进去。为什么拼假设本轮第 1 次请求触发了压缩产生了新记录。第 2 次请求时这条新记录还没写文件session.get_compactions()里没有它不拼上去的话ContextManager就会以为从没压缩过把 500 条全量历史拿来重新处理压缩成功时把新记录先放进new_compactions暂存等本轮agent_loop.run()整体结束再把它落盘_persist_new_messages(session,result.new_messages)# 先写本轮新增的消息_persist_compactions(session,new_compactions)# 再写压缩记录_persist_compactions就是个循环逐条调session.add_compaction追加进 JSONL。之后进程哪怕重启Session.restore()读回消息和压缩记录就能重建出一模一样的视图。为什么必须拼走一遍数字时间线500 条历史本轮要调 3 次工具即 4 次模型请求第 1 次请求build_for_model_result(500 条消息, compactions[]) → 估算 90k超了 84k → 压缩产生记录 Afirst_kept430含摘要 → 返回摘要 第 430 条后的原文发给模型 ← 模型正常干活 → ui 把 A 暂存进 new_compactions [A]注意此刻 A 只存在于内存列表里没写进 JSONL落盘要等整个agent_loop.run()结束才统一做。第 2 次请求工具执行完历史涨到 505 条传对compactions session.get_compactions() new_compactions [] [A] [A] → 按有 A 的状态组装摘要 第 430 条后的原文 ✓ 传错只传 session.get_compactions() [] → ContextManager 看到空列表 → 从没压缩过 → 把 505 条全量历史拿来 → 估算又超 → 又触发压缩 → 又花一次摘要请求的钱产生一条多余的记录 B → 而且刚生成的 A 白做了“以为从没压缩过”就是这个意思不是它失忆是它的世界观完全由参数决定你传了空列表它的世界里就没有 A。一句话总结ContextManager是无状态的它的世界观完全由每次调用传进来的列表决定所以 Turn 内靠内存列表周转压缩结果Turn 末统一落盘读的时候拼上内存里的新记录写的时候攒到收尾。5. Token 怎么估预算模型三个数DEFAULT_CONTEXT_BUDGETContextBudget(100_000,16_000,20_000)# context_window100kreserve_tokens16kkeep_recent_tokens20k# compaction_threshold 100k - 16k 84kreserve_tokens是给模型回复留的空间keep_recent_tokens是压缩时保留多少最近原文。注意预算不只算消息_message_budget扣掉了工具定义 JSON 和系统消息的开销每条消息另有 4 token 协议开销MESSAGE_PROTOCOL_TOKENS。估算的第一个层次是字符估算estimate_text_tokens宽字符中日韩、emoji按 1 字符 1 token其余 4 字符 1 token。不接 Tokenizer够用且纯函数可测试。估算的第二个层次是真实 usage 回填用服务端返回的精确用量代替本地估算。为什么需要它要从每次请求前的那次检验说起追问每次请求前都要检验总量是否超限那旧消息的 token 是不是每次都要重新算解释一下这个追问假设经历一次压缩后first_kept_message_index为 85现在历史涨到了 99 条。下一次请求前我们要检验“系统内容 摘要 第 85 条之后的全部原文”总共多少 token、有没有超过 84k。这个检验需要知道每条消息的 token 数。那么第 86~99 条这些早就存在、可能早就算过的消息是不是每次请求都要用字符估算公式再算一遍系统提示词、压缩摘要这些每次请求都在场的内容岂不是每轮都重算历史越长这个重复计算岂不是越滚越大先说一个事实模型被请求的次数远大于 user 消息的数量。Agent Loop 里模型的回复可以携带工具调用——工具执行完、结果追加进历史后会立刻再次请求模型中间不需要任何新的 user 消息。一个 Turn 里工具调 20 轮模型就被请求 20 次。也就是说历史里每一条 assistant 消息都是一次模型请求的产物有多少条 assistant 消息就发生过多少次请求。再纠正一个容易搞混的地方不触发压缩时根本不存在“从最后往回选保留消息”这个步骤。往回选满 20k 只在压缩触发那一刻执行日常请求是把first_kept_message_index后面的所有原文原样全发检验的也只是总量。而检验总量时旧消息的 token 不需要重算答案藏在每次模型响应的返回值里。服务端每次响应都会附带这次请求的真实用量输入消耗了多少 tokenprompt_tokens、这次输出了多少completion_tokens。我们把这组数连同当次请求输入内容的哈希指纹一起记在这次请求的那条 assistant 回复上随消息落盘。注意只能记在 assistant 消息上用量是服务端对“一次请求”的回报只有模型回复那一刻才产生tool 和 user 消息上永远不会有这个字段。看一个真实的例子{ type: message, role: assistant, content: , reasoning: 用户要求查看 pyproject.toml 声明了哪些依赖。这是一个只读操作。让我先找到这个文件。, tool_calls: [ { call_id: call_00_4GCXbAQHDAl52xsrdA757676, name: list_files, arguments: {path: .} }, { call_id: call_01_HK2A0dGBClzwa19drjsO0065, name: read_file, arguments: {path: pyproject.toml} } ], usage: { prompt_tokens: 2004, completion_tokens: 94, total_tokens: 2098, cached_tokens: 640, cache_miss_tokens: 1364 }, request_fingerprint: d1fafe052c4d98b6a2c203f6376f37c2c26c4f68fd71af17da4a07861bb59cee }下次估算总量时从最新消息往回找到最近一条带用量的 assistant 消息就称它为锚点把锚点前面的消息再算一次哈希和存的指纹比对指纹相同说明这段前缀从那次请求到现在一个字没变过新消息只会追加在锚点后面动不了它前面的内容那么服务端当初数出的精确值就直接是这段前缀的 token 数。于是下一次请求的估算 锚点消息上存的 prompt_tokens精确值覆盖锚点之前的全部 锚点消息自身的字符估算它是那次请求的输出不在自己的 prompt_tokens 里 锚点之后新增几条的字符估算第 N 次请求发出 ↓ 模型回复 assistant 消息内容我需要看一下这个文件带 tool_calls: read_file← 这就是锚点 ↓ 执行工具 ↓ 工具结果追加为 tool 消息roletool ← 排在锚点之后 并行调了 3 个工具就是 3 条 tool 消息 ↓ 第 N1 次请求告诉模型工具调用结果前估算总量 ← 此刻锚点之后多了 1~3 条 tool 消息由于每一轮工具调用都会刷新一次锚点锚点离最新消息通常只隔一两条。每次请求真正要自己算的就只有这一小段。prompt_tokens 是累计值而不是增量第 N 次请求的输入已经包含了之前所有轮次的内容所以永远只需要取最近一次的值不需要把历次用量加总。指纹校验还顺带防了一个坑一旦前缀被压缩摘要替换过、或被驱逐改写过指纹立刻不匹配旧用量自动作废退回全量字符估算。真正需要逐条重算的只剩两种情况一是触发压缩、要重新往回选保留段的那一次但压缩几十轮工具调用才发生一次属于低频成本。二是找不到任何可用用量比如会话第一次请求或者压缩刚发生后还没请求过、旧锚点指纹全部失效。日常路径上每次请求自己计算的 token 量是常数只有锚点之后新增的那几条历史再长也不变。6. 压缩怎么切对话组和工具链首先要建立一个正确的因果first_kept_message_index不是决定保留多少原文的输入而是选完保留段之后记下来的结果——决定保留多少的是keep_recent_tokens 20000这个预算。先按预算选出要保留的原文选中段的起点在完整历史里的下标才被写进压缩记录。select_recent_messages不按单条消息切而是先把历史按用户消息边界分成对话组_conversation_groups从最新往回整组拿groups_conversation_groups(conversation_messages)# 按用户消息边界切成组selected_groups[]selected_tokens0forgroupinreversed(groups):# 从最后一组往回走group_tokens_estimate_messages(group,...)ifselected_groupsandselected_tokensgroup_tokensmax_tokens:break# 再装就超过 20k停selected_groups.append(group)# 没超过 20k就整组装进来selected_tokensgroup_tokens# 更新待保留原文的 token 数max_tokens就是min(keep_recent_tokens, 消息预算) ≈ 20k。从最新的对话组往回一组一组装装到再装一组就要超 20k 为止。然后把这个“选中段的起点”换算成完整历史里的下标——这就是压缩记录里那个字段的来源def_first_message_index(messages,selected_messages):selected_ids{id(m)forminselected_messages}forindex,messageinenumerate(messages):# 在完整历史里从头数ifid(message)inselected_ids:returnindex# 选中段最早那条的下标整组拿的原因assistant 的 tool_call 和 tool 的结果必须成对出现切在中间会产生服务端直接拒绝的非法消息序列。配套还有两个函数处理边界情况_has_valid_tool_chain检查call_id和结果双向配对。_remove_unpaired_tool_messages兜底裁剪后清掉落单的调用/结果。单轮超大是个特殊情况第二版补的最新一轮自己就超过keep_recent_tokens如果整体保留就超预算如果整体丢弃就丢需求。_split_oversized_latest_turn从这轮的尾部往前找“预算内且工具链完整”的最长后缀保留原文前面的部分单独生成一条前缀摘要。最终模型看到历史累计摘要 本轮前缀摘要 本轮后缀原文7. 请求模型对部分上下文做摘要发一个独立的模型请求让它把这次被摘掉的旧消息写成一份总结。上文贴的那条真实CompactionRecordsummary 字段里那段## Goal\n- 原始任务阅读当前项目…就是那次请求的输出。比如这次被摘掉的是下标 0~84 条我们做了几件事情把这 85 条消息序列化成纯文本_serialize_messages[user] 帮我把这个项目的上下文管理梳理清楚[assistant] 我先看一下目录结构[tool_call] list_files({“path”: “.”}) idcall_01_xxx[tool] src/ design/ tests/ ……85 条全部包进一个新请求system: 你是上下文摘要助手只根据给定的历史生成结构化摘要不要继续回答历史中的问题必须严格包含以下标题## Goal / ## Progress / ## Key Decisions / ## Next Steps / ## Critical Contextuser: 上面 85 条的文本模型回复的那段文本 summary存进CompactionRecord这是一个和主对话完全无关的小请求主对话在等它完成之后才继续。就这么简单。但如果只有这个裸动作会撞上三个坑坑 1第二次压缩时第一次的摘要会被扔掉 → 累计摘要第 1 次压缩0~84 条变成摘要 S1。第 2 次压缩被摘掉的是 85~171 条。如果只把 85~171 交给模型S1 就没人管了。S2 只描述 85~171 的内容而“用户最初要干嘛”这件事只存在于 S1 里。压缩两次之后最早的任务目标从模型的世界里彻底消失它开始答非所问。解决把 S1 作为第一条消息拼在 85~171 前面一起给模型让它生成“合并版” S2。最早的目标就这样一路传递下去虽然有损但压多少次都不丢。坑 2模型不写摘要而是接着历史继续干活 → 结构校验你丢给模型的是一段对话文本模型的天然倾向是继续这段对话。比如被摘掉的历史结尾是“帮我读一下 pyproject.toml”模型很可能直接回“依赖如下openai、prompt-toolkit……”。这不是摘要是它替用户把活干了。把这种东西塞回上下文模型以后会把它当成“自己说过的话”任务就串线了。解决两道关第一道在提示词里“不要继续回答历史中的问题” 五个标题第二道在代码里_is_structured_summary检查回复里五个标题字符串是否齐全不齐就判定失败换一个更严厉的 retry 提示词再请求一次。上文真实那条摘要以## Goal开头、五节齐全就是这两道关筛出来的结果。坑 3摘要不会替你记文件路径 → 文件清单被摘掉的旧消息里有大量read_file/edit_file调用。摘要倾向记“做了什么决定”例如“调整了配置读取逻辑”不倾向罗列路径例如“改过config.py和settings.py”。后果第 2 次压缩后模型要继续改config.py但它不知道自己上一轮已经改过这个文件、改到了什么状态因为那个信息在被摘掉的原文里。解决这件事根本不交给模型_collect_file_operations用代码扫全部历史把执行成功的读写路径收集、去重然后用字符串拼接把两个部分放在摘要末尾read-files - pyproject.toml - src/core/context.py /read-files modified-files - src/core/ui.py /modified-files判断“这是不是一个文件读/写工具”用的是工具定义上的capability元数据file.read/file.write不是硬编码工具名所以 MCP 接进来的自定义文件工具也能被统计。确定性的信息路径列表用确定性的手段代码扫描保证模型只负责它擅长的语义总结。摘要请求自己的输入也有预算。最初的做法取消息预算的一半≈40k超出时只把最近的一段送进摘要请求并在开头声明省略。这个机制本身是对的摘要请求也是个模型请求输入不能无限大问题出在一半这个数上。发现问题的起点是真实摘要里的一句话“原始任务描述被省略、无法从当前历史恢复”。回头算了一笔账触发压缩时总量约 84k保留最近 20k 后被压部分起步就是 64k天然是 40k 预算的近两倍。也就是说超出摘要预算不是异常情况而是每次压缩的常态那次真实压缩85 条被压消息67 条是工具输出估算约 80k摘要请求只收到了最近约 40k最前面的任务起点被直接砍掉模型没看到只能如实声明无法恢复。而且这不是一次性的损失每压一次丢一次被压历史的前半段永远进不了摘要。后来改了两处预算从消息预算的一半改成 floor(0.85 × 窗口)摘要输出预留。被压部分的典型大小是 64k~80k新预算全量装得下正常压缩一句不省。留 15% 余量而不是顶格是因为字符估算对源码类文本会偏低估错一次摘要请求就是硬 400 报错而这恰是超限会话的救命请求余量必须有但 15% 够了不需要原来的 50%。真放不下时被压部分超过新预算只有极端超长的会话才会不再只送最近段 声明省略改成折叠式多窗口摘要按消息边界切成若干个装得下的窗口逐窗口调摘要后一窗的输入携带前一窗产出的 summary 迭代更新。服务端报超窗就把窗口对半缩小重试下限 16k。改动之后两种情况都不再丢内容正常压缩绝大多数全量送入那句原始任务描述被省略不会再出现极端超长时走折叠代价从丢掉前半段信息变成多花几次摘要请求。省略声明现在只保留给一种场景单条消息本身就超过预算比如用户粘贴了一个超大文件这时按比例截断单条并声明而不是放弃整段历史。8. 摘要失败不落盘摘要两次都失败ContextSummaryError时走build_fallback确定性规则裁剪保 system 最近消息 插入一条“早期历史因摘要失败而省略”的明示不写任何压缩记录。下次请求或恢复会话时从完整历史重新尝试摘要因为一次网络故障不应让早期历史永久丢失。故障降级是临时的不固化为事实。9. 三个削减上下文的机制削减上下文的地方有三个防火墙、驱逐、压缩。压缩是第 3~8 节的主角这一节把三者放在一起说清楚分工。防火墙工具输出进历史之前。工具执行完结果要变成一条消息进历史先量字符数apply_output_firewall单条超过 8000 字符就把原文存到 harness 运行目录的/artifacts/session/下上下文里只放头尾各 10 行的预览和一行取回地址Full output: artifact://3模型需要完整内容时用普通的read_file读回来。不超过 8000 字符就原样进历史。防火墙必须发生在消息进历史之前之后不再改写。理由是前缀缓存改写历史中段会让缓存断在不可预测的位置而在写入前定型缓存只可能断在“新消息追加”这个自然边界上。驱逐请求模型前总量超过驱逐阈值时。把历史里陈旧的工具输出不碰用户消息和模型回复批量换成占位符用的是防火墙同一套落盘机制。默认关闭。三个常量控制它的行为EVICTION_TARGET_RATIO 0.65触发后一次降到阈值的 65%不贴着阈值反复触发EVICTION_MIN_SAVINGS_TOKENS 4_000预计节省不足 4000 token 就不驱逐不为小收益断缓存KEEP_RECENT_TOOL_OUTPUT_TOKENS 16_000最近 16k token 的工具输出受保护不驱逐。有个自然的问题驱逐改写了历史前缀不会把前缀缓存打断吗会必然断这正是驱逐默认关闭的原因。断一次的代价是未命中部分全价重算所以设计上全部围绕“少断”预计节省不足 4000 token 不驱逐省的还不够断的、触发后一次降到阈值的 65%不贴线反复触发、一次只生成一条原子记录不逐轮漂移。即便如此阈值设错时依然净亏损。实测缓存命中率掉 20.9 个百分点、token 总量反增 64%这就是第 10 节的故事。压缩请求模型前总量超过 84k 时。旧消息换成摘要保留最近 20k 原文这是前面讲的全部内容。三者对比防火墙驱逐压缩什么时候工具刚执行完结果还没进历史请求前总量超驱逐阈值请求前总量超 84k对什么这一条超大的输出历史里陈旧的工具输出旧的对话消息按组怎么处理原文落盘放预览取回地址原文落盘换占位符让模型重写成摘要原文还能回来吗能read_file 取回能read_file 取回不能只剩摘要花一次模型请求吗不花不花花摘要请求现在开着吗开默认关开为什么需要三个而不是一个它们各管一种情况成本不同。防火墙管“单条太大”不花模型请求、原文无损、进历史前定型但只能拦刚产生的这一条。驱逐管“很多条中等大小、累积起来太大”同样不花模型请求、原文无损但默认关闭原因见第 10 节的实验。压缩管“整体就是太大”最后手段要花一次摘要请求的钱而且有损原文变成摘要就回不来了。一次工具输出的完整路径先过防火墙8000 字符进历史每次请求模型前先查驱逐阈值默认跳过再查压缩阈值。10. 负面实验一次优化让成本涨 64%实验设置同一个三阶段长任务、同一个模型跑两遍——E0 关闭驱逐对照E1 开启驱逐并设置驱逐阈值 20k。指标E0关闭E1开启20k累计 token1,134,6101,862,15864.1%缓存命中率93.7%72.8%−20.9 个百分点未命中 token7.2 万50.7 万7 倍驱逐触发次数051 次三阶段通过3/33/3任务结果完全一样成本涨了 64%起负作用。为什么触发 51 次驱逐两个因素叠加一是阈值 20k 远低于任务的真实上下文峰值后面量出来是 42,717。任务中后段上下文一直悬在 20k 上方走一步就超一次。二是当时第一版驱逐的行为是渐进驱逐每次触发只把估算降到刚好低于阈值就停手。于是循环降到 19k → 模型继续干活几步 → 涨回 20k 上方 → 再触发 → 再降一点 → ……51 次触发 51 次改写前缀 51 次缓存断裂省下的 token 远不够付断缓存的未命中重算。也就是说第 9 点的三个常量迟滞 0.65、收益门槛 4000、保护 16k不是一开始就有的设计它们是这次实验失败之后根据教训补的迟滞修贴线反复触发收益门槛修为微小收益断缓存。怎么定新阈值离线回放。三步E0 跑的时候每一步的完整消息历史都存了档。写一个脚本读这份归档把任务从头到尾重新走一遍。不请求模型、不执行工具。能这样做是因为驱逐判定是纯计算给定当前历史、阈值就能算出这一步触发不触发、驱逐哪些。上下文怎么长起来的虽然依赖模型但 E0 归档里已经记下了全部真实响应照着放就行。换不同阈值各扫一遍每档数一下会触发几次阈值20k32k38k40~42k43k预计触发26 次9 次6 次2 次0 次峰值 42,717阈值压在峰值之上43k就一次不触发低于峰值的阈值必然反复触发而每次触发都是一次缓存断裂。补完常量之后还有个后续。原计划是花钱再跑一次真实任务E2验证新常量但离线扫描的结果让这个实验失去了意义。E2 要测的是驱逐的代价和收益前提是驱逐至少发生几次实验组和对照组才有行为差异。而扫描算了两件事一是上面那张表——每档阈值下何时越线二是越线之后最多能省多少——把驱逐候选全部假想降级算前后估算差。第二件事的结果是这条轨迹上驱逐的可操作对象本来就很少最近 16k 的工具输出受保护超过 8000 字符的大输出早被防火墙换成了占位符候选全部降级也只能省 2,054 token。这个数是候选池决定的和阈值无关——也就是说不管选哪档阈值、越线多少次每次驱逐计划的节省都 ≤ 2,054低于 4,000 的收益门槛门槛必拦。两件事合起来所有档位下实际驱逐都是 0 次。实际驱逐 0 次意味着花钱跑 E2实验组和对照组行为完全一样结果只剩自然波动什么问题都回答不了。按事先定下的离线扫描通过后再花钱的约束付费实验直接不跑。零成本预判出这个实验不值得跑这正是离线回放的价值它买到的不是阈值高于峰值就不触发这种常识而是峰值是多少“每档阈值会触发几次”触发后最多能省多少这些事先未知的数。结论驱逐的收益是少发 N 个 token成本是断一次缓存后未命中部分全价重算 M 个 token净收益 N − M阈值低于真实峰值时 M 碾压 N。所以驱逐默认关闭要开阈值必须先用离线回放量出真实峰值再定不能凭感觉设阈值。