# Per-Thread Memory Brainstorm Date: 2026-05-07 ## Background Deerflow 现有的记忆功能是单租户的——不同会话都属于同一个用户,所有对话共享一份全局 `memory.json`。 要做一个新的记忆功能:不同对话属于不同用户,每个会话都有一个长期记忆,内容包括用户的使用习惯、个人信息、个人喜好和偏好语气。 ## 现有记忆系统 - **存储**:单一全局 `backend/.deer-flow/memory.json`,所有会话共享 - **认证**:没有用户认证,没有用户隔离(better-auth 已搭建但未启用) - **结构**: - `user`: workContext / personalContext / topOfMind - `history`: recentMonths / earlierContext / longTermBackground - `facts[]`: id, content, category, confidence, source - **读路径**:system prompt 生成时注入 `...` XML 标签 - **写路径**:MemoryMiddleware 在对话后过滤消息 → MemoryUpdateQueue debounce 30s → MemoryUpdater 调 LLM 提取更新 → 原子写入 - **配置**:`config.yaml > memory`(enabled, debounce_seconds, max_facts, max_injection_tokens 等) --- ## 决策记录 ### 存储方式: 数据库 ~~文件存储 `threads/{thread_id}/profile-memory.json`~~ → **改为数据库表**,通过 `thread_id` 区分用户。 ### 数据库: SQLite(本地/测试) + MySQL(生产环境) ### 表结构: 单表 + JSON 列(Option A) ### 依赖: 最小化,不引入 SQLAlchemy SQLite 用标准库 `sqlite3`,MySQL 用 `pymysql`(纯 Python,轻量)。 ### 与全局记忆关系: 策略 B(fallback) Per-thread 有记忆就用 per-thread 的,没有就 fallback 到全局记忆。 ### 首次对话: 不主动询问用户偏好 --- ## 1. 数据库表设计 ```sql -- SQLite CREATE TABLE IF NOT EXISTS thread_memory ( thread_id TEXT PRIMARY KEY, profile TEXT NOT NULL DEFAULT '{}', preferences TEXT NOT NULL DEFAULT '{}', facts TEXT NOT NULL DEFAULT '[]', last_updated TEXT NOT NULL DEFAULT (datetime('now')) ); -- MySQL CREATE TABLE IF NOT EXISTS thread_memory ( thread_id VARCHAR(64) PRIMARY KEY, profile JSON NOT NULL, preferences JSON NOT NULL, facts JSON NOT NULL, last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); ``` **profile** ({}): | 字段 | 类型 | 说明 | |------|------|------| | `name` | `string \| null` | 用户称呼 | | `role` | `string \| null` | 职业/角色 | | `expertise` | `string[]` | 技术栈/专业领域 | | `language` | `"zh-CN" \| "en-US" \| null` | 使用的语言 | | `context` | `string \| null` | 其他上下文(自由文本) | **preferences** ({}): | 字段 | 类型 | 说明 | |------|------|------| | `tone` | `"casual" \| "formal" \| "technical" \| "friendly" \| null` | 语气偏好 | | `verbosity` | `"concise" \| "detailed" \| null` | 回答详细程度 | | `codeStyle` | `string \| null` | 代码风格偏好 | | `other` | `string \| null` | 其他偏好(自由文本) | **facts** ([]):复用现有全局记忆的 fact 结构 ```json { "id": "fact_abc123", "content": "用户在使用 React + TypeScript", "category": "tech_stack | preference | personal | context | goal", "confidence": 0.9, "createdAt": "2026-05-07T...", "source": "thread_id" } ``` **说明**:三个 JSON 字段在 SQLite 中存为 TEXT(sqlite3 标准库没有原生 JSON 类型),在 MySQL 中存为 JSON。代码层面读写时做 `json.dumps` / `json.loads`,对上层透明。 ## 2. config.yaml 新增配置段 ```yaml thread_memory: enabled: true debounce_seconds: 30 model_name: null # null = 使用默认模型 max_facts: 100 fact_confidence_threshold: 0.7 injection_enabled: true max_injection_tokens: 2000 database: type: sqlite # sqlite | mysql sqlite: path: "thread_memory.db" mysql: host: "localhost" port: 3306 user: "root" password: "$MYSQL_PASSWORD" database: "deerflow" ``` 大部分字段和现有 `memory` 配置段语义相同,可以在两个配置段之间复用。`database` 段按 type 取子段,工厂函数只读自己需要的部分。 ## 3. 存储层设计 ### 3.1 抽象接口 ```python # deerflow/agents/memory/thread_storage.py import abc import json import sqlite3 from datetime import datetime from typing import Any class ThreadMemoryStorage(abc.ABC): @abc.abstractmethod def load(self, thread_id: str) -> dict[str, Any] | None: """加载指定 thread 的记忆,不存在返回 None。""" ... @abc.abstractmethod def save(self, thread_id: str, data: dict[str, Any]) -> bool: """保存指定 thread 的记忆(upsert)。""" ... @abc.abstractmethod def delete(self, thread_id: str) -> bool: """删除指定 thread 的记忆(thread 被删除时联动)。""" ... def _create_empty_memory() -> dict[str, Any]: """Per-thread 记忆的初始空结构。""" return { "profile": { "name": None, "role": None, "expertise": [], "language": None, "context": None, }, "preferences": { "tone": None, "verbosity": None, "codeStyle": None, "other": None, }, "facts": [], } def _row_to_memory(row: tuple) -> dict[str, Any]: """将数据库行转为 memory dict。SQLite 的 JSON 列存的是 TEXT,需要 parse。""" return { "threadId": row[0], "profile": json.loads(row[1]), "preferences": json.loads(row[2]), "facts": json.loads(row[3]), "lastUpdated": row[4], } ``` ### 3.2 SQLite 实现(本地测试) ```python class SqliteThreadMemoryStorage(ThreadMemoryStorage): def __init__(self, db_path: str): self._conn = sqlite3.connect(db_path) self._conn.execute(""" CREATE TABLE IF NOT EXISTS thread_memory ( thread_id TEXT PRIMARY KEY, profile TEXT NOT NULL DEFAULT '{}', preferences TEXT NOT NULL DEFAULT '{}', facts TEXT NOT NULL DEFAULT '[]', last_updated TEXT NOT NULL DEFAULT (datetime('now')) ) """) self._conn.commit() def load(self, thread_id: str) -> dict | None: row = self._conn.execute( "SELECT thread_id, profile, preferences, facts, last_updated " "FROM thread_memory WHERE thread_id = ?", (thread_id,) ).fetchone() return _row_to_memory(row) if row else None def save(self, thread_id: str, data: dict) -> bool: now = datetime.utcnow().isoformat() + "Z" self._conn.execute(""" INSERT INTO thread_memory (thread_id, profile, preferences, facts, last_updated) VALUES (?, ?, ?, ?, ?) ON CONFLICT(thread_id) DO UPDATE SET profile = excluded.profile, preferences = excluded.preferences, facts = excluded.facts, last_updated = excluded.last_updated """, ( thread_id, json.dumps(data["profile"], ensure_ascii=False), json.dumps(data["preferences"], ensure_ascii=False), json.dumps(data["facts"], ensure_ascii=False), now, )) self._conn.commit() return True def delete(self, thread_id: str) -> bool: self._conn.execute("DELETE FROM thread_memory WHERE thread_id = ?", (thread_id,)) self._conn.commit() return True ``` ### 3.3 MySQL 实现(生产环境) ```python class MysqlThreadMemoryStorage(ThreadMemoryStorage): def __init__(self, host: str, port: int, user: str, password: str, database: str): import pymysql self._conn = pymysql.connect( host=host, port=port, user=user, password=password, database=database, charset="utf8mb4", ) with self._conn.cursor() as cur: cur.execute(""" CREATE TABLE IF NOT EXISTS thread_memory ( thread_id VARCHAR(64) PRIMARY KEY, profile JSON NOT NULL, preferences JSON NOT NULL, facts JSON NOT NULL, last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) """) self._conn.commit() def load(self, thread_id: str) -> dict | None: with self._conn.cursor() as cur: cur.execute( "SELECT thread_id, profile, preferences, facts, last_updated " "FROM thread_memory WHERE thread_id = %s", (thread_id,) ) row = cur.fetchone() return _row_to_memory(row) if row else None def save(self, thread_id: str, data: dict) -> bool: now = datetime.utcnow() with self._conn.cursor() as cur: cur.execute(""" INSERT INTO thread_memory (thread_id, profile, preferences, facts, last_updated) VALUES (%s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE profile = VALUES(profile), preferences = VALUES(preferences), facts = VALUES(facts), last_updated = VALUES(last_updated) """, ( thread_id, json.dumps(data["profile"], ensure_ascii=False), json.dumps(data["preferences"], ensure_ascii=False), json.dumps(data["facts"], ensure_ascii=False), now, )) self._conn.commit() return True def delete(self, thread_id: str) -> bool: with self._conn.cursor() as cur: cur.execute("DELETE FROM thread_memory WHERE thread_id = %s", (thread_id,)) self._conn.commit() return True ``` ### 3.4 工厂函数 ```python def get_thread_memory_storage() -> ThreadMemoryStorage: """从 config 读取 database 配置,构建对应的 storage 实例(单例)。""" config = get_thread_memory_config() db = config.database if db.type == "sqlite": return SqliteThreadMemoryStorage(db.sqlite.path) elif db.type == "mysql": return MysqlThreadMemoryStorage( host=db.mysql.host, port=db.mysql.port, user=db.mysql.user, password=db.mysql.password, database=db.mysql.database, ) else: raise ValueError(f"Unknown thread_memory database type: {db.type}") ``` ### 3.5 注意事项 - **JSON 在 SQLite 中存为 TEXT**:`sqlite3` 标准库没有 JSON 类型,用 TEXT 存储 `json.dumps` 的结果。读写时做序列化/反序列化。MySQL 用原生 JSON 列,`pymysql` 自动处理。 - **upsert 语法差异**:SQLite 用 `ON CONFLICT ... DO UPDATE SET`,MySQL 用 `ON DUPLICATE KEY UPDATE`,语义等价。 - **连接管理**:两个实现都在 `__init__` 创建连接并持有。单线程场景没问题。如果将来需要并发,可以加连接池或改为每次操作创建连接。 --- ## 4. upsert 语义:全量替换 vs 合并更新 ### 两种模式 **模式 A — 增量合并**(LLM 出 delta,应用层合并): ``` LLM 输入: 现有记忆 + 新对话 LLM 输出: { profile: { name: "新值", shouldUpdate: true }, newFacts: [...], factsToRemove: [...] } 应用层: 读取现有记忆 → 按 delta 逐字段合并 → 写入 ``` 现有全局记忆用的就是这个模式。LLM 输出里带 `shouldUpdate` 标记和 `factsToRemove` 列表,应用代码做合并。 **模式 B — 全量替换**(LLM 出完整状态,应用层直接覆盖): ``` LLM 输入: 现有记忆 + 新对话 LLM 输出: { profile: { name: "...", role: "...", ... }, preferences: {...}, facts: [...] } 应用层: INSERT ... ON CONFLICT DO UPDATE(整行覆盖) ``` ### 选择模式 B 的理由 1. **profile 和 preferences 本身很小**。每个对象 5-6 个字段,全部输出最多几十个 token,增量节省的 token 可以忽略。 2. **去重和淘汰由 LLM 负责,应用层零逻辑**。LLM 看到了完整的现有记忆,在 prompt 中就能决定哪些 facts 要保留、哪些过时了要删、哪些要合并。应用代码只需要 `json.dumps` + upsert。 3. **避免字段删除的尴尬**。如果 LLM 想把 `profile.context` 从 `"前端开发者"` 改成 `null`(表示不再确定这个信息),增量模式需要额外表达"显式置 null"还是"不变",全量替换没有歧义。 4. **和现有全局记忆的模式不同是合理的**。全局记忆的 `history` 有大量的对话摘要文本,不适合全量替换。Per-thread 记忆的 profile/preferences 是结构化的元数据,全量输出成本低。 ### 具体流程 ``` 用户对话结束 ↓ MemoryMiddleware.after_agent() 提取 user + final AI 消息 ↓ queue.add(thread_id, messages) # debounce 30s ↓ ThreadMemoryUpdater.update() 1. 从 DB 读取现有记忆(不存在就用 _create_empty_memory()) 2. 构建 prompt: "以下是用户的现有画像和偏好:{existing_memory},以下是新的对话:{conversation},请更新用户画像。" 3. LLM 返回完整的 profile + preferences + facts 4. storage.save(thread_id, data) # upsert 整行覆盖 ``` **关键点**:LLM 的 prompt 里放了**现有记忆**,LLM 看到之后自己决定: - 保留哪些 facts - 更新哪些 profile 字段 - 新增什么偏好 - 删除过时的信息(不输出就是删除) 应用代码不做任何合并判断,只负责把 LLM 输出写入数据库。 --- ## 5. 更新路径 ### 5.1 MemoryMiddleware 改造(最小改动) 在现有 `MemoryMiddleware.after_agent()` 中加一段逻辑,当 `thread_id` 存在时,同时向 per-thread 记忆的 queue 推一条: ```python # 现有逻辑:全局记忆 queue = get_memory_queue() queue.add(thread_id=thread_id, messages=filtered_messages, ...) # 新增:per-thread 记忆 if thread_id: thread_queue = get_thread_memory_queue() thread_queue.add(thread_id=thread_id, messages=filtered_messages) ``` ### 5.2 ThreadMemoryUpdater 新类,结构类似现有的 `MemoryUpdater`,但使用不同的 prompt 和存储后端: ```python class ThreadMemoryUpdater: def update(self, messages, thread_id): storage = get_thread_memory_storage() existing = storage.load(thread_id) or _create_empty_memory() prompt = THREAD_MEMORY_UPDATE_PROMPT.format( existing_memory=json.dumps(existing, ensure_ascii=False), conversation=format_conversation(messages), ) response = model.invoke(prompt) new_memory = parse_llm_output(response) # { profile, preferences, facts } storage.save(thread_id, new_memory) ``` ### 5.3 Prompt 设计要点 与全局记忆 prompt 的关键区别: | | 全局记忆 prompt | Per-thread 记忆 prompt | |---|---|---| | **目标** | "对话中发生了什么" | "这个人是谁、喜欢什么" | | **输出** | user context 摘要 + history 摘要 + facts | profile + preferences + facts | | **侧重** | 保留对话内容的事实性信息 | 推断用户的身份、偏好、风格 | | **语气影响** | 无 | 输出 `preferences.tone` 直接影响后续回复风格 | --- ## 6. 读取路径(注入 System Prompt) ```python def inject_thread_memory(system_prompt: str, thread_id: str) -> str: storage = get_thread_memory_storage() memory = storage.load(thread_id) if memory is None: # fallback 到全局记忆 return inject_global_memory(system_prompt) # 生成 标签注入 system prompt profile_xml = _format_profile_xml(memory) return system_prompt + "\n" + profile_xml ``` 注入内容的 XML 结构示例: ```xml 张三 全栈工程师 React, TypeScript, Python zh-CN 在做一个电商项目 casual detailed prefers functional components with hooks ``` 语气偏好(`preferences.tone`)不直接改 system prompt 模板,而是放在 `` XML 里让 LLM 自己理解。方式简单,不用维护 prompt 模板的分支逻辑。如果发现 LLM 不遵循,再考虑动态改写 prompt 模板。 --- ## 7. Thread 删除时的联动 Gateway 已有 `DELETE /api/threads/{id}`。在现有 handler 中加一行: ```python # app/gateway/routers/threads.py @router.delete("/api/threads/{thread_id}") async def delete_thread(thread_id: str): # ... 现有清理逻辑 ... # 新增:删除 per-thread 记忆 get_thread_memory_storage().delete(thread_id) ``` --- ## 8. 实施步骤 1. **新增配置模型** — `thread_memory_config.py`(参考现有 `memory_config.py`) 2. **新增存储层** — `thread_storage.py`(`ThreadMemoryStorage` + `SqliteThreadMemoryStorage` + `MysqlThreadMemoryStorage`) 3. **新增 prompt** — `thread_memory_prompt.py`(用于 LLM 提取用户画像) 4. **新增 updater** — 或扩展现有 `MemoryUpdater`,根据 `thread_id` 参数路由到不同逻辑 5. **改造 middleware** — `MemoryMiddleware` 中加 per-thread 记忆的 queue 逻辑 6. **改造注入** — system prompt 生成时注入 `` 标签 7. **扩展 thread 删除 handler** — 联动删除 DB 记录 8. **写入测试** — `test_thread_memory_storage.py`, `test_thread_memory_updater.py` ## 9. 待确认事项 - [ ] pymysql 作为新依赖是否 OK? - [ ] `database` 配置段结构是否合适? - [ ] upsert 使用全量替换模式(模式 B)是否认同? ## 10. 第二轮脑暴(风险前置) 下面这轮不是改大方向,而是把容易在落地时踩坑的点先钉住。 ### 10.1 隔离键:`thread_id` 是否足够? 当前设计用 `thread_id` 作为主键隔离用户记忆,简单可行。但有一个隐含前提: - 一个 thread 永远只对应一个真实用户 如果未来支持“同一用户多 thread 共享画像”或“thread 可能转移 owner”,只用 `thread_id` 会限制扩展。 可选路径: - 路径 A(维持现状,推荐短期):主键 `thread_id`,最快上线。 - 路径 B(兼容未来):增加 `owner_id`(可空),并加索引 `(owner_id, thread_id)`。 建议: - 第一版继续 `thread_id`,但在表里预留 `owner_id` nullable 字段,避免后续大迁移。 ### 10.2 并发一致性:同一 thread 的并发写覆盖问题 场景:同一 thread 在短时间内触发多次 update,后到达的旧结果可能覆盖先到达的新结果。 可选保护: - 方案 A:`last_updated` 乐观锁(更新时带 where 条件) - 方案 B:`memory_version` 整数版本号(推荐) - 方案 C:严格串行队列(单 thread 单 worker) 建议: - 加 `memory_version`(默认 0)。`save` 时做 compare-and-swap 语义: - 读取 version = n - 写入时要求 version 仍为 n,成功后 version = n+1 - 失败则重试一次(重新 load + merge prompt 再写) 这样不需要分布式锁,也能规避“旧结果回写”。 ### 10.3 记忆质量控制:防止噪声和幻觉固化 LLM 抽取用户画像时,最大风险是把一次性表达当长期偏好。 建议加三道门: 1. 事实类别阈值 - `preference` 类阈值可略低(如 0.7) - `personal` 类阈值更高(如 0.85) 2. 稳定性规则 - 同类偏好至少被 2 次独立对话支持,才提升为 profile/preference 的强字段 3. 冲突降级 - 新旧事实冲突时,不立刻删旧值 - 先把旧值降权并标记 `supersededBy`,下一轮再淘汰 ### 10.4 隐私与合规:先定义“不能记”的边界 建议在 prompt 与代码都加 denylist(双保险): - 默认不写入:身份证号、手机号、邮箱、住址、银行卡、密码/API Key 等敏感信息 - 允许写入:技术偏好、工作语境、沟通风格、项目目标 实现上: - 在 `ThreadMemoryUpdater` parse 后做一次 server-side scrub - 命中敏感模式就丢弃并打审计日志(不落库原文) ### 10.5 注入预算:避免 memory 挤爆上下文 当前有 `max_injection_tokens`,但还缺“裁剪策略”。 建议固定优先级: 1. profile(最高) 2. preferences 3. facts(按 confidence + recency 排序后截断) 当超预算时: - 永远保留 profile/preference - 只裁剪 facts ### 10.6 可观测性:上线后如何判断有效 建议最小指标集: - `thread_memory_update_total{status=ok|error}` - `thread_memory_injection_tokens` - `thread_memory_fact_count` - `thread_memory_update_latency_ms` - `thread_memory_conflict_retry_total` 加两条抽样日志: - 更新前后摘要 diff(脱敏后) - 注入片段长度与截断原因 ### 10.7 迁移与回滚策略(从全局记忆过渡) 你已选 fallback 策略,这很好。建议再补两个机制: - 冷启动导入(可选) - 首次访问 thread 且无 per-thread 记录时,从全局记忆抽取一份“弱画像”写入 - 打 `bootstrapped_from_global=true` - 一键回滚 - 配置开关 `thread_memory.injection_enabled=false` 时,立刻只走全局注入 - 更新链路可继续跑,便于回滚期间保留数据 ### 10.8 API 语义建议(便于后续运维) 即使第一版 UI 不暴露,也建议预留内部接口: - `GET /internal/thread-memory/{thread_id}`(脱敏视图) - `DELETE /internal/thread-memory/{thread_id}` - `POST /internal/thread-memory/{thread_id}/rebuild` 这样排障时不用直接查库。 --- ## 11. 第三轮决策清单(进入实现前最后拍板) - [ ] 表结构是否预留 `owner_id` 与 `memory_version`? - [ ] 是否采用 `memory_version` 方案处理并发覆盖? - [ ] 敏感信息 denylist 范围是否按 10.4 执行? - [ ] 注入裁剪优先级是否固定为 profile > preferences > facts? - [ ] 是否需要“冷启动导入”全局记忆到 per-thread? - [ ] 是否要在首版就加内部运维接口? 如果以上 6 项确定,基本就能把实现风险压到可控范围内。 ## 12. 默认拍板方案(建议直接采用) 目标:在不显著增加复杂度的前提下,拿到“可上线 + 可回滚 + 可演进”的第一版。 ### 12.1 表结构默认值 采用:**预留 `owner_id` + 引入 `memory_version`**。 SQLite: ```sql CREATE TABLE IF NOT EXISTS thread_memory ( thread_id TEXT PRIMARY KEY, owner_id TEXT NULL, profile TEXT NOT NULL DEFAULT '{}', preferences TEXT NOT NULL DEFAULT '{}', facts TEXT NOT NULL DEFAULT '[]', memory_version INTEGER NOT NULL DEFAULT 0, last_updated TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_thread_memory_owner_id ON thread_memory(owner_id); ``` MySQL: ```sql CREATE TABLE IF NOT EXISTS thread_memory ( thread_id VARCHAR(64) PRIMARY KEY, owner_id VARCHAR(64) NULL, profile JSON NOT NULL, preferences JSON NOT NULL, facts JSON NOT NULL, memory_version INT NOT NULL DEFAULT 0, last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_owner_id (owner_id) ); ``` ### 12.2 并发一致性默认值 采用:**`memory_version` 乐观并发控制 + 失败重试 1 次**。 保存逻辑: - `load()` 读出 `memory_version=n` - `save()` 时执行条件更新(`WHERE thread_id=? AND memory_version=n`) - 成功则 `memory_version=n+1` - 如果受影响行数为 0,说明被并发写抢先,重读并重试一次 这能防止“旧更新覆盖新更新”,同时实现复杂度可控。 ### 12.3 隐私策略默认值 采用:**默认拒绝敏感信息入库(代码层 hard filter)**。 默认 denylist: - 手机号 - 邮箱 - 身份证号/护照号 - 银行卡号 - 密码/API Key/Token - 详细住址 规则: - 命中则从 `profile/preferences/facts` 中删除该片段 - 仅记录脱敏审计信息(类型 + 时间 + thread_id),不记录原文 ### 12.4 注入裁剪默认值 采用固定优先级:**`profile > preferences > facts`**。 当超过 `max_injection_tokens`: - 必保留:`profile`、`preferences` - 裁剪:`facts`(按 `confidence DESC, createdAt DESC` 排序后截断) 这能保证人格与风格信息稳定注入,不被历史 facts 挤掉。 ### 12.5 冷启动策略默认值 采用:**首版不开启自动冷启动导入**(`bootstrap_from_global=false`)。 理由: - 降低“全局脏数据复制到 thread”风险 - 逻辑更清晰,便于观察 per-thread 记忆真实质量 补充: - 保留 fallback(你当前已定) - 后续若需要可加后台任务做可控回填 ### 12.6 内部运维接口默认值 采用:**首版只加读接口,写接口延后**。 第一版建议: - `GET /internal/thread-memory/{thread_id}`(脱敏后返回) 暂不做: - `DELETE /internal/thread-memory/{thread_id}`(已有 thread delete 联动可覆盖主场景) - `POST /internal/thread-memory/{thread_id}/rebuild`(二期再加) 这样可以先满足排障可见性,避免过早扩大运维面。 --- ## 13. 实施前冻结版 Checklist(可直接转开发) - [ ] DDL 按 12.1 落地(含 `owner_id`, `memory_version`, index) - [ ] Storage `save()` 改为 compare-and-swap 语义 - [ ] Updater 增加一次冲突重试 - [ ] parse 后执行敏感信息 scrub - [ ] 注入模块按 `profile > preferences > facts` 裁剪 - [ ] fallback 保持开启,冷启动导入保持关闭 - [ ] 增加最小指标与脱敏 diff 日志 - [ ] 增加内部只读排障接口 到这一步,方案已经可以进入实现,不需要再做大改。