feat(memory): L3 图库媒体反查 + 修 L2→L3 引用泄漏

媒体记忆四层收尾。方案 A:只做引用,不建媒体实体节点。

## 为何不把媒体建成图库实体

图库里的实体与关系全部来自**描述文本**的 NLP 提取——描述经
mediaSummaryForEvent 进 L0 事件的 Input,随归档进 L2 文档的 Content,
蒸馏时提取器自然从描述文字里抽出实体和关系。检索能力已经具备。

若再把媒体本身建成节点,节点名只能从描述里取,而描述会被重新生成
(换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下
多个语义模糊的节点。代价换不来能力。

所以这一层只做一件事:**反查**。图库句子写着「[image a1b2c3d4e5f6]
一张紫蓝红三色带图」,要能从这条句子取回那份字节。

## CommitWithMedia:新增方法而非改签名

Commit 有 10 个非测试调用点 + 21 个测试调用点。为一个多数调用方都不需要
的返回值改全部签名不划算。新增 CommitWithMedia 返回
map[句子文本]sentences.id,Commit 内部转调同一份落库逻辑。

## digest 靠正则从文本反解

三元组由 NLP 提取器从纯文本产出(nlp.ToMemoryTriple 只填 Subject/
Relation/Object/Confidence/SentenceText),提取链路上没有任何位置能塞进
结构化的 digest。要贯通就得改 internal/nlp 的整条数据流。而媒体标记本身
是我们自己按固定格式写进文本的,反解是最省的可靠做法。

配套加 media.ResolvePrefix:文本里是 12 位短 digest(完整 64 位会把一行
撑爆且无助人眼辨认),media_refs 主键要完整 digest。

**前缀歧义视为错误而非"取第一个"**:挂错引用会让 GC 删掉仍被引用的内容。
完整但不存在的 digest 也报错,否则调用方会挂一条孤儿引用。

## 顺带修掉 L2→L3 的引用泄漏

这是上一层(f855893)留下的缺口:我当时只处理了 L0→L2 的引用转移,
漏了 L2→L3 这一跳。archiveColdDocs 调 docStore.Remove(doc.ID) 时不注销
媒体引用——文档一旦消失就再没有任何东西能告诉我们它引用过哪些 digest,
media_refs 里那条记录永久悬空、引用计数永不归零,对应 blob 永远不会被
GC 回收。

新增 releaseDocMedia。L2→L3 这一跳是**释放**而非转移,因为图库存的是从
描述文本抽出的实体与关系,不再持有字节;媒体此时已完成使命。

顺序有讲究:必须在 commitTriplesWithMedia 之后释放。那一步已把引用挂到
graph_sentence owner 上,先销后挂会让引用计数瞬时归零,此时若后台 GC
正在跑就会把内容当孤儿清掉。

## 顺带修 Pending 的排除逻辑遗漏(承上一提交)

## 测试

graphmedia_test.go 11 例。核心是 TestBindSentenceMedia_RoundTrip:
写入 → 提交 → 从句子 id 反查 digest → 取回字节逐字节比对 → 跑 GC(0)
确认被引用的内容不被清。

其余覆盖:正则不误命中普通方括号([注意]/[TODO] 不能当 digest,否则会拿
假前缀去 ResolvePrefix)、无法补全的 digest 不挂引用、媒体关闭时全链路
静默 no-op、releaseDocMedia 释放后 GC 真能回收、200 个样本的前缀补全
要么唯一命中要么明确报歧义。

TestCommit_StillWorksAfterRefactor 记录一个既有行为:重复提交时
entitiesCreated 不归零,因为 SQLite 的 ON CONFLICT DO UPDATE 也算一行
affected。用 main 分支的 graph.go 单独跑过基线确认与本次重构无关,
该字段只用于日志,故记录现状不改行为。

全仓 go build / go vet / go test 通过,internal/agent/core 与
internal/memory 全部 -race -count=2 通过,SDK 冻结 diff = 0。
This commit is contained in:
JianFeeeee
2026-09-04 22:52:30 +08:00
parent 74a24f93d7
commit 28fc833a6f
6 changed files with 667 additions and 24 deletions

View File

@ -37,13 +37,13 @@ type Relation struct {
}
type Triple struct {
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
Confidence float64 `json:"confidence,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
ObjectType string `json:"object_type,omitempty"`
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本Commit时写入sentences表
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
Confidence float64 `json:"confidence,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
ObjectType string `json:"object_type,omitempty"`
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本Commit时写入sentences表
}
type GraphDB struct {
@ -192,13 +192,37 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error {
return nil
}
// Commit 把三元组写入图库,返回新建的实体数与关系数。
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
_, ec, rc, err := g.commit(triples, sessionID, turnID, false)
return ec, rc, err
}
// CommitWithMedia 与 Commit 相同,但额外返回每条句子文本对应的 sentences.id。
//
// 为何单独开一个方法而不改 Commit 的签名Commit 有十个非测试调用点
// 加二十多个测试调用点,为了一个多数调用方都不需要的返回值去改全部签名
// 不划算。这里让 Commit 内部转调,两者共享同一份落库逻辑。
//
// 返回的 map 只包含本次真正写入了 sentences 表的句子。调用方据此把媒体
// 引用挂到 graph_sentence owner 上——句子是媒体描述在图库里的落点,
// 关系行本身不持有媒体。
func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]int64, int, int, error) {
return g.commit(triples, sessionID, turnID, true)
}
func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSentences bool) (map[string]int64, int, int, error) {
g.mu.Lock()
defer g.mu.Unlock()
var sentenceIDs map[string]int64
if trackSentences {
sentenceIDs = make(map[string]int64)
}
tx, err := g.db.Begin()
if err != nil {
return 0, 0, err
return nil, 0, 0, err
}
defer tx.Rollback()
@ -229,24 +253,24 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
ec, err := g.upsertEntity(tx, t.Subject, subjType)
if err != nil {
return 0, 0, err
return nil, 0, 0, err
}
entitiesCreated += ec
ec, err = g.upsertEntity(tx, t.Object, objType)
if err != nil {
return 0, 0, err
return nil, 0, 0, err
}
entitiesCreated += ec
var sourceID, targetID int64
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID)
if err != nil {
return 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err)
return nil, 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err)
}
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID)
if err != nil {
return 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
return nil, 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
}
// 写入/查找句子
@ -255,11 +279,13 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
_, err = tx.Exec(
`INSERT OR IGNORE INTO sentences (text) VALUES (?)`, t.SentenceText)
if err != nil {
return 0, 0, fmt.Errorf("insert sentence: %w", err)
return nil, 0, 0, fmt.Errorf("insert sentence: %w", err)
}
err = tx.QueryRow("SELECT id FROM sentences WHERE text = ?", t.SentenceText).Scan(&sentenceID)
if err != nil {
sentenceID = 0
} else if sentenceIDs != nil {
sentenceIDs[t.SentenceText] = sentenceID
}
}
@ -275,11 +301,11 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID,
)
if err != nil {
return 0, 0, err
return nil, 0, 0, err
}
relationsCreated++
} else if err != nil {
return 0, 0, err
return nil, 0, 0, err
} else {
// 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数
_, err = tx.Exec(
@ -288,16 +314,16 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
confidence, sourceID, targetID, t.Relation, sessionID,
)
if err != nil {
return 0, 0, err
return nil, 0, 0, err
}
}
}
if err := tx.Commit(); err != nil {
return 0, 0, err
return nil, 0, 0, err
}
return entitiesCreated, relationsCreated, nil
return sentenceIDs, entitiesCreated, relationsCreated, nil
}
func validEntityName(name string) bool {

View File

@ -46,6 +46,9 @@ const (
OwnerGraphSentence = "graph_sentence"
)
// digestHexLen 是 sha256 的十六进制串长度。
const digestHexLen = sha256.Size * 2
// Kind 是媒体大类。刻意只分三类而不细分具体格式:
// 记忆检索关心的是“这是张图还是段音频”,具体编码交给 MIME 字段。
type Kind string
@ -722,3 +725,56 @@ func truncate(s string, n int) string {
}
return s[:n] + "..."
}
// ResolvePrefix 把 digest 前缀补全为完整 digest。
//
// 日志、事件摘要与图库句子里出现的都是 shortDigest前 12 位),
// 因为完整的 64 位 sha256 会把一行文字撑爆、也无助于人眼辨认。
// 反查时需要这个补全,否则那些短标记只能看不能用。
//
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容,
// 宁可这次绑定失败。12 位十六进制的碰撞概率极低,真撞上说明该用更长前缀。
func (s *Store) ResolvePrefix(prefix string) (string, error) {
prefix = strings.ToLower(strings.TrimSpace(prefix))
if len(prefix) < 8 {
return "", fmt.Errorf("digest 前缀过短(至少 8 位): %q", prefix)
}
if len(prefix) == digestHexLen {
// 已是完整 digest仍要确认存在否则调用方会挂一条孤儿引用
if _, err := s.Stat(prefix); err != nil {
return "", err
}
return prefix, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(
`SELECT digest FROM media WHERE digest LIKE ? || '%' LIMIT 2`, prefix)
if err != nil {
return "", err
}
defer rows.Close()
var found []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err != nil {
return "", err
}
found = append(found, d)
}
if err := rows.Err(); err != nil {
return "", err
}
switch len(found) {
case 0:
return "", fmt.Errorf("digest 前缀 %q 未匹配到媒体", prefix)
case 1:
return found[0], nil
default:
return "", fmt.Errorf("digest 前缀 %q 有歧义(至少匹配 %s 和 %s",
prefix, found[0][:16], found[1][:16])
}
}