Files
HomeAgent/providers/qwen3vl/embedder_onnx_test.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

557 lines
20 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.

//go:build onnxruntime
package qwen3vl
import (
"bytes"
"context"
"encoding/json"
"errors"
"image"
"image/png"
"math"
"os"
"path/filepath"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
)
// onnxModelDir 返回三段式 Qwen 多模态 ONNX 产物目录。
//
// 产物约 8GB含外部权重不进仓库由 scripts/export_qwen3vl_embedding_onnx.py
// 自动拉取模型并导出。可通过 QWEN_ONNX_MODEL_DIR 指向别处;目录不存在时相关
// 测试跳过而不是失败——CI 与本机开发者都不一定有这份产物。
func onnxModelDir() string {
if v := os.Getenv("QWEN_ONNX_MODEL_DIR"); v != "" {
return v
}
return "/home/newqqagent/models/qwen3-vl-embed-multimodal-onnx"
}
// artifactDeclaresVideo 读产物自带的 embed_config.json判断它是否声明支持原生视频。
//
// 用途:把「这个产物本来就不含视频」与「这个产物应该有视频,但参考里没有」分开。
// 后者是产物/参考不匹配,必须报错而不是跳过——否则一个声明了视频支持的目录
// 可以带着空视频参考一路「通过」。
func artifactDeclaresVideo(t *testing.T, dir string) bool {
t.Helper()
data, err := os.ReadFile(filepath.Join(dir, "embed_config.json"))
if err != nil {
return false
}
var cfg struct {
SupportsNativeVideo bool `json:"supports_native_video"`
VideoGroups []int `json:"video_groups"`
}
if err := json.Unmarshal(data, &cfg); err != nil {
return false
}
return cfg.SupportsNativeVideo || len(cfg.VideoGroups) > 0
}
func requireONNXArtifacts(t *testing.T) string {
t.Helper()
dir := onnxModelDir()
for _, name := range []string{"TokenEmbedding.onnx", "Transformer.onnx", "Vision.onnx", "embed_config.json", "tokenizer.json"} {
if _, err := os.Stat(dir + "/" + name); err != nil {
t.Skipf("ONNX 产物不完整(%s: %v跳过用 scripts/export_qwen3vl_embedding_onnx.py 导出", name, err)
}
}
return dir
}
// onnxReference 是导出脚本 `--emit-reference` 写出的冻结参考。
//
// 刻意不把浮点常量硬编码在测试里:参考值必须能追溯到「哪个模型、哪次导出、
// 什么输入」,而不是一组无人知道出处的数字。参考文件里的 RGB/尺寸同时用来
// 构造测试图片,保证输入与参考按构造一致,不会因测试改动而静默错位。
type onnxReference struct {
Text string `json:"text"`
TextVectorPrefix []float64 `json:"text_vector_prefix"`
ImageRGB []int `json:"image_rgb"`
ImageSize int `json:"image_size"`
ImageVectorPrefix []float64 `json:"image_vector_prefix"`
Dim int `json:"dim"`
// 视频参考相邻两帧构成一个时间组tp0←帧2g、tp1←帧2g+1
// 帧颜色用来构造与导出脚本完全一致的测试输入。
VideoGroups int `json:"video_groups"`
VideoFrameRGB [][]int `json:"video_frame_rgb"`
VideoVectorPrefix []float64 `json:"video_vector_prefix"`
}
func loadReference(t *testing.T, dir string) *onnxReference {
t.Helper()
path := os.Getenv("QWEN_ONNX_REFERENCE")
if path == "" {
path = filepath.Join(dir, "qwen_reference.json")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Skipf("缺少冻结参考 %s由 scripts/export_qwen3vl_embedding_onnx.py --emit-reference 生成): %v", path, err)
}
var ref onnxReference
if err := json.Unmarshal(raw, &ref); err != nil {
t.Fatalf("解析参考 %s: %v", path, err)
}
if ref.Text == "" || len(ref.TextVectorPrefix) == 0 || len(ref.ImageRGB) != 3 || ref.ImageSize <= 0 {
t.Fatalf("参考 %s 不完整: %+v", path, ref)
}
return &ref
}
// solidPNG 生成一张 size×size 纯色 PNG供跨语言冻结向量回归。
//
// 刻意用纯色且尺寸与视觉塔一致Go 侧预处理对已是 768×768 的输入不做插值、
// 不补边,于是 patch 张量只由布局决定。一旦 patch 排列写错(内层循环顺序、
// merge 分组顺序、通道顺序),冻结向量立刻不匹配——而那类错误在人工看图时
// 几乎发现不了。
func solidPNG(t *testing.T, size int, r, g, b uint8) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
i := img.PixOffset(x, y)
img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = r, g, b, 255
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
func newTestEmbedder(t *testing.T, dir string) *Embedder {
t.Helper()
e, err := New(dir)
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(e.Close)
info := e.Info()
if info.Dimension != 2048 || info.Fingerprint == "" {
t.Fatalf("元数据异常: dim=%d fingerprint=%q", info.Dimension, info.Fingerprint)
}
if err := embedding.ValidateInfo(info); err != nil {
t.Fatalf("Info 不满足公共契约: %v", err)
}
return e
}
func assertNormalized(t *testing.T, name string, got []float64, dim int) {
t.Helper()
if dim > 0 && len(got) != dim {
t.Fatalf("%s 维度 = %d期望 %d", name, len(got), dim)
}
var norm float64
for _, v := range got {
norm += v * v
}
if diff := math.Abs(math.Sqrt(norm) - 1); diff > 1e-6 {
t.Errorf("%s L2 norm = %.9f,期望 1", name, math.Sqrt(norm))
}
}
func assertFrozenPrefix(t *testing.T, name string, got, want []float64) {
t.Helper()
if len(got) < len(want) {
t.Fatalf("%s 向量过短: %d", name, len(got))
}
for i := range want {
if diff := math.Abs(got[i] - want[i]); diff > 2e-5 {
t.Errorf("%s 维度 %d = %.10g,参考 %.10g,差 %.3g", name, i, got[i], want[i], diff)
}
}
}
// TestEmbedderMatchesONNXReference 逐维对比导出脚本写出的冻结参考向量,
// 验证完整 Go 路径:模板渲染 → BPE → TokenEmbedding → Transformer →
// last-token 池化 → L2 normalize。
//
// 只覆盖前若干维不是因为放宽正确性(脚本侧的 PyTorch↔ONNX 校验是逐维的),
// 而是避免把 2048 个浮点常量塞进仓库;这里负责捕获 Go 张量形状、输入名、
// 输出名、池化/归一化或模板接线错误。
func TestEmbedderMatchesONNXReference(t *testing.T) {
dir := requireONNXArtifacts(t)
e := newTestEmbedder(t, dir)
ref := loadReference(t, dir)
ids, _, _, _, err := e.tok.textModelInput(e.config.Instruction, ref.Text, e.config.MaxLength)
if err != nil {
t.Fatalf("textModelInput: %v", err)
}
postID, ok := e.tok.SpecialID("<|endoftext|>")
if !ok || ids[len(ids)-1] != postID {
t.Fatalf("模型输入 post-processor 异常: len=%d tail=%v postID=%d ok=%v", len(ids), ids[len(ids)-1:], postID, ok)
}
got, err := e.VectorizeDense(ref.Text)
if err != nil {
t.Fatalf("VectorizeDense: %v", err)
}
assertNormalized(t, "text", got, ref.Dim)
assertFrozenPrefix(t, "text", got, ref.TextVectorPrefix)
}
// TestEmbedderImageMatchesONNXReference 冻结一张纯色图的参考向量,
// 验证 Go 侧的视觉预处理 + patch 排列 + 视觉注入 + 语言模型与 Python 参考一致。
func TestEmbedderImageMatchesONNXReference(t *testing.T) {
dir := requireONNXArtifacts(t)
e := newTestEmbedder(t, dir)
ref := loadReference(t, dir)
img := solidPNG(t, ref.ImageSize, uint8(ref.ImageRGB[0]), uint8(ref.ImageRGB[1]), uint8(ref.ImageRGB[2]))
got, err := e.EmbedImageDense(img, "image/png")
if err != nil {
t.Fatalf("EmbedImageDense: %v", err)
}
assertNormalized(t, "image", got, ref.Dim)
assertFrozenPrefix(t, "image", got, ref.ImageVectorPrefix)
}
// TestEmbedderTextIsSensitiveToInput 阴性对照:冻结向量必须真的随输入变化。
//
// 没有这条对照,一个「永远返回同一向量」的错误实现也能通过上面的冻结回归
// (只要那个常量恰好等于参考值)。这里验证不同文本给出不同向量,且相似文本
// 的余弦高于无关文本——即嵌入确实携带语义,而不是常量。
func TestEmbedderTextIsSensitiveToInput(t *testing.T) {
dir := requireONNXArtifacts(t)
e := newTestEmbedder(t, dir)
base, err := e.VectorizeDense("今天天气怎么样")
if err != nil {
t.Fatalf("VectorizeDense: %v", err)
}
same, err := e.VectorizeDense("今天天气怎么样")
if err != nil {
t.Fatalf("VectorizeDense: %v", err)
}
if cosine(base, same) < 0.999999 {
t.Errorf("同一输入两次嵌入不一致: cos=%.9fONNX 会话被并发复用或存在非确定性)", cosine(base, same))
}
other, err := e.VectorizeDense("数据库索引的选择性是怎么计算的")
if err != nil {
t.Fatalf("VectorizeDense: %v", err)
}
if cosine(base, other) > 0.999 {
t.Errorf("无关文本的余弦高达 %.6f,嵌入可能是常量", cosine(base, other))
}
}
// TestEmbedderImageMatchesONNXReference 的替代:不依赖冻结参考的不变量检查。
//
// 即使参考文件缺失(没有导出产物)或未重新生成,这些不变量也应成立:
// 图像路径必须真的走了视觉塔,且不同图片给出不同坐标。
func TestEmbedderImageDiffersFromText(t *testing.T) {
dir := requireONNXArtifacts(t)
e := newTestEmbedder(t, dir)
imgVec, err := e.EmbedImageDense(solidPNG(t, qwenImageSize, 200, 30, 30), "image/png")
if err != nil {
t.Fatalf("EmbedImageDense: %v", err)
}
txtVec, err := e.VectorizeDense(DefaultInstruction)
if err != nil {
t.Fatalf("VectorizeDense: %v", err)
}
if cosine(imgVec, txtVec) > 0.999 {
t.Error("图像向量与文本向量几乎相同,视觉塔可能没被真正执行")
}
// 不同颜色的图必须给出不同向量
blue, err := e.EmbedImageDense(solidPNG(t, qwenImageSize, 30, 150, 220), "image/png")
if err != nil {
t.Fatalf("EmbedImageDense: %v", err)
}
if cosine(imgVec, blue) > 0.999999 {
t.Error("不同图片给出相同向量,视觉路径未生效")
}
}
// TestEmbedderVideoMatchesONNXReference 冻结一个视频用例的参考向量,
// 验证 Go 侧完整的视频路径:多帧预处理(时间组布局)→ M-RoPE<|video_pad|>
// → 视觉注入 → 语言模型。
//
// 帧颜色在参考里,用来构造与导出脚本一致的输入;帧顺序(组 g 的 tp0←帧2g、
// tp1←帧2g+1写错时这个测试会失败——而那类错误看图时发现不了。
//
// ⚠️ 当前**明确未通过**因此跳过而不是静默当通过Go 侧的 video 模板
// 与 HuggingFace processor 产出的不相等。已定位的差异processor 会按时间组
// 插入字面时间戳文本,逐 token 实测为
//
// <|vision_start|> <0.0 seconds> <|vision_start|> {576×video_pad} <|vision_end|>
// <1.0 seconds> <|vision_start|> {576×video_pad} <|vision_end|>
//
// 而 Go 侧只生成 <|vision_start|>{G×576 pads}<|vision_end|>。实测同一输入
// 下 Python seq=11901152 视觉 + 38 文本、Go 侧只有 22 个文本 token。
// 时间戳文本也会占用 M-RoPE 位置,因此 TestVideoModelInputMRope 的自洽断言
// 虽然通过,也不能证明与官方实现一致。
//
// 修复位置在**本 provider 内部**(模型专属模板本就属于这里,不属于核心):
// 按 processor 的规则生成同样的分组时间戳文本,然后取消本跳过。
func TestEmbedderVideoMatchesONNXReference(t *testing.T) {
dir := requireONNXArtifacts(t)
e := newTestEmbedder(t, dir)
ref := loadReference(t, dir)
if ref.VideoGroups < 2 || len(ref.VideoFrameRGB) != 2*ref.VideoGroups {
// 产物声明了视频支持、参考里却没有视频用例 → 参考没跟上产物,这是缺陷。
// 只有「产物本来就不含视频」才允许跳过。
if artifactDeclaresVideo(t, dir) {
t.Fatalf("产物声明支持原生视频但参考缺少视频用例video_groups=%d frames=%d"+
"参考与产物不匹配,请重跑导出脚本的 --verify-only",
ref.VideoGroups, len(ref.VideoFrameRGB))
}
t.Skipf("产物不含原生视频video_groups=%d跳过视频回归", ref.VideoGroups)
}
// 产物确实带视频用例:说明我们应当能验证。但 Go 侧模板尚未复现 processor
// 的分组时间戳,现在跑必然失败。显式跳过并说明原因,避免出现
// 「测试通过」与「视频实际未验证」混为一谈。
if ref.VideoGroups > 0 {
t.Skip("已知未修复Go 侧 video 模板缺少 processor 插入的分组时间戳文本" +
"(详见本测试注释);修复前视频冻结回归不得视为已验证")
}
frames := make([][]byte, len(ref.VideoFrameRGB))
for i, rgb := range ref.VideoFrameRGB {
if len(rgb) != 3 {
t.Fatalf("帧 %d 颜色字段异常: %v", i, rgb)
}
frames[i] = solidPNG(t, ref.ImageSize, uint8(rgb[0]), uint8(rgb[1]), uint8(rgb[2]))
}
got, err := e.EmbedVideoDense(frames, "video/mp4")
if err != nil {
t.Fatalf("EmbedVideoDense: %v", err)
}
assertNormalized(t, "video", got, ref.Dim)
assertFrozenPrefix(t, "video", got, ref.VideoVectorPrefix)
}
// TestVideoModelInputMRope 逐 token 校验视频的 M-RoPE 位置。
//
// 对应 transformers 的 get_rope_index它先把 video_grid_thw 按 grid_t 展开成
// G 个 (1,h,w) 的 grid 项,每项单独算位置,项间 current_pos 前进
// max(h,w)/spatial_merge。位置算错不会报错只是嵌入慢慢变差所以必须逐项验。
func TestVideoModelInputMRope(t *testing.T) {
dir := requireONNXArtifacts(t)
e := newTestEmbedder(t, dir)
const groups = 3
ids, _, position, visual, err := e.tok.videoModelInput("", groups, e.config.MaxLength)
if err != nil {
t.Fatalf("videoModelInput: %v", err)
}
seq := len(ids)
// 模板必须以 <|video_pad|> 填充(用成 <|image_pad|> 不会报错,只会错模态)。
videoPad, ok := e.tok.SpecialID("<|video_pad|>")
if !ok {
t.Fatal("tokenizer 缺少 <|video_pad|>")
}
imagePad, _ := e.tok.SpecialID("<|image_pad|>")
wantVisual := groups * qwenVisualTokens
count := 0
for i, id := range ids {
if visual[i] {
count++
if id != videoPad {
t.Fatalf("第 %d 个视觉 token id=%d期望 video_pad=%dimage_pad=%d", i, id, videoPad, imagePad)
}
}
}
if count != wantVisual {
t.Fatalf("视觉 token 数 = %d期望 %d", count, wantVisual)
}
start := -1
for i, v := range visual {
if v {
start = i
break
}
}
if start < 0 {
t.Fatal("找不到视觉区间")
}
// 视觉区间必须连续(中间不能夹文本 token
for i := start; i < start+wantVisual; i++ {
if !visual[i] {
t.Fatalf("视觉区间在 %d 处断裂", i)
}
}
if start+wantVisual < seq && visual[start+wantVisual] {
t.Fatal("视觉区间超出期望长度")
}
// 视觉之前的文本 token 数就是 M-RoPE 的起始位置。
base0 := int64(start)
for g := 0; g < groups; g++ {
base := base0 + int64(g*qwenVisionScale)
for j := 0; j < qwenVisualTokens; j++ {
i := start + g*qwenVisualTokens + j
wantT := base
wantH := base + int64(j/qwenVisionScale)
wantW := base + int64(j%qwenVisionScale)
if position[i] != wantT || position[seq+i] != wantH || position[2*seq+i] != wantW {
t.Fatalf("组%d 第%d 个视觉 token 位置 = (%d,%d,%d),期望 (%d,%d,%d)",
g, j, position[i], position[seq+i], position[2*seq+i], wantT, wantH, wantW)
}
}
}
}
// TestVideoInputRejectsUnsupportedShapes 帧数与档位不匹配时必须明确报错,
// 而不是悄悄补齐/截断成另一个语义。
func TestVideoInputRejectsUnsupportedShapes(t *testing.T) {
if _, _, err := preprocessVideoFrames([][]byte{solidPNG(t, qwenImageSize, 1, 2, 3)}); err == nil {
t.Error("单帧无法构成一个时间组,应报错")
}
many := make([][]byte, 2*(maxVideoGroupsSafety+1))
if _, _, err := preprocessVideoFrames(many); err == nil {
t.Errorf("超过分配安全上限 %d 应报错,而不是静默分配巨量内存", maxVideoGroupsSafety)
}
dir := requireONNXArtifacts(t)
e := newTestEmbedder(t, dir)
if _, _, _, _, err := e.tok.visionModelInput("", "<|video_pad|>", 0, e.config.MaxLength); err == nil {
t.Error("groups=0 应报错")
}
// 未导出的档位必须明确报错并告知已加载哪些档,而不是默默找一个相近的。
if _, err := e.EmbedVideoDense(framesOf(t, 2*(maxExportedGroupsInTest+1)), "video/mp4"); err == nil {
t.Errorf("未导出的 G=%d 应报错", maxExportedGroupsInTest+1)
}
}
// maxExportedGroupsInTest 是测试环境预期导出的视频最大档(与导出脚本默认 2,3,4 一致)。
const maxExportedGroupsInTest = 4
func framesOf(t *testing.T, n int) [][]byte {
t.Helper()
out := make([][]byte, n)
for i := range out {
out[i] = solidPNG(t, qwenImageSize, uint8(i), 100, 150)
}
return out
}
// TestEmbedderRejectsUnsupportedModalities 音频必须显式报「不在本空间」。
//
// Qwen3-VL 模型卡与 config 双重确认无 audio_token_id音频需要另一个真正的
// 音频模型。若这里退化成普通错误,调用方会把它当「本次失败、下次重试」,
// 于是每轮启动都重试一批永远不可能成功的条目。
func TestEmbedderRejectsUnsupportedModalities(t *testing.T) {
dir := requireONNXArtifacts(t)
e := newTestEmbedder(t, dir)
for _, mime := range []string{"audio/wav", "audio/mpeg"} {
_, err := e.EmbedImageDense([]byte("not-a-real-media"), mime)
if err == nil {
t.Fatalf("%s 应返回错误而不是造出向量", mime)
}
if !errors.Is(err, embedding.ErrUnsupportedModality) {
t.Errorf("%s 错误应为 ErrUnsupportedModality实际: %v", mime, err)
}
// 公共 SPI 路径也必须给出可识别的不支持信号。
if _, err := e.Embed(context.Background(), embedding.Input{
Modality: embedding.ModalityAudio, Data: []byte("x"), MIME: mime,
}); !errors.Is(err, embedding.ErrUnsupportedModality) {
t.Errorf("Embed(audio/%s) 应为 ErrUnsupportedModality实际: %v", mime, err)
}
}
// 视频**文件**不能直接喂给单帧入口Go 侧没有视频解码器),
// 必须由调用方先抽帧再走 EmbedVideoDense。
if _, err := e.EmbedImageDense([]byte("not-a-real-media"), "video/mp4"); !errors.Is(err, embedding.ErrUnsupportedModality) {
t.Errorf("EmbedImageDense(video/mp4) 应为 ErrUnsupportedModality实际: %v", err)
}
}
func cosine(a, b []float64) float64 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var dot, na, nb float64
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
// TestProviderOpensThroughPublicSPI 走完核心真正的路径:
// embedding.Open(名字) → 工厂 → Info 校验 → Embed。
//
// 它与 newTestEmbedder 的区别很关键:后者直接调 New(),只能证明「模型能加载」;
// 本测试证明**注册表 + 公共契约**这条链路是通的——名字对得上、工厂能构造、
// Info 满足契约、Embed 返回合法向量。核心升级后真正会走的就是这条路由。
func TestProviderOpensThroughPublicSPI(t *testing.T) {
dir := requireONNXArtifacts(t)
names := embedding.Names()
found := false
for _, n := range names {
if n == "qwen3vl" {
found = true
}
}
if !found {
t.Fatalf("qwen3vl 未注册到公共注册表;已注册: %v", names)
}
provider, err := embedding.Open("qwen3vl", embedding.Config{
Options: map[string]string{"model_dir": dir},
})
if err != nil {
t.Fatalf("embedding.Open(qwen3vl): %v", err)
}
defer provider.Close()
info := provider.Info()
if info.Dimension != 2048 || info.Fingerprint == "" {
t.Fatalf("Info 异常: dim=%d fp=%q", info.Dimension, info.Fingerprint)
}
// 公共契约路径只声明 text/image本 provider 没有视频解码器,
// 若这里出现 video 就意味着核心会创建一条注定失败的输入通道。
for _, m := range info.Modalities {
if m == embedding.ModalityVideo {
t.Fatal("Info 不应声明 videoprovider 无视频解码器,见文档)")
}
}
vec, err := provider.Embed(context.Background(), embedding.Input{
Modality: embedding.ModalityText, Purpose: embedding.PurposeQuery, Text: "hello",
})
if err != nil {
t.Fatalf("Embed(text): %v", err)
}
if err := embedding.ValidateVector(vec, info.Dimension); err != nil {
t.Fatalf("返回向量不合法: %v", err)
}
// 未知模态必须给出可识别的「本空间不支持」,而不是普通错误。
_, err = provider.Embed(context.Background(), embedding.Input{
Modality: embedding.ModalityAudio, Data: []byte("x"), MIME: "audio/wav",
})
if !errors.Is(err, embedding.ErrUnsupportedModality) {
t.Fatalf("audio 应为 ErrUnsupportedModality实际: %v", err)
}
}
// TestOpenRejectsProviderWithoutModelDir 未配置 model_dir 时必须是明确的构造失败,
// 而不是构造成功、每次 Embed 才报错(那会让启动日志看起来正常)。
func TestOpenRejectsProviderWithoutModelDir(t *testing.T) {
if _, err := embedding.Open("qwen3vl", embedding.Config{}); err == nil {
t.Fatal("缺 model_dir 时应打开失败")
}
}