Day 34 / 共 60 天 · 阶段6 记忆与知识
unified_memory.py:Memory 类的六个对外动作
Day 33 认识了 Memory 的字段。今天读它的方法——真正干活的部分。重点看四个精妙设计:① model_post_init 怎么把字符串配置装配成真实对象;② remember/remember_many 一个同步一个后台,为什么这样分;③ 后台保存用单线程池 + contextvars 复制上下文做串行化;④ recall 开头的 drain_writes() 是个"读屏障",保证异步写完才读。全是并发编程的真实功夫。
📍 你在 60 天里的位置(阶段6 记忆与知识 · 共 8 天)
D33 记忆总览→
D34 unified_memory→
D35 recall/encoding→
D36 短/长/entity→
D37 scope 作用域→
D38 RAG→
D39 knowledge→
D40 embedding/存储
💡 先用一个类比兜住今天
Memory 就像一个前台接待:你把要记的东西递给它(remember),它不当场慢慢办,而是转交后台一个"专职文员"(单线程池)排队处理——因为写记忆要调 LLM 分析、要嵌入、要合并,很慢,不该卡住你。等你回头要查资料(recall),前台会先确认后台那批还没归档的都办完了(读屏障),再去检索,免得你查到一半的、旧的。今天就是看这个"前台如何调度后台"。L01
痛点:存记忆很慢,不能卡住 Agent
🤔 痛点存一条记忆不是"写一行数据库"那么简单:要调 LLM 判断分类/重要性、要调嵌入模型算向量、要搜相似记忆看要不要合并——一条可能耗 1~3 秒。如果 Agent 每干完一步都同步等这个存完,整个 crew 会慢得没法用。但如果放后台异步存,又会引入新问题:刚存的还没落盘,马上 recall 就查不到怎么办?多个存操作并发写同一个库冲突怎么办?
💡 一句话本质
Memory 用一个 max_workers=1 的线程池把所有写操作串行化(避免并发写冲突),remember_many 提交后立刻返回(不阻塞 Agent),而 recall 开头调 drain_writes() 等所有挂起的写完成——这就是"读之前先把待写的排空"的读屏障。既快(写不阻塞)又对(读能看到写)。先看类里管并发的私有属性(unified_memory.py:161):
# unified_memory.py:161
_config: MemoryConfig = PrivateAttr()
_storage: StorageBackend = PrivateAttr()
_save_pool: ThreadPoolExecutor = PrivateAttr( # ★单线程保存池
default_factory=lambda: ThreadPoolExecutor(
max_workers=1, thread_name_prefix="memory-save"))
_pending_saves: list[Future[Any]] = PrivateAttr(default_factory=list) # 挂起的写
_pending_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
_reset_lock: Any = PrivateAttr(default_factory=threading.RLock)
_save_pool max_workers=1★整个记忆只有一个后台保存线程——所有写排成一队,天然不会两个写同时改库。_pending_saves记着"提交了但还没跑完"的 Future 列表,读屏障就靠遍历它来等待。_reset_lock 是 RLock可重入锁:同一线程能多次获取(reset 里会调 drain_writes,drain 又可能要锁),用普通 Lock 会自己把自己锁死。L02
model_post_init:把字符串配置变成真对象
Pydantic 模型初始化后自动调 model_post_init(unified_memory.py:206):
# unified_memory.py:206(节选)
def model_post_init(self, __context: Any) -> None:
self._config = MemoryConfig( # ① 把散落的权重打包成一个 config
recency_weight=self.recency_weight, semantic_weight=self.semantic_weight,
importance_weight=self.importance_weight, ...)
self._llm_instance = ( # ② LLM:字符串则延后创建(懒),实例则包装成非流式
None if isinstance(self.llm, str) else _non_streaming_analysis_llm(self.llm))
if isinstance(self.storage, str): # ③ 字符串 storage → 解析成真实后端
from crewai.memory.storage.factory import resolve_memory_storage
custom = resolve_memory_storage(self.storage) # 先问全局工厂
if custom is not None:
self._storage = custom
elif self.storage == "qdrant-edge":
from crewai.memory.storage.qdrant_edge_storage import QdrantEdgeStorage
self._storage = QdrantEdgeStorage()
elif self.storage == "lancedb":
from crewai.memory.storage.lancedb_storage import LanceDBStorage
self._storage = LanceDBStorage()
else: # 其他字符串当成路径
from crewai.memory.storage.lancedb_storage import LanceDBStorage
self._storage = LanceDBStorage(path=self.storage)
else:
self._storage = self.storage # 已经是实例,直接用
① 组装 _config把用户传的一堆权重字段收进一个 MemoryConfig——之后传给 Flow 只递这一个对象(呼应 D33 L04)。② LLM 懒创建如果 llm 是字符串(模型名),先设 None,等真要分析时(_llm 属性)才 new。省启动开销、也让"不用 LLM 分析"的路径完全不碰 LLM。_non_streaming_analysis_llm把用户的 LLM 复制一份并关掉流式(:58)——内部分析要的是完整 JSON,流式反而添乱。用副本避免污染用户原来的 LLM。③ storage 字符串分派先问可插拔工厂(D33 L06 的 factory),没命中再按 "lancedb"/"qdrant-edge" 内建、否则当路径。全部函数内 import——又是懒加载,不用哪个后端就不导入它。图注:用户只填左侧简单配置;
model_post_init 把字符串/权重装配成右侧真实的 config、LLM、存储后端和并发设施。💡 设计取舍①:为什么 storage 字段是"字符串 or 实例"两用?
朴素做法:字段就要求传
StorageBackend 实例——类型干净,但用户每次都得自己 Memory(storage=LanceDBStorage(path="...")),啰嗦。源码做法:接受字符串("lancedb" / 路径 / "qdrant-edge")当"简写",也接受实例当"完全控制"。90% 用户写 Memory() 就够,高级用户塞自己的实例。用一点点内部分派逻辑,换来 API 的"渐进式复杂度"——上手极简、深用不设限。L03
remember:同步存一条,等结果返回
remember 存单条,需要立刻拿到 record(unified_memory.py:430):
# unified_memory.py:430(节选)
def remember(self, content, scope=None, ..., root_scope=None) -> MemoryRecord | None:
if self.read_only:
return None # 只读模式:静默不存
effective_root = root_scope if root_scope is not None else self.root_scope
try:
crewai_event_bus.emit(self, MemorySaveStartedEvent(value=content, ...))
# ★仍走同一个保存池(串行化),但阻塞等它完成
future = self._submit_save(
self._encode_batch, [content], scope, categories, metadata,
importance, source, private, effective_root)
records = future.result() # ★阻塞:直到这条存完
record = records[0] if records else None
crewai_event_bus.emit(self, MemorySaveCompletedEvent(value=content, ...))
return record
except Exception as e:
crewai_event_bus.emit(self, MemorySaveFailedEvent(value=content, error=str(e), ...))
raise
read_only 早退只读记忆(如给 Agent 一个共享的"公司知识"视图)调 remember 是静默 no-op,不报错——方便把同一段代码用在读写/只读两种配置上。_submit_save + future.result()★关键:还是丢进保存池排队(和后台写共用一条队列,避免和它们冲突),但立刻 .result() 阻塞等完成。所以 remember 是"排队 + 等"。三个事件Started / Completed / Failed 成对发到事件总线(D26 讲的 events 系统),供监听器打日志、算耗时、做可观测。大白话为什么单条也要走保存池、不直接存?因为后台可能正有一批在写同一个库。如果 remember 绕过队列直接写,就可能和后台那批撞车(并发写冲突)。所以"排同一条队"是为了串行、不冲突;只是 remember 愿意等,remember_many 不等而已。
L04
remember_many:提交即返回,后台慢慢办
批量存不阻塞(unified_memory.py:523):
# unified_memory.py:523(节选)
def remember_many(self, contents, ..., root_scope=None) -> list[MemoryRecord]:
if not contents or self.read_only:
return []
effective_root = root_scope if root_scope is not None else self.root_scope
self._submit_save( # ★只提交,不 .result()
self._background_encode_batch,
contents, scope, categories, metadata, importance,
source, private, agent_role, effective_root)
return [] # ★立刻返回空列表(记录还没生成)
只 _submit_save 不 .result()提交到保存池就返回,不等。Agent 干完活喊一声"这些记下"就继续跑,不卡。return []★诚实地返回空列表——因为后台还没跑完,记录对象此刻确实不存在。文档里明说"records are not available until the background save completes"。_background_encode_batch后台版:在后台线程里自己发 Started + Completed 事件(:581),保证事件在事件总线的"作用域栈"上成对出现。⚠️ 边界:进程退出时后台写来不及怎么办?
_background_encode_batch(:641)里有段细致处理:编码流水线内部用 asyncio.run() → to_thread(),进程关闭时默认线程池已关,to_thread 会抛 "cannot schedule new futures after shutdown"。源码专门识别这个字符串、静默放弃这次保存(反正进程都要退了),但其他 RuntimeError 必须继续抛,好让保存 Future 的回调通过 MemorySaveFailedEvent 报出来。——区分"进程正常退出导致的写失败(无所谓)"和"真 bug 导致的写失败(要报警)",非常克制。L05
_submit_save:contextvars 复制 + 关闭兜底
所有写都过这个提交口(unified_memory.py:297):
# unified_memory.py:297
def _submit_save(self, fn, *args, **kwargs) -> Future[Any]:
with self._reset_lock:
ctx = contextvars.copy_context() # ★复制当前上下文
try:
future = self._save_pool.submit(ctx.run, fn, *args, **kwargs)
except RuntimeError: # 池已关闭(close 之后)
future = Future() # ★兜底:同步跑,不丢
try:
result = fn(*args, **kwargs)
future.set_result(result)
except Exception as exc:
future.set_exception(exc)
return future
with self._pending_lock:
self._pending_saves.append(future) # 登记为"挂起"
future.add_done_callback(self._on_save_done) # 完成时从挂起表移除
return future
contextvars.copy_context()★把当前线程的上下文变量(如事件总线作用域、追踪上下文)拷一份,让后台线程 ctx.run(fn,...) 时能读到正确的上下文——否则后台线程发的事件会"挂错地方"。这和 D08 原生工具并行用的是同一招。except RuntimeError 兜底如果池已经 shutdown(比如 crew close 后又来了一条迟到的存),不报错、当场同步执行并包成已完成的 Future——迟到的写也不丢。add_done_callback登记完成回调 _on_save_done:把 Future 从挂起表删掉,若有异常就发 MemorySaveFailedEvent。回调里吞掉一切异常(:347),因为它可能在进程关闭、总线已关时被调用。图注:所有写经单线程保存池串行;recall 用 drain_writes 等池排空,保证"写后读一致"。
L06
drain_writes:读之前先把待写排空
读屏障本体(unified_memory.py:350)非常短:
# unified_memory.py:350
def drain_writes(self) -> None:
"""Block until all pending background saves have completed."""
with self._pending_lock:
pending = list(self._pending_saves) # 快照当前挂起的 Future
for future in pending:
if future.cancelled():
continue
future.exception() # ★阻塞直到完成;不重新抛(失败已由事件报过)
recall 开头第一句就调它(unified_memory.py:711):
# unified_memory.py:711
def recall(self, query, ..., depth="deep", ...) -> list[MemoryMatch]:
# Read barrier: wait for any pending background saves to finish
# so that the search sees all persisted records.
self.drain_writes()
...
先拿锁快照 pending把挂起列表复制一份再遍历——遍历期间不长时间占着锁(否则后台完成回调想删元素会被卡)。future.exception()★这一句会阻塞直到 Future 完成,但拿到异常后不 raise——因为写失败已经通过 MemorySaveFailedEvent 报过了,不该让一条写失败连累整个 recall 崩掉。recall 首行调用保证"你刚 remember_many 的东西,紧接着 recall 一定查得到"——异步写 + 读屏障 = 对用户表现得像同步。👶 小白:为什么写异步、读却要等?不能都异步吗?
👨🏫 老师:因为写和读的"急迫度"不同。写记忆是旁路副作用——Agent 记不记得住不影响它当前这步干活,慢点无所谓,异步最好。但读记忆是Agent 决策的输入——它要靠查到的东西做下一步,此刻必须拿到完整、最新的结果,查漏了会直接影响正确性。所以写图快(异步)、读图对(等齐再查),各取所需。
L07
recall 两档深度:shallow 直搜 vs deep 走 Flow
排空后,recall 按 depth 分两条路(unified_memory.py:734):
# unified_memory.py:734(节选)
if depth == "shallow":
embedding = embed_text(self._embedder, query) # 直接嵌入查询
if not embedding:
results = []
else:
raw = self._storage.search(embedding, scope_prefix=effective_scope,
categories=categories, limit=limit, min_score=0.0)
if not include_private: # 隐私过滤
raw = [(r, s) for r, s in raw if not r.private or r.source == source]
results = []
for r, s in raw:
composite, reasons = compute_composite_score(r, s, self._config) # 复合评分
results.append(MemoryMatch(record=r, score=composite, match_reasons=reasons))
results.sort(key=lambda m: m.score, reverse=True)
else: # deep:走 RecallFlow(D35)
from crewai.memory.recall_flow import RecallFlow
flow = RecallFlow(storage=self._storage, llm=self._llm,
embedder=self._embedder, config=self._config)
flow.kickoff(inputs={"query": query, "scope": effective_scope, ...})
results = flow.state.final_results
shallow:一次向量搜把查询嵌入 → 直接搜 → 复合评分排序。不调 LLM,最快,适合短查询/对延迟敏感的场景。隐私过滤不含私有时,把 private 且 source 不匹配的记录剔掉——L03 提到的多租户隔离在这落地。deep(默认):RecallFlow交给 D35 的智能检索流:LLM 蒸馏子查询、多 scope 并行搜、按置信度决定要不要深挖。更全但更慢。touch_records拿到结果后(:786)调 touch_records 更新 last_accessed——"被翻到过"的记忆刷新访问时间,可用于后续淘汰策略。📝 例子:什么时候用哪档
m.recall("SF天气", depth="shallow"):短问题、要秒回 → shallow,一次搜完事。m.recall(整段任务描述, depth="deep"):一大段上下文 → deep,让 LLM 先把它拆成"天气""交通""预算"几个子查询分别搜,再合并——找得更全。默认就是 deep。L08
取舍 + 今日小结
💡 设计取舍②:为什么保存池只给 1 个 worker?
多线程一般是为了"更快",这里却故意只用 1 个。原因是写记忆的正确性 > 写的吞吐:LanceDB 这类嵌入式向量库并发写会遇到"提交冲突"(optimistic concurrency,D40 讲它怎么重试)。与其让 N 个线程抢着写、频繁冲突重试,不如用单线程把写排成一队,从根上消灭冲突。写本来就是后台异步的、不阻塞前台,慢一点用户根本感知不到——所以拿"写的并行度"换"零冲突",是划算的。真要并行的是读(RecallFlow 内部多 scope 并行搜,D35)。
⚠️ 边界:__deepcopy__ 为什么要手写?
Memory 手写了 __deepcopy__(unified_memory.py:174)。因为它的私有属性里有 ThreadPoolExecutor 和 Lock——这些不能被 pickle/deepcopy(线程、锁是操作系统资源)。crew 在做检查点/克隆时会深拷贝,若不特判就会崩。源码的做法:遇到 _save_pool/各种 lock 就用它们的默认工厂新建一个(而不是拷贝旧的),其余字段正常深拷。这是"含不可拷贝资源的对象要自定义拷贝语义"的标准处理。🧠 今天你应该能回答
- model_post_init 做了哪三件装配?storage 字符串怎么变实例?
- remember 和 remember_many 一个同步一个后台,各自适合什么?
- 保存池为什么只 1 个 worker?contextvars 复制解决什么?
- drain_writes 是什么屏障?为什么写异步、读要等?
- shallow 和 deep 检索差在哪?默认哪个?
- 为什么 Memory 要手写 __deepcopy__?
✋ 10 分钟动手
P=lib/crewai/src/crewai/memory/unified_memory.py
sed -n '206,252p' $P # model_post_init 装配
sed -n '297,363p' $P # _submit_save + drain_writes
sed -n '681,782p' $P # recall:读屏障 + shallow/deep 分流
# 感受异步写 + 读屏障
python -c "
from crewai.memory import Memory
m = Memory()
m.remember_many(['A 计划下周启动','B 计划已取消']) # 立刻返回 []
print('recall 前会自动 drain_writes:')
for h in m.recall('计划状态', depth='shallow'):
print(h.record.content)
"
明日预告 · Day 35:今天
recall(deep) 里那句 RecallFlow(...).kickoff()、remember 里的 EncodingFlow,就是记忆的两条流水线。明天逐步拆 recall_flow.py(自适应深度 + 置信度路由)和 encoding_flow.py(5 步批处理 + 4 组分类)。