Files
HomeAgent/internal/agent/core/distill.go
JianFeeeee 387b28ce09 fix(memory): 媒体归档三缺陷——数据丢失、L3 入库不可靠、L3 检索未接线
自动触发链实测(medialive)连续暴露的三个缺陷,全部是「手工调 API 的
单测无法发现」的类型。附带该实测本身。

## 缺陷一:三元组全被拒时仍释放引用并删文档(数据丢失)

archiveColdDocs 只检查 len(triples) > 0 就释放媒体引用、删除文档。
但 Commit 会静默跳过实体名不合法的三元组(validEntityName 要求
2–50 字符),于是「无错但一条也没写进去」真实发生:

  [agent] doc→graph: doc_xxx → 0 entities, 0 relations
  [media] 文档 doc_xxx 入图库,释放 1 个媒体引用(描述已留在图库)
  被记忆引用的内容被 GC 删除了(清 1 条/318 字节)

图库里没有任何句子承载引用,文档也被删,blob 被 GC 回收 → 图片与描述
彻底消失。我在上一层写的注释「Commit 之后引用已挂到 graph_sentence」
是错的:ec=0 rc=0 时它什么也没挂。

修法(用户选定 B+A):
  B. ec==0 && rc==0 时保留文档、跳过归档——归档的实质是「信息从 L2
     搬到 L3」,搬不过去就不该删源,下轮再试。
  A. bindSentenceMedia 返回实际绑定数,commitTriplesWithMedia 透出为
     mediaBound;释放前四路判断(查引用出错→保守不释放/本无引用→无需
     释放/mediaBound==0→保留并记录原因/否则释放)。宁可留一条悬空
     引用(内容还在,可由后续一致性检查清理)也不能丢内容。

反向验证:旧行为下新测试确实 FAIL,报「引用被释放了」+「GC 删掉了本该
保留的内容」;恢复修复后 PASS。

## 缺陷二:媒体入 L3 依赖 NLP 提取器运气(可靠性)

媒体能否进图库,取决于提取器碰巧从描述文本里提出合规三元组。实测 LLM
的 477 字图片描述只产出「水平 -分割-> 成」,obj 仅 1 字被拒 → 整条媒体
记忆进不了图库。表现为「阶段 5 时好时坏」,取决于描述文本。

但媒体自身的 digest / mime / 描述都是确定的,不该受提取器支配。

新增 parseMediaMarkers + mediaTriplesFromText:从文档正文的媒体标记
直接产出确定三元组,先于 NLP 提取。同一份真实文档由 0 entities 0
relations 变为 ec=4 rc=2 且拿到句子 id。

三个设计点:
  - 实体名用「图片 <短digest>」而非描述:描述会被重新生成(换视觉模型、
    补描述),若名字取自描述,同一张图会在图谱上留下多个节点。digest
    不变则名字不变,长度也天然合规。
  - SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能反解
    到 digest——绑定从概率事件变成确定行为。
  - 描述为空时仍产出「类型」三元组:描述是后台异步补的,媒体节点不该
    因为还没描述就不存在于图谱。
  - summarizeForEntity 按 rune 截断而非字节:按字节切会破坏 UTF-8,
    图库里会留下乱码实体名。同时清 Markdown 强调符。

这是过渡方案,用户已定:下个 feature 换多模态嵌入后不再依赖
「描述文本 → 提取三元组 → 图谱节点」这条链路。

## 缺陷三:L3 媒体检索没有任何调用方(接线缺失)

第四层实现的 RecallMediaForSentence / mediaContextForSentences 从未被
调用——媒体能存进 L3、能反查,但 agent 拿不出来。实测第二轮 agent 显式
调了 doc_query,回答「没有找到那张图片的任何记录」。

接两个入口:
  - buildMemoryContext(自动注入,每次 LLM 调用都走)
  - memory_recall 工具结果末尾(显式查询)

关系行只有实体名和关系类型,看不出「这条记忆当时还带了一张图」,
媒体挂在句子上,必须经 关系→句子→media_refs 反查。

一处折返:最初直接用 injected.Relations 取 sentence_id,测试失败。
Indexer.BuildContext 刻意把 Relations 置 nil(自动注入只给实体索引以省
token,细节留给 memory_recall)。改为用命中的实体名再查一次关系,
深度固定 1——媒体是「这条记忆当时带的图」,顺关系网扩散只会带出无关
媒体并挤占 token。

## medialive 自动触发链实测

internal/agent/core/medialive_test.go,medialive build tag,默认
go test 不收录。源/模型/密钥全部由调用方经环境变量显式指定,缺任何一项
Skip 并列出缺哪个——刻意不提供 fallback,猜一个 base_url 可能打到调用者
机器上不相干的服务,而失败会被误报成「媒体记忆有问题」。

  MEDIALIVE_BASE_URL=... MEDIALIVE_API_KEY=... \
  MEDIALIVE_MODEL=... MEDIALIVE_ADAPTER=... \
  go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v

只注入一个 image 事件,之后七个阶段全由生产代码自己触发:CAS 落盘 →
引用绑定 → 描述生成 → L0→L2 转移 → L2→L3 绑定 → GC 保护 → 第二轮召回。
另有阴性对照:不给记忆时不该「记得」,否则阳性用例的通过可能只是模型
猜常见配色。上游不可用时 Skip 而非假 PASS。

真实 claude-opus-5 实测通过:第二轮不给图,agent 答出
「上:紫罗兰色 #8800DD / 中:蓝色 #0055EE / 下:纯红 #EE0000」。

## 测试

graphmedia_test.go 新增 8 例:数据丢失回归(反向验证过)、mediaBound
计数、媒体标记解析、实体名生成、描述截断、确定性三元组必然可入库、
关系→句子映射、L3 检索接线(自动注入与显式查询两路)。

全仓 go build / go vet / go test 通过,internal/agent/core 与
internal/memory 全绿,SDK 冻结 diff = 0。
2026-09-05 12:03:30 +08:00

560 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"fmt"
"log"
"runtime/debug"
"strings"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
)
type ConsolidationTask struct {
Type string `json:"type"`
Reason string `json:"reason"`
Data interface{} `json:"data"`
}
func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
msg := fmt.Sprintf(
"【记忆整理任务】\n类型: %s\n说明: %s\n\n注意\n1. 仅使用 memory_merge 合并实体,或使用 memory_block_merge 标记不合并\n2. 不要使用 memory_commit 写入新的三元组\n3. 不要从这段任务文本中提取任何信息写入图库\n4. 只需要做出合并/不合并的判断并执行对应工具",
task.Type, task.Reason,
)
a.injectSelf(msg)
log.Printf("[agent] enqueued consolidation task: %s", task.Reason)
}
// ──────────────────────────────────────────────
// 四个独立心跳循环,各自拥有独立的 ticker 和配置
// ──────────────────────────────────────────────
// distillLoop 上下文裁剪L1→L2使用 distillInterval
func (a *Agent) distillLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] distillLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.distillLoop()
}
}()
if a.docStore == nil {
return
}
ticker := time.NewTicker(a.distillInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
log.Printf("[agent] heartbeat distill tick")
a.distillContext()
a.autoReloadPlugins()
case <-a.ctx.Done():
return
}
}
}
// archiveLoop 冷文档归档L2→L3使用 archiveInterval
func (a *Agent) archiveLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] archiveLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.archiveLoop()
}
}()
if a.memory == nil {
return
}
ticker := time.NewTicker(a.archiveInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
log.Printf("[agent] heartbeat archive tick")
a.archiveColdDocs()
case <-a.ctx.Done():
return
}
}
}
// mergeLoop 实体合并检测GraphDB → LLM 裁决),使用 mergeInterval
func (a *Agent) mergeLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] mergeLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.mergeLoop()
}
}()
if a.memory == nil {
return
}
ticker := time.NewTicker(a.mergeInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
log.Printf("[agent] heartbeat merge tick")
a.detectEntityMerge()
case <-a.ctx.Done():
return
}
}
}
// reviewLoop 关系复审GraphDB → ClearSentenceID → CleanupOrphanedSentences使用 reviewInterval
func (a *Agent) reviewLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] reviewLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.reviewLoop()
}
}()
if a.memory == nil {
return
}
ticker := time.NewTicker(a.reviewInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
log.Printf("[agent] heartbeat review tick")
a.reviewRelations()
case <-a.ctx.Done():
return
}
}
}
// ──────────────────────────────────────────────
// 蒸馏逻辑
// ──────────────────────────────────────────────
func (a *Agent) distillContext() {
if a.docStore == nil {
return
}
n := a.context.Len()
if n > a.maxContextSize*2 {
archived := a.context.Prune("", a.maxContextSize, a.docStore)
if archived > 0 {
log.Printf("[agent] distill: pruned %d low-relevance events to document memory (total=%d)", archived, n)
}
}
}
// ──────────────────────────────────────────────
// 冷文档归档docStore → GraphDB (L3→L4)
// ──────────────────────────────────────────────
func (a *Agent) archiveColdDocs() {
if a.memory == nil {
return
}
log.Printf("[agent] cold doc archival start")
if a.indexer != nil {
if err := a.indexer.Sync(); err != nil {
log.Printf("[agent] indexer sync error: %v", err)
}
}
if a.docStore != nil {
a.docStore.Reindex()
}
if a.docStore != nil {
coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2)
for _, doc := range coldDocs {
triples := docToTriples(doc, a.embedder)
if len(triples) == 0 {
continue
}
ec, rc, mediaBound, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0)
if err != nil {
log.Printf("[agent] doc→graph archival error: %v", err)
continue
}
// 归档的实质是「信息从 L2 搬到 L3」。一条实体、一条关系都没写进
// 图库时,信息并没有搬过去,此时删文档等于直接丢数据。
//
// 这不是理论情形Commit 会静默跳过实体名不合法的三元组
//validEntityName 要求 250 字符),而 LLM 生成的长描述几乎
// 提不出合规实体名——实测 456 字图片描述得到 0 entities 0
// relations随后文档被删、媒体引用被释放、blob 被 GC 清掉,
// 图片与描述彻底消失。保留文档,下一轮再试。
if ec == 0 && rc == 0 {
log.Printf("[agent] doc→graph: %s 未写入任何实体/关系,保留文档待下轮重试"+
"(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples))
continue
}
log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
// 先销媒体引用再删文档:文档一旦从 docStore 消失,就再没有任何
// 东西能告诉我们它曾经引用过哪些 digestmedia_refs 里那条记录
// 就永久悬空、引用计数永不归零,导致 blob 永远不会被 GC 回收。
//
// 但只有在引用**确实**转移到 graph_sentence 之后才能释放:
// 图库里没有任何句子承载这些 digest 时释放旧引用,计数归零,
// GC 会把内容当孤儿删掉。宁可留一条悬空引用(内容还在,可由
// 后续一致性检查清理),也不能丢内容。
refs, refErr := a.docMediaRefs(doc.ID)
switch {
case refErr != nil:
log.Printf("[media] 查文档 %s 的媒体引用失败,保守不释放: %v", doc.ID, refErr)
case len(refs) == 0:
// 该文档本就没有媒体引用,无需释放。
case mediaBound == 0:
log.Printf("[media] 文档 %s 有 %d 个媒体引用但图库一个都没绑上,"+
"保留引用以免 GC 删除内容(句子正文里可能没有可反解的短 digest",
doc.ID, len(refs))
default:
a.releaseDocMedia(doc.ID)
}
a.docStore.Remove(doc.ID)
}
}
}
// docMediaRefs 返回文档当前持有的媒体引用nil store 时为空)。
//
// 单独取出来是为了让归档路径能在释放前先确认「有没有东西要释放」——
// 没有引用时不必打日志,有引用但没绑上图库时必须保留。
func (a *Agent) docMediaRefs(docID string) ([]string, error) {
if a.mediaStore == nil || docID == "" {
return nil, nil
}
return a.mediaStore.Refs(media.OwnerDocument, docID)
}
// releaseDocMedia 注销文档持有的全部媒体引用。
//
// L2→L3 这一跳不再转移引用而是直接释放,因为图库存的是从描述
// 文本里抽出的实体与关系,不再持有字节。媒体本身此时已完成使命:
// 描述已经进了图库blob 可以交给容量 GC 决定去留。
func (a *Agent) releaseDocMedia(docID string) {
if a.mediaStore == nil || docID == "" {
return
}
n, err := a.mediaStore.DropOwner(media.OwnerDocument, docID)
if err != nil {
log.Printf("[media] 文档归档释放引用失败 (doc %s): %v", docID, err)
return
}
if n > 0 {
log.Printf("[media] 文档 %s 入图库,释放 %d 个媒体引用(描述已留在图库)", docID, n)
}
}
// ──────────────────────────────────────────────
// 实体合并检测GraphDB → LLM 裁决
// ──────────────────────────────────────────────
func (a *Agent) detectEntityMerge() {
if a.memory == nil {
return
}
log.Printf("[agent] entity merge detection start")
result, err := a.memory.Recall(nil, nil, 1, "")
if err != nil || result == nil || len(result.Entities) < 2 {
return
}
llmCandidates := 0
maxCandidates := 5
for i := 0; i < len(result.Entities) && llmCandidates < maxCandidates; i++ {
for j := i + 1; j < len(result.Entities) && llmCandidates < maxCandidates; j++ {
ea, eb := result.Entities[i].Name, result.Entities[j].Name
if ea > eb {
ea, eb = eb, ea
}
key := ea + "||" + eb
// 跳过已标记"不合并"的实体对
a.noMergeMu.Lock()
rounds, ok := a.noMergeMarkers[key]
if ok {
rounds--
if rounds <= 0 {
delete(a.noMergeMarkers, key)
} else {
a.noMergeMarkers[key] = rounds
}
}
a.noMergeMu.Unlock()
if ok {
continue
}
// 复合相似度:字符二元组 + 语义向量(仅增强检测,不做自动合并)
sim := entitySimilarity(result.Entities[i].Name, result.Entities[j].Name)
semSim := entitySemanticSimilarity(result.Entities[i].Name, result.Entities[j].Name, a.embedder)
if semSim > sim {
sim = semSim
}
if sim > 0.75 {
llmCandidates++
a.enqueueConsolidationTask(ConsolidationTask{
Type: "entity_merge",
Reason: fmt.Sprintf(
"实体「%s」(类型:%s, 提及%d次) 与「%s」(类型:%s, 提及%d次) 相似度 %.0f%%,可能指代同一事物,请判断是否需要合并",
result.Entities[i].Name, result.Entities[i].Type, result.Entities[i].MentionCount,
result.Entities[j].Name, result.Entities[j].Type, result.Entities[j].MentionCount,
sim*100,
),
Data: map[string]interface{}{
"entity_a": result.Entities[i].Name,
"entity_a_type": result.Entities[i].Type,
"entity_a_mentions": result.Entities[i].MentionCount,
"entity_b": result.Entities[j].Name,
"entity_b_type": result.Entities[j].Type,
"entity_b_mentions": result.Entities[j].MentionCount,
"similarity": sim,
},
})
}
}
}
if llmCandidates > 0 {
log.Printf("[agent] entity merge: %d merge candidates sent for LLM decision", llmCandidates)
} else {
log.Printf("[agent] entity merge: no similar entities found")
}
}
// ──────────────────────────────────────────────
// 关系复审GraphDB → ClearSentenceID → CleanupOrphanedSentences
// ──────────────────────────────────────────────
func (a *Agent) reviewRelations() {
if a.memory == nil {
return
}
log.Printf("[agent] relation review start")
reviewCount := 0
const maxReviewBatch = 5
relResult, err := a.memory.Recall(nil, nil, 1, "")
if err != nil || relResult == nil {
return
}
for _, rel := range relResult.Relations {
if reviewCount >= maxReviewBatch {
break
}
if rel.SentenceID == 0 || rel.SentenceText == "" {
continue
}
a.enqueueConsolidationTask(ConsolidationTask{
Type: "relation_review",
Reason: fmt.Sprintf(
"【关系复审】原始句子: '%s'\n当前三元组: (%s → %s → %s) 置信度 %.2f\n请判断是否需要修正如相对引用未解析、主宾颠倒、噪音三元组等如需修正请用 memory_edit 工具",
rel.SentenceText, rel.SourceName, rel.RelationType, rel.TargetName, rel.Confidence,
),
Data: map[string]interface{}{
"relation_id": rel.ID,
"source": rel.SourceName,
"relation_type": rel.RelationType,
"target": rel.TargetName,
"confidence": rel.Confidence,
"sentence": rel.SentenceText,
},
})
// 清除句子引用(复审后解除关联)
if err := a.memory.ClearSentenceID(rel.ID); err != nil {
log.Printf("[agent] clear sentence_id for relation %d: %v", rel.ID, err)
}
reviewCount++
}
if reviewCount > 0 {
// 清理无引用的句子
if deleted, err := a.memory.CleanupOrphanedSentences(); err != nil {
log.Printf("[agent] cleanup orphaned sentences: %v", err)
} else if deleted > 0 {
log.Printf("[agent] cleanup %d orphaned sentences", deleted)
}
log.Printf("[agent] relation review: %d relations sent for review", reviewCount)
}
}
// entitySemanticSimilarity 使用词嵌入向量余弦相似度计算实体名语义相似度
func entitySemanticSimilarity(a, b string, embedder *memory.StaticEmbedder) float64 {
if a == "" || b == "" || embedder == nil || !embedder.Loaded() {
return 0
}
va := embedder.Vectorize(a)
vb := embedder.Vectorize(b)
if len(va) == 0 || len(vb) == 0 {
return 0
}
return vector.CosineSimilarity(va, vb)
}
func entitySimilarity(a, b string) float64 {
if a == "" || b == "" {
return 0
}
if a == b {
return 1.0
}
runesA, runesB := []rune(a), []rune(b)
if len(runesA) < 2 || len(runesB) < 2 {
if len(runesA) == len(runesB) && len(runesA) == 1 {
if runesA[0] == runesB[0] {
return 1.0
}
}
return 0
}
setA := make(map[string]bool)
for i := 0; i < len(runesA)-1; i++ {
setA[string(runesA[i:i+2])] = true
}
setB := make(map[string]bool)
for i := 0; i < len(runesB)-1; i++ {
setB[string(runesB[i:i+2])] = true
}
intersect := 0
for bg := range setA {
if setB[bg] {
intersect++
}
}
union := len(setA) + len(setB) - intersect
if union <= 0 {
return 0
}
return float64(intersect) / float64(union)
}
func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
var triples []memory.Triple
if doc == nil {
return triples
}
if doc.Source == "graph" || doc.Source == "" {
return nil
}
isArchivedContext := doc.Meta != nil && doc.Meta["is_archived_context"] == "true"
// 文档元数据:仅当 summary 合理(非空、非模板化、长度适中)时才写「主题」
if !isArchivedContext && doc.Summary != "" && len([]rune(doc.Summary)) < 80 && !isTemplateSummary(doc.Summary) {
triples = append(triples, memory.Triple{
Subject: "文档",
SubjectType: "Concept",
Relation: "主题",
Object: doc.Summary,
ObjectType: "Topic",
Confidence: 1.0,
})
}
// 媒体三元组:确定性产出,先于 NLP 提取。
//
// 媒体入 L3 曾完全依赖提取器碰巧从描述文本里提出合规三元组——实测
// LLM 的 477 字图片描述只产出「水平 -分割-> 成」这类语法碎片,
// obj 仅 1 字被 validEntityName 拒掉,整条媒体记忆就进不了图库
//(阶段性表现是"时好时坏",取决于提取器运气)。媒体自身的
// digest / mime / 描述都是确定的,直接建三元组而不经提取器。
triples = append(triples, mediaTriplesFromText(doc.Content)...)
// NLP 通用提取
e := nlp.NewExtractor(nil)
if embedder != nil {
e.SetEmbedder(embedder)
}
result := e.Extract(doc.Content)
if result != nil {
for _, nt := range result.Triples {
mt := nlp.ToMemoryTriple(nt)
if mt.Subject != "" && mt.Relation != "" && mt.Object != "" {
triples = append(triples, mt)
}
}
}
// 仅当来源非归档上下文且非空时写「来源」——归档文档写死模板三元组属于垃圾
if doc.Source != "" && doc.Source != "context_archived" {
triples = append(triples, memory.Triple{
Subject: "文档",
SubjectType: "Concept",
Relation: "来源",
Object: doc.Source,
ObjectType: "Source",
Confidence: 1.0,
})
}
return triples
}
// isTemplateSummary 识别 summarizeEntries 生成的模板化摘要
// (形如「来自 N 个来源的 M 条对话 (src1, src2) 涉及: kw1, kw2」
// 这类摘要无独立信息量,不应作为「主题」实体写入图库。
func isTemplateSummary(s string) bool {
if s == "" {
return true
}
return strings.HasPrefix(s, "来自 ") && strings.Contains(s, "条对话")
}
func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) {
a.io.EmitOutput("memory", "memory_candidate", map[string]interface{}{
"source": source,
"input": input,
"response": response,
"tool_results": toolResults,
"tools_used": toolsUsed,
"agent_id": string(a.id),
"timestamp": time.Now().Unix(),
})
}
func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
start := time.Now()
a.currentOutputChannel = "_consolidation_"
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
stageCtx.Extra["output_channel"] = evt.OutputChannel
a.injectSourceContext(stageCtx, evt)
_, toolsUsed, _, err := a.process(input, stageCtx)
if err != nil {
log.Printf("[agent] consolidation error: %v", err)
return
}
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
}