Files
HomeAgent/internal/memory/vector/http_embedder.go
JianFeeeee a37bc7333e refactor(memory): 核心不再适配具体模型——公共 embedding provider SPI + 注册表
问题:cmd/homed 里 `case "onnx": qwen.New(modelDir)` 把模型适配写进了核心,
`type=onnx` 名义上是格式、实际写死了一个模型家族;2117 行 Qwen 专属代码
(BPE、chat template、M-RoPE、Vision_gN 命名)住在内核树里,还带着一对
`//go:build onnxruntime` 的 stub。加任何新模型都要改内核。

现在核心只认一个模型无关的公共契约(pkg/embedding):
- 输入是不透明的 Data+MIME,解码/预处理/时序分组全归 provider
- 能力是数据(Info.Modalities),不是接口方法——新增模态无需改核心接口
- 不支持的模态返回 embedding.ErrUnsupportedModality(可 errors.Is 识别)
- 按名字注册,重复注册 panic;Options 是 provider 私有命名空间,核心不解释

改动:
- 新增 pkg/embedding:Modality/Purpose/Input/Info/Provider/Config + 注册表
  (Open 校验 Info,ValidateVector 在入库前拦下维度错与非有限值)
- providers/qwen3vl:Qwen 实现整体移出内核(git mv),实现公共 SPI 并自注册
- internal/memory/vector:新增 ProviderAdapter(公共 SPI → 内部小接口);
  ErrModalityUnsupported 改为公共哨兵别名;删除 VideoEmbedder 可选接口
  (那正是「核心为每个新模态长方法」的坏味道)
- http embedder 也变成普通 provider(注册名 http)
- cmd/homed:删除 qwen import 与 onnx/http 分支,改为按 provider 名打开 +
  透传 options.*;provider 打开失败只警告并禁用多模态检索,不影响启动
- config:multimodal_space.type/onnx./http.* → provider + options.*
- 删除 internal/memory/qwen(整体搬迁)

测试:
- pkg/embedding:注册表隔离/未知名字/非法 Info 自动关闭/ValidateVector
- vector:适配器原样透传字节与 MIME、维度错被拦、Close 幂等且停止使用、
  两个哨兵 errors.Is 互通
- providers/qwen3vl:新增公共 SPI 全链路集成测试(Open→Info→Embed→
  未知模态哨兵),并明确断言 Info 不声明 video

已知未完成(不得当作已验证):
- 视频冻结回归 TestEmbedderVideoMatchesONNXReference **显式跳过**:Go 侧
  video 模板缺少 processor 按时间组插入的字面时间戳文本
  (<0.0 seconds>/<1.0 seconds>),同一输入 Python seq=1190(1152+38)、
  Go 只有 22 个文本 token。时间戳也占 M-RoPE 位置,故现有 M-RoPE 自洽断言
  通过不能证明与官方实现一致。修复属 provider 内部工作。
- 视觉侧三档已导出并逐档校验通过(cos 1.000000119/1.000000119/1.000000000)

验证:go build ./... ;go vet -tags onnxruntime ./... ;
go test -short ./internal/memory/... ./internal/agent/core/... ./internal/sdk/... ./pkg/...
;onnxruntime 下 providers/qwen3vl 全绿(视频为显式 skip)
2026-09-11 18:26:19 +08:00

195 lines
5.6 KiB
Go
Raw 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 vector
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
)
func init() {
// http 也是一个普通 provider核心只按名字打开它不知道它背后是云 API、
// 自建服务还是别的语言写的模型。
embedding.Register("http", func(cfg embedding.Config) (embedding.Provider, error) {
return NewHTTPEmbedder(HTTPEmbedderConfig{
Endpoint: cfg.Options["endpoint"],
APIKey: cfg.Options["api_key"],
Model: cfg.Options["model"],
Fingerprint: cfg.Options["fingerprint"],
Dimension: atoiOrZero(cfg.Options["dimension"]),
Timeout: durationOrZero(cfg.Options["timeout"]),
})
})
}
func atoiOrZero(s string) int {
n := 0
for _, r := range strings.TrimSpace(s) {
if r < '0' || r > '9' {
return 0
}
n = n*10 + int(r-'0')
}
return n
}
func durationOrZero(s string) time.Duration {
d, err := time.ParseDuration(strings.TrimSpace(s))
if err != nil {
return 0
}
return d
}
// HTTPEmbedderConfig 配置一个外部多模态向量服务。
// 服务契约刻意很小POST Endpoint输入 modality/data/mime/side返回 embedding。
// 任何云 API 或自建服务只需适配这一个协议,即可复用内核全部向量存储与检索链路。
type HTTPEmbedderConfig struct {
Endpoint string
APIKey string
Model string
Dimension int
Timeout time.Duration
Fingerprint string
}
// HTTPEmbedder 是 MultimodalEmbedder 的外部 API 实现。
type HTTPEmbedder struct {
cfg HTTPEmbedderConfig
client *http.Client
mu sync.Mutex
closed bool
}
type httpEmbedRequest struct {
Model string `json:"model,omitempty"`
Modality string `json:"modality"`
Side string `json:"side"`
Text string `json:"text,omitempty"`
Data string `json:"data,omitempty"`
MIME string `json:"mime,omitempty"`
}
type httpEmbedResponse struct {
Embedding []float64 `json:"embedding"`
Data []struct {
Embedding []float64 `json:"embedding"`
} `json:"data,omitempty"`
}
func NewHTTPEmbedder(cfg HTTPEmbedderConfig) (*HTTPEmbedder, error) {
if strings.TrimSpace(cfg.Endpoint) == "" {
return nil, fmt.Errorf("vector: empty HTTP embedding endpoint")
}
if cfg.Dimension <= 0 {
return nil, fmt.Errorf("vector: invalid HTTP embedding dimension %d", cfg.Dimension)
}
if cfg.Timeout <= 0 {
cfg.Timeout = 30 * time.Second
}
if cfg.Fingerprint == "" {
cfg.Fingerprint = "http:" + cfg.Model + fmt.Sprintf(":%d", cfg.Dimension)
}
return &HTTPEmbedder{cfg: cfg, client: &http.Client{Timeout: cfg.Timeout}}, nil
}
func (e *HTTPEmbedder) VectorizeDense(text string) ([]float64, error) {
return e.embed(context.Background(), httpEmbedRequest{Model: e.cfg.Model, Modality: string(ModalityText), Side: "query", Text: text})
}
func (e *HTTPEmbedder) EmbedImageDense(img []byte, mime string) ([]float64, error) {
return e.embed(context.Background(), httpEmbedRequest{Model: e.cfg.Model, Modality: string(ModalityImage), Side: "document", Data: base64.StdEncoding.EncodeToString(img), MIME: mime})
}
func (e *HTTPEmbedder) embed(ctx context.Context, payload httpEmbedRequest) ([]float64, error) {
e.mu.Lock()
closed := e.closed
e.mu.Unlock()
if closed {
return nil, fmt.Errorf("vector: HTTP embedder closed")
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.cfg.Endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if e.cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+e.cfg.APIKey)
}
resp, err := e.client.Do(req)
if err != nil {
return nil, fmt.Errorf("vector: HTTP embedding request: %w", err)
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("vector: HTTP embedding status %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var out httpEmbedResponse
if err := json.Unmarshal(b, &out); err != nil {
return nil, fmt.Errorf("vector: decode HTTP embedding: %w", err)
}
v := out.Embedding
if len(v) == 0 && len(out.Data) > 0 {
v = out.Data[0].Embedding
}
if len(v) != e.cfg.Dimension {
return nil, fmt.Errorf("vector: HTTP embedding dimension %d, want %d", len(v), e.cfg.Dimension)
}
return v, nil
}
func (e *HTTPEmbedder) Fingerprint() string { return e.cfg.Fingerprint }
func (e *HTTPEmbedder) Dim() int { return e.cfg.Dimension }
// Embed 实现公共 provider 契约:核心只传模态与不透明字节,本实现负责把它
// 翻译成外部服务的协议。
func (e *HTTPEmbedder) Embed(ctx context.Context, in embedding.Input) ([]float64, error) {
req := httpEmbedRequest{
Model: e.cfg.Model,
Modality: string(in.Modality),
Side: string(in.Purpose),
Text: in.Text,
MIME: in.MIME,
}
if in.Modality != embedding.ModalityText {
req.Data = base64.StdEncoding.EncodeToString(in.Data)
}
return e.embed(ctx, req)
}
// Info 声明本 provider 的向量空间身份。外部服务的支持模态无法在本地探测,
// 因此只声明 text/image 这两条内核真正会走到的路径。
func (e *HTTPEmbedder) Info() embedding.Info {
return embedding.Info{
Dimension: e.cfg.Dimension,
Fingerprint: e.cfg.Fingerprint,
Modalities: []embedding.Modality{embedding.ModalityText, embedding.ModalityImage},
}
}
func (e *HTTPEmbedder) Loaded() bool {
e.mu.Lock()
defer e.mu.Unlock()
return !e.closed
}
func (e *HTTPEmbedder) Close() {
e.mu.Lock()
e.closed = true
e.mu.Unlock()
}