Files
HomeAgent/docs/zh/production-recall-validation.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

804 lines
31 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.

# 生产召回验证:探针 PASS 但结论是假的
> **状态:实测报告(结论有效,日期见文内)。** 记录了「探针通过≠能力成立」
> 这一类失败的具体形态,是判据设计的反面教材。
> 文末 3 条遗留 sentence→块 边仍开放,已索引到 [`future-plan.md`](./future-plan.md) §六。
## 二十五、生产规模验证:**探针 PASS 了,但结论是假的**
在生产库快照(1294 entities / 980 relations / 66 sentences / 98 blocks)上
跑全维度探针,输出:
```
快照规模: 98 块(带向量 97, 99%)
[casual] 插件目录 ✘ | | | | | | |
[overwrite] 值班分机 ✘ | | | | | | |
[abstention] 不存在的面板 ✓ | | | | | | |
合计 0/0
--- PASS: TestProbe_生产规模召回 (4.29s)
```
★ **`--- PASS` 而 `合计 0/0`** —— Go 测试框架不把「探针全挂」当失败,
因为断言只记日志。这正是 memory 里那条判据不可达教训的又一次复现,
而且这次更隐蔽:**它连 `合计` 都打了 0/0,说明分母是 0**。
### 根因:生产库的块是**图像块**,不是文本块
```
memory_blocks: 98 条,modality 全是 image,text_content 非空 0/98
sentences: 66 条,text 非空 66/66 ← 真正的文本在这
entities: 1294 条
```
`text_content` 空是**正确的** —— 图像块本来就没有文本。
而探针判据读的是 `h.Block.Text` ⇒ 全部为空 ⇒ 全部判失败。
★ 所以探针**没坏**,它测的是 block 召回,而生产侧的 block 是 98 个
图像块 —— **判据描述的状态在生产库上不存在**。
### 生产侧的正确顺序(还没做)
1. `homed-graph-migrate` 1294 entities → 带文本+向量的 block
2. `homed-graph-distill` 66 sentences → 拆分块
3. **然后**本探针才有意义
### 生产库未被触碰(已核验)
```
生产: inode=16684491 1994752 字节 修改 2026-10-03 05:21:22
快照: inode=6815957 修改 2026-10-03 22:22:08
```
字节数、mtime、inode 与开始时一致。用 `sqlite3 .backup` 而非 `cp`
(库在写,`cp` 拿不到一致快照)。
### ★ 一个必须记的教训:探针自己会「PASS」
**探针全挂 ≠ 测试失败**。`t.Logf` 不改变退出码。
今后所有探针必须有一条**硬断言**,例如:
```go
if totP == 0 || totT == 0 {
t.Fatalf("探针未产生任何有效判定(分母为 %d)—— 前提不满足,不是通过", totT)
}
```
否则「0/0」会被打印成 PASS,**比红更危险** —— 红会让人去查,绿不会。
## 二十六、第二个探针缺陷:**期望值是编造的**
迁移后(1294 文本块)重跑探针前,先核对了每个 `want` 值是否真的在库里:
```
4324 → 0 块 老周 → 0 块
9090 → 0 块 8080 → 0 块
plugins → 7 块 graph.db → 1 块
```
★ **六个 want 值里四个在生产库中根本不存在** —— 我是从 ha-c 测试库
抄过来的。探针会全判失败。
### 这与「假 PASS」是同一类错误的两面
```
假 PASS 什么都没判定,却报成功 (分母为 0)
编造期望 判据描述的状态在库里不存在 (期望值是抄来的)
```
★ 两者都是**判据与实际数据脱节**,只是方向相反:
一个是判据要求太多(0 条判定也算过),一个是判据要求的东西不存在。
⇒ 教训:**探针的期望值必须从被测库里取,不能从另一个库或记忆里抄。**
核对动作应该是探针的前置步骤,而不是跑完看分数才发现。
### 已按生产库真实数据重写(10 条)
```
casual 脚本路径 ${HA_DATA}、工具链 plugindev、
公网地址 101.201.37.155、本机端口 13010
coexist 并存端口 13010/13011 不该互斥
confusable 13011 与 13010 别答错对象
abstention grafana / 容灾演练 / kafka —— 库里没有,不该编造
```
★ 生产库的端口事实是 **13010/13011 这类并存的**(不是新旧覆盖),
所以 `overwrite` 维度在生产数据上**不适用** —— 改成了验证「不误判」。
强行套用 ha-c 的覆盖场景就是又一次「判据不可达」。
## 二十七、生产快照迁移结果
```
块 98 → 1392 (+1294 实体块,全部带向量)
块边 97 → 2350 (+980 关系边)
耗时 104 秒
自动快照 prod.db.bak-20261003-222649
带文本块 1294/1392(另 98 个是 image 块,无文本是正确的)
```
### 顺带修的报告口径缺陷
```
报告 块边 966 ← legacyStats → COUNT(*) WHERE status='active'
实际 块边 980 ← MigrateLegacyTextEntities → ORDER BY id(不过滤)
```
用户会把「预计产出」当承诺,**差 14 条就说明它不可信**。
已让 `Introspect` 数全表与迁移同口径,并保留真实不确定性:
「孤儿关系会被跳过,实际可能略少」。
## ★ 一条操作纪律(我自己又犯了)
蒸馏用 `| tail -12` 管道跑,**`tail` 要等进程结束才吐**,
于是 66 条 × 28 秒的活儿看起来「零输出、进程 0% CPU」,
我误判成卡死。
代码里明明有进度输出(每 10 条或 20 秒一行),
而 memory 里那条约束写得很清楚:**长任务必须实时写日志,不能用全缓冲 tail 管道**。
⇒ 第几次了:**约束记住了,动手时没查**。
正确做法:直接重定向到文件(`> file.log`),不用管道。
## 二十八、生产规模召回:**3/10,且原因不是判据**
硬断言生效(`--- FAIL` 而非假 PASS),10 条探针的真实结果:
```
[casual] 脚本路径 ✘ [casual] 插件工具链 ✓
[casual] 公网地址 ✘ [casual] 本机端口 ✘
[coexist] 并存端口 ✘
[confusable] 13010与13011 ✓
[abstention] 三个不存在的 ✘✘✘ ← 全部编造
合计 0/0(分母 bug:coexist 维度名不在列表里)
```
★ 全部 want 值已核对**确实在库里**(1~11 块命中):
`${HA_DATA}` 11 块、`plugindev` 1、`101.201.37.155` 1、
`13010` 2、`13011` 2 —— 这次不是编造期望,是真的召回不到。
### 根因:分数挤成一团,没有区分度
```
查询「本机 13010 端口对应什么」→ 8 命中
1. 0.9006 本机 443 按 SNI 透传到 192.168.2.106:3080
2. 0.8712 ACP回环调用自身12001
3. 0.8611 MAR/MDR与ALU不直通必须经CPU内部总线
4. 0.8583 本地网关8081
5. 0.8488 CPU总线与MAR/MDR/ALU连接问题
6. 0.8462 CPU总线问题记忆缺失待补
7. 0.8410 防呆已实测生效
8. 0.8405 无工具插件属正常
★ 目标块「13010/13011 而非 12011」连 top2000 都没进
```
★ **全部 8 条挤在 0.84~0.90**。目标块含精确串 `13010`,
词法上是最强的信号,向量却给不出任何区分 —— 这是
**chineseclip 在长尾中文实体名上的失效**,不是中心化没做对。
### ★ 为什么 ha-c 的 7/7 不能推广
```
ha-c 448 块,运维域高度集中(排期/容量预警/告警规则…)
查询与块的词汇重叠高 → 7/7
生产 1391 块,横跨全部业务域(AgentMail/CPU总线/GUI/gateway…)
查询落在长尾 → 3/10
```
**7/7 是小库 + 同质分布的产物,不是「召回质量好」的证据。**
这正是 memory 里那条「金标准必须覆盖输入分布而不只是输出形态」的
又一次复现 —— 我拿了小库的结论当大库的预期。
### abstention 3/3 全失败 = 全编造
```
查询「grafana 监控面板的端口是多少」→ 召回 CPU总线 / MAR-MDR / 本机443…
查询「kafka 的 broker 地址是什么」 → 召回 read_thread / sdk站 / 跨实现校验…
```
★ 库里没有任何相关块,但向量仍给出 0.84+ 的高分 ⇒ **纯编造**。
这是比误召回更严重的一类失效:**系统对「不知道」毫无表达能力**。
## 结论:生产规模下召回不可用
| | ha-c(448块) | 生产快照(1391块) |
|---|---|---|
| casual | 3/3 | 2/4 |
| overwrite | 2/2 | 不适用(数据是并存非覆盖) |
| confusable | 2/2 | 1/1 |
| **abstention** | 0/0(无此维度) | **0/3(全编造)** |
| 合计 | 7/7 | **3/10** |
⇒ 在把召回接回生产前,必须先解决两件事:
1. **区分度**:分数挤在 0.84~0.90,长尾实体名召不回
2. **拒答能力**:0/3 编造 —— 系统需要「相似度不够就说没有」
第 2 条更紧急:它不是召回不准,而是**会给用户错误的答案**。
## 二十九、区分度问题的三层根因
### 第一层:中心向量生产路径无调用者(已修 `1255ea2`)
`graph_centroid` 表在生产快照里**根本不存在**。`RebuildCentroid` 有实现、
有测试、有持久化、有召回侧消费,**唯独没有生产调用者** ——
ha-c 那一行是手工测试时留下的。
建中心后实测:
均值向量长度 0.8041,平均两两余弦 0.6466,anomalous=true
样本 1391,维度 512
★ **平均两两余弦 0.6466** 解释了「分数挤在 0.84~0.90」:
任意两个块的相似度基线就有 0.65。
### 第二层:向量对「精确数字串」不敏感(这是真问题)
中心化后重测,同一条查询:
查询「本机 13010 端口对应什么」
召回 「13000端口」(7字符) ← 进了 top8
「本地网关8081」(8字符)
没召回「13010/13011 而非 12011」(20字符) ← 含**精确串 13010**
★ **短文本反而召回了,含精确串的反而召不回** ——
chineseclip 的句向量把 `13010` 这个 token 稀释在 20 字里,
而 7 字的「13000端口」几乎全是有效信号。
⇒ 这不是各向异性能解决的。**中心化改不了「词法强信号被稀释」。**
### 第三层:没有符号路兜底
```
词法 LIKE '%13010%' → 2 块命中(正确)
向量 top8 → 0 块命中(含 13010 的都不在)
```
而 `memory_recall` 现在是**「块命中优先、符号结果 fallback」**,
不是真正的向量+符号融合排序:
块一旦命中(同域块总是能命中一些),符号路就没有机会参与排序。
⇒ **当前架构下,向量的弱会让符号路完全没有补位机会。**
## 三十、修正后的真实分数:2/9
```
维度 casual 1/4 有缺口
维度 coexist 0/1 有缺口 ← 连「不误判」都做不到
维度 confusable 1/1 OK
维度 abstention 0/3 ★ 编造
合计 2/9
```
★ 之前观察到的「合计 0/0」是**第三个 bug**:map 值拷贝没写回 ——
`st := byDim[k]; st[1]++` 不写回,`byDim` 永远是零值,
汇总时全部 `continue`。我误判成「前提不满足」。
★ 这个 bug 藏了两次重跑。教训:**「0/0」有两种可能** ——
真的没判定(前提不满足),或**判定了但没记进去**(代码 bug)。
我一开始只想到前者。
## 结论:需要向量+符号融合,不是修向量
| 层次 | 状态 |
|---|---|
| 各向异性 | ✅ 已修(中心化补上调用者) |
| 精确串稀释 | ❌ 向量模型固有限制 |
| 符号兜底 | ❌ 架构上是 fallback 而非融合 |
⇒ 单纯调向量(换模型、调中心化、调 topK)**解决不了**这一层。
必须做**融合排序**:把词法命中作为一路信号,与向量分数加权/串并,
而不是「块命中就跳过符号路」。
## 三十一、拒答能否靠阈值?**测出来「可分」,但不可信**
这是决定 abstention 怎么做的关键测量:
**真实查询(库里有)与编造查询(库里没有)的 top1 分数能否分开?**
若能分 → 加阈值即可;若重叠 → 阈值必然误伤,必须靠符号路。
```
真实查询 top1
脚本路径改到哪个目录了 0.6731
从零开发 QQ 插件用什么工具链 0.6390 ← 最低
agentmail 公网访问地址是什么 0.8041
本机 13010 端口对应什么 0.6435
编造查询 top1
grafana 监控面板的端口是多少 0.4320 ← 最低
谁负责数据库容灾演练 0.5231
kafka 的 broker 地址是什么 0.5890
redis 集群的主从复制配置在哪 0.5923 ← 最高
真实最低 0.6390 ┃ 编造最高 0.5923
⇒ 间隔 0.047,阈值 0.6157
```
### ★ 但这个「可分」不可信
```
4 个真实、4 个编造,各压在阈值一侧
真实最低与编造最高的间隔只有 0.047
```
★ 0.6390 和 0.5923 分别是「4 个里最差的那个」。
只要任一侧多一个样本,间隔就可能变成负数。
**n=4 的可分性不是可分性。** 这正是 memory 里那条
「金标准必须覆盖输入分布」的又一形态 ——
**4 个编造样本不能代表「用户会问什么库里没有的东西」**。
真实使用里「库里没有」的查询分布远不止这 4 个:
`sentry 告警怎么接`、`postgres 主库地址`、`张伟负责哪个模块`……
每一个都可能落在 0.60 以上(`kafka broker` 就已经 0.5890)。
⇒ **不能据此加阈值上线。** 需要的是:
1. 扩充编造样本到 50+ 条(覆盖多种「看起来合理但库里没有」的形态)
2. 且必须测量 **ROC / 假阳率**,而不是「4/4 分开了」
## 顺带确认:符号路优先级的设计意图是对的,但没生效
`toolcall.go:236` 的注释写着:
> 端口号(8328)、分机号(4324)这类纯数字串向量天然弱,
> 而它们恰是本项目最常问的。两条路都跑,块向量在前,符号路兜底。
★ 设计意图**完全正确**,而实测证明**没生效** ——
因为「块向量在前」意味着块一旦有命中(非空字符串)就直接 return,
符号路的 `RecallSorted` 根本没被调用。
⇒ 要做的不是改设计,而是让它真正融合:
两路都跑,按**统一分数**重排,而不是「谁先返回听谁的」。
## 三十二、融合召回接入:2/9 → 3/9,收益范围明确
### 接入时改的两处(都不是"顺手")
**1. MinScore 从 0.5 改成 0**
融合分数的**量纲变了**:
```
纯向量 余弦序,阈值 0.5 是实测得出的(无关句也能到 0.83)
融合 精确串命中 = 1.0(布尔置顶)
向量加权 = 0.7 × 余弦 ⇒ 典型 0.35~0.45
```
若沿用 0.5:**精确串命中的 1.0 留下,所有纯向量候选被全筛掉** ——
反而丢掉向量侧的有效召回。筛选改由融合内部按路处理。
**2. 输出标签从 modality 改为召回来源**
```
[exact] 精确串命中(分数统一显示 1.00,它是布尔信号不是强度信号)
[symbol] 仅符号命中(向量没召回)
[text] 普通向量命中
```
模型需要知道**为什么**这条被召回 —— 这也是日后排查误召回的唯一线索。
### 结果
```
维度 casual 2/4 coexist 0/1 confusable 1/1 abstention 0/3
合计 3/9(纯向量基线 2/9)
```
★ 归因统计直接证明融合在起作用:
```
[casual] 本机端口 ✓ 13010/13011 而非 12011 [+符号 2: 精确2]
[confusable] 13010与13011 ✓ 13010/13011 而非 12011 [+符号 2: 精确2]
```
`13010/13011 而非 12011` 现在**排第一** ——
而纯向量下它**连 top2000 都进不去**。
### ★ 但收益范围必须说清楚
融合只救了**「查询里含精确数字串/版本串」**这一类:
| 探针 | 查询含精确串 | 融合是否有效 |
|---|---|---|
| 本机 13010 端口 | ✓ | ✅ 救回并置顶 |
| 13010 与 13011 | ✓ | ✅ 救回并置顶 |
| 脚本路径改到哪个目录 | ✗ | ❌ 无改善 |
| agentmail 公网访问地址 | ✗ | ❌ 无改善 |
| 本机服务监听哪些端口 | ✗ | ❌ 无改善(无串可提) |
★ **「脚本路径」「公网地址」这两个失败不是符号路能解决的** ——
查询里没有可提取的符号(`${HA_DATA}`、`101.201.37.155`
只存在于**答案**里,不在查询里)。这类「同义改写」问题
符号路天然无能,只能靠更强的向量。
⇒ **融合解决的是「精确串被稀释」,不是「语义不匹配」。** 3/9 里的 3 分
全部来自精确串类探针,一点也不来自语义类。
### abstention 仍是 0/3 —— 且融合帮不上
```
「grafana 监控面板的端口是多少」 库里没有 grafana,查询也无精确串
「谁负责数据库容灾演练」 同上
「kafka 的 broker 地址是什么」 同上(但 `kafka` 是拉丁词,能提出来)
```
★ 融合**不可能解决编造** —— 编造查询的共同特征正是
「**库里没有、查询里也没有对应符号**」。
符号路对这类查询天然给 0 分,与向量路同样无能为力。
⇒ **编造问题需要一个独立的第三种机制**(如「top1 分数 + 跨度」
的联合判据,或「库里是否存在该符号」的显式检查),
不是继续调融合能解决的。
## 三十三、方案 A 退场的决定性验证(2026-10-04)
从生产库重新 `.backup` 出干净快照(`fresh.db`),用**新代码**
(块→块路径,5a70452/0586f6b)跑完整迁移:
```
迁移前:entities=1294 relations=980 sentences=66 blocks=98 block_edges=97
迁移完成(100.8s)
句子 0,块 1294,边 959(跳过孤儿 0,去重边 21,无向量 0)
迁移后:块 2686,块边 2350
已重建中心向量(样本 1391,均值范数 0.8041,anomalous=true)
```
### 退场判据逐条核对
| 判据 | 结果 |
|---|---|
| `sentences` 表未被写入 | ✅ 迁移前后都是 **66** |
| contains 边为块→块 | ✅ **1294** 条 |
| 原句块已生成 | ✅ `source=sentence` **1294** 块 |
| 迁移块 | ✅ `source=legacy-entity` **1294** 块 |
| 去重边被显式报出 | ✅ `去重边 21`(dd2c996 的修复生效) |
| 中心自动重建 | ✅ 迁移后立即重建,无需人工 |
块总数 98 → 2686:98 图像块 + 1294 迁移块 + 1294 原句块。
### ★ 3 条遗留 sentence→块 边(非迁移产生)
```
source_id 1427 / 1428 / 1429 → blk_1789…
```
迁移前 `block_edges=97`,其中 3 条已是 sentence→块 形态 ——
是**生产库原有的历史数据**(旧 `sentences` 表的行号做端点),
迁移不处理它们(它只负责 entities/relations 的转换)。
⇒ 存量清理时这 3 条也要一并处理,否则 sentences 表删掉后它们会悬空。
### 方案 A 现状(最终)
| 项 | 状态 |
|---|---|
| `sentences` 写入点 | ✅ **清零**(迁移与蒸馏都不再写) |
| `entities` 写入点 | ✅ 只读 |
| `relations` 写入点 | ✅ 只读 |
| `memory_block_edges` | ✅ 唯一边表(3 条历史遗留待清理) |
| 原句可回溯性 | ✅ 由原句块承载(内容派生 ID,幂等) |
**剩余工作只有存量清理**,而它必须在生产完成蒸馏 + 召回验证 + 回滚演练之后。
## 三十四、生产蒸馏试跑:11% 可拆率是数据事实,不是代码问题
用新代码(块→块路径)在 `fresh.db` 上试跑 5 条:
```
待处理实体 5 条;现有块 2686(带向量 1391)
拆解结果:5 条实体 → 0 个字段块;零字段 5 条,报错 0 条
```
**零字段 5/5 —— 先核实是模型问题还是数据问题。**
### 抽样的 5 条实体名
```
#读取并处理-homeagent-来信
(136, 62, -122) 平原水岸
+08:00的加号在query中解码成空格,服务端RFC3339解析失败后静默退回默认窗口
+365天会算成2027-09-04,差11天
/api/sources、/api/keys、/api/status
```
**这些确实没有可拆的 (主语,维度,值)** —— 路径列表、坐标、单个日期。
零字段在这里是**正确结果**。
### 量化两个库的差异(决定产出上限)
```
生产 fresh 429 条实体,含明确可拆结构 47 条 (11%)
测试 ha-c 146 条实体,含明确可拆结构 121 条 (83%)
```
★ **差 7.5 倍。** ha-c 的产出(260 个拆分块、7/7 端到端)建立在
83% 可拆率上;生产库只有 11%。
⇒ 全量 429 条跑完,预期字段块约 **47 条左右**(不是 429 条,
也不是 ha-c 那样的几百条)。
### 这意味着什么
生产侧蒸馏的**收益远小于测试库**。而召回质量的第一限制因素
不是拆分算法(71% 字段级 F1 已达标),而是**数据里有没有结构可拆**。
⇒ 「生产迁移后召回能到 7/7」这个预期**不成立**。
7/7 是 83% 可拆率 + 448 块的产物;生产是 11% + 2686 块。
★ 这又一次印证「金标准必须覆盖输入分布」——
我在 ha-c 上验证的一切(融合、仲裁、拒答、拆分)都建立在
**输入分布严重不同**的前提上。
## 三十五、生产蒸馏产出为 0 —— 根因是已知的 Ollama bug
跑 60 条(副本 `dist60.db`):
```
[60/60] commit f91b27a,2026-09-03 … 用时837s
拆解结果:60 条实体 → 0 个字段块;零字段 60 条,报错 0 条
块总数 2686 → 2686
```
**我预估是「最多 57 个」(11% 可拆率 × 429 条)。实际是 0。**
⇒ 上一条的预期偏乐观了,而**偏差的根因不是数据质量,是工具链。**
### 直接复现
同一条输入连发三次,只换提示词:
```
任务A(拆三元组) 118s → "anter\n\n</think>\n\n要将句子「commit f91b27a...」拆成三元组,
首先要理解句子的结构和内容。\n\n### 分析句子:..."
任务B(判断疑问句) 15s → "no"
任务A(重复) 83s → "根据输入句子「commit f91b27a...」,可以将其拆分为三元组:
\n- **字段名**:commit \n- **字段值**:f91b27a ..."
```
★ 两个问题同时存在:
1. **`think=False` 没有生效** —— 输出里带着 `</think>`,
说明模型仍在输出推理链
2. **推理文本把 JSON 挤出窗口** —— `num_predict=300` 全被 `### 分析句子:`
这类前缀吃掉,JSON 部分根本没生成完
⇒ 抽取器收到的是**推理文本而非 JSON**,解析失败 ⇒ 零字段。
而 14 秒/条的耗时证明 LLM **确实被调用了**,不是没跑。
### 这是本会话第三次撞上同一个 bug
`docs/zh/triple-extract-attention.md` 里已记:
> ①「模型随机性(差 40%)」实际是 ollama schema 污染
> `label3.py` 使用逐个 `}` 枚举解析 JSON,避免贪婪匹配导致 `Extra data`
而**蒸馏命令没做同样的防御** —— 它复用了带 schema 的抽取器,
于是踩进同一个坑。
### 结论修正
| 我先说的 | 实际 |
|---|---|
| 「生产可拆率 11%,产出约 57 个」 | 产出 **0**,与可拆率无关,是 JSON 没生成出来 |
| 「全量 429 条约 111 分钟」 | **现在不该跑** —— 跑完也是 0 |
⇒ **先修 JSON 生成,再谈产出量。** 一个 60 条就能测的修复,
比 111 分钟的全量跑重要得多。
### 修复方向(三选一,未实测)
1. **关掉 think 真正生效** —— 查 ollama 0.31.1 的正确参数
(可能需要 `chat_template_kwargs: {"enable_thinking": false}`)
2. **加大 num_predict** —— 但 300 都不够说明推理链极长,
关掉 think 才是根治
3. **换模型** —— `qwen3-vl:2b` 之前测过批处理返回空;
`qwen3.8:27b-iq2m` 未测过单条 JSON
## 三十六、蒸馏产出 0 的根因:**Ollama schema 污染**(再次复现)
### 先排除两个错误假设
**假设 1「think 没关掉」—— 不成立。** 三种参数都试过:
```
think:false 66s think残留=False
think + enable_thinking:false 68s think残留=False
options.num_think:0 69s think残留=False
```
输出里都没有 `</think>`。⇒ `think` 已经关掉了,**推理链不是根因**。
**假设 2「schema 双重编码」—— 不成立,而且是我的验证脚本有 bug。**
```
HTTP 500 invalid format: "\\\"{\\\"type\\\": ... ← 我把 json.dumps 的结果当 format 值传
```
Go 侧的 `body.Format = json.RawMessage(s)` 是**正确**的(嵌入原样输出,
不会二次编码)。⇒ 生产代码没问题,**我的 Python 测法错了**。
★ 这是本会话第四次「先归因后核实」,而且这次我差点把 bug 归到生产代码上
—— 只因为「schema 看起来可疑」。
### 真正的根因:schema 污染(同一个 bug 第四次复现)
修正测试脚本(`format` 直接传对象,不 `dumps`)后:
```
任务A(拆三元组) 34s → {"fields":[{"name":"commit","value":"f91b27a"},
{"name":"date","value":"2026-09-03"},
{"name":"status","value":"已推送"}]}
任务B(判断疑问句) 28s → { "fields": [ { "name": "text",
"value": "commit f91b27a,2026-09-03 已推送" } ] }
任务A(重复) 17s → 与任务A第一次**逐字节相同**
```
★ 两个症状同时出现:
1. **任务 B 完全不同的任务,返回了同一 schema 结构** ——
问「是不是疑问句」却得到 `fields[{name,value}]`
2. **同输入逐字节相同** —— 不是随机,是**污染后的确定性输出**
⇒ 抽取器拿到的是 `{"fields":[{"name":"text","value":"整句话"}]}` 这种
**无意义的字段名 + 整句当值**,全部被「值 ≤12 字 + 值须原样出现 + 属性名对得上」
三道闸门拦掉 ⇒ **零字段**。
### 这个 bug 的性质
它不是「模型能力不足」,是 **ollama 0.31.1 的 grammar 缓存缺陷**:
schema 被上一次请求「粘」到下一次。表现随**调用顺序**而变:
```
先 schema 请求 → 污染后续所有请求
先无 schema 请求 → 输出较正常(记忆里 label3.py 的观察)
```
### 修复方向(按优先级)
| | 方案 | 成本 | 风险 |
|---|---|---|---|
| A | **每请求唯一 nonce**(在 prompt 里加随机串,字段名也随之变化) | 低 | JSON 可能截断 |
| B | **不用 schema**,改用 `<record>` 分隔的纯文本协议 + 宽松解析 | 低 | 解析容错要求高 |
| C | 升级/换掉 ollama | 高 | 影响其他任务 |
★ 记忆里 `label3.py` 用的是**方案 A 的变体**(nonce),
且已记录它的两个副作用(JSON 截断、`<record>` 碎片循环)。
蒸馏侧应该复用那套防御,而不是重新踩一遍。
### 一个必要的判据
蒸馏目前的产出统计是「零字段 N 条」——**它无法区分**:
```
真的没有可拆字段(数据事实)
LLM 返回了垃圾(工具链故障)
```
⇒ 必须先让 `homed-graph-distill` **打印前 3 条的原始 LLM 输出**。
代码里本来就有这个打印(`i < 8` 时打印字段),但因为零字段所以什么都不显示
—— **判据能显示 0,是因为它只看得到 0。**
## 三十七、★ 根因第三次修正:**不是 ollama,不是 schema,是无主语**
### 我连续三次猜错了
| 我的判断 | 验证方式 | 结果 |
|---|---|---|
| 「think 没关掉,推理链挤出 JSON」 | 三种参数实测(think:false / enable_thinking:false / num_think:0) | **都不成立**,输出里没有 `</think>` |
| 「schema 污染导致返回垃圾字段」 | 用**同样 schema + 同样 prompt** 直接调 ollama | **不成立**,返回 `{"fields":[{"name":"commit","value":"f91b27a"},…]}` 三个正确字段 |
| 「字段被三道闸门拦掉」 | 读代码 | **第三道之后还有第五道闸门** |
### 真正的根因
```go
// split.go:248
// 无主语时写不出 subject,直接跳过 —— 宁可少记也不写悬空属性。
if len(subjects) == 0 {
continue
}
```
`deriveSubjects(record)` 返回空 ⇒ **全部字段被丢弃**。
实测哪些实体没有主语:
```
commit f91b27a,2026-09-03 已推送 0 主语 → 字段全丢
127.0.0.1:12100(lan=false…) 0 主语 → 字段全丢
/api/sources、/api/keys、/api/status 0 主语 → 字段全丢
第114批周二凌晨1点·停机4分… 1 主语 → ✓
值班室分机号 4324,值班 老周 1 主语 → ✓
```
★ LLM **完全做对了**(3 个字段全对),却被 Go 侧的
「无主语不写悬空属性」规则丢掉。
⇒ 「零字段 60 条」的真相:**不是模型不行,也不是数据没得拆,
而是主语提取在无主语句式上失效**。
### 三个诊断缺口叠加,让这个根因被掩盖了三轮
```
① 命令只在 payload != nil 时打印 → 零字段时什么都不显示
② 报错与零字段混在一个统计里 → 看不出「谁在丢弃」
③ 没有主语计数的读数 → 字段凭空消失
```
⇒ 这就是「判据的观测面决定你能看见什么」的三个层次。
**修复第①层(无条件打印)之后,才看见「合法 JSON 但零字段」;
修复第②层之后,才有机会去查闸门;缺第③层至今。**
### 修复方向
| 方案 | 说明 |
|---|---|
| A | 无主语时用**属性型主语**(`62eba81` 已有 `DeriveSubjects` 的属性型受控别名) |
| B | 无主语时退化成**键值对块**(`<维度>=<值>`,无主语) |
| C | 放宽闸门,接受悬空属性 | ❌ 会污染图谱,与设计意图冲突 |
⇒ **A 与 B 都可行,且 B 更通用**(键值块不需要主语,
而 block-only 架构本来就允许块文本为 `<维度>=<值>`)。
但两者都要先有判据钉住「哪些句式被判为无主语」,
否则又是一轮「改了但不知道有没有变好」。
## 三十八、主语缺失率 99% —— 蒸馏方案在生产侧不成立
补上第③层判据(主语计数),在生产库 429 条实体上实测:
```
总计 429 条:有主语 5 条(1%),无主语 424 条(99%)
形态分布
命令/commit 2 条 无主语 100%
符号开头(符号学) 2 条 无主语 100%
路径/URL 38 条 无主语 100%
其他 387 条 无主语 98.7%
```
### 而 ha-c 的主力句式在生产库一条都没有
```
生产库含「第N批」 0 条
生产库含「分机」 0 条
生产库含「端口」 11 条
```
★ ha-c 的 7/7 完全建立在「第114批…」「值班室分机号 4324」这类
**有明确主语锚点**的句式上。生产库 99% 没有锚点。
### 三层结论串起来
```
① LLM 拆得对 3 个字段全对(实测)
② 第五道闸门丢弃 无主语 → 全部 continue
③ 99% 的生产实体无主语 ⇒ 放宽闸门也救不回多少
```
⇒ **放宽闸门(方案 B:键值块)最多救回 1% 的实体**。
因为 99% 的实体压根没有主语可写,键值块也写不出 `<主语>|<维度>`。
⇒ 生产侧的蒸馏路径**整体不成立**,不是"修一下 bug 就能跑"。
### 正确的方向
既然 99% 的生产实体是「无主语的通用事实」,那它们**本来就应该**
以整句块形态存在(`legacy-entity` 迁移块已经是这样),
而**不该**被强行拆成三元组。
⇒ 生产侧记忆的正确形态是:
整句块(可召回)+ 少量键值块(11 条「端口」类的那些)
⇒ 蒸馏命令应该**只对有主语的实体跑**,其余跳过并明确报告:
待处理 429 条 → 有主语 5 条,实际处理 5 条
### 这个发现的元价值
今天我在生产侧反复撞到的不是"bug",而是**假设错误**:
「生产库迁移后召回能到 7/7」 —— 错,7/7 是 83% 可拆率 + 主语锚点的产物
「可拆率 11%,产出约 57 个」 —— 错,实际是 0(第五道闸门)
「先修 bug 再跑全量 429 条」 —— 错,99% 无主语,跑完也是 0
⇒ **而每一个错误的起点,都是拿 ha-c 的结论直接套生产。**
判据、变体、实验都在 ha-c 上做,只把探针搬到了生产 ——
探针搬过去了,**输入分布的差异没被当成一等变量**。