feat(memory): 千问三段式 ONNX 嵌入补齐——可复现导出脚本 + Go 侧首次完整验证

此前三段式拆分后 ONNX 路径从未从 Go 侧跑通:embedder_onnx_test.go 仍引用
分段前的 API(e.renderInput、TextTower.onnx、旧目录),go vet -tags onnxruntime
直接编译失败。导出脚本只在 /tmp 且硬编码本机路径、从第三个目录拷贝固定形状的
Vision.onnx,完全不可复现。音频会被视觉塔编码,静默往统一空间灌入错误坐标。

本提交补齐这些缺口:

一、可复现导出脚本(scripts/export_qwen3vl_embedding_onnx.py)
- 自动拉取模型(HuggingFace 优先,失败回落 ModelScope,支持 HF_ENDPOINT 镜像);
- 导出 TokenEmbedding + Transformer + Vision 三段图,图文共用同一 token
  embedding、28 层 Transformer、last-token 池化与 fingerprint;
- 双重自检(不可省):分段 PyTorch vs 完整模型 + 导出后的 ONNX vs 完整模型,
  cos < 0.999999 即非零退出——「能加载」不等于「算得对」;
- 默认把 L2 归一化后的冻结参考向量写入产物目录(qwen_reference.json)——
  Go 测试据此做逐维冻结回归,且「该目录是哪次导出的」从文件本身可追溯;
- --verify-only 校验既有产物不重新导出,可用来确认线上在用的图没坏。

关键实测结论(已写入 docs/zh/multimodal-space.md 与长期记忆):
原生多帧视频不可行——Qwen3-VL 视觉塔把 grid_thw 当 Python 值消费
(grid_thw.tolist()),legacy tracer 固化为常量,导出后图中根本没有 grid_thw
输入,换帧数调用直接 Invalid input name: grid_thw。故视觉塔固定 (1,48,48),
视频由上层抽帧后逐帧按图像编码(同模型/同维度/同 fingerprint),音频明确
unsupported。

二、模态边界(vector.ErrModalityUnsupported)
- 新增 vector.ErrModalityUnsupported:表示「该模态不在本统一空间的原生覆盖
  范围内」,与普通错误语义不同——调用方应把它当「永远不会有向量」而非
  「本次失败、下次重试」;
- qwen.EmbedImageDense 按 mime 拒绝 audio/* 与 video/*:此前它会拿视觉塔
  去解音频字节,往统一空间灌入语义错误的坐标且静默;
- reembedStaleMedia 对 ErrModalityUnsupported 不计失败、不重试、不用别的
  模型向量顶替(TestReembedStaleMedia_SkipsUnsupportedWithoutFaking 守住)。

三、Go ONNX 测试首次完整通过
- 重写 embedder_onnx_test.go:修复编译 + 文本冻结回归 + 图像冻结回归 +
  两条阴性对照(不同输入必须不同、图像与文本必须不同)+ 不支持模态断言;
- 参考值从产物目录的 qwen_reference.json 读取(不在测试里硬编码浮点);
- 用线上部署产物实测全部通过(text cos=0.999999940, image cos=0.999999762)。

四、.gitignore 修复
- /scripts/ 此前被列在「运行时产物」下,但它是作者维护的工具目录
  (模型导出、侧车、部署校验),deploy/systemd/embed-sidecar.service 直接
  引用 scripts/embed_sidecar.py,忽略它会让那份 unit 在别人的机器上指向
  不存在的文件。改为只忽略 __pycache__。

五、文档(docs/zh/multimodal-space.md)
- 获取/启用/产物契约/模态边界/验证/资源成本/与现有部署产物的等价性。

验证:go build ./...、go vet ./...、go vet -tags onnxruntime ./...、
go test -short 全部通过;ONNX 标签测试对线上部署产物全部通过。
This commit is contained in:
JianFeeeee
2026-09-11 13:45:01 +08:00
parent 5836c2ce5c
commit 1a02971f88
11 changed files with 1518 additions and 53 deletions

106
scripts/verify_deploy.sh Executable file
View File

@ -0,0 +1,106 @@
#!/usr/bin/env bash
# HomeAgent 部署验证脚本:检查 healthcheck 残留 / 记忆去重 / archived 残留 / 嵌入规格
# 用法: verify_deploy.sh [data_dir] 默认 /home/newqqagent
set -u
DATA_DIR="${1:-/home/newqqagent}"
PASS=0; FAIL=0; SKIP=0
ok() { echo "[PASS] $1"; PASS=$((PASS+1)); }
bad() { echo "[FAIL] $1"; FAIL=$((FAIL+1)); }
skip() { echo "[SKIP] $1"; SKIP=$((SKIP+1)); }
echo "== HomeAgent 部署验证 =="
echo "数据目录: $DATA_DIR"
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "模式: 只读检测,不修改/删除任何数据"
echo
# ---- 0. 数据目录可用性 ----
if [ ! -d "$DATA_DIR" ]; then
echo "[FATAL] 数据目录不存在: $DATA_DIR"
exit 2
fi
# ---- 1. healthcheck 残留检查 ----
echo "--- 1. healthcheck 残留 ---"
hc_files=$(find "$DATA_DIR/knowledge" "$DATA_DIR/memory" -maxdepth 2 \
\( -name '*_hc_*' -o -name 'gotest' -o -name 'luatest' \) 2>/dev/null)
if [ -z "$hc_files" ]; then
ok "无 _hc_*/gotest/luatest 残留"
else
echo " 发现残留:"
echo "$hc_files" | sed 's/^/ /'
bad "存在 healthcheck 残留(上列,需人工确认清理)"
fi
# ---- 2. relations 去重检查 ----
echo "--- 2. relations 去重 ---"
GRAPH_DB="$DATA_DIR/memory/graph.db"
if [ -f "$GRAPH_DB" ] && command -v sqlite3 >/dev/null 2>&1; then
dup=$(sqlite3 "$GRAPH_DB" "SELECT count(*) - count(DISTINCT source_id||'|'||target_id||'|'||relation_type||'|'||COALESCE(session_id,'')) FROM relations;" 2>/dev/null)
total=$(sqlite3 "$GRAPH_DB" "SELECT count(*) FROM relations;" 2>/dev/null)
if [ -n "$dup" ] && [ "$dup" -le 0 ] 2>/dev/null; then
ok "relations 去重良好 (共 ${total:-0} 条, 重复 ${dup:-0})"
else
bad "relations 存在重复: 共 ${total:-?} 条, 重复 ${dup:-?}"
fi
# UNIQUE 索引存在性:查 relations 表 DDL 是否含 UNIQUE 约束
# 注意UNIQUE 约束创建的 sqlite_autoindex_* 索引其 sql 字段为 NULL
# 用 LIKE 查索引 sql 会漏报——须查表 DDL
ddl=$(sqlite3 "$GRAPH_DB" "SELECT sql FROM sqlite_master WHERE type='table' AND name='relations';" 2>/dev/null)
if echo "$ddl" | grep -q 'UNIQUE'; then
# 进一步确认唯一约束覆盖的列
auto_idx=$(sqlite3 "$GRAPH_DB" "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'sqlite_autoindex_relations%' AND tbl_name='relations';" 2>/dev/null | head -1)
if [ -n "$auto_idx" ]; then
cols=$(sqlite3 "$GRAPH_DB" "SELECT group_concat(name,',') FROM pragma_index_info('$auto_idx');" 2>/dev/null)
echo " UNIQUE 自动索引: $auto_idx, 覆盖列: ${cols:-?}"
fi
ok "relations 复合 UNIQUE 约束存在"
else
bad "relations 复合 UNIQUE 约束缺失(迁移未生效?)"
fi
else
skip "graph.db 不存在或 sqlite3 不可用 ($GRAPH_DB)"
fi
# ---- 3. archived 模板垃圾检查 ----
echo "--- 3. archived 残留 ---"
if [ -f "$GRAPH_DB" ] && command -v sqlite3 >/dev/null 2>&1; then
# 兜底检测relation_type 直接为 context_archived或 target_id 含 context_archived
arch=$(sqlite3 "$GRAPH_DB" "SELECT count(*) FROM relations WHERE relation_type='context_archived' OR relation_type='来源' AND target_id LIKE '%context_archived%';" 2>/dev/null)
if [ -n "$arch" ] && [ "$arch" -eq 0 ] 2>/dev/null; then
ok "无 context_archived 模板垃圾"
else
bad "context_archived 模板垃圾: ${arch:-?}"
fi
else
skip "graph.db 不存在或 sqlite3 不可用"
fi
# ---- 4. 嵌入规格检查 ----
echo "--- 4. 嵌入规格 ---"
CONFIG_DB="$DATA_DIR/config.db"
if [ -f "$CONFIG_DB" ] && command -v sqlite3 >/dev/null 2>&1; then
emb=$(sqlite3 "$CONFIG_DB" "SELECT value FROM config WHERE key='core.agent.embedding_model_path';" 2>/dev/null)
echo " embedding_model_path = ${emb:-(未配置)}"
if [ -n "$emb" ]; then
# 模型路径含 #topN 则裁剪生效否则全量加载300 维 ≈ 1.5G/模型)
if [[ "$emb" == *"#top"* ]]; then
ok "嵌入模型已启用 #topN 裁剪(内存预算受控)"
else
# 逗号分隔的模型个数估算内存
n=$(echo "$emb" | awk -F',' '{print NF}')
est=$(( n * 1500 ))
echo " 全量加载 ${n} 个模型,估算常驻 ≈ ${est}MB未裁剪"
bad "嵌入模型未启用 #topN 裁剪(内存预算偏高)"
fi
else
skip "embedding_model_path 未配置(可能是默认值)"
fi
else
skip "config.db 不存在或 sqlite3 不可用 ($CONFIG_DB)"
fi
# ---- 汇总 ----
echo
echo "== 汇总: PASS=$PASS FAIL=$FAIL SKIP=$SKIP =="
[ "$FAIL" -eq 0 ] && exit 0 || exit 1