This commit is contained in:
toom1996
2026-10-18 17:42:10 +08:00
parent 1b06bd5bda
commit 1de1ec46a6
58 changed files with 5281 additions and 19191 deletions

177
internal/vision/client.go Normal file
View 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
}

View 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()
}

View 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)
}
}