perf(embedding,memory): 批上限 4→8 削掉请求放大,审查预算 20s→90s 覆盖实测链长

实测(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,与批次无关),方向是
重试/分片级容错,调常量解决不了。
This commit is contained in:
AskaEth committed 2026-10-13 21:59:31 +08:00
1 parent 20051f0168
commit f3fb097630
8 files changed
+280 -53

No files matched your search

@@ -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)
}
}