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 b0b0d906d8
commit 3cf67fcae1
21 changed files with 2100 additions and 627 deletions

218
providers/qwen3vl/image.go Normal file
View File

@ -0,0 +1,218 @@
//go:build onnxruntime
package qwen3vl
import (
"bytes"
"fmt"
"image"
"image/color"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"math"
)
const (
qwenImageSize = 768
qwenPatchSize = 16
qwenTemporalPatch = 2
qwenSpatialMerge = 2
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
)
// fitCanvas 把任意图片解码并转成固定 768×768 画布。
//
// 保持宽高比缩放并在中心补中性灰(归一化后约为 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)
}
b := src.Bounds()
if b.Dx() <= 0 || b.Dy() <= 0 {
return nil, fmt.Errorf("qwen: empty image")
}
scale := math.Min(float64(qwenImageSize)/float64(b.Dx()), float64(qwenImageSize)/float64(b.Dy()))
w := max(1, int(math.Round(float64(b.Dx())*scale)))
h := max(1, int(math.Round(float64(b.Dy())*scale)))
if w > qwenImageSize {
w = qwenImageSize
}
if h > qwenImageSize {
h = qwenImageSize
}
resized := resizeBicubic(src, w, h)
canvas := image.NewNRGBA(image.Rect(0, 0, qwenImageSize, qwenImageSize))
neutral := color.NRGBA{R: 128, G: 128, B: 128, A: 255}
for i := 0; i < len(canvas.Pix); i += 4 {
canvas.Pix[i], canvas.Pix[i+1], canvas.Pix[i+2], canvas.Pix[i+3] = neutral.R, neutral.G, neutral.B, neutral.A
}
ox, oy := (qwenImageSize-w)/2, (qwenImageSize-h)/2
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
canvas.SetNRGBA(ox+x, oy+y, resized.NRGBAAt(x, y))
}
}
return canvas, nil
}
// 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++ {
for mh := 0; mh < qwenSpatialMerge; mh++ {
for mw := 0; mw < qwenSpatialMerge; mw++ {
baseY := (bh*qwenSpatialMerge + mh) * qwenPatchSize
baseX := (bw*qwenSpatialMerge + mw) * qwenPatchSize
for c := 0; c < 3; c++ {
for temporal := 0; temporal < qwenTemporalPatch; temporal++ {
canvas := slots[temporal]
for py := 0; py < qwenPatchSize; py++ {
for px := 0; px < qwenPatchSize; px++ {
p := canvas.NRGBAAt(baseX+px, baseY+py)
v := [3]uint8{p.R, p.G, p.B}[c]
out = append(out, float32(v)/127.5-1)
}
}
}
}
}
}
}
}
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 三次卷积。
func resizeBicubic(src image.Image, dstW, dstH int) *image.NRGBA {
b := src.Bounds()
if b.Dx() == dstW && b.Dy() == dstH {
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
for x := 0; x < dstW; x++ {
dst.SetNRGBA(x, y, color.NRGBAModel.Convert(src.At(b.Min.X+x, b.Min.Y+y)).(color.NRGBA))
}
}
return dst
}
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
sx, sy := float64(b.Dx())/float64(dstW), float64(b.Dy())/float64(dstH)
for y := 0; y < dstH; y++ {
fy := (float64(y)+0.5)*sy - 0.5
y0 := int(math.Floor(fy))
for x := 0; x < dstW; x++ {
fx := (float64(x)+0.5)*sx - 0.5
x0 := int(math.Floor(fx))
var sum [4]float64
var weight float64
for j := -1; j <= 2; j++ {
wy := cubicWeight(fy - float64(y0+j))
yy := min(max(y0+j, 0), b.Dy()-1)
for i := -1; i <= 2; i++ {
w := wy * cubicWeight(fx-float64(x0+i))
xx := min(max(x0+i, 0), b.Dx()-1)
p := color.NRGBAModel.Convert(src.At(b.Min.X+xx, b.Min.Y+yy)).(color.NRGBA)
sum[0] += float64(p.R) * w
sum[1] += float64(p.G) * w
sum[2] += float64(p.B) * w
sum[3] += float64(p.A) * w
weight += w
}
}
if weight == 0 {
weight = 1
}
dst.SetNRGBA(x, y, color.NRGBA{
R: clampByte(sum[0] / weight), G: clampByte(sum[1] / weight),
B: clampByte(sum[2] / weight), A: clampByte(sum[3] / weight),
})
}
}
return dst
}
func cubicWeight(x float64) float64 {
x = math.Abs(x)
if x <= 1 {
return 1.5*x*x*x - 2.5*x*x + 1
}
if x < 2 {
return -0.5*x*x*x + 2.5*x*x - 4*x + 2
}
return 0
}
func clampByte(v float64) uint8 {
return uint8(min(255, max(0, int(math.Round(v)))))
}