feat(memory): 媒体 GC 与描述生成两条后台循环

补齐媒体记忆的最后两块:容量上限真正生效,描述文本成为持久语义记忆。

## mediaGCLoop:让容量上限不再形同虚设

CAS 的 GC 只在被显式调用时执行,Put 路径不触发它。此前配置项
core.memory.media.max_mb 注册了却没有任何调用方——一次 see_video 抽 10 帧,
帧本身在工具结果被 Prune 后就没人引用了,若无人清理会一直堆在磁盘上。

现在按 gc_interval(默认 6h)周期调 GC(gc_min_age)。两个不变量:
  - 有引用的内容永不删除,即使超容量(宁可超限也不断引用)
  - gc_min_age(默认 1h)保护刚 Put 还没来得及 AddRef 的项——它们
    refcount 也是 0

## mediaDescribeLoop:描述才是能活过 GC 的那部分

blob 会被容量 GC 淘汰,而描述留在 media 表里,并经 mediaSummaryForEvent
写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。于是「那张紫蓝红三色
带图」在原始字节早已被清掉之后仍然可被检索到。

复用既有的视觉回退链(resolveModalFallback + chatModalFallbackBatch),
不新造一套模型调用。

三个刻意的决定:

  - **走后台而非入库时同步**:视觉模型一次调用生产实测 9.6s。放在对话
    路径上会让每张图都给回复加十几秒,而描述的价值是几个月后还能检索到,
    不是这一轮——这一轮模型本来就直接看着图。
  - **逐条而非批量**:批量拿回来是一整段文字,无法可靠切分回各自的
    digest(模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次
    往返也要保证「描述 ↔ digest」的对应关系确定。
  - **默认关闭**(describe_on_ingest=false):它消耗视觉模型配额。开启后
    每 30s 最多处理 4 条,不跟对话抢额度。

失败处理分三类:
  - 网络抖动/配额 → 不标记,下轮重试
  - 空回复 → 视作失败(上游剥离媒体时通常回空,与 modalfallback 同理)
  - 不可描述(kind=other、blob 已丢失)→ 标记 described_by=unsupported/
    content-missing,退出队列

## 顺带修掉 Pending 的一个真缺陷

测试写出来才发现:Pending 原先只看 `description = ''`,于是被标记为
described_by=unsupported 但 description 仍空的项**每轮都会被重新取出来
重试**,永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
改为同时要求 described_by 也为空。

这是「先写断言再看它是否成立」抓到的——原本我以为标记一下就够了。

## 测试

medialoop_test.go 7 例:两条循环在禁用时立即返回(nil store / 零间隔 /
describe 关闭三种形态,不留空转 goroutine)、GC 清孤儿保留有引用项、
minAge 保护新项、无可用源时不误标记、不可描述大类被标记后退出队列。
media_test.go 补 1 例专测 Pending 的排除逻辑。

全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。
This commit is contained in:
JianFeeeee
2026-09-04 22:00:03 +08:00
parent 2879e76883
commit 74a24f93d7
7 changed files with 422 additions and 1 deletions

View File

@ -55,6 +55,12 @@ type Agent struct {
// L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过——
// 它是记忆增强而非对话必需品,缺了不该让对话失败。
mediaStore *media.Store
// mediaGCInterval 为 0 时不跑 GC 循环(容量上限就仅在手动调 GC 时生效)。
mediaGCInterval time.Duration
// mediaGCMinAge 保护新入库媒体:刚 Put 还没来得及 AddRef 的项引用计数也是 0。
mediaGCMinAge time.Duration
// mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。
mediaDescribe bool
// 人格设定
personality *agentPkg.Personality
@ -165,6 +171,9 @@ type AgentConfig struct {
SocialStore *social.SocialStore
TextMemory *text.Memory
MediaStore *media.Store
MediaGCInterval time.Duration
MediaGCMinAge time.Duration
MediaDescribe bool
Personality *agentPkg.Personality
PluginReg *plugin.Registry
PluginDir string
@ -242,6 +251,9 @@ func New(cfg AgentConfig) *Agent {
social: cfg.SocialStore,
textMem: cfg.TextMemory,
mediaStore: cfg.MediaStore,
mediaGCInterval: cfg.MediaGCInterval,
mediaGCMinAge: cfg.MediaGCMinAge,
mediaDescribe: cfg.MediaDescribe,
personality: cfg.Personality,
pluginReg: cfg.PluginReg,
pluginDir: cfg.PluginDir,
@ -276,6 +288,8 @@ func (a *Agent) Start() {
go a.archiveLoop()
go a.mergeLoop()
go a.reviewLoop()
go a.mediaGCLoop()
go a.mediaDescribeLoop()
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
}

View File

@ -0,0 +1,183 @@
package core
import (
"log"
"runtime/debug"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// 媒体记忆的两条后台循环。
//
// mediaGCLoop 清理无人引用的 blob让容量上限真正生效。
// mediaDescribeLoop 给未描述的媒体生成文字描述(方案 C 的另一半)。
//
// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s
// see_video 6 帧批量 23s。放在对话路径上会让每张图都给回复加十几秒
// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话——
// 这一轮模型本来就直接看着图。
const (
// mediaDescribeBatch 是单轮描述的媒体条数上限。
//
// 取 4既有回退链的 modalFallbackMaxBlocks 是 6一次请求最多带 6 个媒体),
// 这里留出余量,且每条单独请求以便逐条落库——批量描述拿回来一整段文字
// 无法可靠切分回各自的 digest。
mediaDescribeBatch = 4
// mediaDescribeMinInterval 是两轮描述之间的最小间隔。
//
// 描述是纯后台的锦上添花,不该跟对话抢视觉模型配额。取 30s 让它
// 慢慢消化积压,而不是一上线就把几百条历史媒体全打过去。
mediaDescribeMinInterval = 30 * time.Second
)
// mediaGCLoop 周期清理无引用的媒体内容。
//
// 不做这件事的后果容量上限形同虚设。CAS 的 GC 只在被显式调用时执行,
// 而 Put 路径不触发它——一次 see_video 抽 10 帧,帧本身没人引用(工具
// 结果被 Prune 掉之后),若无人清理就会一直堆在磁盘上。
func (a *Agent) mediaGCLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] mediaGCLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.mediaGCLoop()
}
}()
if a.mediaStore == nil || a.mediaGCInterval <= 0 {
return
}
ticker := time.NewTicker(a.mediaGCInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
removed, freed, err := a.mediaStore.GC(a.mediaGCMinAge)
if err != nil {
log.Printf("[media] GC 失败: %v", err)
continue
}
if removed > 0 {
st := a.mediaStore.Stats()
log.Printf("[media] GC 清理 %d 条(释放 %d 字节),剩余 %v 条 / %v 字节",
removed, freed, st["count"], st["total_bytes"])
}
case <-a.ctx.Done():
return
}
}
}
// mediaDescribeLoop 给未描述的媒体补文字描述。
//
// 描述文本才是持久语义记忆blob 会被容量 GC 淘汰,而描述留在 media 表里,
// 并经 mediaSummaryForEvent 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。
// 于是「那张紫蓝红三色带图」在原始字节早已被清掉之后仍然可被检索到。
func (a *Agent) mediaDescribeLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] mediaDescribeLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.mediaDescribeLoop()
}
}()
if a.mediaStore == nil || !a.mediaDescribe {
return
}
ticker := time.NewTicker(mediaDescribeMinInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.describePendingMedia()
case <-a.ctx.Done():
return
}
}
}
// describePendingMedia 取一批未描述的媒体逐条描述。
//
// 逐条而非批量:批量拿回来是一整段文字,无法可靠切分回各自的 digest
// (模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次往返
// 也要保证「描述 ↔ digest」的对应关系是确定的。
func (a *Agent) describePendingMedia() {
pending, err := a.mediaStore.Pending(mediaDescribeBatch)
if err != nil {
log.Printf("[media] 取待描述项失败: %v", err)
return
}
if len(pending) == 0 {
return
}
for _, it := range pending {
select {
case <-a.ctx.Done():
return
default:
}
kind := "image"
if it.Kind == media.KindAudio {
kind = "audio"
} else if it.Kind != media.KindImage {
// 视频帧以 image 入库;其余大类没有可用的描述通道,
// 标记成"不可描述"以免每轮都被 Pending 取出来重试。
if err := a.mediaStore.Describe(it.Digest, "", "unsupported"); err != nil {
log.Printf("[media] 标记不可描述失败 %s: %v", shortDigest(it.Digest), err)
}
continue
}
p, srcName := a.resolveModalFallback(kind)
if p == nil {
// 没有声明该模态能力的源——这一轮整体跳过,不逐条重试。
// 配置好之后自然会被下一轮捡起来。
log.Printf("[media] 无可用的 %s 描述源,跳过本轮(%d 条待描述)", kind, len(pending))
return
}
data, err := a.mediaStore.Get(it.Digest)
if err != nil {
// blob 已被 GC 清掉但元数据还在GC 会同删,此处属异常路径):
// 标记一下避免死循环。
log.Printf("[media] 读内容失败 %s: %v", shortDigest(it.Digest), err)
if e := a.mediaStore.Describe(it.Digest, "", "content-missing"); e != nil {
log.Printf("[media] 标记内容缺失失败 %s: %v", shortDigest(it.Digest), e)
}
continue
}
mime := it.MIME
if mime == "" {
mime = "image/png"
}
url := media.DataURL(mime, data)
desc, err := a.chatModalFallbackBatch(p, kind, []string{url}, []string{"high"})
if err != nil {
// 失败不标记:可能是网络抖动或配额,下一轮该重试。
log.Printf("[media] 描述失败 %s (源=%s): %v", shortDigest(it.Digest), srcName, err)
continue
}
if desc == "" {
// 空回复通常意味着上游把媒体剥离了——与 modalfallback 里的判断
// 同一个道理,视作失败而非"没什么可说的"。
log.Printf("[media] 描述为空 %s (源=%s),视作失败", shortDigest(it.Digest), srcName)
continue
}
if err := a.mediaStore.Describe(it.Digest, desc, srcName); err != nil {
log.Printf("[media] 写描述失败 %s: %v", shortDigest(it.Digest), err)
continue
}
log.Printf("[media] 已描述 %s (%s, %d 字, 源=%s)", shortDigest(it.Digest), kind, len([]rune(desc)), srcName)
}
}

View File

@ -0,0 +1,176 @@
package core
import (
"context"
"path/filepath"
"testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// 媒体后台循环测试。
//
// 两条循环都要能在「未启用」时干净退出——它们随 Agent.Start() 无条件启动,
// 若不早退就会在每个没配媒体存储的部署上空转一个 goroutine。
func newMediaLoopAgent(t *testing.T, gcInterval, minAge time.Duration, describe bool) (*Agent, *media.Store) {
t.Helper()
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"), 0)
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
a := &Agent{
mediaStore: ms,
mediaGCInterval: gcInterval,
mediaGCMinAge: minAge,
mediaDescribe: describe,
}
a.ctx, a.cancel = context.WithCancel(context.Background())
t.Cleanup(a.cancel)
return a, ms
}
func TestMediaGCLoop_ExitsWhenDisabled(t *testing.T) {
// 两种禁用形态都必须立刻返回,不留空转 goroutine
// 1. mediaStore 为 nil媒体记忆整体关闭
// 2. gcInterval 为 0显式不自动清理
cases := []struct {
name string
agent *Agent
}{
{"nil store", func() *Agent {
a := &Agent{mediaGCInterval: time.Hour}
a.ctx, a.cancel = context.WithCancel(context.Background())
return a
}()},
{"zero interval", func() *Agent {
dir := t.TempDir()
ms, _ := media.New(filepath.Join(dir, "m"), 0)
t.Cleanup(func() { ms.Close() })
a := &Agent{mediaStore: ms, mediaGCInterval: 0}
a.ctx, a.cancel = context.WithCancel(context.Background())
return a
}()},
}
for _, c := range cases {
done := make(chan struct{})
go func(a *Agent) { a.mediaGCLoop(); close(done) }(c.agent)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("%s: mediaGCLoop 未立即返回(会空转 goroutine", c.name)
}
c.agent.cancel()
}
}
func TestMediaGCLoop_ClearsOrphansKeepsReferenced(t *testing.T) {
a, ms := newMediaLoopAgent(t, 50*time.Millisecond, 0, false)
kept, _ := ms.Put([]byte("referenced"), media.Item{MIME: "image/png"})
if err := ms.AddRef(kept, media.OwnerContext, "evt-1"); err != nil {
t.Fatal(err)
}
orphan, _ := ms.Put([]byte("orphaned"), media.Item{MIME: "image/png"})
go a.mediaGCLoop()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, err := ms.Stat(orphan); err != nil {
break // 孤儿已被清
}
time.Sleep(20 * time.Millisecond)
}
a.cancel()
if _, err := ms.Stat(orphan); err == nil {
t.Fatal("无引用项应被 GC 清理")
}
// 关键不变量:有引用的内容永不被删,否则记忆里的 digest 成悬空指针
if _, err := ms.Get(kept); err != nil {
t.Fatalf("被引用的内容不该被清: %v", err)
}
}
func TestMediaGCLoop_MinAgeProtectsFresh(t *testing.T) {
// minAge 保护刚 Put 还没来得及 AddRef 的项——它们 refcount 也是 0
a, ms := newMediaLoopAgent(t, 30*time.Millisecond, time.Hour, false)
d, _ := ms.Put([]byte("just-arrived"), media.Item{MIME: "image/png"})
go a.mediaGCLoop()
time.Sleep(400 * time.Millisecond) // 足够跑十几轮 GC
a.cancel()
if _, err := ms.Get(d); err != nil {
t.Fatalf("minAge 内的新项不该被清: %v", err)
}
}
func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) {
// describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路)
a, _ := newMediaLoopAgent(t, 0, 0, false)
done := make(chan struct{})
go func() { a.mediaDescribeLoop(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("describe 关闭时 mediaDescribeLoop 未立即返回")
}
}
func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) {
// 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理——
// 配置好之后必须还能被捡起来。
a, ms := newMediaLoopAgent(t, 0, 0, true)
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
// providerManager 为 nil → resolveModalFallback 返回 nil
a.describePendingMedia()
it, err := ms.Stat(d)
if err != nil {
t.Fatal(err)
}
if it.Description != "" || it.DescribedBy != "" {
t.Fatalf("无可用源时不该写描述: %+v", it)
}
pending, _ := ms.Pending(10)
if len(pending) != 1 {
t.Fatalf("项应仍在待描述队列里,实际 %d 条", len(pending))
}
}
func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) {
// video/other 大类没有可用的描述通道,必须标记掉,
// 否则每轮 Pending 都把它取出来重试,永远卡住队列头部。
a, ms := newMediaLoopAgent(t, 0, 0, true)
other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"})
a.describePendingMedia()
it, err := ms.Stat(other)
if err != nil {
t.Fatal(err)
}
if it.DescribedBy != "unsupported" {
t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy)
}
// 标记后必须退出待描述队列,否则每轮都被取出来重试、永久占着
// LIMIT 的名额,真正需要描述的新项永远轮不到。
pending, _ := ms.Pending(10)
if len(pending) != 0 {
t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending))
}
}
func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) {
a, _ := newMediaLoopAgent(t, 0, 0, true)
a.describePendingMedia() // 不该 panic
}

View File

@ -654,6 +654,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg(ConfigDef{Key: "core.memory.media.max_mb", Default: "2048", Type: "int", DisplayName: "媒体容量上限(MB)", Description: "超限时按最后访问时间淘汰无引用的媒体;被记忆引用的内容即使超限也不会删除(宁可超限也不断引用)。描述文本不受此限,淘汰后仍可检索", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期0 表示不自动清理", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0靠这个保护期避免被误删", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。**描述文本才是持久语义记忆**——blob 会被容量 GC 淘汰,描述会随记忆各层一直留存并可检索。代价是消耗视觉模型配额(单张图实测约 10s故默认关闭开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"})
reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"})

View File

@ -438,6 +438,13 @@ func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) {
}
// Pending 返回尚无描述的媒体,供后台描述任务消费。
// Pending 返回尚无描述的媒体,供后台描述任务消费。
//
// 不只看 description 为空,还要求 described_by 也为空。
// 因为“已尝试但无法描述”的项(如 kind=other 的二进制、blob 已丢失)
// 会被标记为 described_by=unsupported/content-missing 而 description 仍为空——
// 若只看 description这些项每轮都会被取出来重试永远卡在队列头部
// 真正需要描述的新项永远轮不到LIMIT 只取前 N 条)。
func (s *Store) Pending(limit int) ([]*Item, error) {
if limit <= 0 {
limit = 10
@ -447,7 +454,8 @@ func (s *Store) Pending(limit int) ([]*Item, error) {
rows, err := s.db.Query(`
SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, ref_count, first_seen, last_seen
FROM media WHERE COALESCE(description,'') = ''
FROM media
WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = ''
ORDER BY last_seen DESC LIMIT ?`, limit)
if err != nil {
return nil, err

View File

@ -475,3 +475,39 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
t.Fatalf("内容应持久化: %v / %q", err, data)
}
}
func TestPending_ExcludesAttemptedButUndescribable(t *testing.T) {
// 「已尝试但无法描述」的项必须退出待描述队列。
//
// 这些项被标记为 described_by=unsupported/content-missing 而 description
// 仍为空。若 Pending 只看 description它们每轮都会被取出来重试、
// 永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
s := newTestStore(t, 0)
fresh, _ := s.Put([]byte("needs-describe"), Item{MIME: "image/png"})
unsupported, _ := s.Put([]byte("cannot-describe"), Item{MIME: "application/octet-stream"})
described, _ := s.Put([]byte("已描述"), Item{MIME: "image/png"})
// 标记「尝试过但不支持」description 空described_by 非空
if err := s.Describe(unsupported, "", "unsupported"); err != nil {
t.Fatal(err)
}
if err := s.Describe(described, "一张图", "visionllm"); err != nil {
t.Fatal(err)
}
pending, err := s.Pending(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 {
var names []string
for _, p := range pending {
names = append(names, shortDigest(p.Digest))
}
t.Fatalf("应只剩 1 条待描述,实际 %d 条: %v", len(pending), names)
}
if pending[0].Digest != fresh {
t.Fatalf("待描述的应是未处理项,实际 %s", shortDigest(pending[0].Digest))
}
}