跳到主要内容

记忆与知识:三层记忆 + 混合检索 + Deep Dream

30 秒导读: 一个 Agent 每轮都从零重建 prompt(第 2 章),那它凭什么"记得"你上周说过的话?CowAgent 的答案是把记忆分成三层——对话上下文(短期)、每日记忆 memory/YYYY-MM-DD.md(中期)、MEMORY.md 长期索引——再配一套 SQLite 里的向量 + 关键词混合检索按需读回,最后靠 Deep Dream 在夜里把当天的碎片蒸馏成精炼的常青记忆。本章讲清这三层怎么协作。


1. 先建直觉:为什么要"分层"

Agent 的上下文窗口是有限的。你不可能把用户三个月来的所有对话都塞进每一轮的 prompt——既装不下,也会淹没真正重要的信息。

这和操作系统的内存 vs 磁盘是同一个问题:快而小的空间放当前要用的,大而慢的空间放全部历史,中间用一套机制按需搬运。

CowAgent 就是这么设计的。它把记忆分成三层,每层的"新鲜度"和"容量"正好相反:

存在哪角色类比特点
对话上下文内存 + SQLite messagesCPU 寄存器 / 内存最新、最全、最贵,装不下就要"下沉"
每日记忆memory/YYYY-MM-DD.md当天的工作日志按天累积,是"事件流水",会随时间衰减
长期索引MEMORY.md(工作区根)精炼过的备忘录常青、精简(目标 ≤50 条)、每轮自动注入 prompt

再加一个和记忆正交的东西:知识库 knowledge/,按主题(概念、实体……)组织的结构化文档,也一起进了同一个向量索引供检索。

一句话直觉:对话是"刚刚发生的",每日记忆是"今天发生的",MEMORY.md 是"值得永远记住的",知识库是"我学到的一整块领域知识"。


2. 顶层全景:一条记忆的一生

先看四层数据怎么流动。从上往下是"信息下沉"(越往下越浓缩),从下往上是"检索读回"。

┌──────────────────────────────┐
用户消息 ───────────▶ │ ① 对话上下文(短期) │
│ 内存 self.messages │
│ + SQLite messages 表 │
└───────────────┬──────────────┘
│ 上下文超长 / 被裁剪
│ (flush_memory,LLM 蒸馏)

┌──────────────────────────────┐
│ ② 每日记忆(中期) │
│ memory/2026-07-14.md │
│ "按事件"记的当天流水 │
└───────────────┬──────────────┘
│ 夜里 23:5x
│ Deep Dream(LLM 蒸馏 + 去重)

┌──────────────────────────────┐
│ ③ 长期索引(常青) │
│ MEMORY.md (≤50 条) │
│ + memory/dreams/ 叙事日记 │
└──────────────────────────────┘

④ 知识库 knowledge/<主题>/<页>.md ── 和 ①②③ 正交,人工/工具维护

①②③④ 中的 .md 文件 ──▶ 全部切块 + 向量化 ──▶ SQLite 索引(chunks 表)

memory_search / memory_get 工具 ◀┘ 按需读回(第 5 章)
MEMORY.md 每轮自动注入 prompt ◀── (第 2 章)

各部件一句话职责:

部件干什么在哪个文件
MemoryManager记忆总入口:检索、同步、下沉的调度中枢agent/memory/manager.py:20
MemoryStorageSQLite 存储层:向量检索 + FTS5 关键词检索agent/memory/storage.py:74
TextChunker.md 按行切成带行号的块agent/memory/chunker.py:20
EmbeddingProvider把文本变成向量(多厂商 OpenAI 兼容)agent/memory/embedding/provider.py:34
MemoryFlushManager下沉 + Deep Dream:对话→每日记忆→MEMORY.mdagent/memory/summarizer.py:194
ConversationStore对话历史的 SQLite 持久化(第①层的落盘)agent/memory/conversation_store.py:342
KnowledgeService知识库文件管理 + 索引同步agent/knowledge/service.py:28

主线走一遍(高层):

  1. 用户发消息,进入对话上下文;每轮结束由 ConversationStore.append_messages 落盘。
  2. 上下文快撑爆时,老的对话被 flush_memory 蒸馏成一两句,追加进当天的每日记忆
  3. 每天夜里,Deep Dream 读当天(或最近几天)的每日记忆,和现有 MEMORY.md 一起交给 LLM,产出一份更新后的精炼 MEMORY.md + 一篇叙事式梦境日记
  4. 所有这些 .md 文件(加知识库)被 sync 切块、向量化,写进 SQLite;下一轮对话里,MEMORY.md 自动进 prompt,其余的靠 memory_search 按需捞。

3. 核心机制一:混合检索(向量 + 关键词,失败静默降级)

它要解决的小问题

用户问"我上次说的那个 Python 教程链接呢?"——记忆库里可能有几百个块。怎么找到最相关的那几个?

两种找法各有短板:向量检索懂语义(能把"教程"和"tutorial"关联起来)但要调用嵌入 API,可能超时/失败;关键词检索(FTS5 全文索引)精确、离线、快,但只认字面。

CowAgent 的策略:两个都跑,加权融合;向量那一路一旦出问题,静默退回纯关键词,绝不让检索整个失败。

思路图示

query ──┬──▶ 向量检索 search_vector ──┐
│ (embed 失败?→ 记日志,当作空结果)
│ ├──▶ _merge_results ──▶ 时间衰减 ──▶ TopK
└──▶ 关键词检索 search_keyword ┘ (加权融合) (对 dated 文件)
(FTS5 → trigram → LIKE 三级兜底)

关键在于:关键词那一路永远会跑(manager.py:155),所以哪怕向量路彻底挂掉,keyword_results 还在,检索仍有结果。

原理演示

# 示意,非源码 —— 混合检索的骨架
async def search(query):
vector_results = []
if embedding_provider: # 有嵌入才试向量
try:
q_vec = embed_query(query) # 可能超时/401/限流
vector_results = storage.search_vector(q_vec, limit=k*2)
except Exception as e:
log.error("向量检索失败,降级为纯关键词") # 不 raise,静默降级

keyword_results = storage.search_keyword(query, limit=k*2) # 永远跑

merged = merge(vector_results, keyword_results,
vector_weight=0.7, keyword_weight=0.3) # 加权融合
return [r for r in merged if r.score >= min_score][:k]

重点看:向量检索被包在 try 里,异常只记日志不抛出——这就是"失败静默降级为关键词-only"。

真实实现

  • 入口 MemoryManager.search(agent/memory/manager.py:90)。向量路的 try/exceptmanager.py:150-153,注释直说 "degrade silently to keyword-only — no exception is raised"。
  • 融合逻辑 _merge_results(agent/memory/manager.py:503):用 (path, start_line, end_line) 做 key 把同一块的两路分数并进一个 map,再算 combined = vector_weight * v + keyword_weight * k。默认权重 0.7 / 0.3agent/memory/config.py:41-42
  • 检索前若脏了会先 sync(manager.py:125),查询向量还带一层进程内缓存 EmbeddingCache(manager.py:137,避免同一 session 内重复调 API)。

关键细节:三级关键词兜底 + BM25 打分

search_keyword(agent/memory/storage.py:637)本身也是一套降级链,专为中英混排设计:

级别用什么处理什么
1标准 FTS5(unicode61)纯 ASCII 查询;含 CJK 直接跳过(否则会丢掉中文部分)
2trigram FTS5CJK / 混合查询,或第 1 级没命中时的补捞(3 字滑窗)
3LIKE 兜底FTS5 不可用、或单字这种 trigram 也接不住的 CJK

一个巧妙点在 _bm25_rank_to_score(storage.py:1028):SQLite 的 bm25() 返回非正数(越负越相关),直接 max(0, rank) 会把所有负值削成 0、让每条得分都变 1.0、毁掉全部排序信息。它改用 abs(rank)/(1+abs(rank)) 保留相对强弱,再加 0.3 地板分,保证任何 FTS 命中都能越过默认 min_score


4. 核心机制二:时间衰减(dated 文件会"褪色",MEMORY.md 常青)

它要解决的小问题

三个月前某天的流水记录,和今天刚记的,理应不一样重要。但 MEMORY.md 里那些"用户是左撇子""偏好简体中文"这种事实,不该随时间褪色

思路

只对文件名形如 YYYY-MM-DD.md 的每日记忆做指数时间衰减;MEMORY.md 和其它非日期文件是"常青"(衰减系数恒为 1.0)。

真实实现

_compute_temporal_decay(agent/memory/manager.py:472)用正则 (\d{4})-(\d{2})-(\d{2})\.md$ 判断是不是 dated 文件:

  • 不匹配(如 MEMORY.md)→ 直接返回 1.0,不衰减。
  • 匹配 → 按半衰期 30 天算 multiplier = exp(-ln2/30 * age_days)

这个系数在 _merge_results 里乘到最终分上(manager.py:541-542)。灵感来自注释里提到的 OpenClaw temporal-decay。

# 示意,非源码 —— 衰减系数怎么算
def temporal_decay(path, half_life_days=30):
m = re.search(r'(\d{4})-(\d{2})-(\d{2})\.md$', path)
if not m:
return 1.0 # MEMORY.md 等:常青
age = (today - date(*m.groups())).days
return math.exp(-math.log(2)/half_life_days * age) # 30 天减半

效果:一条 30 天前的每日记忆,分数打对折;60 天前打到 1/4。新鲜的流水自然浮上来,陈旧的自然沉下去,而核心事实永不褪色。


5. 核心机制三:两趟 sync(把嵌入 API 成本摊薄,失败留待重试)

它要解决的小问题

sync 要把工作区里所有变过的 .md 重新切块、向量化、入库。一个装了 101 个小文件的工作区,如果每个文件各调一次嵌入 API,就是 ~100 次 HTTP 往返——又慢又贵,还容易半路超时。

思路:先攒后发

MemoryManager.sync(agent/memory/manager.py:251)拆成两趟:

Pass 1 遍历所有文件 ── 只对 hash 变了的切块 ── 攒进 pending[](不调 API)


Pass 2 把 pending 里所有块的文本合成一个大列表 ── 一次 embed_batch
│ (provider 内部按厂商上限自动分页)
├── 成功 ──▶ Pass 3 逐文件落库(delete_by_path 后重写 chunks + file_hash)
└── 失败 ──▶ 立即 return,索引一个字节不动,_dirty 保持 True 待下次重试

这样 ~100 次 HTTP 被压成 ceil(总块数 / 厂商批上限) 次。

关键细节:失败时"宁可不动"

这是最容易写错的地方。sync 在 Pass 2 有一句关键注释(manager.py:344-349):在拿到有效嵌入之前,绝不碰索引。

如果 embed_batch 抛异常,代码直接 return(manager.py:370),写 NULL 嵌入、更新 file_hash。因为一旦更新了 file_hash,这个文件就被标记成"已成功同步",下次 sync 会因为 hash 没变而跳过它——于是这个文件永远没有向量、被悄悄搁浅。保持索引原样,下次 sync 才会重试同样的文件。

还有一个降噪设计:sync 在遍历时跳过 dreams/ 目录(manager.py:285)。因为梦境日记是 Deep Dream 产出的叙事反思,其事实内容已经被蒸馏进 MEMORY.md;把它也索引进去只会制造"近似重复",在检索里挤掉那条权威条目。

配套的存储细节:入库走 save_chunks_batch(storage.py:461),用 SQLite UPSERT(INSERT … ON CONFLICT DO UPDATE)而不是 INSERT OR REPLACE——后者是 DELETE+INSERT 会换掉 rowid,而两张 FTS5 表都以 content_rowid='rowid' 关联,rowid 一变就会留下指向死行的索引、报 "missing row" 错。嵌入本身以 float32 BLOB 存储(_encode_embedding,storage.py:891),比 JSON 小约 6 倍。


6. 核心机制四:Deep Dream(夜里把碎片蒸馏成精炼记忆)

这是记忆系统里最有"灵魂"的一环。它回答:怎么从一堆零散的每日流水,长出一份高密度的长期记忆?

两个动作,别混淆

先分清 MemoryFlushManager(agent/memory/summarizer.py:194)干的两件事:

动作何时触发输入 → 输出
Flush(下沉)上下文被裁剪 / 溢出时,实时被丢弃的对话消息 → 追加进当天每日记忆
Deep Dream(蒸馏)每天夜里 23:5x,批量每日记忆 + 旧 MEMORY.md → 新 MEMORY.md + 梦境日记

Flush:把对话蒸馏进每日记忆

flush_from_messages(summarizer.py:258)是先同步去重、再异步蒸馏:

  • 同步:先剥掉调度器注入的 [SCHEDULED] 消息对(_strip_scheduler_pairs,summarizer.py:770——否则会把"11:28 价格=1013,正常"这种低价值流水也蒸进记忆);再按内容 hash 去重,已 flush 过的跳过。
  • 异步:去重后的消息快照丢进后台线程 _flush_worker(summarizer.py:317),用 LLM 按"事件"维度归纳成几行(prompt 见 SUMMARIZE_SYSTEM_PROMPT_ZH,summarizer.py:19),追加进 memory/YYYY-MM-DD.md主回复流程一秒都不阻塞。

一个诚实兜底:LLM 判定对话"无记录价值"时回一个哨兵词("无"/"None",_is_empty_sentinel,summarizer.py:185),此时直接跳过、不写文件;LLM 调用异常才退回规则式摘要(_extract_summary_fallback,summarizer.py:721)。

flush_from_messages 还能带一个 context_summary_callback(summarizer.py:352):同一次 LLM 摘要既落盘、又回注到活跃消息列表里,让被裁剪的上下文以"一句话摘要"的形式留在对话中——一次调用,两处受益

Deep Dream:把每日记忆蒸馏成常青 MEMORY.md

deep_dream(summarizer.py:414)的流程:

读 MEMORY.md(现有长期记忆)
读最近 lookback_days 天的每日记忆 ──▶ 没有内容就跳过,保住现有 MEMORY.md

│ input-hash 去重:今天已用相同 daily 内容梦过 → 跳过
│ (只 hash daily 内容,不含 memory —— 因为 dream 本身会改写 MEMORY.md)

交给 LLM(DREAM_SYSTEM_PROMPT:合并提炼 / 冲突更新 / 清理无效,≤50 条)


_parse_dream_output 拆出 [MEMORY] 和 [DREAM] 两段

├──▶ [MEMORY] 覆盖写 MEMORY.md
└──▶ [DREAM] 写 memory/dreams/YYYY-MM-DD.md(叙事日记)

几个关键细节:

  • 去重只哈希 daily 内容(summarizer.py:447-458)。因为 deep_dream 的副作用就是改写 MEMORY.md,若把 memory_content 也纳入 hash,同一窗口内每次调用 hash 都变、去重永远失效。
  • 没内容就早退(summarizer.py:443):最近没有每日记录时直接返回,绝不拿空输入去让 LLM 重写、避免把现有 MEMORY.md 洗空。
  • 解析靠标记:_parse_dream_output(summarizer.py:567)用字符串切 [MEMORY] / [DREAM],顺手 .replace("```", "") 去掉模型可能吐出的代码围栏。
  • 产出双份:精炼记忆覆盖 MEMORY.md(summarizer.py:508),叙事日记由 _write_dream_diary(summarizer.py:585)写进 memory/dreams/——而这个目录正是 §5 里 sync 会跳过的,闭环成立。

谁在夜里按下按钮

调度在 bridge/agent_initializer.py:576_start_daily_flush_timer:后台线程每天算到 23:50–23:55 之间一个带抖动的时刻(agent_initializer.py:590-592),先给所有活跃 session 跑 create_daily_summary(summarizer.py:386,内容没变则跳过),等 flush 线程 join 完,再跑一次 deep_dream(agent_initializer.py:653)。用户也能手动触发(CLI /memory dream,force=True 绕过去重与配置开关)。


7. 第①层落盘:ConversationStore

前六节主要讲 .md 文件那两三层。最上面的对话上下文本身也要持久化,这归 ConversationStore(agent/memory/conversation_store.py:342)——一个纯 SQLite、append-only 的会话历史库。

它和记忆索引共用同一个 SQLite 文件(~/cow/memory/long-term/index.db,见 get_conversation_store,conversation_store.py:1251),但用独立的 sessions / messages 表,互不干扰。

几个值得记的方法:

方法作用行号
append_messages追加消息,seq 从当前最大值续号,并发不撞conversation_store.py:438
load_messages可见轮次(只数真实用户文本,不数 tool_result)裁 max_turns 加载conversation_store.py:361
prune_scheduled_messages只保留最近 N 对调度器注入的 [SCHEDULED] 消息,老的删掉conversation_store.py:749
clear_contextcontext_start_seq 边界:老消息仍存盘但不再进 LLM 上下文conversation_store.py:535

load_messages 的巧妙处(conversation_store.py:419):max_turns可见轮次而非消息条数。一个工具用得很凶的 session,几十条 tool_result 也只算它们所属的那一个用户轮——避免工具调用把"轮次预算"提前烧光。


8. 知识库:和记忆正交的结构化文档

记忆是"关于这个用户/这段对话"的;知识库 knowledge/ 是"关于某个领域"的,按主题正交组织:

knowledge/
├── index.md # 自动重建的目录(rebuild_index_md)
├── log.md # 受保护文件
└── <category>/ # 按主题分类:concepts / entities / ...
└── <slug>.md

KnowledgeService(agent/knowledge/service.py:28)直接在文件系统上增删改查,每次改动后调 _sync_index(service.py:115)让被改的知识页从向量索引里删掉再重新 sync——知识和记忆进的是同一个 chunks 表(sync 里对 knowledge/ 目录一视同仁,manager.py:305-309),所以 memory_search 一次检索能同时覆盖记忆和知识。

两个可借鉴的设计:

  • 索引永不漂移:rebuild_index_md(service.py:141)从真实目录树重新生成 index.md,而不是手工维护——文件在,链接就在。
  • 路径穿越防护:_resolve_path(service.py:46)会解析"最近的已存在祖先"再校验是否仍在 knowledge/ 根内,堵住用符号链接逃逸的路子。

此外 build_graph(service.py:525)会扫描所有页面里的 .. 交叉引用,产出节点 + 连线,供前端画知识图谱。


9. 记忆/知识如何回流到 Agent

记完了,怎么用?两条回路:

回路一:MEMORY.md 每轮自动进 prompt。 系统提示词构建时(第 2 章),load_context_files(agent/prompt/workspace.py:110)把工作区根的 MEMORY.md 作为常驻上下文注入。为保护上下文窗口,它被 _truncate_memory_content(workspace.py:190)截到 ≤200 行 / ≤25000 字节的"冻结快照"。_build_memory_section(agent/prompt/builder.py:353)则在 prompt 里告诉模型:核心信息已在 MEMORY.md,完整内容和每日记忆要用工具去取。

回路二:其余记忆靠工具按需查(第 5 章)。 prompt 给模型的检索决策树是:位置不确定 → memory_search(关键词/语义,底层就是本章 §3 的混合检索);位置已知 → memory_get 读精确的行;搜不到 → memory_get 读最近两天的每日记忆(builder.py:376-380)。

于是三层记忆各就各位:常青的 MEMORY.md 白送进每轮 prompt,海量的每日记忆和知识库躺在 SQLite 里等着被 memory_search 精准捞回。

工具本身的实现(MemorySearchTool / MemoryGetTool)不在本章展开,见 05-skills-tools-mcp.md


10. 边界与局限

诚实地说清它不做什么、会在哪打折扣:

  • 嵌入全靠外部 API。 没配 embedding_provider 时,MemoryManager 整体退化为**纯关键词(FTS5)**检索(manager.py:59-63),语义召回能力消失。这是刻意的:manager 不会偷偷自己初始化一个 vendor,以免绕过调用方的状态校验、污染索引。
  • Deep Dream 依赖 LLM 且非事务。 无 LLM 直接跳过(summarizer.py:433);MEMORY.md覆盖写(summarizer.py:508),写坏了没有内建版本回滚(整理质量完全托付给 prompt 的"严禁编造"约束)。
  • 时间衰减写死半衰期 30 天(manager.py:473),且只认 YYYY-MM-DD.md 命名;换个命名就当常青处理。
  • 每日/每轮的 flush 是尽力而为。 蒸馏跑在 daemon 线程里,进程被强杀时最后一批未落盘的下沉可能丢失。
  • FTS5 shadow 表可能损坏。 存储层为此做了不少自愈(_fts5_shadow_corrupt / _rebuild_fts5_from_chunks,storage.py:376/396),但极端情况(SQLite < 3.24 无 UPSERT)下 rowid 仍可能漂移,需要周期性重建索引恢复。

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

主题文件路径关键符号
记忆总入口 / 检索调度agent/memory/manager.pyMemoryManagersearchsyncadd_memoryflush_memory
混合融合 + 时间衰减agent/memory/manager.py_merge_results_compute_temporal_decay
存储 / 向量 / 关键词检索agent/memory/storage.pyMemoryStoragesearch_vectorsearch_keywordMemoryChunk_bm25_rank_to_score
切块agent/memory/chunker.pyTextChunkerchunk_text
嵌入(多厂商)agent/memory/embedding/provider.pyEmbeddingProviderOpenAIEmbeddingProvider.embed_batchEmbeddingCache
下沉 + Deep Dreamagent/memory/summarizer.pyMemoryFlushManagerflush_from_messages_flush_workerdeep_dream_parse_dream_output_write_dream_diary
对话历史落盘agent/memory/conversation_store.pyConversationStoreappend_messagesload_messagesprune_scheduled_messages
配置(路径 / 权重)agent/memory/config.pyMemoryConfigvector_weightkeyword_weight
知识库agent/knowledge/service.pyKnowledgeServicerebuild_index_mdbuild_graph_sync_index
夜间调度bridge/agent_initializer.py_start_daily_flush_timer_flush_all_agents
回流到 promptagent/prompt/workspace.pyagent/prompt/builder.pyload_context_files_truncate_memory_content_build_memory_section
触发下沉agent/protocol/agent_stream.pymemory_manager.flush_memory(overflow / trim 两处)

同组其它章: index.md(全景) · 01-agent-loop.md(核心循环) · 02-system-prompt.md(每轮重建的 prompt,含记忆/知识节) · 03-context-robustness.md(上下文裁剪,触发本章的 flush) · 05-skills-tools-mcp.md(memory_search/memory_get 工具实现) · 06-self-evolution.md(空闲时回看对话、自我成长)