M9(EMBRECALL)。故障机制(M6 + 本次复测):客户端 5s 单次超时先放弃,服务端仍在 算那一批 ⇒ 本机 llama.cpp 被自己顶住 ⇒ 后续每个请求(含写向量索引的单条请求) 排队到自己的超时 ⇒ 召回全线降级。 - embedding/budget.go(新):把「一次请求的耗时上界 ≤ 单次超时的一半」写成不变式, 批上限(条数 4 / 字符 480)与 5s 超时由**同一个不等式**解出;实测依据与复跑命令 写进注释(空闲态摊销曲线 1→0.19s / 8→1.1s / 12→1.3s / 64→8.5s,约 50ms 固定 + 2.7~3.9ms/字符,n≥32 起有数倍放大 ⇒ 余量取一半)。 - embedding/api.go:Embed 按同一对上限**自己切片**(不变式与调用方无关);冷启动 首次调用单独给 warmupTimeout,拿到第一个成功响应后立刻回常态超时。 - memory/tidy_config.go:tidyEmbedBatch = embedding.DefaultBatchLimit(同源), 并加编译期断言 —— 调回 64 会直接编译失败(constant -60 overflows uint)。 - memory/tidy.go | tidy_recall.go | tidy_dryrun.go:recall_mode 改报**实际发生** 的来源(""=本轮没候选可比 / embedding / lexical / mixed),新增 recall_degraded_partitions;试算轮 RecallMode 同口径(M6 medium finding)。 - 测试:embedding 侧用「实测曲线 + 会睡够时间的假客户端」钉住两态 (批内 < 超时 ⇒ 不降级;旧配对 64 条 ⇒ 必然超时),并断言 abandoned == 0; memory 侧钉住降级/健康/没候选三态与试算轮的如实上报。 不改 preinsert*、不改 .env、不动判定规则与任何阈值。
311 lines
11 KiB
Go
311 lines
11 KiB
Go
package embedding
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// 本文件把 budget.go 的**不变式**钉成可执行的断言,两态各一条:
|
||
//
|
||
// - 「批内耗时 < 超时」⇒ 不降级:DerivedBatchCompletesWithinTimeout;
|
||
// - 「批内耗时 ≥ 超时」⇒ 超时(本次故障形态):LegacyBatchOf64TimesOut;
|
||
//
|
||
// 以及**变异自证**:把批上限调回旧值 64(或把推演调松)会让哪条测试红,
|
||
// 见 TestBatchBudget_InvariantHolds 与 TestAPIEmbedder_LegacyBatchOf64TimesOut。
|
||
//
|
||
// 假客户端用的是**实测曲线**(不是随口编的常数):单次请求耗时
|
||
// ≈ measuredOverhead + 字符总量 × measuredPerChar,取值见下面两个常量
|
||
// (2026-10-13 本机实测:固定开销 ~50ms、每字符 2.7~3.9ms,此处取上界)。
|
||
const (
|
||
measuredOverhead = 50 * time.Millisecond
|
||
measuredPerChar = 4 * time.Millisecond
|
||
latencyModelChars = 33 // 活库短正文的平均长度(实测 33 汉字)
|
||
)
|
||
|
||
// 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 是**推演本身**的断言(不连任何网络):
|
||
// 由超时推出来的一对上限,必须使「一次请求的耗时上界 ≤ 超时的一半」成立。
|
||
//
|
||
// 变异自证:把 DefaultBatchLimit 的推演改成旧口径(条数写回 64)⇒ 第一条断言红;
|
||
// 若改成「把超时放大到盖住 64 条」⇒ 最后一条断言红(旧配对必须仍是越界的)。
|
||
func TestBatchBudget_InvariantHolds(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
if DefaultBatchLimit < 1 || DefaultBatchChars < 1 {
|
||
t.Fatalf("推出来的上限必须 ≥ 1:条数=%d 字符=%d", DefaultBatchLimit, DefaultBatchChars)
|
||
}
|
||
share := DefaultRequestTimeout / callBudgetShare
|
||
byItems := requestOverhead + time.Duration(DefaultBatchLimit)*perItemBudget
|
||
if byItems > share {
|
||
t.Fatalf("条数上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
|
||
DefaultBatchLimit, byItems, share)
|
||
}
|
||
byChars := requestOverhead + time.Duration(DefaultBatchChars)*perCharBudget
|
||
if byChars > share {
|
||
t.Fatalf("字符上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
|
||
DefaultBatchChars, byChars, share)
|
||
}
|
||
// 旧配对(批次 64 + 5s 超时)必须仍被判为越界 —— 那正是 2026-10-13 的故障形态:
|
||
// 实测 64 条 7.5~8.5s > 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_WarmupFirstCallBudget 钉冷启动那一路:第一次调用(模型加载 /
|
||
// GPU 缓冲分配)拿的是 warmupTimeout,**只有第一次**;一旦拿到成功响应,后续请求
|
||
// 立刻回到 DefaultRequestTimeout(真挂死的请求不会被 30s 拖住)。
|
||
func TestAPIEmbedder_WarmupFirstCallBudget(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.warmed.Load() {
|
||
t.Fatal("拿到成功响应后 warmed 应为真")
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|