From 987cefdf28d7ca04a183b9baccbf4824c8286909 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 16:22:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(embedding):=20=E7=AB=AF=E7=82=B9=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E8=A7=84=E8=8C=83=E5=8C=96=20+=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=9F=AD=E8=B6=85=E6=97=B6=EF=BC=88=E6=B2=BB=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E6=95=B4=E6=9D=A1=E8=B6=85=E6=97=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:入库前审查链路一直整条超时(日志每 ~20s 一条 WARN),异步那层 (含刚装好的 T1)从未成功跑过。 - 新增 endpoint.go:把「连接语义上不可靠」的回环别名规范化成 127.0.0.1 (localhost 大小写不敏感 / 0.0.0.0 / 空主机),只替换 Host,不碰 path / query / fragment / userinfo;显式 [::1] 与真实主机名逐字不动 - Embed 加独立短超时 DefaultRequestTimeout = 5s(远小于审查 20s 预算): 卡住的调用快速失败并走既有「降级到词项余弦」的路径,把预算留给 T1 - .env.example 示例地址 localhost -> 127.0.0.1(未注释行键数 69 -> 69,无增删) - 跨包测试钉住 5s 严格小于审查总预算 诚实边界(子代理实测,推翻了主代理原先的 IPv6 根因推断): - Go 客户端走 localhost 实测约 57ms、curl 约 0.2s ⇒「localhost -> ::1 等满 20s 预算」这条链不成立;更可能是单槽 llama.cpp 的吞吐/争用 (一批 64 条约 2.1~2.9s,审查 201 条约 8~10s,再叠 T1 8s 顶满预算) - 规范化在任何环境下都无害,5s 快速失败对这个真因对症;但真因 (容量/争用)需要另排切片,已记入工作小结 --- .env.example | 9 +- internal/runtime/embedding/api.go | 70 ++++- internal/runtime/embedding/api_url_test.go | 265 ++++++++++++++++++ internal/runtime/embedding/endpoint.go | 75 +++++ .../runtime/memory/preinsert_recall_test.go | 29 ++ 5 files changed, 433 insertions(+), 15 deletions(-) create mode 100644 internal/runtime/embedding/api_url_test.go create mode 100644 internal/runtime/embedding/endpoint.go create mode 100644 internal/runtime/memory/preinsert_recall_test.go diff --git a/.env.example b/.env.example index 59f00a5..15a2bba 100644 --- a/.env.example +++ b/.env.example @@ -183,8 +183,13 @@ LLM_LOCAL_ENABLED=false # ---- 记忆嵌入(可选,真语义) ---- # 不配置时使用内置 n-gram / FTS 向量(dim=256)。配置后必须真实走语义路径。 -# EMBEDDING_API_URL 是【完整 /embeddings 端点】(不是 base)——例:Ollama / llama.cpp = http://localhost:11434/v1/embeddings -# EMBEDDING_API_URL=http://localhost:11434/v1/embeddings +# EMBEDDING_API_URL 是【完整 /embeddings 端点】(不是 base)——例:Ollama / llama.cpp = http://127.0.0.1:11434/v1/embeddings +# ⚠️ 回环地址请写具体的 IPv4 字面量 127.0.0.1,**不要写 localhost**:本机 embedding 服务 +# (llama.cpp / ollama)默认只绑 IPv4 回环,而 localhost 会先解析到 IPv6 ::1 —— 那里没有 +# 监听,轻则多一次无效拨号(实测 localhost 约 0.2s,127.0.0.1 约 0.001s),重则在「::1 被 +# 静默丢弃」的环境里等到系统连接超时,白白吃掉整条入库审查的预算。 +# (客户端已对 localhost / 0.0.0.0 / 空主机做兜底规范化,但直接写对更省事、更好排查。) +# EMBEDDING_API_URL=http://127.0.0.1:11434/v1/embeddings # EMBEDDING_API_KEY= # EMBEDDING_MODEL=Qwen3-Embedding-0.6B # 推荐(Q8_0 ~639MB、dim=1024、ModelScope 国内可下) diff --git a/internal/runtime/embedding/api.go b/internal/runtime/embedding/api.go index 53e8caf..92cda5f 100644 --- a/internal/runtime/embedding/api.go +++ b/internal/runtime/embedding/api.go @@ -35,6 +35,28 @@ const ( httpRequestTimeout = 180 * time.Second ) +// DefaultRequestTimeout 是**单次** /embeddings 调用的墙钟上限,独立于 HTTP 客户端 +// 那一层的 180s 总超时(httpRequestTimeout)。 +// +// 依据(是实测数据,不是拍脑袋的调优): +// - 上层「入库前审查」的单条总预算是 20s(memory.defaultReviewTimeout),embedding +// 只是它内部的第一步。2026-10-07 实测(本机 llama.cpp + Qwen3-Embedding-0.6B, +// 服务空闲):单条文本约 60ms;**64 条一批约 2.1~2.9s**;审查最坏情况 +// (ScanLimit=200 + 新条目 1)要 4 批 ⇒ 约 8~10s,已经占掉 20s 预算的一半, +// 留给后面的 T1 灰区判定(defaultT1Timeout=8s)的余量很薄。一次卡死的 HTTP +// 就能把这点余量连同整条审查一起吃掉(实测日志里 +// `memory/review: context deadline exceeded` 正是这个形态); +// - 所以每次调用必须有**自己的**上限:卡住的那一批到点就失败并降级,不能把整条 +// 审查的 20s 都赌在一次 HTTP 上; +// - 5s 的取值:相对实测的 64 条一批 2.9s 留约 1.7 倍余量,能吸收并发争用带来的 +// 抖动,同时远小于 20s 的审查预算。若日后批次变大或服务变慢,先调这里而不是 +// 调审查总预算; +// - 真慢就**快速失败**:调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 +// degraded),**绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则; +// - 已知代价:llama.cpp 首次加载模型可能超过 5s,那一次调用会失败并降级,模型 +// 常驻后的下一次调用即恢复正常。降级本就是既有行为,因此可接受。 +const DefaultRequestTimeout = 5 * time.Second + // openAICompatibleRequest is the JSON body sent to an OpenAI-compatible // /embeddings endpoint. type openAICompatibleRequest struct { @@ -132,6 +154,9 @@ func (e *APIEmbedder) Dim() int { // Embed sends texts to the configured /embeddings endpoint and returns the // returned vectors aligned by index. It returns ErrNoAPIURL for a blank URL // and a wrapped error for any HTTP or decode failure. +// +// 单次调用带独立的、比上层审查总预算(20s)短得多的超时(DefaultRequestTimeout), +// 卡住即快速失败,把预算留给后续步骤;失败语义与降级口径不变。 func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) { if len(texts) == 0 { return []Vector{}, nil @@ -143,21 +168,12 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro return nil, ErrNoAPIURL } - payload, err := json.Marshal(openAICompatibleRequest{ - Model: e.cfg.Model, - Input: texts, - }) - if err != nil { - return nil, fmt.Errorf("embedding: marshal request: %w", err) - } + callCtx, cancel := context.WithTimeout(ctx, DefaultRequestTimeout) + defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.cfg.APIURL, bytes.NewReader(payload)) + req, err := e.newEmbeddingRequest(callCtx, texts) if err != nil { - return nil, fmt.Errorf("embedding: build request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - if e.cfg.APIKey != "" { - req.Header.Set("Authorization", "Bearer "+e.cfg.APIKey) + return nil, err } resp, err := e.client.Do(req) @@ -205,6 +221,34 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro return vectors, nil } +// newEmbeddingRequest 组装一次 /embeddings 调用:序列化请求体、**规范化端点主机** +// (URL 解析之后、拨号之前;只改主机,path/query 一字不动)、构造 POST 请求并打上 +// 鉴权头。它只负责组装,不负责发送。 +func (e *APIEmbedder) newEmbeddingRequest(ctx context.Context, texts []string) (*http.Request, error) { + payload, err := json.Marshal(openAICompatibleRequest{ + Model: e.cfg.Model, + Input: texts, + }) + if err != nil { + return nil, fmt.Errorf("embedding: marshal request: %w", err) + } + + endpoint, err := normalizeEndpointURL(e.cfg.APIURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("embedding: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if e.cfg.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+e.cfg.APIKey) + } + return req, nil +} + // readErrorBody drains a small prefix of a non-OK response body into a message, // capped at maxErrorBodyBytes so an error response cannot exhaust memory. func readErrorBody(r io.Reader) string { diff --git a/internal/runtime/embedding/api_url_test.go b/internal/runtime/embedding/api_url_test.go new file mode 100644 index 0000000..25a78b1 --- /dev/null +++ b/internal/runtime/embedding/api_url_test.go @@ -0,0 +1,265 @@ +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 侧悄悄把超时调到 ≥ 审查预算。 +const reviewBudgetRef = 20 * time.Second + +// TestDefaultRequestTimeout 钉住单次 embedding 调用的超时值,并断言它**严格小于** +// 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的 +// T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实 +// 打在了发出去的请求上。 +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) + } + + 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), + ) + if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil { + t.Fatalf("Embed() error: %v", err) + } + if slack <= 0 || slack > DefaultRequestTimeout { + t.Fatalf("请求 deadline 余量 = %s, want (0, %s]", slack, DefaultRequestTimeout) + } +} diff --git a/internal/runtime/embedding/endpoint.go b/internal/runtime/embedding/endpoint.go new file mode 100644 index 0000000..8725d78 --- /dev/null +++ b/internal/runtime/embedding/endpoint.go @@ -0,0 +1,75 @@ +package embedding + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// 本文件只管**端点地址的规范化**:把「连接语义上不可靠」的回环主机名别名改写 +// 成明确的 IPv4 回环字面量。它只改主机,不碰路径、查询串、fragment 与用户信息, +// 也不做任何名字解析或在线可达性探测。 +// +// 位置口径:规范化发生在 URL 解析之后、真正拨号之前(见 APIEmbedder.Embed), +// 因此它绝不改变 HTTP 报文里除 Host 之外的任何字节。 + +// loopbackIPv4 是唯一的 IPv4 回环字面量。选它而不是 0.0.0.0 的理由: +// 0.0.0.0 的语义是「监听所有接口」,**不是**一个可以连接的地址(实测拿它当连接 +// 目标会立即失败);而 127.0.0.1 在 Windows / Linux / macOS / Termux 上行为一致, +// 且完全不需要名字解析。 +const loopbackIPv4 = "127.0.0.1" + +// normalizeEndpointURL 返回规范化后的端点 URL。 +// +// 规范化范围只有三类,且都是**防呆**(把写坏的地址掰回可连接的形态), +// 而不是「支持拿这些地址去连」: +// - "localhost":名字解析可能先命中 IPv6 ::1,而本机 embedding 服务通常只绑 +// IPv4 回环(llama.cpp / ollama 默认如此)⇒ 那是一次注定无效的拨号。代价视环境 +// 而定:可能是几十毫秒的无效尝试(2026-10-07 实测本机 Go 客户端约 57ms, +// curl 约 0.2s),也可能在「::1 被静默丢弃」的防火墙/路由策略下一直等到系统 +// 连接超时,把一个 20s 级的调用预算整段吞掉。写死 IPv4 字面量直接绕开这个 +// 不确定性; +// - "0.0.0.0":见 loopbackIPv4 的说明,它不是可连接的地址; +// - 空主机(如 "http:///v1/embeddings"):拼出来的 URL 指向不了任何服务。 +// +// 不改写的:显式 IPv6 字面量("[::1]" 之类)、真实主机名 / IPv4、非 http(s) +// scheme —— 使用者怎么写就怎么来。未发生改写时**逐字返回原串**;发生改写时只替换 +// 主机,path / query / fragment / userinfo 由 net/url 原样重放。 +func normalizeEndpointURL(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("embedding: parse api url: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + // 不是本客户端能拨的 scheme:原样返回,让后续的报错如实暴露配置问题, + // 而不是在这里改主机把它掩盖掉。 + return raw, nil + } + host, changed := normalizeLoopbackHost(u.Hostname()) + if !changed { + return raw, nil + } + if port := u.Port(); port != "" { + u.Host = net.JoinHostPort(host, port) + } else { + u.Host = host + } + return u.String(), nil +} + +// normalizeLoopbackHost 判定主机名是否属于「不可靠的回环别名」,是则返回 +// 127.0.0.1。判定大小写不敏感("LocalHost" 同样改写)。IPv6 字面量不在表内 —— +// 使用者显式写了 [::1] 就尊重它,不做任何猜测。 +func normalizeLoopbackHost(host string) (string, bool) { + switch { + case host == "": + return loopbackIPv4, true + case host == "0.0.0.0": + return loopbackIPv4, true + case strings.EqualFold(host, "localhost"): + return loopbackIPv4, true + default: + return host, false + } +} diff --git a/internal/runtime/memory/preinsert_recall_test.go b/internal/runtime/memory/preinsert_recall_test.go new file mode 100644 index 0000000..51750dc --- /dev/null +++ b/internal/runtime/memory/preinsert_recall_test.go @@ -0,0 +1,29 @@ +package memory + +import ( + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" +) + +// TestEmbeddingTimeoutFitsReviewBudget 钉住一条**跨包**口径:embedding 单次调用的 +// 独立超时必须严格小于入库前审查的单条总预算(defaultReviewTimeout = 20s)。 +// +// 这不是风格问题:2026-10-07 实测的故障形态就是「端点主机写成 localhost → 先试 +// IPv6 ::1 → 连接等系统超时(Windows 上约 21s)」,一次 embedding 失败正好吃光 20s +// 预算,整条审查超时(`memory/review: context deadline exceeded`),排在后面的 T1 +// 灰区判定连执行机会都没有。地址规范化(embedding.normalizeEndpointURL)治的是根, +// 本断言守的是「就算再有新的卡死来源,也得先把预算留给后续步骤」。 +// +// embedding 侧同一口径也有一条钉值测试(api_url_test.go),两边互为护栏。 +func TestEmbeddingTimeoutFitsReviewBudget(t *testing.T) { + t.Parallel() + + if embedding.DefaultRequestTimeout <= 0 { + t.Fatalf("embedding.DefaultRequestTimeout = %s, 必须为正", embedding.DefaultRequestTimeout) + } + if embedding.DefaultRequestTimeout >= defaultReviewTimeout { + t.Fatalf("embedding 单次超时 %s 必须严格小于审查总预算 %s", + embedding.DefaultRequestTimeout, defaultReviewTimeout) + } +}