mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-02 15:23:57 +00:00
此前三段式拆分后 ONNX 路径从未从 Go 侧跑通:embedder_onnx_test.go 仍引用 分段前的 API(e.renderInput、TextTower.onnx、旧目录),go vet -tags onnxruntime 直接编译失败。导出脚本只在 /tmp 且硬编码本机路径、从第三个目录拷贝固定形状的 Vision.onnx,完全不可复现。音频会被视觉塔编码,静默往统一空间灌入错误坐标。 本提交补齐这些缺口: 一、可复现导出脚本(scripts/export_qwen3vl_embedding_onnx.py) - 自动拉取模型(HuggingFace 优先,失败回落 ModelScope,支持 HF_ENDPOINT 镜像); - 导出 TokenEmbedding + Transformer + Vision 三段图,图文共用同一 token embedding、28 层 Transformer、last-token 池化与 fingerprint; - 双重自检(不可省):分段 PyTorch vs 完整模型 + 导出后的 ONNX vs 完整模型, cos < 0.999999 即非零退出——「能加载」不等于「算得对」; - 默认把 L2 归一化后的冻结参考向量写入产物目录(qwen_reference.json)—— Go 测试据此做逐维冻结回归,且「该目录是哪次导出的」从文件本身可追溯; - --verify-only 校验既有产物不重新导出,可用来确认线上在用的图没坏。 关键实测结论(已写入 docs/zh/multimodal-space.md 与长期记忆): 原生多帧视频不可行——Qwen3-VL 视觉塔把 grid_thw 当 Python 值消费 (grid_thw.tolist()),legacy tracer 固化为常量,导出后图中根本没有 grid_thw 输入,换帧数调用直接 Invalid input name: grid_thw。故视觉塔固定 (1,48,48), 视频由上层抽帧后逐帧按图像编码(同模型/同维度/同 fingerprint),音频明确 unsupported。 二、模态边界(vector.ErrModalityUnsupported) - 新增 vector.ErrModalityUnsupported:表示「该模态不在本统一空间的原生覆盖 范围内」,与普通错误语义不同——调用方应把它当「永远不会有向量」而非 「本次失败、下次重试」; - qwen.EmbedImageDense 按 mime 拒绝 audio/* 与 video/*:此前它会拿视觉塔 去解音频字节,往统一空间灌入语义错误的坐标且静默; - reembedStaleMedia 对 ErrModalityUnsupported 不计失败、不重试、不用别的 模型向量顶替(TestReembedStaleMedia_SkipsUnsupportedWithoutFaking 守住)。 三、Go ONNX 测试首次完整通过 - 重写 embedder_onnx_test.go:修复编译 + 文本冻结回归 + 图像冻结回归 + 两条阴性对照(不同输入必须不同、图像与文本必须不同)+ 不支持模态断言; - 参考值从产物目录的 qwen_reference.json 读取(不在测试里硬编码浮点); - 用线上部署产物实测全部通过(text cos=0.999999940, image cos=0.999999762)。 四、.gitignore 修复 - /scripts/ 此前被列在「运行时产物」下,但它是作者维护的工具目录 (模型导出、侧车、部署校验),deploy/systemd/embed-sidecar.service 直接 引用 scripts/embed_sidecar.py,忽略它会让那份 unit 在别人的机器上指向 不存在的文件。改为只忽略 __pycache__。 五、文档(docs/zh/multimodal-space.md) - 获取/启用/产物契约/模态边界/验证/资源成本/与现有部署产物的等价性。 验证:go build ./...、go vet ./...、go vet -tags onnxruntime ./...、 go test -short 全部通过;ONNX 标签测试对线上部署产物全部通过。
441 lines
14 KiB
Go
441 lines
14 KiB
Go
//go:build onnxruntime
|
||
|
||
// Package qwen 提供 Qwen3-VL-Embedding 的完整图文共享 ONNX 编码器。
|
||
// 文本和图像共用 token embedding、28 层 Transformer、last-token 池化与
|
||
// fingerprint;Vision.onnx 只产生注入 Transformer 的中间特征。
|
||
package qwen
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
|
||
ort "github.com/yalue/onnxruntime_go"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||
)
|
||
|
||
type embedConfig struct {
|
||
Arch string `json:"arch"`
|
||
Dimension int `json:"dim"`
|
||
MaxLength int `json:"max_length"`
|
||
Instruction string `json:"instruction"`
|
||
Pooling string `json:"pooling"`
|
||
ImageSize int `json:"image_size"`
|
||
PatchSize int `json:"patch_size"`
|
||
TemporalPatch int `json:"temporal_patch_size"`
|
||
SpatialMerge int `json:"spatial_merge_size"`
|
||
ImageMean []float64 `json:"image_mean"`
|
||
ImageStd []float64 `json:"image_std"`
|
||
RopeTheta float64 `json:"rope_theta"`
|
||
MRopeSection []int `json:"mrope_section"`
|
||
}
|
||
|
||
type Embedder struct {
|
||
mu sync.RWMutex
|
||
|
||
loaded bool
|
||
config embedConfig
|
||
tok *Tokenizer
|
||
token *ort.DynamicAdvancedSession
|
||
transform *ort.DynamicAdvancedSession
|
||
vision *ort.DynamicAdvancedSession
|
||
fp string
|
||
close sync.Once
|
||
}
|
||
|
||
func New(modelDir string) (*Embedder, error) {
|
||
if modelDir == "" {
|
||
return nil, fmt.Errorf("qwen model dir not specified")
|
||
}
|
||
cfgRaw, err := os.ReadFile(filepath.Join(modelDir, "embed_config.json"))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read embed_config.json: %w", err)
|
||
}
|
||
var cfg embedConfig
|
||
if err := json.Unmarshal(cfgRaw, &cfg); err != nil {
|
||
return nil, fmt.Errorf("parse embed_config.json: %w", err)
|
||
}
|
||
if cfg.Dimension != 2048 || cfg.MaxLength < 598 || cfg.Pooling != "last_token" {
|
||
return nil, fmt.Errorf("qwen: incompatible config dim=%d max_length=%d pooling=%q", cfg.Dimension, cfg.MaxLength, cfg.Pooling)
|
||
}
|
||
if cfg.ImageSize != qwenImageSize || cfg.PatchSize != qwenPatchSize || cfg.TemporalPatch != qwenTemporalPatch || cfg.SpatialMerge != qwenSpatialMerge {
|
||
return nil, fmt.Errorf("qwen: incompatible vision layout image=%d patch=%d temporal=%d merge=%d", cfg.ImageSize, cfg.PatchSize, cfg.TemporalPatch, cfg.SpatialMerge)
|
||
}
|
||
if cfg.RopeTheta <= 0 || len(cfg.MRopeSection) != 3 || cfg.MRopeSection[0]+cfg.MRopeSection[1]+cfg.MRopeSection[2] != qwenRotaryHalfDim {
|
||
return nil, fmt.Errorf("qwen: incompatible rope theta=%g section=%v", cfg.RopeTheta, cfg.MRopeSection)
|
||
}
|
||
|
||
tok, err := LoadTokenizer(modelDir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !ort.IsInitialized() {
|
||
if lib := findOnnxLib(); lib != "" {
|
||
ort.SetSharedLibraryPath(lib)
|
||
}
|
||
if err := ort.InitializeEnvironment(); err != nil {
|
||
return nil, fmt.Errorf("init onnx env: %w", err)
|
||
}
|
||
}
|
||
|
||
token, err := ort.NewDynamicAdvancedSession(
|
||
filepath.Join(modelDir, "TokenEmbedding.onnx"),
|
||
[]string{"input_ids"}, []string{"hidden"}, nil,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("create qwen token embedding session: %w", err)
|
||
}
|
||
transform, err := ort.NewDynamicAdvancedSession(
|
||
filepath.Join(modelDir, "Transformer.onnx"),
|
||
[]string{"hidden", "deepstack_0", "deepstack_1", "deepstack_2", "rotary_cos", "rotary_sin", "causal_mask"},
|
||
[]string{"embedding"}, nil,
|
||
)
|
||
if err != nil {
|
||
token.Destroy()
|
||
return nil, fmt.Errorf("create qwen transformer session: %w", err)
|
||
}
|
||
vision, err := ort.NewDynamicAdvancedSession(
|
||
filepath.Join(modelDir, "Vision.onnx"), []string{"pixel_values"},
|
||
[]string{"deepstack_feature_0", "deepstack_feature_1", "deepstack_feature_2", "vision_hidden_states"}, nil,
|
||
)
|
||
if err != nil {
|
||
token.Destroy()
|
||
transform.Destroy()
|
||
return nil, fmt.Errorf("create qwen vision session: %w", err)
|
||
}
|
||
|
||
return &Embedder{
|
||
loaded: true, config: cfg, tok: tok,
|
||
token: token, transform: transform, vision: vision,
|
||
fp: computeFingerprint(modelDir),
|
||
}, nil
|
||
}
|
||
|
||
func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
|
||
e.mu.RLock()
|
||
defer e.mu.RUnlock()
|
||
if !e.loaded {
|
||
return nil, fmt.Errorf("qwen embedder not loaded")
|
||
}
|
||
ids, _, position, _, err := e.tok.textModelInput(e.config.Instruction, text, e.config.MaxLength)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
hidden, err := e.runTokenEmbedding(ids)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
deep := make([][]float32, 3)
|
||
for i := range deep {
|
||
deep[i] = make([]float32, len(hidden))
|
||
}
|
||
return e.runTransformer(hidden, deep, position, len(ids))
|
||
}
|
||
|
||
// EmbedImageDense 把一张图片编码到统一空间。
|
||
//
|
||
// mime 决定这个模态是否在本空间的原生覆盖范围内:Qwen3-VL 能原生编码文本与
|
||
// 图像,但**不原生支持音频**。音频(以及未抽帧的视频文件)必须返回
|
||
// ErrModalityUnsupported,而不是拿视觉塔去编码——那会往统一空间里灌入
|
||
// 语义错误的坐标,而错误是静默的。视频请由上层抽帧后逐帧当作图像编码。
|
||
func (e *Embedder) EmbedImageDense(raw []byte, mime string) ([]float64, error) {
|
||
e.mu.RLock()
|
||
defer e.mu.RUnlock()
|
||
if !e.loaded {
|
||
return nil, fmt.Errorf("qwen embedder not loaded")
|
||
}
|
||
switch {
|
||
case strings.HasPrefix(mime, "audio/"):
|
||
return nil, fmt.Errorf("%w: audio (%s) 需由真正的统一音频模型扩展", vector.ErrModalityUnsupported, mime)
|
||
case strings.HasPrefix(mime, "video/"):
|
||
return nil, fmt.Errorf("%w: 视频文件请先抽帧,逐帧按图像编码 (%s)", vector.ErrModalityUnsupported, mime)
|
||
}
|
||
pixels, err := preprocessImage(raw)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
features, err := e.runVision(pixels)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ids, _, position, visual, err := e.tok.imageModelInput(e.config.Instruction, e.config.MaxLength)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
hidden, err := e.runTokenEmbedding(ids)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
deep := make([][]float32, 3)
|
||
for i := range deep {
|
||
deep[i] = make([]float32, len(hidden))
|
||
}
|
||
visualIndex := 0
|
||
for tokenIndex, isVisual := range visual {
|
||
if !isVisual {
|
||
continue
|
||
}
|
||
dst := tokenIndex * e.config.Dimension
|
||
src := visualIndex * e.config.Dimension
|
||
copy(hidden[dst:dst+e.config.Dimension], features[3][src:src+e.config.Dimension])
|
||
for layer := range deep {
|
||
copy(deep[layer][dst:dst+e.config.Dimension], features[layer][src:src+e.config.Dimension])
|
||
}
|
||
visualIndex++
|
||
}
|
||
if visualIndex != qwenVisualTokens {
|
||
return nil, fmt.Errorf("qwen: injected visual tokens=%d, want %d", visualIndex, qwenVisualTokens)
|
||
}
|
||
return e.runTransformer(hidden, deep, position, len(ids))
|
||
}
|
||
|
||
func (e *Embedder) runTokenEmbedding(ids []int) ([]float32, error) {
|
||
inputIDs := make([]int64, len(ids))
|
||
for i, id := range ids {
|
||
inputIDs[i] = int64(id)
|
||
}
|
||
in, err := ort.NewTensor(ort.Shape{1, int64(len(ids))}, inputIDs)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("qwen token input: %w", err)
|
||
}
|
||
defer in.Destroy()
|
||
outs := make([]ort.Value, 1)
|
||
if err := e.token.Run([]ort.Value{in}, outs); err != nil {
|
||
return nil, fmt.Errorf("qwen token embedding run: %w", err)
|
||
}
|
||
if outs[0] == nil {
|
||
return nil, fmt.Errorf("qwen token embedding output is nil")
|
||
}
|
||
defer outs[0].Destroy()
|
||
tensor, ok := outs[0].(*ort.Tensor[float32])
|
||
if !ok {
|
||
return nil, fmt.Errorf("qwen token embedding output type %T", outs[0])
|
||
}
|
||
shape := tensor.GetShape()
|
||
if len(shape) != 3 || shape[0] != 1 || shape[1] != int64(len(ids)) || shape[2] != int64(e.config.Dimension) {
|
||
return nil, fmt.Errorf("qwen token embedding shape=%v", shape)
|
||
}
|
||
return append([]float32(nil), tensor.GetData()...), nil
|
||
}
|
||
|
||
func (e *Embedder) runVision(pixels []float32) ([][]float32, error) {
|
||
in, err := ort.NewTensor(ort.Shape{qwenImagePatches, qwenPatchVectorSize}, pixels)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("qwen vision input: %w", err)
|
||
}
|
||
defer in.Destroy()
|
||
outs := make([]ort.Value, 4)
|
||
if err := e.vision.Run([]ort.Value{in}, outs); err != nil {
|
||
return nil, fmt.Errorf("qwen vision run: %w", err)
|
||
}
|
||
features := make([][]float32, 4)
|
||
for i, value := range outs {
|
||
if value == nil {
|
||
return nil, fmt.Errorf("qwen vision output %d is nil", i)
|
||
}
|
||
defer value.Destroy()
|
||
tensor, ok := value.(*ort.Tensor[float32])
|
||
if !ok {
|
||
return nil, fmt.Errorf("qwen vision output %d type %T", i, value)
|
||
}
|
||
shape := tensor.GetShape()
|
||
if len(shape) != 2 || shape[0] != qwenVisualTokens || shape[1] != int64(e.config.Dimension) {
|
||
return nil, fmt.Errorf("qwen vision output %d shape=%v", i, shape)
|
||
}
|
||
features[i] = append([]float32(nil), tensor.GetData()...)
|
||
}
|
||
return features, nil
|
||
}
|
||
|
||
func (e *Embedder) runTransformer(hidden []float32, deep [][]float32, position []int64, seq int) ([]float64, error) {
|
||
if len(hidden) != seq*e.config.Dimension || len(deep) != 3 || len(position) != 3*seq {
|
||
return nil, fmt.Errorf("qwen: invalid transformer inputs hidden=%d deep=%d position=%d seq=%d", len(hidden), len(deep), len(position), seq)
|
||
}
|
||
cos, sin := e.rotary(position, seq)
|
||
causal := causalMask(seq)
|
||
|
||
hiddenTensor, err := ort.NewTensor(ort.Shape{1, int64(seq), int64(e.config.Dimension)}, hidden)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("qwen hidden tensor: %w", err)
|
||
}
|
||
defer hiddenTensor.Destroy()
|
||
inputs := []ort.Value{hiddenTensor}
|
||
var deepTensors []*ort.Tensor[float32]
|
||
for i, data := range deep {
|
||
if len(data) != len(hidden) {
|
||
return nil, fmt.Errorf("qwen deepstack %d length=%d, want %d", i, len(data), len(hidden))
|
||
}
|
||
t, err := ort.NewTensor(ort.Shape{1, int64(seq), int64(e.config.Dimension)}, data)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("qwen deepstack %d tensor: %w", i, err)
|
||
}
|
||
deepTensors = append(deepTensors, t)
|
||
inputs = append(inputs, t)
|
||
}
|
||
defer func() {
|
||
for _, t := range deepTensors {
|
||
t.Destroy()
|
||
}
|
||
}()
|
||
cosTensor, err := ort.NewTensor(ort.Shape{1, int64(seq), qwenRotaryDim}, cos)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("qwen rotary cos: %w", err)
|
||
}
|
||
defer cosTensor.Destroy()
|
||
sinTensor, err := ort.NewTensor(ort.Shape{1, int64(seq), qwenRotaryDim}, sin)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("qwen rotary sin: %w", err)
|
||
}
|
||
defer sinTensor.Destroy()
|
||
causalTensor, err := ort.NewTensor(ort.Shape{1, 1, int64(seq), int64(seq)}, causal)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("qwen causal mask: %w", err)
|
||
}
|
||
defer causalTensor.Destroy()
|
||
inputs = append(inputs, cosTensor, sinTensor, causalTensor)
|
||
|
||
out, err := ort.NewEmptyTensor[float32](ort.Shape{1, int64(e.config.Dimension)})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("qwen output tensor: %w", err)
|
||
}
|
||
defer out.Destroy()
|
||
if err := e.transform.Run(inputs, []ort.Value{out}); err != nil {
|
||
return nil, fmt.Errorf("qwen transformer run: %w", err)
|
||
}
|
||
return normalize(out.GetData()), nil
|
||
}
|
||
|
||
const (
|
||
qwenRotaryHalfDim = 64
|
||
qwenRotaryDim = 128
|
||
)
|
||
|
||
func (e *Embedder) rotary(position []int64, seq int) ([]float32, []float32) {
|
||
cos := make([]float32, seq*qwenRotaryDim)
|
||
sin := make([]float32, seq*qwenRotaryDim)
|
||
inv := make([]float64, qwenRotaryHalfDim)
|
||
for i := range inv {
|
||
inv[i] = 1 / math.Pow(e.config.RopeTheta, float64(2*i)/qwenRotaryDim)
|
||
}
|
||
for token := 0; token < seq; token++ {
|
||
freq := make([]float64, qwenRotaryHalfDim)
|
||
for i := range freq {
|
||
freq[i] = float64(position[token]) * inv[i]
|
||
}
|
||
for dim, offset := range []int{0, 1, 2} {
|
||
if dim == 0 {
|
||
continue
|
||
}
|
||
limit := e.config.MRopeSection[dim] * 3
|
||
for i := offset; i < limit; i += 3 {
|
||
freq[i] = float64(position[dim*seq+token]) * inv[i]
|
||
}
|
||
}
|
||
for i, f := range freq {
|
||
c, s := float32(math.Cos(f)), float32(math.Sin(f))
|
||
cos[token*qwenRotaryDim+i] = c
|
||
cos[token*qwenRotaryDim+qwenRotaryHalfDim+i] = c
|
||
sin[token*qwenRotaryDim+i] = s
|
||
sin[token*qwenRotaryDim+qwenRotaryHalfDim+i] = s
|
||
}
|
||
}
|
||
return cos, sin
|
||
}
|
||
|
||
func causalMask(seq int) []float32 {
|
||
out := make([]float32, seq*seq)
|
||
for row := 0; row < seq; row++ {
|
||
for col := row + 1; col < seq; col++ {
|
||
out[row*seq+col] = -math.MaxFloat32
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalize(raw []float32) []float64 {
|
||
out := make([]float64, len(raw))
|
||
var norm float64
|
||
for i, v := range raw {
|
||
out[i] = float64(v)
|
||
norm += out[i] * out[i]
|
||
}
|
||
if norm > 0 {
|
||
norm = math.Sqrt(norm)
|
||
for i := range out {
|
||
out[i] /= norm
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (e *Embedder) Fingerprint() string { return e.fp }
|
||
func (e *Embedder) Dim() int { return e.config.Dimension }
|
||
func (e *Embedder) Loaded() bool {
|
||
e.mu.RLock()
|
||
defer e.mu.RUnlock()
|
||
return e.loaded
|
||
}
|
||
func (e *Embedder) Close() {
|
||
e.close.Do(func() {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
if e.token != nil {
|
||
e.token.Destroy()
|
||
e.token = nil
|
||
}
|
||
if e.transform != nil {
|
||
e.transform.Destroy()
|
||
e.transform = nil
|
||
}
|
||
if e.vision != nil {
|
||
e.vision.Destroy()
|
||
e.vision = nil
|
||
}
|
||
e.loaded = false
|
||
})
|
||
}
|
||
|
||
func computeFingerprint(modelDir string) string {
|
||
h := sha256.New()
|
||
for _, name := range []string{"TokenEmbedding.onnx", "Transformer.onnx", "Vision.onnx", "embed_config.json"} {
|
||
if data, err := os.ReadFile(filepath.Join(modelDir, name)); err == nil {
|
||
h.Write([]byte(name))
|
||
h.Write([]byte{0})
|
||
h.Write(data)
|
||
h.Write([]byte{0})
|
||
}
|
||
}
|
||
entries, _ := os.ReadDir(modelDir)
|
||
var names []string
|
||
for _, entry := range entries {
|
||
n := entry.Name()
|
||
if strings.HasPrefix(n, "embed_tokens.") || strings.HasPrefix(n, "layers.") || strings.HasPrefix(n, "onnx__") || strings.HasSuffix(n, ".onnx.data") {
|
||
names = append(names, n)
|
||
}
|
||
}
|
||
sort.Strings(names)
|
||
for _, n := range names {
|
||
if info, err := os.Stat(filepath.Join(modelDir, n)); err == nil {
|
||
fmt.Fprintf(h, "%s:%d\n", n, info.Size())
|
||
}
|
||
}
|
||
return hex.EncodeToString(h.Sum(nil))
|
||
}
|
||
|
||
func findOnnxLib() string {
|
||
for _, p := range []string{"/opt/onnxruntime/libonnxruntime.so", "/usr/local/lib/libonnxruntime.so", "/usr/lib/libonnxruntime.so"} {
|
||
if _, err := os.Stat(p); err == nil {
|
||
return p
|
||
}
|
||
}
|
||
return ""
|
||
}
|