DeerFlow 记忆系统设计详解:一个没有向量库的长期记忆是怎么跑起来的
本文基于 DeerFlow 源码(
backend/packages/harness/deerflow/agents/memory/及其宿主中间件)梳理,所有结论都能在代码里找到落点。文中只标文件路径与函数/类名,不标行号------行号会随重构腐烂。
0. 先说结论:这套设计在解决什么问题
Agent 的长期记忆通常被当成"检索问题"来做:把历史对话切块、embedding、存进向量库、下一轮 Top-K 召回。DeerFlow 的记忆系统(内部代号 DeerMem)走的是另一条路:
- 写入是异步、去抖、带水印(watermark)的批处理,不是每轮对话都调 LLM;
- 存储载体是可读、可 diff、可人工编辑的 Markdown + JSON 清单,而不是不透明的向量集合;
- 检索默认用 SQLite FTS5 词法索引 + 可选的 IDF/MMR 重排 ,DeerMem 里没有任何 embedding;
- 注入(injection)与检索(retrieval)是两条独立通路,注入有严格的 token 预算与被保护类目;
- 记忆后端是可插拔契约 (
deermem/noop/mem0/honcho/openviking),换后端不需要改主流程一行代码; - 一切隐私与并发边界都在写入前和落盘时强制:PII 脱敏在入队边界、按用户分桶、按 agent 分目录、跨进程文件锁 + 双平面版本号 + 可恢复提交日志。
代价也很明确:写入可能丢(队列是进程内存态)、抽取依赖模型自觉、词法检索不懂语义。第六节我会把这条路的坑摊开讲。
1. 整体分层:契约层、编排层、后端层
#mermaid-svg-egwg1mlLUfbFiyGw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-egwg1mlLUfbFiyGw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-egwg1mlLUfbFiyGw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-egwg1mlLUfbFiyGw .error-icon{fill:#552222;}#mermaid-svg-egwg1mlLUfbFiyGw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-egwg1mlLUfbFiyGw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-egwg1mlLUfbFiyGw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-egwg1mlLUfbFiyGw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-egwg1mlLUfbFiyGw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-egwg1mlLUfbFiyGw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-egwg1mlLUfbFiyGw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-egwg1mlLUfbFiyGw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-egwg1mlLUfbFiyGw .marker.cross{stroke:#333333;}#mermaid-svg-egwg1mlLUfbFiyGw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-egwg1mlLUfbFiyGw p{margin:0;}#mermaid-svg-egwg1mlLUfbFiyGw .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-egwg1mlLUfbFiyGw .cluster-label text{fill:#333;}#mermaid-svg-egwg1mlLUfbFiyGw .cluster-label span{color:#333;}#mermaid-svg-egwg1mlLUfbFiyGw .cluster-label span p{background-color:transparent;}#mermaid-svg-egwg1mlLUfbFiyGw .label text,#mermaid-svg-egwg1mlLUfbFiyGw span{fill:#333;color:#333;}#mermaid-svg-egwg1mlLUfbFiyGw .node rect,#mermaid-svg-egwg1mlLUfbFiyGw .node circle,#mermaid-svg-egwg1mlLUfbFiyGw .node ellipse,#mermaid-svg-egwg1mlLUfbFiyGw .node polygon,#mermaid-svg-egwg1mlLUfbFiyGw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-egwg1mlLUfbFiyGw .rough-node .label text,#mermaid-svg-egwg1mlLUfbFiyGw .node .label text,#mermaid-svg-egwg1mlLUfbFiyGw .image-shape .label,#mermaid-svg-egwg1mlLUfbFiyGw .icon-shape .label{text-anchor:middle;}#mermaid-svg-egwg1mlLUfbFiyGw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-egwg1mlLUfbFiyGw .rough-node .label,#mermaid-svg-egwg1mlLUfbFiyGw .node .label,#mermaid-svg-egwg1mlLUfbFiyGw .image-shape .label,#mermaid-svg-egwg1mlLUfbFiyGw .icon-shape .label{text-align:center;}#mermaid-svg-egwg1mlLUfbFiyGw .node.clickable{cursor:pointer;}#mermaid-svg-egwg1mlLUfbFiyGw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-egwg1mlLUfbFiyGw .arrowheadPath{fill:#333333;}#mermaid-svg-egwg1mlLUfbFiyGw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-egwg1mlLUfbFiyGw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-egwg1mlLUfbFiyGw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-egwg1mlLUfbFiyGw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-egwg1mlLUfbFiyGw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-egwg1mlLUfbFiyGw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-egwg1mlLUfbFiyGw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-egwg1mlLUfbFiyGw .cluster text{fill:#333;}#mermaid-svg-egwg1mlLUfbFiyGw .cluster span{color:#333;}#mermaid-svg-egwg1mlLUfbFiyGw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-egwg1mlLUfbFiyGw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-egwg1mlLUfbFiyGw rect.text{fill:none;stroke-width:0;}#mermaid-svg-egwg1mlLUfbFiyGw .icon-shape,#mermaid-svg-egwg1mlLUfbFiyGw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-egwg1mlLUfbFiyGw .icon-shape p,#mermaid-svg-egwg1mlLUfbFiyGw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-egwg1mlLUfbFiyGw .icon-shape .label rect,#mermaid-svg-egwg1mlLUfbFiyGw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-egwg1mlLUfbFiyGw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-egwg1mlLUfbFiyGw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-egwg1mlLUfbFiyGw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Agent 轮次结束
MemoryMiddleware.after_agent
上下文压缩前
memory_flush_hook
redact_queued_messages (PII 脱敏)
MemoryManager.add / add_nowait
DeerMem 后端
过滤 + 信号识别
Debounce 队列
MemoryUpdater (1 次 LLM 调用)
FileMemoryStorage 落盘
memory.json + facts/*.md
FTS5 检索索引
DynamicContextMiddleware
隐藏 HumanMessage 注入
memory_search 工具
分层职责非常克制:
- 契约层 :
MemoryManager的抽象基类定义了强制方法from_config/add/get_context,其余按能力分级(tier-2 可选、tier-3 钩子)。前端用户文档里写得很直白:"契约只说 what,不说 how" 、"按实现成本分层",目的就是让第三方后端可以只实现三个方法就跑起来。 - 编排层 :
manager.py::get_memory_manager是一个双检锁单例,内部_scan_backends+_resolve_manager_class依据memory.manager_class(默认"deermem")选择实现。关键设计:没有静默回退 ------配置写错直接ValueError,而不是悄悄换成内存实现。理由也写在文档里:"记忆是持久状态,静默写到错误位置比崩溃严重得多"。 - 后端层 :
backends/deermem/、backends/mem0/、backends/honcho/、backends/openviking/、backends/noop/各自独立,DeerMem 自成一包(backends/deermem/deermem/),并有回归测试test_deermem_self_contained.py钉住"这个模块自己拥有自己的存储,不向外泄露实现细节"。
2. 写路径:一轮对话如何变成一条磁盘上的事实
写路径是这套系统最讲究的部分,我把它拆成五段。
2.1 触发与脱敏:两个入口,一个边界
触发点只有两个:
| 入口 | 位置 | 语义 |
|---|---|---|
MemoryMiddleware.after_agent |
agents/middlewares/memory_middleware.py |
常规:每次 run 结束后提交本轮消息 |
memory_flush_hook |
agents/memory/summarization_hook.py |
紧急:上下文压缩(compaction)会丢弃消息,必须在丢弃前落一份快照 |
两者都先经过同一个函数:redact_queued_messages(messages, pii_redaction_config)。
这里有个容易忽略但很重要的设计决策:PII 脱敏是宿主(host)边界,不是后端职责 。后端拿到的消息已经脱敏过。脱敏覆盖的字段范围相当偏执------不只 content,还包括:
tool_calls/invalid_tool_calls的args与error(_redact_tool_call);additional_kwargs["tool_calls"]/["function_call"],且连 key 一起 过_redact_strings;- 原始用户文本
ORIGINAL_USER_CONTENT_KEY、人机确认卡片的应答HUMAN_INPUT_RESPONSE_KEY。
因为 LLM 调用要把这些原样发给外部模型供应商,任何一处漏掉就等于把手机号/密钥送进第三方 API。
2.2 过滤:什么内容"不配"进记忆
backends/deermem/deer_mem.py::DeerMem.add → _prepare_update,依次执行 core/message_processing.py 里的:
filter_messages_for_memory- 丢弃
hide_from_ui的 HumanMessage(除非是结构性镜像或人类澄清应答_is_human_clarification_response); - 剥离
<current_uploads>块,并连带跳过它后面那一条 AI 消息; - 丢弃任何带工具调用意图的 AI 消息 :
tool_calls、invalid_tool_calls、additional_kwargs["tool_calls"]、additional_kwargs["function_call"]命中任一即丢。
- 丢弃
filter_trivial:对文本strip()后去掉尾缀标点做fullmatch,命中客套话("好的""谢谢"一类)就连同下一条 AI 回复一起丢。- 结构校验:必须同时存在 ≥1 条 human 和 ≥1 条 ai,否则这轮没什么可学的。
detect_signals(filtered, patterns_dir):用 YAML 正则模式(patterns_dir)产出一个 frozenset 信号,信号名包括correction/reinforcement/preference/identity/goal/decision,滑动窗口宽度 6。
为什么丢工具调用消息 :bash -c "curl ... token=xxx" 这类内容既包含一次性参数又可能带凭据,把它写进长期记忆,等于让模型在下周的一个无关任务里"记起"这条命令的意图。这是典型的记忆污染,也是这套过滤存在的理由。
2.3 队列:去抖、合并、以及"永远不许饿死"的上界
core/queue.py,队列 key 是三元组 (thread_id, user_id, agent_name)。
due_at = now # 若 bypass_watermark(紧急快照)
= min(now + debounce_seconds, first_at + max_wait_seconds) # 常规
默认参数(deermem/config.py::DeerMemConfig):debounce_seconds=30、max_wait_seconds=300、queue_max_depth=1000。
值得逐条品味:
first_at是同 key 首次入队时刻,只设置一次 。所以一个持续对话不可能靠不断入队把 deadline 往后推------这是"去抖"实现里最常见的错误(纯 debounce 会让活跃会话永不触发),这里用min(...)加了硬上界,对应测试test_memory_queue_deadlines.py(用假单调时钟钉死)。- 同 key 的常规项会合并 (signals 取并集、强制 deadline 保留),紧急快照永不合并------否则压缩前的完整快照会被后续增量覆盖掉。
- 背压是有条件的 :
QueueFull只在"新项 + 非 bypass + 无信号 + 队列已满"时抛出。带信号的项和紧急项永远放行;queue_max_depth=0表示无限。这就是所谓的紧急快照绕过队列深度限制 ,由test_memory_emergency_backlog.py钉住。 QueueFull被add捕获后只记日志,不前进水印------这一轮没有被"消费掉",下个周期会重新喂进去。丢批次不等于丢数据,这是水印语义给的保证。- 消费是单线程串行 的:一个
threading.Timer守护线程,按最早 deadline 唤醒,_process_queue(due_only=True)取到期项,逐项调用updater.update_memory(...),项间sleep(0.5)避免连打模型。重排用_rearm_timer_locked,只保留更早的唤醒时间,宁可标记_reprocess_pending也不空转。 - 队列是纯进程内存态,模块 docstring 明说退出时未处理项会丢失。 只有抽取水印是持久的。这是个诚实的取舍(见 §6.3)。
- 关停时
MemoryManager.shutdown_flush(timeout)→queue.flush_sync(timeout)(flush_sync先 join worker,再在名为memory-shutdown-flush的线程上排空,只有"队列空 + 空闲 + 无异常"才返回 True),由backend/app/gateway/app.py的 lifespan 调用,预算来自memory.shutdown_flush_timeout_seconds(默认 30,约束 1--300)。K8s 的 pod terminationGracePeriodSeconds 必须覆盖"检索等待 + flush + 余量",这不是可选建议而是部署约束。 - 取消语义:
cancel_by_agent只过滤_items中尚未开始的项,不会打断正在执行的 worker。
2.4 抽取:一次 LLM 调用,加上一道确定性闸门
core/updater.py::MemoryUpdater。
- 每个被排空的上下文只调用一次 LLM :
_do_update_memory_sync_impl里model.invoke(prompt, config={"run_name": "memory_agent"})。压缩前的 prompt 会同时携带 staleness 复审和 consolidation 的段落(见 §2.6),所以"整理记忆"不是额外一次调用。 - 消息序列化发生在排空时而不是入队时 :
core/prompt.py::format_conversation_for_update------纯上传消息的 human 轮次丢掉;超过 1000 字符的消息截成"前 500 +\n...[truncated]...\n+ 后 500";然后html.escape(text, quote=False)。
这个html.escape是提示注入防线 :会话文本要进 prompt,转义可以阻止用户内容伪造 prompt 的结构。同样的转义也用于注入给模型的 fact/summary 叶子节点,以及 prompt 里内嵌的current_memory。 - Prompt 契约 (
core/prompts/memory_update.chat.yaml)要求每条事实带三个标签:scope = user | thread | project(作用域)durability = durable | temporary(耐久)authority = descriptive | transactional(权威来源)
以及 confidence 分档(0.9 / 0.7 / 0.5)、expected_valid_days、类别。输出 JSON 顶层键user/history/newFacts是必需的(_REQUIRED_MEMORY_UPDATE_TOP_LEVEL_KEYS),可选factsToReinforce/factsToRemove(带replacementFactIndex) /staleFactsToRemove/staleFactsToExtend/factsToConsolidate。
- 解析容错但不放松 :
_parse_memory_update_response用raw_decode取第一个 JSON 对象(能容忍模型输出尾部啰嗦),但缺必需键即失败。 - 越界置信度直接丢弃,绝不夹紧 :
_normalize_memory_update_fact对非[0,1]、非有限、bool 型的 confidence 逐项 drop 并_log_dropped_confidence记日志。把1.5悄悄夹成1.0等于把模型的输出错误变成"最高可信度记忆",这是最危险的一类静默修复。 - 确定性闸门 (不依赖模型自觉):
_fact_scope_gate_reason/_summary_scope_gate_reason/_removal_scope_gate_reason,加上fact_confidence_threshold = 0.7;新事实里authority="transactional"(即"用户让我做什么"这种一次性事务)以及 scope/durability 不合格的,一律拒收。 - 失败即整批中止(fail-closed) :
_normalize_memory_update_data在factsToRemove存在但newFacts结构不合格时抛json.JSONDecodeError("Unsafe partial memory update: factsToRemove with malformed newFacts")。语义很清楚:绝不允许"只删不写替换项"------否则模型一次格式错误就能清空用户记忆。 - 水印(watermark) :
_message_identity优先用("id", msg.id),退化用("content", type, sha256(text));存进程内 LRU(watermark_max_keys=4096)并按 scope 持久化到.metadata/watermarks.json。排空时_feed_after_watermark取"两者中更靠后的位置",只喂尚未抽取的后缀。- 被 prescreen 跳过 的项也前进水印(
_watermark_set),且不发 prompt 不调模型------否则同一轮会被反复判定、白烧 token。 bypass_watermark的紧急项既不读也不写水印:拿一个子集的末条消息去推进水印,会把真实进度倒退回更早位置。
- 被 prescreen 跳过 的项也前进水印(
2.5 落盘:两份载体,两个版本平面
{storage_root}/ # $DEERMEM_DATA_DIR 或 DEER_FLOW_HOME 派生
└── users/{safe_user_id}/ # safe_user_id: 字符集净化 + SHA-256 前 16 位
├── memory.json # 文档版本 "2.0":manifest revision + 摘要
├── .memory.lock # 跨进程文件锁
├── .memory.journal.json # prepared / committed 提交日志
├── .recovery/{operationId}/ # 崩溃可回放的变更前备份
├── .metadata/
│ ├── watermarks.json # 抽取进度
│ ├── fact-usage.json # 访问热度(hybrid/shadow 才记录)
│ └── eviction-audit.json # 淘汰决策审计(上限 200 条)
└── agents/{agent_name}/ # 默认桶 __default__
└── facts/{sha256(fact_id)[:2]}/{fact_id}.md # 256 路分片
最容易被写错的一点:memory.json 里没有事实。 它只有文档版本、manifest revision、时间戳/指标,以及摘要分区------user.workContext / personalContext / topOfMind / cognitiveStyle 与 history.recentMonths / earlierContext / longTermBackground。事实是一条一个 Markdown 文件 ,由 core/storage.py::_render_fact_markdown 渲染,YAML front matter 携带 id / content / category / confidence / scope / durability / authority / source / createdAt / lastConfirmedAt / revision / expected_valid_days / confirmationCount。
这个选择的收益非常实际:git diff、grep、人工编辑、备份、迁移都能直接作用在单条记忆上;坏一条不会污染整库。
- fact id 形如
fact_{hex8},落盘路径按sha256(fact_id)前两位分片,避免单目录爆量。 safe_user_id的净化 (字符集白名单 + 哈希)既是隔离也是路径穿越防线------用户 ID 来自外部身份,绝不能直接当目录名。strict_user_scope在用户缺失时抛错而不是新建。- 加载时二次校验 scope :
storage.py每次读入都会重新校验每条 fact 的 scope,防止有人手工把别人的事实文件拷进你的目录。 - 新旧事实的匹配 :
_apply_updates的顺序是 reinforcements → user 摘要 → history 摘要 → staleness 操作 → 新事实 → 容量淘汰 → 矛盾删除 → consolidation →_strip_upload_mentions_from_memory→ 指标。匹配用_fact_content_key(casefold 后的内容键)。 - 近似重复去重(opt-in,默认关闭) :
_find_dedup_merge_target/_raise_if_duplicate_fact_content,基于_fact_content_tokens+_fact_content_similarity------有界 128 token 的 Jaccard 相似度,CJK 用字符二元组切分,阈值 0.7。注意它默认不开:去重会误伤合理近义表述,作者把它做成显式开关而不是默认行为。 - 原子性与崩溃恢复 :
_atomic_write= 临时文件 →flush→os.fsync→os.replace→_fsync_parent_directory(Windows 上刻意 no-op)。提交前写.memory.journal.json与.recovery/{operationId}/,状态prepared→committed;下次读_scope_signature时_recover_if_needed决定回放还是丢弃。v1 文档迁移会留下.v1.bak(单向)。 - 并发控制是三层 :
- 进程内 per-scope
threading.RLock,存在weakref.WeakValueDictionary里(不泄漏锁对象); - 跨进程文件锁
_process_file_lock(Windowsmsvcrt.locking,其他fcntl.flock),file_lock_timeout_seconds = 10; - 双平面版本号 :共享 manifest 的
expected_manifest_revision+ 每条事实自己的revision(upsertRevisions/deleteRevisions)。
冲突是类型化 的:MemoryManifestRevisionConflict/MemoryFactRevisionConflict/MemoryStorageCorruption,由DeerMem._call_backend映射成MemoryConflictError/MemoryCorruptionError,Gateway 层分别对应 HTTP 409 与 500 ------"你改的是旧版本"和"数据坏了"必须是两个不同的可行动信号。
_finalize_update的提交守卫:manifest 冲突时重试 3 次,但策略是重新加载快照后重放整个抽取结果,而不是把裁剪动作当点写重放------避免二次应用产生半套状态。
- 进程内 per-scope
- 读写容错不对称 :
markdown_storage.py只影响读(接受被 ```memory-json 围栏包裹的历史脏数据;不可读文件重命名为memory.json.corrupt-<timestamp>隔离,OSError →MemoryStorageCorruption,于是save()返回 False)。写永远是纯 JSON。宽容只给读,不给写。
2.6 治理三段:staleness / consolidation / capacity
这三件事都作用在"记忆太多太老"这个问题上,但实现位置完全不同------这个区别很值得学:
| 治理 | 实现 | 时机 | 默认 |
|---|---|---|---|
| Staleness review(过期复审) | 折进同一次 LLM 调用的 prompt 段落 | 抽取时 | 开 |
| Consolidation(合并归纳) | 折进同一次 LLM 调用的 prompt 段落 | 抽取时 | 关 |
| Capacity eviction(容量淘汰) | 纯确定性算法 | LLM 返回之后 | 开(policy=confidence) |
- staleness :
_select_stale_candidates/_build_staleness_section,配合_effective_fact_staleness_age、_read_expected_valid_days、_safe_add_days(3650 天上限);约束是 90 天、至少 3 个候选、每轮最多删 10 条、correction类目受保护、创建时间上限90×20=1800天。模型的回答以staleFactsToRemove/staleFactsToExtend应用。 - consolidation :
_select_consolidation_candidates/_build_consolidation_section→factsToConsolidate。默认关闭。 - capacity eviction :
core/eviction.py::select_facts_for_capacity,max_facts = 100。confidence(默认):只看置信度,纯确定性。hybrid-v1(opt-in):0.65×confidence + 0.25×确认新鲜度 + 0.10×访问热度,其中新鲜度用指数半衰(90 天 / 30 天,2**(-t/H)),热度min(1, log1p(count)/ln9),并为correction预留槽位min(10, ceil(max_facts*0.10));三个权重必须和为 1.0(配置层校验)。fact_eviction_shadow_enabled:影子模式只记录分歧到.metadata/eviction-audit.json,不真的删。- 仓库里
scripts/benchmark/deermem_eviction/用 LongMemEval 做了离线评测:容量 7 时confidence策略保真 27/45,hybrid-v145/45;线上问答 24/45 → 40/45;判卷器deterministic-overlap-v1是盲测且要求 ≥60% token 重叠。评测集本身用 revision + SHA-256 钉版本。这套系统对"记忆策略是否有效"是拿数据说话的,不是拍脑袋。
2.7 Judge 钩子:一道明确的"成本闸门",而不是安全闸门
prescreen/(contract.py、openai_decisions.py、typesafe.py)与 signals/(contract.py、coordinator.py、typesafe.py)。
契约文件开头就写明:prescreen 的失败方向是"继续抽取"(L2) ,它不能替代 抽取之后的 confidence/scope/durability/authority 确定性闸门(L6)。判定极性也和工具闸门相反:TypeSafeMemoryPrescreen.interpret 是"概率 < skip_threshold 0.2 就跳过"------即"没把握就花钱"。
signals/coordinator.py::_prescreen_eligibility 列出不进 judge 的情形,每条都有编号:
REASON_MAINTENANCE(L8):staleness 复审开启时不判定 。因为默认staleness_review_enabled: true,开箱即用的 prescreen 是完全惰性的。文档明确说这是 Phase 1 的保守设计,不是 bug------这一点对读代码的人极其重要,否则会误以为"我们已经有 LLM 预筛省了 80% 成本"。- 确定性信号命中(L3)、紧急项(
REASON_EMERGENCY)、关停排空(judged=False→REASON_DRAIN)、上下文超过max_state_chars = 6000(刻意用字符不用字节,L5/S16)。 - 模型的
hints只能"否决一次 enforce-skip",_finalize_update只接收确定性的feed_signals------模型提示永远不能确认一条事实,也不能驱动删除(S4)。 request_failed与no_verdict是两种不同的回退原因,分别记审计;mode只是写在 provider 上供审计,消费决策归 coordinator。- judge 变更后由
MemoryManager.refresh_judge热重载;eval_memory_prescreen.py是它的准入门禁。
3. 读路径:注入和检索是两件事
3.1 注入:不是 SystemMessage,是一条隐藏的 HumanMessage
第一个反直觉的决定:写和读分属两个中间件 。MemoryMiddleware 只负责写;把记忆放进模型上下文是 DynamicContextMiddleware 的职责。
- 记忆被包装成一条隐藏 HumanMessage (消息 ID 后缀
__memory),内容用<memory>...</memory>包裹。 - 绝不放 System 通道------这是 OWASP LLM01(提示注入)里的角色分离原则:记忆内容里可能有用户可控文本,如果它出现在 system 通道,就等于给了它"框架指令"的权威。放在 user 通道,模型仍然把它当数据。
- 首轮附加 + 冻结快照 :注入内容一旦确定就冻结,不随后续轮次变化,目的是让 prefix cache 命中率不被破坏(KV cache 前缀复用对成本影响非常大)。同时午夜会刷新日期,避免"今天是 2026 年 10 月"这种陈旧事实。
- 超时降级 :注入路径在独立线程上跑,5 秒 deadline,超时就不注入------记忆不可用时宁可让本轮"失忆",也不能让整条链路挂住。
- token 预算 :
core/prompt.py::format_memory_for_injectionmax_injection_tokens = 2000- 先划出保底池
guaranteed_token_budget = 500,专供guaranteed_categories = ["correction"] - 超预算时,被保护的 facts 追加在尾部(
prompt.py的 protected-suffix 逻辑) - 计数函数
prompt.py::_count_tokens,可通过memory.token_counting: char退化为字符估算;tiktoken 加载失败会进入 600 秒冷却 ,避免每轮都重试。
把"纠正过我的错误"设成保底类目,是很聪明的做法:这类记忆的边际价值最高,丢在预算末尾最亏。
3.2 两种运行模式
| 模式 | 默认 | 注入内容 | 检索 |
|---|---|---|---|
middleware |
✅ | 共享摘要 + 选中 agent 的事实 | FTS5 索引(内部) |
tool |
实验 | 只注入共享摘要 | 模型主动调 memory_search |
工具模式在 agents/memory/tools.py 提供五个工具:memory_search / memory_get / memory_add / memory_update / memory_delete。
要点:工具模式下被动注入只剩摘要 ,事实必须靠模型主动检索才能进入上下文。这带来一个隐患------如果后端 requires_passive_writes_in_tool_mode 为真却关掉被动写,模型就再也写不进记忆了,所以这个能力位是由后端声明、宿主检查的,而不是靠配置自觉。
3.3 检索:FTS5 + 可选词法重排,没有 embedding
core/retrieval.py:
- 默认 SQLite FTS5 索引(BM25),索引文件独立在
retrieval_index_path下(.retrieval)。 - 跨进程新鲜度 不靠通知,靠签名比对:manifest 的
(mtime_ns, file_size, revision)三元组当 cache token,签名变了才rebuild_index,并用 generation fence 保证晋升原子性------不会出现读到"半新半旧"的索引。warm_retrieval()供启动预热。 - 可选词法相关性(#4495) :
core/relevance.py里build_idf/_lexical_relevance------IDF 加权的 query coverage,前缀词干(≥4 字符)、CJK 包含匹配、jieba 或字符二元组,预算 128 token / 4096 字符;组合分数w·relevance + (1-w)·confidence;再用懒惰贪心 MMR 做多样性重排。默认retrieval_relevance_enabled = False(weight 0.5、diversity 0.0)。 - 兜底:索引不可用时退回 substring 匹配。
- 查询来源:由
DynamicContextMiddleware侧发起,而不是让模型自己造查询词。
DeerMem 全程没有 embedding、没有向量库。 这不是遗漏,是选择------见 §6.2。
3.4 子 agent 与运行留痕
- 子 agent(
SubagentExecutor路径)拿到的是只有日期 的上下文,并带skip_memory_flush=True。委派出去的短任务不该污染主用户记忆,也不该各自触发一次 flush。 - 注入内容会参与运行身份计算:隐藏记忆块做 hash,产出一条
context:memory事件并携带content_sha256。这样"这一轮模型到底看到了什么记忆"是可审计、可复现的。
3.5 管理面
- Gateway 路由(
backend/app/):GET /api/memory、POST /api/memory/reload、DELETE /api/memory、POST/PATCH/DELETE /api/memory/facts[/{id}]、GET /api/memory/export、POST /api/memory/import、GET /api/memory/config、GET /api/memory/status,权限位memory:read/memory:write。 - 后端不支持 agent 级管理(
supports_agent_scoped_management为假)时相关端点返回 501,而不是假装成功。 DeerFlowClient提供等价的嵌入式调用;前端在 Settings → memory 页面。- 遗留提醒:
manager.py里的delete_memory/export_memory契约方法当前没有调用方 ,prompt.py的FACT_EXTRACTION_PROMPT只被加载校验、并不是独立的一次抽取调用------历史文档如果把它写成"两次 LLM 调用",那是过时的。
4. 事件循环之外:一条硬约束
DeerFlow 后端对 blocking I/O 有严格门禁(backend/tests/blocking_io/,Blockbuster)。记忆路径上所有磁盘操作、所有同步 LLM 调用都必须离开 asyncio 事件循环:
MemoryUpdater.update_memory会检测当前是否有运行中的 loop ,若有就把_do_update_memory_sync提交到_SYNC_MEMORY_UPDATER_EXECUTOR(ThreadPoolExecutor(max_workers=4),atexit关停),而不是另起一个事件循环;aupdate_memory走asyncio.to_thread。MemoryMiddleware.aafter_agent第一件事是await asyncio.to_thread(get_memory_manager)------单例初始化本身可能碰磁盘。- 队列用
threading.Timer,天生在非 loop 线程;MemoryPrescreenProvider.decide/MemorySignalProvider.decide契约上就是同步方法,因为 updater 就跑在 Timer/executor 线程上。 - 回归锚点:
tests/blocking_io/test_deermem_watermark_sidecar.py的 docstring 直言 "the DeerMem watermark sidecar stays off the event loop"。 - 历史 issue #2615 的教训:绝不能复用 agent 的共享 async httpx 连接池做记忆调用,否则记忆的低优先级请求会和主推理抢同一条通道。
5. 配置与部署
yaml
# config.yaml → memory:
manager_class: deermem # 无静默回退:写错直接启动失败
mode: middleware # middleware | tool
enabled: true
debounce_seconds: 30 # 1--300
max_wait_seconds: 300
queue_max_depth: 1000 # 0 = 无限
shutdown_flush_timeout_seconds: 30 # 1--300
max_injection_tokens: 2000
guaranteed_token_budget: 500
guaranteed_categories: [correction]
fact_confidence_threshold: 0.7
max_facts: 100
fact_eviction_policy: confidence # 或 hybrid-v1(权重必须和为 1.0)
staleness_review_enabled: true # ← 默认开,因此 prescreen 开箱惰性
consolidation_enabled: false
retrieval_relevance_enabled: false # IDF + MMR
fact_dedup_enabled: false # 阈值 0.7
token_counting: char # 或 tiktoken(失败冷却 600s)
retrieval_index_path: ... # 建议实例本地
- 热更新边界 :
memory.*不在STARTUP_ONLY_FIELDS里,是热可重载的 (对比subagent_runtime是启动期-only)。这点对运维很友好:调预算不用重启。 - 多实例部署的坑 :
backend/app/gateway/deps.py::_validate_memory_retrieval_index------SQLite WAL 检索索引不支持放在共享/网络文件系统 上。声明多实例却把 DeerMem FTS5 索引留在共享storage_path内时,行为是告警而非拒绝 (与_validate_agent_storage一致),推荐retrieval_index_path指向实例本地;Helm 挂载 emptyDir/var/lib/deerflow/memory-index。非 deermem / 检索关闭 / 非 fts5 时跳过校验。
但记忆正文本身是有跨进程文件锁 + 版本 CAS + 提交日志的,多实例读写数据不会静默错乱,最坏是 409 冲突需要调用方重试。 storage_path若以.json结尾,会被丢弃并告警(config/memory_config.py)------又是一个"宁可报错不要猜"的选择。- v1 → v2 迁移:
scripts/migrate_memory_markdown.py,单向,保留.v1.bak。
6. 与其他记忆方案的对比,以及这套设计的代价
6.1 三种常见路线
| 路线 | 典型实现 | 写入 | 读取 | 主要失效模式 |
|---|---|---|---|---|
| 纯上下文窗口 | 长上下文 + 压缩 | 无 | 全量塞进去 | 成本线性增长;压缩即失忆 |
| 向量 RAG 记忆 | LangChain Memory / 自建向量库 | 每轮切块 + embedding | Top-K 相似度召回 | 召回不可解释;脏块永久留存;无法人工审计 |
| 结构化记忆服务 | mem0 / Zep / Letta | LLM 抽取 + 后端裁决 add/update/delete | 语义 + 元数据过滤 | 强依赖外部服务;一致性/幂等要自己做 |
DeerMem 属于第三种思路的本地实现 :LLM 负责抽取和判断,但裁决与落盘由确定性代码兜底 ,载体是文件系统。同时它把 mem0 这类外部服务做成可选后端 (backends/mem0/mem0_manager.py、backends/honcho/honcho_manager.py、backends/openviking/openviking_manager.py),契约不变。
6.2 为什么这里可以不用向量库
因为记忆的形状不同 。DeerMem 存的不是"对话片段",而是已被归类的短事实 (correction / preference / identity / goal / decision)和固定分区的摘要。短事实的召回问题更接近配置项查询,而非语义搜索:
- 摘要数量恒定(十几个分区),直接进上下文,不需要检索;
- 事实按
(user, agent)分桶,桶内量级只有百条(max_facts=100),FTS5 足够; - 需要"语义一点"时,用 IDF + MMR 词法重排补足,仍不需要 embedding 服务。
真正的收益是可观测性:能 grep、能 diff、能人工删、能拿 LongMemEval 离线评测策略。向量库里的记忆是"一坨 float32",你无法回答用户"为什么它记得我这件事"。
代价也真实:换个说法就检索不到 (词法的通病)、多语言/同义词召回弱、retrieval_relevance 默认关闭时召回排序基本靠置信度。
6.3 这套设计明确付出的代价
- 写路径不持久 :队列是进程内存态,崩溃即丢未处理项(文档对 OpenViking 也明说 "a durable outbox is still required")。作者选择用"水印不前进 → 下轮重喂"来缓解,而不是引入 outbox。对记忆这种最终一致的场景是可以的,但别把它当"零丢失"来宣传。
- 抽取质量高度依赖模型 :一次
memory_update调用里同时决定分类、置信度、矛盾消除。确定性闸门能挡住越界与畸形,挡不住"模型自信地记错"。这也是为什么要有correction保底注入和 shadow 审计。 - 开箱 prescreen 不生效 :
staleness_review_enabled默认 true 导致 judge 全部REASON_MAINTENANCE跳过。想省 token 要显式改两个配置。读文档时不看这条会得出完全相反的成本结论。 - 一次 LLM 调用的批处理粒度是
(thread, user, agent):同一 agent 在同一 thread 的高频活动会被合并;跨 agent/跨线程不会互相阻塞,但全局是单消费者串行 ,排空慢时会形成 backlog(有_EMERGENCY_BACKLOG_WARNING_DEPTH = 32告警)。 - 多实例共享存储时要挪走 FTS5 索引(WAL 不支持网络文件系统),否则只能靠 substring 兜底。
6.4 值得抄走的六个设计决策
如果要我把这篇长文压成一页 checklist:
- 脱敏放在系统边界(入队前),不放在后端里------后端可以有五个,边界只有一个。
- 去抖必须有
max_wait上界 (min(now+debounce, first_at+max_wait)),否则活跃会话永不触发。 - 紧急项绕过深度限制、且永不合并 ;被拒批次不许前进水印。
- 置信度越界就丢,不夹紧;删除必须带替换项,否则整批中止。 宁可不写,不可写坏。
- 注入用隐藏 HumanMessage,绝不用 System 通道;并且冻结快照以保住 prefix cache。
- 冲突要类型化 (
ManifestRevisionConflict/FactRevisionConflict/StorageCorruption),让上层能映射成 409 与 500 两个不同语义。把"你改旧了"和"数据坏了"混成一个 500,是这类系统最常见的运维灾难。
附:一次写入的完整时序
FileMemoryStorage MemoryUpdater MemoryQueue DeerMem MemoryManager redact_queued_messages MemoryMiddleware FileMemoryStorage MemoryUpdater MemoryQueue DeerMem MemoryManager redact_queued_messages MemoryMiddleware #mermaid-svg-6omTTr3qldIfuuDQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6omTTr3qldIfuuDQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6omTTr3qldIfuuDQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6omTTr3qldIfuuDQ .error-icon{fill:#552222;}#mermaid-svg-6omTTr3qldIfuuDQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6omTTr3qldIfuuDQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6omTTr3qldIfuuDQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6omTTr3qldIfuuDQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6omTTr3qldIfuuDQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6omTTr3qldIfuuDQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6omTTr3qldIfuuDQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6omTTr3qldIfuuDQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6omTTr3qldIfuuDQ .marker.cross{stroke:#333333;}#mermaid-svg-6omTTr3qldIfuuDQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6omTTr3qldIfuuDQ p{margin:0;}#mermaid-svg-6omTTr3qldIfuuDQ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6omTTr3qldIfuuDQ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-6omTTr3qldIfuuDQ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-6omTTr3qldIfuuDQ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-6omTTr3qldIfuuDQ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-6omTTr3qldIfuuDQ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-6omTTr3qldIfuuDQ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-6omTTr3qldIfuuDQ .sequenceNumber{fill:white;}#mermaid-svg-6omTTr3qldIfuuDQ #sequencenumber{fill:#333;}#mermaid-svg-6omTTr3qldIfuuDQ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-6omTTr3qldIfuuDQ .messageText{fill:#333;stroke:none;}#mermaid-svg-6omTTr3qldIfuuDQ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6omTTr3qldIfuuDQ .labelText,#mermaid-svg-6omTTr3qldIfuuDQ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-6omTTr3qldIfuuDQ .loopText,#mermaid-svg-6omTTr3qldIfuuDQ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-6omTTr3qldIfuuDQ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-6omTTr3qldIfuuDQ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-6omTTr3qldIfuuDQ .noteText,#mermaid-svg-6omTTr3qldIfuuDQ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-6omTTr3qldIfuuDQ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6omTTr3qldIfuuDQ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6omTTr3qldIfuuDQ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6omTTr3qldIfuuDQ .actorPopupMenu{position:absolute;}#mermaid-svg-6omTTr3qldIfuuDQ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-6omTTr3qldIfuuDQ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6omTTr3qldIfuuDQ .actor-man circle,#mermaid-svg-6omTTr3qldIfuuDQ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-6omTTr3qldIfuuDQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} due_at = min(now+30s, first_at+300s)同 key 合并;QueueFull 不前进水印 scope RLock + 文件 flockjournal prepared→committed临时文件 fsync + os.replace 读侧签名 (mtime_ns,size,revision) 变化→ rebuild_index(FTS5) 本轮消息(含 tool_calls / additional_kwargs)已脱敏消息 add()契约分发(无静默回退)filter_messages_for_memory + filter_trivialdetect_signals(correction/preference/...)add(key=(thread,user,agent))update_memory(Timer 线程,0.5s 项间隔)水印切片 _feed_after_watermarkformat_conversation_for_update(截断 + html.escape)1 次 model.invoke(memory_agent)raw_decode → 必需键校验 → 越界 confidence dropscope/durability/authority 闸门 + 阈值 0.7确定性容量淘汰 select_facts_for_capacityapply_changes(revision CAS)MemoryConflictError / MemoryCorruptionError