mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
feat(memory): L2 与 L1 同构 —— 事件列表承载原文,正文内联在块里
块化重构(memory-restructure-plan)换了底层数据结构,但 L1↔L2 的流动没跟着改,
导致这条链出现结构性失效。本次按设计原文修复。
## 断点(生产实测)
**① L2 只存渲染视图,事件本体丢失**
ContextToDoc 把每条事件拍平成一行:truncate(Response,100)、truncate(工具输出,200)。
生产库 1000 篇归档文档实测:
- response 段恰好 103 字符(=100+"..."):311/2094 = 15.1%
- 工具输出段恰好 203 字符:7593/13261
- ToolsUsed 完全不存
⇒ 原文在落库那一刻就不存在了,召回再全也拿不回来。
**② L2 里没有文本块,与 L1 不同构**
L2 块分布 image 437 / audio 7 / text 0 —— 文本全靠 Content 这个视图。
**③ 召回造合成事件,时间戳用归档时刻**
doc_query 用 d.CreatedAt 造一条 cold_storage 事件、正文取 Content 再截 2000 字、
且完全不回填 Blocks。块自带 created_at(捕获时刻),实测与 doc.created_at
差近两小时,却没人用。
**④ 块在 Consume 时两层同时消失**
Consume 从内存与磁盘同时删除文档,而块的字节只存在于那个 .json 里。
生产 L2 有 444 个块,其中 396 个不在 L3(约 248MB)—— 一旦被命中即永久丢失。
## 修法(按设计,不是按我的推断)
设计前提(写进 ARCHITECTURE 与计划文档):
· 记忆块是唯一物理载体,层是持有关系
· L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」
· 正文(含多媒体正文)内联在块里;事件只是块的容器
· 任何一层都不压缩(视图不是记忆)
· 内核不得硬编码任何插件行为
1. Doc 增加 Events []DocEvent(与 ContextEvent 同构),成为文档主体
2. 正文各自成块:Input 一块、Response 一块(BlockText,Text 即正文)
3. restoreDocEvents 逐条按原始时间戳迁回 L1,块随事件迁回
4. Content 保留为渲染视图(contentFromEvents 生成),不再是原文载体
5. 蒸馏 docToTriples 改用 documentOriginalText:从块读原文,不再用视图
6. 删掉 ARCHITECTURE 里「过滤 agentcli/terminal 来源」——那是内核硬编码
插件名,改由插件经 NoMemory/Cleaner 声明
## 顺带修掉的真实缺陷
simpleHash 是 O(n²) 且哈希串长度正比于内容:
for _, c := range s { h += fmt.Sprintf("%x", c) }
压力测试实测 1.25MB 正文归档耗时 **270 秒**;生产实测 content_hash
是 content 的 2.2 倍(52072 字 → 114491 字)。
换成 sha256 前 16 字节:**270s → 0.52s(520 倍)**,定长 32 hex。
## 存量
按拍板不迁移,等系统自然冲刷。降级路径:无 Events 的旧文档仍按旧行为召回
(整篇渲成一条事件),不伪造成「有原始事件」。生产副本实测 1104 篇旧文档
全部正常加载。
## 判据(16 条,全部有变异自证)
document/blockflow_test.go(11 条):正文完整性、文本块粒度、逐条保留、
视图定位、幂等、存量兼容、原文可用性、hash 线性、3 条压力
core/recalldoc_test.go(5 条):逐条回填、原始时间戳、块迁回、
回填不截断、存量降级、批量压力
变异全部被抓:
· 落库 truncate(100) → ArchivePreservesFullText(实测 len=303/原 10215)
· Input/Response 合成一块 → InputAndResponseAreSeparateTextBlocks(实际 1)
· 召回用 d.CreatedAt → RestoresEachEventAtOriginalTimestamp(回填 1/期望 3)
· 召回不回填 Blocks → MigratesBlocksBackToContext
· 召回再截 2000 字 → TextIsNotTruncatedOnTheWayBack(2003/原 18020)
· 恢复 O(n²) hash → HashIsLinearAndFixedLength(超时 >300s)
## 验证
- go build ./... 通过
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 生产数据副本实际启动:1104 篇文档加载正常、0 崩溃、干净退出
- 副本 e2e:新归档 2 事件 / 4 文本块、正文 3207 字完整回读、时间戳精确
## 不改
- 公开 SDK 契约(TextEvent / DocMemoryAPI / InsertWithMedia 已等价)
- 存量文档(等自然冲刷)
- 渲染层截断常数(独立议题,避免与本次修复互相掩盖)
This commit is contained in:
@ -558,12 +558,20 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
// 写入 L3 并以 document --contains--> block 边关联,
|
||||
// 不经过文本描述与 NLP 提取器。
|
||||
|
||||
// NLP 通用提取
|
||||
// NLP 通用提取。
|
||||
//
|
||||
// ★ 输入必须是**原文**,不能是视图。
|
||||
//
|
||||
// 旧代码抽的是 doc.Content —— 那在 Events 引入前是对事件的压缩渲染
|
||||
// (Response 截 100 rune、工具输出截 200)。拿压缩过的文本做三元组抽取,
|
||||
// 等于在丢失的信息上再丢一次:句子被截断后依存句法往往解析不出主谓宾。
|
||||
//
|
||||
// 现在优先用事件里的完整正文;存量文档没有 Events 才退回 Content。
|
||||
e := nlp.NewExtractor(nil)
|
||||
if embedder != nil {
|
||||
e.SetEmbedder(embedder)
|
||||
}
|
||||
result := e.Extract(doc.Content)
|
||||
result := e.Extract(documentOriginalText(doc))
|
||||
if result != nil {
|
||||
for _, nt := range result.Triples {
|
||||
mt := nlp.ToMemoryTriple(nt)
|
||||
@ -635,3 +643,33 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
|
||||
|
||||
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
|
||||
}
|
||||
|
||||
// documentOriginalText 取文档的**原文**用于抽取/蒸馏。
|
||||
//
|
||||
// 有 Events 时按事件顺序拼正文(原文来自文本块);存量文档退回 Content 视图。
|
||||
// 这是「视图不是记忆」在蒸馏侧的落地:抽取必须基于原文。
|
||||
func documentOriginalText(doc *document.Doc) string {
|
||||
if doc == nil {
|
||||
return ""
|
||||
}
|
||||
if !doc.HasEvents() {
|
||||
return doc.Content
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, ev := range doc.Events {
|
||||
in, resp, _ := ev.InOut()
|
||||
if in != "" {
|
||||
b.WriteString(in)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if resp != "" {
|
||||
b.WriteString(resp)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, tr := range ev.ToolResults {
|
||||
b.WriteString(tr.Output)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@ -301,33 +301,42 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) {
|
||||
}
|
||||
|
||||
// 块应已到达 L2,且身份不变。
|
||||
//
|
||||
// ★ 2026-10-22 更新:L2 与 L1 同构后,事件正文也各自成为文本块
|
||||
// (Input 一块、Response 一块),所以这里不能再假设「文档只有 1 个块」。
|
||||
// 要断言的是**媒体块原样迁移**,而不是块总数。
|
||||
var found *document.Doc
|
||||
for _, d := range docStore.RecentDocs(20) {
|
||||
if len(d.Blocks) > 0 {
|
||||
found = d
|
||||
break
|
||||
for _, b := range d.Blocks {
|
||||
if b.ID == block.ID {
|
||||
found = d
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("归档文档应持有一等记忆块")
|
||||
t.Fatal("媒体块未随归档事件迁移到 L2")
|
||||
}
|
||||
if len(found.Blocks) != 1 {
|
||||
t.Fatalf("文档应有 1 个块,实际 %d", len(found.Blocks))
|
||||
var got *memory.MemoryBlock
|
||||
for i := range found.Blocks {
|
||||
if found.Blocks[i].ID == block.ID {
|
||||
got = &found.Blocks[i]
|
||||
}
|
||||
}
|
||||
got := found.Blocks[0]
|
||||
if got.ID != block.ID || got.Modality != block.Modality || got.MIME != block.MIME ||
|
||||
got.Fingerprint != block.Fingerprint || len(got.Vector) != len(block.Vector) {
|
||||
t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", got, block)
|
||||
t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", *got, block)
|
||||
}
|
||||
// 字节随块迁移(union)
|
||||
if string(got.Data) != string(block.Data) {
|
||||
t.Fatalf("块字节应原样迁移: %q vs %q", got.Data, block.Data)
|
||||
}
|
||||
|
||||
// 同一块不能同时留在 L0。
|
||||
// 同一块不能同时留在 L0(L1)。
|
||||
for _, e := range rc.Recent(100) {
|
||||
if len(e.Blocks) > 0 {
|
||||
t.Fatalf("块仍留在 L0(同一块同时存在于两层): %+v", e.Blocks)
|
||||
for _, b := range e.Blocks {
|
||||
if b.ID == block.ID {
|
||||
t.Fatalf("块仍留在 L1(同一块同时存在于两层): %+v", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
286
internal/agent/core/recalldoc_test.go
Normal file
286
internal/agent/core/recalldoc_test.go
Normal file
@ -0,0 +1,286 @@
|
||||
package core
|
||||
|
||||
// L2 → L1 召回协议的判据。
|
||||
//
|
||||
// 依据(assets/docs/zh/ARCHITECTURE.md「记忆流转」② Document 层的读取分支):
|
||||
//
|
||||
// doc_query → Consume(读取并删除)
|
||||
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
|
||||
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
|
||||
//
|
||||
// 三条不变量:
|
||||
// ① 逐条:N 条事件回到时间线,不是 1 条合成事件
|
||||
// ② 原始时间戳:用事件时刻,不是文档归档时刻
|
||||
// ③ 块随事件迁回:Consume 已删 L2,不回填就两层都不在
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
)
|
||||
|
||||
// archiveEvents 走生产路径(Prune)把一批事件归档进 L2。
|
||||
//
|
||||
// Prune 无条件保护最近 10 条事件,只在更早的部分里挑归档对象,
|
||||
// 所以要填充足够多的无关事件把目标挤出保护窗口。
|
||||
func archiveEvents(t *testing.T, a *Agent, target []ContextEvent) {
|
||||
t.Helper()
|
||||
for _, ev := range target {
|
||||
a.context.Append(ev)
|
||||
}
|
||||
for i := 0; i < 14; i++ {
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "filler",
|
||||
Input: "无关填充对话,用来把目标事件挤出保护窗口",
|
||||
Response: "好的。",
|
||||
})
|
||||
}
|
||||
if n := a.context.Prune("当前输入", 2, a.docStore); n == 0 {
|
||||
t.Fatal("Prune 未归档任何事件")
|
||||
}
|
||||
}
|
||||
|
||||
func countSource(c *RelevanceContext, src string) int {
|
||||
n := 0
|
||||
for _, e := range c.Recent(0) {
|
||||
if e.Source == src {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ① + ② 逐条回填,且每条用自己的原始时间戳。
|
||||
//
|
||||
// 变异:改用 d.CreatedAt 当时间戳 ⇒ 红;改回单条合成事件 ⇒ 红。
|
||||
func TestRecallByDoc_RestoresEachEventAtOriginalTimestamp(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
base := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||
var want []ContextEvent
|
||||
for i := 0; i < 3; i++ {
|
||||
want = append(want, ContextEvent{
|
||||
Timestamp: base.Add(time.Duration(i) * 7 * time.Minute),
|
||||
Source: "archive-src",
|
||||
Input: "档案事件-" + string(rune('A'+i)) + " 关于紫蓝红配色方案的讨论",
|
||||
Response: "该事件的完整回答,编号 " + string(rune('A'+i)),
|
||||
})
|
||||
}
|
||||
archiveEvents(t, a, want)
|
||||
|
||||
if n := countSource(a.context, "archive-src"); n != 0 {
|
||||
t.Fatalf("归档后 L1 仍有 %d 条 archive-src", n)
|
||||
}
|
||||
|
||||
out := a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_query",
|
||||
Arguments: map[string]interface{}{"query": "紫蓝红配色方案", "top_k": float64(5)},
|
||||
})
|
||||
if strings.Contains(out, "未找到") {
|
||||
t.Fatalf("doc_query 未召回:%s", out)
|
||||
}
|
||||
|
||||
seen := map[time.Time]string{}
|
||||
for _, e := range a.context.Recent(0) {
|
||||
if e.Source == "archive-src" {
|
||||
seen[e.Timestamp] = e.Input
|
||||
}
|
||||
}
|
||||
if len(seen) != 3 {
|
||||
t.Fatalf("回填了 %d 条 archive-src 事件,期望 3"+
|
||||
"(逐条回填,不是合成一条;也不是换了个时间戳)", len(seen))
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
ts := base.Add(time.Duration(i) * 7 * time.Minute)
|
||||
if _, ok := seen[ts]; !ok {
|
||||
t.Errorf("时间戳 %s 缺失 —— 回填必须用事件原始时刻(不是归档时刻)",
|
||||
ts.Format("15:04:05"))
|
||||
}
|
||||
}
|
||||
// 不得出现合成事件。
|
||||
for _, e := range a.context.Recent(0) {
|
||||
if e.Source == "cold_storage" {
|
||||
t.Errorf("出现 cold_storage 合成事件(%q)—— 有 Events 时应逐条回填", e.Input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 块随事件迁回 L1(迁移,不是复制也不是丢弃)。
|
||||
//
|
||||
// 变异:不回填 Blocks ⇒ 红(块两层都消失 = 永久丢字节)。
|
||||
func TestRecallByDoc_MigratesBlocksBackToContext(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
payload := []byte("recall-block-bytes")
|
||||
b := mediaBlock("image/png", payload)
|
||||
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
archiveEvents(t, a, []ContextEvent{{
|
||||
Timestamp: time.Now().Add(-3 * time.Hour),
|
||||
Source: "archive-src",
|
||||
Input: "这张图请记住:紫蓝红三色带的配色参考",
|
||||
Response: "记住了。",
|
||||
Blocks: []memory.MemoryBlock{b},
|
||||
}})
|
||||
|
||||
if blocksInCtx(a.context, b.ID) {
|
||||
t.Fatal("归档后块仍在 L1")
|
||||
}
|
||||
|
||||
a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_query",
|
||||
Arguments: map[string]interface{}{"query": "紫蓝红三色带 配色参考", "top_k": float64(3)},
|
||||
})
|
||||
|
||||
var back *memory.MemoryBlock
|
||||
for _, e := range a.context.Recent(0) {
|
||||
for i := range e.Blocks {
|
||||
if e.Blocks[i].ID == b.ID {
|
||||
back = &e.Blocks[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if back == nil {
|
||||
t.Fatal("召回后块没有迁回 L1 —— Consume 已删除 L2 文档,块会两层都不在(字节永久丢失)")
|
||||
}
|
||||
if string(back.Data) != string(payload) {
|
||||
t.Errorf("迁回的块字节被改动: %q", string(back.Data))
|
||||
}
|
||||
// 迁移而非复制。
|
||||
for _, d := range a.docStore.RecentDocs(50) {
|
||||
for _, db := range d.Blocks {
|
||||
if db.ID == b.ID {
|
||||
t.Error("块同时留在 L2 与 L1 —— 这是复制,违反单层不变量")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 回填的正文必须是原文,不能再被截断。
|
||||
//
|
||||
// 断点史:旧实现在召回时对 Content 再截 2000 字,且 Content 本身已是截断视图。
|
||||
func TestRecallByDoc_TextIsNotTruncatedOnTheWayBack(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
longResp := strings.Repeat("长正文", 2000) + "【召回哨兵R4】"
|
||||
archiveEvents(t, a, []ContextEvent{{
|
||||
Timestamp: time.Now().Add(-2 * time.Hour),
|
||||
Source: "archive-src",
|
||||
Input: "请记住这份长报告",
|
||||
Response: longResp,
|
||||
}})
|
||||
|
||||
a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_query",
|
||||
Arguments: map[string]interface{}{"query": "长报告", "top_k": float64(3)},
|
||||
})
|
||||
|
||||
var got string
|
||||
for _, e := range a.context.Recent(0) {
|
||||
if e.Source == "archive-src" {
|
||||
got = e.Response
|
||||
}
|
||||
}
|
||||
if got == "" {
|
||||
t.Fatal("未回填事件")
|
||||
}
|
||||
if got != longResp {
|
||||
t.Errorf("回填正文被截断:len=%d,原长 %d", len(got), len(longResp))
|
||||
}
|
||||
if !strings.Contains(got, "【召回哨兵R4】") {
|
||||
t.Error("回填正文丢了结尾哨兵")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 存量文档(无 Events)降级:整篇渲成一条事件,不丢弃。
|
||||
//
|
||||
// 决定:存量等自然冲刷,但不得因为格式旧就召回不到。
|
||||
func TestRecallByDoc_LegacyDocDegrades(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
if err := a.docStore.Insert(&document.Doc{
|
||||
Summary: "紫蓝红三色带的旧格式文档",
|
||||
Content: "[10:00] legacy: 这是 Events 引入之前归档的正文视图",
|
||||
Source: "context_archived",
|
||||
Meta: map[string]string{"content_hash": "legacy-hash-1"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_query",
|
||||
Arguments: map[string]interface{}{"query": "紫蓝红三色带 旧格式", "top_k": float64(3)},
|
||||
})
|
||||
if strings.Contains(out, "未找到") {
|
||||
t.Fatalf("存量文档召回失败:%s", out)
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, e := range a.context.Recent(0) {
|
||||
if e.Source == "cold_storage" && strings.Contains(e.Response, "Events 引入之前") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("存量文档没有降级成合成事件 —— 会静默丢失整篇内容")
|
||||
}
|
||||
}
|
||||
|
||||
func blocksInCtx(c *RelevanceContext, blockID string) bool {
|
||||
for _, e := range c.Recent(0) {
|
||||
for _, b := range e.Blocks {
|
||||
if b.ID == blockID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 压力:大批量事件回填不得退化(回填是逐条 InsertByTimestamp,本身 O(n) 插入)。
|
||||
func TestRecallByDoc_StressManyEvents(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("压力测试")
|
||||
}
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
const n = 120
|
||||
base := time.Date(2026, 2, 3, 4, 0, 0, 0, time.UTC)
|
||||
var evs []ContextEvent
|
||||
for i := 0; i < n; i++ {
|
||||
evs = append(evs, ContextEvent{
|
||||
Timestamp: base.Add(time.Duration(i) * time.Second),
|
||||
Source: "stress",
|
||||
Input: "压力输入 " + strings.Repeat("内容", 10),
|
||||
Response: "压力回答 " + strings.Repeat("正文", 20),
|
||||
})
|
||||
}
|
||||
archiveEvents(t, a, evs)
|
||||
|
||||
start := time.Now()
|
||||
if got := countSource(a.context, "stress"); got != 0 {
|
||||
t.Fatalf("归档后仍有 %d 条", got)
|
||||
}
|
||||
a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_query",
|
||||
Arguments: map[string]interface{}{"query": "压力输入 内容", "top_k": float64(5)},
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
got := countSource(a.context, "stress")
|
||||
if got == 0 {
|
||||
t.Fatal("压力场景下一条也没回填")
|
||||
}
|
||||
// 回填 120 条不应超过 5 秒(实测远小于此;阈值防 O(n²) 退化)。
|
||||
if elapsed > 5*time.Second {
|
||||
t.Errorf("回填 %d 条耗时 %v,疑似超线性", got, elapsed)
|
||||
}
|
||||
t.Logf("回填 %d 条耗时 %v", got, elapsed)
|
||||
}
|
||||
@ -1037,24 +1037,33 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
if len(d.Tags) > 0 {
|
||||
parts = append(parts, " 标签: "+strings.Join(d.Tags, ", "))
|
||||
}
|
||||
content := d.Content
|
||||
if len(content) > 2000 {
|
||||
content = content[:2000] + "..."
|
||||
// ★★ 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件
|
||||
//
|
||||
// 设计原文(ARCHITECTURE「记忆流转」② Document 层):
|
||||
//
|
||||
// doc_query → Consume(读取并删除)
|
||||
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
|
||||
//
|
||||
// 此前实现用 d.CreatedAt(归档时刻)造一条 cold_storage 事件、
|
||||
// 正文取 d.Content(渲染视图)再截 2000 字、块完全没回填 ——
|
||||
// 三处都偏离:时间错位、正文二次截断、块两层消失。
|
||||
if n := a.restoreDocEvents(d); n == 0 {
|
||||
// 存量文档(无 Events)降级:整篇渲成一个事件,保持既有行为。
|
||||
// 不伪造成「有原始事件」——那时归档确实只有渲染视图。
|
||||
content := d.Content
|
||||
if labels := a.blockLabelsForDoc(d); labels != "" {
|
||||
content = content + "\n关联媒体: " + labels
|
||||
}
|
||||
a.context.InsertByTimestamp(ContextEvent{
|
||||
Timestamp: d.CreatedAt,
|
||||
Source: "cold_storage",
|
||||
Input: fmt.Sprintf("加载文档记忆: %s", query),
|
||||
Response: content,
|
||||
})
|
||||
}
|
||||
// 媒体块标签单独一行进冷存事件:正文可能被上面的 2000 字截断,
|
||||
// 截掉之后模型就不知道这篇文档带过图。
|
||||
if labels := a.blockLabelsForDoc(d); labels != "" {
|
||||
content = content + "\n关联媒体: " + labels
|
||||
}
|
||||
a.context.InsertByTimestamp(ContextEvent{
|
||||
Timestamp: d.CreatedAt,
|
||||
Source: "cold_storage",
|
||||
Input: fmt.Sprintf("加载文档记忆: %s", query),
|
||||
Response: content,
|
||||
})
|
||||
refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary))
|
||||
}
|
||||
return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(完整内容参见对话时序中 cold_storage 事件)",
|
||||
return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(正文已按原时间戳回填到对话时序,直接读时序即可)",
|
||||
len(docs), strings.Join(refs, ", "))
|
||||
|
||||
case "doc_commit":
|
||||
@ -1262,3 +1271,56 @@ func (a *Agent) currentFingerprint() string {
|
||||
}
|
||||
return a.multimodalSpace.Fingerprint()
|
||||
}
|
||||
|
||||
// restoreDocEvents 把一篇 L2 文档的原始事件**逐条按原时间戳迁回 L1**。
|
||||
//
|
||||
// 返回回填的事件条数;0 表示这篇文档没有可回填的原始事件(存量文档)。
|
||||
//
|
||||
// ## 依据
|
||||
//
|
||||
// ARCHITECTURE「记忆流转」② Document 层的读取分支:
|
||||
//
|
||||
// doc_query → Consume(读取并删除)
|
||||
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
|
||||
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
|
||||
//
|
||||
// L1 与 L2 结构同构,召回是**迁移**:事件连同它持有的块从 L2 换到 L1,
|
||||
// 块 ID 与向量全程不变(不重建、不重算)。
|
||||
//
|
||||
// ## 块为什么要一起回填
|
||||
//
|
||||
// Consume 已把文档从 L2 删掉,而块的字节只存在于那篇文档里。
|
||||
// 不回填块 = 块两层都不在 = 永久丢内容(生产实测这一敞口约 248MB)。
|
||||
func (a *Agent) restoreDocEvents(d *document.Doc) int {
|
||||
if a == nil || a.context == nil || d == nil || !d.HasEvents() {
|
||||
return 0
|
||||
}
|
||||
|
||||
n, blocks := 0, 0
|
||||
for _, ev := range d.Events {
|
||||
input, response, held := ev.InOut()
|
||||
a.context.InsertByTimestamp(ContextEvent{
|
||||
Timestamp: ev.Timestamp, // ★ 原始时间戳:回到事件发生的位置
|
||||
Source: ev.Source,
|
||||
Input: input,
|
||||
Response: response,
|
||||
ToolsUsed: append([]string(nil), ev.ToolsUsed...),
|
||||
ToolResults: func() []ToolResultItem {
|
||||
var out []ToolResultItem
|
||||
for _, tr := range ev.ToolResults {
|
||||
out = append(out, ToolResultItem{Name: tr.Name, Output: tr.Output})
|
||||
}
|
||||
return out
|
||||
}(),
|
||||
Blocks: append([]memory.MemoryBlock(nil), held...),
|
||||
})
|
||||
n++
|
||||
blocks += len(held)
|
||||
}
|
||||
|
||||
log.Printf("[agent] doc→context: %s 逐条回填 %d 条原始事件(%s → %s),块 %d 个迁回 L1",
|
||||
d.ID, n,
|
||||
d.Events[0].Timestamp.Format("01-02 15:04:05"),
|
||||
d.Events[len(d.Events)-1].Timestamp.Format("01-02 15:04:05"), blocks)
|
||||
return n
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user