Files
HomeAgent/providers/qwen3vlgen/generator.go
JianFeeeee 6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。

## 关键区分(这是本次改动安全的前提)

两个 module 是**不同**的,影响面差一个数量级:

| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |

★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。

SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。

## 改动

- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)

## 明确**保留** gitcode 的地方(镜像机制,不能动)

- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)

## 验证

- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好

## 顺带修正

`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
2026-10-08 10:40:17 +08:00

404 lines
12 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 qwen3vlgen implements the generation.Provider SPI on the same
// Qwen3-VL weights this package already uses for embeddings.
//
// 一份权重,两种模式
// ------------------
// 向量(高频,检索时):TokenEmbedding.onnx + Transformer.onnx(末 token 池化)
// 生成(低频,蒸馏时):TokenEmbedding.onnx + SequenceWithHead.onnx(tied head)
//
// 两者共享 TokenEmbedding 段与全部 28 层 Transformer 的权重语义;生成侧
// 多出来的那张图只是「不池化 + 接 tied head」。embed_config.json 的
// `generation` 段声明了这个能力,Open 时据此判断模型目录是否可用作生成。
//
// tied lm_head:零新增权重
// ----------------------
// Qwen3-VL 的 tie_word_embeddings=True,输出层就是 embed_tokens 转置
// (实测 safetensors 里 625 个张量没有独立 lm_head)。embed_tokens 已在
// TokenEmbedding.onnx 里,因此输出层不需要另一份权重。
//
// 没有 KV cache
// -------------
// 自回归解码若每步全量前向是 O(N²)。拆分任务的输出极短(实测 3~7 行,
// N≈200),且蒸馏是定时低频任务,不是检索热路径。为此引入 KV cache
// 状态机会让实现复杂度翻倍,收益只在这条低频路径上。
//
// 若生成侧将来进入热路径,先实测 O(N²) 的实际延迟再决定。
package qwen3vlgen
import (
"context"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"sync"
ort "github.com/yalue/onnxruntime_go"
"github.com/JianFeeeee/HomeAgent/pkg/generation"
"github.com/JianFeeeee/HomeAgent/providers/qwen3vl"
)
func init() {
generation.Register("qwen3vl", func(cfg generation.Config) (generation.Provider, error) {
return New(cfg.Options["model_dir"])
})
}
type genConfig struct {
Generation struct {
Graph string `json:"graph"`
TiedLMHead bool `json:"tied_lm_head"`
KVCache bool `json:"kv_cache"`
Outputs string `json:"outputs"`
} `json:"generation"`
Dim int `json:"dim"`
MaxLength int `json:"max_length"`
RopeTheta float64 `json:"rope_theta"`
MRopeSection []int `json:"mrope_section"`
}
// Generator 是同一份 Qwen3-VL 权重上的生成侧。
type Generator struct {
mu sync.Mutex
dir string
cfg genConfig
tok *qwen3vl.Tokenizer
token *ort.DynamicAdvancedSession
seqHead *ort.DynamicAdvancedSession
fp string
close sync.Once
}
// New 打开一个生成侧实例。modelDir 必须是含 embed_config.json 与
// SequenceWithHead.onnx 的导出目录。
func New(modelDir string) (*Generator, error) {
if modelDir == "" {
return nil, fmt.Errorf("qwen3vlgen: model dir not specified")
}
raw, err := os.ReadFile(filepath.Join(modelDir, "embed_config.json"))
if err != nil {
return nil, fmt.Errorf("qwen3vlgen: read embed_config.json: %w", err)
}
var cfg genConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("qwen3vlgen: parse embed_config.json: %w", err)
}
if cfg.Generation.Graph == "" {
return nil, fmt.Errorf("qwen3vlgen: 该模型目录未导出生成侧图(embed_config.json 缺 generation.graph);" +
"用 scripts/export_qwen3vl_embedding_onnx.py 重新导出")
}
if !cfg.Generation.TiedLMHead {
return nil, fmt.Errorf("qwen3vlgen: 只支持 tied lm_head(该模型声明 tied_lm_head=false)")
}
if cfg.Generation.KVCache {
return nil, fmt.Errorf("qwen3vlgen: 该导出带 KV cache,本实现尚未支持(会把生成跑成错的)")
}
if cfg.Dim <= 0 || len(cfg.MRopeSection) != 3 {
return nil, fmt.Errorf("qwen3vlgen: incompatible config dim=%d mrope=%v", cfg.Dim, cfg.MRopeSection)
}
tok, err := qwen3vl.LoadTokenizer(modelDir)
if err != nil {
return nil, fmt.Errorf("qwen3vlgen: %w", err)
}
if !ort.IsInitialized() {
if lib := qwen3vl.FindOnnxLib(); lib != "" {
ort.SetSharedLibraryPath(lib)
}
if err := ort.InitializeEnvironment(); err != nil {
return nil, fmt.Errorf("qwen3vlgen: init onnx env: %w", err)
}
}
token, err := ort.NewDynamicAdvancedSession(
filepath.Join(modelDir, "TokenEmbedding.onnx"),
[]string{"input_ids"}, []string{"hidden"}, nil,
)
if err != nil {
return nil, fmt.Errorf("qwen3vlgen: create token session: %w", err)
}
seqHead, err := ort.NewDynamicAdvancedSession(
filepath.Join(modelDir, cfg.Generation.Graph),
[]string{"hidden", "deepstack_0", "deepstack_1", "deepstack_2",
"rotary_cos", "rotary_sin", "causal_mask"},
[]string{"logits"}, nil,
)
if err != nil {
token.Destroy()
return nil, fmt.Errorf("qwen3vlgen: create %s session: %w", cfg.Generation.Graph, err)
}
return &Generator{
dir: modelDir, cfg: cfg, tok: tok, token: token, seqHead: seqHead,
fp: fingerprint(modelDir),
}, nil
}
// Generate 跑一次自回归解码。
//
// 文本侧走纯文本输入:生成蒸馏拆分的输入永远是文本(对话记录),不进
// 视觉塔 —— 那条路的 DeepStack 相加是为视觉 token 准备的,纯文本时三项全零。
func (g *Generator) Generate(ctx context.Context, req generation.Request) (generation.Response, error) {
g.mu.Lock()
defer g.mu.Unlock()
if req.JSONSchema != "" {
// 这条路径的约束靠解码时的语法/采样实现,不能靠 ONNX 图 ——
// 图只到 logits。schema 约束由 provider 之外的机制(如结构化采样)
// 完成;本 provider 报不支持而不是静默忽略。
return generation.Response{}, fmt.Errorf(
"qwen3vlgen: 不支持 json schema 约束(返回 ErrSchemaUnsupported 会让调用方改用自由文本,"+
"那正是拆分场景要避免的):%w", generation.ErrSchemaUnsupported)
}
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = 256
}
ids := g.tok.Encode(req.Prompt)
if len(ids) == 0 {
return generation.Response{}, fmt.Errorf("qwen3vlgen: prompt 为空")
}
maxLen := g.cfg.MaxLength
if maxLen <= 0 {
maxLen = 2560
}
if len(ids) > maxLen-1 {
ids = ids[:maxLen-1]
}
var out strings.Builder
truncated := true
for step := 0; step < maxTokens; step++ {
if err := ctx.Err(); err != nil {
return generation.Response{}, err
}
next, err := g.forward(ids)
if err != nil {
return generation.Response{}, err
}
tokenText, isStop := g.decodeToken(next)
if isStop {
truncated = false
break
}
out.WriteString(tokenText)
ids = append(ids, next)
if len(ids) >= maxLen-1 {
truncated = false
break
}
}
return generation.Response{Text: out.String(), Truncated: truncated}, nil
}
// forward 跑一次前向,返回 argmax token id。
//
// 全量前向:没有 KV cache,每步重算全序列。蒸馏输出短(N≈200),
// 这个代价是可接受的 —— 见包注释。
func (g *Generator) forward(ids []int) (int, error) {
seq := len(ids)
hidden, err := g.runTokenEmbedding(ids)
if err != nil {
return 0, err
}
cos, sin := rotary(g.cfg.RopeTheta, g.cfg.MRopeSection, seq)
causal := causalMask(seq)
zero := make([]float32, len(hidden))
var deepTensors []*ort.Tensor[float32]
defer func() {
for _, t := range deepTensors {
t.Destroy()
}
}()
tensors := make([]ort.Value, 0, 7)
hT, err := ort.NewTensor(ort.Shape{1, int64(seq), int64(g.cfg.Dim)}, hidden)
if err != nil {
return 0, fmt.Errorf("qwen3vlgen: hidden tensor: %w", err)
}
tensors = append(tensors, hT)
for i := 0; i < 3; i++ {
t, err := ort.NewTensor(ort.Shape{1, int64(seq), int64(g.cfg.Dim)}, zero)
if err != nil {
return 0, fmt.Errorf("qwen3vlgen: deepstack tensor: %w", err)
}
deepTensors = append(deepTensors, t)
tensors = append(tensors, t)
}
cosT, err := ort.NewTensor(ort.Shape{1, int64(seq), qwenRotaryDim}, cos)
if err != nil {
return 0, err
}
tensors = append(tensors, cosT)
sinT, err := ort.NewTensor(ort.Shape{1, int64(seq), qwenRotaryDim}, sin)
if err != nil {
return 0, err
}
tensors = append(tensors, sinT)
maskT, err := ort.NewTensor(ort.Shape{1, 1, int64(seq), int64(seq)}, causal)
if err != nil {
return 0, err
}
tensors = append(tensors, maskT)
defer func() {
hT.Destroy()
cosT.Destroy()
sinT.Destroy()
maskT.Destroy()
}()
outs := make([]ort.Value, 1)
if err := g.seqHead.Run(tensors, outs); err != nil {
return 0, fmt.Errorf("qwen3vlgen: run: %w", err)
}
defer outs[0].Destroy()
lg, ok := outs[0].(*ort.Tensor[float32])
if !ok {
return 0, fmt.Errorf("qwen3vlgen: logits type %T", outs[0])
}
data := lg.GetData()
shape := lg.GetShape()
if len(shape) != 3 || shape[0] != 1 || shape[1] != int64(seq) {
return 0, fmt.Errorf("qwen3vlgen: logits shape %v(期望 [1 %d vocab])", shape, seq)
}
vocab := int(shape[2])
// argmax over last position
last := data[(seq-1)*vocab : seq*vocab]
best, bestVal := 0, float32(math.Inf(-1))
for i, v := range last {
if v > bestVal {
best, bestVal = i, v
}
}
return best, nil
}
func (g *Generator) runTokenEmbedding(ids []int) ([]float32, error) {
in := make([]int64, len(ids))
for i, id := range ids {
in[i] = int64(id)
}
t, err := ort.NewTensor(ort.Shape{1, int64(len(ids))}, in)
if err != nil {
return nil, err
}
defer t.Destroy()
outs := make([]ort.Value, 1)
if err := g.token.Run([]ort.Value{t}, outs); err != nil {
return nil, fmt.Errorf("qwen3vlgen: token run: %w", err)
}
defer outs[0].Destroy()
tensor, ok := outs[0].(*ort.Tensor[float32])
if !ok {
return nil, fmt.Errorf("qwen3vlgen: token output %T", outs[0])
}
return append([]float32(nil), tensor.GetData()...), nil
}
// decodeToken 判定是否为停止 token,并返回其文本。
func (g *Generator) decodeToken(id int) (string, bool) {
for _, stopID := range []int{151645, 151643} { // <|im_end|>, <|endoftext|>
if id == stopID {
return "", true
}
}
return g.tok.DecodeOne(id), false
}
func (g *Generator) Info() generation.Info {
return generation.Info{
Model: "qwen3vl/" + filepath.Base(g.dir),
SupportsJSONSchema: false, // 图只到 logits;schema 约束未实现
}
}
// Close 释放两张图的 session。close sync.Once 保证重复调用安全。
func (g *Generator) Close() {
g.close.Do(func() {
g.mu.Lock()
defer g.mu.Unlock()
if g.seqHead != nil {
g.seqHead.Destroy()
}
if g.token != nil {
g.token.Destroy()
}
})
}
const (
qwenRotaryHalfDim = 64
qwenRotaryDim = 128
)
func rotary(theta float64, section []int, seq int) ([]float32, []float32) {
cos := make([]float32, seq*qwenRotaryDim)
sin := make([]float32, seq*qwenRotaryDim)
inv := make([]float64, qwenRotaryHalfDim)
for i := range inv {
inv[i] = 1 / math.Pow(theta, float64(2*i)/qwenRotaryDim)
}
for token := 0; token < seq; token++ {
freq := make([]float64, qwenRotaryHalfDim)
for i := range freq {
freq[i] = float64(token) * inv[i]
}
// M-RoPE:三个分段共用同一 base 频率,替换各自区间。
// 纯文本输入下三段位置相同(文本视觉混合才不同),但仍按
// embedder.go 的同一算法算,保持与向量侧逐位一致。
for _, dim := range []int{1, 2} {
if dim >= len(section) {
continue
}
limit := section[dim] * 3
for i := dim; i < limit && i < qwenRotaryHalfDim; i += 3 {
freq[i] = float64(token) * inv[i]
}
}
for i, f := range freq {
c, s := float32(math.Cos(f)), float32(math.Sin(f))
cos[token*qwenRotaryDim+i] = c
cos[token*qwenRotaryDim+qwenRotaryHalfDim+i] = c
sin[token*qwenRotaryDim+i] = s
sin[token*qwenRotaryDim+qwenRotaryHalfDim+i] = s
}
}
return cos, sin
}
func causalMask(seq int) []float32 {
m := make([]float32, seq*seq)
min := float32(math.Inf(-1))
for i := 0; i < seq; i++ {
for j := 0; j < seq; j++ {
if j > i {
m[i*seq+j] = min
}
}
}
return m
}
func fingerprint(dir string) string {
// 指纹只用 embed_config.json:它声明了 arch/dim/generation,
// 足以区分「同一份权重能否跨版本互比向量」。
p := filepath.Join(dir, "embed_config.json")
data, err := os.ReadFile(p)
if err != nil {
return "unreadable"
}
var h uint64 = 14695981039346656037
for _, b := range data {
h ^= uint64(b)
h *= 1099511628211
}
return fmt.Sprintf("%016x", h)
}