docs: phase B 开工前决策建议 —— 异步化回归风险可结构性消除

回应四条裁定与一个交叉点。不含实现。

核心结论:草案 A1「recallBlocks 改 async」是我图省事的设计偷懒,
把「要拿向量」和「要打分」混在一个函数里。切分后风险归零:

  recallBlocks(store, query, opts)              保持同步,签名行为不变
    新增可选 opts.vectorScores = Map<blockId, number>
  recallBlocksHybrid(store, embedder, q, opts)  新的 async 包装

实测调用面:生产代码 3 行(lib/index.js:124/140/277),
测试代码 0 行(test/store.test.js 5 处 + scripts/checkpoints.js 4 处
全部继续调同步版)。已实测确认 test/plugin.test.js:119 那个
`contextForStep = () => ''` 的同步桩在 async 化后仍兼容(await 非
Promise 值即其本身),所以那条变异测试继续有效。

建议把 guard 组 A1 改成「recallBlocks 保持同步且实现不变」,
并新增 A3:hybrid 在 embedder 为 null 时返回值与同步版逐字段相同
(含 why 字符串)——切分后最大的新风险是两个入口漂移,A3 钉死它。

四条裁定核对
- §2.1 endpoint 规范化:建议固定四条(去尾斜杠 / host 小写 /
  省略默认端口 / 去空白),不做 query 重排与路径归一。
- §2.2 四态:可执行,我草案的三态漏了「向量化失败」,裁定并进来更准。
  新增 D7:stats 默认不解析向量 JSON(用 length(vector)),维度从
  graph_centroid 取。修正一处我原稿的说漏——HA 为算 Dimensions 必须
  逐行 decodeVector(block_recall.go:303/313),所以「维度统计」与
  「不解析」互斥,只能靠中心表绕开。
- §2.3 centroid 补 stats 列:已列为第一件事。
- §2.4 失败降级:同意,并建议 mode 三值而非 bool
  (no_embedder / embed_error / all_mismatched 对用户意义不同)。

§3 建议把 embedder 焊成单方法窄接口(embedText / info / close),
加 G5 源码 grep 守卫:除 lib/embed/http.js 外不得出现 http/endpoint/
fetch/Bearer 等 provider 专属标识——机器可查,比约定强。

§4 交叉点:建议投完整 phase B,按 §1 切分后 5.5-6.5 人天。
但建议你先确认一件事:是否有真实使用中的 dsh 会话在产生块?
若无,可先投 2.5 人天的中间态(向量写入 + 四态可见,不改召回主路径)。

§6 记录本轮两处自我修正。

参考仓未改动。
This commit is contained in:
2026-10-30 09:52:06 +08:00
parent 0dfa6dcb27
commit eac9f0fca6

259
docs/phase-b-decision.md Normal file
View File

@ -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<blockId, number>
传了就参与打分,不传就是 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:<model>:<dim>:<sha256(endpoint)[:8]>`,并要求 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<number[]>} embedText 查询侧
* @property {() => { fingerprint: string, dimension: number }} info
* @property {() => Promise<void>} [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 疏漏是我自己发现的,已按你要求列为第一件事。