fix(embedding): 端点地址规范化 + 独立短超时(治入库审查整条超时)
背景:入库前审查链路一直整条超时(日志每 ~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:
1 parent
a4efbffe60
commit
987cefdf28
5 files changed
+433
-15
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user