Files
AskaEth f3fb097630 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,与批次无关),方向是
重试/分片级容错,调常量解决不了。
2026-10-13 21:59:31 +08:00

433 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package embedding
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件把 budget.go 的**不变式**钉成可执行的断言,两态各一条:
//
// - 「批内耗时 < 超时」⇒ 不降级:DerivedBatchCompletesWithinTimeout;
// - 「批内耗时 ≥ 超时」⇒ 超时(本次故障形态):LegacyBatchOf64TimesOut;
//
// 以及**变异自证**:把批上限调回旧值(4 或 64)会让哪条测试红,见
// TestBatchBudget_InvariantHolds(钉值 + 两层上界)与
// TestAPIEmbedder_LegacyBatchOf64TimesOut。
//
// 假客户端用的是**实测曲线**(不是随口编的常数):单次请求耗时
// ≈ measuredOverhead + 字符总量 × measuredPerChar,取值见下面两个常量
// (2026-10-13 本机实测:固定开销 ~50ms、每字符 3.4ms,此处取上界)。
const (
measuredOverhead = 50 * time.Millisecond
measuredPerChar = 4 * time.Millisecond
latencyModelChars = 33 // 活库短正文的平均长度(实测 33~35 汉字)
// measuredPathologicalItems 是**实测**已经出界的批次形状(文件头:单请求
// 11.043s > 5s 超时)。它只用于说明「上界不该往哪边放」,不是断言对象 ——
// 断言对象是推出来的 DefaultBatchLimit 与它必须落在的 maxSafeBatchLimit 之内。
measuredPathologicalItems = 16
)
// latencyTripper 是一个按上面那条曲线**真的睡够时间**的 RoundTripper:
// 它模拟「服务端会算完,不管你客户端还在不在等」。这正是本次故障的形态 ——
// 客户端 5s 放弃、服务端还在算,后面每个请求都排在它后面。
type latencyTripper struct {
mu sync.Mutex
requests int
maxItems int
maxChars int
// abandoned 计数「客户端先放弃、服务端还在算」的次数 —— 本次故障的形态。
// 修好之后它必须是 0(见 TestAPIEmbedder_OversizedBatchIsSplit 的断言)。
abandoned int
// firstSleep 非零时,第一次请求额外多睡这么久(模拟冷启动的模型加载)。
firstSleep time.Duration
}
func (l *latencyTripper) RoundTrip(r *http.Request) (*http.Response, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
var req openAICompatibleRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
chars := 0
for _, s := range req.Input {
chars += len([]rune(s))
}
l.mu.Lock()
l.requests++
if len(req.Input) > l.maxItems {
l.maxItems = len(req.Input)
}
if chars > l.maxChars {
l.maxChars = chars
}
first := l.requests == 1
extra := l.firstSleep
l.mu.Unlock()
wait := measuredOverhead + time.Duration(chars)*measuredPerChar
if first && extra > 0 {
wait += extra
}
select {
case <-time.After(wait):
case <-r.Context().Done():
// 客户端放弃了:服务端那一侧不会因为客户端走了就把已排队的算力还回来
// (实测的级联堆积正是从这一步开始的),因此这里明确记一笔。
l.mu.Lock()
l.abandoned++
l.mu.Unlock()
return nil, r.Context().Err()
}
resp := openAICompatibleResponse{Data: make([]openAICompatibleDatum, 0, len(req.Input))}
for i := range req.Input {
resp.Data = append(resp.Data, openAICompatibleDatum{Index: i, Embedding: []float32{1, 0}})
}
raw, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(string(raw))),
Header: make(http.Header),
}, nil
}
// shortTexts 造 n 条与活库短正文等长的文本。
func shortTexts(n int) []string {
out := make([]string, n)
line := strings.Repeat("河", latencyModelChars)
for i := range out {
out[i] = line
}
return out
}
// TestBatchBudget_InvariantHolds 是**推演本身**的断言(不连任何网络):
// 由超时推出来的一对上限,必须使「一次请求的耗时上界 ≤ 超时的一半」成立。
//
// 四层,各自的语义不同(改坏任一层都必须是红的):
//
// 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()
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 条 6.872s > 5s。这里用同一套预算常量复核它仍然出界。
legacy := requestOverhead + 64*perItemBudget
if legacy <= DefaultRequestTimeout {
t.Fatalf("旧配对(64 条)的耗时上界 %s 竟然落在单次超时 %s 之内 —— "+
"说明超时被放大到迁就批次了,本次修法不允许", legacy, DefaultRequestTimeout)
}
}
// TestChunkTexts_RespectsBothLimits 钉住切片:条数与字符总量两道界都要守,
// 单条本身越界的文本独占一段且**不被截断**。
func TestChunkTexts_RespectsBothLimits(t *testing.T) {
t.Parallel()
got := chunkTexts(shortTexts(9), 2, 100)
if len(got) != 5 {
t.Fatalf("2 条/段 切 9 条 = %d 段,want 5", len(got))
}
for i, seg := range got {
if len(seg) > 2 {
t.Fatalf("第 %d 段有 %d 条,超过条数上限 2", i, len(seg))
}
}
// 字符界绑定:4 条 33 字符 = 132 字符 ⇒ 100 字符/段时必须收口(3 条 = 99 字符满,
// 余下 1 条自成一段)。
byChars := chunkTexts(shortTexts(4), 64, 100)
if len(byChars) != 2 {
t.Fatalf("100 字符/段 切 4×33 字符 = %d 段,want 2", len(byChars))
}
for i, seg := range byChars {
total := 0
for _, s := range seg {
total += len([]rune(s))
}
if total > 100 {
t.Fatalf("第 %d 段 %d 字符,超过字符上限 100", i, total)
}
}
// 超长单条:不切碎、不截断,独占一段。
huge := strings.Repeat("长", 500)
segs := chunkTexts([]string{huge, "短"}, 64, 100)
if len(segs) != 2 || segs[0][0] != huge {
t.Fatalf("超长单条应独占一段且不改写,got %d 段", len(segs))
}
if chunkTexts(nil, 4, 100) != nil {
t.Fatal("空输入应返回 nil")
}
}
// TestAPIEmbedder_DerivedBatchCompletesWithinTimeout 钉**不降级态**:
// 按推出来的上限发一次(Embed 内部切成 1 段),在实测曲线下必须在单次超时内拿到向量。
func TestAPIEmbedder_DerivedBatchCompletesWithinTimeout(t *testing.T) {
t.Parallel()
rt := &latencyTripper{}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
texts := shortTexts(DefaultBatchLimit)
vectors, err := emb.Embed(context.Background(), texts)
if err != nil {
t.Fatalf("上限内的批不该超时:%v", err)
}
if len(vectors) != len(texts) {
t.Fatalf("got %d vectors, want %d", len(vectors), len(texts))
}
if rt.requests != 1 {
t.Fatalf("上限内的批应发 1 次请求,实际 %d 次", rt.requests)
}
}
// TestAPIEmbedder_OversizedBatchIsSplit 钉**不变式与调用方无关**:
// 调用方给 3 倍上限的切片时,Embed 自己切成多段、每段都在界内,向量顺序与入参一致。
func TestAPIEmbedder_OversizedBatchIsSplit(t *testing.T) {
t.Parallel()
rt := &latencyTripper{}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
texts := shortTexts(DefaultBatchLimit * 3)
vectors, err := emb.Embed(context.Background(), texts)
if err != nil {
t.Fatalf("超大切片应被切开而不是超时:%v", err)
}
if len(vectors) != len(texts) {
t.Fatalf("got %d vectors, want %d(切开后必须按序拼回)", len(vectors), len(texts))
}
if rt.requests < 3 {
t.Fatalf("3 倍上限应至少切成 3 段,实际 %d 段", rt.requests)
}
if rt.maxItems > DefaultBatchLimit {
t.Fatalf("有请求带了 %d 条,超过推出来的条数上限 %d", rt.maxItems, DefaultBatchLimit)
}
if rt.maxChars > DefaultBatchChars {
t.Fatalf("有请求带了 %d 字符,超过推出来的字符上限 %d", rt.maxChars, DefaultBatchChars)
}
// 🔴 本次修复的核心不变式,确定性断言(不依赖机器负载):
// 没有任何一次请求发生「客户端先放弃、服务端还在算」——那正是级联堆积的起点。
if rt.abandoned != 0 {
t.Fatalf("有 %d 次请求被客户端先放弃(服务端还在算)⇒ 不变式没守住", rt.abandoned)
}
// 服务端侧的同一条不变式:任何一次请求的建模耗时(实测曲线)都落在单次超时内。
if cost := measuredOverhead + time.Duration(rt.maxChars)*measuredPerChar; cost > DefaultRequestTimeout {
t.Fatalf("最大一次请求的实测曲线耗时 %s 越过单次超时 %s", cost, DefaultRequestTimeout)
}
}
// TestAPIEmbedder_LegacyBatchOf64TimesOut 是**变异自证**的另一半:同样是实测曲线,
// 旧配对(一次请求 64 条 + 5s 超时)必须**超时**。它证明把上限写回 64 不是「保守」,而是
// 必然把客户端与服务端撕裂(客户端走了、服务端还在算 ⇒ 后续请求全排队)。
func TestAPIEmbedder_LegacyBatchOf64TimesOut(t *testing.T) {
t.Parallel()
rt := &latencyTripper{}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
// 先把一次性冷启动放宽花掉,否则第一次调用按 warmupTimeout 算、不会超时。
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
t.Fatalf("预热调用失败:%v", err)
}
start := time.Now()
_, err := emb.embedChunk(context.Background(), shortTexts(64))
elapsed := time.Since(start)
if err == nil {
t.Fatalf("旧配对(64 条 + %s 超时)在实测曲线下不该成功", DefaultRequestTimeout)
}
if elapsed > DefaultRequestTimeout+2*time.Second {
t.Fatalf("超时保护没生效:等了 %s", elapsed)
}
}
// TestAPIEmbedder_WarmupIsOneShot 钉 p1 的核心:冷启动放宽**一次性**,三种失败形态
// 下都必须是「第一次最多等 warmupTimeout,第二次起一律 DefaultRequestTimeout」。
//
// 这是 reviewer 在 round1 打回的那条:旧实现 `warmed` 只在 2xx 之后才翻,于是
// 「服务接受连接但永不回包」时**每一次**调用都等完整放宽预算(30s),把 fail-fast
// 变成 fail-slow —— 一次挂死就能吃光审查的 20s 预算、把索引写入拖成 8×30s。
func TestAPIEmbedder_WarmupIsOneShot(t *testing.T) {
t.Parallel()
cases := []struct {
name string
rt func(t *testing.T) http.RoundTripper
}{
{
name: "连接被拒",
rt: func(t *testing.T) http.RoundTripper {
return roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp 127.0.0.1:11434: connectex: 目标计算机积极拒绝")
})
},
},
{
name: "HTTP 500",
rt: func(t *testing.T) http.RoundTripper {
return roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader("server exploded")),
Header: make(http.Header),
}, nil
})
},
},
{
name: "接受连接但永不回包",
rt: func(t *testing.T) http.RoundTripper {
return roundTripFunc(func(r *http.Request) (*http.Response, error) {
<-r.Context().Done() // 只等服务端超时,永不回包
return nil, r.Context().Err()
})
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(tc.rt(t)))
// 第一次:放宽预算。断言上界 = warmupTimeout(不得再宽),
// 下界只对会真正等到截止的那一种(挂死)有意义。
start := time.Now()
_, err := emb.Embed(context.Background(), shortTexts(1))
first := time.Since(start)
if err == nil {
t.Fatal("这一组失败形态不该成功")
}
t.Logf("首次耗时 = %s", first.Round(time.Millisecond))
if first > warmupTimeout+time.Second {
t.Fatalf("首次耗时 %s 超过冷启动放宽 %s", first, warmupTimeout)
}
// 第二次:**必须**回到常态超时 —— 一次挂死绝不许变成每次都慢。
start = time.Now()
if _, err := emb.Embed(context.Background(), shortTexts(1)); err == nil {
t.Fatal("这一组失败形态不该成功")
}
second := time.Since(start)
t.Logf("第二次耗时 = %s(常态超时 %s)", second.Round(time.Millisecond), DefaultRequestTimeout)
if second > DefaultRequestTimeout+time.Second {
t.Fatalf("第二次耗时 %s 超过常态超时 %s ⇒ 放宽成了长期的", second, DefaultRequestTimeout)
}
// 放宽许可必须已经被花掉(它是一次性的,任何路径都不许给它续期)。
if emb.warmupLeft.Load() {
t.Fatal("第二次请求之后放宽许可仍为真 —— 一次性的约束被绕过了")
}
})
}
}
// TestAPIEmbedder_WarmupCoversColdStartOnce 钉放宽的**正面**作用:第一次请求
// (模型加载 / GPU 缓冲分配)可以比常态超时多等一点,只要它落在 warmupTimeout
// 之内;而这一刻过去之后,请求立刻回到常态超时(deadline 断言)。
func TestAPIEmbedder_WarmupCoversColdStartOnce(t *testing.T) {
t.Parallel()
rt := &latencyTripper{firstSleep: DefaultRequestTimeout + time.Second}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
t.Fatalf("冷启动第一次调用应被 warmupTimeout(%s) 兜住:%v", warmupTimeout, err)
}
if emb.warmupLeft.Load() {
t.Fatal("放宽许可应在第一次请求时就被花掉")
}
var slack time.Duration
steady := roundTripFunc(func(r *http.Request) (*http.Response, error) {
dl, ok := r.Context().Deadline()
if !ok {
t.Error("稳态请求没有 deadline")
} else {
slack = time.Until(dl)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[1]}]}`)),
Header: make(http.Header),
}, nil
})
emb.client = &http.Client{Transport: steady}
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
t.Fatalf("稳态调用失败:%v", err)
}
if slack <= 0 || slack > DefaultRequestTimeout {
t.Fatalf("稳态单次超时余量 = %s, want (0, %s]", slack, DefaultRequestTimeout)
}
}