跳到主要内容

记忆与持久化: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_* / *_completedra_aid/tools/memory.py
格式化器DB 行 → 注入用的 markdownra_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)。

六张表的定位:

模型类关键字段外键指向
sessionSessionstatusplancommand_line—(是根)
human_inputHumanInputcontentsource('cli'/'chat'/'hil')session
key_factKeyFactcontenthuman_inputsession
key_snippetKeySnippetfilepathline_numbersnippetdescriptionhuman_inputsession
research_noteResearchNotecontenthuman_inputsession
trajectoryTrajectorytool_nametool_parameterstool_resultcurrent_costinput_tokensoutput_tokenshuman_inputsession

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_factsmemory.py:255-266)。这条轨迹是给审计/UI/成本用的, 属于 05 的范畴,这里只需知道"写记忆 ≈ 存业务数据 + 留一行日志"。

3.5 读回侧:把 DB 行格式化后注入下一个 prompt

它要解决的小问题: 存进 SQLite 的是一行行结构化数据,可 LLM 只吃文本。必须把这些行渲染成 markdown,再塞进下一个阶段 agent 的 prompt 里——这一步是"磁盘 → 内存"的回灌。

格式化器负责渲染。 ra_aid/model_formatters/ 下三个纯函数把"{id: 内容} 字典"变成 markdown:

格式化器文件:行输出样子
format_key_facts_dictkey_facts_formatter.py:32## 🔑 Key Fact #1\n\n<内容>
format_key_snippets_dictkey_snippets_formatter.py:44## 📝 Code Snippet #1 + 文件/行/代码块
format_research_notes_dictresearch_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:21prompts/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> 50memory.py:281run_key_facts_gc_agent()
Key Snippet> 35memory.py:383run_key_snippets_gc_agent()
Research Note> 30memory.py:209run_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 条、删掉更老的——因为"哪条输入 更该留"有明确答案(越新越重要),不需要判断题,规则删即可。

两种 GC 的分工,一句话: 语义价值需要判断的(事实/片段/笔记)→ 交给小 agent;有明确时间序的 (人类输入)→ 直接按数量截断。


4. 巧妙之处(可借鉴的技术)

  • 上下文当内存、SQLite 当磁盘的显式分层。 记忆不隐式堆在对话历史里,而是显式存成可查表, 阶段之间靠"读盘"传递(research_agent.py:147)。好处:进程能重启续跑、记忆能被人 sqlite3 检视、 各阶段 prompt 只装它需要的那部分记忆。

  • contextvar 做依赖注入。ContextVar + Manager(key_fact_repository.py:21,48)把 db 连接 变成"环境背景",工具函数无需层层传参就能 get_*_repository();取不到就显式 RuntimeError (:92),而不是静默用错连接。既解耦又安全。

  • 记忆全程挂靠 session/human_input。 每条记忆都知道"我属于哪次运行、回应哪句话" (memory.py:239-246)。这一个外键设计同时支撑了三件事:审计、按会话隔离、以及 GC 时的 "当前请求保护"(key_facts_gc_agent.py:62,209)。

  • 格式化器保留 #id DB 行渲染成 markdown 时带上 Key Fact #7(key_facts_formatter.py:29), 让"注入进 prompt 的文本"和"库里的行"可双向对齐——模型引用/删除时能精确定位。

  • "删除"也是一次 agent 调用。 与其写死启发式规则去裁剪记忆,不如把"哪条最没用"这个判断题 外包给一个只带删除工具的小 agent(key_facts_gc_agent.py:221-228)。这是"用 agent 维护 agent 的记忆"的自洽设计。


5. 边界与局限

  • GC 靠 LLM,有成本也有不确定性。 每次超阈值就要多跑一趟模型来决定删谁 (key_facts_gc_agent.py:221-239),既费 token 又可能误删——只有"当前请求相关"的条目被硬保护。

  • 触发是懒惰式的,不是定时的。 GC 只在 emit_* 写入时顺带检查(memory.py:281),不写就不清; 阈值是写死的常量(50/35/30),不随模型上下文大小自适应。

  • 注入是"全量相关记忆",不做语义检索。 回灌时是把该类记忆整批格式化塞进 prompt (research_agent.py:147),没有"按当前子任务只挑最相关几条"的向量检索;控制体量全靠 GC 截断。

  • 并发写入的边界看不太出来。 contextvar 保证的是"上下文隔离",SQLite 单文件下多进程并发写的 竞争行为,代码里看不出显式的加锁/重试策略(inferred)。

  • 本章不覆盖: 轨迹用于成本核算与 UI 渲染的逻辑在 05; 这些工具作为"agent 手脚"的非记忆职责在 04


6. 代码地图(导航索引)

主题文件路径符号名
模型基类/时间戳/get_or_createra_aid/database/models.pyBaseModelsaveget_or_create
6 张表定义ra_aid/database/models.pySessionHumanInputKeyFactKeySnippetResearchNoteTrajectory
建表ra_aid/database/models.pyinitialize_database
连接/库目录 .ra-aid/ra_aid/database/connection.pyDatabaseManagerinit_db
仓库+contextvar(以 KeyFact 为例)ra_aid/database/repositories/key_fact_repository.pykey_fact_repo_varKeyFactRepositoryManagerget_key_fact_repositoryKeyFactRepository.get_facts_dict
管理器栈(入口铺开所有 repo)ra_aid/__main__.pywith (DatabaseManager … RepositoryManager …)(约 226-236)
写入侧工具ra_aid/tools/memory.pyemit_key_factsemit_key_snippetemit_research_notesemit_planemit_related_files
挂靠 id 辅助ra_aid/tools/memory.py_get_current_session_id_get_most_recent_human_input_id
人类输入/会话落库ra_aid/__main__.pyhuman_input_repository.createsession_repo.create_session
格式化器(读回渲染)ra_aid/model_formatters/format_key_facts_dictformat_key_snippets_dictformat_research_notes_dict
注入点(研究 agent 读盘)ra_aid/agents/research_agent.pyrun_research_agent(行 147/154/221/402)
prompt 记忆占位符ra_aid/prompts/web_research_prompts.pychat_prompts.pyreasoning_assist_prompt.py{key_facts}/{key_snippets}/{research_notes}
记忆 GC(小 agent)ra_aid/agents/key_facts_gc_agent.pyrun_key_facts_gc_agentdelete_key_facts
人类输入 GC(规则截断)ra_aid/database/repositories/human_input_repository.pygarbage_collect
schema 迁移ra_aid/database/migrations.pyensure_migrations_appliedRouter

相邻章节: index · 01 三阶段编排 · 02 两种 agent 后端 · 04 工具箱与专家/HIL · 05 健壮性与成本