From f3fb097630971bc685709aac314acf71031fd4db Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 21:59:31 +0800 Subject: [PATCH 1/2] =?UTF-8?q?perf(embedding,memory):=20=E6=89=B9?= =?UTF-8?q?=E4=B8=8A=E9=99=90=204=E2=86=928=20=E5=89=8A=E6=8E=89=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=94=BE=E5=A4=A7=EF=BC=8C=E5=AE=A1=E6=9F=A5=E9=A2=84?= =?UTF-8?q?=E7=AE=97=2020s=E2=86=9290s=20=E8=A6=86=E7=9B=96=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E9=93=BE=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测(2026-10-13,本机空闲,活库 cyrene/user 分区 201 条、均长 34.7 汉字): - 一条入库审查召回链 = 51 个「4 条」请求 = 23.070 / 21.860 s;旧审查预算 20s 比链长还短 ⇒ 每一条候选满额的审查都超时,且超时后 **一行留痕都不写** (7 天里 92.1% 的审查 recall_mode 为空、2274 条超时降级的根因) - 同一条链改成「8 条」= 26 个请求 = 21.435 / 21.597 s:请求数减半,墙钟几乎不变 —— 链路耗时由「条数 × 每条成本」主导,批次只改请求数 - 「16 条」在**真实代码路径**上 3 次里 2 次让单请求越过 5s 单次超时(1.818 / 5.016 超时 / 5.001 超时)⇒ 批上限不越过 8;隔离测量里 12 条 0.996~1.017 s 仍在界内 改动: - embedding: perItemBudget 500ms→300ms(实测摊销 70~107ms/条的约 3 倍) ⇒ DefaultBatchLimit 由 (5s/2−100ms)/300ms = **8**,同一套推演、两维都成立 (条数 100ms+8×300ms=2.5s;字符 100ms+480×5ms=2.5s,均在超时的一半内) - embedding: 新增 maxSafeBatchLimit=12(实测外沿)+ 编译期断言(越界即 uint 溢出、 编译失败),与 tidy_config.go 那条同手法 - memory: defaultReviewTimeout 20s→**90s**(> 实测最坏链:空闲 21.6s⇒4.2 倍余量、 与整理链并发 56.7s + T1 8s ⇒ 1.4 倍;不再大的理由=单 worker 有界队列 256 的 最坏积压 256×90s≈6.4h、服务真挂死时第一片 5s 就降级用不到这条预算) 测试(不留过时断言):budget_test.go 加「钉值 8/480 + 两维不等式 + ≤12 外沿 + 16 条必须仍判越界」四层;api_url_test.go 的 reviewBudgetRef 20s→90s; preinsert_recall_test.go 新增跨包断言「预算 > 实测最坏链 + defaultT1Timeout」。 变异自证:perItemBudget 回 500ms ⇒ 钉值断言红;reviewTimeout 回 20s ⇒ 跨包断言红; perItemBudget 150ms(⇒16)⇒ 编译失败(constant -4 overflows uint)。三者均已逐字还原。 遗留(如实登记,未修):① 整理轮每轮重嵌整个分区(user 一轮仍 250 个请求), 解法方向是复用已入库的 vec_items 或给整理轮一个 embedding 请求预算;② 链路对单点 慢分片没有韧性 —— 一个分片失败即整条 Embed 失败、丢掉其余分片的工作量,且实测 任何批次形状都会偶发 >5s 尖峰(4 条 7.139s / 8 条 9.711s,与批次无关),方向是 重试/分片级容错,调常量解决不了。 --- internal/runtime/embedding/api.go | 32 +++-- internal/runtime/embedding/api_url_test.go | 7 +- internal/runtime/embedding/budget.go | 128 ++++++++++++++---- internal/runtime/embedding/budget_test.go | 51 ++++++- internal/runtime/memory/preinsert_config.go | 34 ++++- internal/runtime/memory/preinsert_recall.go | 12 ++ .../runtime/memory/preinsert_recall_test.go | 53 +++++++- internal/runtime/memory/tidy_config.go | 16 ++- 8 files changed, 280 insertions(+), 53 deletions(-) diff --git a/internal/runtime/embedding/api.go b/internal/runtime/embedding/api.go index 9986e7b..e4a95a6 100644 --- a/internal/runtime/embedding/api.go +++ b/internal/runtime/embedding/api.go @@ -42,16 +42,24 @@ const ( // budget.go 里**同一个不等式**推出 —— 单次请求的耗时上界不得超过超时的一半。 // 改这一行就等于改了批次上限,反之亦然;两侧永远是配套的。 // -// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 20s -// (memory.defaultReviewTimeout),embedding 只是它内部的第一步,后面还有 T1 灰区 -// 判定(8s)。一次卡死的 HTTP 不能把整条审查的 20s 都赌掉。真慢就**快速失败**, -// 调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 degraded), -// **绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则。 +// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 90s +// (memory.defaultReviewTimeout),而**一条召回链是几十个请求**(201 条 ÷ 8 条/片 +// = 26 个,见 budget.go 文件头的链路实测)—— 一次卡死的 HTTP 不能把整条审查的预算 +// 都赌掉:把它压在 5s,最坏也只是这一片失败,调用方按**既有语义**降级(审查降级到 +// 词项余弦并如实留痕 degraded),**绝不静默丢记忆**。本常量只砍等待时间,不改任何 +// 判定规则。 // -// 5s 保持不变的依据:本次故障(2026-10-13)的成因不是这个数太小,而是批次太大 —— -// 64 条的实测耗时 7.5~8.5s 已经越过它,客户端放弃、服务端还在算,把整台本机 -// embedding 服务顶住,连带单条请求(写向量索引)也一起超时。所以修法是**把批次 -// 收进预算内**,而不是把预算放大到迁就批次(后者会让真挂死的请求也拖住整轮)。 +// 5s 保持不变的依据:本次故障(2026-10-13)的成因**不是**这个数太小(链条是 +// 「N 条 → ⌈N/批次⌉ 个请求」,链长超的是**调用方**预算),因此修法是把链条收短 +// (budget.go: 批上限 4 → 8)并**单独**抬高调用方预算(memory.defaultReviewTimeout +// 20s → 90s),而不是把单次超时放大到迁就链条 —— 后者会让真挂死的请求也一起 +// 拖住整轮审查。 +// +// ⚠️ 已知残余(见 budget.go 文件头 ④):空闲窗口实测单请求最大 1.782s,但**任何** +// 批次形状都偶发越过本超时的尖峰(实测 4 条 7.139s、8 条 9.711s —— 明确与批次无关)。 +// 越过它的后果被设计成**有界的**:那一次调用失败 ⇒ 调用方按既有语义降级 +// (召回回落词项余弦 + 留痕 degraded),**不是**整轮丢失。真正要治的是链路对单点 +// 慢分片的韧性(重试/分片级容错),不在这里。 // // 冷启动另有 warmupTimeout(见 budget.go),但它**只放宽本进程的第一个请求**、 // 且一次性花掉:失败不会让它续期,因此「服务一直挂死」这件事**不会**变成 @@ -161,6 +169,12 @@ func (e *APIEmbedder) Dim() int { // **自己切片**,分多次请求顺序发出,因此「一次请求的耗时 < 单次超时」这条不变式 // 对任何调用方都成立 —— 调用方给多大的切片都不会把服务端顶住。中途任一段失败即 // 整体失败(返回已发请求的错误),绝不半途改用混合表示。 +// +// ⚠️ 代价在调用方那一侧:**一次 Embed(N) 是 ⌈N/条数上限⌉ 个请求**(改前 201 条 = +// 51 个;改后 8 条/片 = 26 个),整条链的墙钟实测 ~21s(见 budget.go 文件头)。 +// 因此调用方的**总**预算必须按链长算,而不是按一次请求算 —— +// memory.defaultReviewTimeout = 90s 就是这条口径(20s 的旧值比链长还短, +// 让每一条候选数满额的审查都必然超时,且失败时不留痕)。 func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) { if len(texts) == 0 { return []Vector{}, nil diff --git a/internal/runtime/embedding/api_url_test.go b/internal/runtime/embedding/api_url_test.go index c7b8234..2ebc7c1 100644 --- a/internal/runtime/embedding/api_url_test.go +++ b/internal/runtime/embedding/api_url_test.go @@ -216,7 +216,12 @@ func TestAPIEmbedder_LoopbackAliasReachesIPv4OnlyServer(t *testing.T) { // 权威定义在 internal/runtime/memory/preinsert_config.go 的 defaultReviewTimeout。 // 跨包的真实断言在 memory 包(preinsert_recall_test.go)里,这里钉住同一个口径, // 免得 embedding 侧悄悄把超时调到 ≥ 审查预算。 -const reviewBudgetRef = 20 * time.Second +// +// 2026-10-13 由 20s 改为 90s:审查的召回链是 201 条 ⇒ ⌈201/8⌉ = 26 个 embedding +// 请求,整条链实测 21.4~21.6s(空闲)—— **20s 的旧值比链长还短**,于是每一条候选 +// 满额的审查都必然超时(这是「92% 的审查 recall_mode 为空」的根因)。本文件钉住的 +// 是同一个数,不是另一个数:embedding 侧任何一次调用的超时都必须严格小于它。 +const reviewBudgetRef = 90 * time.Second // TestDefaultRequestTimeout 钉住单次 embedding 调用的超时值,并断言它**严格小于** // 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的 diff --git a/internal/runtime/embedding/budget.go b/internal/runtime/embedding/budget.go index 815ffc8..4be2048 100644 --- a/internal/runtime/embedding/budget.go +++ b/internal/runtime/embedding/budget.go @@ -20,28 +20,72 @@ import "time" // 时刻**,而且只发生一次(见 warmupTimeout 的说明)。批形状与超时仍然同源: // 无论第一个请求的截止多宽,它带的条数/字符数永远按稳态超时算出来。 // -// ## 实测依据(2026-10-13,本机 = AMD RX 6600 XT + llama.cpp b11371 + -// Qwen3-Embedding-0.6B Q8_0,端点 127.0.0.1:11434,**服务器空闲**,正文取自活库 -// data/cyrene.db:短正文约 33 汉字、长正文约 168 汉字;每档 5~6 次取最坏) +// ## 实测依据(2026-10-13,本机 = AMD RX 6600 XT + llama.cpp(PID 318144,起于 +// 10-07,**排除冷启动**)+ Qwen3-Embedding-0.6B Q8_0,端点 127.0.0.1:11434, +// **服务器空闲**,正文取自活库 data/cyrene.db 的 cyrene/user 分区:本次样本 +// 201 条、均长 34.7 汉字、最长 75 汉字) // -// n=1 短 0.066~0.185s 长 0.740s -// n=8 短 0.86~1.11s -// n=12 短 1.27s 长 5.47s -// n=16 短 2.20~3.31s -// n=64 短 7.49~8.49s(改前的批次上限) +// ① 单次请求的**形状曲线**(隔离测量 = 一条请求发完算完,同档重复 2~3 次): // -// 拟合:单次请求耗时 ≈ 固定开销 + 字符总量 × 每字符成本;固定开销实测约 50ms, -// 每字符成本实测 2.7~3.9ms。**同一批 n≥32 起出现过数倍放大**(8.97s / 26.6s), -// 因此大批次的耗时不可预测 —— 这也是「余量取一半」而不是「卡着边界」的依据。 +// n=1 15 字 0.069 / 0.076 / 0.072 s +// n=4 78 字 0.243 / 0.224 / 0.221 s +// n=8 184 字 0.550 / 0.570 / 0.569 s +// n=12 331 字 0.996 / 1.017 s +// n=16 464 字 1.385 / 1.394 / 1.353 s +// n=32 991 字 2.884 s +// n=64 1997 字 6.872 s +// +// 拟合:耗时 ≈ 固定开销 + 条数 × 每条成本;固定开销实测约 50ms、每条成本 +// 70~110ms。64 条 6.9s **已越过单次超时 5s**(旧批次上限正落在这里)。 +// +// ② **真实链路的墙钟与请求数**:把一条入库审查的召回链(201 条 = 新条 1 + +// ScanLimit 200,preinsert_recall.go 的 embedReviewTexts)按批次形状整条发出: +// +// 形状 请求数 墙钟(s) 单请求均值(s) 单请求最大(s) +// 4 条/片(改前) 51 23.070 / 21.860 0.452 / 0.429 1.449 / 1.052 +// 8 条/片(本次) 26 21.435 / 21.597 0.824 / 0.831 1.782 / 1.732 +// 16 条/片 / 480 字 16 21.477 → 49.433 1.342 → 3.089 2.119 → **11.043** +// 16 条/片 / 不限字 13 21.012 / 21.904 1.616 / 1.685 2.313 / 2.582 +// +// 三条结论 —— 本次修复的判断全部落在这里: +// +// - **链路墙钟由「条数 × 每条成本」主导,几乎与批次形状无关**:同样 201 条, +// 4 条/片与 8 条/片的墙钟都是 ~21s。抬高批次上限买到的是**请求数减半** +// (51 → 26)与其带来的服务端排队压力下降,**不是**链路变快。要让链路落回 +// 调用方预算,只有把预算抬到链长之上(审查侧 defaultReviewTimeout)。 +// - **8 条/片不越界**:空闲窗口里三轮最大的单请求 1.782s,远在 5s 单次超时之内。 +// - **16 条/片会越界**:同一批正文、同一次会话里复现出单请求 **11.043s** +// (那一轮整条链 49.4s)。批上限取 8 而不是 12/16,依据是**实测到的最坏单请求 +// 本身就出界**,不需要归因。(隔离测量里 16 条三次稳定在 1.35~1.39s ⇒ 这个 +// 形态方差极大,不能只看隔离值。) +// +// ③ **真实代码路径**(不是直连端点:跑 APIEmbedder.Embed(201 条) 本身,用注入的 +// RoundTripper 数真实 HTTP 请求数): +// +// 8 条/片 26 个请求 / 201 个向量,墙钟 21.489s、28.220s(两轮,均未触发单请求超时) +// 16 条/片 直接 embedChunk:1.818s、**5.016s 超时**、**5.001s 超时**(3 次里 2 次越界) +// +// ④ **必须登记的残余(实测,不是推测)**:任何形状下都偶发「单请求越过 5s」, +// 与批次**无关** —— 交错对比 40 次 4 条 + 40 次 8 条(同一批正文): +// +// 4 条/片 p50 0.456s p90 1.008s 最大 **7.139s**(>5s 计 1/40) +// 8 条/片 p50 0.848s p90 1.861s 最大 **9.711s**(>5s 计 2/40) +// +// 该窗口机器上确有别的负载(`tasklist` 可见多个 go/link 进程 = 塔里其它 worker 在 +// 编译;同窗后端也在跑群聊回合)。归因**未定**,但两件事可以确定:① 4 条/片同样尖峰 +// (7.139s)⇒ 尖峰**不是**本次放大批次的后果;② 因为「一个分片失败 ⇒ 整条 Embed +// 失败」,一次尖峰就丢掉其余分片已算完的工作量 —— 链路对单点慢分片的**韧性**是遗留 +// 问题,**调常量解决不了**(修法是重试/分片级容错,不在本切片内)。 // // 复跑命令(读活库正文、只连本机 embedding 服务、不写任何库): // // python - <<'PY' // import json,sqlite3,time,urllib.request // URL="http://127.0.0.1:11434/v1/embeddings"; MODEL="" -// t=[r[0] for r in sqlite3.connect("data/cyrene.db").execute( -// "select content from memory_items order by id limit 64")] -// for b in (t[:1],t[:8],t[:12],t[:16],t[:64]): +// t=[r[0] for r in sqlite3.connect("file:data/cyrene.db?mode=ro",uri=True).execute( +// "select content from memory_items where status='active' and persona_id='cyrene'" +// " and scope='user' order by created_at desc, rowid desc limit 201")] +// for b in (t[:1],t[:4],t[:8],t[:16],t[:64]): // body=json.dumps({"model":MODEL,"input":b}).encode() // r=urllib.request.Request(URL,data=body,headers={"Content-Type":"application/json"}) // s=time.perf_counter(); urllib.request.urlopen(r,timeout=300).read() @@ -54,43 +98,75 @@ const ( // perCharBudget 是**每字符**的墙钟预算上界(实测 2.7~3.9 ms/字符,取 5ms)。 perCharBudget = 5 * time.Millisecond - // perItemBudget 是**每条文本**的墙钟预算上界:按活库里最长的正文(168 汉字, - // 最坏摊销 0.456 s/条)向上取整到 500ms。 - perItemBudget = 500 * time.Millisecond + // perItemBudget 是**每条文本**的墙钟预算上界:它守住「条数」那一维。 + // + // 2026-10-13 由 500ms 降到 300ms —— 旧值是把条数上限压到 **4** 的那个约束项, + // 而 4 条/片会把一次召回放大成 ⌈N/4⌉ 个 HTTP 请求(201 条的审查链 = 51 个请求, + // 见文件头)。依据不是「拍一个更小的数」,而是**实测摊薄**:整条链里每条的平均 + // 成本 70~103ms(8 条/片轮次 0.824s ÷ 8 ≈ 103ms;64 条档最坏 6.872s ÷ 64 ≈ + // 107ms),300ms 仍是实测的约 **3 倍**。按不变式反解,它把条数上限放到 8: + // (5s/2 − 100ms) / 300ms = 8 —— 恰好用满「超时的一半」,另一半=抖动余量。 + // + // 为什么不再大:条数上限 12 需要 perItemBudget ≤ 200ms(只剩实测的 1.9 倍), + // 16 需要 ≤ 150ms(1.4 倍)—— 而 16 条/片本次已复现出**单请求 11.043s** + // (> 单次超时 5s,见文件头)。批次越靠近那条界,单请求越可能「客户端先放弃、 + // 服务端还在算」(级联堆积的起点)。取 8:**请求数减半,单请求仍留 3 倍余量**。 + perItemBudget = 300 * time.Millisecond // callBudgetShare 表示一次请求最多用掉超时的 1/2:另一半是抖动余量 - // (见上面 n≥32 的数倍放大)。调大它 = 余量变小,必须连带复核实测。 + // (见文件头 16 条/片那轮的数倍放大)。调大它 = 余量变小,必须连带复核实测。 callBudgetShare = 2 // DefaultBatchLimit 是单次请求的**条数**上限,由上面三个常量推出来(唯一推演): - // (5s/2 − 100ms) / 500ms = 4。 + // (5s/2 − 100ms) / 300ms = 8。 DefaultBatchLimit = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perItemBudget) // DefaultBatchChars 是单次请求的**字符总量**上限,同一个不等式换成每字符预算: - // (5s/2 − 100ms) / 5ms = 480。它防的是「条数很少但每条很长」: - // 4 条 × 168 汉字 = 672 字符已越过它,会被再切一刀。 + // (5s/2 − 100ms) / 5ms = 480。它防的是「条数很少但每条很长」。 + // + // 本次**不动**它(每字符成本实测 2.7~3.9ms,5ms 仍是上界),因此两道界的分工 + // 是:8 条 × 均长 35 字(280 字)走**条数界**;8 条 × 长正文(如 168 字 = 1344 字) + // 被它再切一刀,落到 2 条/片。两维都必须成立,缺哪一维都会有请求越界。 DefaultBatchChars = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perCharBudget) + // maxSafeBatchLimit 是条数上限的**实测外沿**(12),它守的是与上面那条不等式 + // **不同**的一层:不等式管的是**建模成本**(requestOverhead + 批次 × perItemBudget + // ≤ 超时的一半),它管的是**实测形状** —— 文件头里 16 条/片复现出 11.043s 的单 + // 请求(> 5s 超时),8 条/片三轮最大 1.782s,隔离测量 12 条 0.996~1.017s 仍在界内。 + // 两层必须同时成立:把批次放到 12 需要**同时**把 perItemBudget 压到 ≤ 200ms + // (只剩实测摊销的 1.9 倍,且算式上 100ms + 12×200ms = 2.5s 已顶到边界); + // 越过 12 则已进入「实测已知会出界」的区间 —— 由下面那条编译期断言拦住。 + maxSafeBatchLimit = 12 + // warmupTimeout 是**一次性的**冷启动放宽:本 embedder 生命周期里的**第一个** // 请求可以拿到这个截止(模型加载 / GPU 缓冲分配还没发生过),此后**每一个** // 请求(含刚失败的那个之后的下一个)一律回到 DefaultRequestTimeout。 // // 🔴 它**不是**「只要还没成功过就一直放宽」—— 那个口径会在服务挂死时把 - // fail-fast 变成 fail-slow(每次都等 warmupTimeout),并把审查的 20s 预算、 + // fail-fast 变成 fail-slow(每次都等 warmupTimeout),并把审查单条预算、 // 索引写入的 5s 预算整段吃掉。一次性 + 8s 两条一起才是有界的: // - 一次性:放宽最多花掉**一个**请求的时长,最坏情形之后立刻回到 5s; - // - 8s:与常态 5s **同一量级**(1.6×),且严格小于审查单条总预算 20s - // (memory.defaultReviewTimeout)⇒ 一次冷启动不可能吃光整轮审查, + // - 8s:与常态 5s **同一量级**(1.6×),且严格小于审查单条总预算 + // (memory.defaultReviewTimeout = 90s)⇒ 一次冷启动不可能吃光整轮审查, // 后面的 T1 灰区判定(8s)仍有预算。这条不等式由测试守住 // (api_url_test.go 的 TestDefaultRequestTimeout)。 // // 依据:2026-10-13 部署后第一分钟的生产日志里有一条 // `vector embed for index failed ... embed 1 texts ... context deadline exceeded` - // —— 模型还没热时 5s 不够;而稳态实测单条只要 0.03~0.19s(见上面的曲线), - // 所以放宽只需要覆盖「进程的第一个请求」这一刻。 + // —— 模型还没热时 5s 不够;而本次实测稳态单条只要 0.069~0.076s(见文件头的 + // 形状曲线),所以放宽只需要覆盖「进程的第一个请求」这一刻。 warmupTimeout = 8 * time.Second ) +// 编译期不变式(2026-10-13):条数上限一旦越过**实测划出来的外沿** +// (maxSafeBatchLimit)—— 例如把 perItemBudget 压到 150ms(⇒ 16)、或把 +// callBudgetShare / requestOverhead 往「放宽」的方向调 —— 下面这行常量表达式 +// 会变成负数、uint 溢出 ⇒ **编译失败**。与 tidy_config.go 里那条「整理侧批上限 +// 不许超过 embedding 推出来的值」同一手法:把实测承诺交给编译器守,而不是只写在 +// 注释里。反方向的漂移(perItemBudget 调大 ⇒ 批上限变小 ⇒ 报错变多)由 +// budget_test.go 的钉值断言抓。 +const _ = uint(maxSafeBatchLimit - DefaultBatchLimit) + // callBudget 返回在 timeout 这个单次超时下,一次请求允许用掉的墙钟预算。 func callBudget(timeout time.Duration) time.Duration { if timeout <= 0 { diff --git a/internal/runtime/embedding/budget_test.go b/internal/runtime/embedding/budget_test.go index 415489e..b44e912 100644 --- a/internal/runtime/embedding/budget_test.go +++ b/internal/runtime/embedding/budget_test.go @@ -19,16 +19,21 @@ import ( // - 「批内耗时 < 超时」⇒ 不降级:DerivedBatchCompletesWithinTimeout; // - 「批内耗时 ≥ 超时」⇒ 超时(本次故障形态):LegacyBatchOf64TimesOut; // -// 以及**变异自证**:把批上限调回旧值 64(或把推演调松)会让哪条测试红, -// 见 TestBatchBudget_InvariantHolds 与 TestAPIEmbedder_LegacyBatchOf64TimesOut。 +// 以及**变异自证**:把批上限调回旧值(4 或 64)会让哪条测试红,见 +// TestBatchBudget_InvariantHolds(钉值 + 两层上界)与 +// TestAPIEmbedder_LegacyBatchOf64TimesOut。 // // 假客户端用的是**实测曲线**(不是随口编的常数):单次请求耗时 // ≈ measuredOverhead + 字符总量 × measuredPerChar,取值见下面两个常量 -// (2026-10-13 本机实测:固定开销 ~50ms、每字符 2.7~3.9ms,此处取上界)。 +// (2026-10-13 本机实测:固定开销 ~50ms、每字符 3.4ms,此处取上界)。 const ( measuredOverhead = 50 * time.Millisecond measuredPerChar = 4 * time.Millisecond - latencyModelChars = 33 // 活库短正文的平均长度(实测 33 汉字) + latencyModelChars = 33 // 活库短正文的平均长度(实测 33~35 汉字) + // measuredPathologicalItems 是**实测**已经出界的批次形状(文件头:单请求 + // 11.043s > 5s 超时)。它只用于说明「上界不该往哪边放」,不是断言对象 —— + // 断言对象是推出来的 DefaultBatchLimit 与它必须落在的 maxSafeBatchLimit 之内。 + measuredPathologicalItems = 16 ) // latencyTripper 是一个按上面那条曲线**真的睡够时间**的 RoundTripper: @@ -115,7 +120,18 @@ func shortTexts(n int) []string { // TestBatchBudget_InvariantHolds 是**推演本身**的断言(不连任何网络): // 由超时推出来的一对上限,必须使「一次请求的耗时上界 ≤ 超时的一半」成立。 // -// 变异自证:把 DefaultBatchLimit 的推演改成旧口径(条数写回 64)⇒ 第一条断言红; +// 四层,各自的语义不同(改坏任一层都必须是红的): +// +// 1. **钉值**:推出来的条数上限必须是 8、字符上限必须是 480。它是「常量变了必须 +// 连带复核链长/病态区」的那根 tripwire —— 例如把 perItemBudget 调回 500ms +// (⇒ 4)或 200ms(⇒ 12)都会让这一条红,而不是静默改掉整条链的请求数。 +// 2. **不变式(条数维)**:requestOverhead + 批次 × perItemBudget ≤ 超时的一半。 +// 3. **不变式(字符维)**:requestOverhead + 字符上限 × perCharBudget ≤ 超时的一半。 +// 4. **实测外沿**:条数上限必须 ≤ maxSafeBatchLimit(12),且**实测已知出界的形状** +// (16 条/片,单请求 11.043s)在推演下必须仍然判为越界 —— 即推演不许松到 +// 把 16 条当成一个批次。 +// +// 变异自证:把 DefaultBatchLimit 的推演改成旧口径(条数写回 64)⇒ 第 1 层红; // 若改成「把超时放大到盖住 64 条」⇒ 最后一条断言红(旧配对必须仍是越界的)。 func TestBatchBudget_InvariantHolds(t *testing.T) { t.Parallel() @@ -123,19 +139,42 @@ func TestBatchBudget_InvariantHolds(t *testing.T) { if DefaultBatchLimit < 1 || DefaultBatchChars < 1 { t.Fatalf("推出来的上限必须 ≥ 1:条数=%d 字符=%d", DefaultBatchLimit, DefaultBatchChars) } + // 1. 钉值:值变了就必须连带复核「审查链请求数」与「病态区上界」两件事。 + if DefaultBatchLimit != 8 { + t.Fatalf("DefaultBatchLimit = %d, want 8(值变了要连带复核审查链请求数 ⌈201/批次⌉ 与 maxSafeBatchLimit)", + DefaultBatchLimit) + } + if DefaultBatchChars != 480 { + t.Fatalf("DefaultBatchChars = %d, want 480(字符维本次刻意未动;改了要复核长正文分片)", + DefaultBatchChars) + } share := DefaultRequestTimeout / callBudgetShare + // 2. 条数维:批次上限 × 每条预算 + 固定开销 ≤ 超时的一半。 byItems := requestOverhead + time.Duration(DefaultBatchLimit)*perItemBudget if byItems > share { t.Fatalf("条数上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)", DefaultBatchLimit, byItems, share) } + // 3. 字符维:同一个不等式换成每字符预算。 byChars := requestOverhead + time.Duration(DefaultBatchChars)*perCharBudget if byChars > share { t.Fatalf("字符上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)", DefaultBatchChars, byChars, share) } + // 4. 实测外沿:条数上限不得越过实测划出来的那条线(12),而且**实测已出界的 + // 形状**(16 条/片)在推演下必须仍是越界的 —— 否则说明推演松到把已知的病态 + // 形状也当成合法批次了。 + if DefaultBatchLimit > maxSafeBatchLimit { + t.Fatalf("条数上限 %d 越过实测外沿 %d(16 条/片实测单请求 11.043s > 单次超时 %s)", + DefaultBatchLimit, maxSafeBatchLimit, DefaultRequestTimeout) + } + if pathological := requestOverhead + time.Duration(measuredPathologicalItems)*perItemBudget; pathological <= share { + t.Fatalf("%d 条/片的耗时上界 %s 竟落在超时的一半 %s 之内 —— "+ + "推演已经松到把这个实测出界的形状当成合法批次了", + measuredPathologicalItems, pathological, share) + } // 旧配对(批次 64 + 5s 超时)必须仍被判为越界 —— 那正是 2026-10-13 的故障形态: - // 实测 64 条 7.5~8.5s > 5s。这里用同一套预算常量复核它仍然出界。 + // 实测 64 条 6.872s > 5s。这里用同一套预算常量复核它仍然出界。 legacy := requestOverhead + 64*perItemBudget if legacy <= DefaultRequestTimeout { t.Fatalf("旧配对(64 条)的耗时上界 %s 竟然落在单次超时 %s 之内 —— "+ diff --git a/internal/runtime/memory/preinsert_config.go b/internal/runtime/memory/preinsert_config.go index 0a32e76..6484b17 100644 --- a/internal/runtime/memory/preinsert_config.go +++ b/internal/runtime/memory/preinsert_config.go @@ -19,7 +19,39 @@ const ( // defaultReviewScanLimit 是每次审查在同一分区内最多比对的既有条目数。 defaultReviewScanLimit = 200 // defaultReviewTimeout 是单条审查的墙钟上限。 - defaultReviewTimeout = 20 * time.Second + // + // 🔴 2026-10-13 由 20s 抬到 90s。依据是**实测的链长**,不是感觉: + // 单条审查的召回链是「新条 1 + ScanLimit 200 = 201 条文本」,而 embedding 侧 + // 一次请求只吃 DefaultBatchLimit 条(改前 4、改后 8,见 embedding/budget.go 的 + // 文件头实测)⇒ 链长 = ⌈201/批次⌉ 个请求: + // + // 改前(4 条/片)51 个请求:整条链 **23.070 / 21.860 s**(空闲机器,两轮实测) + // 改后(8 条/片)26 个请求:整条链 **21.435 / 21.597 s**(同上两轮) + // 另有一次**并发整理链**时测到的同一条链 = **56.7 s**(M14 只读勘测报告) + // + // ⇒ 旧值 20s **比链长还短**:即使机器空闲,一条候选数满额(200)的审查也跑不完 + // 它的 embedding,于是父 ctx 到期 ⇒ 整条审查失败、**一行留痕都不写**(7 天内 + // 92.1% 的审查 `recall_mode` 为空,其中超时降级 2274 条,正是这条链的后果)。 + // + // 取 90s 的推导:预算必须 > 链长 + 本层后面的 T1 灰区判定(defaultT1Timeout 8s)。 + // 空闲实测 21.6s ⇒ 4.2 倍余量;并发实测 56.7s + 8s = 64.7s ⇒ 仍有 1.4 倍余量。 + // + // 为什么**不再更大**(例如 180s / 300s): + // - 审查是**异步、单 worker、有界队列**(QueueSize 256,preinsert.go 的 + // `go r.worker`)。worker 的最坏占用 = 一条审查的预算 ⇒ 队列 256 条的最坏 + // 积压 = 256 × 90s ≈ **6.4 小时**(180s 就是 12.8 小时)。预算再大,等于把 + // 「worker 被一条慢审查占住」的时间线性放大。 + // - 90s 已经是 embedding 单次超时(5s)的 **18 倍**、T1 自身超时(8s)的 11 倍。 + // embedding 服务**真挂死**时这条预算根本用不到 —— 第一片请求在 5s 就失败, + // 召回立刻降级到词项余弦、审查照常跑完(这一点是结构性事实,见 + // preinsert_recall.go 的降级路径)⇒ 预算只对「慢但在推进」的链有意义。 + // 那种形态说明 embedding 服务本身退化了,该修的是服务,不是继续等。 + // - 审查超时后的失败形态目前**不写留痕**(preinsert.go 的 `ctx.Err()` 分支, + // 不属本次改动范围)⇒ 预算越长,「静默停摆」的窗口越长、面板越看不出来。 + // + // 🔴 与设计稿的差异(如实登记):design/23 §15 那一行写的是 `Timeout=20s`, + // 是本次改动**之前**的口径;本值按上面实测抬高,设计稿那一行待同步。 + defaultReviewTimeout = 90 * time.Second // reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**, // 不是任何角色(design §4.1 C 的同一理由)。 reviewAuditWho = "system/memoryreview" diff --git a/internal/runtime/memory/preinsert_recall.go b/internal/runtime/memory/preinsert_recall.go index 817fbcb..4d9c966 100644 --- a/internal/runtime/memory/preinsert_recall.go +++ b/internal/runtime/memory/preinsert_recall.go @@ -82,6 +82,18 @@ func reviewPairs(items []MemoryItem, threshold float64, sim func(i, j int) float // embedReviewTexts 分批把条目向量化(批次上限复用整理侧的 tidyEmbedBatch: // 不把整个分区的文本一次性交给远端 embedding API)。单批失败即整体失败, // 由调用方决定降级还是上报,绝不半途改用混合表示。 +// +// 🔴 链长就是**调用方预算的输入**(2026-10-13 实测,见 embedding/budget.go 文件头): +// 条数是「新条 1 + cfg.ScanLimit(默认 200)」= 201,因此一次召回发出的 HTTP 请求数 +// = ⌈201 / tidyEmbedBatch⌉(tidyEmbedBatch = embedding.DefaultBatchLimit): +// +// 批次 4(改前)= 51 个请求 ⇒ 整条链 23.070 / 21.860 s +// 批次 8(改后)= 26 个请求 ⇒ 整条链 21.435 / 21.597 s +// +// 两点必须一起读:① 请求数减半是本改动**唯一**买到的收益 —— 链路墙钟由 +// 「条数 × 每条成本」主导,两个批次形状都是 ~21s;② 所以单条审查的预算 +// (memory.defaultReviewTimeout)必须按**整条链**算,20s 的旧值比链长还短, +// 会让每一条候选满额的审查都超时(这正是「92% 的审查 recall_mode 为空」的根因)。 func embedReviewTexts(ctx context.Context, emb embedding.Embedder, items []MemoryItem) ([]embedding.Vector, error) { out := make([]embedding.Vector, 0, len(items)) for start := 0; start < len(items); start += tidyEmbedBatch { diff --git a/internal/runtime/memory/preinsert_recall_test.go b/internal/runtime/memory/preinsert_recall_test.go index 51750dc..79f19fd 100644 --- a/internal/runtime/memory/preinsert_recall_test.go +++ b/internal/runtime/memory/preinsert_recall_test.go @@ -2,18 +2,46 @@ package memory import ( "testing" + "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" ) -// TestEmbeddingTimeoutFitsReviewBudget 钉住一条**跨包**口径:embedding 单次调用的 -// 独立超时必须严格小于入库前审查的单条总预算(defaultReviewTimeout = 20s)。 +// measuredReviewChain* 是**实测参照值**(2026-10-13,本机空闲;正文取自活库 +// cyrene/user 分区 201 条,均长 34.7 汉字;完整曲线与复跑命令见 +// internal/runtime/embedding/budget.go 的文件头)。它们只用于本文件的断言 —— +// 与 embedding 侧 api_url_test.go 的 reviewBudgetRef 同一手法:把「拿实测数字做的 +// 决定」留成可复核的常量,而不是消失在注释里。 // -// 这不是风格问题:2026-10-07 实测的故障形态就是「端点主机写成 localhost → 先试 -// IPv6 ::1 → 连接等系统超时(Windows 上约 21s)」,一次 embedding 失败正好吃光 20s -// 预算,整条审查超时(`memory/review: context deadline exceeded`),排在后面的 T1 +// 链的形状 = ⌈(ScanLimit + 1) / embedding.DefaultBatchLimit⌉ 个请求: +// +// 改前 4 条/片:51 个请求 ⇒ 墙钟 23.070 / 21.860 s +// 改后 8 条/片:26 个请求 ⇒ 墙钟 21.435 / 21.597 s +// +// loaded 那一档取自 M14 只读勘测报告(同一条链与整理链并发时测得 56.7s)—— +// 它是按**改前**的 4 条/片形状测的;链路墙钟由「条数 × 每条成本」主导、与批次 +// 形状几乎无关,因此拿它当改后代码的最坏参照是**偏保守**的一侧。 +const ( + measuredReviewChainIdle = 22 * time.Second + measuredReviewChainLoaded = 57 * time.Second +) + +// TestEmbeddingTimeoutFitsReviewBudget 钉住两条**跨包**口径: +// +// 1. embedding 单次调用的独立超时必须严格小于入库前审查的单条总预算; +// 2. 反过来,审查的单条总预算必须严格大于「实测最坏召回链长 + T1 自己的超时」。 +// +// 第 1 条的理由(2026-10-07 实测的故障形态):端点主机写成 localhost → 先试 IPv6 +// ::1 → 连接等系统超时(Windows 上约 21s),一次 embedding 失败正好吃光预算, +// 整条审查超时(`memory/review: context deadline exceeded`),排在后面的 T1 // 灰区判定连执行机会都没有。地址规范化(embedding.normalizeEndpointURL)治的是根, -// 本断言守的是「就算再有新的卡死来源,也得先把预算留给后续步骤」。 +// 第 1 条守的是「就算再有新的卡死来源,也得先把预算留给后续步骤」。 +// +// 第 2 条是本次修复(2026-10-13)的核心断言,**变异自证**:把 defaultReviewTimeout +// 调回 20s ⇒ 这一条必红。它守的是「预算 ≥ 链长 + T1」这条不可让步的下界 —— +// 链长随 ScanLimit / 批上限漂移,T1 超时随 defaultT1Timeout 漂移,任一侧动到把 +// 预算吃掉都会被它抓到。旧值 20s 正是踩在这条线上的:实测链长 21~23s(空闲), +// 于是每一条候选满额的审查都必然超时,7 天里 2274 条审查因此降级为空 recall_mode。 // // embedding 侧同一口径也有一条钉值测试(api_url_test.go),两边互为护栏。 func TestEmbeddingTimeoutFitsReviewBudget(t *testing.T) { @@ -26,4 +54,17 @@ func TestEmbeddingTimeoutFitsReviewBudget(t *testing.T) { t.Fatalf("embedding 单次超时 %s 必须严格小于审查总预算 %s", embedding.DefaultRequestTimeout, defaultReviewTimeout) } + // 第 2 条:预算必须盖得住实测最坏链长,还要留得下 T1 那一步自己的超时 + // (T1 在召回**之后**跑,同一条 ctx)。空闲余量倍数由 budget.go 的注释列明, + // 这里只钉住「盖得住」这条下界。 + worst := measuredReviewChainLoaded + defaultT1Timeout + if defaultReviewTimeout <= worst { + t.Fatalf("审查单条预算 %s 必须严格大于「实测最坏召回链 %s + T1 超时 %s = %s」—— "+ + "否则链还没跑完预算就用光(2026-10-13 的 2274 条超时降级正是这条)", + defaultReviewTimeout, measuredReviewChainLoaded, defaultT1Timeout, worst) + } + if measuredReviewChainIdle >= defaultReviewTimeout { + t.Fatalf("空闲实测链长 %s 已不小于审查预算 %s(余量必须为正)", + measuredReviewChainIdle, defaultReviewTimeout) + } } diff --git a/internal/runtime/memory/tidy_config.go b/internal/runtime/memory/tidy_config.go index 59eee80..e23aa5c 100644 --- a/internal/runtime/memory/tidy_config.go +++ b/internal/runtime/memory/tidy_config.go @@ -78,10 +78,18 @@ const ( // // 🔴 它**不是**拍出来的数字,而是 internal/runtime/embedding 里**同一条推演** // 的结果(budget.go:一次请求的耗时上界 ≤ 单次超时的一半 ⇒ 批上限 = 超时的一半 - // ÷ 单条预算上界)。2026-10-13:旧值 64 的实测耗时 7.5~8.5s 已越过客户端 5s - // 单次超时 ⇒ 客户端放弃、服务端继续算 ⇒ 本机 embedding 服务被顶住,单条请求 - // (写向量索引)也跟着超时。把批上限与超时绑到同一条推演上,就不会再出现 - // 「只调一侧、另一侧静默失效」。 + // ÷ 单条预算上界)。2026-10-13 的实测沿革: + // - 旧值 64:单请求实测 6.872s(对比超时 5s)⇒ 客户端放弃、服务端继续算 ⇒ + // 本机 embedding 服务被顶住,单条请求(写向量索引)也跟着超时; + // - 先收到 4:单请求落回界内,但把**一次调用**放大成 ⌈N/4⌉ 个请求 —— + // user 分区一轮(CompareLimit 2000 条)就是 500 个请求、审查链 51 个请求; + // - 本次放到 **8**(= embedding.DefaultBatchLimit,`perItemBudget` 500ms→300ms): + // 同样的分区 500 → **250** 个请求、审查链 51 → **26** 个请求,而单请求实测最大 + // 1.782s,仍在 5s 之内(16 条/片已复现出 11.043s 的越界单请求,故不取更大)。 + // + // ⚠️ 但它**不缩短整理轮的墙钟**:链路耗时由「条数 × 每条成本」主导,批次只改请求 + // 数(实测 201 条在 4 条/片与 8 条/片下都是 ~21s)。「整理轮每轮把整个分区重新 + // 向量化」这个**放大源仍然存在**(user 分区一轮 250 个请求),本切片不动它。 // // 批上限在 embedder 内部**还有一道强制**(Embed 自己切片),因此这里即使被改大 // 也不会把服务端顶住;这一行只是让调用方别去构造注定被切开的超大切片。 From bdf3f3edaf5e9cad492a8c809619ef17bd0f1a26 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 22:12:11 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(embedding,memory):=20=E5=A4=8D?= =?UTF-8?q?=E6=A0=B8=20p2=20=E7=9A=84=E4=B8=89=E5=A4=84=E6=8E=AA=E8=BE=9E?= =?UTF-8?q?=E6=94=B9=E6=AD=A3=EF=BC=88=E7=BA=AF=E6=B3=A8=E9=87=8A=EF=BC=8C?= =?UTF-8?q?=E9=9B=B6=E8=A1=8C=E4=B8=BA=E6=94=B9=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review-m15 round1 判 p2-1items(fix-then-merge),三条都属「把证据能支撑什么写准」: 1. api.go 原写「最坏也只是这一片失败」与实现语义**相反** —— Embed 的实际语义是 「任一片失败 ⇒ 整条调用失败、其余分片已算完的工作量一并丢弃」。改为与该包 budget.go 里已经写对的那一句对齐,并点明调用方据此降级(记忆一条不丢、判定规则 一字不改,变的是这一条审查的召回质量)。 2. 「尖峰与批次无关」是**过度断言**:原证据只支持「至少存在一个与批次无关的外部/ 争用分量」(4 条/片也实测到 7.139s);越界的**频率**是否随批次上升,1/40 vs 2/40 的样本不足以判定 ⇒ 改标为「归因未定」,并明说批上限取 8 的依据始终是硬证据 (16 条/片实测越界 + 真实代码路径 3 次里 2 次越界),**不依赖**任何成因推断 (原文与同文件「16 条越界故不取更大」的理由自相矛盾)。同时补上独立复核窗口的 对照(review-m15:24 次单请求 0 尖峰)说明尖峰强依赖窗口负载。 3. budget.go 的不变式不再被读成延迟保证:明确定性为「**稳态、空闲机器的建模拟合**, 用途是构造批形状」,并点名真正的兜底(90s 审查预算按整条链算 + 任一片失败即降级 的路径 + 已登记的链路韧性遗留);结论句由「8 条/片不越界」改为「**空闲实测下**不 越界、负载下仍偶发越界」;perItemBudget 与 callBudgetShare 注释里「另一半/余量」 的同口径措辞一并改正(加一句:这是建模余量,不是单请求一定不越界的保证)。 另:preinsert_config.go 里「设计稿那一行待同步」已过期 —— 塔同日已把 design/23 §15 同步为 Timeout=90s,改为「已按实测同步」。 验证:go build ./... / go vet ./... 干净;go test -count=1 ./... 全绿; go test -race -count=1 ./internal/runtime/embedding/... ./internal/runtime/memory/... 全绿;diff 中无非注释行(纯文档改动)。 --- internal/runtime/embedding/api.go | 19 ++++---- internal/runtime/embedding/budget.go | 48 ++++++++++++++------- internal/runtime/memory/preinsert_config.go | 4 +- 3 files changed, 45 insertions(+), 26 deletions(-) diff --git a/internal/runtime/embedding/api.go b/internal/runtime/embedding/api.go index e4a95a6..b3f1559 100644 --- a/internal/runtime/embedding/api.go +++ b/internal/runtime/embedding/api.go @@ -45,9 +45,11 @@ const ( // 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 90s // (memory.defaultReviewTimeout),而**一条召回链是几十个请求**(201 条 ÷ 8 条/片 // = 26 个,见 budget.go 文件头的链路实测)—— 一次卡死的 HTTP 不能把整条审查的预算 -// 都赌掉:把它压在 5s,最坏也只是这一片失败,调用方按**既有语义**降级(审查降级到 -// 词项余弦并如实留痕 degraded),**绝不静默丢记忆**。本常量只砍等待时间,不改任何 -// 判定规则。 +// 都赌掉:把它压在 5s,最坏是**这一片超时**;而 `Embed` 的语义是**任一片失败 ⇒ +// 整条调用失败**(其余分片已经算完的工作量一并丢弃),调用方据此按**既有语义**降级 +// (召回降级到词项余弦并如实留痕 degraded)——**记忆一条不丢、判定规则一字不改**, +// 变的是这一条审查的召回质量。本常量只砍等待时间,不改任何判定规则。 +// (同一口径见 budget.go 文件头 ④ 末段。) // // 5s 保持不变的依据:本次故障(2026-10-13)的成因**不是**这个数太小(链条是 // 「N 条 → ⌈N/批次⌉ 个请求」,链长超的是**调用方**预算),因此修法是把链条收短 @@ -55,11 +57,12 @@ const ( // 20s → 90s),而不是把单次超时放大到迁就链条 —— 后者会让真挂死的请求也一起 // 拖住整轮审查。 // -// ⚠️ 已知残余(见 budget.go 文件头 ④):空闲窗口实测单请求最大 1.782s,但**任何** -// 批次形状都偶发越过本超时的尖峰(实测 4 条 7.139s、8 条 9.711s —— 明确与批次无关)。 -// 越过它的后果被设计成**有界的**:那一次调用失败 ⇒ 调用方按既有语义降级 -// (召回回落词项余弦 + 留痕 degraded),**不是**整轮丢失。真正要治的是链路对单点 -// 慢分片的韧性(重试/分片级容错),不在这里。 +// ⚠️ 已知残余(见 budget.go 文件头 ④):空闲窗口实测单请求最大 1.782s,但尖峰会越过 +// 本超时 —— **至少存在一个与批次无关的外部/争用分量**(实测 4 条/片也能到 7.139s); +// 越界的**频率**是否随批次上升,本组样本(1/40 vs 2/40)不足以判定,**归因未定**。 +// 后果:那一片超时 ⇒ **整条 `Embed` 失败**、其余分片已算完的工作量丢弃 ⇒ 调用方按 +// 既有语义降级(召回回落词项余弦 + 留痕 degraded)—— 记忆不丢、判定规则不改。 +// 真正要治的是链路对单点慢分片的韧性(重试/分片级容错),不在这里。 // // 冷启动另有 warmupTimeout(见 budget.go),但它**只放宽本进程的第一个请求**、 // 且一次性花掉:失败不会让它续期,因此「服务一直挂死」这件事**不会**变成 diff --git a/internal/runtime/embedding/budget.go b/internal/runtime/embedding/budget.go index 4be2048..d00a79c 100644 --- a/internal/runtime/embedding/budget.go +++ b/internal/runtime/embedding/budget.go @@ -12,8 +12,14 @@ import "time" // // requestOverhead + 一次请求的字符总量 × perCharBudget ≤ DefaultRequestTimeout / callBudgetShare // -// 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半(另一半是这台机器的 -// 抖动余量)。批次上限由解这个不等式得到(见 DefaultBatchLimit / DefaultBatchChars)。 +// 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半。⚠️ 这条不等式的**地位** +// 必须读准:它是**稳态、空闲机器上的建模拟合**,用途是**构造批形状**(批次上限由解它 +// 得到,见 DefaultBatchLimit / DefaultBatchChars),**不是**「单次请求 < 超时」的延迟 +// 保证 —— 文件头 ④ 的实测尖峰(4 条 7.139s / 8 条 9.711s)整段越过 5s,2.5s 的 +// 「另一半」装不下它。真正的兜底是三件东西、缺一不可:**调用方预算** +// (memory.defaultReviewTimeout = 90s,按整条链而不是单片算)、**降级路径** +// (任一片失败 ⇒ 调用方回落词项余弦 + 留痕 degraded,记忆不丢、规则不改),以及已登记 +// 的**链路韧性遗留**(一片失败丢整条 ⇒ 修法是重试/分片级容错,不在这里)。 // // ⚠️ 这条不等式**只按稳态超时(DefaultRequestTimeout)推导,且只约束批次形状**; // 冷启动那次放宽(warmupTimeout)**不改批形状**,只放宽进程第一个请求的**截止 @@ -53,11 +59,12 @@ import "time" // 4 条/片与 8 条/片的墙钟都是 ~21s。抬高批次上限买到的是**请求数减半** // (51 → 26)与其带来的服务端排队压力下降,**不是**链路变快。要让链路落回 // 调用方预算,只有把预算抬到链长之上(审查侧 defaultReviewTimeout)。 -// - **8 条/片不越界**:空闲窗口里三轮最大的单请求 1.782s,远在 5s 单次超时之内。 +// - **8 条/片在空闲实测下不越界**:三轮最大单请求 1.782s,远在 5s 单次超时之内。 +// ⚠️ 但这**不是**延迟保证 —— 负载下仍偶发越界(见 ④),尖峰与批次的关系未定。 // - **16 条/片会越界**:同一批正文、同一次会话里复现出单请求 **11.043s** -// (那一轮整条链 49.4s)。批上限取 8 而不是 12/16,依据是**实测到的最坏单请求 -// 本身就出界**,不需要归因。(隔离测量里 16 条三次稳定在 1.35~1.39s ⇒ 这个 -// 形态方差极大,不能只看隔离值。) +// (那一轮整条链 49.4s);③ 里真实代码路径 3 次有 2 次越过 5s。批上限取 8 而不是 +// 12/16,依据就是这两条**实测**(隔离测量里 16 条三次稳定在 1.35~1.39s ⇒ 这个形态 +// 方差极大,不能只看隔离值),**不依赖**任何关于尖峰成因的推断。 // // ③ **真实代码路径**(不是直连端点:跑 APIEmbedder.Embed(201 条) 本身,用注入的 // RoundTripper 数真实 HTTP 请求数): @@ -65,17 +72,23 @@ import "time" // 8 条/片 26 个请求 / 201 个向量,墙钟 21.489s、28.220s(两轮,均未触发单请求超时) // 16 条/片 直接 embedChunk:1.818s、**5.016s 超时**、**5.001s 超时**(3 次里 2 次越界) // -// ④ **必须登记的残余(实测,不是推测)**:任何形状下都偶发「单请求越过 5s」, -// 与批次**无关** —— 交错对比 40 次 4 条 + 40 次 8 条(同一批正文): +// ④ **必须登记的残余(实测,不是推测)**:尖峰会越过 5s 单次超时 —— 交错对比 +// 40 次 4 条 + 40 次 8 条(同一批正文): // // 4 条/片 p50 0.456s p90 1.008s 最大 **7.139s**(>5s 计 1/40) // 8 条/片 p50 0.848s p90 1.861s 最大 **9.711s**(>5s 计 2/40) // // 该窗口机器上确有别的负载(`tasklist` 可见多个 go/link 进程 = 塔里其它 worker 在 -// 编译;同窗后端也在跑群聊回合)。归因**未定**,但两件事可以确定:① 4 条/片同样尖峰 -// (7.139s)⇒ 尖峰**不是**本次放大批次的后果;② 因为「一个分片失败 ⇒ 整条 Embed -// 失败」,一次尖峰就丢掉其余分片已算完的工作量 —— 链路对单点慢分片的**韧性**是遗留 -// 问题,**调常量解决不了**(修法是重试/分片级容错,不在本切片内)。 +// 编译;同窗后端也在跑群聊回合);同一夜 review-m15 的独立窗口(24 次单请求:n=4 +// max 0.236s、n=8 max 1.097s)**一次尖峰都没有** ⇒ 尖峰强依赖窗口负载。因此这里只 +// 断言证据支持得住的那一层:**至少存在一个与批次无关的外部/争用分量**(4 条/片也能 +// 到 7.139s ⇒ 尖峰**不是**本次放大批次的后果);**越界的频率是否随批次上升,1/40 与 +// 2/40 的样本不足以判定,归因未定**(方向反而更像「批次越大越容易越界」,但那需要带 +// 在飞计数的观测才能定论)。 +// +// 另一件确定的事:因为「一个分片失败 ⇒ 整条 Embed 失败」,一次尖峰就丢掉其余分片 +// 已算完的工作量 —— 链路对单点慢分片的**韧性**是遗留问题,**调常量解决不了** +//(修法是重试/分片级容错,不在本切片内)。 // // 复跑命令(读活库正文、只连本机 embedding 服务、不写任何库): // @@ -105,16 +118,19 @@ const ( // 见文件头)。依据不是「拍一个更小的数」,而是**实测摊薄**:整条链里每条的平均 // 成本 70~103ms(8 条/片轮次 0.824s ÷ 8 ≈ 103ms;64 条档最坏 6.872s ÷ 64 ≈ // 107ms),300ms 仍是实测的约 **3 倍**。按不变式反解,它把条数上限放到 8: - // (5s/2 − 100ms) / 300ms = 8 —— 恰好用满「超时的一半」,另一半=抖动余量。 + // (5s/2 − 100ms) / 300ms = 8 —— 恰好用满「超时的一半」(另一半按建模拟合留给现场 + // 抖动;它是**建模口径**、不是延迟保证,见文件头的不变式一节)。 // // 为什么不再大:条数上限 12 需要 perItemBudget ≤ 200ms(只剩实测的 1.9 倍), // 16 需要 ≤ 150ms(1.4 倍)—— 而 16 条/片本次已复现出**单请求 11.043s** // (> 单次超时 5s,见文件头)。批次越靠近那条界,单请求越可能「客户端先放弃、 - // 服务端还在算」(级联堆积的起点)。取 8:**请求数减半,单请求仍留 3 倍余量**。 + // 服务端还在算」(级联堆积的起点)。取 8:**请求数减半,且建模上界对实测摊销仍留 + // 约 3 倍**(这是建模余量,不是单请求一定不越界的保证 —— 见文件头 ④)。 perItemBudget = 300 * time.Millisecond - // callBudgetShare 表示一次请求最多用掉超时的 1/2:另一半是抖动余量 - // (见文件头 16 条/片那轮的数倍放大)。调大它 = 余量变小,必须连带复核实测。 + // callBudgetShare 表示一次请求最多用掉超时的 1/2。它是**建模口径**(一半给建模拟合、 + // 一半留给现场抖动),**不是**延迟保证 —— 文件头 ④ 的尖峰整段越过单次超时。调大它 + // = 建模上界更紧,必须连带复核实测。 callBudgetShare = 2 // DefaultBatchLimit 是单次请求的**条数**上限,由上面三个常量推出来(唯一推演): diff --git a/internal/runtime/memory/preinsert_config.go b/internal/runtime/memory/preinsert_config.go index 6484b17..e1b23ce 100644 --- a/internal/runtime/memory/preinsert_config.go +++ b/internal/runtime/memory/preinsert_config.go @@ -49,8 +49,8 @@ const ( // - 审查超时后的失败形态目前**不写留痕**(preinsert.go 的 `ctx.Err()` 分支, // 不属本次改动范围)⇒ 预算越长,「静默停摆」的窗口越长、面板越看不出来。 // - // 🔴 与设计稿的差异(如实登记):design/23 §15 那一行写的是 `Timeout=20s`, - // 是本次改动**之前**的口径;本值按上面实测抬高,设计稿那一行待同步。 + // 🔴 与设计稿的对齐(如实登记):design/23 §15 那一行原写 `Timeout=20s`(本次改动 + // **之前**的口径),已按上面实测同步为 `Timeout=90s`。 defaultReviewTimeout = 90 * time.Second // reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**, // 不是任何角色(design §4.1 C 的同一理由)。