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循环中,记忆出现在两个环节:

  1. 推理前的上下文组装:每一轮开始前,系统需要从持久化存储中取出相关记忆,组装成LLM的输入上下文。取什么、取多少、以什么格式组织——这些决策直接影响推理质量和成本。
  2. 行动后的记忆更新:每轮结束后,新的交互需要写入持久化存储。对话历史追加上去,工作记忆可能更新,工具执行结果记录下来。

简单说就是:推理时读记忆,行动后写记忆。读决定了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记忆系统都需要压缩策略——把早期的对话历史浓缩成摘要,而不是逐条保留。

通用的压缩思路是:

  1. 设置一个触发阈值(比如context占用达到80%)
  2. 保留最近N轮完整对话(保证近期的交互细节不丢失)
  3. 把N轮之前的对话压缩成一段摘要——用LLM生成一个简短的总结,包含关键决策和结果
  4. 用摘要替换掉旧的历史

这样可以保持”近期完整 + 远期摘要”的平衡。近期上下文保留细节让模型能准确理解当前状态,远期摘要保留关键信息又不需要付出逐条记录的成本。

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
2
3
4
5
session_abc123.jsonl 内容示例:
{"type":"user","content":"帮我看看这个 bug","ts":1717000000000}
{"type":"assistant","content":"让我先读一下代码","ts":1717000001000}
{"type":"tool_call","name":"read","args":{"path":"src/foo.ts"},"result":"...","ts":1717000002000}
{"type":"tool_result","name":"read","content":"...","ts":1717000002001}

写入时只做追加——fs.appendFileSync(file, JSON.stringify(entry) + '\n')。这个操作不论文件多大都是O(1),保证了写入性能不会随着session增长而劣化。

4.2增量流式解析 + 破损行容错 + 自动修复

读取JSONL时不是一次性读入内存再 JSON.parse,而是逐行流式解析。这样做有三个原因:

  1. 大文件不爆内存:逐行处理,不需要把整个文件放进内存。即使session文件增长到几十MB,内存占用始终是”一行的大小”。
  2. 并发安全:流式解析能容忍文件在读取过程中被追加——读到一半有新行写入,不会影响已读取的部分。
  3. 破损行容错:文件写入可能因进程崩溃、磁盘错误产生损坏行(比如写入了一半个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
2
3
4
当前任务:修复 src/foo.ts 中第 42 行的 null reference
已尝试:方案 A(加 optional chain)失败,因为 foo 可能是 undefined
待尝试:方案 B(加 early return)
相关文件:src/foo.ts, tests/foo.spec.ts

工作记忆由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触发时用精确。

压缩的执行流程:

  1. 保留最近的N轮完整对话(当前配置是10轮,保留最近的交互细节)
  2. 把N轮之前的所有对话发送给LLM,请求生成一段摘要:”用户最初要求X,agent通过工具A和B完成了Y,遇到了问题Z,当前方案是W”
  3. 把生成的摘要作为一条 compaction_marker 类型的system message注入context
  4. .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不止活在当下一轮对话中。这篇文章从三个角度拆解了记忆系统的设计:

  1. 概念层面:agent需要对话历史(完整记录)、工作记忆(当前关注)、长期知识(跨session事实)三种记忆类型。记忆在ReAct循环中扮演”推理时读、行动后写”的角色。

  2. 方案对比:方案A(全量JSON内存加载)实现简单但大session性能差;方案B(SQLite数据库)功能完整但依赖重且二进制不可读;方案C(JSONL append-only + compaction)零依赖、纯文本可读,适合学习项目。

  3. aptbot的选择:JSONL append-only保证写入性能不随session增长劣化,增量流式解析 + 破损行容错 + 自动修复保障数据韧性,SessionEntry联合类型提供类型安全,Compaction(80% 触发 / 30% 目标 / 三级估算)控制context增长,Working memory + /continue提供轻量的跨会话继承。

下一篇文章,我们看Skills系统:agent的”知识层”如何做到既丰富又不撑爆system prompt。