Files
HomeAgent/internal/nlp/onnx.go
JianFeeeee ba281a855a feat(update): 差分 OTA + 安装形态分派 + 事件总线统一受安全监控
三条主线,由 e2e 实测驱动。

## 一、差分 OTA(internal/update/delta.go)

实测:改一行逻辑,88MB 的二进制逐字节只差 80 字节,而 OTA 一直下整包。

用 rsync 族的滚动哈希块匹配(4KB 粒度),不是 bsdiff —— 后者需 17×N
峰值内存(88MB → 1.5GB),而升级恰可能发生在内存紧张时。

真实二进制实测:88,036,728 字节 → 补丁 77,316 字节(0.088%)。

三道闸:应用前验旧文件 sha256(错基线会静默产出垃圾)、应用后验结果
sha256、COPY/LITERAL 越界防护。任何失败**退回整包**,不向上抛 ——
差分是优化,不是可用性前提。

接入:Source.Resolve 优先试 delta;CheckRelease 按 -from-<commit8>.delta
匹配(不猜基线);CLI 新增 -make-delta;打包脚本 build_delta(需
PREVIOUS_HOMED),delta 进 SHA256SUMS。

## 二、安装形态分派(internal/update/install.go + channel.go)

内核对「能不能自己升级」此前只有一个答案:直接替换二进制。而 deb/rpm
形态下那样做会让 dpkg/rpm 数据库与实际内容不一致(下次 apt upgrade
覆盖回旧版,或 dpkg 拒绝后续升级)。

新增探测:deb/rpm/tar/container/source 五类形态 + 变体(full/server/
client/toolchain)+ 目录写权限。判据全部来自可观测系统事实(dpkg 的
info 目录、容器标记文件),不做路径推断。

三端统一走 PlanUpgrade 分派;形态闸门放在**下载 880MB 之前**。

## 三、事件总线统一受安全监控(internal/events + security)

用户指正:「eventbus 上所有事件都要接受安全模块的监控」。

此前 EventPolicy 只被 internal/sdk/events.go 调用,而那是四条路径中的
一条。s.Publish / s.Subscribe / s.EventBus() 拿的是裸 *events.Bus,
任何插件都能绕过策略发任意事件(含伪造内核事件)。

现在闸门在 Bus.Publish/Subscribe 本体上(Guard 接口注入,避免 events
↔ security 成环),四条路径全部覆盖。sdk.EventBus() 改返回带身份的
BusHandle。内核身份免授权,但载荷照样过凭据闸门。

白名单按主体收窄:agent_output/stage/terminal_output 只允许承载者
(webui/cli/agentcli)发布 —— 它们是内核向外的输出面,谁都能发就等于
能伪造 agent 说的话。

## e2e 暴露并修掉的三个真实回归

	[bus] subscribe denied: pluginmgr type=update_request
	[bus] subscribe denied: skillmgr type=skill_detected

前两个让升级请求静默丢失、职责链交接断掉。内部协作事件必须可订阅
(安全边界在发布侧,订阅只是「听得到」)。

## 顺带的止血修复

- 回滚判据从 `--version` 改为 digest:旧内核(1.3.x 及以前)没有该
  flag,而它恰是最常见的回滚目标 —— 原逻辑让「从旧版升级失败」永久
  无法回滚。
- CopyFile 补 fsync(替换 homed 本体时掉电会留半截文件)。
- restore 完整还原才清 marker(原先先清再还原,还原失败就失去第二次
  自愈机会)。
- 回滚时移除升级中新增的 depgraph(否则「旧 homed + 新 depgraph」
  会因 digest 不匹配崩溃循环)。
- homed-upgrade 不再硬编码 systemctl restart(丢 guard 状态、unit 名
  漂移);改用内核已有的 restartCh → exit 42 语义。
- homed-upgrade 不再用 CGO_ENABLED=0 编却依赖 go-sqlite3(stub 下
  静默失效,该路径从未工作过);改用 internal/config。
- 服务单元补 User=hologramagent + 三条安装路径补建用户(此前发行物里
  完全没有落实降权)。
- rpm 的 fpm 调用补 --after-install(此前 rpm 装完根本不建用户)。

## 判据

新增 30 条:差分 8(往返/压缩比/错基线/截断/篡改/边界)、通道 8、
执行器 6、事件闸门 8、按主体授权 8。全仓 go test ./... 0 失败。
2026-10-29 09:54:55 +08:00

375 lines
9.7 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 nlp
import (
"embed"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/JianFeeeee/HologramAgent/internal/memory"
ort "github.com/yalue/onnxruntime_go"
)
//go:embed models/*
var onnxModelFS embed.FS
const maxSeqLen = 128
type ONNXParser struct {
rt *ort.DynamicAdvancedSession
vocab map[string]int64
posVocab map[string]int64
close sync.Once
modelPath string // 实际加载的模型路径(空 ModelPath 时为解出的内嵌模型)
}
// ModelPath 返回**实际**加载的 ONNX 模型路径。
//
// 为什么需要它:配置里的 core.agent.onnx_model_path 允许留空,此时用二进制内嵌
// 的 dep_parser.onnx(解到 DataDir/nlp 后加载)。只看配置就会把「内嵌模型已启用」
// 误报成「未配置 → 走规则引擎」—— 启动日志一度这样误导。
func (p *ONNXParser) ModelPath() string {
if p == nil {
return ""
}
return p.modelPath
}
type ONNXConfig struct {
ModelPath string
DataDir string
}
func NewONNXParser(cfg ONNXConfig) (*ONNXParser, error) {
vocab, err := loadWordMap("models/vocab.json")
if err != nil {
return nil, fmt.Errorf("load vocab: %w", err)
}
posVocab, err := loadWordMap("models/pos_vocab.json")
if err != nil {
return nil, fmt.Errorf("load pos_vocab: %w", err)
}
modelPath := cfg.ModelPath
if modelPath == "" {
modelPath, err = extractEmbeddedModel(cfg.DataDir)
if err != nil {
return nil, fmt.Errorf("extract model: %w", err)
}
}
// ORT 环境是**进程级单例**,同一进程里可能有多个消费者(多模态向量
// provider、本依存解析器)。onnxruntime_go 的行为是:第二次
// InitializeEnvironment 报「already been initialized」,而
// DestroyEnvironment 会把别人正在用的环境一起拆掉——先初始化的 provider
// 会因此拿到失效的会话。所以这里只在未初始化时初始化,并且**永不销毁**
// (与 providers/chineseclip、providers/qwen3vl 的约定一致):环境随进程存活。
//
// 这个缺陷是在「发行版默认带 onnxruntime 标签」后才暴露的:不带标签时
// 两个消费者不会同时存在,重复初始化与误销毁都无法发生。
if !ort.IsInitialized() {
ort.SetSharedLibraryPath(libPath())
if err := ort.InitializeEnvironment(); err != nil {
return nil, fmt.Errorf("init onnx env: %w", err)
}
}
inputNames := []string{"input_ids"}
outputNames := []string{"pos_logits", "head_logits", "rel_logits"}
session, err := ort.NewDynamicAdvancedSession(modelPath, inputNames, outputNames, nil)
if err != nil {
// 不在这里 DestroyEnvironment:环境是进程级的,可能正被多模态 provider 使用。
return nil, fmt.Errorf("create session: %w", err)
}
return &ONNXParser{
rt: session,
vocab: vocab,
posVocab: posVocab,
modelPath: modelPath,
}, nil
}
func (p *ONNXParser) Close() error {
p.close.Do(func() {
p.rt.Destroy()
// 不销毁进程级 ORT 环境:多模态 provider 可能仍在使用(见 NewONNXParser)。
})
return nil
}
func (p *ONNXParser) Parse(text string) (*ParseResult, error) {
if text == "" {
return &ParseResult{}, nil
}
x := memory.GetJieba()
if x == nil {
return nil, fmt.Errorf("jieba unavailable")
}
words := x.Cut(text, true)
if len(words) == 0 {
return &ParseResult{}, nil
}
inIDs := p.wordsToIDs(words, maxSeqLen)
n := len(inIDs) - 1 // exclude <bos>
if n <= 0 {
return &ParseResult{}, nil
}
if n > len(words) {
n = len(words)
}
padded := padTo(inIDs, maxSeqLen)
inTensor, err := ort.NewTensor(ort.NewShape(1, maxSeqLen), padded)
if err != nil {
return nil, fmt.Errorf("create input tensor: %w", err)
}
defer inTensor.Destroy()
outputs := make([]ort.Value, 3)
if err := p.rt.Run([]ort.Value{inTensor}, outputs); err != nil {
return nil, fmt.Errorf("onnx run: %w", err)
}
posOut, ok := outputs[0].(*ort.Tensor[float32])
if !ok {
return nil, fmt.Errorf("pos output not Tensor[float32]")
}
headOut, ok := outputs[1].(*ort.Tensor[float32])
if !ok {
return nil, fmt.Errorf("head output not Tensor[float32]")
}
relOut, ok := outputs[2].(*ort.Tensor[float32])
if !ok {
return nil, fmt.Errorf("rel output not Tensor[float32]")
}
defer posOut.Destroy()
defer headOut.Destroy()
defer relOut.Destroy()
posShape := posOut.GetShape() // [1, seq, posDim]
headShape := headOut.GetShape() // [1, seq, seq]
relShape := relOut.GetShape() // [1, seq, seq, relDim]
if len(posShape) < 3 || len(headShape) < 3 || len(relShape) < 4 {
return nil, fmt.Errorf("unexpected output ranks: pos=%d head=%d rel=%d",
len(posShape), len(headShape), len(relShape))
}
seqDim := int(headShape[1])
posDim := int(posShape[2])
relDim := int(relShape[3])
if n > seqDim {
n = seqDim
}
rawPOS := posOut.GetData()
rawHeads := headOut.GetData()
rawRels := relOut.GetData()
pos := decodePOS(rawPOS, n, posDim, p.posVocab)
heads := decodeHeads(rawHeads, n, seqDim)
rels := decodeRels(rawRels, n, seqDim, relDim, heads)
return &ParseResult{
Tokens: words[:n],
POS: pos,
Heads: heads,
DepRels: rels,
}, nil
}
func (p *ONNXParser) wordsToIDs(words []string, maxLen int) []int64 {
ids := make([]int64, 0, maxLen)
if bos, ok := p.vocab["<bos>"]; ok {
ids = append(ids, bos)
}
for _, w := range words {
if len(ids) >= maxLen {
break
}
if id, ok := p.vocab[w]; ok {
ids = append(ids, id)
} else if unk, ok := p.vocab["<unk>"]; ok {
ids = append(ids, unk)
}
}
return ids
}
func padTo(ids []int64, length int) []int64 {
for len(ids) < length {
ids = append(ids, 0)
}
return ids
}
func decodePOS(raw []float32, n, posDim int, posVocab map[string]int64) []string {
rev := make(map[int64]string)
for k, v := range posVocab {
rev[v] = k
}
pos := make([]string, n)
for i := 0; i < n; i++ {
bestIdx := 0
bestVal := float32(-1e9)
for j := 0; j < posDim; j++ {
if v := raw[i*posDim+j]; v > bestVal {
bestVal = v
bestIdx = j
}
}
if tag, ok := rev[int64(bestIdx)]; ok {
pos[i] = tag
} else {
pos[i] = "X"
}
}
return pos
}
func decodeHeads(raw []float32, n, seqDim int) []int {
heads := make([]int, n)
for i := 0; i < n; i++ {
bestIdx := 0
bestVal := float32(-1e9)
for j := 0; j < seqDim; j++ {
if v := raw[i*seqDim+j]; v > bestVal {
bestVal = v
bestIdx = j
}
}
heads[i] = bestIdx
}
return heads
}
func decodeRels(raw []float32, n, seqDim, relDim int, heads []int) []string {
rels := make([]string, n)
stride := seqDim * relDim
for i := 0; i < n; i++ {
h := heads[i]
if h < 0 || h >= seqDim {
rels[i] = "dep"
continue
}
bestIdx := 0
bestVal := float32(-1e9)
for r := 0; r < relDim; r++ {
if v := raw[i*stride+h*relDim+r]; v > bestVal {
bestVal = v
bestIdx = r
}
}
rels[i] = depRelLabel(bestIdx)
}
return rels
}
func loadWordMap(path string) (map[string]int64, error) {
data, err := onnxModelFS.ReadFile(path)
if err != nil {
return nil, err
}
var raw struct {
Word map[string]int64 `json:"word"`
}
if err := json.Unmarshal(data, &raw); err != nil {
var flat map[string]int64
if err2 := json.Unmarshal(data, &flat); err2 != nil {
return nil, err
}
return flat, nil
}
return raw.Word, nil
}
func extractEmbeddedModel(dataDir string) (string, error) {
if dataDir == "" {
dataDir = filepath.Join(os.TempDir(), "homeagent-nlp")
}
os.MkdirAll(dataDir, 0755)
// ★ 外置模型优先(第 4 项:数据外置)。
//
// 模型 47MB 是**数据**不是代码,却 #go:embed 进了 88MB 的二进制:
// 改一行代码要重下 47MB,而 patch 版本之间它从未变过。
//
// 查找顺序(只认非空文件):
// ① <DataDir>/nlp/dep_parser.onnx(部署目录,与旧行为兼容:
// 旧版首次启动会把内嵌模型解到这里)
// ② /usr/share/hologram/models/dep_parser.onnx(系统目录)
// ③ 内嵌解出
//
// ★ 不做哈希校验:模型是数据,装错版本最多依存分析变差,
// 不阻断启动。哈希会让“用户手工换模型”直接不可用,那是不必要的刚性。
for _, cand := range []string{
filepath.Join(dataDir, "nlp", "dep_parser.onnx"),
"/usr/share/hologram/models/dep_parser.onnx",
} {
if st, err := os.Stat(cand); err == nil && !st.IsDir() && st.Size() > 0 {
return cand, nil
}
}
// ③ 退回内嵌(保持旧行为:首次启动解到 dataDir/nlp/)
nlpDir := filepath.Join(dataDir, "nlp")
os.MkdirAll(nlpDir, 0755)
dst := filepath.Join(nlpDir, "dep_parser.onnx")
if st, err := os.Stat(dst); err == nil && st.Size() > 0 {
return dst, nil
}
data, err := onnxModelFS.ReadFile("models/dep_parser.onnx")
if err != nil {
return "", err
}
if err := os.WriteFile(dst, data, 0644); err != nil {
return "", err
}
return dst, nil
}
func libPath() string {
for _, env := range []string{"ONNXRUNTIME_DIR", "ONNX_ML_DIR"} {
if d := os.Getenv(env); d != "" {
for _, name := range []string{"libonnxruntime.so", "libonnxruntime.dylib", "onnxruntime.dll"} {
if candidate := filepath.Join(d, name); fileExists(candidate) {
return candidate
}
}
}
}
for _, name := range []string{"libonnxruntime.so", "libonnxruntime.dylib", "onnxruntime.dll"} {
if fileExists(name) {
abs, _ := filepath.Abs(name)
return abs
}
}
return "onnxruntime.dll"
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func depRelLabel(id int) string {
labels := []string{"root", "nsubj", "obj", "iobj", "obl", "vocative", "expl", "csubj", "ccomp", "xcomp",
"advcl", "advmod", "amod", "appos", "nmod", "acl", "det", "clf", "case", "mark",
"nummod", "discourse", "aux", "cop", "cc", "conj", "fixed", "flat", "list", "parataxis",
"orphan", "goeswith", "reparandum", "punct", "dep"}
if id >= 0 && id < len(labels) {
return labels[id]
}
return "dep"
}