Files
backend_v2/internal/vision/client.go
toom1996 1de1ec46a6 update
2026-10-18 17:42:10 +08:00

178 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package vision 调用独立部署的 FashionSigLIP 视觉服务,把图片编码成归一化向量。
//
// 设计要点(对应规格 docs/superpowers/specs/2026-10-12-fashionclip-siglip-vision-service-design.md §6/§11):
// - 服务不可用、超时、维度不符一律返回 ErrUnavailable,调用方据此「不写向量」而非中断入库;
// - 连续失败达到阈值后熔断冷却,避免每张图都卡在超时上;
// - 熔断期与禁用态下不发起任何 HTTP 请求。
//
// 该客户端只服务于「疑似同款」提示,**不参与任何自动去重**。
package vision
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"time"
)
// MaxBatch 单次请求的最大图片数,须与服务端限制保持一致。
const MaxBatch = 32
// cooldown 熔断后的冷却时长。
const cooldown = 5 * time.Minute
var (
// ErrUnavailable 表示服务不可用:未启用、熔断中、网络错误、超时、响应异常或维度不符。
ErrUnavailable = errors.New("vision service unavailable")
// ErrBatchTooLarge 表示单批图片数超过 MaxBatch。
ErrBatchTooLarge = errors.New("vision batch too large")
)
// embedResponse 是服务端 POST /embed 的响应结构。
type embedResponse struct {
Dim int `json:"dim"`
Vectors [][]float32 `json:"vectors"`
Indexes []int `json:"indexes"`
Skipped []int `json:"skipped"`
Device string `json:"device"`
}
// embedRequest 是 POST /embed 的请求结构。
type embedRequest struct {
Images []string `json:"images"`
}
// Client 是视觉服务客户端。并发安全。
type Client struct {
endpoint string
dim int
failLimit int
http *http.Client
mu sync.Mutex
fails int
cooldownEnd time.Time
disabled bool
}
// New 构造客户端。timeout 与 failLimit 非正时取默认值(5s / 3 次)。
func New(endpoint string, dim int, timeout time.Duration, failLimit int) *Client {
if timeout <= 0 {
timeout = 5 * time.Second
}
if failLimit <= 0 {
failLimit = 3
}
return &Client{
endpoint: endpoint,
dim: dim,
failLimit: failLimit,
http: &http.Client{Timeout: timeout},
}
}
// Disable 关闭客户端:此后 Embed 直接返回 ErrUnavailable 且不发起请求。
// 用于配置未启用视觉服务时走零开销路径。
func (c *Client) Disable() {
c.mu.Lock()
defer c.mu.Unlock()
c.disabled = true
}
// Enabled 报告客户端当前是否可发起请求(未禁用且不在熔断冷却期)。
func (c *Client) Enabled() bool { return c.Open() }
// Open 报告是否可以发起请求(未禁用且不在熔断冷却期)。
func (c *Client) Open() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.canRequestLocked()
}
func (c *Client) canRequestLocked() bool {
if c.disabled {
return false
}
return time.Now().After(c.cooldownEnd)
}
// fail 记录一次失败;达到阈值则进入熔断冷却并清零计数。
func (c *Client) fail() {
c.mu.Lock()
defer c.mu.Unlock()
c.fails++
if c.fails >= c.failLimit {
c.cooldownEnd = time.Now().Add(cooldown)
c.fails = 0
}
}
// ok 记录一次成功,清零连续失败计数。
func (c *Client) ok() {
c.mu.Lock()
defer c.mu.Unlock()
c.fails = 0
}
// Embed 把图片字节批量编码为归一化向量,返回的 skipped 是无法解码的图片下标。
//
// 任何故障(未启用 / 熔断 / 网络 / 超时 / 响应异常 / 维度不符)都返回 ErrUnavailable,
// 调用方应据此跳过写向量,而不是让入库失败。
// 空输入直接返回 nil, nil, nil;超过 MaxBatch 返回 ErrBatchTooLarge。
func (c *Client) Embed(ctx context.Context, imgs [][]byte) ([][]float32, []int, error) {
if len(imgs) == 0 {
return nil, nil, nil
}
if len(imgs) > MaxBatch {
return nil, nil, ErrBatchTooLarge
}
if !c.Open() {
return nil, nil, ErrUnavailable
}
payload := embedRequest{Images: make([]string, len(imgs))}
for i, b := range imgs {
payload.Images[i] = base64.StdEncoding.EncodeToString(b)
}
body, err := json.Marshal(payload)
if err != nil {
return nil, nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/embed", bytes.NewReader(body))
if err != nil {
return nil, nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
c.fail()
return nil, nil, ErrUnavailable
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
c.fail()
return nil, nil, ErrUnavailable
}
var out embedResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
c.fail()
return nil, nil, ErrUnavailable
}
if out.Dim != c.dim {
// 维度不符属于配置与模型不一致:必须报错并拒写,防止污染向量列。
c.fail()
return nil, nil, fmt.Errorf("%w: dim=%d want=%d", ErrUnavailable, out.Dim, c.dim)
}
c.ok()
return out.Vectors, out.Skipped, nil
}