mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 背景 仓库早已迁移到 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 保留。
404 lines
12 KiB
Go
404 lines
12 KiB
Go
//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)
|
||
}
|