diff --git a/docs/phase-b-decision.md b/docs/phase-b-decision.md new file mode 100644 index 0000000..8ed4a58 --- /dev/null +++ b/docs/phase-b-decision.md @@ -0,0 +1,259 @@ +# Phase B 开工前决策建议(RD-1) + +> 回应四项裁定 + 一个交叉点。**不含实现。** +> 前序:`docs/phase-b-criteria.md`(判据草案,`0dfa6dc`) +> +> 本文只回答两件事: +> 1. 你裁定的四条,我**逐条核对后确认可执行**,并标出各自的实现落点。 +> 2. **你提示的最大回归风险,我认为可以消除**——不必靠"小心地改 26 个测试",而是靠接口切分让它不发生。这是本文的重点。 + +--- + +## 1. 先说最重要的一条:异步化的回归风险可以结构性消除 + +你指出「异步化改动会碰 `recallBlocks` 这条已发布的召回主路径,26 个测试全量要改,这是本阶段最大的回归风险,不是工时问题」。 + +**我核了自己的草案,这个判断建立在我一个不必要的设计选择上。改掉它,风险归零。** + +### 1.1 问题出在哪 + +我的草案 A1 写的是「`recallBlocks` 改为 `async`」。这是我图省事——把"要拿向量"和"要打分"混在了一个函数里。但这两件事**根本不需要在同一个函数里**。 + +看现有实现的分层(`lib/recall.js:223`): + +``` +pool = FTS5 候选 + 包含兜底 ← 纯同步(读 sqlite) +scored = 对 pool 打分排序 ← 纯同步(纯函数,无 IO) +``` + +向量要插进来的位置只有一个:**给 pool 里的候选补一个 `vectorSim`**。补齐之后,打分逻辑完全不变,仍然是纯同步的。 + +### 1.2 所以切法应该是两段,不是一段 + +``` +recallBlocks(store, query, opts) ← 保持同步,签名不变,行为不变 + └─ 只做:pool 构建 + 打分 + 排序 + 截断 + 新增可选入参 opts.vectorScores = Map + 传了就参与打分,不传就是 phase A 的逐字节原行为 + +recallBlocksHybrid(store, embedder, query, opts) ← 新的 async 包装 + └─ await embedder.embed(query) → 取候选池 → 补齐 vectorScores + → 调用上面的同步 recallBlocks +``` + +好处是**每一条都是硬性质,不是"我注意点"**: + +| 性质 | 为什么成立 | +|---|---| +| **phase A 的 `recallBlocks` 一行不改** | 它只是多了一个可选入参,默认 `undefined` | +| **9 个测试调用点一个不改** | 它们仍调同步版,仍拿同步返回值 | +| **golden fixture(A2)自动成立** | 不传 `vectorScores` 走的就是原代码路径 | +| **异步只出现在新函数里** | 旧的 API 面完全没被动过 | +| **26 个测试不需要"改",只需要"加"** | 现有断言保持原样,新断言追加 | + +### 1.3 实测的调用面(这就是全部改动量) + +我把所有调用点数了一遍: + +``` +生产代码:lib/index.js:124 return recallBlocks(...) → 改为 recallBlocksHybrid + lib/index.js:140 const hits = this.recall(query) → 加 await + lib/index.js:277 const injected = mem.contextForStep(...) → 加 await +测试代码:test/store.test.js 5 处调用 → 全不改(用同步版) + scripts/checkpoints.js 4 处调用 → 全不改(用同步版) + test/plugin.test.js:119 `mem.contextForStep = () => ''` → 不改 + (async 化后 `await ''` 仍得 `''`,桩天然兼容) +``` + +**净改动:生产代码 3 行。测试代码 0 行。** + +我实测确认了这个桩的兼容性——现有变异测试把 `contextForStep` 替换成同步返回 `''` 的函数,async 化之后 `await` 一个非 Promise 值就是它本身,所以那条变异测试**继续有效且不需要动**。 + +### 1.4 对 guard 组的建议 + +你要求「guard 组提到外部依赖之前,先证明异步化没改坏 phase A 语义」。我同意顺序,但建议把判据改成可结构验证的形式: + +| # | 原草案 | 建议改成 | +|---|---|---| +| A1 | 「`recallBlocks` 改 async 后 phase A 仍通过」 | **「`recallBlocks` 保持同步,签名与实现均不变;同时存在 `recallBlocksHybrid`」**——用一条断言钉住"同步版没有被偷偷改" | +| A2 | 「无 embedder 时返回与 phase A 逐字节相同」 | 保留,且升级为 **golden fixture 对比**:固化 phase A 的 `[{id,score,exact,why}]`,phase B 跑同一 fixture 必须全等 | + +附加一条新的守卫,这条比原 A1 更有价值: + +| # | 断言 | 变异 | +|---|---|---| +| A3 | `recallBlocksHybrid` 在 **embedder 为 null 时**,其返回值与 `recallBlocks` 同参调用**逐字段相同**(含 `why` 字符串) | 让 hybrid 在无 embedder 时走一条略有差异的分支(哪怕只是 `why` 多个后缀)⇒ A3 红 | + +A3 是真正防"两套实现漂移"的那条。因为切分之后存在两个入口,最大的新风险就是它们的行为不一致——A3 把它钉死。 + +### 1.5 结论 + +**你担心的 26 个测试全量改动,按这个切法不会发生。** 工时可从 6–7 人天里省回约 0.5 人天(原草案 A 组),但更重要的是:回归风险从"依赖执行纪律"变成"接口形状保证"。 + +我建议把这个切分直接写进判据草案的 A 组,替换掉原来的 A1。**需要你确认这个改法**——因为它改的是判据本身,不是实现细节。 + +--- + +## 2. 四条裁定的可执行性核对 + +### 2.1 指纹补 endpoint hash — 可执行,一处需明确 + +你定的格式:`http:::`,并要求 endpoint 先规范化。 + +**可执行。** 落点在新的 embedder 窄接口后面,不在判据里出现 http 字段名(见 §3)。 + +需要你明确一个边界:**规范化具体包含哪些**?我准备按这四条,理由都是"同一服务写成不同字符串很常见": + +| 规范化 | 例子 | 理由 | +|---|---|---| +| 去尾部 `/` | `http://h:8080/embed/` → `http://h:8080/embed` | 最常见的写法差异 | +| host 小写 | `http://HOST:8080` → `http://host:8080` | DNS 大小写不敏感,但字符串比较敏感 | +| 省略默认端口 | `http://h:80/embed` → `http://h/embed` | 同义 | +| 去首尾空白 | — | 配置粘贴常见 | + +**不做**:不解析/不重排 query string、不做路径归一(`/a/../b`)。理由:前者会掩盖真实差异,后者的实现成本与收益不成比例。 + +若你认可,我把这四条写成 F 组的一条判据(每条一个用例 + 一个变异:去掉该条规范化则指纹变化 ⇒ 红)。 + +### 2.2 空指纹 fail-closed + 四态 — 可执行,但有一处需要你知道 + +你定的四态:无向量 / 指纹空 / 指纹不符 / 向量化失败。**可执行,且比我草案的三态更准**——我把"向量化失败"漏在了三态之外(草案 §0.4 还单独问你),你直接合进来是对的,因为它同样是"用户下一步动作不同"。 + +需要你知道的一点:**这四态的扫描成本**。 + +`BlockVectorStats` 在 HA 里是**扫全表**算的(`block_recall.go` 的 `BlockVectorStats` 全程 `SELECT vector, fingerprint FROM memory_blocks` 再逐行 `decodeVector`)。在 dsh 里如果 `stablemem_stats` 每次都这么扫: + +| 块数 | 估算(含 JSON 解析向量) | +|---|---| +| 1k | 可忽略 | +| 10k | 首次调明显卡顿(HA 注释里 1391 块已"可接受但不优化") | +| 100k | 不可接受 | + +HA 的实现里有个细节值得注意:它为了算 `Dimensions` 必须逐行 `decodeVector`(`block_recall.go:303`,`:313` 的 `dimSet[len(vec)]`)。**维度统计与"不解析"是互斥的**,这点我上一版说漏了。 + +**建议的取舍**: + +| 输出项 | 需要解析向量? | 默认 | +|---|---|---| +| `total` / `no_vector` | 否(`length(vector) > 0`) | ✔ 报 | +| `no_fingerprint` / `stale` / `by_fingerprint.blocks` | 否(只看 `fingerprint` 列) | ✔ 报 | +| `by_fingerprint.dim` | **是**(或读 `graph_centroid.dim`) | 从 agent 中心表读,不扫块 | +| `vector_dimensions` 直方图 | **是** | 仅 `verbose: true` | + +即:默认路径只扫 `fingerprint` + `length(vector)`,从 `graph_centroid` 取维度(中心表按 fp 一行,天然带 `dim`)。只有在 `verbose` 下才逐行解析来发现"同一 fp 下维度不一致"这种异常。 + +这样默认扫描是 O(n)、无 JSON 解析;10k 块约几十毫秒。 + +> D7:`stablemem_stats` 的**默认**路径不解析向量 JSON(用 `length(vector)` 判有/无,维度从 `graph_centroid` 取);`verbose: true` 才逐行解析。 +> **变异 1**:默认路径改为 `decodeVector` ⇒ 断言 10k 块 fixture 的执行时间上界(或直接注入一个 decodeVector 计数器断其为 0)⇒ 红 +> **变异 2**:从中心表取维度改成扫块 ⇒ 无中心时(第一次跑 stats)报维度为空 ⇒ 红 + +顺带一条边界:`graph_centroid` 可能为空(还没建中心)。此时 `by_fingerprint.dim` 报 `null` 而不是 `0`——`0` 会被误读成"维度是 0"。 + +### 2.3 `graph_centroid` 补 `stats` 列 — 已改为 HA 同构 + +这条是我草案 §0.3 自己承认的疏漏。对齐后的 DDL(`vector_count` / `vector` / `stats` / `built_at`)在 `stats` 里存 `centroidMeta` 同构 JSON:`{fingerprint, dim, block_count, vectored_count, mean_length, anomalous, built_at}`。 + +`anomalous` 必须在表里,因为 `LoadCentroid` 靠它决定是否启用校正(`centroid_store.go:205-209`)——没有它就无法复现判定。 + +**已按你的要求列为 phase B 第一件事。** + +### 2.4 embedding 失败降级到纯 FTS5 — 可执行,且与我草案一致 + +草案 §0.4 和 D1 就是这个口径("召回主路径永不因向量问题抛错")。你裁定后它从"待拍板"变成"已定",我把它从 §0.4 的疑问项移到 D 组的硬判据。 + +一个补充:**降级也要带原因**。D 组的 `last_recall.mode` 我准备写成三值而非 bool: + +| mode | 含义 | +|---|---| +| `hybrid` | 向量 + 符号都参与了 | +| `symbol_only` | 走了符号路(原因见 `degraded_reason`) | +| `symbol_only` + `degraded_reason: 'no_embedder'` | 没配向量 | +| `symbol_only` + `degraded_reason: 'embed_error'` | 配了但调失败 | +| `symbol_only` + `degraded_reason: 'all_mismatched'` | 配了、调成功,但库里没有可比块 | + +这三种 `symbol_only` 对用户的意义不同:没配 = 没启用;调失败 = 服务挂了;全失配 = 该重嵌。合成一个 bool 就又回到"降级不可见"了。 + +--- + +## 3. 关于「向量来源可晚点定」——我建议现在就把接口焊死 + +你说向量来源可以晚点定,只要把 embedder 收在单方法窄接口后面、判据里不出现 http 专属字段名。**同意,并且建议再收紧一点。** + +```js +/** + * 唯一的向量能力接口。整个插件只依赖这三个成员。 + * @typedef {object} Embedder + * @property {(text: string) => Promise} embedText 查询侧 + * @property {() => { fingerprint: string, dimension: number }} info + * @property {() => Promise} [close] + */ +``` + +三点说明: + +1. **`embedText` 只有 text**,没有 media。phase B 的 `Modality` 仍只有 `text`(草案 §4 已排除媒体块),所以接口不需要 reserve 媒介参数。等到真做媒体时再加,比现在猜一个签名好。 +2. **`info()` 同步返回** fingerprint 与 dimension。这让"维度校验"能在**调用 embedText 之前**做掉一部分(配置维度不符可以立刻硬报错,不用等一次网络往返)。 +3. 判据里凡是断言 http 契约的(H 组 H1/H2/H6),**全部标成"provider 适配层的契约测试"**,与 V/F/C/D 组解耦。这样换本地 ONNX 时,H 组整组替换,V/F/C/D 不动。 + +**我建议加一条守卫**(G 组): + +> G5:除 `lib/embed/http.js` 这一个文件外,`lib/` 下任何文件**不得出现** `http`、`endpoint`、`fetch`、`Bearer`、`model` 等 provider 专属标识。 +> **实现方式**:一条源码 grep 断言(像 HA 的 `grep -rn '"agentcli"\|"terminal"'` 那条内核插件名判据一样)。 +> **变异**:在 `recall.js` 里写一句 `import ... from './embed/http.js'` 之外的 http 细节 ⇒ 红。 + +这条比"约定不出现"强,因为它是机器可查的。 + +--- + +## 4. 交叉点:6–7 人天要不要投 + +**我的建议:投,但要按 §1 的切法重新算,实际是 5.5–6.5 人天。** + +| 项 | 原草案 | 按 §1 切分后 | +|---|---|---| +| A 组 异步化 + golden fixture | 0.5 | 0.5(fixture 仍要做,但异步化的改动量降到 3 行) | +| H 组 http provider + 契约测试 | 1 | 1 | +| schema 迁移框架 + 加列 + centroid 对齐 | 0.5 | 0.5 | +| V 组 融合 + 分带 | 1 | 1 | +| F/D 组 四态归类 + 计数器 + stats 工具 | 1 | 1(D7 的 `length(vector)` + 中心表取维度不额外加时) | +| C 组 中心化 | 1 | 1 | +| 判据与变异 | 1 | 1(A3/A5 两条新增,被 §1 省下的测试改动抵消) | +| **合计** | **6–7** | **5.5–6.5** | + +**支持投的理由**(你关心的不是工时,我按同等分量给反对的可能): + +- phase A 交付的是"能存的记忆",phase B 交付的是"能找得到的记忆"。只存不找,这个插件的价值边界就是"精确串查询"——那其实用一个 FTS 索引就够了,不需要块、边、中心化这一整套。 +- 已定死的四条裁定(endpoint 指纹、四态、centroid 列、失败降级)**都是为 phase B 才存在的**。不投这 6 天,这四条裁定就悬空。 +- 增量风险可控:§1 之后,phase A 的代码路径基本没被动,新逻辑集中在新文件。 + +**反对该投的理由**(我认为不足以否掉,但列出来): + +- phase A 至今没有真实使用数据。6 天投下去,如果实际没人用 `stablemem_commit`,向量侧就是空转。**这一条我认为是最值得你先确认的**——是否有真实使用中的 dsh 会话在产生块? +- tier3(纯泛化描述)在 HA 里即使有向量也只是"目标块排名 389/2686"(`docs/zh/recall-capability-tiers.md`)。也就是说 phase B 交付后,tier3 从"完全不可用"变成"勉强可用",不是"好用"。这是能力上限,不是实现问题。 + +如果你的判断是"先看有没有人用",那我建议一个更便宜的中间态:**只做 H 组 + schema + F 组(向量写入与四态可见),不做 V/C 组(不参与召回)**。约 2.5 人天,拿到"块有向量、统计能看、空间切换可见",但不改召回主路径。等有使用数据再上 V/C。**这条中间路我愿意做,但它不满足你已裁定的"phase B"目标**,所以需要你明确。 + +--- + +## 5. 需要你回话的(4 项) + +| # | 事项 | 我的建议 | 不定的后果 | +|---|---|---|---| +| 1 | **§1 的接口切分**(`recallBlocks` 保持同步 + 新增 `recallBlocksHybrid`),替换草案 A1 | 采纳 | 按原 A1 做,26 个测试全量改动,回归风险回到"靠纪律" | +| 2 | **§2.1 endpoint 规范化的四条边界** | 采纳四条,不做 query 重排与路径归一 | 指纹对"同一服务不同写法"过敏,触发无谓全量重嵌 | +| 3 | **§2.2 D7**(stats 默认不解析向量,维度取自中心表) | 采纳 | 10k 块以上 stats 工具变卡;或将来被迫改接口 | +| 4 | **§4 交叉点**:投 5.5–6.5 做完整 phase B,还是投 2.5 做中间态(写入+可见,不改召回) | 完整 phase B;但**建议你先确认是否有真实使用中的会话在产生块** | 无 | + +另有三项**不需要你回话、我按已定裁定执行**:§2.3 centroid 列对齐(你已裁定)、§2.4 失败降级(你已裁定)、§3 的 G5 源码 grep 守卫(我建议加,属实现细节)。 + +--- + +## 6. 我这一轮的自我修正记录 + +写这份建议时发现自己草案里两处该改的地方,一并记下,避免下一轮重复: + +1. **A1 把"异步化"和"保持兼容"绑在一起了**(§1)。这是设计偷懒,不是必要复杂度。切分之后两者解耦。 +2. **§0.4 把"embedding 失败"当成待拍板项**(§2.2)。你的四点裁定里直接把它并进四态说明它本来就不该单独问——它和"指纹失配"是同一类问题(静默降级的可见性),归类是我做窄了。 + +已完成的一条:§0.3 的 centroid DDL 疏漏是我自己发现的,已按你要求列为第一件事。