fix: 修复记忆系统自循环与计算层污染

- 删 syncGraphToDocs(): Graph 快照不再写入 Document,避免污染向量索引和三层隔离
- 删 toolCallRing(): 已被工具 NoMemory/Cleaner 机制取代,不再需要独立环形缓冲
- 加 toolOutputClean 回调线程 Prune→ContextToDoc: 归档时按 NoMemory 跳过、Cleaner 清洗后再过 jieba,原文保留
- 加 eval_status 持久化 (RecallPending/UpdateEvalStatus/ResolveEvaluating): 避免重复 LLM 评估
This commit is contained in:
root
2026-07-26 19:29:01 +08:00
parent db4844c6c6
commit d19b7bd13e
7 changed files with 373 additions and 280 deletions

View File

@ -105,20 +105,6 @@ type Agent struct {
noMergeMarkers map[string]int
noMergeMu sync.Mutex
// toolCallRing 保护最近 40 条工具调用记录不被上下文淘汰,
// 确保 LLM 不会重复调用同一工具、反复查询同一数据。
toolCallRing []ToolCallRecord
toolCallRingMax int
toolCallRingMu sync.Mutex // 独立的锁,不与 a.mu 混用避免死锁
}
// ToolCallRecord 记录一次工具调用,保留元数据供后续 LLM 回合参考。
type ToolCallRecord struct {
Timestamp time.Time `json:"timestamp"`
Name string `json:"name"`
Args string `json:"args,omitempty"` // 参数摘要(最多 200 字符)
ResultStub string `json:"result_stub"` // 结果摘要(具体内容通过文本记忆层获取)
FullResult string `json:"result_full,omitempty"` // 完整结果(仅保留最近 5 条,其余仅存 stub
}
type AgentConfig struct {
@ -202,8 +188,7 @@ func New(cfg AgentConfig) *Agent {
thinkingEnabled: cfg.ThinkingEnabled,
inputCfg: cfg.InputProcessing,
noMergeMarkers: make(map[string]int),
toolCallRing: make([]ToolCallRecord, 0, 40),
toolCallRingMax: 40,
}
}

View File

@ -111,6 +111,25 @@ func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolD
return memory.CleanText(text)
}
// toolOutputClean 根据工具定义的 NoMemory/Cleaner 清洗输出,用于计算层。
// 返回 "" 表示跳过NoMemory否则返回清洗后文本Cleaner 或原文)。
func (c *RelevanceContext) toolOutputClean(name, output string) string {
if c.toolDefLookup == nil {
return output
}
def := c.toolDefLookup(name)
if def == nil {
return output
}
if def.NoMemory {
return ""
}
if def.Cleaner != nil {
return def.Cleaner(output)
}
return output
}
func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector {
return c.embedder.Vectorize(textForVector(evt, c.toolDefLookup))
}
@ -255,7 +274,7 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
ToolResults: convertToolResults(s.event.ToolResults),
}
}
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder)
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean)
if err == nil && doc != nil {
archived = len(entries)
}

View File

@ -46,7 +46,6 @@ func (a *Agent) distillLoop() {
case <-ticker.C:
log.Printf("[agent] heartbeat distill tick")
a.distillContext()
a.syncGraphToDocs()
a.reorgGraph()
a.autoReloadPlugins()
case <-a.ctx.Done():
@ -68,74 +67,7 @@ func (a *Agent) distillContext() {
}
}
func (a *Agent) syncGraphToDocs() {
if a.memory == nil || a.docStore == nil {
return
}
stats, err := a.memory.Introspect()
if err != nil {
return
}
entityCount, _ := stats["entity_count"].(int)
if entityCount == 0 {
return
}
result, err := a.memory.Recall(nil, nil, 1, "")
if err != nil || result == nil {
return
}
if len(result.Entities) == 0 && len(result.Relations) == 0 {
return
}
var summaryParts []string
summaryParts = append(summaryParts, fmt.Sprintf("图记忆快照: %d 个热点实体", len(result.Entities)))
for _, e := range result.Entities {
summaryParts = append(summaryParts, fmt.Sprintf("- %s (%s, %d次)", e.Name, e.Type, e.MentionCount))
}
if len(result.Relations) > 0 {
summaryParts = append(summaryParts, "关联关系:")
for i, r := range result.Relations {
if i >= 10 {
break
}
summaryParts = append(summaryParts, fmt.Sprintf(" %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName))
}
}
summary := fmt.Sprintf("图记忆索引 (%d 实体, %d 关系)", len(result.Entities), len(result.Relations))
content := strings.Join(summaryParts, "\n")
recent := a.docStore.RecentDocs(1)
if len(recent) > 0 && recent[0].Source == "graph" && recent[0].Content == content {
return
}
doc := &document.Doc{
Summary: summary,
Content: content,
Tags: []string{"graph_memory", "auto_sync"},
Entities: extractEntityNames(result.Entities),
Source: "graph",
}
if err := a.docStore.Insert(doc); err != nil {
log.Printf("[agent] graph→doc sync error: %v", err)
} else {
log.Printf("[agent] graph→doc synced: %s", doc.Summary)
}
}
func extractEntityNames(entities []memory.Entity) []string {
names := make([]string, len(entities))
for i, e := range entities {
names[i] = e.Name
}
return names
}
func (a *Agent) reorgGraph() {
if a.memory == nil {
@ -237,54 +169,69 @@ func (a *Agent) evaluateGraphQuality() {
return
}
result, err := a.memory.Recall(nil, nil, 1, "")
if err != nil || result == nil || len(result.Relations) == 0 {
pending, err := a.memory.RecallPending(10)
if err != nil {
log.Printf("[agent] recall pending relations error: %v", err)
return
}
if len(pending) == 0 {
return
}
var lowQuality []string
for _, r := range result.Relations {
var pendingIDs []int64
var skipIDs []int64
for _, r := range pending {
isLow := false
if (r.SourceName == "用户" || r.SourceName == "AI") &&
(r.RelationType == "提及" || r.RelationType == "回应") {
lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」", r.SourceName, r.RelationType, r.TargetName))
isLow = true
} else if r.RelationType == "关联" {
isLow = true
} else if r.Confidence < 0.3 && r.RelationType != "" {
isLow = true
}
if !isLow {
skipIDs = append(skipIDs, r.ID)
continue
}
pendingIDs = append(pendingIDs, r.ID)
label := fmt.Sprintf("「%s」-「%s」→「%s」", r.SourceName, r.RelationType, r.TargetName)
if r.RelationType == "关联" {
lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」(jieba 共现)", r.SourceName, r.RelationType, r.TargetName))
continue
}
if r.Confidence < 0.3 && r.RelationType != "" {
lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」(confidence=%.1f)", r.SourceName, r.RelationType, r.TargetName, r.Confidence))
label += "(jieba 共现)"
} else if r.Confidence < 0.3 {
label += fmt.Sprintf("(confidence=%.1f)", r.Confidence)
}
lowQuality = append(lowQuality, label)
}
if len(skipIDs) > 0 {
a.memory.UpdateEvalStatusBatch(skipIDs, "approved")
}
if len(lowQuality) == 0 {
return
}
batchSize := 10
for i := 0; i < len(lowQuality); i += batchSize {
end := i + batchSize
if end > len(lowQuality) {
end = len(lowQuality)
}
batch := lowQuality[i:end]
a.enqueueConsolidationTask(ConsolidationTask{
Type: "graph_quality",
Reason: fmt.Sprintf(
"图数据库中发现 %d 条低质量关系,请逐条判断是否应该删除(保留 = keep删除 = discard\n%s",
len(batch),
strings.Join(batch, "\n"),
),
Data: map[string]interface{}{
"candidates": batch,
"action": "evaluate_quality",
},
})
if err := a.memory.UpdateEvalStatusBatch(pendingIDs, "evaluating"); err != nil {
log.Printf("[agent] mark relations evaluating error: %v", err)
return
}
log.Printf("[agent] graph quality: %d low-quality connection batches sent for LLM evaluation", (len(lowQuality)+batchSize-1)/batchSize)
a.enqueueConsolidationTask(ConsolidationTask{
Type: "graph_quality",
Reason: fmt.Sprintf(
"图数据库中发现 %d 条低质量关系,请逐条判断是否应该删除(保留 = keep删除 = discard\n%s",
len(lowQuality),
strings.Join(lowQuality, "\n"),
),
Data: map[string]interface{}{
"candidates": lowQuality,
"action": "evaluate_quality",
},
})
log.Printf("[agent] graph quality: %d pending relations sent for LLM evaluation", len(lowQuality))
}
func entitySimilarity(a, b string) float64 {
@ -335,6 +282,10 @@ func docToTriples(doc *document.Doc) []memory.Triple {
return triples
}
if doc.Source == "graph" || doc.Source == "" {
return nil
}
triples = append(triples, memory.Triple{
Subject: "文档",
SubjectType: "Concept",
@ -397,28 +348,19 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
stageCtx.Extra["output_channel"] = evt.OutputChannel
a.injectSourceContext(stageCtx, evt)
archived := a.context.Prune(input, a.maxContextSize-1, a.docStore)
if archived > 0 {
log.Printf("[agent] consolidation: pruned %d low-relevance events", archived)
}
a.context.Append(ContextEvent{
Timestamp: start,
Source: "system",
Input: input,
})
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
_, toolsUsed, _, err := a.process(input, stageCtx)
if err != nil {
log.Printf("[agent] consolidation error: %v", err)
return
}
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: input,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
})
if a.memory != nil {
if n, err := a.memory.ResolveEvaluating(); err != nil {
log.Printf("[agent] resolve evaluating relations error: %v", err)
} else if n > 0 {
log.Printf("[agent] resolved %d evaluating relations to approved", n)
}
}
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
}

View File

@ -2,13 +2,10 @@ package core
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"sort"
"strings"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
@ -237,9 +234,6 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
}
argsJSON, _ := json.Marshal(tc.Arguments)
a.recordToolCall(tc.Name, string(argsJSON), result)
msgContent := ""
if contentOnce {
msgContent = resp.Content
@ -301,117 +295,28 @@ func (a *Agent) docStoreSize() int {
return 0
}
func (a *Agent) recordToolCall(name, args, result string) {
a.toolCallRingMu.Lock()
defer a.toolCallRingMu.Unlock()
if len(args) > 200 {
args = args[:200] + "..."
}
var resultStub string
var fullResult string
if len(a.toolCallRing) < 5 {
fullResult = result
}
if len(result) > 80 {
resultStub = result[:80] + "..."
} else {
resultStub = result
}
rec := ToolCallRecord{
Timestamp: time.Now(),
Name: name,
Args: args,
ResultStub: resultStub,
FullResult: fullResult,
}
if len(a.toolCallRing) >= a.toolCallRingMax {
a.toolCallRing = a.toolCallRing[1:]
}
a.toolCallRing = append(a.toolCallRing, rec)
}
func (a *Agent) formatToolCallRing() string {
a.toolCallRingMu.Lock()
defer a.toolCallRingMu.Unlock()
if len(a.toolCallRing) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("【已执行工具记录(最近40条)】\n")
start := 0
if len(a.toolCallRing) > 40 {
start = len(a.toolCallRing) - 40
}
for i, rec := range a.toolCallRing[start:] {
if len(rec.FullResult) > 0 {
sb.WriteString(fmt.Sprintf(" [%d] %s: %s(%s)=%s\n", i+1,
rec.Timestamp.Format("15:04:05"), rec.Name, rec.Args,
truncateStr(rec.FullResult, 120)))
} else {
sb.WriteString(fmt.Sprintf(" [%d] %s: %s(%s) → (已缓存,具体结果通过文本记忆层获取)\n", i+1,
rec.Timestamp.Format("15:04:05"), rec.Name, rec.Args))
}
}
return sb.String()
}
func (a *Agent) formatMergedTimeline() string {
a.context.mu.Lock()
events := make([]*ContextEvent, len(a.context.events))
copy(events, a.context.events)
a.context.mu.Unlock()
a.toolCallRingMu.Lock()
ring := make([]ToolCallRecord, len(a.toolCallRing))
copy(ring, a.toolCallRing)
a.toolCallRingMu.Unlock()
if len(events) == 0 && len(ring) == 0 {
if len(events) == 0 {
return ""
}
type timelineEntry struct {
ts time.Time
label string
text string
}
entries := make([]timelineEntry, 0, len(events)+len(ring))
for _, e := range events {
text := fmt.Sprintf("[对话] %s: %s", e.Source, e.Input)
if len(e.ToolsUsed) > 0 {
text += fmt.Sprintf(" → 调用工具: %s", strings.Join(e.ToolsUsed, ", "))
}
if e.Response != "" {
text += fmt.Sprintf(" → %s", truncateStr(e.Response, 120))
}
entries = append(entries, timelineEntry{ts: e.Timestamp, label: "对话", text: text})
}
for _, r := range ring {
text := fmt.Sprintf("[工具] %s(%s)", r.Name, r.Args)
if r.FullResult != "" {
text += fmt.Sprintf(" = %s", truncateStr(r.FullResult, 120))
} else {
text += " → (结果已缓存,可通过文本记忆层获取)"
}
entries = append(entries, timelineEntry{ts: r.Timestamp, label: "工具", text: text})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].ts.Before(entries[j].ts)
})
var sb strings.Builder
sb.WriteString("【对话时序】\n")
for _, e := range entries {
sb.WriteString(fmt.Sprintf("[%s] %s\n", e.ts.Format("15:04:05"), e.text))
for _, e := range events {
sb.WriteString(fmt.Sprintf("[%s] %s: %s",
e.Timestamp.Format("15:04:05"), e.Source, e.Input))
if len(e.ToolsUsed) > 0 {
sb.WriteString(fmt.Sprintf(" → 调用工具: %s", strings.Join(e.ToolsUsed, ", ")))
}
if e.Response != "" {
sb.WriteString(fmt.Sprintf(" → %s", truncateStr(e.Response, 120)))
}
sb.WriteString("\n")
}
return sb.String()
}