ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI编码代理抗上下文压缩:用文件系统持久化记忆

AI编码代理抗上下文压缩:用文件系统持久化记忆

1. 从一次上下文崩溃说起:为什么长会话代理总会“失忆”

做过 AI 编码代理的人大概率都经历过这种场景:前两个小时它还能准确记住你三小时前随口提过的一个变量命名约定,到了第四个小时,你让它改一个函数,它突然开始重新问你项目用什么语言写的。这不是模型变笨了,而是上下文窗口被压缩机制“动过刀”了。

我把它叫做脑叶切除术——当会话 token 累积到某个阈值,系统会自动触发上下文压缩,把早期的对话历史摘要化、截断、甚至直接丢弃。对于短对话这没什么问题,但对于一个需要持续数小时甚至数天的编码代理来说,这等于把它的长期记忆一刀切掉。它还记得怎么说话,但忘了自己是谁、在做什么项目、之前踩过哪些坑。

这个项目要解决的核心问题就是:如何让一个 AI 编码代理在经历多次上下文压缩后,依然保持对项目状态、约定和历史的完整认知。思路借鉴了两个看似毫不相关的东西——Unix 的init.d服务管理机制,以及电影《记忆碎片》里主角用纹身和纸条对抗短期记忆丧失的做法。

适合阅读这篇文章的人:正在构建或使用 AI 编码代理的开发者、对长会话代理架构感兴趣的技术人员、以及任何被“代理失忆”问题折磨过的人。不需要你是 AI 专家,但需要对命令行和基本的系统管理概念有了解。

整个方案的核心思想可以用一句话概括:不依赖模型的上下文记忆,而是把关键状态外化成文件系统上的持久化记录,让代理每次“醒来”都从这些记录中重建认知。下面我会把这个思路拆开,从设计动机到具体实现,再到踩过的坑,完整讲一遍。

2. 核心设计思路:把记忆从模型里搬到文件系统上

2.1 上下文压缩到底做了什么

要理解这个方案为什么有效,先得搞清楚上下文压缩的机制。主流的长会话代理在处理超长对话时,通常采用以下几种策略的组合:

  • 滑动窗口:只保留最近 N 个 token 的对话,早期的直接丢弃
  • 摘要压缩:把早期对话用模型总结成一段简短摘要,替换原始内容
  • 关键信息提取:尝试从早期对话中抽取“重要”信息保留,其余丢弃
  • 分层记忆:把对话分成热、温、冷三层,冷层只在需要时检索

问题在于,这几种策略对于编码任务都不够可靠。滑动窗口会丢掉项目约定,摘要压缩会丢失具体的技术细节(比如某个 API 的精确参数),关键信息提取的“重要性”判断经常出错——它可能觉得你讨论的架构决策不重要,但实际上那是整个项目的基石。

我实测过一个典型场景:一个中等规模的 TypeScript 项目,代理在会话进行到约 15 万 token 时触发了第一次压缩。压缩后我让它给一个新组件添加单元测试,它生成的测试文件里 import 路径全是错的——因为它忘记了项目配置了路径别名。这个信息在对话早期提过一次,压缩时被判定为“不重要”丢掉了。

2.2 init.d 的启发:服务状态与进程状态分离

Unix 的init.d机制有一个很优雅的设计:服务的运行状态和服务的配置状态是分离的。进程本身可能崩溃、重启,但配置文件、启动脚本、依赖声明这些东西持久化在磁盘上,每次启动都从这些文件重新读取。

把这个思路搬到 AI 代理上:模型的上下文窗口相当于“进程的运行内存”,压缩机制相当于“内存回收”。如果所有状态都只存在运行内存里,回收时就必然丢失。但如果把关键状态写到“磁盘”上——也就是项目目录下的持久化文件——那么无论上下文被压缩多少次,代理都可以通过读取这些文件来恢复认知。

具体来说,我在项目根目录下建立了一个.agent/目录,里面存放几类关键文件:

文件作用更新时机
state.md当前任务状态、进行中的工作每次任务状态变更
conventions.md项目约定、命名规范、架构决策约定确立或修改时
history.log关键操作的时间线记录每个重要操作完成后
context.json结构化上下文数据按需更新

这些文件的内容会被注入到每次对话的系统提示或初始上下文中,确保代理在每次“醒来”时都能快速重建对项目的认知。

2.3 《记忆碎片》的启发:用外部线索对抗遗忘

电影《记忆碎片》里,主角因为无法形成新的长期记忆,就用拍立得照片、纹身和纸条来记录关键信息。每当他“醒来”,第一件事就是查看这些线索,重新建立对当前处境的理解。

这个思路对应到代理设计上,就是主动写入 + 主动读取的循环。代理在执行任务过程中,不是被动地等压缩机制来截断,而是主动把关键信息写到外部文件中。每次新的对话轮次开始时,先读取这些文件,把内容作为上下文的一部分。

关键区别在于:被动等待压缩是“被截肢”,主动写入是“做备份”。前者你无法控制什么被保留什么被丢弃,后者你完全掌控哪些信息需要持久化。

注意:这个方案不是要完全替代模型的上下文记忆,而是给它加一层“外部硬盘”。模型仍然依赖上下文进行推理,但关键状态不再只存在于上下文中。

3. 具体实现:从文件结构到注入机制

3.1 目录结构与文件格式设计

.agent/目录的结构设计遵循一个原则:人类可读、机器可解析、Git 可追踪。这意味着不用二进制格式,不用数据库,就是纯文本和 JSON。

.agent/ ├── state.md # 当前状态,Markdown 格式,人类可读 ├── conventions.md # 项目约定,Markdown 格式 ├── history.log # 操作日志,每行一条 JSON ├── context.json # 结构化上下文,JSON 格式 └── snapshots/ # 关键节点的快照 ├── 001-initial.md └── 002-after-refactor.md

state.md的格式我经过多次迭代,最终确定了一个简洁但信息密度高的模板:

# 当前状态 ## 进行中的任务 - [ ] 重构用户认证模块,提取公共逻辑到 auth-utils.ts - [ ] 为新的 API 端点编写集成测试 ## 最近完成 - [x] 数据库迁移脚本编写完成,已测试通过 - [x] 用户模型添加了 lastLoginAt 字段 ## 已知问题 - 测试环境的时间戳时区处理有 bug,暂未修复 - 第三方支付 SDK 的类型定义不完整,用了 as any 绕过 ## 下一步计划 1. 完成认证模块重构 2. 补充集成测试 3. 处理时区 bug

这个格式的好处是:代理读一遍就能快速定位当前该做什么,不需要扫描整个对话历史。而且 Markdown 格式对模型来说解析成本很低,不容易出错。

conventions.md记录的是那些“一旦确定就不应该再变”的东西:

# 项目约定 ## 代码风格 - 使用 2 空格缩进 - 组件文件使用 PascalCase,工具函数使用 camelCase - 所有导出函数必须有 JSDoc 注释 ## 架构决策 - 状态管理使用 Zustand,不用 Redux(决策日期:项目第 3 天) - API 请求统一走 src/lib/api-client.ts,不直接使用 fetch - 数据库查询使用 Prisma,不写原生 SQL ## 路径别名 - @/ 映射到 src/ - @components/ 映射到 src/components/ - @utils/ 映射到 src/utils/

history.log用 JSON Lines 格式,每行一条记录:

{"ts":"2024-01-15T10:30:00Z","action":"refactor","target":"auth.ts","note":"提取了 validateToken 到 utils"} {"ts":"2024-01-15T11:45:00Z","action":"fix","target":"api-client.ts","note":"修复了重试逻辑的指数退避计算错误"} {"ts":"2024-01-15T14:20:00Z","action":"decision","target":"state-management","note":"确定使用 Zustand,理由:项目规模不需要 Redux 的复杂度"}

3.2 注入机制:让代理每次“醒来”都读到这些文件

光有文件不够,关键是让代理在每次对话开始时自动读取这些内容。实现方式取决于你用的代理框架,但核心逻辑是一样的:

def build_system_context(project_root): agent_dir = os.path.join(project_root, ".agent") context_parts = [] # 读取状态文件 state_file = os.path.join(agent_dir, "state.md") if os.path.exists(state_file): with open(state_file) as f: context_parts.append("## 当前项目状态\n" + f.read()) # 读取约定文件 conventions_file = os.path.join(agent_dir, "conventions.md") if os.path.exists(conventions_file): with open(conventions_file) as f: context_parts.append("## 项目约定\n" + f.read()) # 读取最近的历史记录(只取最近 20 条) history_file = os.path.join(agent_dir, "history.log") if os.path.exists(history_file): with open(history_file) as f: lines = f.readlines() recent = lines[-20:] context_parts.append("## 最近操作历史\n" + "".join(recent)) return "\n\n".join(context_parts)

这段代码在每次构建系统提示时被调用,把外部文件的内容拼接到系统提示中。这样即使对话历史被压缩了,系统提示里的这些内容仍然完整存在。

提示:历史记录不要全部注入,只取最近 N 条。全部注入会占用大量 token,反而加速压缩触发。20 条是一个经验值,可以根据项目复杂度调整。

3.3 写入机制:什么时候写、写什么

写入时机的判断是这个方案能否落地的关键。写得太频繁会拖慢代理执行速度,写得太少又起不到持久化作用。我总结了几条规则:

必须写入的时机:

  • 完成一个可独立验证的任务单元后(比如一个函数写完并通过测试)
  • 做出一个架构决策后(比如选择某个库、确定某个模式)
  • 发现并修复一个 bug 后
  • 用户明确表达偏好或约定时

可以批量写入的时机:

  • 连续的小修改可以合并成一条记录
  • 探索性的代码阅读不需要记录
  • 临时的调试输出不需要记录

写入操作本身通过代理的工具调用完成。比如在 Claude Code 或类似的代理框架中,可以定义一个自定义工具:

const updateAgentState = { name: "update_agent_state", description: "更新代理的持久化状态文件", parameters: { type: "object", properties: { file: { type: "string", enum: ["state", "conventions", "history"] }, content: { type: "string" }, mode: { type: "string", enum: ["replace", "append"] } } } };

代理在完成任务后主动调用这个工具,把状态写入文件。这比依赖框架自动保存要可靠得多,因为代理自己最清楚哪些信息是重要的。

4. 实操过程:从零搭建一个抗压缩的代理环境

4.1 初始化项目结构

假设你有一个现有的项目,第一步是在项目根目录创建.agent/目录和初始文件:

mkdir -p .agent/snapshots # 初始化状态文件 cat > .agent/state.md << 'EOF' # 当前状态 ## 进行中的任务 - 项目初始化完成,等待分配具体任务 ## 最近完成 - 无 ## 已知问题 - 无 ## 下一步计划 - 等待用户指示 EOF # 初始化约定文件 cat > .agent/conventions.md << 'EOF' # 项目约定 ## 代码风格 - 待确定 ## 架构决策 - 待确定 ## 路径别名 - 待确定 EOF # 初始化历史日志 touch .agent/history.log

然后把这个目录加入 Git 追踪。这样每次提交代码时,代理的状态也一起被版本控制了。回滚代码的同时也能回滚代理状态,保持一致性。

4.2 配置代理的系统提示

不同的代理框架配置方式不同,但核心是把前面build_system_context函数生成的文本注入到系统提示中。以常见的配置文件为例:

# agent-config.yaml system_prompt: | 你是一个编码代理,正在协助开发一个软件项目。 在开始任何工作之前,请先阅读以下项目状态和约定信息。 这些信息来自项目目录下的 .agent/ 文件夹,是持久化的, 不会因为对话历史被压缩而丢失。 {agent_context} 在执行任务过程中,如果你完成了重要的工作、做出了架构决策、 或发现了需要记录的问题,请调用 update_agent_state 工具 更新对应的文件。 记住:你的上下文记忆可能会被压缩,但这些文件不会。 依赖文件,不要依赖记忆。

关键点是最后那句“依赖文件,不要依赖记忆”。这句话需要反复强化,因为模型的默认行为是依赖上下文记忆。通过系统提示的反复强调,可以逐渐引导它养成读写外部文件的习惯。

4.3 实际运行记录:一次完整的抗压缩过程

我记录了一次真实的运行过程,展示这个方案在实际中如何工作。

初始状态:项目是一个中等规模的 Next.js 应用,约 50 个源文件。代理开始工作时,上下文窗口是空的。

第 1 小时:代理阅读了.agent/下的文件,了解了项目约定(使用 App Router、Tailwind CSS、Prisma ORM)。开始执行第一个任务:给用户列表页面添加分页功能。

第 2 小时:分页功能完成。代理调用update_agent_state,在state.md中标记任务完成,在history.log中记录了一条操作。同时发现了一个问题:分页组件的页码计算在边界条件下有 off-by-one 错误,修复后也记录了下来。

第 3 小时:开始第二个任务,重构 API 路由。此时对话 token 累积到约 12 万。

第 4 小时:token 达到 18 万,触发了第一次上下文压缩。压缩后,代理的对话历史被截断,早期的分页功能讨论全部丢失。

压缩后的第一次交互:我让代理继续重构 API 路由。它读取了系统提示中的.agent/内容,看到了state.md中记录的“分页功能已完成,修复了边界 bug”,以及conventions.md中的 API 路由约定。它没有重新问项目用什么框架,也没有忘记之前的决策,直接继续了重构工作。

关键验证:我故意问它“之前分页功能遇到的那个 bug 是什么”。它回答:“根据 history.log 记录,是页码计算在边界条件下的 off-by-one 错误,已修复。”这个信息完全来自外部文件,而不是被压缩掉的上下文。

4.4 参数调优:注入多少、多久写一次

经过多次实验,我总结了一组比较稳定的参数:

参数推荐值说明
历史记录注入条数15-25 条太少会丢失近期上下文,太多会占用 token
状态文件更新频率每个任务单元完成后不要每改一行代码就写
约定文件更新频率仅在约定变更时频繁更新会导致代理困惑
快照创建频率每个大阶段完成后用于回滚和对比
系统提示中 agent 内容的占比不超过 15%超过会挤占正常对话空间

这些值不是绝对的,需要根据项目规模和代理的上下文窗口大小调整。核心原则是:注入的内容要精炼,写入的频率要适中。

5. 常见问题与排查技巧实录

5.1 代理不主动写入文件怎么办

这是最常见的问题。模型天生倾向于“记住”而不是“记录”,因为训练数据中很少有主动写外部文件的模式。解决方法有几个层次:

第一层:系统提示强化。在系统提示中用明确的指令和示例告诉它什么时候该写。比如:“当你完成一个函数并验证通过后,立即调用 update_agent_state 记录。”

第二层:工具描述优化。把update_agent_state的工具描述写得非常具体,包括使用示例。模型对工具描述的理解直接影响调用频率。

第三层:后置检查。在代理完成一个任务后,用一个简单的检查逻辑判断是否需要写入。比如检测到对话中出现了“完成了”“修复了”“决定了”等关键词,就提醒代理写入。

第四层:自动化钩子。如果代理框架支持钩子(hook),可以在每次工具调用后自动检查状态文件是否过期,过期则强制更新。

我实测下来,第一层加第二层能解决 80% 的问题。剩下的 20% 需要后置检查兜底。

5.2 文件内容冲突或过期

当代理同时维护多个文件时,可能出现内容不一致。比如state.md说任务 A 在进行中,但history.log显示任务 A 已经完成了。

解决方法是建立一个单一数据源原则:state.md是唯一的状态权威,其他文件都是辅助。每次读取时,以state.md为准。history.log只用于追溯,不用于判断当前状态。

另外,在写入时加一个简单的版本号或时间戳,读取时检查时间戳,如果发现文件比对话历史还旧,说明代理忘记更新了,需要提醒。

5.3 压缩后代理仍然“失忆”

有时候即使有外部文件,代理压缩后还是表现异常。排查思路如下:

症状可能原因解决方法
忘记项目约定约定文件没被注入检查系统提示构建逻辑
重复问已回答的问题状态文件内容太旧检查写入频率
引用不存在的文件历史记录中有错误信息清理 history.log
行为风格突变系统提示被压缩截断把 agent 内容放在系统提示最前面
完全忽略外部文件模型注意力被分散减少注入内容,提高信噪比

注意:系统提示本身也可能被压缩机制影响。有些框架会对系统提示也做摘要处理。如果是这种情况,需要把.agent/的内容放在一个不会被压缩的位置,比如作为第一条用户消息注入,或者使用框架提供的“持久上下文”功能。

5.4 性能开销与 token 成本

这个方案会增加每次对话的 token 消耗,因为系统提示中多了.agent/的内容。实测数据:

  • 不注入时,系统提示约 500 token
  • 注入后,系统提示约 2000-3000 token
  • 每次对话的额外成本约 0.5-1 美分(取决于模型定价)

对于长会话来说,这个成本是值得的,因为它避免了因失忆导致的重复工作和错误。一次因忘记约定而写错的代码,修复成本远高于几美分的 token 费用。

如果成本敏感,可以优化注入策略:只在对话轮次开始时注入完整内容,后续轮次只注入状态文件的摘要。

6. 进阶技巧:让代理的记忆系统更智能

6.1 分层记忆:热、温、冷三层设计

基础方案是所有信息平铺在几个文件里。当项目变大时,可以引入分层设计:

  • 热层:当前任务相关的信息,每次对话都注入
  • 温层:近期完成的任务和活跃的约定,按需注入
  • 冷层:历史归档,只在明确需要时检索

实现方式是在.agent/下建子目录,用不同的注入策略处理。热层文件小、更新频繁;冷层文件大、更新少。

6.2 自动摘要:让代理自己维护记忆

与其手动决定写什么,不如让代理定期对对话历史做摘要,把摘要写入外部文件。这相当于把压缩机制从“被动截断”变成“主动摘要”,而且摘要的内容和格式由你控制。

具体做法是每隔 N 轮对话,触发一次摘要生成:

def summarize_and_persist(conversation_history, agent_dir): summary_prompt = """ 请总结以下对话中的关键信息,包括: 1. 完成的任务和结果 2. 做出的决策和理由 3. 发现的问题和解决方案 4. 下一步计划 用简洁的 Markdown 格式输出。 """ summary = call_model(summary_prompt + conversation_history) with open(os.path.join(agent_dir, "state.md"), "w") as f: f.write(summary)

这样即使原始对话被压缩,摘要仍然完整保留在外部文件中。

6.3 跨会话继承:让代理记住“上一次”

这个方案的另一个好处是支持跨会话继承。当你关闭代理、第二天重新打开时,它读取.agent/文件,就能知道昨天做到哪里了。不需要重新解释项目背景,直接继续工作。

我实测过一个场景:周五下午完成了一个功能模块,周一早上重新打开代理,它读取状态文件后直接问:“认证模块重构已完成,是否继续处理集成测试?”这种连续性体验是纯上下文方案无法提供的。

6.4 与版本控制的集成

把.agent/目录纳入 Git 管理后,可以做很多有意思的事情:

  • 回滚代理状态:如果代理做出了错误决策,可以回滚到之前的快照
  • 对比不同分支的代理状态:看看不同功能分支上代理的工作记录
  • 审计代理行为:通过 history.log 追溯代理的每一步操作
  • 团队共享:多个开发者使用同一个项目时,代理状态可以共享

提示:在.gitignore中排除.agent/snapshots/下的临时快照,只保留关键节点的快照。否则仓库会变得很大。

7. 我在实际使用中踩过的坑

第一个坑是过度注入。一开始我把所有历史记录都注入系统提示,结果 token 消耗巨大,而且代理的注意力被分散,反而更容易出错。后来改成只注入最近 20 条,效果明显改善。这让我意识到:外部记忆的价值在于精炼,不在于多。

第二个坑是文件格式不统一。早期我混用了 Markdown、JSON 和纯文本,导致代理在解析时经常出错。后来统一成“Markdown 用于人类可读的状态和约定,JSON Lines 用于机器可读的日志”,问题就消失了。格式一致性对模型来说比人类更重要。

第三个坑是忘记处理并发写入。当代理同时执行多个任务时,可能出现两个任务同时更新state.md的情况,导致内容覆盖。解决方法是在写入前加一个简单的锁机制,或者让代理串行执行写入操作。

第四个坑是系统提示位置。我一开始把.agent/内容放在系统提示的末尾,结果发现压缩时它最先被截断。后来移到最前面,并加上了明确的标记(如<!-- PERSISTENT CONTEXT -->),才稳定下来。

这个方案不是银弹。它解决的是“状态持久化”问题,不解决“推理能力”问题。代理仍然可能犯错,仍然需要你的监督。但至少,它不会再因为上下文压缩而忘记自己是谁、在做什么。对于一个需要长时间运行的编码代理来说,这个改善是质变的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进