Memory系统:持久化、压缩、跨会话记忆
没有记忆的agent每次对话都从零开始。它不记得你十分钟前说了什么,不记得昨天修了哪个bug,不记得这个项目上周的进度。这就像一个同事每次见面都要你重新介绍自己——你能容忍,但无法合作。
但”记住一切”也不是答案。LLM的context window是有限的——当前主流模型在128K-200K token之间,看起来很大,但一轮完整的ReAct循环(对话历史 + 工具定义 + 工具结果 + system prompt)可以轻松吃掉10K-20K token。几十轮交互后,context就会被撑爆。而且context越长,推理速度和成本都线性增长。
所以Memory系统的核心矛盾是:既要记住足够多的信息以保持连续性,又不能让记忆无限制膨胀撑爆context window。这篇文章会从记忆系统的基本概念讲起,对比几种持久化方案的取舍,然后深入看aptbot如何通过append-only日志 + 定时压缩来解决这个矛盾。
一、概念:agent需要什么样的”记忆”
1.1三种记忆类型
认知科学把人脑的记忆分为三层:感觉记忆、短期记忆、长期记忆。agent的记忆系统也有类似的层次划分,但工程实现上,我们通常按功能分为三类:
对话历史(Conversation History):当前session中每一轮的完整记录——用户说了什么、模型回复了什么、调用了哪些工具、结果如何。这是最”贵”的记忆,因为它需要完整的上下文才能让模型理解当前状态。
工作记忆(Working Memory):agent正在关注的”当前任务”的关键信息。它不像对话历史那样逐条记录,而是agent主动维护的”摘要卡片”——当前在修哪个bug、已经尝试了什么方案、还有什么待办。工作记忆是”主动维护”的,agent自己决定哪些信息值得记住。
长期知识(Long-term Knowledge):跨session的事实性知识——项目的代码结构、常用的命令模式、用户的偏好设置。这类记忆不需要每轮加载,只在相关时检索。长期知识是最”便宜”的,但也是最难做好的——它需要高效的检索和合理的更新策略。
1.2记忆在ReAct循环中的角色
在ReAct循环中,记忆出现在两个环节:
- 推理前的上下文组装:每一轮开始前,系统需要从持久化存储中取出相关记忆,组装成LLM的输入上下文。取什么、取多少、以什么格式组织——这些决策直接影响推理质量和成本。
- 行动后的记忆更新:每轮结束后,新的交互需要写入持久化存储。对话历史追加上去,工作记忆可能更新,工具执行结果记录下来。
简单说就是:推理时读记忆,行动后写记忆。读决定了agent当前的”视野”,写决定了agent未来的”可回忆范围”。
1.3持久化存储的工程需求
一个agent的记忆存储方案,需要满足以下工程需求:
- 写入高效:每轮交互后都要写,写入必须快。最好是append-only的O(1) 写入。
- 读取灵活:要能按顺序读完整历史(恢复上下文),也要能跳读(检索特定信息)。
- 容错性好:进程崩溃、磁盘写满、并发写入——这些异常场景不能导致数据全部丢失或文件不可解析。
- 轻量可读:对学习项目来说,存储文件应该能用文本编辑器打开直接看。这既是教学优势,也是调试优势。
- 零依赖或低依赖:不需要引入外部数据库系统就能正常运行。
这些需求之间可能存在冲突。比如”写入高效”和”读取灵活”就很难兼得——特定存储方案的选择本质上是对这些需求的优先级排序。
二、通用设计方案
2.1持久化格式的选择
agent的记忆持久化,本质上是一个”如何把结构化的交互数据存到磁盘上”的问题。常用的选择有三个:
JSON数组:把整个对话历史作为一个JSON数组存到一个文件里。简单直接,JSON.parse / JSON.stringify 两行代码就能读写。
但问题在于:append一个entry需要重写整个文件。当文件增长到几MB(对话历史很容易达到这个规模),每次写入的O(N) 成本会变得不可接受。而且整个文件读入内存再解析,对大session的内存压力很大。
SQLite:用嵌入式关系数据库存储每条entry。写入是O(1) 的INSERT,读取可以用SQL灵活查询和过滤,并发控制、事务、索引一应俱全。
但SQLite是二进制格式——你不能用文本编辑器直接看数据。它的引入会增加打包体积和依赖复杂度。对于学习项目来说,SQLite像一个”黑箱”——你看不到数据是怎么存的,只能通过API操作。
JSONL(JSON Lines):每行一个独立的JSON对象,写入是append-only的O(1) 操作。读取时逐行解析,不需要把整个文件加载到内存。纯文本格式,任何编辑器都能打开直接查看。
JSONL的代价是”按行号修改”的成本高——要改某一行需要重写后续所有行。但这个代价可以通过”只append、定期compact”的策略来管理。
2.2压缩策略的通用思路
无论用什么存储方案,context window的容量上限是无法回避的硬约束。所以任何agent记忆系统都需要压缩策略——把早期的对话历史浓缩成摘要,而不是逐条保留。
通用的压缩思路是:
- 设置一个触发阈值(比如context占用达到80%)
- 保留最近N轮完整对话(保证近期的交互细节不丢失)
- 把N轮之前的对话压缩成一段摘要——用LLM生成一个简短的总结,包含关键决策和结果
- 用摘要替换掉旧的历史
这样可以保持”近期完整 + 远期摘要”的平衡。近期上下文保留细节让模型能准确理解当前状态,远期摘要保留关键信息又不需要付出逐条记录的成本。
2.3跨会话记忆的两种模式
agent的记忆如果不跨越session,那么每次新会话都是一次”失忆”。跨会话记忆的通用方案有两种:
完整继承:新session启动时,把旧session的完整历史加载到context。最简单,但context膨胀最快——如果旧session已经跑了几百轮,继承后直接超限。
摘要继承:新session启动时,只从旧session继承”关键摘要”(比如任务状态、待办事项、关键发现),不加载完整历史。更节省token,但摘要的质量直接决定继承的效果。
这两种模式不是互斥的——很多系统会结合使用:短间隔的新session用完整继承,长间隔的用摘要继承。
三、市面其他记忆方案对比
在不同agent项目中,记忆系统的持久化和压缩策略各有侧重。以下是三种有代表性的设计路线。
3.1方案A:全量JSON内存加载
这套路线的做法最朴素:把所有对话历史以JSON数组格式持久化,启动时整个文件读入内存,运行时在内存中追加修改,结束时整体写回磁盘。
设计特点:
- 数据结构简单:就是一个JSON数组
[{role, content, timestamp}, ...] - 全量内存加载:session启动时
JSON.parse(fs.readFileSync(path)),所有历史在内存中 - 整文件写回:每次变更后
fs.writeFileSync(path, JSON.stringify(data))
优势:
- 实现极为简单:总共只需要几十行代码
- 内存中所有数据立即可用,读取速度最快
- 没有任何外部依赖
劣势:
- 写入是O(N) 操作——session越长写入越慢,几百轮后每次append可能需要几百毫秒
- 大session的内存占用持续增长——一个500轮的session可能占用几十MB内存
- 并发写入不安全——两次并行写操作可能导致数据丢失
- 没有容错——如果写入中途进程崩溃,文件可能损坏成不可解析的JSON
3.2方案B:SQLite数据库
这套路线的做法是把每条entry作为一行写入SQLite数据库表,通过SQL进行查询和管理。
设计特点:
- 结构化存储:每个字段独立列(role、content、tool_name、timestamp等)
- SQL查询:按时间范围、按角色、按工具类型灵活过滤
- 事务支持:写入有ACID保证,崩溃后自动回滚
- 索引优化:可以在session_id、timestamp等字段建索引加速查询
优势:
- 功能最完整——事务、索引、复杂查询全部开箱即用
- 读性能稳定——即使百万级数据也能通过索引快速定位
- 并发控制成熟——多个会话同时写入不会互相干扰
- 数据完整性高——写入崩溃不会损坏已有数据
劣势:
- 依赖重——项目需要包含SQLite库,增加打包体积和编译时间
- 二进制不可读——数据文件
.db无法用文本编辑器直接查看,对学习和调试不友好 - 查询需要SQL知识——简单的”读全部”也要写
SELECT * FROM entries ORDER BY seq - 对小数据量场景过度设计——agent session通常只有几百到几千条entry,SQLite的索引和事务优势体现不出来
- 嵌入式数据库的运维知识(WAL模式、vacuum、连接池)对学习项目是负担
3.3方案C:JSONL append-only + compaction
这套路线用JSONL格式做append-only写入,配合定期的compaction机制控制文件增长和context膨胀。
设计特点:
- append-only写入:每次新entry追加一行到文件末尾,O(1) 操作,不重写已有数据
- 增量解析:读取时逐行流式解析,不一次性加载整个文件到内存
- 定期compaction:当context占用达到阈值时,把旧历史压缩成摘要,重写文件
- 容错机制:破损行跳过 + 自动截断修复
优势:
- 零依赖——纯文本格式,不需要数据库库
- 纯文本可读——任何编辑器都能打开查看,方便学习和调试
- 写入性能稳定——无论session多大,追加一行都是O(1)
- 读取内存可控——逐行解析,文件再大也不会爆内存
劣势:
- compaction逻辑复杂——需要实现”旧历史 → 摘要 → 替换”的完整流程
- 不支持随机行修改——要改中间某行只能通过compaction重写
- 并发写入需要文件锁保护——不过对单agent场景通常够用
3.4三种方案对比
| 维度 | 方案A(全量JSON) | 方案B(SQLite) | 方案C(JSONL + compaction) |
|---|---|---|---|
| 写入性能 | O(N) 整文件写 | O(1) INSERT | O(1) append |
| 内存开销 | 全量加载,高 | 按需加载,低 | 逐行解析,低 |
| 依赖复杂度 | 无依赖 | 需要SQLite库 | 无依赖 |
| 数据可读性 | 纯文本可读 | 二进制不可读 | 纯文本可读 |
| 容错能力 | 差(崩溃丢全部) | 好(事务保护) | 中(略过破损行) |
| 并发安全 | 差 | 好 | 中(需文件锁) |
| 实现复杂度 | 低 | 中 | 中高(compaction逻辑) |
| 适合场景 | 极简原型 | 企业级多用户 | 学习项目 / 个人agent |
四、aptbot的设计特点
aptbot的选择是方案C的路线——JSONL append-only + compaction。原因很直接:作为学习型个人项目,”零依赖、纯文本可读、教学友好”的优先级高于”功能完整、企业级并发”。下面看具体实现。
4.1 JSONL append-only:为什么每行一个JSON
每个session对应一个 .jsonl 文件,文件名是UUID v4。每行一个JSON对象,按时间顺序append。

1 | session_abc123.jsonl 内容示例: |
写入时只做追加——fs.appendFileSync(file, JSON.stringify(entry) + '\n')。这个操作不论文件多大都是O(1),保证了写入性能不会随着session增长而劣化。
4.2增量流式解析 + 破损行容错 + 自动修复
读取JSONL时不是一次性读入内存再 JSON.parse,而是逐行流式解析。这样做有三个原因:
- 大文件不爆内存:逐行处理,不需要把整个文件放进内存。即使session文件增长到几十MB,内存占用始终是”一行的大小”。
- 并发安全:流式解析能容忍文件在读取过程中被追加——读到一半有新行写入,不会影响已读取的部分。
- 破损行容错:文件写入可能因进程崩溃、磁盘错误产生损坏行(比如写入了一半个JSON)。流式解析遇到破损行会跳过并输出warning,不阻塞整个加载过程。
破损行容错的基础上,aptbot还加了一层自动修复:检测到破损行后,调用 fs.truncateSync 把文件截断到最后一个完整行之后。这是”修复”而非”忽略”——它把文件恢复到可正常使用的状态,保证下次启动时agent能加载所有有效数据。
这个”容错 + 自修复”组合让JSONL在非理想环境下也能稳定工作。它不保证零数据丢失(破损行的内容确实丢了),但保证文件始终可解析、agent始终能启动。对一个可能随时被用户 Ctrl+C 中断的个人项目来说,这层韧性很重要。
4.3 SessionEntry联合类型 + UUID路径校验
session文件中的每一行是一个 SessionEntry,使用TypeScript的联合类型(discriminated union)来区分不同类型:
- user message:用户的输入
- assistant message:模型的回复文本
- tool_call:工具调用记录(工具名 + 参数 + 结果)
- compaction_marker:压缩点标记,标识历史摘要的位置
- metadata:会话元数据(标题、创建时间、模型名称等)
联合类型的价值在于类型安全——TypeScript在解析每一行时强制做类型收窄(type narrowing),确保你不可能把一个 tool_call 当成 user message 来处理。这是方案A和方案B都做不到的编译时保障。
session文件名使用UUID v4格式。每次读写session文件前都校验UUID格式——只接受符合 /^[0-9a-f-]{36}$/ 的sessionId。这是一层容易被忽略的安全防护:即使攻击者构造 ../../etc/passwd 作为sessionId试图做路径遍历,UUID校验会直接拒绝。它和tool系统中的path-guard形成了跨模块的安全合力。
4.4 Working memory + /continue跨会话继承
aptbot区分两类记忆,而不是一个”大而全”的存储:
会话历史(Conversation History):完整的turn-by-turn记录,存在 .jsonl 文件。这是agent的”长期存档”,只读不删(除非compaction)。
工作记忆(Working Memory):agent当前关注的关键信息,存在 .meta.json sidecar文件中。这不是历史的压缩,而是agent主动维护的”当前状态卡”。
举个例子:当agent在执行”修复bug X”任务时,工作记忆可能包含:
1 | 当前任务:修复 src/foo.ts 中第 42 行的 null reference |
工作记忆由agent通过 update_working_memory 工具主动更新。这意味着agent自己决定”什么信息值得记住”——这是一个语义化的记忆机制,不是简单的”最后几轮对话”。
/continue 命令实现跨会话继承:新session启动时,用户指定 --continue <sessionId>,新session继承旧session的工作记忆(不继承完整历史)。这让用户可以”昨天没做完的事今天接着做”——agent不需要重新读所有历史,工作记忆已经把关键信息浓缩好了。
4.5 Compaction:80% 触发、30% 目标、三级token估算
session越长,context越大。当context占用超过LLM的context window时,推理会失败——要么截断关键信息,要么直接报错。Compaction是解决这个问题的核心机制。
aptbot的compaction参数:
- 触发阈值80%:当当前context的token估算值达到context window的80% 时触发压缩。留20% 的缓冲避免刚好在边缘时因一次tool result返回就超限。
- 目标30%:压缩完成后,context占用降到30%。留下70% 的空间给后续对话,避免频繁压缩。
- 三级token估算:提供粗略/中等/精确三种估算级别。粗略估算最快(基于字符数x系数),精确估算最慢(使用tokenizer)。平衡精度与性能——日常用粗略,compaction触发时用精确。
压缩的执行流程:
- 保留最近的N轮完整对话(当前配置是10轮,保留最近的交互细节)
- 把N轮之前的所有对话发送给LLM,请求生成一段摘要:”用户最初要求X,agent通过工具A和B完成了Y,遇到了问题Z,当前方案是W”
- 把生成的摘要作为一条
compaction_marker类型的system message注入context - 将
.jsonl文件重写为:摘要行 + 最近N轮的完整行
这样做的效果是:agent还能引用早期发生的事(通过摘要),但不需要为每一条历史消息付出逐条的token成本。
对比方案A的”全量内存加载”,compaction让aptbot能在128K context window下运行几百轮不超限;对比方案B的”SQLite但无压缩”,compaction从根源上控制了context增长,而不只是把存储问题推给数据库。
五、发展方向
5.1三层记忆架构(L1/L2/L3)
aptbot当前的记忆系统是”单层”的——只有会话历史 + 工作记忆。规划的远期架构是三层,参考认知科学的分层记忆模型:
- L1(即时记忆):当前session的对话历史,正在使用。每次LLM调用都完整加载,最贵。
- L2(短期记忆):近期session的摘要,按需检索。当agent需要使用”上周的工作成果”时,从L2检索相关摘要加载到context。
- L3(长期记忆):跨session的事实性知识,按相关性检索。项目的代码结构、常用的命令模式、用户的偏好设置——这些不需要每轮加载,只在相关场景由agent主动查询。
三层架构的核心思想是”按使用频率分层存储”:频繁访问的放贵的L1,少访问的沉到便宜的L3,按需”提升”或”沉降”。L1已实现,L2/L3是未来版本的方向。
5.2 Working dict:让LLM自己管键值存储
当前的工作记忆是一个字符串字段,agent通过 update_working_memory 工具整体替换。远期的规划是working dict——结构化的键值存储:
set(key, value):设置某项get(key):读取某项delete(key):删除某项keys():列出所有键
这让agent能”记住多件事并分别引用”,而不是把所有事塞进一个字符串。执行多步任务时,每步的中间结果可以存到不同的key下,后续步骤按key取用。相比当前”替换整个字符串”的模式,working dict更精确、更可控。
5.3记忆检索的语义化
当前记忆检索是基于”时间顺序”和”显式继承”的——要么按时间顺序加载历史,要么通过 /continue 显式继承。未来可以引入语义检索:agent能”回忆”相关记忆,而不是只按时间线性回溯。
比如用户说”还记得上周我们讨论的数据库迁移方案吗?”,agent应该能检索到相关的session摘要或工作记忆内容,而不是从头翻历史。这需要嵌入索引和向量检索的引入,是L2/L3架构落地的关键技术。
小结
Memory系统解决的是agent的”连续性”问题——让agent不止活在当下一轮对话中。这篇文章从三个角度拆解了记忆系统的设计:
概念层面:agent需要对话历史(完整记录)、工作记忆(当前关注)、长期知识(跨session事实)三种记忆类型。记忆在ReAct循环中扮演”推理时读、行动后写”的角色。
方案对比:方案A(全量JSON内存加载)实现简单但大session性能差;方案B(SQLite数据库)功能完整但依赖重且二进制不可读;方案C(JSONL append-only + compaction)零依赖、纯文本可读,适合学习项目。
aptbot的选择:JSONL append-only保证写入性能不随session增长劣化,增量流式解析 + 破损行容错 + 自动修复保障数据韧性,SessionEntry联合类型提供类型安全,Compaction(80% 触发 / 30% 目标 / 三级估算)控制context增长,Working memory + /continue提供轻量的跨会话继承。
下一篇文章,我们看Skills系统:agent的”知识层”如何做到既丰富又不撑爆system prompt。