夜雨聆风学习资料网

ARTICLE · 1246671

Agent框架Letta 源码解析(六):compaction——窗口满了怎么办

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 compaction
变化
驱逐比例固定(队列的一半)
0.3 起步、按 token 预算自动递增
驱逐量由预算决定
摘要反复压摘要,损耗累积
旧摘要只提取关键信息;长月权威交给记忆系统(reflection)
摘要降级为短期角色
被驱逐内容只剩摘要一份
摘要主干 + 查找提示连检索 + 记忆存权威
三层冗余,可找回

核心变化一句话:论文把摘要当记忆,Letta 把摘要当缓存——长期状态归 MemFS,摘要只负责让近期消息可理解。

设计亮点

摘要提示词把「装不下」写成指令。"Lookup hints" 一节是摘要与检索的显式分工:摘要保主干,装不下的细节留关键词、靠搜索找回。对照「努力把一切塞进摘要」的提示词,这里承认摘要有损、并给损失修了路。可迁移的判断:有损压缩的设计要包含找回路径,否则丢失是静默的。

切点合法性是结构约束,不是启发式。必须切在 assistant 边界、不许撕开工具配对——对话的语义单元(一轮完整的模型回合)在物理删除时保持完整。驱逐算法可以简单(比例递增),因为合法性规则挡住了所有会产生残破上下文的切法。

驱逐量由预算反推。0.3 是起点,不是答案;保留部分要装进 (1 - 驱逐比例) × 窗口 的预算才算数。对照固定比例的实现,这个设计在消息长短差异大的对话里不会出现「按比例切了但还是超」的二次压缩。

代价与局限

摘要质量取决于摘要模型。提示词再细致,执行者是模型:标识符抄错、该保的没保,下游没有校验(记忆那边有三道闸,摘要这边没有)。错误摘要会让近期推理建立在错的背景上,而且用户很难察觉。

compaction 本身有成本和延迟。一次独立模型调用加一段提示词,长对话驱逐比例递增到 0.5、0.6 时摘要输入本身就不小;它发生在回合处理路径上,用户会等。

找不到合法切点时退到全量压缩。消息全是非 assistant 边界的极端情况(比如超长工具输出连成片)下,sliding_window 规划失败、退到 all——一次更重的压缩。工具输出截断(事前减量)缓解了这个场景,但没消除。

相关学习资料