update
This commit is contained in:
177
internal/vision/client.go
Normal file
177
internal/vision/client.go
Normal file
@ -0,0 +1,177 @@
|
||||
// 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
|
||||
}
|
||||
104
internal/vision/client_integration_test.go
Normal file
104
internal/vision/client_integration_test.go
Normal file
@ -0,0 +1,104 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:打真实运行的视觉服务,验证 Go 客户端与服务端的接口契约。
|
||||
//
|
||||
// 运行前先起服务(本地开发用项目 .venv,走 GPU):
|
||||
//
|
||||
// cd D:\project\fashion-siglip
|
||||
// .venv\Scripts\python.exe -m uvicorn app:app --host 127.0.0.1 --port 8900 --workers 1
|
||||
//
|
||||
// 然后:
|
||||
//
|
||||
// go test -tags integration ./internal/vision/ -run TestClientAgainstRealService -v
|
||||
//
|
||||
// 服务未启动时本测试 **跳过**(不算失败),以免阻塞没有该服务的环境。
|
||||
// 可用 VISION_ENDPOINT 环境变量指向其它地址。
|
||||
package vision
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientAgainstRealService(t *testing.T) {
|
||||
endpoint := os.Getenv("VISION_ENDPOINT")
|
||||
if endpoint == "" {
|
||||
endpoint = "http://127.0.0.1:8900"
|
||||
}
|
||||
|
||||
// 1) 探活:不通就跳过(本测试依赖外部进程,不应让 CI 变红)。
|
||||
hc := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := hc.Get(endpoint + "/health")
|
||||
if err != nil {
|
||||
t.Skipf("视觉服务不可达(%s),跳过集成测试:%v", endpoint, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var health struct {
|
||||
Ready bool `json:"ready"`
|
||||
Dim int `json:"dim"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
|
||||
t.Fatalf("解析 /health 失败: %v", err)
|
||||
}
|
||||
if !health.Ready {
|
||||
t.Skipf("视觉服务未就绪(模型仍在加载或加载失败),跳过:%+v", health)
|
||||
}
|
||||
t.Logf("服务就绪:dim=%d device=%s", health.Dim, health.Device)
|
||||
|
||||
// 2) 用一张程序生成的 PNG 验证完整契约:base64 请求 → 服务解码 → 768 维归一化向量。
|
||||
img := generateTestPNG(t)
|
||||
|
||||
c := New(endpoint, health.Dim, 60*time.Second, 3)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
vecs, skipped, err := c.Embed(ctx, [][]byte{img})
|
||||
if err != nil {
|
||||
t.Fatalf("Embed 失败: %v", err)
|
||||
}
|
||||
if len(skipped) != 0 {
|
||||
t.Fatalf("合法 PNG 不应被跳过,skipped=%v", skipped)
|
||||
}
|
||||
if len(vecs) != 1 {
|
||||
t.Fatalf("应返回 1 个向量,得到 %d", len(vecs))
|
||||
}
|
||||
if got := len(vecs[0]); got != health.Dim {
|
||||
t.Fatalf("向量维度应为 %d,得到 %d", health.Dim, got)
|
||||
}
|
||||
|
||||
// 服务端承诺已 L2 归一化:模长≈1 是「点积即余弦相似度」的前提。
|
||||
var sumSq float64
|
||||
for _, f := range vecs[0] {
|
||||
sumSq += float64(f) * float64(f)
|
||||
}
|
||||
norm := sumSq
|
||||
if norm < 0.99 || norm > 1.01 {
|
||||
t.Fatalf("向量应已 L2 归一化(模长²≈1),实际 %f", norm)
|
||||
}
|
||||
}
|
||||
|
||||
// generateTestPNG 生成一张 64x64 的纯色 PNG(不引入外部测试资源)。
|
||||
func generateTestPNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||
for x := 0; x < 64; x++ {
|
||||
for y := 0; y < 64; y++ {
|
||||
img.Set(x, y, color.RGBA{R: 200, G: 60, B: 120, A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("生成测试 PNG 失败: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
164
internal/vision/client_test.go
Normal file
164
internal/vision/client_test.go
Normal file
@ -0,0 +1,164 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEmbedReturnsVectorsAndSkipsBroken 验证正常路径:批量编码返回向量,
|
||||
// 服务端跳过的图片下标原样带回,向量维度与配置一致。
|
||||
func TestEmbedReturnsVectorsAndSkipsBroken(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/embed" {
|
||||
t.Errorf("路径不对: %s", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"dim": 768,
|
||||
"vectors": [][]float32{make([]float32, 768), make([]float32, 768)},
|
||||
"indexes": []int{0, 2},
|
||||
"skipped": []int{1},
|
||||
"device": "cpu",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, 768, 2*time.Second, 3)
|
||||
vecs, skipped, err := c.Embed(context.Background(), [][]byte{[]byte("a"), []byte("b"), []byte("c")})
|
||||
if err != nil {
|
||||
t.Fatalf("Embed 返回错误: %v", err)
|
||||
}
|
||||
if len(vecs) != 2 {
|
||||
t.Fatalf("应有 2 个向量,得到 %d", len(vecs))
|
||||
}
|
||||
if len(skipped) != 1 || skipped[0] != 1 {
|
||||
t.Fatalf("skipped 应为 [1],得到 %v", skipped)
|
||||
}
|
||||
if len(vecs[0]) != 768 {
|
||||
t.Fatalf("向量维度应为 768,得到 %d", len(vecs[0]))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbedDisabledSkipsCall 验证禁用后完全不发起 HTTP 请求(视觉服务未启用时的零开销路径)。
|
||||
func TestEmbedDisabledSkipsCall(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, 768, time.Second, 3)
|
||||
c.Disable()
|
||||
vecs, skipped, err := c.Embed(context.Background(), [][]byte{[]byte("x")})
|
||||
if err != ErrUnavailable {
|
||||
t.Fatalf("禁用时应返回 ErrUnavailable,实际 %v", err)
|
||||
}
|
||||
if vecs != nil || skipped != nil {
|
||||
t.Fatalf("禁用时应返回 nil,实际 %v %v", vecs, skipped)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("禁用时不应发起 HTTP 请求")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbedDimensionMismatchIsError 验证维度不符必须报错:
|
||||
// 若放过,会把错误维度的向量写进 image_embeddings 造成脏数据。
|
||||
func TestEmbedDimensionMismatchIsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"dim": 512, "vectors": [][]float32{make([]float32, 512)}, "indexes": []int{0}, "skipped": []int{},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, 768, time.Second, 3)
|
||||
if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil {
|
||||
t.Fatal("维度不符应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbedTripBreakerThenCooldownSkips 验证熔断:连续失败达阈值后进入冷却,
|
||||
// 冷却期内直接返回错误且不再发起请求(避免每张图都卡在超时上)。
|
||||
func TestEmbedTripBreakerThenCooldownSkips(t *testing.T) {
|
||||
hits := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, 768, time.Second, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil {
|
||||
t.Fatalf("第 %d 次调用应报错", i+1)
|
||||
}
|
||||
}
|
||||
if c.Open() {
|
||||
t.Fatal("熔断后 Open 应为 false")
|
||||
}
|
||||
before := hits
|
||||
if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil {
|
||||
t.Fatalf("熔断期应直接返回错误: %v", err)
|
||||
}
|
||||
if hits != before {
|
||||
t.Fatalf("熔断期间不应再发起请求,hits 从 %d 变为 %d", before, hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbedSendsBase64Payload 验证请求体格式(base64 编码的图片数组),
|
||||
// 这是与服务端约定的契约,写错会导致服务端解码失败。
|
||||
func TestEmbedSendsBase64Payload(t *testing.T) {
|
||||
var got struct {
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"dim": 768, "vectors": [][]float32{make([]float32, 768)}, "indexes": []int{0}, "skipped": []int{},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, 768, time.Second, 3)
|
||||
if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("hello")}); err != nil {
|
||||
t.Fatalf("Embed 返回错误: %v", err)
|
||||
}
|
||||
want := base64.StdEncoding.EncodeToString([]byte("hello"))
|
||||
if len(got.Images) != 1 || got.Images[0] != want {
|
||||
t.Fatalf("请求体 base64 不正确: %v", got.Images)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbedEmptyInputIsNoop 验证空输入不发起请求、不报错。
|
||||
func TestEmbedEmptyInputIsNoop(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, 768, time.Second, 3)
|
||||
vecs, skipped, err := c.Embed(context.Background(), nil)
|
||||
if err != nil || vecs != nil || skipped != nil {
|
||||
t.Fatalf("空输入应返回 nil,nil,nil,实际 %v %v %v", vecs, skipped, err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("空输入不应发起请求")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbedBatchTooLarge 验证单批上限,超限直接拒绝(避免把一张大图撑爆服务端)。
|
||||
func TestEmbedBatchTooLarge(t *testing.T) {
|
||||
c := New("http://127.0.0.1:1", 768, time.Second, 3)
|
||||
imgs := make([][]byte, MaxBatch+1)
|
||||
for i := range imgs {
|
||||
imgs[i] = []byte("x")
|
||||
}
|
||||
if _, _, err := c.Embed(context.Background(), imgs); err != ErrBatchTooLarge {
|
||||
t.Fatalf("超上限应返回 ErrBatchTooLarge,实际 %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user