--- title: "Chapter 07 — 管线 D:记忆巩固 (Memory Consolidation)" date: 2026-05-20 draft: false tags: ["PRISM", "世界模型", "空间记忆", "Gaussian Splatting", "机器人"] categories: ["PRISM"] --- # Chapter 07 — 管线 D:记忆巩固 (Memory Consolidation) > 本章目标:机器人**充电 / 空闲**时跑一次"睡眠",把 `delta/pending.jsonl` 中**反复确认**的变化真正写入 LTM,并淘汰过时锚点、重训 3DGS、版本化备份。 --- ## 7.1 为什么需要"睡眠" 如果 ZED 看到一次"沙发挪了"就立刻改 LTM,机器人就会变得**易骗**: - 客人挪一下沙发拍照?被记成永久挪动 - ZED 单帧检测错把椅子识成桌子?长期记忆被污染 - 镜面区噪声偶尔产生"虚影"?写进去再也清不掉 仿照人脑:**白天积累短期记忆 → 睡眠时筛选 → 仅把多次确认的信号转入长期记忆**。 PRISM 也一样: ```mermaid flowchart LR subgraph DAY["白天(Online)"] D1[("delta/pending.jsonl
各种 DeltaEvent 累积,可能很乱")] end subgraph NIGHT["夜里 / 充电时(Consolidation)"] direction LR N1["pending"] --> N2["筛选"] --> N3["confirmed"] --> N4["应用到 LTM"] --> N5["版本化"] end DAY -. 触发 .-> NIGHT style DAY fill:#fff7d6,stroke:#c97a00 style NIGHT fill:#d8e4ff,stroke:#1565c0 ``` --- ## 7.2 触发条件 | 触发 | 频率 | 模式 | |------|------|------| | 充电桩 + 静止 > 10 min | 通常每天 1 次 | **完整巩固** | | 手动命令 `prism consolidate` | 按需 | 完整巩固 | | `pending.jsonl` > 5000 条 | 紧急 | **轻量巩固**(只筛选不重训) | | LTM 版本年龄 > 30 天 | 月级 | **深度巩固**(含重算 anchors + CLIP) | --- ## 7.3 巩固总流程 > ⚠️ **v1.5 升级**:本节描述的"筛选 → 仲裁 → 应用 → 重训"线性流程及其 > 内部 deduplicate / merge 决策器(Step 1–3)的**训练范式**已被 > [§ 7.13.1](#7131-v15-升级self-augmentation-巩固训练) 增强为 > self-augmentation 监督学习;本节流程本身仍可作为 baseline 使用, > 决策器若由简单规则(`PROMOTION_RULES` + `arbitrate()`)实现则完全不受影响。 > 仅当决策器升级为可训练策略网络(v1.5 → v2.0 路线图)时,§ 7.13.1 > 的 self-aug 范式才生效。 ```mermaid flowchart TB P[("pending.jsonl")] S1["Step 1: 事件分类与筛选
observation_count > N
时间跨度 > T
多 keyframe 多视角"] S2["Step 2: 冲突仲裁
iPhone 标 vs ZED 改"] S3["Step 3: 应用到 LTM
add / update / remove node"] S4["Step 4: 锚点 / 索引 / CLIP
重新计算受影响项"] S5["Step 5: 增量 3DGS 重训
只重训受影响房间"] S6["Step 6: 版本化 + 健康检查
snapshots/, validate()"] P --> S1 --> S2 --> S3 --> S4 --> S5 --> S6 style P fill:#f5e1ff,stroke:#7b1fa2 style S6 fill:#d4f0d4,stroke:#2e7d32 ``` --- ## 7.4 Step 1:事件筛选 ```python # consolidation/filter.py from datetime import timedelta # 不同事件类型有不同"晋升门槛" PROMOTION_RULES = { "object_moved": dict(min_obs=5, min_span_s=300, min_views=2), "object_removed": dict(min_obs=10, min_span_s=600, min_views=3), "object_added": dict(min_obs=8, min_span_s=300, min_views=2), "geometry_changed":dict(min_obs=15, min_span_s=900, min_views=4), } def classify(ev: DeltaEvent, mem: SpatialMemory) -> str: rule = PROMOTION_RULES[ev.event_type] span = ev.last_observed - ev.first_observed unique_views = count_unique_viewpoints(ev.evidence, mem) # 若是 iPhone 的高 confidence 节点,门槛 1.5x target = mem.nodes.get(ev.target_uid) if ev.target_uid else None if target and target.source == "iphone" and target.confidence > 0.85: rule = {k: v*1.5 for k, v in rule.items()} if ev.observation_count >= rule["min_obs"] and \ span >= rule["min_span_s"] and \ unique_views >= rule["min_views"]: return "confirm" if span > 7*86400 and ev.observation_count < rule["min_obs"]//2: return "reject" # 7 天还没攒够观测 → 拒绝 return "keep" # 继续等待 ``` **关键直觉**: - `min_obs`:要"看过很多次" - `min_span_s`:必须**跨越足够长时间**(防止瞬时假象) - `min_views`:必须**来自多个视点**(防止单点死磕) --- ## 7.5 Step 2:冲突仲裁 不同 delta 之间,或 delta 与 LTM 之间可能冲突: ```python # consolidation/arbiter.py def arbitrate(events: List[DeltaEvent], mem: SpatialMemory) -> List[DeltaEvent]: """对同一 target 的冲突事件做仲裁""" by_target = group_by_target(events) final = [] for uid, evs in by_target.items(): if len(evs) == 1: final.append(evs[0]); continue # 多个事件涉及同一节点 sorted_evs = sorted(evs, key=lambda e: e.observation_count, reverse=True) primary = sorted_evs[0] # "搬走"+"挪到新位置" → 合并为一个 moved 事件 if {e.event_type for e in evs} == {"object_removed","object_added"}: primary = merge_remove_add(evs) final.append(primary) return final def merge_remove_add(evs): rem = next(e for e in evs if e.event_type == "object_removed") add = next(e for e in evs if e.event_type == "object_added") return DeltaEvent( event_id=uuid(), event_type="object_moved", target_uid=rem.target_uid, new_pose=add.new_pose, new_bbox=add.new_bbox, evidence=rem.evidence + add.evidence, observation_count=rem.observation_count + add.observation_count, first_observed=min(rem.first_observed, add.first_observed), last_observed=max(rem.last_observed, add.last_observed)) ``` --- ## 7.6 Step 3:应用到 LTM ```python # consolidation/apply.py import shutil from copy import deepcopy def apply_events(events: List[DeltaEvent], mem: SpatialMemory) -> SpatialMemory: """返回应用后的新 SpatialMemory;不修改原对象""" new = deepcopy(mem) for ev in events: if ev.event_type == "object_moved": n = new.nodes[ev.target_uid] n.pose = ev.new_pose n.bbox_3d = ev.new_bbox n.last_seen = ev.last_observed n.confidence = min(1.0, n.confidence * 0.95) # 轻微降低(被动过) # 重新计算 parent_room n.parent_room = find_room_for_point(n.pose.position[:2], new) elif ev.event_type == "object_removed": uid = ev.target_uid n = new.nodes[uid] # 不立刻硬删除,标记 deprecated 一段时间 n.attributes["state"] = "removed" n.confidence *= 0.3 # 切断关系 new.edges = [e for e in new.edges if e.src_uid != uid and e.dst_uid != uid] elif ev.event_type == "object_added": node = SpatialNode(**ev.payload) node.source = Source.FUSED # 经过 consolidation node.confidence = 0.7 # 提升 new.add_node(node) new.add_edge(SpatialEdge(node.parent_room, node.uid, "contains", source=Source.FUSED)) elif ev.event_type == "geometry_changed": # 在 L2 TSDF / OctoMap 上打孔重建,不改场景图 update_dense_layer_at_bbox(new, ev.new_bbox) return new ``` → 注意 `deepcopy + 整体替换` 保证**原子性**:要么全部成功,要么回滚。 --- ## 7.7 Step 4:锚点 / 索引 / CLIP 更新 ```python # consolidation/refresh.py def refresh_anchors_and_index(new_mem: SpatialMemory): # 1) 锚点重算 new_mem.anchors = build_anchors(new_mem) # 同 Chapter 04 # 2) 受影响节点的 CLIP 重算 for ev in events_just_applied: if ev.event_type in ("object_moved","object_added"): uid = ev.target_uid or ev.payload["uid"] node = new_mem.nodes[uid] # 渲染或从近期 keyframe 裁剪 crops = collect_recent_crops(uid) node.clip_embedding = mean_clip_embedding(crops) # 3) Faiss 索引重建(L4 全量) build_faiss_index(new_mem) # 4) L3 房间 CLIP 受影响时也重算 affected_rooms = {ev.target_uid_room for ev in events_just_applied} for r_uid in affected_rooms: new_mem.nodes[r_uid].clip_embedding = recompute_room_clip(r_uid) ``` --- ## 7.8 Step 5:增量 3DGS / TSDF 重训 完全重训 3DGS 太贵(一房间 15 k iter × 5 min),所以**只重训受影响房间** + **复用未变区域**: ```python def incremental_retrain_3dgs(new_mem: SpatialMemory, events): affected_rooms = set(find_room_for_point(ev.new_pose.position[:2], new_mem) for ev in events if ev.new_pose) for r_uid in affected_rooms: # 收集该房间最近 24h 的关键帧 kfs = list_keyframes_in_room(r_uid, since=time.time()-86400) # 从老 ckpt warmstart cmd = f"""ns-train splatfacto-bigtraining \ --pipeline.model.warmstart-ckpt outputs/{r_uid}/last.ckpt \ --max-num-iterations 3000 \ --data {kfs_dir}""" subprocess.run(cmd, shell=True) # 替换 LTM 中的 .ply shutil.move(f"outputs/{r_uid}/final.ply", f"robot_memory/ltm/dense/3dgs/{r_uid}.ply") def incremental_update_tsdf(new_mem, events): """TSDF 更便宜,直接在受影响 bbox 内重新融合最近关键帧""" for ev in events: bbox = ev.new_bbox if ev.new_bbox is not None else \ new_mem.nodes[ev.target_uid].bbox_3d # 把 LTM TSDF 在该 bbox 内"清零" new_mem.dense.tsdf.reset_in_bbox(bbox) # 用最近 keyframes 重融合 for kf in keyframes_observing_bbox(bbox, since_h=24): new_mem.dense.tsdf.integrate(kf) ``` --- ## 7.9 Step 6:版本化 + 健康检查 + 切换 巩固结果不直接覆盖在线版本,先写到一个 staging,校验通过才切换: ```python # consolidation/commit.py def commit(new_mem, old_dir, new_dir): # 1) 写 staging save(new_mem, f"{new_dir}/spatial_memory.json") copy_dense_dir(f"{old_dir}/dense", f"{new_dir}/dense") apply_dense_updates(new_dir, new_mem) # 2) 校验 errs = validate(new_mem) sanity = run_sanity_relocalize(new_dir) # 用 10 张历史关键帧重定位 if errs or sanity.success_rate < 0.9: log.error(f"consolidation rejected: {errs}, success={sanity}") return False # 3) 原子切换 timestamp = datetime.now().strftime("%Y%m%d_%H%M") snapshot = f"robot_memory/snapshots/{timestamp}.tar.zst" archive(old_dir, snapshot) os.rename(old_dir, f"{old_dir}.prev") os.rename(new_dir, old_dir) return True ``` --- ## 7.10 锚点淘汰策略 ```python def prune_stale_anchors(mem, max_age_days=60, min_recent_validations=2): keep = [] for anc in mem.anchors: node = mem.nodes[anc.anchor_uid] age_days = (time.time() - anc.last_validated) / 86400 # 仍是不可移动 + 最近 60 天被巩固确认过 → 保留 if not anc.is_mobile and \ node.attributes.get("state","") != "removed" and \ age_days < max_age_days: keep.append(anc) elif node.observation_count >= min_recent_validations: keep.append(anc) mem.anchors = keep ``` → 每次巩固跑一次。 --- ## 7.11 完整 CLI:`prism consolidate` ```python # tools/prism_consolidate.py import click @click.command() @click.option("--mode", default="full", type=click.Choice(["full","light","deep"])) @click.option("--ltm", default="robot_memory/ltm") @click.option("--delta", default="robot_memory/delta/pending.jsonl") def main(mode, ltm, delta): mem = load(f"{ltm}/spatial_memory.json") raw_events = load_pending(delta) print(f"[consolidate-{mode}] start, ltm_nodes={len(mem.nodes)}, " f"pending={len(raw_events)}") # Step 1: filter decisions = [(ev, classify(ev, mem)) for ev in raw_events] confirmed = [ev for ev, d in decisions if d == "confirm"] rejected = [ev for ev, d in decisions if d == "reject"] kept = [ev for ev, d in decisions if d == "keep"] # Step 2: arbitrate final = arbitrate(confirmed, mem) # Step 3-4: apply + refresh staging = f"{ltm}.staging" new_mem = apply_events(final, mem) refresh_anchors_and_index(new_mem) # Step 5: dense(mode=full/deep 时才跑) if mode in ("full","deep"): incremental_update_tsdf(new_mem, final) if mode == "deep": incremental_retrain_3dgs(new_mem, final) # Step 6: commit ok = commit(new_mem, ltm, staging) if not ok: print("[consolidate] rolled back; pending.jsonl unchanged") return # 重写 pending:把 confirmed 移走,rejected 归档,kept 留下 write_jsonl(f"robot_memory/delta/confirmed.jsonl", final, append=True) write_jsonl(f"robot_memory/delta/rejected.jsonl", rejected, append=True) write_jsonl(delta, kept) # 覆盖 print(f"[consolidate-{mode}] applied={len(final)} " f"rejected={len(rejected)} kept={len(kept)}") ``` 运行: ```bash # 充电时自动触发 systemd-timer --on=20:00 --weekly --command "prism consolidate --mode full" # 月度深度巩固 systemd-timer --on=monthly --command "prism consolidate --mode deep" ``` --- ## 7.12 安全机制 | 风险 | 缓解 | |------|------| | 巩固后机器人 reloc 失败 | Step 6 的 sanity check 不通过 → 自动回滚 | | 巩固过程中断电 | staging 写完之前不替换 `ltm/`;半成品 staging 启动时清理 | | 误删 iPhone 高 conf 节点 | 应用 remove 前再次检查 `source==iphone && conf>0.85`,若是则需 ≥ 10 倍证据 | | 数据竞争(在线感知与巩固同时写) | 巩固开始前发 `set_readonly(true)`,在线感知此期间只允许写 `delta/`,不能切换 LTM 句柄 | --- ## 7.13 巩固后的指标记录 ```python def log_metrics(events, mem_before, mem_after): with open("robot_memory/logs/consolidation.log", "a") as f: f.write(json.dumps({ "ts": datetime.now().isoformat(), "applied": len(events), "nodes_before": len(mem_before.nodes), "nodes_after": len(mem_after.nodes), "anchors_before": len(mem_before.anchors), "anchors_after": len(mem_after.anchors), "ltm_version_new": mem_after.schema_version, }) + "\n") ``` 便于后续画"机器人记忆演化"曲线。 --- ## 7.13.1 v1.5 升级:Self-Augmentation 巩固训练 ### 背景:train-test discrepancy § 7.4–7.7 的 dedup / merge / arbitrate 决策当前由**简单规则** (`PROMOTION_RULES` 阈值 + `arbitrate()` 启发式)做。v1.5 → v2.0 路线 图中,我们计划把这些规则升级为可训练的**策略网络** (`policy_net: SceneRepr → {confirm, reject, merge, ...}`)。一旦走到 策略网络,就立刻撞上 Lyra 2.0 § 3.3 描述的 train-test discrepancy: - **训练时**:策略网络看到的是"干净的当前 L3 snapshot"——离线流水线 bundle adjustment 完毕、节点位姿无累计漂移、CLIP 嵌入由完整观测重算。 - **推理时**:策略网络面对的是"有累积误差的 snapshot"——白天 Pipeline C 在线写入、L2 已漂、CLIP 由匆忙的 ZED crop 算出、还混着第 50 次巩固 之后才逐渐显形的系统性偏差。 这种分布偏移导致策略网络在第 1–10 次巩固时表现良好、到第 50 次开始 误删高 confidence 节点或把两个真实独立的家具误合为一个。Lyra 2.0 § 3.3(b) 给出的解法是 **self-augmentation**:训练时**主动把模型自己 之前的不完美输出当作输入**,但**监督信号仍用干净 ground-truth**——模型 反复见到"自己会犯的错",因此学会自我纠错。完整原则陈述与 $p_{\text{aug}}$ 取值讨论见 [`18_lyra_inspirations.md` § 18.4](18_lyra_inspirations.md)。 ### 算法:`consolidate_with_self_aug()` ```python # consolidation/self_aug.py (v1.5 新增,配合 policy_net 升级使用) import numpy as np def consolidate_with_self_aug( l3_clean: SceneRepr, # 当前离线巩固出的"干净"L3 快照 history_l3s: List[SceneRepr], # 过去 N 次巩固留下的不完美快照 policy_net, # 待训练的 dedup/merge 决策器 p_aug: float = 0.7, t_max: float = 0.5, ) -> torch.Tensor: """ 单步训练:以 p_aug 概率把"历史不完美 L3"当输入, 监督信号始终是从 l3_clean 推得的 dedup/merge 目标。 """ # ---- 1. 决定本步是否使用 self-aug ---- if np.random.rand() < p_aug and len(history_l3s) > 0: # 从历史中采样一份"曾经的不完美 snapshot"做基底 t = np.random.uniform(0.0, t_max) # 噪声强度,见下 t 的物理意义 base = sample_history(history_l3s) corrupt_input = inject_noise_from_history( l3_clean, base, t=t) # 见下"噪声注入策略" else: # 1 - p_aug = 0.3 概率用真实的"干净"输入做兜底 corrupt_input = l3_clean # ---- 2. 前向 + 统一向"干净目标"对齐 ---- pred = policy_net(corrupt_input) # logits over actions targets = dedup_targets_from(l3_clean) # 监督一律取 clean loss = cross_entropy(pred, targets) return loss # 调用方负责 backward + step;典型 1000 iter / 场景(见参数表) ``` ### 噪声注入策略:把历史 L3 当 "corruption source" `inject_noise_from_history(l3_clean, base, t)` 不是凭空加 Gaussian,而是 **让 `l3_clean` 退化成"看起来像 `base` 那个时点的中间状态"**。具体扰动 按 `t ∈ [0, 0.5]` 线性加权应用以下三类: | # | 扰动名 | 实现 | 模拟的真实失败模式 | |---|--------|------|-------------------| | 1 | **节点位置抖动** | 对每个节点 `pose.position` 加 `N(0, σ_xyz)`,`σ_xyz = t · 0.10 m` | L2 长走廊漂移导致 L3 节点中心偏移 ±10 cm | | 2 | **CLIP 嵌入扰动** | 对 `clip_embedding` 加 `N(0, σ_clip)` 后重新 L2 归一化,`σ_clip = t · 0.05` | ZED 暗光 / 模糊 crop 导致嵌入轻微偏离 | | 3 | **节点随机丢弃** | 以概率 `p_drop = t · 0.10`(即 t=0.5 时丢 5%)随机移除非锚点节点 | 在线管线漏检小物品 / 被遮挡未上报 | 实现时三种扰动**独立采样、叠加施加**,并对应 `history_l3s` 中真实出现过 的偏差量级做了线性归一(最大扰动幅度对齐"第 50 次巩固"时的统计观测)。 ### 参数表 | 参数 | 默认值 | 物理意义 / 来源 | |------|--------|----------------| | `p_aug` | **0.7** | Lyra 2.0 § 3.3(b) 报告的最佳点;> 0.5 让模型主要见自己的错,< 1.0 保留 30% 真实兜底,避免沉迷自生失败模式 | | `t_max` | **0.5** | $t \in [0, 0.5]$ 表示"最多让历史看起来像**巩固到一半时**的中间状态";t=1.0 会让样本退化到几乎全噪声,监督信号失效 | | `σ_xyz` | t · 0.10 m | 与典型 L2 长走廊漂移上限对齐 | | `σ_clip` | t · 0.05 | 与 ZED 暗光 crop 实测 CLIP 偏移分位数对齐 | | `p_drop` | t · 0.10 | 与 Pipeline C 漏检率(v1.4 实测 ~5%)对齐 | | 收敛迭代数 | **~1000 iter / 场景** | Lyra 用 7000 iter(视频扩散数据规模大);PRISM 单场景规模约小 5–10×,1000 iter 经验上够 | | 训练批大小 | 1 场景 / step | 场景图本身就是一个 graph batch,无需 mini-batch | ### 评测建议(回链 [`13_evaluation.md`](13_evaluation.md)) 在 [`13_evaluation.md`](13_evaluation.md) 的"**长时一致性**"指标族 (典型项:第 N 次巩固后的节点误删率、误合率、ghost-node 残留率)下, **工程预估**:使用 self-aug 训练的策略网络在第 50 次巩固之后, - **误删高 confidence 节点率**:下降 **30–50%**(baseline 无 self-aug 训练时通常 ~8% → 预期 4–5.5%) - **误合相邻独立家具率**:下降 **30–40%** - 第 1–10 次巩固指标基本持平(self-aug 主要解决长尾分布偏移,不解决初期能力问题) ablation 计划:固定其他条件,对 `p_aug ∈ {0.3, 0.5, 0.7, 0.9}` 与 `t_max ∈ {0.25, 0.5, 0.75}` 跑 2D 扫描,验证 (0.7, 0.5) 是否对 PRISM 真实数据也最优——如果最优点漂到 (0.5, 0.5),说明 Pipeline C 已足够稳定, self-aug 信号可以降权。 ### 与原巩固管线的关系 本小节**不替换** § 7.4–7.7 的任何步骤,只升级其中 dedup/merge 决策器的 **训练范式**——决策器接口、`apply_events()` 的下游调用、commit / 回滚 机制全部不变。旧版规则式决策器(`PROMOTION_RULES` + `arbitrate()`)仍 可作为 baseline 与 self-aug 训练出的策略网络做 A/B。回链 [`18_lyra_inspirations.md` § 18.4](18_lyra_inspirations.md) "原则三:巩固期 Self-Augmentation"。 --- ## 7.14 与 Chapter 06 的接口 | 项 | Chapter 06 | Chapter 07 | |----|-----------|------------| | 写 LTM | ❌ 严禁 | ✅ 唯一允许的写者 | | 写 delta | ✅ append | 读取 + 重写 confirmed/rejected/kept | | 读 LTM | 只读 | 只读 + 写 staging | | 频率 | 实时 | 离线(充电时) | 两者通过 **文件系统** 解耦:在线感知不必知道巩固何时跑,巩固也不必停下感知。 --- ## 7.15 本章小结 | 关键点 | 一句话 | |--------|--------| | **何时跑** | 充电 / 空闲;典型每天 1 次 | | **筛选规则** | observation_count + 时间跨度 + 视点多样性 | | **冲突仲裁** | 同 target 多事件合并;iPhone 高 conf 项有更高门槛 | | **应用** | deepcopy + 整体替换,保证原子性 | | **重训** | 仅受影响房间 + warmstart | | **安全** | staging + sanity check + 回滚机制 | | **比喻** | 像人脑睡眠——白天乱记,夜里整理 | 读完本章你应能: - ✅ 编写一个能跑通的 `prism consolidate` 脚本 - ✅ 解释为什么要"延迟修改 LTM" - ✅ 设计安全的回滚机制 下一章 [`08_runtime_timeline.md`](08_runtime_timeline.md) 把 Chapter 04–07 的所有模块串成一个**完整的端到端时序剧本**,从 T0 到 T4。 --- **章节版本**:v1.0 **估计阅读时间**:18 分钟 **关键收获**:把"短期 delta"变成"长期 LTM"的完整安全流程