ARTICLE · 1246671
Agent框架Letta 源码解析(六):compaction——窗口满了怎么办
上下文窗口满了,Letta 的处理是把最旧的消息换成一条摘要:默认驱逐最旧的 70%(比例从 30% 起、按 token 预算自动加大),用一次独立的模型调用为被驱逐内容生成摘要,作为一条 user 消息放在保留的近期消息之前。
压缩前后,模型看到什么
compaction 只改一件事:发给模型的消息列表。被驱逐的消息没有删——它们还在服务端的完整历史里(可检索),只是不再发给模型。前后对比:
压缩前,发给模型 100 条: 压缩后,发给模型 31 条:第 1-70 条(最旧的消息) user Note: 70 条早期消息已因记忆user「开始重构 auth 模块」 约束隐藏,以下是摘要:assistant(方案讨论) <七节摘要正文>assistant + tool(工具调用与结果) user 「现在跑一下测试」…… assistant (跑测试的回合)第 71-100 条(近期消息) ……user「现在跑一下测试」 ↑ 近期 30% 原文保留……
左边的 70 条变成了右边的一条。模型对早前细节的了解从「原文」降级为「摘要」——但三条退路同时存在:摘要里的标识符逐字保留(能定位)、摘要末尾有「查找提示」(能搜索回原文)、compaction 事件触发 reflection 把要点写进长期记忆。
压缩流程
// 回合处理中检测到窗口压力时:const mode = settings.mode ?? ”sliding_window”; // 默认滑动窗口if (mode === ”sliding_window”) {cutoff = 找切点(messages, 起始驱逐比例 0.3); // ① 见「切点」节evicted = messages[0..cutoff); // 被驱逐的最旧消息kept = messages[cutoff..]; // 保留的近期消息summary = await 摘要模型(evicted); // ② 一次独立模型调用transcript = [user角色(summary), ...kept]; // ③ 见下一节} else { // ”all”:整段对话摘要成一条transcript = [user角色(摘要模型(messages))];}
两个模式的默认值是三行常量(compaction.ts:43):
export const LOCAL_DEFAULT_COMPACTION_MODE = ”sliding_window”;export const LOCAL_DEFAULT_SLIDING_WINDOW_PERCENTAGE = 0.3;export type LocalCompactionMode = ”all” | ”sliding_window”;
all 模式把整段对话压成一份摘要;sliding_window 只压被驱逐的部分、保留近 30% 原文。sliding_window 找不到合法切点时抛 LocalSlidingWindowCompactionPlanningError,由上层改走 all。
摘要以 user 角色发送,且自带说明包装
骨架里 ③ 那条摘要消息,角色是user(local-store.ts:1183):
const summaryMessage: LocalMessage = {id,role: ”user”, // user 角色metadata: { ..., compaction: { summary, stats } },content: [{ type: ”text”, text: input.packedSummary }],};const compactedMessages = [summaryMessage, ...remainingMessages];
发给模型的不是裸摘要,是包了一层说明的版本(packageLocalSummaryMessage,compaction.ts:682):
Note: 70 messages from the beginning of the conversation have been hiddenfrom view due to memory constraints.The following is a summary of the previous messages:<摘要正文>
两个设计:为什么是 user 角色——对话中段要插入内容,模型消息流里可用的位置只有 user 消息(system 只在开头),「这是系统通知、不是用户说的话」的语义靠包装文本自解释,防止模型把摘要当成用户的最新指令。两层表示——存储层是 role: user 加 metadata.compaction(含摘要和统计),投影到对话历史和界面时变成 summary_message 类型。
切点:必须切在 assistant 消息上
驱逐从哪里切,有一条硬规则(compaction.ts:573):
function isValidSlidingWindowCutoff(messages, index, maximumCutoffIndex) {const message = messages[index];return (message?.role === ”assistant” && index > 0 && index < maximumCutoffIndex);}
切点必须落在assistant 消息上——保留部分从「模型完整说完一段话」的地方开始。maximumCutoffIndex 还有条配对规则:最后一条消息带有未完成的工具调用时,切点最多到倒数第二条——不许把tool_call和它的结果切开。
驱逐比例不是一次定死。规则一句话:先按 30% 试切,切完保留的部分还超预算,就每次多赶 10%,直到装得下。规划循环(:610 节选):
const goalTokens = (1 - percentage) * options.contextWindow; // 保留部分的 token 预算let evictionPercentage = percentage; // 从 0.3 开始while (approxTokenCount >= goalTokens && evictionPercentage < 1.0) {evictionPercentage += 0.1; // 每次加 10%cutoffIndex = 从新比例往回找最近的合法切点;approxTokenCount = 估算保留消息的 token 数;}
目标导向:0.3 只是起点——如果按 30% 切完、保留的消息还是超预算,比例自动加 10 个点重切,直到装得下。驱逐多少是预算算出来的,不是配置写死的。
摘要提示词的四个设计
摘要是用另一次模型调用生成的(不走主对话),两个模式各有一份提示词(compaction.ts:61、:85)。all 版要求 7 个小节:高层目标、发生了什么、重要细节、错误与修复、当前状态、可选下一步、查找提示;sliding_window 版是 5 节(近期消息还在原文里,不需要「当前状态」和「下一步」)。四条指令值得逐条看:
标识符逐字保留(提示词原文):"Preserve identifiers verbatim (plan filename/path, exact URL, issue/PR number, ticket ID); do not paraphrase or truncate."——文件路径、URL、issue 号不许改写。摘要有损,但可寻址的锚点不允许有损:之后凭这些标识符才能找回原文。
查找提示(原文):"For any detailed content that couldn't fit in the summary, note the topic and key terms that could be used to find it in message history later."——装不进摘要的长清单、大段数据,记下主题和关键词。摘要主动声明自己装不下了什么、并给出找回的路标——这是摘要和检索的分工:摘要保主干,细节靠 conversation_search按关键词找回。
旧摘要的提取(原文):"If there is a previous summary being evicted, please extract a concise version of the critical info from it."——被驱逐的部分里可能已经有一份旧摘要(上一次 compaction 的产物),这次要把它的关键信息提取进新摘要。这就是论文「递归摘要」的损耗点:摘要的摘要会反复有损;提示词的对策是只提取 critical info,并且——结合compaction-event 会触发 reflection,被驱逐内容的要点先写进记忆再丢,摘要只承担「让近期消息可理解」的短期职责。
错误与用户反馈:"record verbatim if useful"——错误和修复过程、用户的原话反馈单独成节。长任务里重复踩坑的主要原因是忘了上次的错,这一节就是防它的。
另外,送入摘要模型的对话文本里,工具输出先截断到 2000 字符(LOCAL_SUMMARY_TOOL_RETURN_TRUNCATION_CHARS)——工具输出是体积大头,摘要不需要完整版本。
失败的兜底
摘要这一步模型调用可能失败,兜底是个阶梯:
const fallbackTranscript = formatLocalMessagesForSummary(input.messages, {...});if (fallbackTranscript === previousTranscript) continue; // 同样的降级结果不重试
每一级用不同的格式化参数重新生成摘要输入,同样的输入不重复试;此外还有一套 compaction 专用的备用模型运行时(:365 "a compaction-only fallback runtime is created")——主摘要模型失败时换备用模型。整个 compaction 是后台保命机制,它自己失败不能让回合失败。
工具输出侧还有两道更靠前的截断(src/tools/impl/truncation.ts、overflow.ts):单次工具返回超限时的原地截断和溢出处理——很多「窗口满」在工具输出层就被拦下了,走不到 compaction。
一次长对话的完整时间线
14:00 长任务进行中,消息累积,token 逼近窗口上限14:01 回合处理检测到压力 → compaction 启动├─ 切点搜索:0.3 起步 → 保留部分仍超预算 → 0.4 → 合法切点(assistant 边界)├─ 摘要模型(独立调用):被驱逐消息 → 7 节摘要(含查找提示)└─ transcript = [user 角色的摘要消息, ...保留的近期消息],回合继续14:02 compaction-event 触发 reflection└─ 后台复盘被驱逐内容 → 要点写进 MemFS → worktree 合并16:00 用户问起 14:00 前的一个细节(已被驱逐)└─ 模型读摘要的「查找提示」→ conversation_search 按关键词找回原始消息 → 回答
三级分工在这条时间线里齐了:工具输出截断(事前减量)、compaction 摘要(事中保主干)、reflection 写记忆(事后存权威)。被驱逐的内容不是丢了,是从「窗口里」搬进了「记忆和检索里」。
对照论文:递归摘要的三个演化
核心变化一句话:论文把摘要当记忆,Letta 把摘要当缓存——长期状态归 MemFS,摘要只负责让近期消息可理解。
设计亮点
摘要提示词把「装不下」写成指令。"Lookup hints" 一节是摘要与检索的显式分工:摘要保主干,装不下的细节留关键词、靠搜索找回。对照「努力把一切塞进摘要」的提示词,这里承认摘要有损、并给损失修了路。可迁移的判断:有损压缩的设计要包含找回路径,否则丢失是静默的。
切点合法性是结构约束,不是启发式。必须切在 assistant 边界、不许撕开工具配对——对话的语义单元(一轮完整的模型回合)在物理删除时保持完整。驱逐算法可以简单(比例递增),因为合法性规则挡住了所有会产生残破上下文的切法。
驱逐量由预算反推。0.3 是起点,不是答案;保留部分要装进 (1 - 驱逐比例) × 窗口 的预算才算数。对照固定比例的实现,这个设计在消息长短差异大的对话里不会出现「按比例切了但还是超」的二次压缩。
代价与局限
摘要质量取决于摘要模型。提示词再细致,执行者是模型:标识符抄错、该保的没保,下游没有校验(记忆那边有三道闸,摘要这边没有)。错误摘要会让近期推理建立在错的背景上,而且用户很难察觉。
compaction 本身有成本和延迟。一次独立模型调用加一段提示词,长对话驱逐比例递增到 0.5、0.6 时摘要输入本身就不小;它发生在回合处理路径上,用户会等。
找不到合法切点时退到全量压缩。消息全是非 assistant 边界的极端情况(比如超长工具输出连成片)下,sliding_window 规划失败、退到 all——一次更重的压缩。工具输出截断(事前减量)缓解了这个场景,但没消除。















































































