mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +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。
184 lines
6.0 KiB
Go
184 lines
6.0 KiB
Go
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)
|
||
}
|
||
}
|