mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
补齐媒体记忆的最后两块:容量上限真正生效,描述文本成为持久语义记忆。
## 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。
177 lines
5.3 KiB
Go
177 lines
5.3 KiB
Go
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
|
||
}
|