1. 这个实验到底在折腾什么
先交代背景。我做 AI 编码代理的集成和调优差不多两年了,从最早的补全插件一路跟到现在的 agent 形态。这次实验的起点很朴素:上下文压缩之后,代理接不上活。
具体场景是这样的。你在一个稍大的代码库里跑一个编码代理,比如让它改一个跨了七八个文件的接口。对话轮次一多,上下文窗口就顶到上限了。这时候系统会触发上下文压缩——把前面的历史对话摘要成一段短文本,腾出空间继续跑。问题就出在这个"摘要"上:压缩完之后,代理经常表现得像失忆了一样,明明十分钟前刚确认过的函数签名,它转头就写错;明明已经排除掉的方案,它又绕回去重试。
我一开始以为是模型能力问题,换了好几个模型,发现都差不多。后来才意识到,压缩丢的不是"信息量",而是"结构"。摘要能把"我们在改用户模块"这件事保留下来,但保留不了"用户模块的鉴权函数叫 verify_token,参数是 user_id 和 scope,返回 bool"这种精确的、可执行的知识。代理要接着干活,靠的恰恰是后者。
所以这个实验想验证一件事:在上下文被压缩之后,能不能用一套外挂的长期记忆机制,把代理"接回"到之前的工作状态。我给它起了个名字叫 long_mem,核心组件叫 engram——engram 这个词借的是神经科学里的"记忆痕迹"概念,指的是一条可以被重新激活的记忆单元。
整个实验跑了 10 天,公开记录了 430 条交互日志。为什么是 430 条?因为这是我实际跑完三个真实项目、累计触发压缩 60 多次之后自然产生的记录量,不是我凑的数。下面我把这套东西的设计思路、实现细节、踩过的坑,一条条摊开讲。
适合谁看:如果你在用 Codex 这类编码代理跑真实项目,遇到过"聊着聊着它就傻了"的情况,这篇对你有用。如果你只是想了解上下文压缩的原理,也能看,但我会更偏实操。
2. 为什么压缩之后会"断片":先把病根找出来
2.1 上下文压缩到底压掉了什么
要解决问题,先得看清楚压缩干了什么。主流编码代理的压缩策略,本质上是把 token 序列做有损摘要。它通常按这样的优先级丢弃信息:
- 最早期的对话轮次,优先被摘要成一段自然语言
- 工具调用的原始返回(比如文件内容、命令输出),被截断或概括
- 中间过程的试错记录,大量被丢弃
这个策略对"闲聊型"对话没问题,但对"施工型"对话是灾难。因为编码代理的工作状态,很大一部分藏在工具调用的原始数据里,而不是藏在自然语言里。
举个我日志里的真实例子。第 3 天那次压缩前,代理刚执行过一次文件读取,拿到了一个 200 行的配置文件。压缩后,摘要里只写了"读取了配置文件,确认了数据库连接参数"。结果代理下一步要改连接池大小时,它不知道参数名到底是pool_size还是max_connections,只能重新读一遍文件。这一读,又吃掉几百 token,等于压缩白做了。
注意:压缩的收益是"省 token",但代价是"代理要重新获取信息"。如果重新获取的成本高于压缩省下的,这次压缩就是负收益。很多代理框架不算这笔账,导致越压越慢。
2.2 "接不上"的三种典型表现
我把 430 条记录里所有"断片"事件做了分类,归成三类,占比大概是:
| 断片类型 | 占比 | 典型表现 |
|---|---|---|
| 事实丢失 | 约 52% | 忘记已确认的函数名、变量名、文件路径 |
| 决策丢失 | 约 31% | 重复尝试已被否决的方案,或推翻已定的架构决策 |
| 进度丢失 | 约 17% | 不知道当前改到哪一步,重复已完成的工作 |
事实丢失最好理解,就是精确信息没了。决策丢失更隐蔽——代理不是忘了"做过什么",而是忘了"为什么不做某个方案"。比如它之前试过用正则解析某个格式,发现边界情况太多放弃了,压缩后它又兴冲冲地回去写正则。进度丢失则表现为"原地打转",明明已经改完三个文件,它又从第一个文件开始检查。
这三类问题的共同点是:它们需要的都不是"概括性记忆",而是"可检索的精确记忆"。这就直接决定了 long_mem 的设计方向。
2.3 为什么现成的方案不够用
有人会问,向量数据库检索不行吗?RAG 不就是干这个的?我试过,直接说结论:通用 RAG 对编码代理的记忆需求,匹配度不高。
原因有三。第一,编码场景的检索键往往是符号而不是语义,比如你要找的是verify_token这个函数,而不是"那个验证令牌的东西",语义检索反而容易召回一堆不相关的。第二,编码代理需要的是结构化状态,不是一堆文本片段,检索回来五段相关代码,代理还得自己拼。第三,延迟敏感,代理每一步都要查记忆,如果每次检索要几百毫秒,整个循环就卡死了。
所以 long_mem 没有走通用 RAG 路线,而是走了一条更"土"但更稳的路:结构化记忆 + 符号索引 + 轻量激活。下面详细讲。
3. long_mem 与 engram 的设计:把记忆做成可激活的单元
3.1 engram 的数据结构长什么样
engram 是整个系统的原子单位。我把它设计成一个带类型标签的结构化记录,而不是一段自由文本。一条 engram 大概长这样(用伪结构表示):
{ "id": "eng_20240612_0031", "type": "fact", "symbol": "verify_token", "scope": "auth/user.py", "content": { "signature": "verify_token(user_id: str, scope: str) -> bool", "notes": "scope 为空时默认校验 read 权限" }, "confidence": 0.95, "created_at": "step_142", "last_activated": "step_389", "activation_count": 7 }关键字段解释一下。type分四类:fact(事实)、decision(决策)、progress(进度)、artifact(产物,比如某个文件的关键片段)。symbol是符号索引键,专门给代码场景用的,让检索能精确命中。confidence是置信度,因为有些记忆是代理"推测"出来的,不能和确认过的事实同等对待。activation_count和last_activated是给激活策略用的,越常被激活、越近被激活的记忆,优先级越高。
为什么用结构化而不是纯文本?因为结构化让"重新注入上下文"这件事变得可控。压缩后我要把记忆塞回上下文,如果记忆是文本,我只能整段塞;如果是结构化的,我可以只塞signature字段,省 token 又精确。
3.2 记忆是怎么被"写"进去的
写入时机很关键。我试过两种策略:一种是每步都写,一种是关键节点写。前者噪音太大,后者容易漏。最后采用的是混合触发:
- 工具调用返回后,如果返回内容包含新的符号定义(函数、类、配置项),自动抽取成
fact类型 engram - 代理显式表达决策时(比如"我们决定用方案 B"),抽取成
decision类型 - 每完成一个文件修改,写一条
progress类型 - 压缩触发前,强制做一次全量扫描,把当前工作状态固化
这里有个细节值得说:抽取不是靠另一个模型调用。我一开始想用一个小模型来做信息抽取,实测下来延迟太高,而且不稳定。后来改成基于规则的抽取——正则匹配函数定义、配置项赋值、文件路径,配合代理输出里的固定标记(比如我在系统提示里要求它用特定格式声明决策)。规则抽取的召回率不如模型,但精确率高、零延迟、可预测,对编码场景够用了。
实操心得:不要迷信"用模型处理模型"。在代理的每一步都插一次模型调用,延迟会累积到无法接受。能用规则解决的,坚决用规则。
3.3 记忆是怎么被"读"回来的
读取分两个通道,这是 long_mem 的核心设计。
通道一:符号精确匹配。代理当前正在处理的符号(比如它刚读了verify_token的调用点),直接拿符号去索引里查,命中就返回对应 engram。这个通道快、准,覆盖大部分事实类需求。
通道二:激活扩散。当符号匹配不到,或者需要的是决策/进度类记忆时,走激活扩散。简单说,就是从一个种子 engram 出发,沿着"相关"关系往外扩一跳。相关关系怎么建?我用了三个维度:同一文件、同一符号前缀、时间上相邻(step 差小于 20)。这个策略借鉴了记忆的"激活扩散"理论,实测比纯向量检索在编码场景下召回质量高不少。
两个通道的结果会合并、去重、按confidence × recency × activation_count排序,取前 N 条注入上下文。N 我设的是 8,超过 8 条注入的 token 成本就划不来了。
3.4 为什么这套设计能"接得上"
回到最初的问题:压缩后代理为什么接不上?因为它丢了精确的、结构化的、可检索的工作状态。long_mem 做的事情,本质上是把这份状态从"易失的上下文"里搬到了"持久的记忆库"里。
压缩可以随便压上下文,因为真正重要的东西已经落库了。压缩后代理需要什么,就按需从库里取什么。这就把"压缩"和"记忆"解耦了——压缩只管省 token,记忆只管保状态,各司其职。
这个解耦带来的一个额外好处是:记忆可以跨会话复用。同一个项目,今天跑一半关了,明天重开,engram 还在,代理能接着昨天的进度干。这一点在 10 天实验里帮我省了大量重复劳动。
4. 10 天实验的完整实操过程
4.1 实验环境与项目选择
环境没什么特别的,就是一台开发机,跑编码代理的 CLI,接的是本地可用的模型服务。三个实验项目分别是:
- 项目 A:一个中等规模的 Python 后端服务,约 1.2 万行,主要改鉴权和数据层
- 项目 B:一个前端组件库,约 8000 行,主要做重构
- 项目 C:一个脚本工具集,约 3000 行,主要加功能
选这三个是因为它们的"压缩压力"不同。项目 A 文件多、依赖深,压缩触发最频繁;项目 C 简单,压缩少。这样能覆盖不同场景。
4.2 关键参数与计算过程
参数不是拍脑袋定的,我列一下几个核心参数的来由。
记忆注入条数 N=8。这个是从 token 预算倒推的。我的上下文窗口假设是 32k token,压缩后留给"记忆注入"的预算大概 2k token。一条 engram 平均 250 token,2000/250 = 8。如果 engram 更精简(只注入 signature),能塞更多,但信息完整度下降,权衡后取 8。
激活扩散的跳数 = 1。试过 2 跳,召回的相关记忆数量暴涨,但精确率掉得厉害,注入一堆弱相关的东西反而干扰代理。1 跳是精确率和召回率的平衡点。
recency 衰减半衰期 = 50 步。意思是 50 步之前的记忆,权重减半。这个值是根据日志里"代理实际回看多远"统计出来的——大部分有效回看发生在最近 50 步内,更早的记忆命中率明显下降。
confidence 阈值 = 0.6。低于这个值的记忆不注入,避免代理被自己的推测带偏。
4.3 一次完整的压缩-恢复流程记录
拿第 5 天项目 A 的一次真实流程举例,这是日志里比较典型的一次。
压缩前状态:代理正在改鉴权模块,已经确认了三个函数的签名,否决了"用装饰器统一鉴权"的方案(因为和现有中间件冲突),改完了两个文件。
压缩触发:上下文到 90% 阈值,系统执行摘要压缩,历史对话被压成一段 300 字的概述。
记忆固化:压缩前钩子触发,扫描当前状态,写入 6 条 engram:3 条 fact(三个函数签名)、1 条 decision(否决装饰器方案及原因)、2 条 progress(两个文件已改完)。
压缩后第一步:代理要改第三个文件,需要知道第一个文件的接口。它读取调用点,提取符号verify_token,走符号匹配通道,命中对应 engram,拿到精确签名。
压缩后第二步:代理准备动手,激活扩散把那条 decision 也带出来了,提示它"装饰器方案已否决",它就没再往那个方向想。
结果:这次压缩-恢复全程没有出现重复劳动,代理无缝接上。对比实验里没开 long_mem 的对照组,同样的压缩点,对照组重复读了一次文件、重试了一次装饰器方案,多花了约 12 步。
4.4 430 条记录里我关注的核心指标
10 天下来,我重点盯三个指标:
| 指标 | 含义 | 实验组 | 对照组 |
|---|---|---|---|
| 压缩后重复劳动步数 | 压缩后重做已完成工作的步数 | 平均 1.3 步 | 平均 6.8 步 |
| 压缩后首次命中率 | 压缩后第一步就取到正确记忆的比例 | 78% | 不适用 |
| 单任务总步数 | 完成一个任务的总交互步数 | 平均 41 步 | 平均 53 步 |
重复劳动步数从 6.8 降到 1.3,是我最满意的结果。总步数降了约 22%,说明记忆机制确实在省事,而不是在添乱。
注意:这些数字是在我这套特定配置下测的,换模型、换项目规模,绝对值会变。但趋势——开了记忆比不开强——在三个项目上是一致的。
5. 踩过的坑与排查技巧实录
5.1 记忆污染:最坑的一个问题
实验第 2 天,我发现代理开始"记错东西"。它坚称某个函数返回int,实际返回bool。查了半天,发现是记忆污染:代理在探索阶段猜了一个签名,被规则抽取误当成 fact 写进了库,后面一直用这个错的。
这个坑的根源是:规则抽取分不清"确认的事实"和"代理的猜测"。我的解决办法是引入来源标记。只有来自工具调用返回(真实文件内容)的才标confidence=0.95,来自代理自然语言表述的标confidence=0.7,来自代理推测的标confidence=0.5。低于 0.6 的不注入。同时,当真实文件内容与已有 engram 冲突时,以文件内容为准,覆盖旧记忆。
这个"文件优先"原则很重要。记忆库永远不能比真实代码更权威,否则代理会基于错误记忆改出错误的代码。
5.2 激活扩散的"雪崩"
第 4 天遇到一次性能问题:某一步激活扩散突然召回了几百条记忆,排序和去重花了明显时间。排查发现是某个高频符号(比如main)作为种子,一跳扩散把半个库都带出来了。
解决办法是给扩散加度数上限:一个符号关联的 engram 超过 30 条时,不再扩散,只做精确匹配。同时对高频符号做特殊处理,降低其作为扩散种子的权重。这个改动之后,再没出现过雪崩。
5.3 常见问题速查表
把 10 天里遇到的问题整理成表,方便对照排查:
| 现象 | 可能原因 | 排查方向 | 解决 |
|---|---|---|---|
| 代理重复已否决的方案 | decision 类记忆没写入或没召回 | 查压缩前钩子是否触发 | 补写 decision,检查扩散通道 |
| 代理用错函数签名 | 记忆污染或未覆盖 | 查 engram 的 confidence 和来源 | 以文件内容覆盖,降权推测记忆 |
| 压缩后变慢 | 记忆注入过多或检索慢 | 查注入条数和检索耗时 | 降 N,加度数上限 |
| 记忆库膨胀 | 重复写入同一事实 | 查是否有去重逻辑 | 按 symbol+scope 去重,保留最新 |
| 跨会话接不上 | 记忆没持久化 | 查存储层 | 落盘,按项目隔离 |
5.4 几条压箱底的经验
第一,记忆的写入比读取更重要。读取策略再花哨,写进去的是垃圾,召回也是垃圾。我后来把大部分精力放在写入的精确性上,收益比优化检索大得多。
第二,不要试图记住一切。我一开始想全量记录,结果库膨胀、检索变慢、噪音变多。后来只记四类核心信息,反而效果好。记忆系统的价值在于"选择性遗忘",不在于"全记住"。
第三,给记忆加过期机制。有些 progress 类记忆,任务完成后就没用了,留着只会干扰。我加了个规则:任务标记完成后,相关 progress 记忆降权到不注入。
第四,实测永远比理论靠谱。我设计时觉得激活扩散是核心,实测发现符号精确匹配才是主力,扩散只是补充。如果不实测,我可能会在扩散上过度投入。
6. 关于 Codex 这类代理的接入细节
既然热搜里 Codex 相关词很多,我顺带说说这套记忆机制怎么接到 Codex 这类编码代理上。核心是找到压缩钩子和工具调用钩子两个切入点。
压缩钩子负责在压缩前固化记忆,工具调用钩子负责在每次工具返回后抽取记忆。这两个钩子大部分代理框架都提供了扩展点,如果没有,就得在代理循环外面包一层。
接入时要注意几点。一是别改代理的核心循环,记忆机制应该是旁路的,出问题能随时关掉,不影响主流程。二是记忆注入要放在系统提示之后、用户输入之前,位置不对代理可能忽略。三是控制注入格式,我用的是紧凑的结构化文本,不是 JSON,因为 JSON 的括号和引号很费 token。
至于 Codex 安装、登录、模型配置这些,属于基础环境问题,和记忆机制是两码事。环境跑通了,记忆机制才有意义。我见过有人环境都没配好就折腾记忆,那是本末倒置。
这套东西后续还能往两个方向扩。一个是跨项目记忆共享,把通用的编码约定(比如"这个团队用 snake_case")抽成全局记忆。另一个是记忆的主动整理,定期把零散的 fact 合并成更高层的知识。这两个我还没做,但思路是通的。
最后分享一个我自己的体会:上下文压缩不是敌人,记忆机制也不是万能药。真正管用的是想清楚"什么必须记住、什么可以忘"。这个判断做对了,机制怎么实现都是次要的。我花了前三天才想明白这一点,后面七天就顺多了。