回应四条裁定与一个交叉点。不含实现。
核心结论:草案 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 记录本轮两处自我修正。
参考仓未改动。
16 KiB
Phase B 开工前决策建议(RD-1)
回应四项裁定 + 一个交叉点。不含实现。 前序:
docs/phase-b-criteria.md(判据草案,0dfa6dc)本文只回答两件事:
- 你裁定的四条,我逐条核对后确认可执行,并标出各自的实现落点。
- 你提示的最大回归风险,我认为可以消除——不必靠"小心地改 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 专属字段名。同意,并且建议再收紧一点。
/**
* 唯一的向量能力接口。整个插件只依赖这三个成员。
* @typedef {object} Embedder
* @property {(text: string) => Promise<number[]>} embedText 查询侧
* @property {() => { fingerprint: string, dimension: number }} info
* @property {() => Promise<void>} [close]
*/
三点说明:
embedText只有 text,没有 media。phase B 的Modality仍只有text(草案 §4 已排除媒体块),所以接口不需要 reserve 媒介参数。等到真做媒体时再加,比现在猜一个签名好。info()同步返回 fingerprint 与 dimension。这让"维度校验"能在调用 embedText 之前做掉一部分(配置维度不符可以立刻硬报错,不用等一次网络往返)。- 判据里凡是断言 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. 我这一轮的自我修正记录
写这份建议时发现自己草案里两处该改的地方,一并记下,避免下一轮重复:
- A1 把"异步化"和"保持兼容"绑在一起了(§1)。这是设计偷懒,不是必要复杂度。切分之后两者解耦。
- §0.4 把"embedding 失败"当成待拍板项(§2.2)。你的四点裁定里直接把它并进四态说明它本来就不该单独问——它和"指纹失配"是同一类问题(静默降级的可见性),归类是我做窄了。
已完成的一条:§0.3 的 centroid DDL 疏漏是我自己发现的,已按你要求列为第一件事。