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

@ -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))
}
}