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

293 lines
11 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"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestNormalizeEndpointURL 钉住「回环别名 → 127.0.0.1」这张表,以及**不该被动**
// 的那些输入。全部是纯字符串断言,不连任何网络。
func TestNormalizeEndpointURL(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
{
name: "localhost 改写",
in: "http://localhost:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
name: "localhost 大小写不敏感",
in: "http://LocalHost:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
name: "localhost 无端口",
in: "http://localhost/v1/embeddings",
want: "http://127.0.0.1/v1/embeddings",
},
{
name: "https 同样改写",
in: "https://localhost:8443/v1/embeddings",
want: "https://127.0.0.1:8443/v1/embeddings",
},
{
// 0.0.0.0 是「监听所有接口」的语义,不是可连接的地址(拿它当连接目标会
// 立即失败),因此这里是**防呆**:把写坏的地址掰回真正可连的回环字面量,
// 而不是「支持用 0.0.0.0 去连」。
name: "0.0.0.0 防呆改写",
in: "http://0.0.0.0:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
// 空主机同样只是防呆:原文拼出来的 URL 指向不了任何服务。
name: "空主机防呆改写",
in: "http:///v1/embeddings",
want: "http://127.0.0.1/v1/embeddings",
},
{
name: "显式 IPv6 字面量不改写",
in: "http://[::1]:11434/v1/embeddings",
want: "http://[::1]:11434/v1/embeddings",
},
{
name: "已是 127.0.0.1 原样返回",
in: "http://127.0.0.1:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
name: "远端主机名不改写",
in: "https://embed.example.com/v1/embeddings",
want: "https://embed.example.com/v1/embeddings",
},
{
// 路径与查询串必须逐字不变:包括百分号编码、`+`、重复键与空值。
name: "路径与查询串逐字不变",
in: "http://localhost:8080/v1/emb%65ddings?a=%2F+&b=1&b=&empty#frag",
want: "http://127.0.0.1:8080/v1/emb%65ddings?a=%2F+&b=1&b=&empty#frag",
},
{
// 未改写的主机(含显式 IPv6)必须**原串**返回,不做任何重排。
name: "未改写时逐字返回原串",
in: "https://example.com:443/v1/a%2Fb?q=%7B%7D&r=1#f",
want: "https://example.com:443/v1/a%2Fb?q=%7B%7D&r=1#f",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeEndpointURL(tc.in)
if err != nil {
t.Fatalf("normalizeEndpointURL(%q) error: %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("normalizeEndpointURL(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestNormalizeEndpointURL_NonHTTPSchemeUntouched 保证非 http(s) 的输入不被改写:
// 那类地址不是本客户端能拨的,改主机只会掩盖更该暴露的配置错误。
func TestNormalizeEndpointURL_NonHTTPSchemeUntouched(t *testing.T) {
t.Parallel()
const in = "file://localhost/v1/embeddings"
got, err := normalizeEndpointURL(in)
if err != nil {
t.Fatalf("normalizeEndpointURL(%q) error: %v", in, err)
}
if got != in {
t.Fatalf("normalizeEndpointURL(%q) = %q, want unchanged", in, got)
}
}
// TestAPIEmbedder_NormalizesLoopbackHost 从**发出的请求**上验证规范化确实生效:
// 主机拨号目标变成 127.0.0.1,且 path + query 一字未动。用注入的 RoundTripper,
// 不连任何网络。
func TestAPIEmbedder_NormalizesLoopbackHost(t *testing.T) {
t.Parallel()
var gotHost, gotURI string
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
gotHost = r.URL.Host
gotURI = r.URL.RequestURI()
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[0.1]}]}`)),
Header: make(http.Header),
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "http://localhost:11434/v1/embeddings?k=v#frag"},
WithRoundTripper(rt),
)
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("Embed() error: %v", err)
}
if gotHost != "127.0.0.1:11434" {
t.Errorf("dial host = %q, want 127.0.0.1:11434", gotHost)
}
if gotURI != "/v1/embeddings?k=v" {
t.Errorf("request URI = %q, want /v1/embeddings?k=v", gotURI)
}
}
// TestAPIEmbedder_KeepsExplicitIPv6Host 尊重使用者显式写下的 IPv6 字面量:
// 不改写、不重排。同样走注入的 RoundTripper,不连网络。
func TestAPIEmbedder_KeepsExplicitIPv6Host(t *testing.T) {
t.Parallel()
var gotHost string
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
gotHost = r.URL.Host
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[0.1]}]}`)),
Header: make(http.Header),
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "http://[::1]:11434/v1/embeddings"},
WithRoundTripper(rt),
)
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("Embed() error: %v", err)
}
if gotHost != "[::1]:11434" {
t.Errorf("dial host = %q, want [::1]:11434(显式 IPv6 不许被改写)", gotHost)
}
}
// TestAPIEmbedder_LoopbackAliasReachesIPv4OnlyServer 是**真连本机**的场景验证
// (httptest 服务器只绑 127.0.0.1,正是 llama.cpp / ollama 的默认形态):
// 端点写成 localhost / 0.0.0.0 时,请求必须仍然落到那个 IPv4 回环服务上并拿回向量。
//
// 注意它证明的是「场景通」而非「不做规范化就必挂」:2026-10-07 在本机实测,
// Go 客户端的拨号器对 localhost 会很快回落到 127.0.0.1(约 57ms),把这一条测试
// 的规范化关掉它**仍然会通过**。真正把这条测试变红的是 path/query 被破坏之类的
// 回归;「localhost 一定不去碰 ::1」这个保证由上面的字符串断言守住。
// 全程只连本进程起在 127.0.0.1 上的临时服务器,不碰任何外部服务。
func TestAPIEmbedder_LoopbackAliasReachesIPv4OnlyServer(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"data":[{"index":0,"embedding":[0.1,0.2]}]}`)
}))
defer srv.Close()
for _, alias := range []string{"localhost", "0.0.0.0"} {
alias := alias
// 刻意**不用** t.Parallel():子测试若并行,父测试会先返回并执行
// defer srv.Close(),子测试再跑时就只能撞上「连接被拒绝」。
t.Run(alias, func(t *testing.T) {
// srv.URL 形如 http://127.0.0.1:PORT —— 只把主机换成别名,端口保留,
// 这样「服务确实只绑 IPv4」这个前提不变。
raw := strings.Replace(srv.URL, "127.0.0.1", alias, 1) + "/v1/embeddings"
emb := NewAPI(config.EmbeddingConfig{APIURL: raw})
vectors, err := emb.Embed(context.Background(), []string{"hi"})
if err != nil {
t.Fatalf("端点写成 %s 时未落到 IPv4 回环服务:%v", alias, err)
}
if len(vectors) != 1 || len(vectors[0]) != 2 {
t.Fatalf("vectors = %v, want 1 个 2 维向量", vectors)
}
})
}
}
// reviewBudgetRef 是「入库前审查」单条总预算的**参照值**,只用于本文件的断言:
// 权威定义在 internal/runtime/memory/preinsert_config.go 的 defaultReviewTimeout。
// 跨包的真实断言在 memory 包(preinsert_recall_test.go)里,这里钉住同一个口径,
// 免得 embedding 侧悄悄把超时调到 ≥ 审查预算。
//
// 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 连接就能吃光整条审查的预算,排在后面的
// T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实
// 打在了发出去的请求上。
//
// 🔴 冷启动放宽(warmupTimeout)必须一并守住同一条界:它只发生一次,但它那一次的
// 等待**也**不能吃光整轮审查 —— 否则「一次挂死 = 整轮审查没有预算给 T1」这个形态
// 会从常态超时挪到放宽预算上原样复现(reviewer round1 的 p1)。因此这里对
// warmupTimeout 做与 DefaultRequestTimeout **同一条**断言。
func TestDefaultRequestTimeout(t *testing.T) {
t.Parallel()
if DefaultRequestTimeout <= 0 {
t.Fatalf("DefaultRequestTimeout = %s, 必须为正", DefaultRequestTimeout)
}
if DefaultRequestTimeout >= reviewBudgetRef {
t.Fatalf("embedding 单次超时 %s 必须严格小于审查总预算 %s",
DefaultRequestTimeout, reviewBudgetRef)
}
if DefaultRequestTimeout != 5*time.Second {
t.Fatalf("DefaultRequestTimeout = %s, want 5s(值变了要连带复核审查预算口径)",
DefaultRequestTimeout)
}
// 冷启动放宽的两条界:仍在常态超时的**同一量级**(不得成倍放大),
// 且严格小于审查总预算(一次冷启动不许吃光整轮)。
if warmupTimeout <= 0 {
t.Fatalf("warmupTimeout = %s, 必须为正", warmupTimeout)
}
if warmupTimeout > 2*DefaultRequestTimeout {
t.Fatalf("冷启动放宽 %s 超过常态超时 %s 的两倍 —— 它必须是同一量级的一次性放宽",
warmupTimeout, DefaultRequestTimeout)
}
if warmupTimeout >= reviewBudgetRef {
t.Fatalf("冷启动放宽 %s 必须严格小于审查总预算 %s(否则一次冷启动就吃光整轮审查)",
warmupTimeout, reviewBudgetRef)
}
var slack time.Duration
rt := 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":[0.1]}]}`)),
Header: make(http.Header),
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt),
)
// 第一次是冷启动调用(warmupTimeout),结果由它自己的测试断言;这里连发两次,
// 只看稳态那一次打出去的 deadline。
for i := 0; i < 2; i++ {
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("第 %d 次 Embed() error: %v", i+1, err)
}
}
if slack <= 0 || slack > DefaultRequestTimeout {
t.Fatalf("请求 deadline 余量 = %s, want (0, %s]", slack, DefaultRequestTimeout)
}
}