mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
配套 SDK 提交:homeagent-sdk ba49dfd(公开 API 纯追加,无签名变更)。
本仓第三方的库镜像同步至该版本,以保证全新 clone 能编译。
## 1. 注入标志位(内核侧)
- 7 条注入路径(排队/中断/同步 × 纯文本/带媒体 + 旧 NoMem 变体)解析并转发
no_memory / context_policy / cleaner_name;策略在入口**校验**,
非法值报错而不是静默降级成 none(降级会让调用方以为自己声明的裁剪在生效)。
- 新增 validateContextPolicy(与 tool.register 同一套规则)与 pubSdkInjectOpts。
- input.register 不再手写字段白名单重建 ChannelDef,改为整体传递 + 补 ContextPolicy。
- io 层:applyInjectOpts 把标志位写进事件 payload,仅非零时写
(零值与旧 payload 逐字节一致,事件订阅方与旧内核都不受影响)。
- ioAdapter / procCore / internal-sdk 别名补齐六个 *Opts 实现。
## 2. 修掉「输入无条件裁剪」这个真缺陷
eventloop 此前对**每条非中断输入**都调 `context.Prune(...)`:破坏性(低相关事件被
归档移出上下文)且无法从调用点看出是谁触发的。改为 pruneOnInput/pruneDeclared:
优先级:注入点声明(payload.context_policy)> 通道声明(ChannelDef.ContextPolicy)
> 默认**不裁剪**
查询向量仍取清洗后的内容;新增 cleanInputFor 解析清洗文本,优先级为
注入点声明的 cleaner(cleaner_name)> 按 source 查到的通道 cleaner > 原文,
名字查不到时**记日志再回退**(注入是 fire-and-forget,插件看不到错误,
至少要在内核日志留下「你声明的清洗没生效」的痕迹)。
## 3. jieba 词库内嵌(修「猜 GOMODCACHE → 静默失效」)
原 jiebaDictDir() 去猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块
缓存 → GetJieba() 返回 nil → 分词/关键词提取/NLP 依存解析(进而 doc→graph 三元组
抽取)/静态词向量 tokenizer **一律静默返回空列表**,只有一行日志。本机看起来正常
只因开发机与生产机重合、恰好有那份缓存。
现在词库随二进制分发:internal/memory/jiebadict/ 5 文件约 11.6MB + go:embed,
按**内容哈希**命名缓存目录落盘(词库升级不复用旧文件),已齐全则跳过写入。
模块缓存降为兜底。homed 体积 32MB。
顺带确认(并有测试佐证):gojieba 的 Tag() 不需要 pos_dict/ 目录——
cppjieba 的 PosTagger 从主词典每行的词性列取 tag。
## 4. homed 放弃 Windows 原生,改走 WSL2
插件体系依赖「继承的 fd」+「统一共享内存区的段内偏移解引用」,Windows 既无 fd
继承语义,其句柄模型也无法表达后者;强行适配等于再维护一套平台专属 ABI
(C ABI 时代三套 ABI 并存曾导致改写型插件在某平台静默失效)。
- cmd/homed/platform_{windows,other}.go:原生 Windows 启动即拒绝并打印 WSL2 指引。
- internal/plugin/proc/shmalloc_windows.go:allocShm 直接返回「请用 WSL2」,
**不返回半可用的段**(与 shmalloc_other.go 同风格:未支持平台显式报错);
procEnvForShm 返回 nil。顺手修掉两处长期编译错误
(cryptorand→rand、h.evData→h.unified.evtData),使 GOOS=windows 至少能编译。
注:homed 本就编不出 Windows——internal/memory 依赖 cgo-only 的 gojieba。
- deploy/packaging/installer.nsi:不再安装 homed.exe/initconfig.exe,改为携带
**linux payload** 并调用新的 install-via-wsl.ps1;退出码 20/21 表示
「需先装 WSL/发行版」,走指引而非报错。
- deploy/packaging/windows/install-via-wsl.ps1(新):检测 WSL → 引导安装 →
确保 WSL2 → 送包进发行版 → 在 WSL 内按 Linux 方式安装。**复用 Linux 包与
linux/setup.sh**,不另写一套安装逻辑;落点与 deb 布局统一
(/usr/bin/homed + /usr/lib/homeagent/setup.sh)。
- deploy/packaging/linux/setup.sh:API Key 允许 HOMEAGENT_API_KEY 覆盖
(否则安装器界面显示一份、config.db 里另一份 → 登录不上)。
- deploy/packaging/build.sh:windows 目标只构建 waiter + gui,并新增
stage_linux_payload 把 Linux 包暂存给安装器;homed/initconfig 在 windows
目标下明确拒绝。
## 5. 插件调用点统一写明意图
- webui 的 OpenAI 兼容端点(固定提示词模板)→ InjectTextSyncNoMemory。
- agentcli 的 5 处纯状态通知(已启动/超时/执行结束/进程退出/读取结束)→ NoMemory;
**带输出**的 2 处(定时反馈、有新输出)刻意保留记忆并注明理由。
- timer 的定时提醒 → NoMemory(中断本来也隐含 NoMemory,这里是写明意图)。
## 6. 版本
meta.Version 仍为 1.2.0(main 是下一个未发布中版本);
SDKCompatibleVersion 1.1.0 → **1.2.0**(本内核已实现 SDK 1.2.0 全部新增方法)。
## 测试
- core:默认不裁剪(无声明/none/空)、通道 opt-in、注入点双向覆盖通道、
nil context/io 安全、cleaner 优先级与未知名回退。
- io:零值 opts 与历史 payload 逐键相同;text/中断/媒体三类注入标志位都落到
payload;旧方法仍生效。
- proc:validateContextPolicy 只接受 ""/none/prune,报错含位置与实际值;
**跨进程** e2e——testdata 插件经 io.injectText 送出三个标志位,断言它们穿过 RPC
到达内核。
- memory:模块缓存不可见时内嵌词库仍可用(分词与 POS 内容词均非空)、
落盘幂等、内容哈希稳定。
验证:go build ./... / go vet ./... / go vet -tags onnxruntime ./...
go test -short ./internal/memory/... ./internal/nlp/... ./internal/plugin/...
./internal/agent/{core,io}/... ./pkg/...
626 lines
21 KiB
Go
626 lines
21 KiB
Go
//go:build onnxruntime
|
||
|
||
// Package qwen3vl provides the optional Qwen3-VL-Embedding ONNX provider.
|
||
// Model-specific tokenization, preprocessing, graph layout, and runtime code
|
||
// live here rather than in the HomeAgent core.
|
||
package qwen3vl
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
|
||
ort "github.com/yalue/onnxruntime_go"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||
)
|
||
|
||
func init() {
|
||
embedding.Register("qwen3vl", func(cfg embedding.Config) (embedding.Provider, error) {
|
||
modelDir := cfg.Options["model_dir"]
|
||
e, err := New(modelDir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return e, nil
|
||
})
|
||
}
|
||
|
||
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
|
||
dir string
|
||
config embedConfig
|
||
tok *Tokenizer
|
||
token *ort.DynamicAdvancedSession
|
||
transform *ort.DynamicAdvancedSession
|
||
|
||
// vision 按时间组数缓存视觉图会话:1 = 单图(Vision.onnx),
|
||
// G>1 = 视频(Vision_g{G}.onnx)。每张图约 1.6GB,因此按需加载而不是
|
||
// 启动时全开;未用到的档位不占内存。
|
||
visionMu sync.Mutex
|
||
vision map[int]*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, dir: modelDir, config: cfg, tok: tok,
|
||
token: token, transform: transform,
|
||
vision: map[int]*ort.DynamicAdvancedSession{1: vision},
|
||
fp: computeFingerprint(modelDir),
|
||
}, nil
|
||
}
|
||
|
||
func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
|
||
e.mu.RLock()
|
||
loaded, cfg, tok := e.loaded, e.config, e.tok
|
||
e.mu.RUnlock()
|
||
if !loaded {
|
||
return nil, fmt.Errorf("qwen embedder not loaded")
|
||
}
|
||
ids, _, position, _, err := tok.textModelInput(cfg.Instruction, text, cfg.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 能原生编码文本、
|
||
// 图像与视频,但**不原生支持音频**(模型卡与 config 双重确认:没有
|
||
// audio_token_id / audio_config)。音频必须返回 ErrModalityUnsupported,
|
||
// 而不是拿视觉塔去编码——那会往统一空间里灌入语义错误的坐标,而错误是静默的。
|
||
//
|
||
// video/*(视频文件)也在这里拒绝:本函数的入参是**单帧字节**,Go 侧没有
|
||
// 视频解码器;多帧请走 EmbedVideoDense。
|
||
func (e *Embedder) EmbedImageDense(raw []byte, mime string) ([]float64, error) {
|
||
if err := checkImageMime(mime); err != nil {
|
||
return nil, err
|
||
}
|
||
pixels, err := preprocessImage(raw)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return e.embedVision(pixels, 1)
|
||
}
|
||
|
||
// EmbedVideoDense 把已按时间排序的视频帧编码到统一空间(原生跨帧时序)。
|
||
//
|
||
// frames 是已解码的帧(PNG/JPEG 字节),相邻两帧构成一个时间组;
|
||
// groups = len(frames)/2 必须恰好是导出时固定的某一档(Vision_g{G}.onnx),
|
||
// 否则本方法明确报错并告知已导出哪些档位。
|
||
//
|
||
// 为何必须精确匹配而不能“差不多就行”:视觉塔的注意力按 grid 划分,
|
||
// 用 G=2 的图喂 G=3 的数据是未定义行为。实测(onnxruntime)会因维度不符
|
||
// 报 InvalidArgument,因此不会静默算错——但也不该依赖那次报错来兜底。
|
||
//
|
||
// 帧数为奇数时只用得上前 2×floor(n/2) 帧,多余一帧被丢弃(不补重复帧:
|
||
// 那会改变跨帧注意力看到的运动)。
|
||
func (e *Embedder) EmbedVideoDense(frames [][]byte, mime string) ([]float64, error) {
|
||
// 暂时硬失败,而不是算出一个「看起来正常但语义错」的向量。
|
||
//
|
||
// 原因:本 provider 的 Go 侧视频提示词模板与 HuggingFace processor 不等价
|
||
// ——processor 会按时间组插入字面时间戳文本
|
||
//(`<0.0 seconds>` / `<1.0 seconds>`),而 Go 侧只拼
|
||
// `<|vision_start|>{G×576 pads}<|vision_end|>`。实测同一输入 Python seq=1190
|
||
//(1152 视觉 + 38 文本)、Go 只有 22 个文本 token;而这些时间戳文本也会
|
||
// 占用 M-RoPE 位置。
|
||
//
|
||
// 差异的后果是向量整体偏移,**不报错**:检索出来的相似度没有任何意义,
|
||
// 而且看不出是谁的错。视觉图本身(Vision_g2/g3/g4)已逐档对过 PyTorch
|
||
//(cos≥0.999999),差的只是模板。
|
||
//
|
||
// 修复后删掉这个硬失败,并让 TestEmbedderVideoMatchesONNXReference 不再跳过。
|
||
return nil, fmt.Errorf("%w: Qwen3-VL 视频路径尚未完成(Go 模板缺 processor 的分组时间戳文本,"+
|
||
"会静默产生语义错的向量);详见 docs/zh/multimodal-space.md 与 providers/qwen3vl/embedder_onnx_test.go",
|
||
embedding.ErrUnsupportedModality)
|
||
}
|
||
|
||
// checkImageMime 把「不在本空间覆盖范围内」与「参数用错」分开报。
|
||
func checkImageMime(mime string) error {
|
||
switch {
|
||
case strings.HasPrefix(mime, "audio/"):
|
||
return fmt.Errorf("%w: audio (%s) 不在 Qwen3-VL 原生模态内(无 audio_token_id),需真正的统一音频模型",
|
||
embedding.ErrUnsupportedModality, mime)
|
||
case strings.HasPrefix(mime, "video/"):
|
||
return fmt.Errorf("%w: 视频文件 (%s) 无法解码;多帧请用 EmbedVideoDense",
|
||
embedding.ErrUnsupportedModality, mime)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func checkVideoMime(mime string) error {
|
||
if strings.HasPrefix(mime, "audio/") {
|
||
return fmt.Errorf("%w: audio (%s)", embedding.ErrUnsupportedModality, mime)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// visionFor 返回指定时间组数的视觉图会话,按需创建。
|
||
func (e *Embedder) visionFor(groups int) (*ort.DynamicAdvancedSession, error) {
|
||
e.visionMu.Lock()
|
||
defer e.visionMu.Unlock()
|
||
if s, ok := e.vision[groups]; ok {
|
||
return s, nil
|
||
}
|
||
name := "Vision.onnx"
|
||
if groups > 1 {
|
||
name = fmt.Sprintf("Vision_g%d.onnx", groups)
|
||
}
|
||
path := filepath.Join(e.dir, name)
|
||
if _, err := os.Stat(path); err != nil {
|
||
return nil, fmt.Errorf("qwen: 缺少 %s(时间组数 %d 未导出,已加载档位 %v);"+
|
||
"用 scripts/export_qwen3vl_embedding_onnx.py 按所需 --video-groups 重新导出: %w",
|
||
name, groups, e.visionGroupsLocked(), err)
|
||
}
|
||
s, err := ort.NewDynamicAdvancedSession(path, []string{"pixel_values"},
|
||
[]string{"deepstack_feature_0", "deepstack_feature_1", "deepstack_feature_2", "vision_hidden_states"}, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("create qwen vision session %s: %w", name, err)
|
||
}
|
||
e.vision[groups] = s
|
||
return s, nil
|
||
}
|
||
|
||
// visionGroupsLocked 返回已加载的档位(调用方需持 visionMu)。
|
||
func (e *Embedder) visionGroupsLocked() []int {
|
||
out := make([]int, 0, len(e.vision))
|
||
for g := range e.vision {
|
||
out = append(out, g)
|
||
}
|
||
sort.Ints(out)
|
||
return out
|
||
}
|
||
|
||
// embedVision 是图像与视频共用的后半段:视觉塔 → 散射到 hidden → Transformer。
|
||
func (e *Embedder) embedVision(pixels []float32, groups int) ([]float64, error) {
|
||
e.mu.RLock()
|
||
loaded, cfg, tok := e.loaded, e.config, e.tok
|
||
e.mu.RUnlock()
|
||
if !loaded {
|
||
return nil, fmt.Errorf("qwen embedder not loaded")
|
||
}
|
||
|
||
vision, err := e.visionFor(groups)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
wantTokens := groups * qwenVisualTokens
|
||
features, err := e.runVision(vision, pixels, wantTokens, cfg.Dimension)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var ids []int
|
||
var position []int64
|
||
var visual []bool
|
||
if groups == 1 {
|
||
ids, _, position, visual, err = tok.imageModelInput(cfg.Instruction, cfg.MaxLength)
|
||
} else {
|
||
ids, _, position, visual, err = tok.videoModelInput(cfg.Instruction, groups, cfg.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))
|
||
}
|
||
// 视觉特征按占位符出现顺序就地替换 token embedding:
|
||
// 图像一个组、视频 G 个组,展开后长度都是 groups×576,与视觉塔输出一致。
|
||
visualIndex := 0
|
||
for tokenIndex, isVisual := range visual {
|
||
if !isVisual {
|
||
continue
|
||
}
|
||
dst := tokenIndex * cfg.Dimension
|
||
src := visualIndex * cfg.Dimension
|
||
copy(hidden[dst:dst+cfg.Dimension], features[3][src:src+cfg.Dimension])
|
||
for layer := range deep {
|
||
copy(deep[layer][dst:dst+cfg.Dimension], features[layer][src:src+cfg.Dimension])
|
||
}
|
||
visualIndex++
|
||
}
|
||
if visualIndex != wantTokens {
|
||
return nil, fmt.Errorf("qwen: injected visual tokens=%d, want %d (groups=%d)", visualIndex, wantTokens, groups)
|
||
}
|
||
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(sess *ort.DynamicAdvancedSession, pixels []float32, wantTokens, dim int) ([][]float32, error) {
|
||
wantPatches := int64(wantTokens) * qwenSpatialMerge * qwenSpatialMerge
|
||
if int64(len(pixels)) != wantPatches*qwenPatchVectorSize {
|
||
return nil, fmt.Errorf("qwen vision input: %d floats, want %d patches × %d",
|
||
len(pixels), wantPatches, qwenPatchVectorSize)
|
||
}
|
||
in, err := ort.NewTensor(ort.Shape{wantPatches, qwenPatchVectorSize}, pixels)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("qwen vision input: %w", err)
|
||
}
|
||
defer in.Destroy()
|
||
outs := make([]ort.Value, 4)
|
||
if err := sess.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] != int64(wantTokens) || shape[1] != int64(dim) {
|
||
return nil, fmt.Errorf("qwen vision output %d shape=%v, want [%d %d]", i, shape, wantTokens, dim)
|
||
}
|
||
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) Embed(_ context.Context, in embedding.Input) ([]float64, error) {
|
||
switch in.Modality {
|
||
case embedding.ModalityText:
|
||
return e.VectorizeDense(in.Text)
|
||
case embedding.ModalityImage:
|
||
return e.EmbedImageDense(in.Data, in.MIME)
|
||
case embedding.ModalityVideo:
|
||
// 公共契约把视频交给 provider 自行解码,而本 provider 没有视频解码器
|
||
// (Go 标准库不含 H.264/MP4)。这里必须明确说「本 provider 不提供视频
|
||
// 文件编码」,而不是假装支持后再拿错数据算出一个语义错误的向量。
|
||
//
|
||
// 可用的视频路径是本 provider 自己的 EmbedVideoDense:调用方先抽帧,
|
||
// 由本 provider 按自己的时序窗口分组。
|
||
return nil, fmt.Errorf("%w: video(本 provider 不内嵌视频解码器;"+
|
||
"请先抽帧并调用 qwen3vl 的 EmbedVideoDense)", embedding.ErrUnsupportedModality)
|
||
default:
|
||
return nil, fmt.Errorf("%w: %s", embedding.ErrUnsupportedModality, in.Modality)
|
||
}
|
||
}
|
||
|
||
// Info 只声明本 provider 能通过**公共契约**提供的模态。
|
||
//
|
||
// 视频不在其中:契约要求 provider 自行解码 Data,而本 provider 没有视频
|
||
// 解码器;列进来会让核心据以创建 video 输入,然后在运行时全部失败。
|
||
// 视频能力由本 provider 自己的 EmbedVideoDense(接收已解码帧)提供,
|
||
// 待核心有了对 provider 不透明的多帧容器后再纳入契约。
|
||
func (e *Embedder) Info() embedding.Info {
|
||
e.mu.RLock()
|
||
defer e.mu.RUnlock()
|
||
return embedding.Info{
|
||
Dimension: e.config.Dimension,
|
||
Fingerprint: e.fp,
|
||
Modalities: []embedding.Modality{
|
||
embedding.ModalityText,
|
||
embedding.ModalityImage,
|
||
},
|
||
}
|
||
}
|
||
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
|
||
}
|
||
e.visionMu.Lock()
|
||
for groups, sess := range e.vision {
|
||
if sess != nil {
|
||
sess.Destroy()
|
||
}
|
||
delete(e.vision, groups)
|
||
}
|
||
e.visionMu.Unlock()
|
||
e.loaded = false
|
||
})
|
||
}
|
||
|
||
func computeFingerprint(modelDir string) string {
|
||
h := sha256.New()
|
||
graphNames := []string{"TokenEmbedding.onnx", "Transformer.onnx", "Vision.onnx", "embed_config.json"}
|
||
// 视频是按时间组数各导一张图,因此每一张都必须进入指纹:
|
||
// 漏掉它们会让「换了视频图但指纹没变」,历史向量不会重算。
|
||
if entries, err := os.ReadDir(modelDir); err == nil {
|
||
var extra []string
|
||
for _, entry := range entries {
|
||
if strings.HasPrefix(entry.Name(), "Vision_g") && strings.HasSuffix(entry.Name(), ".onnx") {
|
||
extra = append(extra, entry.Name())
|
||
}
|
||
}
|
||
sort.Strings(extra)
|
||
graphNames = append(graphNames, extra...)
|
||
}
|
||
for _, name := range graphNames {
|
||
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 ""
|
||
}
|