fix(embedding): 端点地址规范化 + 独立短超时(治入库审查整条超时)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

背景:入库前审查链路一直整条超时(日志每 ~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 快速失败对这个真因对症;但真因
  (容量/争用)需要另排切片,已记入工作小结
This commit is contained in:
AskaEth committed 2026-10-07 16:22:28 +08:00
1 parent a4efbffe60
commit 987cefdf28
5 files changed
+433 -15

No files matched your search

+7 -2
View File
@@ -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 国内可下)
+57 -13
View File
@@ -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 {
+265
View File
@@ -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)
}
}
+75
View File
@@ -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
}
}
@@ -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)
}
}