From 058fa141bf8a58eb03bcca3465e8431177c2f557 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 30 Oct 2026 09:04:14 +0800 Subject: [PATCH] =?UTF-8?q?docs(memory):=20=E8=AF=B7=E6=B1=82=E4=B8=8A?= =?UTF-8?q?=E7=95=8C=E6=8C=89=E6=9D=A1=E6=95=B0=E7=BB=B4+=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E7=BB=B4=E4=B8=A4=E7=BB=B4=E7=AE=97=E5=87=86=EF=BC=88?= =?UTF-8?q?review-m61=20P2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 的上界注释与日志文案。 零行为改动:判据、实现、请求数都未动,测试全绿。 --- internal/runtime/memory/embed_incremental.go | 9 ++++-- internal/runtime/memory/embed_isolation.go | 30 +++++++++++++------ .../runtime/memory/embed_resilience_test.go | 18 +++++++---- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/internal/runtime/memory/embed_incremental.go b/internal/runtime/memory/embed_incremental.go index 403345c..e4c09c9 100644 --- a/internal/runtime/memory/embed_incremental.go +++ b/internal/runtime/memory/embed_incremental.go @@ -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 diff --git a/internal/runtime/memory/embed_isolation.go b/internal/runtime/memory/embed_isolation.go index 70283cc..8c388e8 100644 --- a/internal/runtime/memory/embed_isolation.go +++ b/internal/runtime/memory/embed_isolation.go @@ -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 在「一条向量都没拿到」时返回:调用方据此走既有的词项余弦 // 降级(记忆一条不丢、判定规则一字不改),而不是把「算不出来」当成「没有相似」。 diff --git a/internal/runtime/memory/embed_resilience_test.go b/internal/runtime/memory/embed_resilience_test.go index 93f490b..5e534d1 100644 --- a/internal/runtime/memory/embed_resilience_test.go +++ b/internal/runtime/memory/embed_resilience_test.go @@ -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()