记忆与持久化:SQLite 作为 agent 的长期记忆
30 秒导读: RA.Aid 的三个阶段(研究→计划→实现)可能跨很多轮 LLM 调用、甚至跨进程重启。 它不靠"把所有历史都塞回上下文"来记事,而是把关键记忆——事实、代码片段、研究笔记、计划—— 抽出来写进一个本地 SQLite 库,下一个阶段开始时再格式化成 markdown 注入进 prompt。 上下文窗口是易失的"内存",SQLite 是持久的"磁盘"。本章讲清这块磁盘怎么读、怎么写、怎么清理。
本章是 RA.Aid 系列的第 3 章。主线编排见 01,agent 后端见 02;记忆写入用的那些工具的非记忆职责在 04,轨迹用于成本与 UI的那部分在 05。
1. 这是什么(零基础也能懂)
-
一句话定义: 一套"把 agent 该记住的东西外置到 SQLite、需要时再喂回 prompt"的长期记忆系统。
-
它解决什么问题: LLM 的上下文窗口有限且易失——一轮对话结束、进程一退,模型就"失忆"了。 可 RA.Aid 干的是"帮你改一个大项目的代码"这种长任务:研究阶段发现的事实,到实现阶段还得用; 今天跑到一半、明天接着跑,之前的进展不能丢。于是它需要一块任务重启也不消失的记忆。
-
它记哪些东西: 不是把整段对话录下来,而是只记结构化的高价值条目:
记忆种类 记什么 典型来源 Key Fact(关键事实) 关于项目/任务的一句话结论 研究阶段发现 Key Snippet(关键代码片段) 一段带文件名+行号的源码 读到关键接口时 Research Note(研究笔记) 一段信息密集的研究总结 研究收尾时 Plan(计划) 实现计划(markdown) 计划阶段产出 Human Input(人类输入) 用户每次输 入的原话及来源 CLI / chat / HIL Trajectory(轨迹) 每次工具调用的名称/参数/结果/成本 每步工具执行 -
用起来什么样: agent 在运行时会主动调用
emit_key_facts(["项目用 peewee 做 ORM"])这样的工具, 终端里立刻弹出一个💡 Key Fact #7面板,同时这条事实被写进.ra-aid/目录下的 SQLite 文件。 下一阶段的 agent 启动时,这条事实会以## 🔑 Key Fact #7的形式出现在它的 system prompt 里。 -
一句话直觉: 把上下文窗口当内存(RAM)、把 SQLite 当磁盘。 agent 干活时在"内存"里思考, 重要的东西主动"存盘";每次新阶段开工,先把磁盘上相关的记忆"读进内存"。这正是操作系统里 "内存 ↔ 磁盘"的心智模型,只不过这里的"磁盘"是一张张 peewee 表。
本节不碰代码。记住一件事就行:记忆不在模型脑子里,在一个你能 sqlite3 打开的文件里。
2. 顶层全景(记忆怎么转)
记忆的生命周期是一个闭环,四个环节:产出 → 存库 → 格式化回灌 → 超量裁剪(GC)。
┌───────────────────────────────────────────────┐
│ agent 运行中 │
│ │
①产出 │ 模型决定"这条值得记" → 调 emit_* 工具 │
────────────────▶ │ (emit_key_facts / emit_key_snippet …) │
└───────────────────┬───────────────────────────┘
│ ②存库
▼
┌───────────────────────────────────────────────┐
│ Repository(仓库层,contextvar 提供) │
│ get_key_fact_repository().create(...) │
└───────────────────┬───────────────────────────┘
│ 写入
▼
┌───────────────────────────────────────────────┐
│ SQLite (.ra-aid/ 下) peewee 表: │
│ session / human_input / key_fact / │
│ key_snippet / research_note / trajectory │
└───────────────────┬───────────────────────────┘
④GC(超阈值时) │ ③回灌:新阶段启动
小 agent 裁剪最不重要的 ▼
┌───────────────────────────────────────────────┐
│ Formatter: DB 行 → markdown │
│ format_key_facts_dict(...) → "## 🔑 Key Fact" │
│ ↓ 塞进 {key_facts} 占位符 │
│ 下一个阶段 agent 的 prompt │
└───────────────────────────────────────────────┘
怎么读这张图:顺时针一圈就是一条记忆的一生。写(①②)和读(③)是解耦的——写的人是"当前 正在干活的 agent",读的人是"下一个阶段的 agent";中间隔着一层 SQLite,谁也不用把对方的上下文 背在身上。④ 是维护:记忆太多会把 prompt 撑爆,所以有专门的清道夫。
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| peewee 模型 | 定义 6 张表的 schema 与外键 | ra_aid/database/models.py |
| 连接管理 | 建/开/关 SQLite,建表 | ra_aid/database/connection.py |
| 仓库层 | 封装 CRUD,用 contextvar 提供全局访问 | ra_aid/database/repositories/*_repository.py |
| 写入侧工具 | agent 调用的 emit_* / *_completed | ra_aid/tools/memory.py |
| 格式化器 | DB 行 → 注入用的 markdown | ra_aid/model_formatters/ |
| GC agent | 条目超阈值时裁剪 | ra_aid/agents/*_gc_agent.py |
| 迁移 | schema 版本演进 | ra_aid/database/migrations.py |
3. 核心原理(逐个机制,由浅入深)
3.1 记忆的形状:6 张表和它们的外键
它要解决的小问题: "记忆"听着抽象,落到磁盘上必须有确定的结构。RA.Aid 用 peewee(一个轻量 Python ORM)把每种记忆定义成一张表,并用外键把它们挂到会话和人类输入上,这样每条记忆都知道 "我是哪次运行、回应哪句用户输入时产生的"。
继承与共性。 所有模型都继承 BaseModel(ra_aid/database/models.py:65),因此天生带
created_at / updated_at 两个时间戳(models.py:73-74)。BaseModel.save 被重写,每次保存
自动刷新 updated_at(models.py:79-91);get_or_create 也被包了一层 错误日志
(models.py:93-110)。
六张表的定位:
| 表 | 模型类 | 关键字段 | 外键指向 |
|---|---|---|---|
| session | Session | status、plan、command_line | —(是根) |
| human_input | HumanInput | content、source('cli'/'chat'/'hil') | session |
| key_fact | KeyFact | content | human_input、session |
| key_snippet | KeySnippet | filepath、line_number、snippet、description | human_input、session |
| research_note | ResearchNote | content | human_input、session |
| trajectory | Trajectory | tool_name、tool_parameters、tool_result、current_cost、input_tokens、output_tokens | human_input、session |
Session 是根。 Session(models.py:113-136)代表"一次程序运行",记录启动时间、命令行、
状态(status,models.py:130,取值如 pending/running/completed/error/halting/halted),
以及该会话最终敲定的 plan(models.py:133)。其它所有记忆都通过外键 backref 归到某个 session
名下——KeyFact.session(models.py:167)、ResearchNote.session(models.py:208)等等。
Trajectory 记的是"每一 次工具调用"。 它是最宽的一张表(models.py:215-260):存下用了哪个工具
(tool_name)、传了什么参数(tool_parameters,JSON)、返回了什么(tool_result,JSON),
还有这一步的成本与 token(current_cost / input_tokens / output_tokens,models.py:239-247)
以及出错信息。轨迹既是"审计日志"也是"UI 渲染数据源";它用于成本与 UI的那部分留到
05,本章只关心它作为"记忆/审计"的一面。
建表时机:
initialize_database()(models.py:22)在连接建立时把这 6 张表create_tables一把建好(models.py:54,safe=True即"不存在才建")。
3.2 记忆存在哪:SQLite 与 .ra-aid/
它要解决的小问题: 记忆要"进程重启也不丢",就不能放内存,得落到磁盘上一个固定位置。
RA.Aid 把库文件放在项目目录下的 .ra-aid/ 文件夹里。DatabaseManager
(ra_aid/database/connection.py:24)是连接的上下文管理器,进入时调 init_db
(connection.py:98)建立 SQLite 连接;不传 base_dir 时就用 os.getcwd()/.ra-aid
作为库目录(见 init_db 内路径拼接)。测试里则可用 in_memory=True 走 :memory: 内存库。
为什么是 SQLite: 零依赖、单文件、跟着项目走。你可以直接 sqlite3 .ra-aid/... 打开看 agent
到底记了什么——记忆是可检视的,不是黑盒。
3.3 仓库层:用 contextvar 把"数据库"变成随手可取的全局
它要解决的小问题: emit_key_facts 这类工具散落在各处,不可能每个都手动传一个 db 连接进去。
但又不能用真全局变量(多线程/多请求会串)。RA.Aid 的解法是仓库模式 + contextvars。
思路: 每种模型配一个 XxxRepository(封装 CRUD)+ 一个 XxxRepositoryManager(上下文管理器)。
Manager 进入时 new 一个 repo 并塞进一个 contextvar;get_xxx_repository() 就从这个 contextvar
里把它取出来。contextvar 天生按执行上下文隔离,所以既"到处能取"又"互不串扰"。
以 KeyFact 为例(ra_aid/database/repositories/key_fact_repository.py):
# 示意,非源码 —— 三件套的关系
repo_var = ContextVar("key_fact_repo", default=None) # 1. 一个上下文变量
class KeyFactRepositoryManager: # 2. 上下文管理器:进→塞,出→清
def __enter__(self):
repo = KeyFactRepository(self.db)
repo_var.set(repo) # 把 repo 放进 contextvar
return repo
def __exit__(self, *exc):
repo_var.set(None) # 离开时复位,防止泄漏到别的上下文
def get_key_fact_repository(): # 3. 任何地方都能取
repo = repo_var.get()
if repo is None: # 没在 Manager 里就用 = 明确报错
raise RuntimeError("No KeyFactRepository available. ...")
return repo
对照真源码:contextvar 定义在 key_fact_repository.py:21;KeyFactRepositoryManager.__enter__
里 key_fact_repo_var.set(repo)(:48-57),__exit__ 里复位为 None(:74);
get_key_fact_repository() 在取不到时抛 RuntimeError(:80-96)。CRUD 方法如
create(:139)、get_all(:239)、get_facts_dict(:256,返回 {id: content} 字典,
专供格式化器用)都在 KeyFactRepository 里。
谁来建立这些上下文? 入口 __main__.py 用一串嵌套的 with 把所有 Manager 一次性铺开
(ra_aid/__main__.py:226-236):
# 示意,非源码 —— 入口处的"管理器栈"
with (
DatabaseManager(base_dir=args.project_state_dir) as db, # 先建 DB 连接
SessionRepositoryManager(db) as session_repo,
KeyFactRepositoryManager(db) as key_fact_repo,
KeySnippetRepositoryManager(db) as key_snippet_repo,
HumanInputRepositoryManager(db) as human_input_repo,
ResearchNoteRepositoryManager(db) as research_note_repo,
TrajectoryRepositoryManager(db) as trajectory_repo,
# …… 还有 RelatedFiles / WorkLog / Config
):
... # 这一大块 with 之内,所有 get_*_repository() 都可用
一进这层 with,整个程序里任何函数(哪怕深在某个工具内部)都能 get_key_fact_repository()
拿到同一个 repo,而不必层层传参。这就是把"数据库连接"变成"环境背景"的手法。
3.4 写入侧:记忆怎么产出并挂到会话上
它要解决的小问题: 模型说"这条要记",怎么把它可靠地落库,并标注清"哪次会话、回应哪句输入"?
写入侧全在 ra_aid/tools/memory.py,都是 @tool 装饰的 LangChain 工具,供 agent 调用:
| 工具 | 行 | 作用 |
|---|---|---|
emit_key_facts | :230 | 批量存关键事实 |
emit_key_snippet | :295 | 存一段代码片段(并自动加入 related files) |
emit_research_notes | :148 | 存一段研究笔记 |
emit_plan | :110 | 把实现计划存到当前 session 的 plan 字段 |
emit_related_files | :504 | 记下"接下来要动的文件" |
task_completed / plan_implementation_completed | :429 / :461 | 标记完成并落一条轨迹 |
每条记忆都要挂靠。 写入前,工具会先算两个"挂靠 id":
_get_current_session_id()(memory.py:694)——取当前会话记录的 id;_get_most_recent_human_input_id()(memory.py:706)——取最近一条人类输入的 id。
例如 emit_key_facts 里,先 human_input_id = _get_most_recent_human_input_id(),再
get_key_fact_repository().create(fact, human_input_id=human_input_id)(memory.py:239-246)。
这样每条 KeyFact 都记得"我是回应第几条用户输入时冒出来的"——这个挂靠关系在 GC 阶段(§3.6)会用来
保护当前请求相关的记忆不被误删。
人类输入本身也是记忆。 CLI 启动时,入口把用户的原 始任务作为一条 HumanInput 落库:
human_input_repository.create(content=base_task, source="cli", session_id=session_id)
(__main__.py:1517-1518);chat 模式则用 source="chat"(__main__.py:1392-1394)。而 session
本身由 session_repo.create_session() 创建(__main__.py:1236)。于是形成一条挂靠链:
session → human_input → key_fact / snippet / note。
一次写入还会顺带记一条轨迹。 每个
emit_*成功后,通常还会get_trajectory_repository().create(tool_name=..., tool_parameters=..., record_type=...)落一条 Trajectory(如emit_key_facts在memory.py:255-266)。这条轨迹是给审计/UI/成本用的, 属于 05 的范畴,这里只需知道"写记忆 ≈ 存业务数据 + 留一行日志"。
3.5 读回侧:把 DB 行格式化后注入下一个 prompt
它要解决的小问题: 存进 SQLite 的是一行行结构化数据,可 LLM 只吃文本。必须把这些行渲染成 markdown,再塞进下一个阶段 agent 的 prompt 里——这一步是"磁盘 → 内存"的回灌。
格式化器负责渲染。 ra_aid/model_formatters/ 下三个纯函数把"{id: 内容} 字典"变成 markdown:
| 格式化器 | 文件:行 | 输出 样子 |
|---|---|---|
format_key_facts_dict | key_facts_formatter.py:32 | ## 🔑 Key Fact #1\n\n<内容> |
format_key_snippets_dict | key_snippets_formatter.py:44 | ## 📝 Code Snippet #1 + 文件/行/代码块 |
format_research_notes_dict | research_notes_formatter.py:30 | 各条笔记按 id 排序、空行分隔 |
它们都很朴素:按 id 排序、拼标题、空行隔开(见 key_facts_formatter.py:49-58)。关键是保留 #id
——这样 agent 在 prompt 里读到 Key Fact #7,想删/引用时能用同一个 id 精确指向。
回灌发生在每个 agent 启动时。 以研究 agent 为例(ra_aid/agents/research_agent.py),它在拼
prompt 前,先把三种记忆从库里拉出来、格式化:
# 示意,非源码 —— research_agent 启动时"读盘"
key_facts = format_key_facts_dict(get_key_fact_repository().get_facts_dict()) # 行 147
key_snippets = format_key_snippets_dict(get_key_snippet_repository().get_snippets_dict()) # 行 154
research_notes= format_research_notes_dict(get_research_note_repository().get_notes_dict()) # 行 221
# …… 然后填进 prompt 模板的占位符
prompt = RESEARCH_PROMPT.format(key_facts=key_facts, key_snippets=key_snippets, ...) # 行 402
对照真源码:research_agent.py:147(facts)、:154(snippets)、:221(notes),最后在
:402-413 用 .format() 填模板。每一步都包在 try/except RuntimeError 里——万一 repo 没初始化,
就退化成空串而不是崩溃(如 :149-151)。
这些字符串落到哪些占位符? 不同阶段的 prompt 各自开了 {key_facts} / {key_snippets} /
{research_notes} 的槽:
| prompt | 注入的记忆槽 | 文件:行 |
|---|---|---|
| 网页研究 | {key_facts}、{key_snippets} | prompts/web_research_prompts.py:89 |
| chat | {key_facts}、{key_snippets} | prompts/chat_prompts.py:16,20 |
| 推理辅助 | {key_facts}、{key_snippets}、{research_notes} | prompts/reasoning_assist_prompt.py:11,15,19 |
| 计划 / 实现 | {research_notes} | prompts/planning_prompts.py:21、prompts/implementation_prompts.py:25 |
闭环合上了: 研究阶段 emit_key_facts 存的事实,到了计划/推理阶段,会以 ## 🔑 Key Fact #N
的形式出现在新 agent 的 system prompt 里。模型不需要"记得"上一阶段——磁盘替它记着。
3.6 记忆的 GC:超量时派个小 agent 去裁剪
它要解决的小问题: 记忆只进不出,迟早把 prompt 撑爆、也塞进一堆过时噪声。需要有人定期"扔掉最 没用的那些"。但"哪条最没用"是个判断题——于是 RA.Aid 干脆再派一个小 LLM agent 来做删除决策。
触发是懒惰式的:写入 时顺手查一下量。 每次 emit_* 成功后,数一下总数,超阈值就唤起对应的
GC agent:
| 记忆 | 阈值 | 触发点 |
|---|---|---|
| Key Fact | > 50 | memory.py:281 → run_key_facts_gc_agent() |
| Key Snippet | > 35 | memory.py:383 → run_key_snippets_gc_agent() |
| Research Note | > 30 | memory.py:209 → run_research_notes_gc_agent() |
GC agent 怎么删: 以 ra_aid/agents/key_facts_gc_agent.py 为例。run_key_facts_gc_agent()
(:143)把所有事实拉出来,先剔除挂靠在"当前人类输入"上的那些(它们被保护,不能删,
:209),把剩下的"可删事实"格式化进 KEY_FACTS_GC_PROMPT(:231),然后用
initialize_llm(...) + agent_utils.create_agent(model, [delete_key_facts]) 造一个只带
一把删除工具的小 agent(:221-228)去跑。这个小 agent 读完所有事实,自己决定哪些该删,
调用 delete_key_facts([...])(工具定义在 :34)。
保护机制在删除工具里再兜一层。 delete_key_facts 真正删之前,会再次比对每条事实的
human_input_id 是否等于当前输入 id,是则归入 protected_facts 跳过(key_facts_gc_agent.py:62)。
"当前请求相关的事实不删"这条规则,产出时挂靠(§3.4)、GC 时校验,前后呼应。
人类输入也有自己的 GC,但方式相反。
HumanInputRepository.garbage_collect()(human_input_repository.py:329)不请 LLM,直接保留最近 100 条、删掉更老的