Files
HomeAgent/internal/memory/pipeline/pipeline.go
JianFeeeee a68d6a428c feat: 模块化内核(编译期依赖图定序)+ 密钥防火墙 + 插件三态生命周期 (#10)
* feat(kernel): 模块化内核 + 编译期依赖图定序

把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。

## 为什么

原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
 · nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
   只靠 ticker 10min 才没炸(隐式依赖,无声明)
 · 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
 · 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
   ⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)

## 怎么改

- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
  nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
  scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
  lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
  all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)

## 判据

kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
 · 删掉 AddCloser 的锁 → -race 报数据竞争
 · Scheduler 同批跑去重 → 203 vs 200
 · 图 hash 不匹配 → 拒绝启动

## 顺带修的缺陷

热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。

实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。

* feat(security): 密钥防火墙 + 配置加密 + close 纪律

## 密钥防火墙

模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
 · 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
 · 入向:模型传别名,入口处填回真值(插件完全无感知)
 · 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件

模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。

## 配置加密

- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
  enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计

## close 纪律(用户明确要求:只要调 close 就要有前置检查)

internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
 · `select { case <-ch: default: close(ch) }` **不是**并发安全的
   (两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
 · `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
   症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
 · map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
   改成 *pendingRPC 包装,每通道一个 once

## 判据

security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。

* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复

## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)

- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
  "未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
  按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
  (不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
  那是进程退出用的,误用于"暂停"就是关了就死

## 数据目录迁移(伪关机 → 迁移 → 唤醒)

- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
  逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
  → 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
  恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本

★ 迁移脚本的三个实测教训(都写成了判据):
 1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
    加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
 2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
    并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)

## 发行包修复(全部经实测确认)

 1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
    ("生产构建(内嵌 hash 非空)但读不到依赖图")
 2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
 3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
    ⇒ --version 显示 unknown
 4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH

## ★ build.sh 的版本号必须校验(真实事故)

原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。

修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。

## deb 的 systemd unit 改为 guard 模式

ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。

## 其他

- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
  修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)

* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)

- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
  串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
  "出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
  白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
  mgrIsRuntimeStopped)+ settings 对齐

判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。

* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)

GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:

  internal/security/prefilter_test.go:39
  internal/security/store_test.go:177
  internal/security/survey_test.go:20

它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。

★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。

改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
  glpat-abcdefghijklmnopqrst       → glpat-FAKE-TEST-TOKEN-NOT-REAL
  sk-abcdefghij...012345           → sk-FAKE-TEST-KEY-NOT-REAL-0000000
  AKIAIOSFODNN7EXAMPLE             → AKIA0000FAKE0000TEST   (保持 20 字符)
  ASIAIOSFODNN7EXAMPLE             → ASIA0000FAKE0000TEST
  xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL

★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。

顺带核实:全仓 grep 无任何真实密钥泄漏。

* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一

上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:

  VERSION="${VERSION:-$(git describe --tags --dirty)}"

无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。

危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。

修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。

判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。

实测:不传 VERSION 跑 package-linux.sh,输出
  [PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
  Version: 1.4.0

* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)

CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:

  --- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
      bridge_sync_test.go:170: 读模板: open
      ../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
      no such file or directory

## 根因:判据依赖了一个**被 gitignore 的路径**

`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
  · 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
  · CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**

我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。

## 修法

文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。

★ 这不是"放弃检查":
  · 跨仓一致性由 **SDK 仓自己的判据**守着
    (tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
  · 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)

## 验证

 · 本机(有文件)  → PASS(判据真跑)
 · 模拟 CI(移走 tools/)→ SKIP(不再 fail)
 · 全量 53 包 ok / 0 fail

## 顺带

同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-17 19:18:24 +08:00

687 lines
21 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.

package pipeline
import (
"bufio"
"context"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/distill"
"github.com/JianFeeeee/HomeAgent/internal/nlp"
"github.com/JianFeeeee/HomeAgent/internal/safeclose"
)
type RawRecord struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
Role string `json:"role"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Distilled bool `json:"distilled"`
// persisted 表示该记录已经写在磁盘 raw 文件里。
// 未导出:只影响本进程的落盘行为,不进 JSON。
// 作用:flush 只写**尚未落盘**的记录,否则 loadExisting 载入的记录
// 会被再写一份,重启后同一批记录从新旧两个文件各读回一次。
persisted bool
}
type DistillerConfig struct {
Interval time.Duration `json:"interval"`
RetentionDays int `json:"retention_days"`
BatchSize int `json:"batch_size"`
}
type Distiller struct {
pauseMu sync.Mutex
paused bool
pauseCh chan struct{}
inflight sync.WaitGroup
mu sync.Mutex
db *memory.GraphDB
rawPath string
records []RawRecord
nextID int64
cfg DistillerConfig
ctx context.Context
cancel context.CancelFunc
onMemory func(input, response string)
embedder nlp.Vectorizer
// embed 把块文本变成向量与指纹;nil 表示不带向量(不编造)。
embed EmbedFunc
// splitter 是小模型拆分器(pkg/generation 提供的 provider)。
//
// ★ 为什么要有它:jieba/ONNX 那条自动蒸馏路实测产出 **0 条**
//(defaultParser 为 nil,POS 模板抽不出),于是图记忆里的 188 个实体
// 全部是模型主动调 memory_commit 写进来的**整句复合值**
//(admin服务端口8861·billing服务端口8499·oauth服务端口8271)。
// 后果是跨维度检索全失效:严格判据下基线 0/5。
//
// splitter 为 nil 时回退 extractKeyTriples(现有 jieba 路),
// 保持蒸馏不停摆 —— 哪怕产出 0 条也比整体失败好。
splitter RecordSplitter
}
// RecordSplitter 把一条原始记录拆成三元组。
//
// 抽成接口是为了让 pipeline 不依赖 internal/memory/distill(那个包依赖
// pkg/generation,而 pipeline 在早期启动阶段不该拉起生成侧依赖)。
type RecordSplitter interface {
Split(ctx context.Context, record string) ([]memory.Triple, error)
}
// SetSplitter 注入小模型拆分器。为 nil 时蒸馏回退 jieba 路。
func (d *Distiller) SetSplitter(s RecordSplitter) { d.splitter = s }
// SetEmbedFunc 注入块向量计算函数(由持有 embedding provider 的一方提供)。
func (d *Distiller) SetEmbedFunc(fn EmbedFunc) { d.embed = fn }
// SetEmbedder 注入词嵌入器,供既有 jieba/ONNX 抽取路做 TransE 语义验证。
// 与 SetSplitter 并存:splitter 存在时优先生效,embedder 仍用于回退路径。
func (d *Distiller) SetEmbedder(ev nlp.Vectorizer) { d.embedder = ev }
func NewDistiller(db *memory.GraphDB, dataDir string, cfg DistillerConfig) *Distiller {
ctx, cancel := context.WithCancel(context.Background())
return &Distiller{
db: db,
rawPath: filepath.Join(dataDir, "memory", "raw"),
cfg: cfg,
ctx: ctx,
cancel: cancel,
pauseCh: make(chan struct{}, 1),
}
}
func (d *Distiller) OnMemoryCandidate(fn func(input, response string)) {
d.onMemory = fn
}
func (d *Distiller) Start() {
if err := os.MkdirAll(d.rawPath, 0755); err != nil {
log.Printf("[memory] create raw path: %v", err)
}
d.loadExisting()
log.Printf("[memory] distiller started (interval: %v, retention: %d days)", d.cfg.Interval, d.cfg.RetentionDays)
go d.distillLoop()
}
func (d *Distiller) Stop() {
d.cancel()
d.flush()
}
// Stopped 报告蒸馏循环是否已被 Stop() 取消。
//
// Stop() 里的 cancel() 是同步生效的,所以本方法在 Stop() 返回后立即为 true,
// 不受循环 goroutine 何时退出的影响。启动自检、健康检查用它确认
// 「Start 之后没有被立即 Stop 掉」——历史回归:main() 拆分时
// initMemoryStack 里残留一句 defer distiller.Stop(),函数一返回就把刚起的
// 循环杀了,10min 心跳从不运行。Start() 之前返回 false(尚未被停)。
func (d *Distiller) Stopped() bool {
select {
case <-d.ctx.Done():
return true
default:
return false
}
}
func (d *Distiller) Append(sessionID string, role string, content string) {
d.mu.Lock()
defer d.mu.Unlock()
d.nextID++
d.records = append(d.records, RawRecord{
ID: d.nextID, SessionID: sessionID, Role: role,
Content: content, CreatedAt: time.Now(),
})
}
func (d *Distiller) flush() {
d.mu.Lock()
defer d.mu.Unlock()
d.flushLocked()
}
// flushLocked 把**尚未落盘**的记录追加写入一个新的 raw 文件(原子写)。
//
// 只写 !persisted 的记录:loadExisting 载入的记录已经在磁盘上,若 flush 再把
// 它们整体重写一份,重启后同一批记录会同时从旧文件与新文件被读回,
// 实体 mention_count 与关系被重复蒸馏。
func (d *Distiller) flushLocked() {
var pending []RawRecord
for _, r := range d.records {
if !r.persisted {
pending = append(pending, r)
}
}
if len(pending) == 0 {
return
}
path := filepath.Join(d.rawPath, fmt.Sprintf("raw_%d.tsv", time.Now().UnixNano()))
var sb strings.Builder
for _, r := range pending {
fmt.Fprintf(&sb, "%d\t%s\t%s\t%s\t%d\n", r.ID, r.SessionID, r.Role, r.Content, r.CreatedAt.Unix())
}
if err := writeFileAtomic(path, []byte(sb.String())); err != nil {
log.Printf("[memory] flush error: %v", err)
return
}
for i := range d.records {
d.records[i].persisted = true
}
}
func (d *Distiller) loadExisting() {
entries, err := os.ReadDir(d.rawPath)
if err != nil {
return
}
cutoff := time.Now().AddDate(0, 0, -d.cfg.RetentionDays)
type fileInfo struct {
name string
mod time.Time
}
var files []fileInfo
for _, entry := range entries {
ext := filepath.Ext(entry.Name())
if ext != ".tsv" && ext != ".jsonl" {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(filepath.Join(d.rawPath, entry.Name()))
continue
}
files = append(files, fileInfo{name: entry.Name(), mod: info.ModTime()})
}
sort.Slice(files, func(i, j int) bool { return files[i].mod.After(files[j].mod) })
loaded := 0
const maxStartupRecords = 5000
for _, entry := range files {
if loaded >= maxStartupRecords {
break
}
path := filepath.Join(d.rawPath, entry.name)
f, err := os.Open(path)
if err != nil {
continue
}
func() {
defer f.Close()
scanner := bufio.NewScanner(f)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
if loaded >= maxStartupRecords {
break
}
line := scanner.Text()
parts := splitLine(line)
if len(parts) < 5 {
continue
}
ts, err := strconv.ParseInt(parts[4], 10, 64)
if err != nil {
continue
}
createdAt := time.Unix(ts, 0)
if createdAt.Before(cutoff) {
continue
}
d.records = append(d.records, RawRecord{
ID: d.nextID, SessionID: parts[1], Role: parts[2], Content: parts[3], CreatedAt: createdAt,
persisted: true,
})
d.nextID++
loaded++
}
}()
}
if loaded >= maxStartupRecords {
log.Printf("[memory] distiller startup load capped at %d recent records", loaded)
}
}
func (d *Distiller) distillLoop() {
ticker := time.NewTicker(d.cfg.Interval)
defer ticker.Stop()
for {
// ★ 伪关机门(Quiesce/Resume):迁移数据目录时必须完全不写。
//
// 为什么不用 cancel:cancel 是**不可逆**的,恢复只能重建整个 Distiller
// (而它持有 rawPath/records 状态)。迁移要"停下来→搬→再起来" ⇒ 需要可逆。
if paused := d.waitNotPaused(); paused {
return
}
select {
case <-ticker.C:
if d.isPaused() {
continue // 暂停期间跳过本轮,避免"刚好卡在检查后开始写"
}
d.inflight.Add(1)
d.distillOnce()
d.inflight.Done()
case <-d.ctx.Done():
return
}
}
}
// waitNotPaused 阻塞到未被暂停;返回 true 表示 ctx 已取消(应退出循环)。
func (d *Distiller) waitNotPaused() bool {
for d.isPaused() {
select {
case <-d.pauseCh: // 被 Resume 唤醒,重新检查
case <-d.ctx.Done():
return true
}
}
return false
}
func (d *Distiller) isPaused() bool {
d.pauseMu.Lock()
defer d.pauseMu.Unlock()
return d.paused
}
// Pause 伪关机:停止写入并**等待在途蒸馏完成**(可逆)。
//
// 语义保证(迁移依赖这三条):
// 1. 返回后**不会再有新的** distillOnce 开始
// 2. 已在跑的那次 distillOnce 会**跑完**(inflight 归零才返回)
// ——否则迁移会搬走一个写了一半的库
// 3. 幂等:重复 Pause 不报错
func (d *Distiller) Pause() error {
d.pauseMu.Lock()
if d.paused {
d.pauseMu.Unlock()
return nil
}
d.paused = true
d.pauseMu.Unlock()
// 等在途的那一次结束(最多等到 ctx 取消)
//
// ★ 用 safeclose.Safe 而不是裸 close:本仓 close 纪律要求"只要调 close
// 就要有前置检查"(见 internal/safeclose 的判据)。这里虽然当前只有
// 一个 goroutine 会 close,但将来加一条错误/超时/重连路径就可能变成两次,
// 而那次改动看起来与 close 无关 ⇒ 退出路径 panic。
done := make(chan struct{})
var doneOnce sync.Once
go func() { d.inflight.Wait(); safeclose.Safe(&doneOnce, done) }()
select {
case <-done:
return nil
case <-d.ctx.Done():
return nil
}
}
// Resume 唤醒:允许再次写入。
func (d *Distiller) Resume() error {
d.pauseMu.Lock()
was := d.paused
d.paused = false
d.pauseMu.Unlock()
if was {
// 唤醒所有在 waitNotPaused 上等的循环(非阻塞广播)
select {
case d.pauseCh <- struct{}{}:
default:
}
}
return nil
}
func (d *Distiller) distillOnce() {
d.mu.Lock()
batchSize := d.cfg.BatchSize
if batchSize <= 0 {
batchSize = 50
}
// 每 tick 取前 N 条未蒸馏记录(无 RetentionDays 门槛),蒸馏成功才标记/移除
var toDistill []RawRecord
var remaining []RawRecord
for _, r := range d.records {
if !r.Distilled && len(toDistill) < batchSize {
toDistill = append(toDistill, r)
} else {
remaining = append(remaining, r)
}
}
d.records = remaining
d.mu.Unlock()
if len(toDistill) == 0 {
return
}
distilled := 0
for i := 0; i < len(toDistill); i += batchSize {
end := i + batchSize
if end > len(toDistill) {
end = len(toDistill)
}
if d.distillBatch(toDistill[i:end]) {
distilled += end - i
// 成功即从磁盘 raw 文件里删除这些行,否则重启后 loadExisting
// 会把它们当未蒸馏记录重新读回,每次启动重蒸同一批历史。
d.removeRawRecords(toDistill[i:end])
} else {
// 蒸馏失败:记录写回待处理队列,下次 tick 重试
d.mu.Lock()
d.records = append(toDistill[i:end], d.records...)
d.mu.Unlock()
}
}
d.cleanupRawFiles()
if distilled > 0 {
log.Printf("[memory] distilled %d records", distilled)
}
}
// distillSplitTimeout 是单次小模型拆分调用的上限。
//
// 实测单条 4-9s(qwen3:1.7b + schema 约束,CPU)。超时不能太短,
// 否则每条都超时 = splitter 恒失败 = 静默退化成 0 产出的 jieba 路;
// 也不能太长,否则一批 50 条会把 30 分钟的蒸馏间隔吃穿。
const distillSplitTimeout = 90 * time.Second
// distillBatch 蒸馏一批记录,全部成功返回 true,任一失败返回 false(调用方重试)
func (d *Distiller) distillBatch(batch []RawRecord) bool {
var userContent, assistantContent string
sessionIDs := make(map[string]bool)
for _, r := range batch {
sessionIDs[r.SessionID] = true
if r.Role == "user" {
userContent += r.Content + " "
} else {
assistantContent += r.Content + " "
}
}
// 块路径优先(正确形态),Triple 路保留为对照期回退。
//
// ★ 为什么改(本会话认知纠错):拆解产物的正确落库形态是
//【句子】【contains 边】【块】—— 节点已从纯文本实体升级为带向量的
// memory_blocks(46f833c)。把拆解结果 Commit 成 entities 是把新产出
// 灌进正在退场的旧形态;而旧 jieba 路实测产出 0 条,保留它只为
//「块路不可用时记忆不丢」的底线。
if d.splitter != nil {
if wb, ok := d.splitter.(BlockSplitter); ok {
if d.writeBlocks(wb, userContent, assistantContent) {
return true
}
// 块路失败(模型错误/超时):不要在这里 return false ——
// 那会让同一批记录无限重试。落回 Triple 路(jieba),
// 失败原因已在 writeBlocks 里记日志。
}
}
triples := d.extractTriples(userContent, assistantContent)
if len(triples) > 0 {
sessionID := ""
for sid := range sessionIDs {
sessionID = sid
break
}
if _, _, err := d.db.Commit(triples, sessionID, 0); err != nil {
log.Printf("[memory] distill commit: %v", err)
return false
}
}
return true
}
// BlockSplitter 是能产出块形态的拆解器(distill.Extractor 实现)。
//
// 与 RecordSplitter 并存的原因:RecordSplitter.Split 返回 Triple(对照期
// 仍被 memoryface 接口使用),块形态经 Blocks 返回。两个方法由同一个
// Extractor 实现,共用同一套闸门,不重复实现。
type BlockSplitter interface {
RecordSplitter
Blocks(ctx context.Context, record string) (*distill.BlockPayload, error)
}
// EmbedFunc 由持有 embedding provider 的一方注入;nil 表示不带向量
// (块仍入库,只是不参与向量召回——不编造零向量)。
type EmbedFunc func(text string) (vec []float64, fingerprint string)
// writeBlocks 逐条拆解并落块。全部成功返回 true;任一条失败记日志并
// 返回 false(调用方落回 Triple 路),已成功的块保留(幂等 ID 保证
// 重试不会产生重复)。
func (d *Distiller) writeBlocks(bs BlockSplitter, userContent, assistantContent string) bool {
text := userContent
if assistantContent != "" {
text += " " + assistantContent
}
// 按句切分:块的语义单位是句子,整段混合会让 contains 边失去指向。
for _, sent := range splitSentences(text) {
sent = strings.TrimSpace(sent)
if len([]rune(sent)) < 4 {
continue
}
ctx, cancel := context.WithTimeout(d.ctx, distillSplitTimeout)
payload, err := bs.Blocks(ctx, sent)
cancel()
if err != nil {
log.Printf("[memory] distill blocks: %v", err)
return false
}
// 用 d.ctx 而非 context.Background():Distiller.Stop() 会 cancel 它,
// 用 Background 会让停机时正在写库的蒸馏循环继续跑。
if _, err := distill.WritePayload(d.ctx, d.db, payload, d.embed); err != nil {
log.Printf("[memory] distill write blocks: %v", err)
return false
}
}
return true
}
func (d *Distiller) cleanupRawFiles() {
entries, err := os.ReadDir(d.rawPath)
if err != nil {
return
}
cutoff := time.Now().AddDate(0, 0, -(d.cfg.RetentionDays + 1))
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
os.Remove(filepath.Join(d.rawPath, entry.Name()))
}
}
}
// writeFileAtomic 写临时文件再 rename,避免进程在写一半时崩溃留下半个文件。
func writeFileAtomic(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// rawKey 唯一标识一条原始记录,用于在 raw 文件里按内容定位并删除。
//
// 为什么按内容而不是 ID:loadExisting 读回记录时会重新分配连续 ID,
// 文件里的 ID 与内存 ID 并不一一对应。
func rawKey(session, role, content string, ts int64) string {
return session + "\x00" + role + "\x00" + content + "\x00" + strconv.FormatInt(ts, 10)
}
// removeRawRecords 从磁盘 raw 文件中删除已成功蒸馏的记录。
//
// 蒸馏成功后记录若只从内存移除、磁盘文件不动,下次启动 loadExisting 会把
// 它们当未蒸馏记录重新读回,导致每次重启都重蒸同一批历史(实体
// mention_count 膨胀,且 distillBatch 的 sessionID 取自 map 首个键,
// 不确定性会放大重复)。
func (d *Distiller) removeRawRecords(batch []RawRecord) {
if len(batch) == 0 {
return
}
drop := make(map[string]bool, len(batch))
for _, r := range batch {
drop[rawKey(r.SessionID, r.Role, r.Content, r.CreatedAt.Unix())] = true
}
entries, err := os.ReadDir(d.rawPath)
if err != nil {
return
}
for _, entry := range entries {
ext := filepath.Ext(entry.Name())
if ext != ".tsv" && ext != ".jsonl" {
continue
}
path := filepath.Join(d.rawPath, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
continue
}
var kept []string
removed := false
for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
if line == "" {
continue
}
parts := splitLine(line)
if len(parts) >= 5 {
if ts, err := strconv.ParseInt(parts[4], 10, 64); err == nil &&
drop[rawKey(parts[1], parts[2], parts[3], ts)] {
removed = true
continue
}
}
kept = append(kept, line)
}
if !removed {
continue
}
if len(kept) == 0 {
os.Remove(path)
continue
}
if err := writeFileAtomic(path, []byte(strings.Join(kept, "\n")+"\n")); err != nil {
log.Printf("[memory] rewrite raw %s: %v", entry.Name(), err)
}
}
}
func extractKeyTriples(userContent, assistantContent string, embedder nlp.Vectorizer) []memory.Triple {
var triples []memory.Triple
e := nlp.NewExtractor(nil)
if embedder != nil {
e.SetEmbedder(embedder)
}
text := userContent
if assistantContent != "" {
text += assistantContent
}
result := e.Extract(text)
if result != nil {
for _, nt := range result.Triples {
mt := nlp.ToMemoryTriple(nt)
if mt.Subject != "" && mt.Relation != "" && mt.Object != "" {
triples = append(triples, mt)
}
}
}
// 注意:这里**不**做噪音过滤。对话蒸馏的抽取器(与 doc→graph 共用一个
// NLP 提取器)历史上就没有常用词闸门:CutExact 那层当年只挂在 doc→graph 上。
// 而且 pipeline_test 明确断言「我 --读书--> 杭州」必须被抽出(代词作主语是
// 该路的既定行为)。要不要在对话路也拦常用词是行为决策,不在此处擅改,
// 参见 memory.IsNoiseEntity 的说明。
return triples
}
// extractTriples 按「有 splitter 用 splitter,否则回退 jieba」的顺序抽取。
//
// 必须能区分「splitter 跑了但没拆出东西」与「splitter 没跑/失败」:
// - 跑出 0 条 = 正常(这类记录本就无字段可拆,例如纯叙述句)
// - 报错 = 异常,回退 jieba 路并保留记录等下次重试
func (d *Distiller) extractTriples(userContent, assistantContent string) []memory.Triple {
if d.splitter != nil {
text := userContent
if assistantContent != "" {
text += " " + assistantContent
}
ctx, cancel := context.WithTimeout(d.ctx, distillSplitTimeout)
defer cancel()
triples, err := d.splitter.Split(ctx, text)
if err == nil {
return triples
}
// 回退前记一笔:splitter 长期失败会静默退化成 jieba 路的 0 条产出,
// 那种情况从外部看不出来(蒸馏照常「成功」,只是没写进任何东西)。
log.Printf("[memory] splitter failed, falling back to jieba path: %v", err)
}
return extractKeyTriples(userContent, assistantContent, d.embedder)
}
func truncate(s string, max int) string {
if len(s) > max {
return s[:max] + "..."
}
return s
}
func splitLine(line string) []string {
if line == "" {
return nil
}
return strings.SplitN(line, "\t", 5)
}
func (d *Distiller) GetRecentRecords(limit int) []RawRecord {
d.mu.Lock()
defer d.mu.Unlock()
n := len(d.records)
if n == 0 {
return nil
}
if limit > 0 && limit < n {
n = limit
}
result := make([]RawRecord, n)
copy(result, d.records[len(d.records)-n:])
return result
}
func (d *Distiller) Stats() map[string]interface{} {
d.mu.Lock()
defer d.mu.Unlock()
return map[string]interface{}{
"raw_records": len(d.records),
"interval": d.cfg.Interval.String(),
"retention_days": d.cfg.RetentionDays,
}
}
// splitSentences 按中英文句读切分文本。
//
// 蒸馏的块语义单位是句子;这里只做粗切(。!?;\n),不做 NLP 级
// 句法分析 —— 粗切足够给块提供「同一场对话里的一个片段」边界。
func splitSentences(text string) []string {
return strings.FieldsFunc(text, func(r rune) bool {
return r == '。' || r == '!' || r == '?' || r == ';' ||
r == '\n' || r == '?' || r == '!'
})
}