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_initunified_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——又是懒加载,不用哪个后端就不导入它。
数据结构:字段(公开配置)→ 私有运行态 公开字段(用户传) llm="gpt-5.4-mini" storage="lancedb" recency/semantic/importance_weight embedder=None root_scope=None ↓ model_post_init 装配 私有运行态(内部造) _config: MemoryConfig(打包权重) _llm_instance(懒建,非流式) _storage: LanceDBStorage 实例 _save_pool(1 worker) _pending_saves / _reset_lock
图注:用户只填左侧简单配置;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),因为它可能在进程关闭、总线已关时被调用。
控制流:写不阻塞、读设屏障 remember_many() remember() _submit_save copy_context 保存池 workers=1 write1 → write2 → … 串行、不冲突 many:提交即走 · single:阻塞等 result() recall() drain_writes() 读屏障 recall 先等保存池排空(等所有 pending Future 完成)再检索 → 读得到刚写的
图注:所有写经单线程保存池串行;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,最快,适合短查询/对延迟敏感的场景。
隐私过滤不含私有时,把 privatesource 不匹配的记录剔掉——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)。因为它的私有属性里有 ThreadPoolExecutorLock——这些不能被 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 组分类)。
← Day 33 记忆总览 Day 35 · 两条 Flow →