fix(memory): 冷启动放宽改为一次性 + 试算轮区分「没候选」与「向量不可用」
review round1 的 p1/p2 收口(commit 5967272 之后)。
p1:旧实现 `warmed` 只在 2xx 之后才翻 ⇒ 注释里写的「只在第一次成功响应之前」
实际是「服务坏着的全部时间」。reviewer 实测「接受连接但永不回包」时**每次**调用
都等满 30s(fail-fast 变 fail-slow):审查 20s 预算被一次吃光、索引写入拖成
8×30s、开机探针阻塞 30s。
- `warmupLeft` 改为**一次性许可**(CompareAndSwap 取走即失效,成败都算花掉)
⇒ 挂死时只有第一个请求慢一次,此后一律 DefaultRequestTimeout。
- `warmupTimeout` 30s → 8s:与常态 5s 同一量级(≤2×),且严格小于审查总预算
20s(一次冷启动不许吃光整轮);两条界都由 TestDefaultRequestTimeout 守住。
- 放宽被花掉且没成功时留一条 WARN(事后可复核「第一个请求」这个判据)。
- 更正两处自述:api.go 的「不会被 30s 拖住」与 budget.go 的不变式说明(批次形状
永远按稳态超时推导;冷启动只放宽第一个请求的截止时刻)。
- p2:删掉零调用者的 `WithRequestTimeout` —— 单次超时只允许改常量,批次上限与它
同源并由编译期/常量断言复核。
p2:试算轮在向量源不可用时 `RecallMode` 与「没有候选可比」同为空串。
- `recallLedger` 增 `unavailable` 计数 + `noteUnavailable()`(strictRecall 失败
那一笔记一笔);试算汇总行新增 `recall_unavailable_partitions`,**成败都记**。
- 不扩 `recallMode` 取值集合:失败那一路不序列化 result(响应是
500 tidy_recall_unavailable),新取值只会出现在日志里却要动一次契约,不划算。
测试:三种失败形态 × 两次调用的耗时断言(连接被拒 / 500 / 永不回包),
试算轮的「不可用」与「没候选」两态日志断言;两处变异自证(warmupTimeout 调回
30s ⇒ TestDefaultRequestTimeout 红;去掉 noteUnavailable ⇒ 试算汇总行退化成
recall_mode="" + recall_unavailable_partitions=0)。
This commit is contained in:
1 parent
59672720bd
commit
6c30ca540a
7 files changed
+273
-72
No files matched your search
@@ -3,6 +3,7 @@ package embedding
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -254,7 +255,7 @@ func TestAPIEmbedder_LegacyBatchOf64TimesOut(t *testing.T) {
|
||||
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
|
||||
WithRoundTripper(rt))
|
||||
|
||||
// 先热一次(越过 warmupTimeout),否则第一次调用有冷启动预算、不会超时。
|
||||
// 先把一次性冷启动放宽花掉,否则第一次调用按 warmupTimeout 算、不会超时。
|
||||
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
|
||||
t.Fatalf("预热调用失败:%v", err)
|
||||
}
|
||||
@@ -269,10 +270,92 @@ func TestAPIEmbedder_LegacyBatchOf64TimesOut(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIEmbedder_WarmupFirstCallBudget 钉冷启动那一路:第一次调用(模型加载 /
|
||||
// GPU 缓冲分配)拿的是 warmupTimeout,**只有第一次**;一旦拿到成功响应,后续请求
|
||||
// 立刻回到 DefaultRequestTimeout(真挂死的请求不会被 30s 拖住)。
|
||||
func TestAPIEmbedder_WarmupFirstCallBudget(t *testing.T) {
|
||||
// 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}
|
||||
@@ -282,8 +365,8 @@ func TestAPIEmbedder_WarmupFirstCallBudget(t *testing.T) {
|
||||
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
|
||||
t.Fatalf("冷启动第一次调用应被 warmupTimeout(%s) 兜住:%v", warmupTimeout, err)
|
||||
}
|
||||
if !emb.warmed.Load() {
|
||||
t.Fatal("拿到成功响应后 warmed 应为真")
|
||||
if emb.warmupLeft.Load() {
|
||||
t.Fatal("放宽许可应在第一次请求时就被花掉")
|
||||
}
|
||||
|
||||
var slack time.Duration
|
||||
|
||||
Reference in new issue
Block a user