Files
HomeAgent/docs/zh/legacy-table-retirement.md
JianFeeeee cf14b840ef docs: 补齐文档状态分类 + 校正两轮代码改动后的失真
## 背景

上一次文档校订(b9b5ac0)之后又有两次代码提交(5194a96 / 8293282),
把 future-plan §1.1 整节变成了错的 —— 它还写着「档位没有生效点」
和「在 prepareInputTask 里加一次 activateSecurityProfile 调用」,
而后者正是被后来的收敛**否定掉的方案**。

## 新增 docs/zh/README.md(文档索引)

32 篇文档此前混着三种时态(当时实测 / 历史推演 / 设计规划),外观相同。
索引给出**状态分类与可信度规则**:

| 状态 | 能不能当事实用 |
|---|---|
| 已实现并部署 | ✅ 直接信 |
| 部分实现 | ⚠️ 只信标了"生效"的部分 |
| 设计 / 未实现 | ❌ 只能当设计意图 |
| 归档说明 | ❌ 不要改、也不要信 |

并写死**判据名可信度规则**:文档里的 `TestXxx` 不保证还存在。
给了核对命令,以及当前基线(27 条缺失的分布 —— 18 条属"设计未实现"类
文档的规划名、7 条是 §2.7.3 有意保留的历史对照表左栏、2 条属视频能力
未纳入契约)。判定标准:**在"已实现"类文档正文里找不到才是缺陷**。

## 重写 future-plan §1.1(已结案)

原节写的是错的方案。改成记录**真正的缺陷分三层**:

```
第 3 层(表面):activateSecurityProfile 零调用方
第 2 层:agent/core 在替 security 做策略决策
         (自己抠 security_profile / 自己选判定链 /
           自己写 Kind:"resident" 声明主体类别 —— 提权口子)
第 1 层(根因):security 缺少「输入接入点」
```

并补上「每 agent 一个实例」的最终形态(共享注册表 + per-agent 执行视图,
sessionProfile / longtimeProfile 两层,优先级 session > longtime 本身是安全语义)。

§〇「机制 ≠ 接线」表格里档位那一条从 🔴 改为 ✅。

## 各文档状态行

此前 14 篇缺状态行(runbook / 实测报告 / 计划类)。逐篇补上**符合实际内容**
的声明,而不是统一套一句。特别地:

- `commondev-design.md` 标题本就写「设计 · 未实现」→ 归到"设计"类
- `live-run-evidence-2026-10-04.md` → 明确标注"历史证据快照,不要用它的
  结论覆盖现在的实现判断"
- `legacy-issues.md` → 写明它与 future-plan 的分工(前者收已知问题,
  后者收能力缺失与零调用方)

## 正文里两处判据名漂移

- `TestBootCtxCloseAllIsReverseOrder` → 实际是
  `TestKernelStateCloseAllIsReverseOrder`(`BootCtx` 改名时判据跟着改的)
- `TestNilTypedBusSafety` → 标注为"规划名,仓库中不存在"

## 新增 §2.7.5 模块产物的实例化粒度

依赖图保证"设施已就绪",但没说该有几个实例 —— 这是独立决策。
记下 security 的两个答案:全局基线共享(否则插件声明对部分 agent 不生效)、
轮次状态 per-agent。并给出给未来新模块的两条判断标准。

## 安全文档

状态头补「架构形态」段(防火墙定位、per-agent 视图、两层档位);
接线状态表里 `ActivateProfile` 从 🔴 改为 ✅(2026-10-31 接通)。

## README

文档列表加索引与 future-plan 入口 —— 此前从 README 找不到 docs/zh 的任何指引。

## 核对

- docs/zh 32 篇全部有状态行
- 索引里列的文档全部存在,无遗漏
- 分类与各文档自述一致(逐篇抽查)
- go build 仍通过(纯文档改动)
2026-10-31 07:50:58 +08:00

452 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 旧表清理可行性评估(2026-10-04)
> **状态:计划(收尾中)。** 遗留表退役的执行记录;剩余读方见文内 §八 / §十二。
> 结论:**现在不能清理**。55 处生产调用点仍在读写 `entities` / `relations` / `sentences`。
> 这份评估回答「能不能删」以及「删之前要先做什么」。
## 一、数据侧已就绪
清理的**数据前置**已全部通过(`TestRetire_清理前置`,生产快照实测):
| 判据 | 结果 |
|---|---|
| ① 每个 entity 都有对应的块 | ✅ 1294 → 1294,缺块 0 个 |
| ② 每条 relation 都有对应的边 | ✅ 980(去重 959)→ 959 |
| ③ 每条 sentence 都有原句块 | ✅ 66 → 66(`55db22c` 修复后) |
| ④ 悬空的 sentence→块 边 | ✅ 0 |
| ⑤ 端点不存在的块边 | ✅ 0 |
## 二、代码侧远未就绪
### 调用点分布
```
internal/memory/social/social.go 7 处
internal/memory/graph.go 6 处(Commit/Recall 实现)
internal/memory/scene.go 4 处
internal/memory/light_memory.go 4 处
internal/plugins/webui/handler_memory.go 2 处
internal/plugins/healthcheck/plugin.go 2 处
internal/plugin/proc/corehandler_memory.go 2 处
internal/plugin/lua_plugin.go 2 处
internal/sdk/memory_impl.go 2 处(Recall + CommitWithMedia)
────────────────────────────────────────
合计 55 处
```
### 三张表各自还被谁依赖
**`entities`**
```go
graph.go:505 Commit 里 SELECT id FROM entities WHERE name = ? (写入路径)
graph.go:681 hotspots ORDER BY mention_count DESC
graph.go:756 SELECT ... FROM entities WHERE LOWER(name) LIKE ? (Recall)
```
**`relations`**
```go
graph.go:532 Commit 里查重(source+target+type+session)
graph.go:705 Recall 的关系遍历
graph.go:748 SELECT MAX(r.sentence_id) FROM relations r
graph.go:362 从 sentence_ref 回填 sentences
```
**`sentences`**
```go
graph.go:362 INSERT OR IGNORE INTO sentences ... FROM relations.sentence_ref
graph.go:522 CommitWithMedia 挂接媒体块的落点
(EnsureSentence 已删 —— 它是最后一个写入点,07b8c2a 之后归零)
```
## 三、为什么这三条链路必须一起换
它们不是三张独立的表,而是**一条写入链 + 一条读取链**:
```
写入 Commit ──> entities(name 唯一) + relations + sentences(挂媒体块)
读取 Recall ──> entities(LIKE) + relations(遍历) + sentence_id
```
**只删表不改代码 ⇒ 写入报 "no such table",读取返回空。**
而 `Commit` 是**在线主路径**(每轮对话都可能调用),
不是可以延后的离线工具。
## 四、清理前必须完成的工作
按依赖顺序:
| # | 工作 | 说明 |
|---|---|---|
| 1 | `Commit` 改写为写块 | 不能再 `INSERT entities`;三元组应直接落 `memory_blocks` + 块边 |
| 2 | `Recall` 改写为读块 | 现在读 entities/relations;应读块 + 块边 |
| 3 | 媒体挂接点迁移 | `CommitWithMedia` 的 `sentence_id` 要换成原句块 ID |
| 4 | social / scene / light_memory 适配 | 它们的调用要改到新接口 |
| 5 | WebUI / healthcheck / proc / lua / SDK 适配 | 对外契约不变,实现换掉 |
| 6 | **回滚演练** | 在快照上跑完 1-5,验证召回不退化 |
★ 第 6 步不是可选项:`Commit` 在线上,改错了**当场影响对话记忆**。
## 五、当前建议
**不要现在清理。** 理由不是"风险高",而是:
1. 数据侧就绪 ≠ 代码侧就绪(当前 55 处调用点)
2. `Commit`/`Recall` 在**在线主路径**上,不是一次性迁移
3. 相比之下,**清理旧表的收益只是"少三张空表"**,
而代价是重写一条在线链路
⇒ 真正该做的顺序是:**先让写入与读取都走块**(1-3),
旧表自然失去写入方,最后再谈删除。
## 附:数据侧的两个真实缺口(已修)
- **66 条 sentence 没有原句块**(`55db22c` 修复):
迁移只从 entities 读,从不为 sentences 建载体 ⇒ 直接清理会丢原文
- **关系边 959 vs relations 980**:
20 组 `(source,target,type)` 完全重复被边表去重(`dd2c996` 已显式报出 `DedupedEdges`)
## 六、⚠️ 一个操作错误:**`cp` 拿不到一致的快照**
验证 `55db22c`(sentences 原句块)时,我用
`cp ${HA_DATA}/memory/graph.db /var/tmp/ha-probe/fix.db` 取副本,
结果:
```
生产库 entities=1294 sentences=66
cp 出来的 entities=1293 sentences=67 ← 差 1,且方向相反
```
第一反应是「迁移改动了源表」,差点去查迁移的写入逻辑。
**实际是 `cp` 的问题** —— 生产库在 WAL 模式下,已提交但未
checkpoint 的数据还在 `-wal` 文件里,`cp` 只拿到主库文件 ⇒ 不一致快照。
用 `sqlite3 .backup` 重取后:
```
.backup 快照 entities=1294 sentences=66 blocks=98 ← 与生产库一致
```
★ 本文档与 `production-recall-validation.md` 里都写过
「库在写,`cp` 拿不到一致快照,要用 `.backup`」,
**我自己写下的规则自己违反了。**
⇒ 判据也该加一条:**快照来源必须可验证**。
最省事的做法是取完快照立刻与源库比对关键计数,不一致就重取
—— 差 1 就会暴露。
## 七、2026-10-04 进展:写入侧已完成
### ✅ Commit 块化(cdf0726)
`Commit` 现在在旧表写入之外**并行块化**:
三元组 主语块 --关系--> 宾语块
原句 原句块 blk_src_<hash>
四条写入路径(媒体桥 / memory_commit 工具 / 驻留子 / 记忆整理流水线)
全部经过它 ⇒ **旧表不再因块化而缺内容**。
### ✅ 边升格为独立单位(52e4596)
去 UNIQUE(source_kind,source_id,target_kind,target_id,edge_type)
+ confidence / session_id / turn_id / status / merged_into
★ 那个 UNIQUE 才是「报告 980、实际 959」的根因 —— 边表从设计上
存不下多条同类边。详见该提交说明。
### ✅ scene_refs 完整迁移(07b8c23)
kind='entity' 450 条 → 'block'
kind='relation' 268 条 → 'edge'
生产快照实测:718 条迁移完成,悬空 0
### ✅ EnsureSentence 已删
`sentences` 表的**最后一个写入点**已移除并连同其专属测试一起删除。
## 八、剩余的读方(尚未切)
| 读方 | 调用处 | 状态 |
|---|---|---|
| `scene.go` | 取边、按名找邻居、悬空检查 | ✅ 已改块/边(13c3292) |
| `social.go` | 3 处 Recall(要「按名取实体 + 关系遍历」) | 需要块侧的等价接口 |
| `indexer.go` | 2 处(全量实体名 → 建向量索引) | 需要 `AllBlockNames` |
| `distill.go` | 2 处(全量 → 记忆整理) | 同上 |
| `light_memory.go` | 3 处(透传代理) | 随上面改动 |
| `toolcall.go` | 旧路兜底(块路优先,已是生产主路) | 保留兜底 |
### 需要的块侧能力(其中两项已就绪)
按名取实体 ❌ 缺(一条 SQL 的事)
按关系遍历 ✅ BFSBlocks(9df1efb)
取边 + 两端节点 ✅ AllRelationEdges / RelationEdgesBetween
全量块名 ❌ 缺(MemoryBlocks 已有,只需薄包装)
⇒ 「旧表退场」的准确说法:**写入侧已全部块化,剩余是读方切换。**
## 九、2026-10-04:读方切换全部完成
### 切了的(每项都有独立判据)
| 读方 | 提交 | 备注 |
|---|---|---|
| `scene.go` 读写两侧 | `13c3292` | 6 处查询 + 补 `contains` 结构边 |
| `Purge` | `258eadf` | 9 处调用方 |
| `PurgeNoise` | `258eadf` | |
| `RecallSorted`(两分支) | `258eadf` `5a6e4a9` | 302 行,`entityIDs` 换键类型 |
| `Introspect` | `258eadf` | |
| `MergeEntities` → `MergeBlocks` | 本轮 | 86 行 → 委托 |
| `social.go` 5 处 | 本轮 | 见下 |
### ★ 「55 处调用点」是虚高的
indexer / social / distill / light_memory 全都不写裸 SQL,
它们都走 db.Recall(...) ⇒ 真正的收敛点是 RecallSorted 一个函数。
⇒ **按处数估工作量是错的。** 逐个适配时才发现它们共用一条路。
### ★ 切换期最危险的状态是「混合态」
social 3 个测试当场变红,根因值得写下来:
写侧(Purge)已切块、读侧(RecallSorted)还没切
⇒ 软删的边在**旧表**里仍是 active
⇒ 召回照样返回它
**混合态比全旧态更危险** —— 它看起来是「部分成功」,
而全旧态至少是一致地坏。读方必须一次切到位。
### MergeBlocks(块合并)
旧 `MergeEntities` 85 行全在旧表,改名只改 `entities.name`,
块还叫「张先生」⇒ 召回照样命中。
★★ **与旧实现的本质差异**:块 ID 是**内容派生**的
(`blk_ent_<hash(name)>`),「张先生」与「张三」是两个不同的块。
所以合并不是「改端点」,而是**让源块消失并把它的边改指向目标块**:
① 关系边重定向(source→target / target→target)
② 去自环:同端**同 edge_type** 才是重复
★ 只按 (source,target) 去重会把「喜欢咖啡」与「讨厌咖啡」误删一条
③ 删指向源块的结构边(否则端点悬空 → graphNodeExists 拒绝后续写入)
④ 删源块
⑤ scene_refs 把源的引用**换成目标**(不是删 ——
「张先生那次值班」这个场景仍存在,只是人换了名字)
**源块不存在必须报错**,不能返回 `(0, nil)`:
它有两种原因(已合并 / 名字写错),返回同一个结果会让后者表现为成功。
### ★ social:ID 空间错配是最隐蔽的一类
`Entity.ID`(int64,召回内序号)与 `Relation.SourceID`(块 ID,字符串)
是**两个不同的 ID 空间**。旧代码 `r.SourceID == personID` 永不匹配。
★ 症状极具迷惑性:`GetTrait` 里 `if 匹配 {…} return r.SourceName`
在「不匹配」时**无条件**执行,于是返回**人物自己的名字**当特质值
(判据里是 `got "张三"` 而非空串)—— 看不出是 ID 错配,只像数据错了。
修法是**按名字匹配** —— 而且这本来就更对:
「张三的特质」与 ID 无关;按 ID 还有个隐患:
同一个人可能有两个块(历史数据里文本相同),按 ID 只认一个。
### ★ 块没有 type 概念
`Triple.SubjectType` 只写旧 `entities.type`,`memory_blocks` **无类型列**。
`ListPersons` 原来靠 `e.Type == "person"` 筛选,改成**从图结构推断**:
人物 = 社交关系(非 trait)的任一端点 ∪ trait 关系的源
★ 这不是权宜之计 —— 它比 type 更可靠:type 是写的时候声明的
(调用方可能不声明、可能声明错),而结构是数据本身的性质。
旧实现里「`AddRelation` 加了人但没 `SubjectType`」就会漏掉那个人。
---
## 十、生产迁移已完成(2026-10-04 15:36)
### 执行
```
homed-graph-migrate -db ${HA_DATA}/memory/graph.db -apply \
-embed-provider chineseclip -model-dir /var/tmp/ha-c/models/chinese-clip-vit-b16-onnx
耗时 161.8s,单事务,命令自动快照 graph.db.bak-20261004-153657
```
### 产出
| | 迁移前 | 迁移后 |
|---|---|---|
| 块 | 98 | **2752**(带向量 1391) |
| 块边 | 97 | **2371** |
| 关系边(confidence 非空) | — | **980** |
| scene_refs 旧 kind | 718 | **0** |
| scene_refs 新 kind | — | block 540 / edge 268 / document 2 |
| 悬空端点 | — | **0** |
| 中心向量 | 无 | 已重建(各向异性 detected,双边中心化启用) |
### 验证(七步全通过)
```
① 停机确认 inactive + disabled ✓
② WAL checkpoint 0|0|0 ✓
③ 一致快照 integrity ok,五表逐一核对 ✓
④ 报告核对 与副本完全一致,未写库 ✓
⑤ 执行 161.8s,exit=0 ✓
⑥ 结构验证 七项全部通过 ✓
⑦ 召回探针 7/9 ✓
```
★ **④ 这一步的价值**:副本上验证过还不够,
生产库的数字必须单独核对一次 —— 因为副本是快照,
而生产库在停机期间理论上不该有任何变化,但**"理论上"不是证据**。
★ ⑥ 的第 1 项(逐表核对旧表)证明了迁移确实没动源表:
```
entities 1294 / relations 980 / sentences 66 ← 与迁移前完全一致
```
### 召回前后对照
| 维度 | 迁移前 | 迁移后 |
|---|---|---|
| casual | 0/4 | **3/4** |
| confusable | 0/1 | **1/1** |
| abstention | 3/3(假象,见 abstention-criterion.md) | **3/3**(真实拒答) |
| coexist | 0/1 | 0/1(BFS 未进召回链) |
| **合计** | 3/9 | **7/9** |
### 回滚
```
主回滚点 /var/tmp/ha-prod-migrate/prod-20261004-153045.db(integrity ok)
迁移自带 ${HA_DATA}/memory/graph.db.bak-20261004-153657
代码回退 e3dea6d 及之前任一提交(全部已提交,未 push)
```
★ 回滚只需换代码:旧表未被改动,迁移前的读方仍能工作。
### ★ 过程中的两次自纠
**1. 迁移报告曾报「关系 0」**(`d4eda47` 已修)
Introspect 的口径在读侧切块后变成数块侧,而迁移报告要的是
「旧表还剩多少」;加上 SQL 语法错误被 `if err == nil` 吞掉 ——
一个语法错误伪装成「库里没有关系」。
★ **诊断误导比报错危险**:报错了有人查,0 不会。
**2. 首条执行命令因中文括号报 shell 语法错误,迁移未执行**
事后确认生产库块数仍是 98 才重试。那次失败无任何影响,
但它说明**执行清单里的命令必须先在目标 shell 上验证语法**。
### 剩余工作
| # | 项 | 说明 | 阻塞 |
|---|---|---|---|
| 1 | 停旧表双写 | `commit()` 里的 `entities`/`relations` INSERT | 无(读方已全切块) |
| 2 | BFS 进召回链 | `coexist` 维度 0/1:泛指提问召不回端口号 | 无 |
| 3 | 观察期后删旧表 | 三张表 + 约 55 处调用点 | 需第 1、2 项完成 + 观察 |
| 4 | 纯中文编造查询 | 契约外,需符号切分改进 | 无 |
★ 第 3 项**不建议现在就做**:删表不可逆,而第 1、2 项都还没做。
## 十一、停旧表双写已完成(2026-10-04 18:xx)
### 做了什么
`commit()` 不再写 `entities` / `relations` / `sentences`。
旧表**停止增长,冻结为历史**(生产 1294 / 980 / 66)。
读方此前已全部切块,所以这一步完全可逆:改回代码即可,旧表数据完好。
★ 判断依据是**行为**不是语句:
判据 `Test停双写_旧表不再增长` 逐表比对 Commit 前后的行数。
### ★★ 它暴露了六个静默失效
全部是「报成功、实际没做」或「悄悄做错事」,**没有一处报错**。
| # | 函数 | 现象 |
|---|---|---|
| ① | `memory_commit` 工具层 | **每次都告诉模型「全部被拒,请检查实体名」** —— 而写入其实成功了。模型据此改实体名,把内容改坏 |
| ② | `syncIfStale` | 索引器**永远不建立索引**(旧表行数恒 0 ⇒ 连首次都判成「无需同步」) |
| ③ | `ExportTriples` | 驻留子 agent 的**回收失效**(合入 0 条) |
| ④ | `ClearSentenceID` | **一直在清理错误的行**(调用方传边 ID,函数改旧表另一行;而 UPDATE 影响 0 行也是「成功」) |
| ⑤ | `Archive` | 对块体系**完全无效** ⇒「按时间衰减记忆」静默失效 |
| ⑥ | `archiveColdDocs` | 判据 `ec==0 && rc==0` 恒真 ⇒ **L2 文档永不删除** |
★ ① 值得单说:**「报假失败」比「报假成功」危险**,
前者会诱发模型的破坏性动作(改不该改的内容)。
★ ⑥ 讽刺:它自己的注释写着
「实测 456 字图片描述得到 0 计数,随后文档被删、内容消失」。
**同一个原因,方向反了** —— 从「误删」变成「永不删」。
另修复三个附带问题:`PurgeOrphans` 删错对象、`Introspect` 的 hotspots
冻结在迁移快照、`FindRelations` 精确查找失效。
### ★ 一个更隐蔽的:旧表的**读取**也必须停
SELECT id FROM entities WHERE name = ?
旧表删掉后它返回 no rows ⇒ **Commit 直接失败** ⇒ 记忆完全写不进去。
★ 这是「停双写」时最容易漏的一环:**写要停,读也要停**。
### ★ 边去重:同会话收敛,跨会话并存
旧 relations 靠 UNIQUE(source,target,type,session_id) 保证
52e4596 为支持「同类边并存」把边表 UNIQUE 全部移除
⇒ 重复提交同一三元组产生重复边(实测同会话两次 → 2 条)
而记忆写入是**高频重试**的(LLM 反复提交同一事实),
重复边会让召回刷屏、场景权重虚高。
### ★★ 判据修正里最危险的一类
**用 `Commit` 造旧表数据**。旧表停写后迁移测试的输入天然为空,
于是「迁出 0 块 0 边」**竟然通过**。
★ 那不是「迁移正确」,是「**测不到**」——
它让「迁移还对不对」这个问题无法回答。
已加显式的 `Seed*` 测试接口(明确标注仅供测试),
迁移测试现在真的测到了迁移。
### 判据修正汇总(约 15 处)
| 类型 | 处理 |
|---|---|
| 断言旧表计数 `ec`/`rc` | 改用块侧信号 |
| 用 `Commit` 造旧表数据 | 改用 `Seed*` 接口 |
| 靠删旧表造状态 | 改删块侧(如「造孤立实体」) |
★ `Commit` 的返回签名是 SDK 契约(9 处调用方),改签名代价大,
所以保留位置并置 0。
★ **代价要说清楚**:这两个返回值现在没有意义了,
依赖它们判断的调用方(若存在)应改用块侧计数。
## 十二、剩余工作
| # | 项 | 说明 | 风险 |
|---|---|---|---|
| 1 | 启动服务 | 生产库已迁移但服务仍停机 | 需确认 |
| 2 | 观察期后删旧表 | 三张表 + 若干调用点 | **不可逆** |
| 3 | `casual` 3 格 / `coexist` 1 格 | 判据 `want` 口径窄于实际能力 | 无 |
| 4 | `Commit` 返回值语义 | `ec`/`rc` 已无意义,9 处调用方 | 无 |
★ 第 2 项**建议先观察**:旧表已冻结,删它唯一的收益是省几 MB,
而代价是不可逆 —— 万一将来要回滚分析历史数据就没了。