Merge branch 'feat/embudget-fix-embedding-request-amplifica' into dev
This commit is contained in:
commit
202cd8535e
8 files changed
+302
-56
No files matched your search
@@ -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`(本次改动
|
||||
// **之前**的口径),已按上面实测同步为 `Timeout=90s`。
|
||||
defaultReviewTimeout = 90 * time.Second
|
||||
// reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**,
|
||||
// 不是任何角色(design §4.1 C 的同一理由)。
|
||||
reviewAuditWho = "system/memoryreview"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 自己切片),因此这里即使被改大
|
||||
// 也不会把服务端顶住;这一行只是让调用方别去构造注定被切开的超大切片。
|
||||
|
||||
Reference in new issue
Block a user