## 背景 上一次文档校订(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 仍通过(纯文档改动)
31 KiB
生产召回验证:探针 PASS 但结论是假的
状态:实测报告(结论有效,日期见文内)。 记录了「探针通过≠能力成立」 这一类失败的具体形态,是判据设计的反面教材。 文末 3 条遗留 sentence→块 边仍开放,已索引到
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 个 图像块 —— 判据描述的状态在生产库上不存在。
生产侧的正确顺序(还没做)
homed-graph-migrate1294 entities → 带文本+向量的 blockhomed-graph-distill66 sentences → 拆分块- 然后本探针才有意义
生产库未被触碰(已核验)
生产: 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 不改变退出码。
今后所有探针必须有一条硬断言,例如:
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 |
⇒ 在把召回接回生产前,必须先解决两件事:
- 区分度:分数挤在 0.84~0.90,长尾实体名召不回
- 拒答能力: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)。
⇒ 不能据此加阈值上线。 需要的是:
- 扩充编造样本到 50+ 条(覆盖多种「看起来合理但库里没有」的形态)
- 且必须测量 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 ..."
★ 两个问题同时存在:
think=False没有生效 —— 输出里带着</think>, 说明模型仍在输出推理链- 推理文本把 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 分钟的全量跑重要得多。
修复方向(三选一,未实测)
- 关掉 think 真正生效 —— 查 ollama 0.31.1 的正确参数
(可能需要
chat_template_kwargs: {"enable_thinking": false}) - 加大 num_predict —— 但 300 都不够说明推理链极长, 关掉 think 才是根治
- 换模型 ——
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第一次**逐字节相同**
★ 两个症状同时出现:
- 任务 B 完全不同的任务,返回了同一 schema 结构 ——
问「是不是疑问句」却得到
fields[{name,value}] - 同输入逐字节相同 —— 不是随机,是污染后的确定性输出
⇒ 抽取器拿到的是 {"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"},…]} 三个正确字段 |
| 「字段被三道闸门拦掉」 | 读代码 | 第三道之后还有第五道闸门 |
真正的根因
// 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 上做,只把探针搬到了生产 —— 探针搬过去了,输入分布的差异没被当成一等变量。