deerflow-memory-design-blog

DeerFlow 记忆系统设计详解:一个没有向量库的长期记忆是怎么跑起来的

本文基于 DeerFlow 源码(backend/packages/harness/deerflow/agents/memory/ 及其宿主中间件)梳理,所有结论都能在代码里找到落点。文中只标文件路径与函数/类名,不标行号------行号会随重构腐烂。

0. 先说结论:这套设计在解决什么问题

Agent 的长期记忆通常被当成"检索问题"来做:把历史对话切块、embedding、存进向量库、下一轮 Top-K 召回。DeerFlow 的记忆系统(内部代号 DeerMem)走的是另一条路:

  1. 写入是异步、去抖、带水印(watermark)的批处理,不是每轮对话都调 LLM;
  2. 存储载体是可读、可 diff、可人工编辑的 Markdown + JSON 清单,而不是不透明的向量集合;
  3. 检索默认用 SQLite FTS5 词法索引 + 可选的 IDF/MMR 重排 ,DeerMem 里没有任何 embedding;
  4. 注入(injection)与检索(retrieval)是两条独立通路,注入有严格的 token 预算与被保护类目;
  5. 记忆后端是可插拔契约 (deermem / noop / mem0 / honcho / openviking),换后端不需要改主流程一行代码;
  6. 一切隐私与并发边界都在写入前和落盘时强制: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 里的:

  1. 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"] 命中任一即丢。
  2. filter_trivial:对文本 strip() 后去掉尾缀标点做 fullmatch,命中客套话("好的""谢谢"一类)就连同下一条 AI 回复一起丢。
  3. 结构校验:必须同时存在 ≥1 条 human 和 ≥1 条 ai,否则这轮没什么可学的。
  4. 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 的紧急项既不读也不写水印:拿一个子集的末条消息去推进水印,会把真实进度倒退回更早位置。

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(单向)。
  • 并发控制是三层 :
    1. 进程内 per-scope threading.RLock,存在 weakref.WeakValueDictionary 里(不泄漏锁对象);
    2. 跨进程文件锁 _process_file_lock(Windows msvcrt.locking,其他 fcntl.flock),file_lock_timeout_seconds = 10;
    3. 双平面版本号 :共享 manifest 的 expected_manifest_revision + 每条事实自己的 revision(upsertRevisions / deleteRevisions)。
      冲突是类型化 的:MemoryManifestRevisionConflict / MemoryFactRevisionConflict / MemoryStorageCorruption,由 DeerMem._call_backend 映射成 MemoryConflictError / MemoryCorruptionError,Gateway 层分别对应 HTTP 409 与 500 ------"你改的是旧版本"和"数据坏了"必须是两个不同的可行动信号。
      _finalize_update 的提交守卫:manifest 冲突时重试 3 次,但策略是重新加载快照后重放整个抽取结果,而不是把裁剪动作当点写重放------避免二次应用产生半套状态。
  • 读写容错不对称 :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-v1 45/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_injection
    • max_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 这套设计明确付出的代价

  1. 写路径不持久 :队列是进程内存态,崩溃即丢未处理项(文档对 OpenViking 也明说 "a durable outbox is still required")。作者选择用"水印不前进 → 下轮重喂"来缓解,而不是引入 outbox。对记忆这种最终一致的场景是可以的,但别把它当"零丢失"来宣传。
  2. 抽取质量高度依赖模型 :一次 memory_update 调用里同时决定分类、置信度、矛盾消除。确定性闸门能挡住越界与畸形,挡不住"模型自信地记错"。这也是为什么要有 correction 保底注入和 shadow 审计。
  3. 开箱 prescreen 不生效 :staleness_review_enabled 默认 true 导致 judge 全部 REASON_MAINTENANCE 跳过。想省 token 要显式改两个配置。读文档时不看这条会得出完全相反的成本结论。
  4. 一次 LLM 调用的批处理粒度是 (thread, user, agent) :同一 agent 在同一 thread 的高频活动会被合并;跨 agent/跨线程不会互相阻塞,但全局是单消费者串行 ,排空慢时会形成 backlog(有 _EMERGENCY_BACKLOG_WARNING_DEPTH = 32 告警)。
  5. 多实例共享存储时要挪走 FTS5 索引(WAL 不支持网络文件系统),否则只能靠 substring 兜底。

6.4 值得抄走的六个设计决策

如果要我把这篇长文压成一页 checklist:

  1. 脱敏放在系统边界(入队前),不放在后端里------后端可以有五个,边界只有一个。
  2. 去抖必须有 max_wait 上界 (min(now+debounce, first_at+max_wait)),否则活跃会话永不触发。
  3. 紧急项绕过深度限制、且永不合并 ;被拒批次不许前进水印。
  4. 置信度越界就丢,不夹紧;删除必须带替换项,否则整批中止。 宁可不写,不可写坏。
  5. 注入用隐藏 HumanMessage,绝不用 System 通道;并且冻结快照以保住 prefix cache。
  6. 冲突要类型化 (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

相关推荐
miofly1 小时前
MIT 团队用 GPT-6 Astra 改写小模型权重,跳过梯度训练提升指令遵循能力
开源·github
AI日报派送佬1 小时前
2026年11月02日 AI 行业日报|商汤开源 Loop-9B、Pika 发布 H3 Flex 音视频模型、Meta× 傅利叶推出人形机器人 Max
人工智能·微软·开源·音视频·openai·1024程序员节·ai日报
砚底藏山河1 小时前
量化回测可复现流水线CI化
java·python·ci/cd·金融
磁场转动100万匹1 小时前
道路巡检系统检测案例:YOLOv5 + Flask + PyQt 全栈实现
python·yolo
前端精髓2 小时前
动态选择工具
python·langchain
miofly2 小时前
GitHub 今日推荐|agent-desktop:通过无障碍树让 AI 代理直接操作桌面应用
rust·开源·github
小杨互联网2 小时前
开源表格数据与文本处理 CLI 离线镜像合集
开源·数据处理·cli·离线镜像
AI宇宙狂想家2 小时前
开源 AI 视频工具进化史:从难用到好用,OpenMontage 改了什么
开发语言·前端·人工智能·开源·ai创业·openmontage