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 1de1b5598d
commit 6f8525cd83
21 changed files with 2100 additions and 627 deletions

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 token(1×576 + 模板),而视频是 G×576——G=2 就要 1190,G=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←帧2g、tp1←帧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 要 1190、G=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 …
config:image_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 不透明的多帧容器后,
再把视频纳入公共契约。