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

+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 {