178 lines
4.9 KiB
Go
178 lines
4.9 KiB
Go
// 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
|
||
}
|