mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
feat(memory): 媒体接入 L0/L2——digest 挂到对话事件,归档时引用随之转移
在 a822674 的 CAS 层之上把媒体真正接进记忆链路。此前 CAS 只是个孤立的
存储包,没有任何写入方。
## 媒体进入对话有两条路,两条都只把文字留给记忆
1. 用户直接发图 → processMediaInput → mediaToBlocks
ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"),
base64 随 message 数组发给模型后就丢了。
2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。
于是下一轮起,模型能看到的只剩一句路径或一句 alt。那个文件被删、被覆盖,
或者本来就是 /tmp 下的临时产物,连线索都断了。
现在两条路在同一处收口(captureBlockMedia):从 ContentBlock 的 data URL
取出字节存进 CAS,digest 挂到当轮 ContextEvent。
## 改动
internal/agent/core/mediaref.go(新)
- captureBlockMedia:ContentBlock → CAS。只处理 data URL——http(s) URL
拿不到字节就无法内容寻址,而「下载它再存」会把一次对话变成一次网络
请求(超时、鉴权、SSRF 全来了),不在本层解决。
- stage/drainMediaDigests:媒体在 process() 期间被捕获,而承载它的
ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id,
故先缓存。与既有 pendingMedia 同一手法,同受 a.mu 保护。
- bindEventMedia:双向落地。evt.Media 让事件记得引了什么(随
context.json 持久化),media_refs 让 CAS 知道谁在引用(GC 的判断依据)。
只写一边的话,要么 GC 误删仍被引用的内容,要么孤儿永远清不掉。
- mediaSummaryForEvent:把已有描述拼成一行写进 Input。这是方案 C 的
落点——**描述文本才是持久语义记忆,blob 只是缓存**。blob 可能被容量
GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,让「那张紫蓝红三色带图」
几个月后仍可被检索。
ContextEvent 新增 ID 与 Media 两个字段,都是 omitempty:
- ID 懒生成,只有真要挂媒体时才赋值。绝大多数对话没有媒体,全量生成
会让每条事件都多一个字段进 context.json。
- 存量 context.json 读回来两字段皆空,不影响任何既有行为(有测试)。
RelevanceContext.Prune 归档时转移引用(transferMediaRefs):
**先挂到归档文档、再注销原事件引用**。顺序不能反——先销后挂会让引用
计数瞬时归零,若此刻后台 GC 正在跑就会把仍被记忆引用的内容当孤儿清掉。
为此把 Prune 内的局部类型 scored 提为包级 scoredEvent(局部类型无法
出现在方法签名上)。
media 包新增 OwnerContext/OwnerDocument/OwnerGraphSentence 常量:
owner_kind 进了主键,拼错一个字符就是一条永远对不上的孤立引用——
AddRef 不报错,DropOwner 也永远匹配不到。
## 配置
core.memory.media.enabled(默认 true)、.dir、.max_mb(2048)、
.gc_interval(6h)、.gc_min_age(1h)。
关闭后全链路静默跳过,对话行为与本特性上线前完全一致(有测试)。
mediaStore 为 nil 时同理——它是记忆增强,不是对话必需品,开不起来
只记一条 warning 不阻止启动。
## 测试(11 例)
入库与 MIME 归类、http URL 跳过、nil store 全链路 no-op、音视频混合、
stage/drain 清空语义、懒生成 ID、描述作为持久记忆、**归档转移期间内容
始终可读且 refcount 不归零**、无媒体存储时归档照常、context.json
向后兼容往返。
全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。
## 尚未接入
L3 图库的 graph_sentence owner(常量已备好,无写入方)、
描述生成的后台任务(Pending() 已就绪,尚无消费者)、
媒体 GC 的定时触发(配置项已注册,尚未接 ticker)。
This commit is contained in:
@ -14,6 +14,7 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"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/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
@ -50,6 +51,11 @@ type Agent struct {
|
||||
// 文本记忆(原始对话日志)
|
||||
textMem *text.Memory
|
||||
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
|
||||
// L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过——
|
||||
// 它是记忆增强而非对话必需品,缺了不该让对话失败。
|
||||
mediaStore *media.Store
|
||||
|
||||
// 人格设定
|
||||
personality *agentPkg.Personality
|
||||
|
||||
@ -106,6 +112,13 @@ type Agent struct {
|
||||
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
|
||||
pendingMedia map[string]interface{}
|
||||
|
||||
// pendingMediaDigests 累积本轮已落进 CAS 的媒体 digest。
|
||||
//
|
||||
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
|
||||
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||||
// 与 pendingMedia 同受 a.mu 保护。
|
||||
pendingMediaDigests []string
|
||||
|
||||
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
|
||||
interruptInput bool
|
||||
|
||||
@ -119,7 +132,7 @@ type Agent struct {
|
||||
|
||||
// 输入去重:防 webui/GUI 断线重连导致的消息重放
|
||||
// key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃
|
||||
lastInput map[string]time.Time
|
||||
lastInput map[string]time.Time
|
||||
lastInputMu sync.Mutex
|
||||
|
||||
// 词嵌入模型,用于实体语义相似度计算
|
||||
@ -151,16 +164,17 @@ type AgentConfig struct {
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
Personality *agentPkg.Personality
|
||||
PluginReg *plugin.Registry
|
||||
PluginDir string
|
||||
DistillInterval time.Duration
|
||||
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
|
||||
ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval
|
||||
MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval
|
||||
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
|
||||
ContextSavePath string // 上下文持久化路径,空则不持久化
|
||||
EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用
|
||||
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
|
||||
ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval
|
||||
MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval
|
||||
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
|
||||
ContextSavePath string // 上下文持久化路径,空则不持久化
|
||||
EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用
|
||||
Embedder *memory.StaticEmbedder // 共享词嵌入实例;nil 时按 EmbeddingModelPath 自建
|
||||
StageHost *StageHost
|
||||
EventBus *events.Bus
|
||||
@ -227,6 +241,7 @@ func New(cfg AgentConfig) *Agent {
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
personality: cfg.Personality,
|
||||
pluginReg: cfg.PluginReg,
|
||||
pluginDir: cfg.PluginDir,
|
||||
|
||||
Reference in New Issue
Block a user