07 -- 记忆管理:窗口满了怎么办
Agent 做到第 40 轮时
上一章结尾说,不管书架管理得多精细,对话本身一直在涨。
具体场景:agent 正在做一个 20 文件的重构任务。每轮读一个文件、改几行、跑一次测试------文件内容、编辑记录、测试日志不断堆入消息历史。到第 40 轮,上下文逼近窗口上限。第 41 轮的请求发出去,API 返回 context length exceeded。任务中断,前 40 轮白费。
消息历史会无限增长,但窗口有限。需要在不丢关键信息的前提下压缩。
铁律:系统前缀不能动
先说清楚"前缀"是什么。每次发给大模型的请求,token 序列最前面是一段固定内容:系统提示词 + 工具定义。这段内容每轮都一模一样,是前缀缓存命中率最高的部分------API 发现请求开头跟上次一样,就复用已缓存的 KV 向量,不用重新计算。
最直觉的压缩做法是滑动窗口------满了就从头砍掉几条消息。但从头砍意味着系统提示词后面紧跟的消息变了,修改点往后的缓存全部失效,每轮重新计算全部输入 token,成本暴涨近一个数量级。
所以整章的设计都在一条约束下展开:系统提示词和工具定义这个"真前缀"永远不动。 后面的对话消息可以动------微压缩把旧工具输出替换为占位符,整体摘要用一段摘要替换大段历史。这些操作会让修改点之后的缓存失效,但最长、最稳定的系统前缀始终命中,代价可控。
为什么前缀缓存这么重要? 大模型处理一次请求,要对每个输入 token 计算 Key-Value 向量(注意力机制的核心计算)。10 万 token 的请求就要算 10 万组 KV。前缀缓存的原理:如果本次请求的前 N 个 token 跟上次完全一样,这 N 组 KV 直接从缓存取,只需计算新增部分。系统提示词 + 工具定义通常占几千到上万 token,每轮一字不差地重复------天然的缓存热区。保住它,每轮只算新增的对话;破坏它,10 万 token 从头算起。
约束确立后,三个压缩手段按从轻到重的顺序依次上场。
不够时
仍逼近上限
微压缩
每轮自动
旧输出 → 占位符
大输出截断
即时拦截
超大输出 → 存盘 + 预览
整体摘要
窗口快满时
全部对话 → 摘要 + 最近几条
第一道防线------旧输出自动退化
对话里最胖的是工具输出------文件内容、搜索结果、命令日志。但这些输出的价值随时间衰减:10 轮前读过的文件,模型早已提取关键信息,原始文本没用了。
beforeModel(state):
compactable = [msg for msg in messages
if msg is ToolMessage
and msg.name in {"bash", "read_file", "grep", "glob"}]
if len(compactable) <= KEEP_RECENT:
return
for msg in compactable[:-KEEP_RECENT]: // 保留最近 3 条不动
if len(msg.content) > 4000:
msg.content = "[已压缩 - 需要时可重新获取]"
保留最近几条原样不动,更早的替换为一行占位符。文件读取和搜索都是幂等操作------需要时重新调用就行,信息从"内存"退回"磁盘"。这一层每轮静默执行,用户无感知。
第二道防线------超大输出先存盘
日常卫生只减缓增长,碰到 cat huge-log.txt 这种几十万字符的输出,第一道防线来不及反应------消息还没变"旧",窗口就爆了。所以在工具返回的那一刻就拦截:
wrapToolCall(request, handler):
result = handler(request)
if len(result.content) <= MAX_OUTPUT_CHARS:
return result
path = save_to_disk(result.content)
preview = result.content[:2000]
return preview + "\n[完整输出已存盘: " + path + "]"
模型拿到路径后按需读取特定片段。这道防线解决的是单次爆炸------不截断,一条命令就能耗尽整个窗口。但两道防线都只控制增量,一个持续 60 轮的长任务,累积量还是会逼近上限。
最后手段------整体摘要
当上下文逼近窗口上限,系统调一个摘要模型,把全部对话历史压成一段按时间线排列的工作摘要:
compactConversation(messages):
transcript = save_to_disk(messages) // 归档完整对话
summary = summarize_model(messages) // 生成因果链摘要
tail = messages[-5:] // 最近 5 条原样保留
messages.clear()
messages.push(summary, ack, ...tail)
摘要的核心要求是保留因果链------"先做了 A,A 因为 Y 失败了,所以改做了 B"。agent 需要知道为什么走到了这一步,不只是当前状态。最近几条原样保留,因为它们是正在进行的工作------连这些也压掉,模型会对"刚刚在做什么"失去感知。
压缩前完整对话先归档到磁盘------信息从窗口中消失,但不永久丢失。这一层平时不触发,触发一次续命数十轮。
压缩的代价
三道防线让对话延续更久,但压缩有一个本质矛盾------压缩就是遗忘,而有些东西不该被遗忘。
假设 agent 列了 20 步重构计划,做到第 12 步时摘要触发。"步骤 7:重构用户服务的数据库查询"变成"之前完成了一些数据库相关的工作"。精确的计划条目被压成模糊记忆。
根源在于消息历史同时承担了两个角色------对话记录 和状态载体。压缩对话记录合理(旧的调试日志确实不重要),但寄生在消息里的状态会被连带压缩。第 04 章的待办清单正是如此:条目通过消息传递给模型,一旦那条消息被摘要化,状态就断裂了。
出路是把需要持久可见的状态从消息历史中抽离,存到压缩碰不到的地方,每轮重新注入。