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

+23 -9
View File
@@ -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
+6 -1
View File
@@ -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 连接就能吃光整条审查的预算,排在后面的
+102 -26
View File
@@ -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="<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 +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 {
+45 -6
View File
@@ -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 之内 —— "+