Files
HologramAgent-DSH-Memory-Port/docs/phase-b-decision.md
jianf eac9f0fca6 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 记录本轮两处自我修正。

参考仓未改动。
2026-10-30 09:52:06 +08:00

16 KiB
Raw Permalink Blame History

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 专属字段名。同意,并且建议再收紧一点。

/**
 * 唯一的向量能力接口。整个插件只依赖这三个成员。
 * @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 疏漏是我自己发现的,已按你要求列为第一件事。