Day 35 / 共 60 天 · 阶段6 记忆与知识

两条流水线:智能检索 RecallFlow + 批量编码 EncodingFlow

Day 34 说 recall(deep) 交给 RecallFlowremember 交给 EncodingFlow。今天把这两条流水线逐步拆开。RecallFlow 是"RLM 风格"的自适应检索:LLM 把查询蒸馏成子查询 → 多 scope 并行搜 → 按置信度决定要不要再深挖一轮@router 循环)。EncodingFlow 是 5 步批处理:一次嵌入全部 → 批内去重 → 并行找相似 → 4 组分类并行分析 → 批量落库。这是记忆系统真正"聪明"的地方,也是 CrewAI Flow(阶段7)在框架内部的实战范例。

📍 你在 60 天里的位置(阶段6 记忆与知识 · 共 8 天)
D33 记忆总览 D34 unified_memory D35 recall/encoding D36 短/长/entity D37 scope 作用域 D38 RAG D39 knowledge D40 embedding/存储
💡 先用一个类比兜住今天 RecallFlow 像一个尽职的研究助理:你问一个大问题,他先把问题拆成几个小问题(子查询),分头去几个书架(scope)同时找,找回来觉得"证据够硬"就直接给你,觉得"还不太确定"就再翻一轮深挖。EncodingFlow 像一个批量归档的文员:一摞纸进来,他先给全部拍照(嵌入),把几乎一模一样的先扔掉(去重),再看每张跟档案柜里已有的像不像,然后分四种情况决定"直接归档 / 叫 LLM 想想该合并还是新增"。
L01

痛点:检索/编码步骤多,还要并行

🤔 痛点"智能检索"不是一次搜就完事:要分析查询、要在多个 scope 里搜、要判断结果够不够好、不够好还要再来一轮——这是有分支、有循环、有并行的多步流程。硬写成一个大函数会又长又乱、还难加"再深挖一轮"这种循环。怎么把它组织得清晰、可扩展?
💡 一句话本质 CrewAI 用自家的 Flow 框架(阶段7 主角)来写记忆流水线:@start 起点、@listen 串下一步、@router 按状态选分支(能形成循环)。状态存在 flow.state 里各步共享。把"多步 + 分支 + 循环"用声明式的装饰器拼出来,比一个大函数清楚得多。

两个 Flow 都关了自身事件、且标记"别对我自己开记忆"(recall_flow.py:66 / encoding_flow.py:85):

# recall_flow.py:58
class RecallFlow(Flow[RecallState]):
    _skip_auto_memory: bool = True          # ★别给记忆流水线自身再开记忆(防递归)
    initial_state: type[RecallState] = RecallState
    def __init__(self, storage, llm, embedder, config=None):
        super().__init__(suppress_flow_events=True)   # 内部流水线,不往事件总线刷屏
        self._storage = storage; self._llm = llm
        self._embedder = embedder; self._config = config or MemoryConfig()
Flow[RecallState]泛型:这个 Flow 的共享状态类型是 RecallState(一个 pydantic 模型)。各步都读写 self.state
_skip_auto_memory=True★防递归:CrewAI 会自动给 Flow 开记忆,但记忆流水线本身是 Flow——不拦住就会"检索时又触发检索",无限套娃。
suppress_flow_events=True这是框架内部流水线,不该把它的每一步都当业务事件刷到总线上。
L02

@start 查询分析:短查询跳过 LLM

RecallFlow 的起点(recall_flow.py:178):

# recall_flow.py:178(节选)
@start()
def analyze_query_step(self) -> QueryAnalysis:
    self.state.exploration_budget = self._config.exploration_budget
    query_len = len(self.state.query)
    skip_llm = query_len < self._config.query_analysis_threshold    # ★短查询跳过 LLM

    if skip_llm:
        analysis = QueryAnalysis(keywords=[], suggested_scopes=[],
            complexity="simple", recall_queries=[self.state.query])
        self.state.query_analysis = analysis
    else:
        available = self._storage.list_scopes(self.state.scope or "/") or ["/"]
        scope_info = (self._storage.get_scope_info(self.state.scope or "/")
                      if self.state.scope else None)
        analysis = analyze_query(self.state.query, available, scope_info, self._llm)
        self.state.query_analysis = analysis
        if analysis.time_filter:                        # LLM 抽出的时间过滤
            try:
                self.state.time_cutoff = datetime.fromisoformat(analysis.time_filter)
            except ValueError:
                pass
    queries = (analysis.recall_queries or [self.state.query])[:3]   # 最多 3 个子查询
    embeddings = embed_texts(self._embedder, queries)               # ★一次批量嵌入
    pairs = [(q, emb) for q, emb in zip(queries, embeddings) if emb]
    ...
    self.state.query_embeddings = pairs
    return analysis
skip_llm 阈值判断★查询短于 250 字符(默认)就不调 LLM,直接把原查询当子查询——省 1~3 秒。长查询(整段任务描述)才值得让 LLM 蒸馏。
analyze_query调 LLM(analyze.py)产出 QueryAnalysis:关键词、建议搜哪些 scope、复杂度 simple/complex、1~3 个子查询、可选时间过滤。
time_filter → time_cutoff"上周的记录"这类会被 LLM 转成 ISO 日期,后面搜完按它过滤掉更早的。
queries[:3] + embed_texts 一次最多 3 个子查询,一次批量嵌入(不是循环逐个调)——嵌入 API 一次能收一批,省往返。
💡 设计取舍①:为什么不总是让 LLM 分析查询? LLM 蒸馏查询能提升召回质量,但每次多花 1~3 秒 + 一次 LLM 费用。对"SF 天气"这种已经很聚焦的短查询,蒸馏几乎不带来增益。源码用一条字符数阈值把两类查询分开:短的走"直接嵌入原文"的快路,长的(如整段任务上下文)走"LLM 蒸馏"的慢路。注释算得很实在:"saving ~1-3s"。用一个可配的阈值,在"质量"和"延迟/成本"之间给出默认平衡,还留了 =0 强制总是分析 的口子。
L03

_do_search:子查询 × scope 并行搜

filter_and_chunk 选定候选 scope 后,search_chunks_do_searchrecall_flow.py:87):

# recall_flow.py:87(节选)
def _do_search(self) -> list[dict[str, Any]]:
    def _search_one(embedding, scope):
        raw = self._storage.search(embedding, scope_prefix=scope,
            categories=search_categories,
            limit=self.state.limit * _RECALL_OVERSAMPLE_FACTOR,   # ★多取 2 倍候选
            min_score=0.0)
        if self.state.time_cutoff and raw:                        # 时间过滤
            raw = [(r, s) for r, s in raw if r.created_at >= self.state.time_cutoff]
        if not self.state.include_private and raw:                # 隐私过滤
            raw = [(r, s) for r, s in raw if not r.private or r.source == self.state.source]
        return scope, raw

    tasks = [(emb, scope)                             # ★笛卡尔积:每个子查询 × 每个 scope
             for _q, emb in self.state.query_embeddings
             for scope in self.state.candidate_scopes]

    findings = []
    if len(tasks) <= 1:
        ... # 单个直接跑
    else:
        with ThreadPoolExecutor(max_workers=min(len(tasks), 4)) as pool:  # 最多 4 并发
            futures = {pool.submit(contextvars.copy_context().run, _search_one, emb, sc): (emb, sc)
                       for emb, sc in tasks}
            for future in as_completed(futures):
                scope, results = future.result()
                if results:
                    top_composite, _ = compute_composite_score(results[0][0], results[0][1], self._config)
                    findings.append({"scope": scope, "results": results, "top_score": top_composite})
    self.state.chunk_findings = findings
    self.state.confidence = max((f["top_score"] for f in findings), default=0.0)  # ★置信度
    return findings
笛卡尔积 tasks3 个子查询 × 5 个候选 scope = 15 个搜索任务。每个都是"用这个向量在这个 scope 里搜"。
oversample ×2每个任务取 limit×2 条候选——因为后面还要去重/复合评分/类目过滤,多备货才够筛出最终的 limit 条(D33 提过 oversample)。
ThreadPoolExecutor ≤4多任务并行搜(向量搜是 IO/CPU 混合,并行能显著提速),并发上限 4。又见 copy_context().run 传上下文。
confidence = max(top_score)★把所有 scope 里"最好那条的复合分"取最大,作为这次检索的置信度——它决定下一步要不要深挖。
大白话"置信度"就是"我这轮找到的最好结果有多靠谱"。如果最好那条复合分很高(比如 0.85),说明找得很准,可以收工;如果最高才 0.4,说明没找到啥好的,得再想办法(深挖)。这个数就是下一讲路由器的判断依据。
L04

@router:按置信度决定"收工还是深挖"

核心的自适应逻辑在 @routerrecall_flow.py:271):

# recall_flow.py:271
@router(search_chunks)
def decide_depth(self) -> str:
    analysis = self.state.query_analysis
    if (analysis and analysis.complexity == "complex"
            and self.state.confidence < self._config.complex_query_threshold):
        if self.state.exploration_budget > 0:
            return "explore_deeper"          # 复杂查询 + 置信不足 + 有预算 → 深挖
    if self.state.confidence >= self._config.confidence_threshold_high:
        return "synthesize"                  # 置信很高(≥0.8)→ 直接收工
    if (self.state.exploration_budget > 0
            and self.state.confidence < self._config.confidence_threshold_low):
        return "explore_deeper"              # 置信很低(<0.5)+ 有预算 → 深挖
    return "synthesize"                      # 其余 → 收工
@router(search_chunks)路由器监听 search_chunks 完成,返回一个字符串标签("synthesize" 或 "explore_deeper"),Flow 据此走对应分支。
复杂查询更爱深挖LLM 判为 complex 的查询,用更宽松的阈值(0.7):只要没到就深挖——因为复杂问题本来就该多找。
高置信直接收工≥0.8 说明结果够好,别浪费 LLM 调用,直接去合成结果。
低置信 + 有预算才深挖<0.5 且 exploration_budget>0 才深挖——预算是循环的刹车,没预算就算不满意也收工,防止无限深挖。
控制流:RecallFlow 的置信度路由循环 analyze_query(@start) search_chunks 并行搜 @router decide_depth synthesize 合成结果 ✅ explore_deeper 深挖一轮 置信≥0.8 / 无预算 置信低 + 有预算 re_search → @router 再判 budget-=1 后回到路由;预算耗尽必收工
图注:路由器按置信度分两支;深挖后重搜、再回路由——预算(budget)保证循环一定终止。
L05

深挖循环 + 结果合成

深挖分支(recall_flow.py:291)先扣预算,再让 LLM 从当前结果里提炼:

# recall_flow.py:291(节选)
@listen("explore_deeper")
def recursive_exploration(self) -> list[Any]:
    self.state.exploration_budget -= 1          # ★先扣预算,保证循环终止
    enhanced = []
    for finding in self.state.chunk_findings:
        content_parts = [r[0].content for r in finding["results"][:5]]
        chunk_text = "\n---\n".join(content_parts)
        prompt = (f"Query: {self.state.query}\n\nRelevant memory excerpts:\n{chunk_text}\n\n"
                  "Extract the most relevant information ... If something is missing, "
                  "say what's missing in one short line.")
        response = self._llm.call([{"role": "user", "content": prompt}])
        if isinstance(response, str) and "missing" in response.lower():
            self.state.evidence_gaps.append(response[:200])   # ★记下"缺什么"
        enhanced.append({"scope": finding["scope"], "extraction": response, "results": finding["results"]})
    self.state.chunk_findings = enhanced
    return enhanced

@listen(recursive_exploration)
def re_search(self) -> list[Any]:
    return self._do_search()                    # 重搜,更新 confidence 给路由再判

@router(re_search)
def re_decide_depth(self) -> str:
    return self.decide_depth()                  # 复用同一套路由逻辑

最终合成(recall_flow.py:343):去重 + 复合评分 + 排序 + 挂证据缺口:

# recall_flow.py:343(节选)
@listen("synthesize")
def synthesize_results(self) -> list[MemoryMatch]:
    seen_ids, matches = set(), []
    for finding in self.state.chunk_findings:
        for item in finding.get("results", []):
            record, score = item[0], item[1]
            if isinstance(record, MemoryRecord) and record.id not in seen_ids:  # 去重
                seen_ids.add(record.id)
                composite, reasons = compute_composite_score(record, float(score), self._config)
                matches.append(MemoryMatch(record=record, score=composite, match_reasons=reasons))
    matches.sort(key=lambda m: m.score, reverse=True)     # 复合分降序
    final_results = matches[: self.state.limit]           # 截断到 limit
    self.state.final_results = final_results
    if self.state.evidence_gaps and self.state.final_results:
        self.state.final_results[0].evidence_gaps = list(self.state.evidence_gaps)  # 挂"缺什么"
    return final_results
budget -= 1★进深挖第一件事就扣预算。这是循环能终止的唯一保证——预算见底路由必回 synthesize。
evidence_gaps让 LLM 说"还缺什么",收集起来挂到最终结果第一条上——告诉调用方"我尽力了,但这些没找到",可解释、可追问。
seen_ids 去重不同 scope / 不同子查询可能搜到同一条记录,用 id 集合去重,只留一份。
复合评分再排一次合成时统一用 compute_composite_score(D33 公式)重排——语义+新鲜+重要三合一,截断到 limit。
L06

EncodingFlow:存的五步流水线

换到编码侧。EncodingFlow 是 5 步顺序 Flow(encoding_flow.py:75),头两步:

# encoding_flow.py:110
@start()
def batch_embed(self) -> None:
    """Embed all items in a single embedder call."""
    items = list(self.state.items)
    texts = [item.content for item in items]
    embeddings = embed_texts(self._embedder, texts)      # ★一次调用嵌入全部
    for item, emb in zip(items, embeddings):
        item.embedding = emb

@listen(batch_embed)
def intra_batch_dedup(self) -> None:
    """Drop near-exact duplicates within the batch."""
    items = list(self.state.items)
    if len(items) <= 1:
        return
    threshold = self._config.batch_dedup_threshold      # 默认 0.98
    n = len(items)
    for j in range(1, n):
        if items[j].dropped or not items[j].embedding: continue
        for i in range(j):
            if items[i].dropped or not items[i].embedding: continue
            sim = self._cosine_similarity(items[i].embedding, items[j].embedding)
            if sim >= threshold:
                items[j].dropped = True                 # ★近乎重复→标记丢弃
                self.state.items_dropped_dedup += 1
                break
batch_embed 一次嵌入整批文本一次 embed_texts——N 条记忆只调一次嵌入 API,不是 N 次。批处理省钱省时。
intra_batch_dedup 两两比O(n²) 两两算余弦相似度,后来的若跟前面某条 ≥0.98 就标 dropped。只在同一批内去重(跨批的靠下一步"找相似 + 合并")。
_cosine_similarity纯手写余弦(:140):点积 / 两个模长之积,零向量返回 0。不依赖 numpy,轻量。
阈值 0.98 很高只丢"几乎一字不差"的,防止把"相似但有用"的误杀(D33 L04 提过这个克制)。

第三步并行找库里已有的相似记录(encoding_flow.py:152),为下一步"要不要合并"做准备——同样用 ThreadPoolExecutor(max_workers=min(len(active), 8)) 并发搜、copy_context().run 传上下文。

L07

parallel_analyze:四组分类,能省 LLM 就省

第四步是最精妙的:按"字段全不全 × 有没有相似记录"把每条分成四组(encoding_flow.py:221):

# encoding_flow.py:264(节选)
fields_provided = (item.scope is not None and item.categories is not None
                   and item.importance is not None)
has_similar = item.top_similarity >= threshold          # ≥0.85

if fields_provided and not has_similar:                 # A 组:0 次 LLM
    self._apply_defaults(item)
    item.plan = ConsolidationPlan(actions=[], insert_new=True)   # 直接插入
elif fields_provided and has_similar:                   # B 组:1 次(只判合并)
    self._apply_defaults(item)
    consol_futures[i] = pool.submit(..., analyze_for_consolidation, ...)
elif not fields_provided and not has_similar:           # C 组:1 次(只补字段)
    save_futures[i] = pool.submit(..., analyze_for_save, ...)
else:                                                   # D 组:2 次并发(补字段 + 判合并)
    save_futures[i] = pool.submit(..., analyze_for_save, ...)
    consol_futures[i] = pool.submit(..., analyze_for_consolidation, ...)
A 组:全给了 + 无相似用户把 scope/类目/重要性都填了、库里也没相似的 → 0 次 LLM,直接标记插入。最快路径。
B 组:全给了 + 有相似字段不用推,但要 1 次 LLM 决定"跟已有的合并/更新/删除还是各存"。
C 组:缺字段 + 无相似1 次 LLM 补 scope/类目/重要性/抽实体(analyze_for_save),然后插入。
D 组:缺字段 + 有相似最重:2 次 LLM 并发跑(一个补字段、一个判合并)。
所有 future 一个 pool全部 item 的所有 LLM 调用丢进 ThreadPoolExecutor(max_workers=10) 一起并行——N 条记忆的分析总耗时≈最慢那条,而非累加。

第五步 execute_plansencoding_flow.py:369)把所有计划落库,关键是跨 item 去重动作

# encoding_flow.py:390(节选)
# 多个 item 的 similar_records 可能重叠 → 对同一 record_id 只保留第一个动作,防 LanceDB 提交冲突
for i, item in enumerate(items):
    for action in item.plan.actions:
        rid = action.record_id
        if action.action == "delete" and rid not in dedup_deletes and rid not in dedup_updates:
            dedup_deletes.add(rid)
        elif action.action == "update" and action.new_content and rid not in ...:
            dedup_updates[rid] = (i, action.new_content)
# 之后:批量 re-embed 更新内容 → delete → update → 一次 bulk save(records)
💡 设计取舍②:为什么按四组分类,而不是每条都跑完整分析? 朴素做法:每条记忆都调 2 次 LLM(补字段 + 判合并),简单统一。但如果用户已经把字段填全了、库里也没相似的,这 2 次调用纯属浪费钱和时间源码做法:先用"字段全不全""有无相似"两个廉价判断把记忆分四档,只有真需要的那档才付 LLM 成本(A 组 0 次、B/C 组 1 次、D 组 2 次)。再加上"全部 item 的 LLM 调用塞进一个池并行",让一批 N 条的分析既省调用数、又省墙上时间。这是"按需付费 + 最大化并行"的精打细算。
L08

边界 + 今日小结

⚠️ 边界:LLM 分析失败了,记忆会丢吗? 不会。analyze.py 里三个分析函数(analyze_query / analyze_for_save / analyze_for_consolidation)都用 try/except 包住,失败时返回安全默认值:查询分析退化成 complexity="simple" 的纯向量搜(analyze.py:244);保存分析退回 _SAVE_DEFAULTS(scope=/、importance=0.5);合并分析退回 insert_new=Trueanalyze.py:318)。宗旨是"LLM 挂了也要把记忆存下来 / 检索降级但不崩"——LLM 只是锦上添花,不该成为记忆能不能用的单点故障。extract_memories 失败甚至把整段原文当一条记忆存(analyze.py:191),宁可粗糙也不丢数据。
数据流:EncodingFlow 五步(N 条记忆一批过) ①batch_embed 1 次嵌入全部 ②dedup 批内去重≥0.98 ③find_similar 并行搜相似 ④analyze 4 组 并行 LLM 按需 ⑤execute 去重+批量落库 串行 5 步,但每步内部对 N 条记忆最大化批处理/并行
图注:编码是"步骤串行、批内并行"——嵌入一次、去重一遍、相似并行搜、分析并行调、最后批量写。

🧠 今天你应该能回答

  • 为什么记忆流水线用 Flow 写?_skip_auto_memory 防什么?
  • 短查询为什么跳过 LLM 分析?阈值多少?
  • _do_search 怎么并行?confidence 怎么算?
  • @router 按什么决定深挖/收工?循环靠什么终止?
  • EncodingFlow 五步分别做什么?为什么按四组分类?
  • LLM 分析失败,记忆会丢吗?为什么?

✋ 10 分钟动手

P=lib/crewai/src/crewai/memory
sed -n '178,241p' $P/recall_flow.py    # @start 查询分析 + skip_llm
sed -n '271,341p' $P/recall_flow.py    # @router 深挖循环
sed -n '221,345p' $P/encoding_flow.py  # 四组分类 + 并行分析
sed -n '155,197p' $P/analyze.py        # extract_memories 失败兜底
明日预告 · Day 36:这套统一记忆是怎么"取代"老版短期/长期/实体三种记忆的?明天用今天学的评分公式和 ExtractedMetadata,讲清"短期=新鲜度、长期=重要性+合并持久化、实体=抽取的 entities/categories"的对应关系。
← Day 34 unified_memory Day 36 · 短/长/entity →