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
@@ -42,16 +42,27 @@ const (
|
||||
// budget.go 里**同一个不等式**推出 —— 单次请求的耗时上界不得超过超时的一半。
|
||||
// 改这一行就等于改了批次上限,反之亦然;两侧永远是配套的。
|
||||
//
|
||||
// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 20s
|
||||
// (memory.defaultReviewTimeout),embedding 只是它内部的第一步,后面还有 T1 灰区
|
||||
// 判定(8s)。一次卡死的 HTTP 不能把整条审查的 20s 都赌掉。真慢就**快速失败**,
|
||||
// 调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 degraded),
|
||||
// **绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则。
|
||||
// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 90s
|
||||
// (memory.defaultReviewTimeout),而**一条召回链是几十个请求**(201 条 ÷ 8 条/片
|
||||
// = 26 个,见 budget.go 文件头的链路实测)—— 一次卡死的 HTTP 不能把整条审查的预算
|
||||
// 都赌掉:把它压在 5s,最坏是**这一片超时**;而 `Embed` 的语义是**任一片失败 ⇒
|
||||
// 整条调用失败**(其余分片已经算完的工作量一并丢弃),调用方据此按**既有语义**降级
|
||||
// (召回降级到词项余弦并如实留痕 degraded)——**记忆一条不丢、判定规则一字不改**,
|
||||
// 变的是这一条审查的召回质量。本常量只砍等待时间,不改任何判定规则。
|
||||
// (同一口径见 budget.go 文件头 ④ 末段。)
|
||||
//
|
||||
// 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);
|
||||
// 越界的**频率**是否随批次上升,本组样本(1/40 vs 2/40)不足以判定,**归因未定**。
|
||||
// 后果:那一片超时 ⇒ **整条 `Embed` 失败**、其余分片已算完的工作量丢弃 ⇒ 调用方按
|
||||
// 既有语义降级(召回回落词项余弦 + 留痕 degraded)—— 记忆不丢、判定规则不改。
|
||||
// 真正要治的是链路对单点慢分片的韧性(重试/分片级容错),不在这里。
|
||||
//
|
||||
// 冷启动另有 warmupTimeout(见 budget.go),但它**只放宽本进程的第一个请求**、
|
||||
// 且一次性花掉:失败不会让它续期,因此「服务一直挂死」这件事**不会**变成
|
||||
@@ -161,6 +172,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
|
||||
|
||||
@@ -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 连接就能吃光整条审查的预算,排在后面的
|
||||
|
||||
@@ -12,36 +12,93 @@ 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)**不改批形状**,只放宽进程第一个请求的**截止
|
||||
// 时刻**,而且只发生一次(见 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);③ 里真实代码路径 3 次有 2 次越过 5s。批上限取 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 在
|
||||
// 编译;同窗后端也在跑群聊回合);同一夜 review-m15 的独立窗口(24 次单请求:n=4
|
||||
// max 0.236s、n=8 max 1.097s)**一次尖峰都没有** ⇒ 尖峰强依赖窗口负载。因此这里只
|
||||
// 断言证据支持得住的那一层:**至少存在一个与批次无关的外部/争用分量**(4 条/片也能
|
||||
// 到 7.139s ⇒ 尖峰**不是**本次放大批次的后果);**越界的频率是否随批次上升,1/40 与
|
||||
// 2/40 的样本不足以判定,归因未定**(方向反而更像「批次越大越容易越界」,但那需要带
|
||||
// 在飞计数的观测才能定论)。
|
||||
//
|
||||
// 另一件确定的事:因为「一个分片失败 ⇒ 整条 Embed 失败」,一次尖峰就丢掉其余分片
|
||||
// 已算完的工作量 —— 链路对单点慢分片的**韧性**是遗留问题,**调常量解决不了**
|
||||
//(修法是重试/分片级容错,不在本切片内)。
|
||||
//
|
||||
// 复跑命令(读活库正文、只连本机 embedding 服务、不写任何库):
|
||||
//
|
||||
// python - <<'PY'
|
||||
// import json,sqlite3,time,urllib.request
|
||||
// URL="http://127.0.0.1:11434/v1/embeddings"; MODEL="<model id>"
|
||||
// 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 +111,78 @@ 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 的数倍放大)。调大它 = 余量变小,必须连带复核实测。
|
||||
// callBudgetShare 表示一次请求最多用掉超时的 1/2。它是**建模口径**(一半给建模拟合、
|
||||
// 一半留给现场抖动),**不是**延迟保证 —— 文件头 ④ 的尖峰整段越过单次超时。调大它
|
||||
// = 建模上界更紧,必须连带复核实测。
|
||||
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 {
|
||||
|
||||
@@ -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 之内 —— "+
|
||||
|
||||
@@ -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