docs(memory): 请求上界按条数维+字符维两维算准(review-m61 P2-1)

embedding.Embed 同时受条数界(8)与字符界(480 字)约束 ⇒ 一次 embedOnce(B 条)
会发 C 个请求(C 由正文长度决定,1..B),因此:

  正常批            C
  含一条坏正文      ≤ C + B(短正文 9;均长 75 汉字的现实批 10;病理上限 2B=16)
  服务整体挂死      首个 chunk 即失败 + 逐条 B ⇒ 1 + B = 9(与字符维无关)

修正 embed_isolation.go 文件头的上界表、approxRequests 的「两者一致」表述、
embedReuseStats.Sent 的注释,以及 embed_resilience_test.go 的上界注释与日志文案。
零行为改动:判据、实现、请求数都未动,测试全绿。
This commit is contained in:
AskaEth committed 2026-10-30 09:04:14 +08:00
1 parent 8f7017fc0b
commit 058fa141bf
3 files changed
+40 -17

No files matched your search

+6 -3
View File
@@ -51,7 +51,8 @@ type embedReuseStats struct {
Requested int
// Reused 是索引里已有向量、一次远端都没发的条目数。
Reused int
// Sent 是真正送进 embedder 的**条数**(≈ 请求数 × 批上限)。
// Sent 是真正送进 embedder 的**条数**;HTTP 请求数 ≥ ⌈Sent/批上限⌉ ——
// embedding 包还会按字符界(480 字)再切,长正文时请求数更多(见 approxRequests)。
Sent int
// Dropped 是逐条隔离重试后仍失败、本轮没有向量的条目数(缺口)。
Dropped int
@@ -62,8 +63,10 @@ type embedReuseStats struct {
}
// approxRequests 估算本轮发出的 HTTP 请求数:条数 ÷ 批上限(向上取整)。
// 它是**观测用的估算**(embedding 包内部还按同一个批上限切片,因此两者一致),
// 不是任何判据。
//
// 🔴 它是**按条数界算的下限估算**,不是精确值:embedding.Embed 还按字符界
// (DefaultBatchChars=480)切片,长正文批会被多切一刀 ⇒ 真实请求数 ≥ 本估算
// (短正文、≤480 字/批时两者相等)。它只进日志做观测,不是任何判据。
func (s embedReuseStats) approxRequests(batch int) int {
if s.Sent <= 0 {
return 0
+21 -9
View File
@@ -19,11 +19,11 @@ import (
//
// ## 为什么是「逐条」而不是递归二分
//
// 二分定位一条坏文本要 1+2+2+2 = 7 个请求(比逐条的 9 少 2 个),但它需要一套
// 失败预算的簿记(哪一层还允许拆、拆到多深收手),而那套簿记本身就是「韧性变成
// 放大」的风险点:预算给紧了会把好正文一起丢掉,给松了会在服务挂死时把请求数
// 放大成 2^深度。逐条隔离的代价**恒定且可算**:一批最多 1+B 个请求(B=8 ⇒ 9),
// 定位精度与二分相同(精确到单条),没有任何深度参数。
// 二分定位一条坏文本在短正文下要 1+2+2+2 = 7 个请求(比逐条的 9 少 2 个),但它
// 需要一套失败预算的簿记(哪一层还允许拆、拆到多深收手),而那套簿记本身就是
// 「韧性变成放大」的风险点:预算给紧了会把好正文一起丢掉,给松了会在服务挂死时
// 把请求数放大成 2^深度。逐条隔离的代价**恒定且可算**:`embedOnce` 调用 ≤ 1+B
// (B = 批上限),定位精度与二分相同(精确到单条),没有任何深度参数。
//
// ## 三条必须同时成立的约束
//
@@ -32,11 +32,23 @@ import (
// 只损失坏的那几条 —— 绝不把「算不出来」说成「没有相似」;
// 3. **预算**:父 ctx 已到期时不再逐条重试(预算花完了,重试只会把 worker 拖住)。
//
// 请求数的上界(B = 批上限,生产 = 8):
// ## HTTP 请求数的上界(必须按**两维**切片算,B = 批上限 = 8)
//
// 正常批 1
// 含一条坏正文 1 + B = 9(定位到它自己)
// 服务整体挂死 1 + B = 9(一次之后 embedMissing 立刻停手,不再发后续批)
// `embedding.Embed` 不是「一批一个请求」:它按 budget.go 的**条数界**
// (DefaultBatchLimit=8)**与字符界**(DefaultBatchChars=480)同时切片
// (chunkTexts),因此一次 `embedOnce(B 条)` 会发出 C 个请求,C 由正文长度决定:
// 全批 ≤480 字 ⇒ C=1;均长 75 汉字(budget.go 的实测口径)时 8×75=600 > 480 ⇒ C=2;
// 每条自己就超过 480 字 ⇒ 每条独占一片 ⇒ C=B(病理上限)。
// 单条重试**永不切分**(它自己就是一片)⇒ 每条恰好 1 个请求。于是:
//
// 正常批 C(短正文 C=1;长正文 2 起)
// 含一条坏正文 首次失败发生在第 1~C 个 chunk 之一,随后逐条 B 次
// ⇒ ≤ C + B(短正文 9;现实长正文 10;病理上限 2B=16)
// 服务整体挂死 首个 chunk 即失败 + 逐条 B 次 ⇒ **1 + B = 9**(与字符维无关)
//
// 口径与 budget.go 同源:那里的「建模上界 ≠ 延迟保证」是同一类提醒 —— 这里的
// 「≤1+B」是**embedOnce 调用次数**的上界,HTTP 请求数必须再按字符维的 chunk 数算
// (正常批 C、含坏正文 ≤C+B);只有「服务整体挂死 = 1+B」这一条与字符维无关。
// ErrEmbedUnavailable 在「一条向量都没拿到」时返回:调用方据此走既有的词项余弦
// 降级(记忆一条不丢、判定规则一字不改),而不是把「算不出来」当成「没有相似」。
@@ -8,10 +8,15 @@ import (
// 本文件是「单分片韧性」的断言(M61,2026-10-30;design/23 §15.3 已登记口径):
// 一条慢/坏分片**不丢整条**——同一批里其它条目的向量照常返回,只损失那一条;
// 而整体性故障(服务挂死)**不放大**:最多 3 个请求就收手。
// 而整体性故障(服务挂死)**不放大**:整批在第一个 chunk 即失败 + 逐条 B 次
// ⇒ 1+B 个请求就收手(B = 批上限 8)。
//
// ⚠️ 请求数口径是**两维**的(见 embed_isolation.go 文件头):本文件的正文都很短
// (8 条合计远小于 DefaultBatchChars=480)⇒ chunkTexts 只按条数界切、C=1,因此
// 「含一条坏正文 = 1+B」在这些用例里是精确值;长正文批的真实上界是 C+B。
// TestEmbedIsolation_OneBadTextKeepsTheRest 是韧性的主断言:批里有一条坏正文时,
// 其余 7 条照常拿到向量,坏的那条记为缺口,请求数被二分定位的成本封顶。
// 其余 7 条照常拿到向量,坏的那条记为缺口,请求数被逐条隔离的成本封顶。
//
// 删掉 embedIsolated(改回「一次 Embed 定生死」)这一条会打红:整批返回错误、
// 7 条好正文的向量一起丢掉。
@@ -58,16 +63,19 @@ func TestEmbedIsolation_OneBadTextKeepsTheRest(t *testing.T) {
if kept != len(texts)-1 {
t.Fatalf("保住的条数 = %d, want %d", kept, len(texts)-1)
}
// 逐条隔离的成本上界:整批 1 次 + 逐条重试 B 次(B = 批上限 8)⇒ 9。
// 逐条隔离的成本上界:整批 1 个 chunk(本例正文短 ⇒ C=1)+ 逐条重试 B 次
// ⇒ 1+B = 9。长正文批的真实上界是 C+B(C 由 480 字字符界决定,见文件头)。
if limit := 1 + tidyEmbedBatch; reqs > limit {
t.Fatalf("定位一条坏分片花了 %d 个请求,超过逐条上界 %d(韧性变成了放大)", reqs, limit)
}
t.Logf("一条坏分片:保住 %d/%d 条,代价 %d 个请求(上界 1+B = %d)", kept, len(texts), reqs, 1+tidyEmbedBatch)
t.Logf("一条坏分片:保住 %d/%d 条,代价 %d 个请求(短正文上界 1+B = %d)",
kept, len(texts), reqs, 1+tidyEmbedBatch)
}
// TestEmbedIsolation_OutageStopsFast 是**反放大**的断言:服务整体挂死时不许把
// 「用请求换韧性」变成请求风暴 —— 逐条重试一条都没拿到即判定整体性故障,当场
// 返回错误,且请求数被 1+B 封顶。
// 返回错误,且请求数被 1+B 封顶(整批在第一个 chunk 就失败 ⇒ 1 个请求 + 逐条 B;
// 这一条与字符维的 chunk 数无关,本例正文短、C 本来就是 1)。
func TestEmbedIsolation_OutageStopsFast(t *testing.T) {
t.Parallel()
ctx := context.Background()