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)
This commit is contained in:
JianFeeeee
2026-09-11 18:26:19 +08:00
parent 1a02971f88
commit a37bc7333e
21 changed files with 2100 additions and 627 deletions

View File

@ -27,7 +27,6 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/qwen"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
@ -44,7 +43,12 @@ import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
// 空白导入内置 provider它们各自在 init 里注册到 pkg/embedding。
// 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main
_ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl"
)
func main() {
@ -346,53 +350,37 @@ func main() {
}
}
// 统一多模态向量空间(可选)
// 统一多模态向量空间。
//
// 两条路径共享同一套基础设施L0/L2/L3 向量缓存、media.Store 坐标、
// QueryMediaScored 检索),只是「算向量的源头」不同:
// - onnx内嵌 Qwen3-VL 完整图文共享空间
// - http外部向量 API 服务Jina / OpenAI / 自建)
// type 为空时禁用多模态向量检索,退回纯 fastText 文本路径。
// 核心**不**知道任何具体模型:它只按配置里的 provider 名从公共注册表
// pkg/embedding打开一个 provider并把 options.* 原样交给它。模型文件
// 布局、预处理、解码、运行时全部属于 provider 内部实现。
// provider 名为空时禁用多模态向量检索,退回纯 fastText 文本路径。
var multimodalSpace vector.MultimodalEmbedder
switch mmType := cfgReg.GetString("core.memory.multimodal_space.type", ""); mmType {
case "onnx":
if modelDir := cfgReg.GetString("core.memory.multimodal_space.onnx.model_dir", ""); modelDir != "" {
e, err := qwen.New(modelDir)
if err != nil {
log.Printf("[homed] warning: qwen multimodal embedder load failed: %v多模态向量检索已禁用", err)
} else {
multimodalSpace = e
defer e.Close()
log.Printf("[homed] multimodal space (qwen onnx) active: dim=%d fp=%s", e.Dim(), e.Fingerprint()[:min(12, len(e.Fingerprint()))])
}
} else {
log.Println("[homed] multimodal_space.type=onnx 但未配置 onnx.model_dir多模态向量检索已禁用")
if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" {
opts := map[string]string{}
const optPrefix = "core.memory.multimodal_space.options."
for _, key := range cfgReg.List("core.memory.multimodal_space.options.") {
opts[strings.TrimPrefix(key, optPrefix)] = cfgReg.GetString(key, "")
}
case "http":
dim := cfgReg.GetInt("core.memory.multimodal_space.http.dimension", 0)
ep := cfgReg.GetString("core.memory.multimodal_space.http.endpoint", "")
if dim > 0 && ep != "" {
e, err := vector.NewHTTPEmbedder(vector.HTTPEmbedderConfig{
Endpoint: ep,
APIKey: cfgReg.GetString("core.memory.multimodal_space.http.api_key", ""),
Model: cfgReg.GetString("core.memory.multimodal_space.http.model", ""),
Dimension: dim,
Timeout: cfgReg.GetDuration("core.memory.multimodal_space.http.timeout", 30*time.Second),
Fingerprint: cfgReg.GetString("core.memory.multimodal_space.http.fingerprint", ""),
})
if err != nil {
log.Printf("[homed] warning: http embedder init failed: %v多模态向量检索已禁用", err)
} else {
multimodalSpace = e
defer e.Close()
log.Printf("[homed] multimodal space (http) active: endpoint=%s dim=%d", ep, dim)
}
provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts})
if err != nil {
log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v多模态向量检索已禁用已注册: %s",
mmProvider, err, strings.Join(embedding.Names(), ", "))
} else if adapted, err := vector.AdaptProvider(provider); err != nil {
provider.Close()
log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v多模态向量检索已禁用", mmProvider, err)
} else {
log.Println("[homed] multimodal_space.type=http 但 endpoint/dimension 配置不完整,多模态向量检索已禁用")
}
default:
if mmType != "" {
log.Printf("[homed] warning: 未知 multimodal_space.type=%q多模态向量检索已禁用", mmType)
multimodalSpace = adapted
defer adapted.Close()
info := provider.Info()
// 指纹可能很长(模型文件哈希),日志里只取前 12 个字符便于对照。
shortFP := info.Fingerprint
if len(shortFP) > 12 {
shortFP = shortFP[:12]
}
log.Printf("[homed] multimodal space active: provider=%s dim=%d fp=%s modalities=%v",
mmProvider, info.Dimension, shortFP, info.Modalities)
}
}

View File

@ -14,10 +14,13 @@ multimodal context 的相关性裁剪/淘汰。
产物约 8 GB含外部权重**不进仓库**;用导出脚本自动拉取模型并导出:
```bash
# 自动拉取HuggingFace 优先,失败回落 ModelScope+ 导出 + 自检
# 默认导出 图像 + 视频 G=2,3,4即 4/6/8 帧)
python3 scripts/export_qwen3vl_embedding_onnx.py \
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
# 只要 4 帧的视频档(省磁盘、省内存)
python3 scripts/export_qwen3vl_embedding_onnx.py --video-groups 2 --out ...
# 已下载过模型:跳过拉取
python3 scripts/export_qwen3vl_embedding_onnx.py \
--model-dir /path/to/Qwen3-VL-Embedding-2B \
@ -42,14 +45,28 @@ python3 scripts/export_qwen3vl_embedding_onnx.py --model-dir ... --out ...
| `TokenEmbedding.onnx` | `input_ids` int64 `[1,seq]` | `hidden` float `[1,seq,2048]` |
| `Transformer.onnx` | `hidden``deepstack_0/1/2` `[1,seq,2048]``rotary_cos/sin` `[1,seq,128]``causal_mask` `[1,1,seq,seq]` | `embedding` `[1,2048]` |
| `Vision.onnx(+.data)` | `pixel_values` `[2304,1536]` | `deepstack_feature_0/1/2``vision_hidden_states` `[576,2048]` |
| `Vision_g{N}.onnx` | `pixel_values` `[N×2304,1536]` | 同上,`[N×576,2048]` |
外加 `tokenizer.json``tokenizer_config.json``chat_template.jinja``embed_config.json`
外加 `tokenizer.json``tokenizer_config.json``chat_template.jinja``embed_config.json`
`qwen_reference.json`
`Vision.onnx` 是图像(单时间组);`Vision_g{N}.onnx` 是视频N 个时间组 = 2N 帧)。
**没有 `Vision_g1.onnx`**——单组就是图像那张。
三段只是部署形式,不是三个向量空间:图文共用同一 token embedding、同一 28 层
Transformer、同一 last-token 池化。RoPE 与视觉特征散射故意留在 Go 计算,
因为旧式 tracer 会把 `seq=598 / visual=576` 烘焙进图里——签名上写着 dynamic
axis实际却只能用导出的那个长度运行。
### ⚠️ max_length 必须按最大视频档推导
`embed_config.json``max_length` 是**整条序列**的上限,包含视觉占位符:
图像只需 598 token1×576 + 模板),而视频是 G×576——G=2 就要 1190G=4 要 2342。
沿用图像的 1024 会让处理器静默截断,然后在 transformers 内部报
`Mismatch in video token count between text and input_ids`
导出脚本因此用 `max_length_for(video_groups) = max(1024, max(G)×576 + 256)` 自动推导,
并在构造视觉输入后显式断言视觉 token 数,把错误提前到导出阶段。
### 导出脚本自检(不可省)
脚本内部跑两道校验,任一道 cos < 0.999999 就以非零码退出
@ -61,44 +78,146 @@ axis实际却只能用导出的那个长度运行。
## 二、启用
核心不识别任何具体模型它只按配置里的 **provider **从公共注册表
`pkg/embedding`打开一个 provider并把 `options.*` 原样交给它
模型文件布局预处理媒体解码运行时都在 provider 内部
```bash
# 配置库config.db或 WebUI 设置页
core.memory.multimodal_space.type = onnx
core.memory.multimodal_space.onnx.model_dir = /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
core.memory.multimodal_space.provider = qwen3vl
core.memory.multimodal_space.options.model_dir = /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
# 或换成一个外部向量服务(任何语言写的都行)
core.memory.multimodal_space.provider = http
core.memory.multimodal_space.options.endpoint = http://127.0.0.1:18999/embed
core.memory.multimodal_space.options.dimension = 2048
```
`options.*` provider 自己的命名空间核心不做任何解释 `qwen3vl`
`model_dir` `http` `endpoint`/`dimension`/`api_key`/…)。第三方 provider
可以定义自己的选项无需改核心
注意事项
- `homed` 必须 `onnxruntime` build tag 构建 `libonnxruntime.so` 可被找到
`/opt/onnxruntime/libonnxruntime.so` )。未带 tag `qwen` no-op stub
- 内置 provider `qwen3vl` 要求 `homed` `onnxruntime` build tag 构建
`libonnxruntime.so` 可被找到`/opt/onnxruntime/libonnxruntime.so` )。
未带 tag 时该 provider 会注册但打开时报requires build tag」,而不是静默降级
- `provider` 为空时禁用多模态向量检索退回纯 fastText 文本路径
- 改配置后需重启进程生效
- 未配置时优雅降级文档层退到 TF-IDF 稀疏检索媒体块仍按结构边关联只是没有跨模态召回
## 二·补、给核心接自己的模型
核心只依赖一个很小的公共接口`pkg/embedding`
```go
// 输入对核心是不透明字节modality 决定语义Data+MIME 由 provider 解释。
type Input struct {
Modality Modality // text / image / audio / video / …
Purpose Purpose // query / document
Text string
Data []byte
MIME string
Metadata map[string]string
}
type Provider interface {
Embed(ctx context.Context, in Input) ([]float64, error)
Info() Info // Dimension, Fingerprint, Modalities
Close()
}
```
接入步骤新建一个包 `init()` `embedding.Register("your-model", factory)`
再把这个包空白导入你的发行版 `main`或替换内置 provider 的导入行)。
分词预处理解码显存/内存管理模型文件命名全部由你的 provider 决定
两条原则值得强调
- **能力是数据不是接口方法**支持哪些模态写在 `Info().Modalities`
这样新增模态不需要改核心接口核心也不需要为每个新模态做类型断言
- **不支持的模态返回 `embedding.ErrUnsupportedModality`**而不要拿别的模型顶替
也不要降级成一个普通错误——调用方靠它区分永远不会有向量本次失败可重试」。
## 三、模态覆盖范围
### Qwen3-VL-Embedding-2B本空间2048 维)
模型卡明载支持 **Text / images / screenshots / videos**`config.json`
`image_token_id` `video_token_id`**没有 `audio_token_id`/`audio_config`**。
| 模态 | 状态 | 说明 |
|---|---|---|
| 文本 | 原生 | `VectorizeDense` |
| 图像 | 原生 | `EmbedImageDense`固定 768×768 视觉塔 |
| 视频 | 逐帧 | 上层抽帧后**逐帧按图像编码**同模型/同维度/ fingerprint不做跨帧时序注意力 |
| 音频 | 明确不支持 | 返回 `vector.ErrModalityUnsupported` |
| 图像 | 原生 | `EmbedImageDense``Vision.onnx`固定 768×768 |
| 视频 | 视觉侧已导出并校验**Go 模板未完成** | `EmbedVideoDense` + `Vision_g{N}.onnx`见下节 |
| 音频 | 本轮明确不 | 决策结果该模型也不具备 `audio_token_id` |
**音频不得用视觉塔硬编码****不得**拿另一个模型的向量顶替——那会把两套坐标系
混进同一空间检索出的相似度没有任何意义而且错误是静默的未来接入真正的统一
音频模型后再扩展
### 视频:帧 → 时间组 → M-RoPE均已实测对齐
### 为什么视频不做原生时序(已实测,勿重复尝试)
| | | 验证方式 |
|---|---|---|
| 占位符 | `<|video_pad|>` = **151656**图像是 `<|image_pad|>` = 151655 | 处理器实测 |
| 模板 | 与图像同构只换占位符 | `apply_chat_template` repr 逐字符比对 |
| 槽位 | g tp0帧2gtp1帧2g+1 | PyTorch `torch.equal == True`maxdiff=0反向对照 False |
| patch 布局 | `[G,24,24,2,2,3,2,16,16]`即图像排列以 grid_t 为最外层堆叠 | 纯色视频于图像张量 `torch.equal == True` |
| 视觉 token | `G×576` | 处理器实测G=2 1152 |
| M-RoPE | 每组独立`base=start+24g``t=base``h=base+j/24``w=base+j%24` | 对应 `get_rope_index` video grid 展开成 G `t=1` |
| 用错档 | onnxruntime `InvalidArgument`维度不符 | 实验实测**不会静默算错** |
Qwen3-VL 视觉塔把 `grid_thw` Python 值消费源码里是 `grid_thw.tolist()`)。
legacy tracer`dynamo=False`会把它固化成常量实测导出后 ONNX 图里**根本没有**
`grid_thw` 输入用别的帧数调用直接报 `Invalid input name: grid_thw`
导出时的 TracerWarning 明确提示 `Converting a tensor to a Python list might cause
the trace to be incorrect`
同步注意事项
因此视觉塔固定 `grid=(1,48,48)`要做到原生多帧需要换 `torch.export`/dynamo 路径
而该路径此前已产生过形状看似动态实际错误的静默故障Core.onnx
`3 by 23 / 3 by 598` 广播错误在时序维度上重试的收益不足以抵消风险
视频价值由逐帧进入同一空间提供帧是真实媒体块按自己的向量被召回
- **帧数必须恰好是 `2×G`**G 取已导出的档)。奇数帧时只用得上前 `2×floor(n/2)`
多出的丢弃——不补重复帧那会改变跳帧注意力看到的运动
- **`video/*`视频文件不能直接喂给图像入口**Go 侧没有视频解码器
`EmbedImageDense(raw, "video/mp4")` 返回 `ErrModalityUnsupported`调用方必须先抽帧
- 视觉图按需懒加载每张约 1.6GB未用到的档位不占内存
### 导出视频时踩过的两个坑(都已加断言)
两个坑都会让产物看起来正常实际是错的」,且都不会在导出时报错
1. **处理器会静默重采样帧**不给 `video_metadata` 时它回落到 `fps=24`
**任何**帧数都改成 `grid_t=2`实测 4/6/8 帧全部得到 1152 个视觉 token
修法`processor(..., videos=[frames], do_sample_frames=False)`
2. **`max_length` 只按图像算是不够的**。它是整条序列含视觉占位符的上限
图像只需 598 token而视频是 `G×576`——G=2 1190G=4 2342
沿用 1024 会截断并报
`Mismatch in video token count between text and input_ids`
修法`max_length_for(G) = max(1024, max(G)×576 + 256)`
两个坑都会在导出脚本里显式断言视觉 token `video_grid_thw` 的组数
把错误提前到导出阶段而不是留给运行时
### 音频(本轮决策:不加)
**Qwen3-VL 不支持音频**由模型卡与 `config.json` 双重确认
```
模型卡Supported Input Modalities: Text, images, screenshots, videos, and …
configimage_token_id ✓ / video_token_id ✓ / audio_token_id ✗ / audio_config ✗
```
本机有音频能力的是另一个模型**jina-v5-omni-nano**768
`modeling_llava_eurobert_audio.py` `audio_token_id=128256` Qwen 空间
**不同维度、不同坐标系,绝不可互相比较**决定**本轮不接入**
其侧车`scripts/embed_sidecar.py`也仍只实现 `text`/`image``audio` 返回 400
无论何时接入**不允许**拿视觉塔去编码音频字节或用另一个模型的向量
冒充某空间的音频向量——那会把两套坐标系混进同一空间且错误是静默的
音频在原空间返回 `vector.ErrModalityUnsupported`使调用方区分
永远不会有向量本次失败可重试」。
Qwen3-VL 视觉塔把 `grid_thw` Python 值消费源码里是 `grid_thw.tolist()`
legacy tracer`dynamo=False`会把它固化成常量实测把 `grid_thw` 声明为图输入后
导出的 ONNX 图里**根本没有该输入**换帧数调用直接报 `Invalid input name: grid_thw`
导出时的 TracerWarning 明确提示
`Converting a tensor to a Python list might cause the trace to be incorrect`
因此视频的可行做法是**在导出时固定时间组数 G每个 G 一张 Vision **
grid = `[G, 48, 48]`Go 侧按实际帧数选用匹配的图 G=2 的图去喂 G=3
数据属于未定义行为视频文件本身不能直接喂进本空间`video/*` 返回
`ErrModalityUnsupported`必须由上层先抽帧
## 四、验证
@ -151,3 +270,33 @@ fingerprint 变化,从而触发一次全量向量重算。重算不会**算错
ONNX 路径 4 worker)。
- fingerprint 由三段图 + `embed_config.json` + 外部权重文件名/大小共同决定
换模型或重新导出都会让它变化从而触发历史向量重算——这是预期行为
## 视频:当前状态(未完成,不得当作已验证)
**视觉侧**`Vision_g2/g3/g4.onnx` 已导出且每一档都与完整 PyTorch 模型逐档对过
`cos` 分别为 1.000000119 / 1.000000119 / 1.000000000覆盖度断言通过)。
**Go 侧模板** HuggingFace processor 产出**不相等**因此冻结回归
`TestEmbedderVideoMatchesONNXReference`当前**显式跳过**并注明原因不算通过
已定位的差异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=1190`1152 视觉 + **38** 文本Go 侧只有 **22** 个文本 token
注意两点
- 时间戳文本**也占用 M-RoPE 位置**所以 `TestVideoModelInputMRope` 的自洽断言
通过**不能**证明与官方实现一致它是拿自己算的序列验自己算的位置)。
- 修复位置在 provider 内部模型专属模板本就属于 provider不是核心
另外公共 provider 契约把 `Data+MIME` 交给 provider 自行解码 provider
没有视频解码器Go 标准库不含 H.264/MP4因此 `Info().Modalities` **不声明 video**
`Embed(video)` 返回 `ErrUnsupportedModality`视频走 provider 自己的
`EmbedVideoDense`接收已解码帧)。待核心有了对 provider 不透明的多帧容器后
再把视频纳入公共契约

View File

@ -651,14 +651,14 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频变成一等记忆块,内容按 sha256 落盘去重。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/", Category: "paths"})
reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 Qwen3-VL 完整图文共享模型;http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.onnx.model_dir", Default: "", Type: "string", DisplayName: "Qwen 多模态 ONNX 目录", Description: "完整 Qwen3-VL 图文共享向量模型目录(含 TokenEmbedding.onnx、Transformer.onnx、Vision.onnx、外部权重、tokenizer.json、embed_config.json。type=onnx 时必填,修改后需重启生效。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.endpoint", Default: "", Type: "string", DisplayName: "外部向量 API 端点", Description: "外部多模态向量服务的 HTTP 端点 URLPOST接受 modality/side/text/data/mime返回 embeddingtype=http 时必填。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.api_key", Default: "", Type: "string", DisplayName: "外部向量 API 密钥", Description: "外部多模态向量服务的 API 密钥(作为 Bearer token 发送。可选。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.model", Default: "", Type: "string", DisplayName: "外部向量模型标识", Description: "外部向量服务使用的模型名,作为 vec_model 持久化。模型切换后历史向量会自动重算。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.dimension", Default: "0", Type: "int", DisplayName: "外部向量维度", Description: "外部向量服务返回的特征向量维度必须与实际 API 返回值一致,否则运行时报错。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.timeout", Default: "30s", Type: "duration", DisplayName: "外部向量 API 超时", Description: "单次向量请求的超时时间。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.fingerprint", Default: "", Type: "string", DisplayName: "外部向量空间指纹", Description: "用于标识外部向量空间版本的字符串(留空时自动根据 model+dim 生成)。模型切换后若 fingerprint 变化,历史向量会被重算。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.provider", Default: "", Type: "string", DisplayName: "多模态向量 provider", Description: "从公共 provider 注册表pkg/embedding按名字打开的多模态向量空间。内置qwen3vl内嵌 Qwen3-VL ONNX需 onnxruntime 构建标签)、http外部向量 API)。也可是第三方注册的名字。留空禁用多模态向量检索,只保留 fastText 文本路径。provider 的模型文件、预处理与运行时全在 provider 内部,核心不做任何模型假设。修改后需重启生效。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.options.model_dir", Default: "", Type: "string", DisplayName: "provider 模型目录", Description: "provider 自定义选项(以 options. 开头的键会去掉前缀后原样传给 provider核心不解释其含义。对内置 qwen3vl指定 ONNX 产物目录。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.options.endpoint", Default: "", Type: "string", DisplayName: "provider 服务端点", Description: "provider 自定义选项。对内置 http外部多模态向量服务的端点 URLPOST接受 modality/side/text/data/mime返回 embedding。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.options.api_key", Default: "", Type: "password", DisplayName: "provider 服务密钥", Description: "provider 自定义选项。对内置 http作为 Bearer token 发送。可选。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.options.model", Default: "", Type: "string", DisplayName: "provider 模型标识", Description: "provider 自定义选项。对内置 http外部服务使用的模型名,作为 vec_model 持久化。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.options.dimension", Default: "0", Type: "int", DisplayName: "provider 向量维度", Description: "provider 自定义选项。对内置 http服务返回的特征向量维度必须与实际返回值一致。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.options.timeout", Default: "30s", Type: "duration", DisplayName: "provider 请求超时", Description: "provider 自定义选项。对内置 http单次向量请求的超时时间。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.options.fingerprint", Default: "", Type: "string", DisplayName: "provider 空间指纹", Description: "provider 自定义选项。对内置 http向量空间版本标识(留空时根据 model+dim 自动生成)。指纹变化会触发历史向量重算。", Category: "memory"})
reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"})

View File

@ -1,277 +0,0 @@
//go:build onnxruntime
package qwen
import (
"bytes"
"encoding/json"
"errors"
"image"
"image/png"
"math"
"os"
"path/filepath"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
// 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"
}
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"`
}
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)
if !e.Loaded() || e.Dim() != 2048 || e.Fingerprint() == "" {
t.Fatalf("元数据异常: loaded=%v dim=%d fingerprint=%q", e.Loaded(), e.Dim(), e.Fingerprint())
}
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("不同图片给出相同向量,视觉路径未生效")
}
}
// TestEmbedderRejectsUnsupportedModalities 音频与视频文件必须显式报「不在本空间」,
// 而不是拿视觉塔硬编码一个语义错误的坐标。
//
// Qwen3-VL 原生支持文本与图像;音频需要未来接入真正的统一音频模型。
// 若这里退化成普通错误,调用方会把它当「本次失败、下次重试」,
// 于是每轮启动都重试一批永远不可能成功的条目。
func TestEmbedderRejectsUnsupportedModalities(t *testing.T) {
dir := requireONNXArtifacts(t)
e := newTestEmbedder(t, dir)
for _, mime := range []string{"audio/wav", "audio/mpeg", "video/mp4", "video/quicktime"} {
_, err := e.EmbedImageDense([]byte("not-a-real-media"), mime)
if err == nil {
t.Fatalf("%s 应返回错误而不是造出向量", mime)
}
if !errors.Is(err, vector.ErrModalityUnsupported) {
t.Errorf("%s 错误应为 ErrModalityUnsupported实际: %v", mime, 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))
}

View File

@ -1,28 +0,0 @@
//go:build !onnxruntime
package qwen
import "fmt"
// Embedder 在未启用 onnxruntime 时为 no-op 实现。
// 默认构建不链接 onnxruntime保持原有 fastText/TF-IDF 行为不变。
type Embedder struct {
loaded bool
}
func New(_ string) (*Embedder, error) {
return nil, fmt.Errorf("qwen embedder requires build tag 'onnxruntime' (go build -tags onnxruntime)")
}
func (e *Embedder) Fingerprint() string { return "" }
func (e *Embedder) Dim() int { return 0 }
func (e *Embedder) Loaded() bool { return e.loaded }
func (e *Embedder) Close() {}
func (e *Embedder) VectorizeDense(_ string) ([]float64, error) {
return nil, fmt.Errorf("qwen embedder not available")
}
func (e *Embedder) EmbedImageDense(_ []byte, _ string) ([]float64, error) {
return nil, fmt.Errorf("qwen embedder not available")
}

View File

@ -1,86 +0,0 @@
//go:build onnxruntime
package qwen
import (
"fmt"
"strings"
)
// imageModelInput 构造 Qwen3-VL 单图对话模板及对应 M-RoPE 位置。
// 固定 768×768 视觉塔产生 576 个合并后的视觉 token。
func (t *Tokenizer) imageModelInput(instruction string, maxLen int) (ids []int, attention, position []int64, visual []bool, err error) {
if instruction == "" {
instruction = DefaultInstruction
}
text := "<|im_start|>system\n" + instruction +
"<|im_end|>\n<|im_start|>user\n<|vision_start|>" +
strings.Repeat("<|image_pad|>", qwenVisualTokens) +
"<|vision_end|><|im_end|>\n<|im_start|>assistant\n"
ids, err = t.encodeModelInput(text, maxLen)
if err != nil {
return nil, nil, nil, nil, err
}
imageID, ok := t.SpecialID("<|image_pad|>")
if !ok {
return nil, nil, nil, nil, fmt.Errorf("tokenizer.json 缺少 <|image_pad|>")
}
visual = make([]bool, len(ids))
attention = make([]int64, len(ids))
position = make([]int64, 3*len(ids))
for i, id := range ids {
attention[i] = 1
visual[i] = id == imageID
}
current := int64(0)
for start := 0; start < len(ids); {
isVisual := visual[start]
end := start + 1
for end < len(ids) && visual[end] == isVisual {
end++
}
if !isVisual {
for i := start; i < end; i++ {
p := current + int64(i-start)
position[i] = p
position[len(ids)+i] = p
position[2*len(ids)+i] = p
}
current += int64(end - start)
} else {
if end-start != qwenVisualTokens {
return nil, nil, nil, nil, fmt.Errorf("qwen: image token count=%d, want %d", end-start, qwenVisualTokens)
}
side := qwenImageSize / qwenPatchSize / qwenSpatialMerge
for i := start; i < end; i++ {
j := i - start
position[i] = current
position[len(ids)+i] = current + int64(j/side)
position[2*len(ids)+i] = current + int64(j%side)
}
current += int64(side)
}
start = end
}
return ids, attention, position, visual, nil
}
// textModelInput 执行完整 tokenizer post_processor并构造纯文本标准 RoPE 位置。
func (t *Tokenizer) textModelInput(instruction, text string, maxLen int) (ids []int, attention, position []int64, visual []bool, err error) {
ids, err = t.encodeModelInput(renderInstructionInput(instruction, text), maxLen)
if err != nil {
return nil, nil, nil, nil, err
}
attention = make([]int64, len(ids))
position = make([]int64, 3*len(ids))
visual = make([]bool, len(ids))
for i := range ids {
attention[i] = 1
position[i] = int64(i)
position[len(ids)+i] = int64(i)
position[2*len(ids)+i] = int64(i)
}
return ids, attention, position, visual, nil
}

View File

@ -2,6 +2,7 @@ package vector
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
@ -10,14 +11,50 @@ import (
"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
APIKey string
Model string
Dimension int
Timeout time.Duration
@ -65,14 +102,14 @@ func NewHTTPEmbedder(cfg HTTPEmbedderConfig) (*HTTPEmbedder, error) {
}
func (e *HTTPEmbedder) VectorizeDense(text string) ([]float64, error) {
return e.embed(httpEmbedRequest{Model: e.cfg.Model, Modality: string(ModalityText), Side: "query", Text: text})
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(httpEmbedRequest{Model: e.cfg.Model, Modality: string(ModalityImage), Side: "document", Data: base64.StdEncoding.EncodeToString(img), MIME: mime})
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(payload httpEmbedRequest) ([]float64, error) {
func (e *HTTPEmbedder) embed(ctx context.Context, payload httpEmbedRequest) ([]float64, error) {
e.mu.Lock()
closed := e.closed
e.mu.Unlock()
@ -83,7 +120,7 @@ func (e *HTTPEmbedder) embed(payload httpEmbedRequest) ([]float64, error) {
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, e.cfg.Endpoint, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.cfg.Endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
@ -119,6 +156,32 @@ func (e *HTTPEmbedder) embed(payload httpEmbedRequest) ([]float64, error) {
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()

View File

@ -0,0 +1,83 @@
package vector
import (
"context"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
)
// ProviderAdapter translates the public model-neutral embedding.Provider SPI
// to the small internal interface used by the existing memory consumers.
// Model selection, media decoding, preprocessing, and runtime details remain
// entirely inside the selected provider.
type ProviderAdapter struct {
provider embedding.Provider
info embedding.Info
mu sync.RWMutex
closed bool
}
// AdaptProvider validates and wraps a public provider for internal memory use.
func AdaptProvider(provider embedding.Provider) (*ProviderAdapter, error) {
info := provider.Info()
if err := embedding.ValidateInfo(info); err != nil {
return nil, err
}
return &ProviderAdapter{provider: provider, info: info}, nil
}
func (a *ProviderAdapter) VectorizeDense(text string) ([]float64, error) {
return a.embed(embedding.Input{
Modality: embedding.ModalityText,
Purpose: embedding.PurposeQuery,
Text: text,
})
}
func (a *ProviderAdapter) EmbedImageDense(data []byte, mime string) ([]float64, error) {
return a.embed(embedding.Input{
Modality: embedding.ModalityImage,
Purpose: embedding.PurposeDocument,
Data: data,
MIME: mime,
})
}
func (a *ProviderAdapter) embed(input embedding.Input) ([]float64, error) {
a.mu.RLock()
closed := a.closed
a.mu.RUnlock()
if closed {
return nil, context.Canceled
}
vec, err := a.provider.Embed(context.Background(), input)
if err != nil {
return nil, err
}
if err := embedding.ValidateVector(vec, a.info.Dimension); err != nil {
return nil, err
}
return vec, nil
}
func (a *ProviderAdapter) Fingerprint() string { return a.info.Fingerprint }
func (a *ProviderAdapter) Dim() int { return a.info.Dimension }
func (a *ProviderAdapter) Loaded() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return !a.closed
}
func (a *ProviderAdapter) Close() {
a.mu.Lock()
if a.closed {
a.mu.Unlock()
return
}
a.closed = true
a.mu.Unlock()
a.provider.Close()
}

View File

@ -0,0 +1,115 @@
package vector
import (
"context"
"errors"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
)
// recordingProvider 记录核心传给 provider 的原始请求,用来断言
// 「核心不解释内容、只搬字节」这一契约。
type recordingProvider struct {
got []embedding.Input
dim int
closed bool
}
func (p *recordingProvider) Embed(_ context.Context, in embedding.Input) ([]float64, error) {
p.got = append(p.got, in)
return make([]float64, p.dim), nil
}
func (p *recordingProvider) Info() embedding.Info {
return embedding.Info{Dimension: p.dim, Fingerprint: "recording:1"}
}
func (p *recordingProvider) Close() { p.closed = true }
func TestProviderAdapterPassesOpaqueDataUnchanged(t *testing.T) {
inner := &recordingProvider{dim: 3}
adapted, err := AdaptProvider(inner)
if err != nil {
t.Fatal(err)
}
defer adapted.Close()
// 核心把媒体当作不透明字节搬运:既不解码也不改字节。
raw := []byte{0x89, 'P', 'N', 'G', 0x00, 0xff}
if _, err := adapted.EmbedImageDense(raw, "image/png"); err != nil {
t.Fatal(err)
}
got := inner.got[0]
if string(got.Data) != string(raw) {
t.Fatalf("provider 收到的字节被改动: %v", got.Data)
}
if got.Modality != embedding.ModalityImage || got.MIME != "image/png" {
t.Fatalf("模态/MIME 未原样传递: %+v", got)
}
if got.Purpose != embedding.PurposeDocument {
t.Fatalf("用途应为 document: %q", got.Purpose)
}
if _, err := adapted.VectorizeDense("hello"); err != nil {
t.Fatal(err)
}
if inner.got[1].Modality != embedding.ModalityText || inner.got[1].Text != "hello" {
t.Fatalf("文本请求不正确: %+v", inner.got[1])
}
}
func TestProviderAdapterRejectsWrongDimensionFromProvider(t *testing.T) {
// provider 声明 3 维却返回 2 维:必须在进入存储前被拦下,
// 否则一个维度错的向量会污染整个余弦检索。
bad := &badDimProvider{}
adapted, err := AdaptProvider(bad)
if err != nil {
t.Fatal(err)
}
defer adapted.Close()
if _, err := adapted.VectorizeDense("x"); err == nil {
t.Fatal("维度不符时应返回错误")
}
}
type badDimProvider struct{}
func (badDimProvider) Embed(context.Context, embedding.Input) ([]float64, error) {
return []float64{1, 2}, nil
}
func (badDimProvider) Info() embedding.Info {
return embedding.Info{Dimension: 3, Fingerprint: "bad:1"}
}
func (badDimProvider) Close() {}
func TestProviderAdapterCloseIsIdempotentAndStopsUse(t *testing.T) {
inner := &recordingProvider{dim: 2}
adapted, err := AdaptProvider(inner)
if err != nil {
t.Fatal(err)
}
adapted.Close()
adapted.Close() // 重复关闭不应 panic 或二次 Close provider
if !inner.closed {
t.Fatal("Close 未传递到 provider")
}
if _, err := adapted.VectorizeDense("x"); err == nil {
t.Fatal("关闭后应拒绝调用")
}
if adapted.Loaded() {
t.Fatal("关闭后 Loaded() 应为 false")
}
}
func TestModalityUnsupportedSentinelIsShared(t *testing.T) {
// 内核侧的哨兵与公共契约的哨兵必须是同一个provider 返回公共哨兵时,
// 内核仍能用自己原有的名字识别。
if !errors.Is(ErrModalityUnsupported, embedding.ErrUnsupportedModality) {
t.Fatal("vector.ErrModalityUnsupported 与 embedding.ErrUnsupportedModality 未打通")
}
wrapped := errors.Join(embedding.ErrUnsupportedModality, errors.New("audio/wav"))
if !errors.Is(wrapped, ErrModalityUnsupported) {
t.Fatal("包装后的错误无法用内核哨兵识别")
}
}

View File

@ -6,6 +6,8 @@ import (
"sort"
"strings"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
)
// Vectorizer 接口:将文本转为向量
@ -56,8 +58,15 @@ var ErrNotSupported = fmt.Errorf("vectorizer does not support image embedding")
// 而不是「这次失败了、下次重试」。绝不能拿另一个模型的向量顶替——那会把
// 两套坐标系混进同一空间,检索出来的相似度没有任何意义。
//
// Qwen3-VL 能原生编码文本/图像,音频需要未来接入真正的统一音频模型。
var ErrModalityUnsupported = fmt.Errorf("modality not supported by this embedding space")
// 它是公共 provider 契约里那个哨兵值的别名,两者 errors.Is 互通:
// provider 在自己的包内返回 embedding.ErrUnsupportedModality 即可,
// 内核侧的判断无需改变。
var ErrModalityUnsupported = embedding.ErrUnsupportedModality
// 注:曾经这里还有一个可选的 VideoEmbedder 接口(用类型断言探测视频能力)。
// 已删除:那让核心为每一个新模态长出一套模型专属方法,正是“核心适配模型”的
// 坏味道。模态能力现在是数据embedding.Info.Modalities输入是不透明的
// Data+MIME见 pkg/embedding
// Vector 是带权特征映射feature → weight
type Vector map[string]float64

172
pkg/embedding/embedding.go Normal file
View File

@ -0,0 +1,172 @@
// Package embedding defines the public SPI for dense multimodal embedding providers.
//
// The HomeAgent core depends only on this package. Model runtimes, tokenizers,
// preprocessing, media decoding, and model-specific configuration belong in
// provider packages registered with Register.
package embedding
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strings"
"sync"
)
// Modality identifies the semantic kind of an embedding input. Providers may
// support additional modality strings; the constants are the common values.
type Modality string
const (
ModalityText Modality = "text"
ModalityImage Modality = "image"
ModalityAudio Modality = "audio"
ModalityVideo Modality = "video"
)
// Purpose tells a provider how the vector will be used. Providers whose model
// distinguishes query and document prompts can map this value accordingly.
type Purpose string
const (
PurposeQuery Purpose = "query"
PurposeDocument Purpose = "document"
)
// Input is the model-neutral request passed to a provider.
//
// Data is deliberately opaque to the core. MIME describes the encoding; the
// selected provider owns decoding, frame sampling, preprocessing, and all
// other model-specific interpretation. Text is used for textual inputs.
type Input struct {
Modality Modality
Purpose Purpose
Text string
Data []byte
MIME string
Metadata map[string]string
}
// Info describes one vector space. Fingerprint must change whenever vectors
// cease to be comparable with vectors produced by a previous provider build.
type Info struct {
Dimension int
Fingerprint string
Modalities []Modality
}
// Provider is the public Go extension point for a dense multimodal vector
// space. Implementations must be safe for concurrent Embed calls unless their
// factory documents otherwise and serializes internally.
type Provider interface {
Embed(context.Context, Input) ([]float64, error)
Info() Info
Close()
}
// Config contains provider-owned options. The core does not interpret option
// names or values; it only passes core.memory.multimodal_space.options.*
// through after stripping the prefix.
type Config struct {
Options map[string]string
}
// Factory constructs a provider instance.
type Factory func(Config) (Provider, error)
var (
// ErrUnsupportedModality means this vector space has no native encoder for
// the requested modality. Callers must not substitute another model's vector.
ErrUnsupportedModality = errors.New("embedding: unsupported modality")
registryMu sync.RWMutex
registry = make(map[string]Factory)
)
// Register makes a provider factory available under name. It is normally
// called from a provider package's init function. Duplicate names panic so a
// build cannot silently select whichever package initialized last.
func Register(name string, factory Factory) {
name = strings.TrimSpace(name)
if name == "" {
panic("embedding: register empty provider name")
}
if factory == nil {
panic("embedding: register nil factory for " + name)
}
registryMu.Lock()
defer registryMu.Unlock()
if _, exists := registry[name]; exists {
panic("embedding: provider already registered: " + name)
}
registry[name] = factory
}
// Open constructs a registered provider and validates its vector-space identity.
func Open(name string, cfg Config) (Provider, error) {
name = strings.TrimSpace(name)
registryMu.RLock()
factory := registry[name]
registryMu.RUnlock()
if factory == nil {
return nil, fmt.Errorf("embedding: unknown provider %q (available: %s)", name, strings.Join(Names(), ", "))
}
provider, err := factory(cloneConfig(cfg))
if err != nil {
return nil, fmt.Errorf("embedding: open provider %q: %w", name, err)
}
if provider == nil {
return nil, fmt.Errorf("embedding: provider %q returned nil", name)
}
if err := ValidateInfo(provider.Info()); err != nil {
provider.Close()
return nil, fmt.Errorf("embedding: provider %q: %w", name, err)
}
return provider, nil
}
// Names returns registered provider names in deterministic order.
func Names() []string {
registryMu.RLock()
defer registryMu.RUnlock()
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
sort.Strings(names)
return names
}
// ValidateInfo checks the stable identity required by vector persistence.
func ValidateInfo(info Info) error {
if info.Dimension <= 0 {
return fmt.Errorf("invalid dimension %d", info.Dimension)
}
if strings.TrimSpace(info.Fingerprint) == "" {
return errors.New("empty fingerprint")
}
return nil
}
// ValidateVector rejects malformed provider output before it reaches storage.
func ValidateVector(vec []float64, dimension int) error {
if len(vec) != dimension {
return fmt.Errorf("embedding: vector dimension %d, want %d", len(vec), dimension)
}
for i, value := range vec {
if math.IsNaN(value) || math.IsInf(value, 0) {
return fmt.Errorf("embedding: vector value %d is not finite", i)
}
}
return nil
}
func cloneConfig(cfg Config) Config {
out := Config{Options: make(map[string]string, len(cfg.Options))}
for key, value := range cfg.Options {
out.Options[key] = value
}
return out
}

View File

@ -0,0 +1,84 @@
package embedding
import (
"context"
"errors"
"math"
"reflect"
"strings"
"testing"
)
type testProvider struct {
info Info
closed bool
}
func (p *testProvider) Embed(_ context.Context, _ Input) ([]float64, error) {
return []float64{1, 0}, nil
}
func (p *testProvider) Info() Info { return p.info }
func (p *testProvider) Close() { p.closed = true }
func TestRegistryOpensProviderWithIsolatedOptions(t *testing.T) {
name := "test-registry-provider"
var got Config
Register(name, func(cfg Config) (Provider, error) {
got = cfg
cfg.Options["mutated"] = "inside"
return &testProvider{info: Info{Dimension: 2, Fingerprint: "test:1"}}, nil
})
input := Config{Options: map[string]string{"model_dir": "/model"}}
provider, err := Open(name, input)
if err != nil {
t.Fatal(err)
}
defer provider.Close()
if got.Options["model_dir"] != "/model" {
t.Fatalf("factory options = %#v", got.Options)
}
if _, changed := input.Options["mutated"]; changed {
t.Fatal("factory mutated caller-owned options")
}
if !reflect.DeepEqual(provider.Info(), Info{Dimension: 2, Fingerprint: "test:1"}) {
t.Fatalf("Info = %#v", provider.Info())
}
}
func TestOpenRejectsUnknownProvider(t *testing.T) {
_, err := Open("definitely-missing-provider", Config{})
if err == nil || !strings.Contains(err.Error(), "unknown provider") {
t.Fatalf("Open error = %v", err)
}
}
func TestOpenRejectsInvalidInfoAndClosesProvider(t *testing.T) {
name := "test-invalid-info-provider"
provider := &testProvider{info: Info{Dimension: 0, Fingerprint: ""}}
Register(name, func(Config) (Provider, error) { return provider, nil })
if _, err := Open(name, Config{}); err == nil {
t.Fatal("Open accepted invalid Info")
}
if !provider.closed {
t.Fatal("invalid provider was not closed")
}
}
func TestValidateVector(t *testing.T) {
if err := ValidateVector([]float64{1, 2}, 2); err != nil {
t.Fatal(err)
}
if err := ValidateVector([]float64{1}, 2); err == nil {
t.Fatal("dimension mismatch accepted")
}
if err := ValidateVector([]float64{1, math.NaN()}, 2); err == nil {
t.Fatal("non-finite vector accepted")
}
}
func TestUnsupportedModalitySentinel(t *testing.T) {
err := errors.Join(ErrUnsupportedModality, errors.New("audio"))
if !errors.Is(err, ErrUnsupportedModality) {
t.Fatal("sentinel does not support errors.Is")
}
}

View File

@ -1,11 +1,12 @@
//go:build onnxruntime
// Package qwen 提供 Qwen3-VL-Embedding 的完整图文共享 ONNX 编码器。
// 文本和图像共用 token embedding、28 层 Transformer、last-token 池化与
// fingerprintVision.onnx 只产生注入 Transformer 的中间特征。
package qwen
// 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"
@ -19,9 +20,20 @@ import (
ort "github.com/yalue/onnxruntime_go"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
"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"`
@ -41,14 +53,21 @@ type embedConfig struct {
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
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) {
@ -113,19 +132,21 @@ func New(modelDir string) (*Embedder, error) {
}
return &Embedder{
loaded: true, config: cfg, tok: tok,
token: token, transform: transform, vision: vision,
fp: computeFingerprint(modelDir),
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()
defer e.mu.RUnlock()
if !e.loaded {
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 := e.tok.textModelInput(e.config.Instruction, text, e.config.MaxLength)
ids, _, position, _, err := tok.textModelInput(cfg.Instruction, text, cfg.MaxLength)
if err != nil {
return nil, err
}
@ -142,34 +163,143 @@ func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
// EmbedImageDense 把一张图片编码到统一空间。
//
// mime 决定这个模态是否在本空间的原生覆盖范围内Qwen3-VL 能原生编码文本
// 图像,但**不原生支持音频**。音频(以及未抽帧的视频文件)必须返回
// ErrModalityUnsupported而不是拿视觉塔去编码——那会往统一空间里灌入
// 语义错误的坐标,而错误是静默的。视频请由上层抽帧后逐帧当作图像编码
// mime 决定这个模态是否在本空间的原生覆盖范围内Qwen3-VL 能原生编码文本
// 图像与视频,但**不原生支持音频**(模型卡与 config 双重确认:没有
// audio_token_id / audio_config。音频必须返回 ErrModalityUnsupported
// 而不是拿视觉塔去编码——那会往统一空间里灌入语义错误的坐标,而错误是静默的。
//
// video/*(视频文件)也在这里拒绝:本函数的入参是**单帧字节**Go 侧没有
// 视频解码器;多帧请走 EmbedVideoDense。
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)
if err := checkImageMime(mime); err != nil {
return nil, err
}
pixels, err := preprocessImage(raw)
if err != nil {
return nil, err
}
features, err := e.runVision(pixels)
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) {
if err := checkVideoMime(mime); err != nil {
return nil, err
}
if len(frames) < qwenTemporalPatch {
return nil, fmt.Errorf("qwen: video needs at least %d frames, got %d", qwenTemporalPatch, len(frames))
}
groups := len(frames) / qwenTemporalPatch
// 先确认这一档的视觉图确实已导出,再去做昂贵的预处理:
// 不然一个未导出档位会先白算一遍(每组 2304×1536 浮点)才报错。
if _, err := e.visionFor(groups); err != nil {
return nil, err
}
pixels, groups, err := preprocessVideoFrames(frames)
if err != nil {
return nil, err
}
ids, _, position, visual, err := e.tok.imageModelInput(e.config.Instruction, e.config.MaxLength)
return e.embedVision(pixels, groups)
}
// 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
@ -178,21 +308,23 @@ func (e *Embedder) EmbedImageDense(raw []byte, mime string) ([]float64, error) {
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 * e.config.Dimension
src := visualIndex * e.config.Dimension
copy(hidden[dst:dst+e.config.Dimension], features[3][src:src+e.config.Dimension])
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+e.config.Dimension], features[layer][src:src+e.config.Dimension])
copy(deep[layer][dst:dst+cfg.Dimension], features[layer][src:src+cfg.Dimension])
}
visualIndex++
}
if visualIndex != qwenVisualTokens {
return nil, fmt.Errorf("qwen: injected visual tokens=%d, want %d", visualIndex, qwenVisualTokens)
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))
}
@ -226,14 +358,19 @@ func (e *Embedder) runTokenEmbedding(ids []int) ([]float32, error) {
return append([]float32(nil), tensor.GetData()...), nil
}
func (e *Embedder) runVision(pixels []float32) ([][]float32, error) {
in, err := ort.NewTensor(ort.Shape{qwenImagePatches, qwenPatchVectorSize}, pixels)
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 := e.vision.Run([]ort.Value{in}, outs); err != nil {
if err := sess.Run([]ort.Value{in}, outs); err != nil {
return nil, fmt.Errorf("qwen vision run: %w", err)
}
features := make([][]float32, 4)
@ -247,8 +384,8 @@ func (e *Embedder) runVision(pixels []float32) ([][]float32, error) {
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)
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()...)
}
@ -376,12 +513,43 @@ func normalize(raw []float32) []float64 {
return out
}
func (e *Embedder) Fingerprint() string { return e.fp }
func (e *Embedder) Dim() int { return e.config.Dimension }
func (e *Embedder) Loaded() bool {
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 e.loaded
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() {
@ -395,17 +563,34 @@ func (e *Embedder) Close() {
e.transform.Destroy()
e.transform = nil
}
if e.vision != nil {
e.vision.Destroy()
e.vision = 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()
for _, name := range []string{"TokenEmbedding.onnx", "Transformer.onnx", "Vision.onnx", "embed_config.json"} {
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})

View File

@ -0,0 +1,556 @@
//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 时应打开失败")
}
}

View File

@ -0,0 +1,28 @@
//go:build !onnxruntime
package qwen3vl
import (
"context"
"errors"
"fmt"
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
)
func init() {
embedding.Register("qwen3vl", func(embedding.Config) (embedding.Provider, error) {
return nil, fmt.Errorf("qwen3vl provider requires build tag 'onnxruntime' (go build -tags onnxruntime)")
})
}
// Embedder 在未启用 onnxruntime 时不可用;保留类型是为了让引用它的代码在
// 默认构建下也能编译。真正的 ONNX 实现见 embedder_onnx.go。
type Embedder struct{}
func (e *Embedder) Embed(context.Context, embedding.Input) ([]float64, error) {
return nil, errors.New("qwen3vl provider not available in this build")
}
func (e *Embedder) Info() embedding.Info { return embedding.Info{} }
func (e *Embedder) Close() {}

View File

@ -1,6 +1,6 @@
//go:build onnxruntime
package qwen
package qwen3vl
import (
"bytes"
@ -21,15 +21,24 @@ const (
qwenImagePatches = (qwenImageSize / qwenPatchSize) * (qwenImageSize / qwenPatchSize)
qwenVisualTokens = qwenImagePatches / (qwenSpatialMerge * qwenSpatialMerge)
qwenPatchVectorSize = 3 * qwenTemporalPatch * qwenPatchSize * qwenPatchSize
// qwenVisionScale 是视觉塔空间步长grid_h / spatial_merge。
// 每个时间组消耗这么多 M-RoPE 位置(见 model_input.go 的说明)。
qwenVisionScale = (qwenImageSize / qwenPatchSize) / qwenSpatialMerge
// maxVideoGroupsSafety 是分配安全上限,**不是**能力上限。
// 真正能导出哪些档由产物目录决定Vision_g{N}.onnx内核不硬编码
// 导出清单,否则别人导出 G=8 就会被内核莫名拒绝。
// 这个上限只用来防住「丢了上千帧进来」导致的巨量分配。
maxVideoGroupsSafety = 64
)
// preprocessImage 把任意图片转成固定 768×768 视觉塔输入
// fitCanvas 把任意图片解码并转成固定 768×768 画布
//
// Vision.onnx 是经过 PyTorch 逐输出验证的固定 48×48 patch 图。为避免拉伸物体,
// 这里保持宽高比缩放并在中心补中性灰(归一化后约为 0这与直接把长方形
// 强拉成正方形相比更能保留 Qwen 的视觉语义。已是 768×768 的输入不做插值,
// 便于用跨语言冻结向量精确回归 patch 排列。
func preprocessImage(raw []byte) ([]float32, error) {
// 保持宽高比缩放并在中心补中性灰(归一化后约为 0直接强拉成正方形会
// 破坏物体形状。已是 768×768 的输入不做插值,以便用跨语言冻结向量
// 精确回归 patch 排列。
func fitCanvas(raw []byte) (*image.NRGBA, error) {
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("qwen: decode image: %w", err)
@ -61,11 +70,18 @@ func preprocessImage(raw []byte) ([]float32, error) {
canvas.SetNRGBA(ox+x, oy+y, resized.NRGBAAt(x, y))
}
}
return canvas, nil
}
// 与 transformers Qwen2VLImageProcessor 的排列严格一致:
// [grid_h/merge, grid_w/merge, merge_h, merge_w, channel,
// temporal_patch, patch_h, patch_w],然后 flatten。
out := make([]float32, 0, qwenImagePatches*qwenPatchVectorSize)
// appendPatches 按 Qwen2VLImageProcessor 的排列把一个时间组的两个画布写入 out。
//
// 排列:[grid_h/merge, grid_w/merge, merge_h, merge_w, channel,
// temporal_patch, patch_h, patch_w],然后 flatten。
//
// 图像与视频共用本函数:图像的两个时间槽传同一张画布,视频传相邻两帧。
// 共用是刻意的——两处各写一份排列,迟早会在某次修改后漂移,
// 而排列错了只会得到一个语义偏移的向量,不会报错。
func appendPatches(out []float32, slots *[qwenTemporalPatch]*image.NRGBA) []float32 {
blocks := qwenImageSize / qwenPatchSize / qwenSpatialMerge
for bh := 0; bh < blocks; bh++ {
for bw := 0; bw < blocks; bw++ {
@ -75,7 +91,7 @@ func preprocessImage(raw []byte) ([]float32, error) {
baseX := (bw*qwenSpatialMerge + mw) * qwenPatchSize
for c := 0; c < 3; c++ {
for temporal := 0; temporal < qwenTemporalPatch; temporal++ {
_ = temporal // 静态图复制同一图片形成 2 帧 temporal patch
canvas := slots[temporal]
for py := 0; py < qwenPatchSize; py++ {
for px := 0; px < qwenPatchSize; px++ {
p := canvas.NRGBAAt(baseX+px, baseY+py)
@ -89,7 +105,52 @@ func preprocessImage(raw []byte) ([]float32, error) {
}
}
}
return out, nil
return out
}
// preprocessImage 把任意图片转成固定 768×768 视觉塔输入(单个时间组)。
func preprocessImage(raw []byte) ([]float32, error) {
canvas, err := fitCanvas(raw)
if err != nil {
return nil, err
}
slots := [qwenTemporalPatch]*image.NRGBA{canvas, canvas}
out := make([]float32, 0, qwenImagePatches*qwenPatchVectorSize)
return appendPatches(out, &slots), nil
}
// preprocessVideoFrames 把已按时间排序的帧转成 G 个时间组的视觉塔输入,
// 返回 patch 张量与时间组数 G。
//
// 时间组 g 的两个时间槽依次取帧 2g 与 2g+1这与处理器实测逐字节一致
// (纯色与异色两组对照均 torch.equal 通过);布局整体是
// [G, blocks_h, blocks_w, merge_h, merge_w, c, temporal, patch_h, patch_w]
// 即图像排列以 grid_t 为最外层堆叠。
//
// 帧数为奇数时不补帧:只用得上的帧参与编码,多余的一帧被丢弃,
// 以免用重复帧伪造时序——那会改变跨帧注意力看到的运动。
func preprocessVideoFrames(frames [][]byte) ([]float32, int, error) {
if len(frames) < qwenTemporalPatch {
return nil, 0, fmt.Errorf("qwen: video needs at least %d frames, got %d", qwenTemporalPatch, len(frames))
}
groups := len(frames) / qwenTemporalPatch
if groups > maxVideoGroupsSafety {
return nil, 0, fmt.Errorf("qwen: video groups %d exceeds safety limit %d请先对帧采样", groups, maxVideoGroupsSafety)
}
canvases := make([]*image.NRGBA, groups*qwenTemporalPatch)
for i := 0; i < groups*qwenTemporalPatch; i++ {
canvas, err := fitCanvas(frames[i])
if err != nil {
return nil, 0, fmt.Errorf("qwen: frame %d: %w", i, err)
}
canvases[i] = canvas
}
out := make([]float32, 0, groups*qwenImagePatches*qwenPatchVectorSize)
for g := 0; g < groups; g++ {
slots := [qwenTemporalPatch]*image.NRGBA{canvases[2*g], canvases[2*g+1]}
out = appendPatches(out, &slots)
}
return out, groups, nil
}
// resizeBicubic 使用半像素中心的 Catmull-Rom 三次卷积。

View File

@ -0,0 +1,130 @@
//go:build onnxruntime
package qwen3vl
import (
"fmt"
"strings"
)
// 视觉输入(图像与视频)的模型输入构造。
//
// 图像与视频的模板结构完全一致,只有两点不同:
// 1. 占位符:<|image_pad|>id 151655vs <|video_pad|>id 151656
// 2. 时间组数:图像恒为 1 组576 个视觉 token视频为 G 组G×576
//
// 因此两者共用同一个构造器。分开写两份必然漂移,而漂移的表现是
// 「嵌入略有不同」——不报错,只是检索慢慢变差。
// visionModelInput 构造视觉输入的 token 序列与 M-RoPE 位置。
//
// padToken 是 <|image_pad|> 或 <|video_pad|>groups 是时间组数。
func (t *Tokenizer) visionModelInput(instruction, padToken string, groups, maxLen int) (ids []int, attention, position []int64, visual []bool, err error) {
if instruction == "" {
instruction = DefaultInstruction
}
if groups < 1 {
return nil, nil, nil, nil, fmt.Errorf("qwen: vision groups must be >= 1, got %d", groups)
}
if _, ok := t.SpecialID(padToken); !ok {
return nil, nil, nil, nil, fmt.Errorf("tokenizer.json 缺少 %s", padToken)
}
text := "<|im_start|>system\n" + instruction +
"<|im_end|>\n<|im_start|>user\n<|vision_start|>" +
strings.Repeat(padToken, groups*qwenVisualTokens) +
"<|vision_end|><|im_end|>\n<|im_start|>assistant\n"
ids, err = t.encodeModelInput(text, maxLen)
if err != nil {
return nil, nil, nil, nil, err
}
padID, _ := t.SpecialID(padToken)
visual = make([]bool, len(ids))
attention = make([]int64, len(ids))
position = make([]int64, 3*len(ids))
for i, id := range ids {
attention[i] = 1
visual[i] = id == padID
}
current := int64(0)
for start := 0; start < len(ids); {
isVisual := visual[start]
end := start + 1
for end < len(ids) && visual[end] == isVisual {
end++
}
if !isVisual {
for i := start; i < end; i++ {
p := current + int64(i-start)
position[i] = p
position[len(ids)+i] = p
position[2*len(ids)+i] = p
}
current += int64(end - start)
} else {
run := end - start
if run != groups*qwenVisualTokens {
return nil, nil, nil, nil, fmt.Errorf("qwen: %s run=%d, want %d (groups=%d)",
padToken, run, groups*qwenVisualTokens, groups)
}
// 每个时间组独立取位置t 在组内固定为 baseh/w 在组内递增,
// 组间 base 前进一个视觉步长。
//
// 与 transformers 的实现对应get_rope_index 对视频先把
// video_grid_thw 按 grid_t 展开成 G 个 (1,h,w) 的 grid 项,
// 每项单独调用 get_vision_position_ids(current_pos, (1,h,w))
// 然后 current_pos += max(h,w)/spatial_merge。因为每项 t=1
// 其 temporal 分量就等于 current_posh/w 从 current_pos 起递增。
for g := 0; g < groups; g++ {
base := current
for j := 0; j < qwenVisualTokens; j++ {
i := start + g*qwenVisualTokens + j
position[i] = base
position[len(ids)+i] = base + int64(j/qwenVisionScale)
position[2*len(ids)+i] = base + int64(j%qwenVisionScale)
}
current += int64(qwenVisionScale)
}
}
start = end
}
return ids, attention, position, visual, nil
}
// imageModelInput 构造 Qwen3-VL 单图对话模板及对应 M-RoPE 位置。
// 固定 768×768 视觉塔产生 576 个合并后的视觉 token。
func (t *Tokenizer) imageModelInput(instruction string, maxLen int) (ids []int, attention, position []int64, visual []bool, err error) {
return t.visionModelInput(instruction, "<|image_pad|>", 1, maxLen)
}
// videoModelInput 构造 Qwen3-VL 视频对话模板及对应 M-RoPE 位置。
//
// groups 是时间组数(每组合 2 帧),共 2×groups 帧、groups×576 个视觉 token。
// 占位符是 <|video_pad|>id 151656与图像的 <|image_pad|> 不同——
// 用错占位符不会报错,只会让模型把它当成另一种模态。
//
// 这里不限制 groups 上限哪些档位真的可用由产物目录Vision_g{N}.onnx决定
// 硬编码一份清单在这里只会与导出脚本漂移。序列过长会因 tokenizer 截断
// 而在下面的视觉区间长度校验处明确报错。
func (t *Tokenizer) videoModelInput(instruction string, groups, maxLen int) (ids []int, attention, position []int64, visual []bool, err error) {
return t.visionModelInput(instruction, "<|video_pad|>", groups, maxLen)
}
// textModelInput 执行完整 tokenizer post_processor并构造纯文本标准 RoPE 位置。
func (t *Tokenizer) textModelInput(instruction, text string, maxLen int) (ids []int, attention, position []int64, visual []bool, err error) {
ids, err = t.encodeModelInput(renderInstructionInput(instruction, text), maxLen)
if err != nil {
return nil, nil, nil, nil, err
}
attention = make([]int64, len(ids))
position = make([]int64, 3*len(ids))
visual = make([]bool, len(ids))
for i := range ids {
attention[i] = 1
position[i] = int64(i)
position[len(ids)+i] = int64(i)
position[2*len(ids)+i] = int64(i)
}
return ids, attention, position, visual, nil
}

View File

@ -13,7 +13,7 @@
// 2. Go 的 `\s` 只覆盖 ASCII而 Rust regex 的 `\s` 是 Unicode
// `\p{White_Space}`。不换成 \p{White_Space} 的话全角空格、NBSP、
// 行分隔符等的切分点会与上游不一致。
package qwen
package qwen3vl
import (
"encoding/json"

View File

@ -1,4 +1,4 @@
package qwen
package qwen3vl
import (
"encoding/json"

View File

@ -35,9 +35,11 @@ grid 固定为 (1, 48, 48),并在导出处做 PyTorch↔ONNX 一致性校验
from __future__ import annotations
import argparse
import gc
import json
import os
import shutil
import subprocess
import sys
import time
@ -49,10 +51,31 @@ IMAGE_SIZE = 768
PATCH_SIZE = 16
TEMPORAL_PATCH = 2
SPATIAL_MERGE = 2
# 每个时间组合并后的视觉 token 数:(768/16/2)^2 = 576。
VISUAL_TOKENS_PER_GROUP = (IMAGE_SIZE // PATCH_SIZE // SPATIAL_MERGE) ** 2
MAX_LENGTH = 1024 # 768x768 有 576 个视觉 token512 会截断视觉占位符
DEFAULT_MODEL_ID = "Qwen/Qwen3-VL-Embedding-2B"
REFERENCE_TEXT = "今天天气怎么样"
REFERENCE_IMAGE_RGB = (200, 30, 30)
# 视频参考4 帧、4 种颜色 → 2 个时间组。用可区分的颜色,
# 这样帧顺序(组 g 的 tp0←帧2g、tp1←帧2g+1写错时参考向量立刻不匹配。
REFERENCE_VIDEO_RGB = [(10, 10, 10), (200, 20, 20), (20, 200, 20), (20, 20, 200)]
DEFAULT_VIDEO_GROUPS = (2, 3, 4)
# MAX_LENGTH 由 main() 按 --video-groups 调大;做成模块级是因为文本/图像/视频
# 三个输入构造函数共用它。
MAX_LENGTH = 1024
def max_length_for(video_groups) -> int:
"""足够容纳最大视频档的序列长度。
图像路径只需 598 token1 组),但视频是 G×576G=2 就要 1190
G=4 要 2342。实测过若沿用图像的 1024处理器会因截断而报
「Mismatch in video token count between text and input_ids」。
模板文本实测约 38 token这里留 256 余量(允许将来插入更长的指令)。
"""
return max(1024, max(video_groups) * VISUAL_TOKENS_PER_GROUP + 256)
def log(msg: str) -> None:
@ -190,31 +213,76 @@ def text_inputs(processor, lm, text):
return x, hidden, (zero, zero, zero), cos, sin, causal_mask(hidden.shape[1]), pos
def image_inputs(processor, model, image):
conv = [{"role": "system", "content": [{"type": "text", "text": INSTRUCTION}]},
{"role": "user", "content": [{"type": "image", "image": image}]}]
rendered = processor.apply_chat_template([conv], add_generation_prompt=True, tokenize=False)
x = processor(text=rendered, images=[image], do_resize=False,
return_tensors="pt", truncation=True, max_length=MAX_LENGTH)
def image_inputs(processor, model, image, groups=1):
"""构造视觉输入。
groups=1 走图像路径(<|image_pad|>groups>1 走视频路径
<|video_pad|>2×groups 帧,相邻两帧一个时间组)。
两者模板结构一致,只差占位符与组数。
"""
lm = model.model.language_model
with torch.no_grad():
vo = model.model.visual(x["pixel_values"], grid_thw=x["image_grid_thw"], return_dict=True)
pos, _ = model.model.get_rope_index(
x["input_ids"], x["mm_token_type_ids"],
image_grid_thw=x["image_grid_thw"], attention_mask=x["attention_mask"])
mask = x["mm_token_type_ids"] == 1
hidden = lm.embed_tokens(x["input_ids"])
hidden = hidden.clone()
hidden[mask] = vo.pooler_output
if groups == 1:
conv = [{"role": "system", "content": [{"type": "text", "text": INSTRUCTION}]},
{"role": "user", "content": [{"type": "image", "image": image}]}]
rendered = processor.apply_chat_template([conv], add_generation_prompt=True, tokenize=False)
x = processor(text=rendered, images=[image], do_resize=False,
return_tensors="pt", truncation=True, max_length=MAX_LENGTH)
with torch.no_grad():
vo = model.model.visual(x["pixel_values"], grid_thw=x["image_grid_thw"], return_dict=True)
pos, _ = model.model.get_rope_index(
x["input_ids"], x["mm_token_type_ids"],
image_grid_thw=x["image_grid_thw"], attention_mask=x["attention_mask"])
visual_mask = x["mm_token_type_ids"] == 1
else:
frames = video_frames(groups)
conv = [{"role": "system", "content": [{"type": "text", "text": INSTRUCTION}]},
{"role": "user", "content": [{"type": "video", "video": frames}]}]
rendered = processor.apply_chat_template([conv], add_generation_prompt=True, tokenize=False)
# do_sample_frames=False 至关重要:处理器默认按 fps 重采样视频,
# 未提供 video_metadata 时回落到 fps=24会把任何帧数都改成 grid_t=2
# (实测 4/6/8 帧都变成 1152 个视觉 token。那会把「G 帧」变成
# 「2 帧」,且在导出阶段看起来一切正常。
x = processor(text=rendered, videos=[frames], do_resize=False, do_sample_frames=False,
return_tensors="pt", truncation=True, max_length=MAX_LENGTH)
got_groups = int(x["video_grid_thw"][0][0])
if got_groups != groups:
raise SystemExit(
f"视频时间组数 {got_groups},期望 {groups}(处理器重采样了帧?"
"确认 do_sample_frames=False 未被覆盖)")
with torch.no_grad():
vo = model.model.visual(x["pixel_values_videos"], grid_thw=x["video_grid_thw"], return_dict=True)
pos, _ = model.model.get_rope_index(
x["input_ids"], x["mm_token_type_ids"],
video_grid_thw=x["video_grid_thw"], attention_mask=x["attention_mask"])
visual_mask = x["mm_token_type_ids"] == 2
hidden = lm.embed_tokens(x["input_ids"]).clone()
if int(visual_mask.sum()) != groups * VISUAL_TOKENS_PER_GROUP:
# 截断、模板改动、占位符扩展异常都会落到这里。它能区分
# 「真的错了」与「只是看起来像」,比后续 scatter 报形状不符清楚得多。
raise SystemExit(
f"groups={groups} 视觉 token 数 {int(visual_mask.sum())},期望 "
f"{groups * VISUAL_TOKENS_PER_GROUP}max_length={MAX_LENGTH}"
"截断会导致此错,请提高 --video-groups 推导出的 max_length")
hidden[visual_mask] = vo.pooler_output
deep = []
for d in vo.deepstack_features:
full = torch.zeros_like(hidden)
full[mask] = d
full[visual_mask] = d
deep.append(full)
cos, sin = lm.rotary_emb(hidden, pos)
return x, hidden, tuple(deep), cos, sin, causal_mask(hidden.shape[1]), pos
def video_frames(groups: int):
from PIL import Image
fps = list(REFERENCE_VIDEO_RGB)
while len(fps) < 2 * groups:
fps.append(fps[len(fps) % len(REFERENCE_VIDEO_RGB)])
return [Image.new("RGB", (IMAGE_SIZE, IMAGE_SIZE), c) for c in fps[: 2 * groups]]
def causal_mask(seq: int) -> torch.Tensor:
m = torch.full((1, 1, seq, seq), torch.finfo(torch.float32).min)
return torch.triu(m, diagonal=1)
@ -271,21 +339,38 @@ def unit(vec):
return v / n if n > 0 else v
def verify_onnx(out_dir: str, model, processor) -> dict:
"""用 onnxruntime 跑导出后的三段图,对比完整模型前向。
RESULT_PREFIX = "@@VERIFY_RESULT@@"
返回参考向量(供 Go 侧测试冻结使用Go 必须复现同一套预处理与模板,
因此这里把同一输入下的期望向量前若干维导出。
"""
def onnx_session(path: str):
import onnxruntime as ort
log("校验②:导出后的 ONNX 三段图 vs 完整模型")
ts = ort.InferenceSession(os.path.join(out_dir, "TokenEmbedding.onnx"), providers=["CPUExecutionProvider"])
xs = ort.InferenceSession(os.path.join(out_dir, "Transformer.onnx"), providers=["CPUExecutionProvider"])
vs = ort.InferenceSession(os.path.join(out_dir, "Vision.onnx"), providers=["CPUExecutionProvider"])
lm = model.model.language_model
return ort.InferenceSession(path, providers=["CPUExecutionProvider"])
reference: dict[str, object] = {}
def load_model(model_dir: str):
"""加载 FP32 CPU 全模型与处理器(导出与校验共用同一套加载参数)。"""
from transformers import AutoProcessor
from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLForConditionalGeneration
processor = AutoProcessor.from_pretrained(model_dir, trust_remote_code=True, padding_side="right")
model = Qwen3VLForConditionalGeneration.from_pretrained(
model_dir, dtype=torch.float32, low_cpu_mem_usage=True).eval()
return model, processor
def verify_case(case: str, out_dir: str, model, processor) -> dict:
"""在**单个进程内**只校验一个用例,返回该用例的参考片段。
一个用例一个进程是有意的:这里必须同时驻留 PyTorch 全模型(~8GB
Transformer.onnx~7.5GB)。若在同一进程里连着校验图像与各档视频,
每档新建的视觉图(~1.6GB/张)不会及时释放,峰值是它们之和——
在 17GB 内存的机器上会被 OOM 杀掉(实测:校验到视频档时 python3 被 kill
total-vm 26GB。拆成子进程后峰值等于单个用例且某一档崩了不影响其余档。
"""
ts = onnx_session(os.path.join(out_dir, "TokenEmbedding.onnx"))
xs = onnx_session(os.path.join(out_dir, "Transformer.onnx"))
lm = model.model.language_model
def run_transform(hidden, deep, cos, sin):
seq = hidden.shape[1]
@ -299,45 +384,157 @@ def verify_onnx(out_dir: str, model, processor) -> dict:
"causal_mask": causal_mask(seq).numpy(),
})[0]
x, h, d, cos, sin, _cm, pos = text_inputs(processor, lm, REFERENCE_TEXT)
with torch.no_grad():
ref_text = model.model(input_ids=x["input_ids"], attention_mask=x["attention_mask"],
position_ids=pos, use_cache=False).last_hidden_state[:, -1].numpy()
h_onnx = ts.run(None, {"input_ids": x["input_ids"].numpy().astype(np.int64)})[0]
zero = np.zeros_like(h_onnx)
got = run_transform(h_onnx, (zero, zero, zero), cos.numpy(), sin.numpy())
compare("text/onnx-vs-full", got, ref_text)
reference["text"] = REFERENCE_TEXT
reference["text_vector_prefix"] = [float(v) for v in unit(got[0])[:12]]
reference["text_norm_raw"] = float(np.linalg.norm(got[0]))
if case == "text":
x, _h, _d, cos, sin, _cm, pos = text_inputs(processor, lm, REFERENCE_TEXT)
with torch.no_grad():
ref = model.model(input_ids=x["input_ids"], attention_mask=x["attention_mask"],
position_ids=pos, use_cache=False).last_hidden_state[:, -1].numpy()
hidden = ts.run(None, {"input_ids": x["input_ids"].numpy().astype(np.int64)})[0]
zero = np.zeros_like(hidden)
got = run_transform(hidden, (zero, zero, zero), cos.numpy(), sin.numpy())
cos_v = compare("text/onnx-vs-full", got, ref)
return {"case": case, "cos": cos_v, "reference": {
"text": REFERENCE_TEXT,
"text_vector_prefix": [float(v) for v in unit(got[0])[:12]],
"text_norm_raw": float(np.linalg.norm(got[0])),
"dim": int(got.shape[1]),
}}
img = reference_image()
x, _h, _d, cos, sin, _cm, _ = image_inputs(processor, model, img)
# 图像与视频共用同一条后半段(视觉塔 → 按掩码散射 → 语言 Transformer
# 两者的差异只在「视觉图 + 输入张量名 + token 类型 + 视觉 token 数」。
if case == "image":
x, _h, _d, cos, sin, _cm, _ = image_inputs(processor, model, reference_image())
pixels = x["pixel_values"]
visual_path = os.path.join(out_dir, "Vision.onnx")
token_type, want_tokens, name = 1, VISUAL_TOKENS_PER_GROUP, "image"
full_kwargs = {"pixel_values": x["pixel_values"], "image_grid_thw": x["image_grid_thw"]}
prefix_key, norm_key = "image_vector_prefix", "image_norm_raw"
extra: dict[str, object] = {
"image_rgb": list(REFERENCE_IMAGE_RGB),
"image_size": IMAGE_SIZE,
}
elif case.startswith("video_g"):
groups = int(case.split("_g", 1)[1])
x, _h, _d, cos, sin, _cm, _ = image_inputs(processor, model, None, groups=groups)
pixels = x["pixel_values_videos"]
visual_path = os.path.join(out_dir, f"Vision_g{groups}.onnx")
token_type, want_tokens, name = 2, groups * VISUAL_TOKENS_PER_GROUP, case
full_kwargs = {"pixel_values_videos": x["pixel_values_videos"],
"video_grid_thw": x["video_grid_thw"]}
prefix_key, norm_key = "video_vector_prefix", "video_norm_raw"
extra = {
"video_groups": groups,
"video_frame_rgb": [list(c) for c in REFERENCE_VIDEO_RGB[: 2 * groups]],
}
else:
raise SystemExit(f"未知校验用例: {case}")
vs = onnx_session(visual_path)
with torch.no_grad():
ref_img = model.model(input_ids=x["input_ids"], attention_mask=x["attention_mask"],
pixel_values=x["pixel_values"], image_grid_thw=x["image_grid_thw"],
mm_token_type_ids=x["mm_token_type_ids"],
use_cache=False).last_hidden_state[:, -1].numpy()
h_onnx = ts.run(None, {"input_ids": x["input_ids"].numpy().astype(np.int64)})[0]
vo = vs.run(None, {"pixel_values": x["pixel_values"].numpy().astype(np.float32)})
mask = x["mm_token_type_ids"].numpy() == 1
h_onnx = h_onnx.copy()
h_onnx[mask] = vo[3]
ref = model.model(input_ids=x["input_ids"], attention_mask=x["attention_mask"],
mm_token_type_ids=x["mm_token_type_ids"],
use_cache=False, **full_kwargs).last_hidden_state[:, -1].numpy()
hidden = ts.run(None, {"input_ids": x["input_ids"].numpy().astype(np.int64)})[0]
vis = vs.run(None, {"pixel_values": pixels.numpy().astype(np.float32)})
mask = x["mm_token_type_ids"].numpy() == token_type
# 视觉区间长度不对,说明模板/占位符/档位三者有一处错了。单独报错比
# 后面 scatter 抛「形状不符」清楚得多。
if int(mask.sum()) != want_tokens:
raise SystemExit(f"{name}: 视觉 token 数 {int(mask.sum())},期望 {want_tokens}")
hidden = hidden.copy()
hidden[mask] = vis[3]
deep = []
for d in vo[:3]:
full = np.zeros_like(h_onnx)
for d in vis[:3]:
full = np.zeros_like(hidden)
full[mask] = d
deep.append(full)
got = run_transform(h_onnx, tuple(deep), cos.numpy(), sin.numpy())
compare("image/onnx-vs-full", got, ref_img)
reference["image_rgb"] = list(REFERENCE_IMAGE_RGB)
reference["image_size"] = IMAGE_SIZE
reference["image_vector_prefix"] = [float(v) for v in unit(got[0])[:12]]
reference["image_norm_raw"] = float(np.linalg.norm(got[0]))
reference["dim"] = int(got.shape[1])
got = run_transform(hidden, tuple(deep), cos.numpy(), sin.numpy())
cos_v = compare(f"{name}/onnx-vs-full", got, ref)
extra[prefix_key] = [float(v) for v in unit(got[0])[:12]]
extra[norm_key] = float(np.linalg.norm(got[0]))
extra["dim"] = int(got.shape[1])
return {"case": case, "cos": cos_v, "reference": extra}
def verify_onnx(out_dir: str, video_groups, require_video: bool = True, model_dir: str = "") -> dict:
"""逐用例在子进程里校验导出后的 ONNX 图,汇总冻结参考向量。
返回参考向量(供 Go 侧测试冻结使用Go 必须复现同一套预处理与模板,
因此这里把同一输入下的期望向量前若干维导出。
"""
log("校验②:导出后的 ONNX 三段图 vs 完整模型(每个用例一个进程)")
cases = case_list(out_dir, video_groups, require_video)
reference: dict[str, object] = {}
verified: list = []
for case in cases:
# 先把子进程跑完,再决定要不要采它的参考值。
# 历史教训:曾经把「参考只取第一档」写成在调用前 continue
# 结果 video_g3/g4 根本没被校验,而脚本仍然 exit 0 ——
# 一个「通过」的假象比报错危险得多。
res = run_verify_child(case, out_dir, model_dir)
verified.append(case)
log(f" {case}: cos={res['cos']:.9f}")
if case.startswith("video_g") and "video_groups" in reference:
# 参考只取第一档Go 侧回归用一档就够),但这一档本身已经真的校验过。
continue
reference.update(res["reference"])
# 覆盖度必须与计划一致:少跑一个用例就不算校验完成。
if verified != cases:
raise SystemExit(f"校验覆盖不完整:计划 {cases},实际 {verified}")
log(f"校验覆盖 {len(verified)} 个用例: {', '.join(verified)}")
if "dim" not in reference:
raise SystemExit("校验没有产出 dim")
return reference
def case_list(out_dir: str, video_groups, require_video: bool) -> list:
"""要校验的用例列表。视频档缺图时:刚导出完必须报错,校验旧目录则跳过。"""
cases = ["text", "image"]
for groups in video_groups:
path = os.path.join(out_dir, f"Vision_g{groups}.onnx")
if os.path.exists(path):
cases.append(f"video_g{groups}")
elif require_video:
raise SystemExit(f"缺少 {path}--video-groups 包含 {groups} 但未导出)")
else:
log(f"跳过视频档 G={groups}:目录里没有 {os.path.basename(path)}")
return cases
def run_verify_child(case: str, out_dir: str, model_dir: str) -> dict:
"""在子进程里校验一个用例并取回它的参考片段。"""
cmd = [sys.executable, os.path.abspath(__file__), "--out", out_dir, "--verify-case", case]
if model_dir:
cmd += ["--model-dir", model_dir]
log(f" 校验 {case}(独立进程)")
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
out = ((proc.stderr or "") + (proc.stdout or "")).strip().splitlines()
raise SystemExit(f"校验 {case} 失败exit={proc.returncode}:\n" + "\n".join(out[-20:]))
for line in reversed((proc.stdout or "").splitlines()):
if line.startswith(RESULT_PREFIX):
return json.loads(line[len(RESULT_PREFIX):])
raise SystemExit(
f"校验 {case} 的子进程没有输出结果行stdout 末尾: {(proc.stdout or '')[-300:]!r}")
def video_groups_from_config(out_dir: str):
"""读取产物自带的 video_groups读不到则返回 None。
max_length 由 video_groups 推导,而推导结果必须与导出时一致,否则校验
会因截断而报「视觉 token 数不符」。产物自己的 config 是权威来源,
比让调用方记得重传 --video-groups 可靠。
"""
try:
with open(os.path.join(out_dir, "embed_config.json")) as f:
cfg = json.load(f)
except (OSError, ValueError):
return None
groups = cfg.get("video_groups")
if isinstance(groups, list) and groups and all(isinstance(g, int) and g >= 2 for g in groups):
return sorted(groups)
return None
def reference_image():
from PIL import Image
@ -346,7 +543,7 @@ def reference_image():
# ─────────────────────────── 导出 ───────────────────────────
def export_graphs(out_dir: str, model, processor, model_dir: str, transformer) -> None:
def export_graphs(out_dir: str, model, processor, model_dir: str, transformer, video_groups) -> None:
os.makedirs(out_dir, exist_ok=True)
# 清掉旧产物,避免 fingerprint 把死文件算进去(旧图/旧外部权重会让
# 空间指纹变化,触发一次毫无意义的全量重算)。
@ -383,9 +580,10 @@ def export_graphs(out_dir: str, model, processor, model_dir: str, transformer) -
opset_version=17, do_constant_folding=True, dynamo=False,
)
log("导出 Vision.onnx固定 grid 1×48×48")
log("导出 Vision.onnx图像,固定 grid 1×48×48")
grid = torch.tensor([[1, IMAGE_SIZE // PATCH_SIZE, IMAGE_SIZE // PATCH_SIZE]], dtype=torch.long)
pv = x["pixel_values"]
xi, _h, _d, _c, _s, _cm, _p = image_inputs(processor, model, reference_image())
pv = xi["pixel_values"]
with torch.no_grad():
torch.onnx.export(
VisionTower(model.model.visual, grid).eval(), (pv,), os.path.join(out_dir, "Vision.onnx"),
@ -395,13 +593,31 @@ def export_graphs(out_dir: str, model, processor, model_dir: str, transformer) -
opset_version=17, do_constant_folding=True, dynamo=False,
)
# 视频每个时间组数一张图。grid_thw 被 legacy tracer 固化为常量,
# 所以“动态时间轴”不可行(实测导出的图里根本没有 grid_thw 输入);
# 反过来,每档导一张则完全可验证。
for groups in video_groups:
name = f"Vision_g{groups}.onnx"
log(f"导出 {name}(视频,固定 grid {groups}×48×48 ⇒ {2 * groups} 帧)")
vgrid = torch.tensor([[groups, IMAGE_SIZE // PATCH_SIZE, IMAGE_SIZE // PATCH_SIZE]], dtype=torch.long)
vx, _h, _d, _c, _s, _cm, _p = image_inputs(processor, model, None, groups=groups)
vpv = vx["pixel_values_videos"]
with torch.no_grad():
torch.onnx.export(
VisionTower(model.model.visual, vgrid).eval(), (vpv,), os.path.join(out_dir, name),
input_names=["pixel_values"],
output_names=["deepstack_feature_0", "deepstack_feature_1",
"deepstack_feature_2", "vision_hidden_states"],
opset_version=17, do_constant_folding=True, dynamo=False,
)
for name in ("tokenizer.json", "tokenizer_config.json", "chat_template.jinja", "added_tokens.json"):
src = os.path.join(model_dir, name)
if os.path.exists(src):
shutil.copy2(src, os.path.join(out_dir, name))
def write_config(out_dir: str, model, processor) -> None:
def write_config(out_dir: str, model, processor, video_groups) -> None:
cfg = model.config
text_cfg = getattr(cfg, "text_config", cfg)
rope_scaling = getattr(text_cfg, "rope_scaling", None) or {}
@ -424,16 +640,18 @@ def write_config(out_dir: str, model, processor) -> None:
"rope_theta": float(getattr(text_cfg, "rope_theta", 5000000)),
"mrope_section": [int(v) for v in mrope_section],
"num_layers": int(getattr(text_cfg, "num_hidden_layers", 28)),
"supports_native_video": False,
"unsupported_modalities": ["audio", "video"],
"notes": ("视频由上层抽帧后逐帧按图像编码(同模型/同维度/同 fingerprint"
"音频需未来接入真正的统一音频模型。grid_thw 被 legacy tracer 固化为常量,"
"故视觉塔固定 1×48×48详见导出脚本 docstring。"),
"supports_native_video": True,
"video_groups": list(video_groups),
"unsupported_modalities": ["audio"],
"notes": ("视频每个时间组数G各一张 Vision 图:grid_thw 被 legacy tracer "
"固化为常量,无法做成运行时输入;用错档会因维度不符报错。"
"相邻两帧构成一个时间组temporal 槽 tp0←帧2g、tp1←帧2g+1。"
"音频不在 Qwen3-VL 原生模态内(无 audio_token_id需另一模型。"),
}
with open(os.path.join(out_dir, "embed_config.json"), "w") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
log(f"写出 embed_config.json dim={meta['dim']} rope_theta={meta['rope_theta']} "
f"mrope={meta['mrope_section']}")
f"mrope={meta['mrope_section']} video_groups={meta['video_groups']}")
def main() -> int:
@ -446,9 +664,38 @@ def main() -> int:
ap.add_argument("--no-reference", action="store_true",
help="不写 <out>/qwen_reference.json默认会写Go 测试靠它做冻结回归)")
ap.add_argument("--skip-verify", action="store_true", help="跳过导出后校验(仅调试用,不推荐)")
ap.add_argument("--video-groups", default=",".join(str(g) for g in DEFAULT_VIDEO_GROUPS),
help="逗号分隔的视频时间组数,每档导一张 Vision_g{N}.onnx"
f"(默认 {','.join(str(g) for g in DEFAULT_VIDEO_GROUPS)}"
"G 组合 2G 帧,即默认 4/6/8 帧)")
ap.add_argument("--verify-only", action="store_true",
help="不重新导出,只校验已存在的 <out> 并(重新)写出参考向量")
ap.add_argument("--verify-case", default="",
help=argparse.SUPPRESS) # 内部用:单用例校验子进程
args = ap.parse_args()
try:
video_groups = [int(g) for g in str(args.video_groups).split(",") if str(g).strip()]
except ValueError:
raise SystemExit(f"--video-groups 必须是逗号分隔的整数,得到 {args.video_groups!r}")
if not video_groups or any(g < 2 for g in video_groups):
raise SystemExit("--video-groups 需至少一个 >=2 的整数(单图档是 Vision.onnx不用列")
if args.verify_case or args.verify_only:
cfg_groups = video_groups_from_config(args.out)
if cfg_groups and cfg_groups != video_groups:
log(f"按产物 embed_config.json 使用 video_groups={cfg_groups}(命令行是 {video_groups}")
video_groups = cfg_groups
global MAX_LENGTH
MAX_LENGTH = max_length_for(video_groups)
log(f"max_length={MAX_LENGTH}(按最大档 {max(video_groups)} 组×{VISUAL_TOKENS_PER_GROUP} 推导)")
if args.verify_case:
model_dir = args.model_dir or pull_model(args.model_id, args.model_store)
model, processor = load_model(model_dir)
res = verify_case(args.verify_case, args.out, model, processor)
print(RESULT_PREFIX + json.dumps(res, ensure_ascii=False), flush=True)
return 0
if args.verify_only:
# 校验既有产物目录:既能确认线上在用的图没坏,也能给旧目录补上参考向量。
@ -456,14 +703,7 @@ def main() -> int:
if not os.path.exists(os.path.join(args.out, name)):
raise SystemExit(f"{args.out} 下缺少 {name},无法 --verify-only")
model_dir = args.model_dir or pull_model(args.model_id, args.model_store)
from transformers import AutoProcessor
from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLForConditionalGeneration
log("加载 processor / model--verify-only")
processor = AutoProcessor.from_pretrained(model_dir, trust_remote_code=True, padding_side="right")
model = Qwen3VLForConditionalGeneration.from_pretrained(
model_dir, dtype=torch.float32, low_cpu_mem_usage=True).eval()
reference = verify_onnx(args.out, model, processor)
reference = verify_onnx(args.out, video_groups, require_video=False, model_dir=model_dir)
if not args.no_reference:
path = os.path.join(args.out, "qwen_reference.json")
with open(path, "w") as f:
@ -480,22 +720,23 @@ def main() -> int:
else:
model_dir = pull_model(args.model_id, args.model_store)
# transformers 只在真正导出时才需要(拉取模型本身只用 huggingface_hub
from PIL import Image # noqa: F401 确保依赖存在并给出清晰报错
from transformers import AutoProcessor
from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLForConditionalGeneration
# transformers / PIL 只在真正导出时才需要(拉取模型本身只用 huggingface_hub
# 这里提前导入一次,缺依赖时给出清晰报错而不是走到深处才炸。
from PIL import Image # noqa: F401
log("加载 processor / modelFP32CPU")
processor = AutoProcessor.from_pretrained(model_dir, trust_remote_code=True, padding_side="right")
model = Qwen3VLForConditionalGeneration.from_pretrained(
model_dir, dtype=torch.float32, low_cpu_mem_usage=True).eval()
model, processor = load_model(model_dir)
transformer = Transformer(model.model.language_model).eval()
verify_split_torch(model, processor, transformer)
export_graphs(args.out, model, processor, model_dir, transformer)
write_config(args.out, model, processor)
export_graphs(args.out, model, processor, model_dir, transformer, video_groups)
write_config(args.out, model, processor, video_groups)
reference = None if args.skip_verify else verify_onnx(args.out, model, processor)
# 校验在子进程里跑,父进程先把模型释放掉,把内存完全让给子进程。
del transformer, model
gc.collect()
reference = None if args.skip_verify else verify_onnx(args.out, video_groups, model_dir=model_dir)
# 参考写进产物目录本身:这样任何一个 ONNX 目录都自带「它应当给出什么输出」,
# Go 测试无需额外配置就能找到,也不会出现「模型换了、参考还是旧的」的错配。
if reference is not None and not args.no_reference: