两条流水线:智能检索 RecallFlow + 批量编码 EncodingFlow
Day 34 说 recall(deep) 交给 RecallFlow、remember 交给 EncodingFlow。今天把这两条流水线逐步拆开。RecallFlow 是"RLM 风格"的自适应检索:LLM 把查询蒸馏成子查询 → 多 scope 并行搜 → 按置信度决定要不要再深挖一轮(@router 循环)。EncodingFlow 是 5 步批处理:一次嵌入全部 → 批内去重 → 并行找相似 → 4 组分类并行分析 → 批量落库。这是记忆系统真正"聪明"的地方,也是 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这是框架内部流水线,不该把它的每一步都当业务事件刷到总线上。@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 一次能收一批,省往返。=0 强制总是分析 的口子。_do_search:子查询 × scope 并行搜
filter_and_chunk 选定候选 scope 后,search_chunks 调 _do_search(recall_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 里"最好那条的复合分"取最大,作为这次检索的置信度——它决定下一步要不要深挖。@router:按置信度决定"收工还是深挖"
核心的自适应逻辑在 @router(recall_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 才深挖——预算是循环的刹车,没预算就算不满意也收工,防止无限深挖。深挖循环 + 结果合成
深挖分支(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。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 传上下文。
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_plans(encoding_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)
边界 + 今日小结
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=True(analyze.py:318)。宗旨是"LLM 挂了也要把记忆存下来 / 检索降级但不崩"——LLM 只是锦上添花,不该成为记忆能不能用的单点故障。extract_memories 失败甚至把整段原文当一条记忆存(analyze.py:191),宁可粗糙也不丢数据。🧠 今天你应该能回答
- 为什么记忆流水线用 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 失败兜底
ExtractedMetadata,讲清"短期=新鲜度、长期=重要性+合并持久化、实体=抽取的 entities/categories"的对应关系。