## 二十五、生产规模验证:**探针 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\n\n要将句子「commit f91b27a...」拆成三元组, 首先要理解句子的结构和内容。\n\n### 分析句子:..." 任务B(判断疑问句) 15s → "no" 任务A(重复) 83s → "根据输入句子「commit f91b27a...」,可以将其拆分为三元组: \n- **字段名**:commit \n- **字段值**:f91b27a ..." ``` ★ 两个问题同时存在: 1. **`think=False` 没有生效** —— 输出里带着 ``, 说明模型仍在输出推理链 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` 已经关掉了,**推理链不是根因**。 **假设 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**,改用 `` 分隔的纯文本协议 + 宽松解析 | 低 | 解析容错要求高 | | C | 升级/换掉 ollama | 高 | 影响其他任务 | ★ 记忆里 `label3.py` 用的是**方案 A 的变体**(nonce), 且已记录它的两个副作用(JSON 截断、`` 碎片循环)。 蒸馏侧应该复用那套防御,而不是重新踩一遍。 ### 一个必要的判据 蒸馏目前的产出统计是「零字段 N 条」——**它无法区分**: ``` 真的没有可拆字段(数据事实) LLM 返回了垃圾(工具链故障) ``` ⇒ 必须先让 `homed-graph-distill` **打印前 3 条的原始 LLM 输出**。 代码里本来就有这个打印(`i < 8` 时打印字段),但因为零字段所以什么都不显示 —— **判据能显示 0,是因为它只看得到 0。** ## 三十七、★ 根因第三次修正:**不是 ollama,不是 schema,是无主语** ### 我连续三次猜错了 | 我的判断 | 验证方式 | 结果 | |---|---|---| | 「think 没关掉,推理链挤出 JSON」 | 三种参数实测(think:false / enable_thinking:false / num_think:0) | **都不成立**,输出里没有 `` | | 「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 上做,只把探针搬到了生产 —— 探针搬过去了,**输入分布的差异没被当成一等变量**。