feat(memory): doc 层去掉 jieba 摘要 + doc_query 返回真实内容 + 三层记忆提示词

上一轮(memory-blockflow-repair-plan)修好了 L1↔L2 结构,但功能不可用。
用生产数据实测:三个互不相关查询分数全挤在 0.75~0.79,区分度≈0,
命中内容与查询无关。本次修四层。

## ① 向量输入:Summary + Content → 事件正文

denseFor 原用 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary
由 summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
生成。同一来源的摘要逐字同质:

    来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
    来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问

真实 chineseclip 实测(4 篇主题完全不同的短正文):两两余弦 0.85~0.95。
这与设计矛盾 —— ARCHITECTURE 写的是「统一多模态空间(主)」,而 Summary
是主路径的输入,却由最末一级的词法分词产出。

改为 documentTextForVector(doc):按事件顺序拼原文(块里的正文)。
存量无 Events 的文档退回 Content。

★ 媒体不单独生成摘要:媒体块与文本块在同一事件内
  (Doc.Events[i].Blocks),事件正文进入向量即覆盖其语义。
  为媒体单独写描述正是 CAS 时代「描述式索引」的错误,v1.2.0 已拆除。
  「按图搜图」是视觉路(RecallBlocks)的职责,已有独立通路。

## ② doc 层移除 jieba 语义生成

- summarizeEntries:jieba 词袋 → 取首条事件正文首行(真实内容,可读)
- 删除 extractTags / extractEntities(纯 jieba 词袋)
- 删除 Doc.Tags / Doc.Entities(前者唯一消费点是 doc_query 返回的
  「标签: map, content」噪声行;后者零消费点)
- doc_commit 删除 tags 参数(无处可存,留着会骗模型)

★ TF-IDF 回退路径**保留**(Store.tfidfEmb/tfidfIdx/ensureTFIDF/Reindex):
  它是「稠密空间没加载」时的兜底,按 ARCHITECTURE「统一多模态空间(主)
  → 词嵌入 → TF-IDF(回退)」必须保留。但它不能是主路径 —— 一旦稠密空间
  可用,jieba 就不参与。

## ③ doc_query 返回真实内容

旧返回:「已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok)」
——全 jieba 词袋,模型无法判断有没有用(这是它几乎不调用 doc_query 的直接原因)。

新返回:
    已召回 3 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):

    [1] 2026-06-07 08:09 · webui · 2 条事件 · 含 1 份媒体
        配色方案怎么定?
        结论:紫蓝红三色带按六比三比一…

    共回填 N 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。

每条给出「时间 · 来源 · 事件数 · 媒体数」+ 真实正文前几行。

## ④ 提示词:agent 终于能理解三层记忆

- doc_query 描述:一句话 → 覆盖「L2 是什么 / 何时用 / 调用后原文回填到时序 /
  与 memory_recall 分工 / 消费式副作用」
- doc_commit 描述:说清「适合写整段过程结论,不适合写单个事实」
- 系统提示词新增【三层记忆】段(内核运行时块):L1 对话时序 / L2 历史记忆 /
  L3 图记忆各自是什么、怎么取、如何选择
- 预填充文案(crossModalMarkdown):统计描述 → 时间+来源+正文开头,
  并在块尾明确引导「要完整原文用 doc_query」

## ⑤ 向量算法版本(否则修复对存量无效)

实测踩到的坑:改完 denseFor 后,1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
区分度 0.003)—— 因为 DenseFP 只记「哪个向量空间」,不记「用什么文本算的」,
指纹不变 ⇒ 全部命中跳过条件 ⇒ 永不重算。

修法:DenseFP = 空间指纹 + "|" + docVectorAlgoVersion(v2)。
算法一变,存量自动重算一次。

## 判据(每条都有变异自证)

document/retrieval_quality_test.go(6 条):
- Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant
- VectorInputIsBodyNotSummary(正文哨兵必须进向量)
- SummaryNoiseMustNotPolluteVector(主题不同的短正文,两两余弦 <0.80)
- AlgoVersionChangeForcesRecompute(换算法必须触发存量重算)

core/recalldoc_test.go(+2):返回值必须含时间/来源/真实正文/时序说明
core/prompt_memory_test.go(5 条):工具描述五项覆盖、doc_commit 范围、
  三层记忆段落、tags 参数已删、预填充文案含内容与 doc_query 引导

变异全部被抓住(截断落库 / Summary+Content / 去掉算法版本 / 旧预填充文案 /
一句话工具描述 / 删三层记忆段)。

## 验证

- go build ./... 通过;go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 真实数据(chineseclip 512 维):
  · 旧格式存量(1055 篇,无 Events):4/5 → 仍是旧分数,等自然冲刷
  · 新格式文档(走 ContextToDoc):4/5 top1 命中正确,
    区分度最高 0.18(旧实现 0.003)
This commit is contained in:
JianFeeeee
2026-10-22 19:49:25 +08:00
parent b1f02a22b6
commit e2cb6e70e0
9 changed files with 1052 additions and 294 deletions

View File

@ -5,6 +5,7 @@ import (
"log"
"sort"
"strings"
"unicode"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
@ -234,37 +235,142 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
return merged
}
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
// 文档行给出摘要;媒体行只给 MIME + 短块 ID(不再有生成的描述)。
// crossModalMarkdown 把融合候选渲染成注入提示词的文本。
//
// ## 为什么改
//
// 旧输出是这样的:
//
// 【跨模态相关记忆】
// [1] 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) (来源:context_archived) 相关度:0.78(文本命中)
//
// 三个问题,都被生产日志证实:
//
// 1. 只有**统计描述**(几个来源几条对话),不是内容 —— 模型无法判断相关性;
// 2. **没有一个字**说"你可以把这些原文取回来" —— 模型看完只能忽略;
// 3. 来源写了 `context_archived` 这种内部标记,对模型无意义。
//
// 现在每条给出「时间 · 来源 · 正文开头」,并在块尾**明确告知 doc_query 的作用**:
// 这才是让预填充真正被用起来的必要条件。
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
if len(hits) == 0 {
return ""
}
var lines []string
for i, h := range hits {
marker := ""
switch {
case h.DoubleHit:
marker = "(图文双命中)"
case h.Doc != nil:
marker = "(文本命中)"
case h.Media != nil:
marker = "(视觉命中)"
}
parts := []string{fmt.Sprintf("[%d]", i+1)}
var parts []string
parts = append(parts, fmt.Sprintf("[%d]", i+1))
// 时间 + 来源:让模型知道"这是什么时候、谁的事"
if h.Doc != nil {
parts = append(parts, h.Doc.Summary)
if h.Doc.Source != "" {
parts = append(parts, fmt.Sprintf("(来源:%s)", h.Doc.Source))
if h.Doc.HasEvents() {
first := h.Doc.Events[0].Timestamp
last := h.Doc.Events[len(h.Doc.Events)-1].Timestamp
if first.Equal(last) {
parts = append(parts, first.Format("2006-01-02 15:04"))
} else {
parts = append(parts, fmt.Sprintf("%s→%s",
first.Format("01-02 15:04"), last.Format("01-02 15:04")))
}
// 真实来源(跳过内部归档标记,它对模型无意义)
if src := firstRealSource(h.Doc.Events); src != "" {
parts = append(parts, src)
}
} else {
parts = append(parts, h.Doc.CreatedAt.Format("2006-01-02 15:04"))
}
}
// 正文开头:真实内容,模型可据此判断相关性
if h.Doc != nil {
if head := docHeadlineForPrompt(h.Doc); head != "" {
parts = append(parts, head)
}
}
// 媒体只报类型与数量,不编描述(描述式索引已在 v1.2.0 拆除)
if h.Media != nil {
if line := blockLabel(h.Media); line != "" {
parts = append(parts, line)
label := string(h.Media.Modality)
if h.Media.MIME != "" {
label = h.Media.MIME
}
parts = append(parts, "["+label+"]")
}
if h.DoubleHit {
parts = append(parts, "(图文双命中)")
}
parts = append(parts, fmt.Sprintf("相关度:%.2f", h.Fused))
lines = append(lines, strings.Join(parts, " · "))
}
var b strings.Builder
b.WriteString("【历史记忆(L2,自动预填充)】\n")
b.WriteString("下面是按语义相似度从历史层挑出的相关片段 —— **不是当前对话**。\n")
b.WriteString(strings.Join(lines, "\n"))
b.WriteString("\n要其中某条的完整原文与当时过程:用 doc_query,它会把那些事件" +
"按原始时间戳插回你的对话时序。")
return b.String()
}
// firstRealSource 取事件里第一个非内部标记的来源。
//
// `context_archived` 是内核归档动作的标记,不是"谁说的",对模型无意义。
func firstRealSource(events []document.DocEvent) string {
for _, ev := range events {
if ev.Source != "" && ev.Source != "context_archived" && ev.Source != "cold_storage" {
return ev.Source
}
}
if len(events) > 0 {
return events[0].Source
}
return ""
}
// docHeadlineForPrompt 取文档正文的第一句,作为注入行里的内容提示。
//
// 不截断成"摘要":这里只是一行提示,完整内容在 L2 里、需要时用 doc_query 取。
func docHeadlineForPrompt(d *document.Doc) string {
if d == nil {
return ""
}
if d.HasEvents() {
for _, ev := range d.Events {
in, resp, _ := ev.InOut()
for _, cand := range []string{in, resp} {
if line := firstMeaningfulLineForPrompt(cand); line != "" {
return truncateRunesForPrompt(line, 60)
}
}
}
parts = append(parts, fmt.Sprintf("相关度:%.2f%s", h.Fused, marker))
lines = append(lines, strings.Join(parts, " "))
return ""
}
return "【跨模态相关记忆】\n" + strings.Join(lines, "\n")
return truncateRunesForPrompt(firstMeaningfulLineForPrompt(d.Content), 60)
}
func firstMeaningfulLineForPrompt(s string) string {
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
hasWord := false
for _, r := range line {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
hasWord = true
break
}
}
if hasWord {
return strings.Join(strings.Fields(line), " ")
}
}
return ""
}
func truncateRunesForPrompt(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}

View File

@ -0,0 +1,192 @@
package core
// 三层记忆的**提示词诚实性**判据。
//
// 这类判据的目的是防止「提示词在说谎」——它比不说更糟:
// 模型按提示词行事,而实现不是那样,于是行为与预期系统性错位。
//
// 生产实测(2026-10-22 日志):agent 几乎不调用 doc_query,即使调用也
// 只看返回值、不知道原文已被回填到对话时序。根因是:
// · 工具描述只有「查询文档记忆。返回相关文档摘要」一句(且与真实行为不符)
// · 系统提示词从未解释 L1/L2/L3 是什么
import (
"strings"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
)
// ① doc_query 的描述必须覆盖五项,且与真实行为一致。
func TestDocQueryDescriptionCoversRealBehavior(t *testing.T) {
a := newToolTestAgent(t)
desc := findToolDescription(t, a, "doc_query")
mustHave := []struct {
name string
sub string
}{
{"L2 是什么", "历史记忆层"},
{"何时该用", "何时用"},
{"调用后行为=回填时序", "插回你的对话时序"},
{"原始时间戳", "原始时间戳"},
{"与 memory_recall 分工", "memory_recall"},
{"副作用=消费式取出", "消费式"},
}
for _, m := range mustHave {
if !strings.Contains(desc, m.sub) {
t.Errorf("doc_query 描述缺少「%s」(应含 %q):\n%s", m.name, m.sub, desc)
}
}
// 不得再声称"返回摘要"—— 那是旧实现的谎
if strings.Contains(desc, "返回相关文档摘要") {
t.Error("doc_query 描述仍声称『返回相关文档摘要』,与真实行为(回填原文到时序)不符")
}
}
// ② doc_commit 描述必须说清「适合/不适合写什么」,并与另外两层分工。
func TestDocCommitDescriptionExplainsScope(t *testing.T) {
a := newToolTestAgent(t)
desc := findToolDescription(t, a, "doc_commit")
for _, sub := range []string{"适合写什么", "不适合写什么", "memory_commit", "knowledge_create"} {
if !strings.Contains(desc, sub) {
t.Errorf("doc_commit 描述缺少 %q:\n%s", sub, desc)
}
}
}
// ③ 系统提示词必须有三层记忆段落,且语义与实现一致。
//
// ★ 这条是**跨文件一致性**判据:提示词说"按原始时间戳插回时序",
//
// 实现就必须真的那样做。若哪天 restoreDocEvents 被改回造合成事件,
// 这里必须红 —— 否则提示词又开始说谎。
func TestSystemPromptExplainsThreeLayers(t *testing.T) {
a := newToolTestAgent(t)
// memContext 为空即可:三层记忆段落是内核无条件注入的
prompt := a.buildSystemPrompt("", "测试输入")
for _, sub := range []string{
"【三层记忆】",
"对话时序(L1",
"历史记忆(L2",
"图记忆(L3",
"doc_query",
"memory_recall",
} {
if !strings.Contains(prompt, sub) {
t.Errorf("系统提示词缺少三层记忆段落的 %q", sub)
}
}
// 提示词承诺「按原始时间戳插回」,实现必须真的这么做
if !strings.Contains(prompt, "原始时间戳") {
t.Error("提示词没有说明 doc_query 会按原始时间戳回填")
}
if !strings.Contains(prompt, "插回你的对话时序") {
t.Error("提示词没有说明 doc_query 把事件插回时序(模型会以为只是返回摘要)")
}
}
// ④ doc_commit 不得再有 tags 参数(Doc.Tags 已删除,留着会骗模型)。
func TestDocCommitHasNoTagsParam(t *testing.T) {
a := newToolTestAgent(t)
params := findToolParams(t, a, "doc_commit")
if _, ok := params["tags"]; ok {
t.Error("doc_commit 仍有 tags 参数,但 Doc.Tags 已删除 —— 参数会静默丢弃")
}
}
// ---- helpers ----
func findToolDescription(t *testing.T, a *Agent, name string) string {
t.Helper()
for _, raw := range a.buildToolDefs() {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
fn, ok := m["function"].(map[string]interface{})
if !ok {
continue
}
if fn["name"] == name {
d, _ := fn["description"].(string)
return d
}
}
t.Fatalf("工具 %s 不在 buildToolDefs 里", name)
return ""
}
func findToolParams(t *testing.T, a *Agent, name string) map[string]interface{} {
t.Helper()
for _, raw := range a.buildToolDefs() {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
fn, ok := m["function"].(map[string]interface{})
if !ok {
continue
}
if fn["name"] == name {
p, _ := fn["parameters"].(map[string]interface{})
props, _ := p["properties"].(map[string]interface{})
return props
}
}
t.Fatalf("工具 %s 不在 buildToolDefs 里", name)
return nil
}
// ⑤ 预填充注入的文案必须:给真实内容 + 明确引导用 doc_query。
//
// 断点史:旧文案只给「来自 4 个来源的 61 条对话 相关度:0.78」——
// 统计描述,没有任何内容,也没有一个字提示可以取全文。
// 生产日志里 agent 从不使用预填充给的信息,直接原因就是这个。
//
// ★ 用**真实归档路径**(ContextToDoc)构造文档,不手工拼 DocEvent:
//
// 块 ID 由 entryTextBlockID 派生,手工拼的 ID 不匹配会被判为非本事件块,
// 那样测的是"我拼的数据对不对",不是生产过程。
func TestPrefillMarkdownCarriesContentAndGuide(t *testing.T) {
a := newToolTestAgent(t)
if _, err := a.docStore.ContextToDoc("context_archived", []document.ContextEntry{{
Timestamp: time.Date(2026, 7, 8, 9, 10, 0, 0, time.UTC),
Source: "webui",
Content: "配色方案怎么定?",
Response: "紫蓝红三色带的配色方案按六比三比一,主色紫、辅色蓝、点缀红。",
}}, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
doc := a.docStore.RecentDocs(1)[0]
md := a.crossModalMarkdown([]CrossModalHit{{Doc: doc, DocScore: 0.8, Fused: 0.8}})
if md == "" {
t.Fatal("预填充文案为空")
}
// 1) 必须给真实内容(正文片段),不是模板摘要
if !strings.Contains(md, "紫蓝红") && !strings.Contains(md, "配色方案") {
t.Errorf("预填充文案没有真实正文片段:\n%s", md)
}
// 2) 不得再出现 jieba 模板摘要
if strings.Contains(md, "涉及:") {
t.Errorf("预填充文案仍是 jieba 模板摘要:\n%s", md)
}
// 3) 必须有时间
if !strings.Contains(md, "2026-07-08") {
t.Errorf("预填充文案没有时间:\n%s", md)
}
// 4) ★ 必须明确引导使用 doc_query
if !strings.Contains(md, "doc_query") {
t.Errorf("预填充文案没有引导使用 doc_query:\n%s", md)
}
// 5) 必须说明它来自历史层而非当前对话
if !strings.Contains(md, "L2") && !strings.Contains(md, "历史记忆") {
t.Errorf("预填充文案没有说明来源层:\n%s", md)
}
}

View File

@ -284,3 +284,73 @@ func TestRecallByDoc_StressManyEvents(t *testing.T) {
}
t.Logf("回填 %d 条耗时 %v", got, elapsed)
}
// ⑥ doc_query 的返回值必须**带真实内容**,不能只有统计描述。
//
// 断点史(生产日志实测):旧返回值是
//
// 已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok), #2(...)
//
// 全 jieba 词袋,零信息量 ⇒ agent 无法判断这笔召回有没有用 ⇒ 几乎不再调用。
//
// 本判据要求返回值同时具备三件事:
// 1. 明确说明原文去了哪里("插回对话时序")
// 2. 每条给出时间 + 来源(可判断"这是哪次的事")
// 3. 每条给出真实正文片段(可判断"这是不是我要的")
func TestRecallByDoc_ReturnValueCarriesRealContent(t *testing.T) {
a := newToolTestAgent(t)
const sentinel = "紫蓝红三色带按六比三比一"
archiveEvents(t, a, []ContextEvent{{
Timestamp: time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC),
Source: "webui",
Input: "配色方案怎么定?",
Response: "结论:" + sentinel + ",主色紫、辅色蓝、点缀红。",
}})
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "配色方案 紫蓝红", "top_k": float64(3)},
})
// 1) 必须说明原文去了时序
if !strings.Contains(out, "时序") {
t.Errorf("返回值没说原文去了哪里:\n%s", out)
}
// 2) 必须有时间(年月日或月-日)
if !strings.Contains(out, "2026-06-07") && !strings.Contains(out, "06-07") {
t.Errorf("返回值没有时间信息:\n%s", out)
}
// 3) 必须有来源
if !strings.Contains(out, "webui") {
t.Errorf("返回值没有来源信息:\n%s", out)
}
// 4) ★ 必须有真实正文(否则模型无法判断相关性)
if !strings.Contains(out, "六比三比一") {
t.Errorf("返回值不含真实正文片段(模型无法判断相关性):\n%s", out)
}
// 5) 不得回退成 jieba 词袋的模板摘要
if strings.Contains(out, "涉及:") {
t.Errorf("返回值仍是 jieba 词袋模板摘要:\n%s", out)
}
}
// ⑦ 旧格式文档的召回也要给出可判断的信息(降级路径不能退化成无内容)。
func TestRecallByDoc_LegacyReturnAlsoCarriesContent(t *testing.T) {
a := newToolTestAgent(t)
if err := a.docStore.Insert(&document.Doc{
Summary: "旧格式摘要",
Content: "[10:00] legacy: 旧格式文档里的真实正文在这里",
Source: "context_archived",
Meta: map[string]string{"content_hash": "legacy-2"},
}); 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.Errorf("存量文档召回没有给出正文:\n%s", out)
}
}

View File

@ -1030,26 +1030,21 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
if len(docs) == 0 {
return "未找到相关文档记忆"
}
var parts []string
var refs []string
var b strings.Builder
b.WriteString(fmt.Sprintf("已召回 %d 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):\n", len(docs)))
totalEvents := 0
for i, d := range docs {
parts = append(parts, fmt.Sprintf("[%d] %s (来源: %s)", i+1, d.Summary, d.Source))
if len(d.Tags) > 0 {
parts = append(parts, " 标签: "+strings.Join(d.Tags, ", "))
}
// ★★ 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件
// 召回 = 把事件(连同它持有的块)迁回 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)降级:整篇渲成一个事件,保持既有行为。
// 不伪造成「有原始事件」——那时归档确实只有渲染视图。
// ARCHITECTURE「记忆流转」② Document 层:
// doc_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
n := a.restoreDocEvents(d)
if n == 0 {
// 存量文档(无 Events)降级:整篇渲成一个事件。
// 不伪造成「有原始事件」—— 那时归档确实只有渲染视图。
content := d.Content
if labels := a.blockLabelsForDoc(d); labels != "" {
content = content + "\n关联媒体: " + labels
@ -1060,11 +1055,25 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
Input: fmt.Sprintf("加载文档记忆: %s", query),
Response: content,
})
n = 1
}
totalEvents += n
// ★ 每条给出「什么时候 + 谁说的 + 正文开头」,
// 让模型**立刻**能判断这笔召回有没有用。
//
// 旧返回只有「[1] 来自 1 个来源的 1 条对话 (agent) 涉及: ok」——
// jieba 词袋,零信息量,模型无法据此判断相关性。
b.WriteString(fmt.Sprintf("\n[%d] %s\n", i+1, docRecallHeadline(d, n)))
// 附上少量真实正文行,供模型即时判断(完整原文在时序里)。
if preview := docRecallPreview(d, n, 3); preview != "" {
b.WriteString(preview)
}
refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary))
}
return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(正文已按原时间戳回填到对话时序,直接读时序即可)",
len(docs), strings.Join(refs, ", "))
b.WriteString(fmt.Sprintf("\n共回填 %d 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。", totalEvents))
return b.String()
case "doc_commit":
content, _ := tc.Arguments["content"].(string)
@ -1076,18 +1085,9 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
summary = truncateStr(content, 100)
}
tagsRaw, _ := tc.Arguments["tags"].([]interface{})
var tags []string
for _, t := range tagsRaw {
if s, ok := t.(string); ok {
tags = append(tags, s)
}
}
doc := &document.Doc{
Summary: summary,
Content: content,
Tags: tags,
Source: "manual",
}
@ -1324,3 +1324,110 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int {
d.Events[len(d.Events)-1].Timestamp.Format("01-02 15:04:05"), blocks)
return n
}
// docRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。
//
// 为什么不用 Doc.Summary:那是 jieba 词袋("来自 N 个来源的 M 条对话 涉及: ok"),
// 零信息量。模型看到它无法判断这笔召回有没有用 —— 这正是 agent 几乎不用
// doc_query 的直接原因之一。
func docRecallHeadline(d *document.Doc, eventCount int) string {
if d == nil {
return ""
}
var parts []string
if d.HasEvents() {
first := d.Events[0].Timestamp
last := d.Events[len(d.Events)-1].Timestamp
if first.Equal(last) {
parts = append(parts, first.Format("2006-01-02 15:04"))
} else {
parts = append(parts, fmt.Sprintf("%s → %s",
first.Format("01-02 15:04"), last.Format("01-02 15:04")))
}
// 来源集合(去重,最多 3 个)
seen := map[string]bool{}
var srcs []string
for _, ev := range d.Events {
if ev.Source != "" && !seen[ev.Source] {
seen[ev.Source] = true
srcs = append(srcs, ev.Source)
if len(srcs) >= 3 {
break
}
}
}
if len(srcs) > 0 {
parts = append(parts, strings.Join(srcs, "/"))
}
} else {
parts = append(parts, d.CreatedAt.Format("2006-01-02 15:04"))
if d.Source != "" {
parts = append(parts, d.Source)
}
}
parts = append(parts, fmt.Sprintf("%d 条事件", eventCount))
if n := len(d.Blocks); n > 0 {
media := 0
for i := range d.Blocks {
if d.Blocks[i].Modality != memory.BlockText {
media++
}
}
if media > 0 {
parts = append(parts, fmt.Sprintf("含 %d 份媒体", media))
}
}
return strings.Join(parts, " · ")
}
// docRecallPreview 取少量**真实正文**行,让模型即时判断相关性。
//
// 只取前 maxLines 行、每行截短:完整原文已在对话时序里(restoreDocEvents 回填),
// 这里只是"先给你看一眼是什么",不是全文。
func docRecallPreview(d *document.Doc, eventCount, maxLines int) string {
if d == nil || maxLines <= 0 {
return ""
}
var out []string
add := func(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return len(out) < maxLines
}
out = append(out, " "+oneLinePreview(s, 100))
return len(out) < maxLines
}
if d.HasEvents() {
for _, ev := range d.Events {
in, resp, _ := ev.InOut()
if in != "" && !add(in) {
return strings.Join(out, "\n")
}
if resp != "" && !add(resp) {
return strings.Join(out, "\n")
}
}
} else {
// 存量文档:从渲染视图取前几行
for _, line := range strings.Split(d.Content, "\n") {
if !add(line) {
break
}
}
}
if len(out) == 0 {
return ""
}
return strings.Join(out, "\n")
}
// oneLinePreview 把一段文本压成单行短摘要(换行折叠、超长截断)。
func oneLinePreview(s string, maxRunes int) string {
s = strings.Join(strings.Fields(s), " ")
r := []rune(s)
if len(r) <= maxRunes {
return s
}
return string(r[:maxRunes]) + "…"
}

View File

@ -177,6 +177,26 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
prompt = appendPromptBlock(prompt, memContext)
}
// 【三层记忆】是**内核运行时块**(提示词分层规范:内核行为由内核生成)。
//
// 它讲的是内核的真实执行语义(哪些内容在哪一层、何时会进上下文、
// 哪个工具会做什么),不是工具用法 —— 工具用法归各 ToolDef.Description。
//
// 为什么必须有这一段:此前系统提示词从未解释 L1/L2/L3 是什么,
// agent 只能从工具名猜。生产日志实测:agent 几乎从不调用 doc_query,
// 即使调用了也只看返回值、不知道原文已被回填到对话时序。
prompt = appendPromptBlock(prompt, "【三层记忆】记忆分三层,你要知道自己现在在看哪一层:\n"+
"- **对话时序(L1,你现在看得到)**:最近的事件,按时间排列在上面那段【对话时序】里。"+
"窗口放不下的会更早被移出,但不会丢。\n"+
"- **历史记忆(L2,不直接给你看)**:被移出窗口的事件,连同**完整原文**(含图片/音频)保存在这里。"+
"每次你收到新输入,内核会按语义相似度自动挑几篇相关的放在【历史记忆】块里,"+
"但那只是片段 —— 要看某次的完整原文与过程,调用 `doc_query`:"+
"它会把命中的事件**按原始时间戳插回你的对话时序**(不是返回一段摘要,那些事件真的回到了时间线上)。\n"+
"- **图记忆(L3,长期)**:实体与关系(谁是什么、某值现在是多少、谁和谁什么关系),"+
"用 `memory_recall` 查、`memory_commit` 记。\n"+
"选择口径:要**原文与过程** → `doc_query`;要**关系与当前值** → `memory_recall`;"+
"要**成文的可复用知识** → `knowledge_search`。")
// 记忆维护:具体选哪个工具、各有什么副作用,都写在 memory_* 的工具描述里
//(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。
prompt = appendPromptBlock(prompt, "【记忆维护】用户要求整理或清理记忆时,必须真的调用 memory_* 工具去做,"+
@ -556,24 +576,47 @@ func (a *Agent) buildToolDefs() []interface{} {
}
if a.docStore != nil {
tools = append(tools, toolDefWith("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "查询内容"},
"top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 3},
}, []string{"query"}, parallelOpts()))
tools = append(tools, toolDef("doc_commit", "提交一条文档记忆。将重要信息显式写入文档记忆层。", map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "文档内容"},
"summary": map[string]interface{}{"type": "string", "description": "摘要(可选)"},
"tags": map[string]interface{}{
"type": "array",
"description": "标签列表",
"items": map[string]interface{}{"type": "string"},
},
"media_digests": map[string]interface{}{
"type": "array",
"description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
"items": map[string]interface{}{"type": "string"},
},
}, "content"))
// ★ doc_query / doc_commit 的描述必须与**真实行为**一致。
//
// 旧描述只有一句「查询文档记忆。输入查询内容,返回相关文档摘要」:
// · "文档记忆"没有定义,模型不知道 L2 是什么;
// · "返回相关文档摘要"与真实行为**不符**(真实是把事件逐条按
// 原始时间戳插回对话时序);
// · 没说什么时候该用,也没说与 memory_recall 的分工。
// 结果:生产实测 agent 几乎不调用 doc_query,即使调用也只看返回值、
// 不知道原文已回填到时序。
tools = append(tools, toolDefWith("doc_query",
"从**历史记忆层**(L2)召回过去的事件原文。"+
"\n\n**这一层是什么**:当前对话窗口里只看得到最近的事件;更早的事件被移出窗口后,"+
"连同原文完整保存在历史层,不会丢失。"+
"\n\n**何时用**:记得«这事办过/说过»但想不起细节;需要当时的原始过程而非结论;"+
"跨会话、跨天追问;用户问\"上次那个\"/\"之前怎么弄的\"。只想知道某个事实的当前值、"+
"或某个实体的关系,用 memory_recall(图记忆),不要用这个。"+
"\n\n**调用后会发生什么**:命中的事件会**逐条按原始时间戳插回你的对话时序**"+
"(就是【对话时序】那一段,出现在它当时的时间点上),"+
"返回值会列出每条的时间/来源/正文开头。要引用细节,读时序里对应时间点即可。"+
"\n\n**副作用**:召回即从历史层取出(消费式),该文档不再留在 L2。",
map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "查询内容,用当时可能出现的词描述"},
"top_k": map[string]interface{}{"type": "integer", "description": "召回多少篇(默认 3)", "default": 3},
}, []string{"query"}, parallelOpts()))
tools = append(tools, toolDef("doc_commit",
"把**整段结论/过程**写进历史记忆层(L2)。"+
"\n\n**适合写什么**:做完一件事的完整过程与结论、排查链条、方案取舍的理由 —— "+
"这些内容长、有上下文、以后要回看原文。"+
"\n\n**不适合写什么**:单个事实或实体关系(谁是谁、某值等于多少、偏好)→ 用 memory_commit;"+
"可复用的成文知识 → 用 knowledge_create。"+
"\n\n写入后会参与后续的自动预填充与 doc_query 召回。",
map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "文档内容(完整正文,不要自己截断)"},
"summary": map[string]interface{}{"type": "string", "description": "一句话摘要(可选,留空则由正文首行生成)"},
"media_digests": map[string]interface{}{
"type": "array",
"description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
"items": map[string]interface{}{"type": "string"},
},
}, "content"))
}
if a.social != nil {