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

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

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

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

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

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

131 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build onnxruntime
package qwen3vl
import (
"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
}