🧠 Day 5 · 记忆增强 + 打标签 / 摘要工具

预计时间:2-3 小时 · 难度:⭐⭐⭐ · 目标:给 Day 4 的知识助手加上"长期能用"的两个关键能力 —— 对话历史自动压缩、让 Agent 反过来整理你的笔记
📖 今日路线

为什么需要 Day 5

Day 4 结束时,我们留了两个明显的问题:

  1. Messages 会无限增长:每轮对话都往 messages 里追加,还带上工具的完整返回(有时候一次 search 返回 3 段 × 500 字)。聊十几轮之后:
  2. Agent 只会"读",不会"整理":Day 4 的三个工具全是只读。真实用起来会想让 Agent 顺手打个标签、留个摘要,下次 list_tags 就能一眼看到笔记全景。
💡 核心思想
记忆压缩是让 Agent 能"聊很久"的必要能力 —— 就像人不会记住每次对话的每个字,而是记要点。
写入工具让 Agent 从"知识检索器"升级为"知识管理者" —— 它开始改变数据,而不只是读取。

架构设计

Day 5 相对 Day 4 的变化,用一张表说清:

模块状态说明
ark_embedding.py不变直接从 day4 拷贝
ingest.py不变直接从 day4 拷贝
knowledge_base.py扩展建库和读全文时剥掉 frontmatter;新增 read_meta / update_meta
tools.py扩展3 个新工具(add_tag、summarize_note、list_tags);引入 ToolContext 传递 LLM client
agent.py改造把裸 messages list 换成 ConversationMemory,每轮调用 maybe_compress()
memory.py🆕 新增对话历史压缩逻辑
frontmatter.py🆕 新增极简 YAML frontmatter 解析器(100 行,不引依赖)

数据流

用户 ─→ agent.py 主循环
             │
             │ 1) memory.append(user)
             │ 2) LLM(memory.get_messages()) → tool_calls
             │ 3) execute_tool(name, args, ToolContext)
             │ 4) memory.append(tool_result)
             │ 5) (循环 2-4 直到 LLM 返回文本)
             │ 6) memory.maybe_compress()  ← ⭐ 新增
             ▼
        显示回答 + 压缩事件

笔记文件 (.md):
    ┌──── frontmatter ────┐
    │ tags: [go, backend] │  ← add_tag 写入
    │ summary: "Go 用..."   │  ← summarize_note 写入
    └─────────────────────┘
    # Go 学习备忘         ← 正文(唯一进入向量库的部分)
    ...

Part 1 · 对话记忆:滑窗 + 自动摘要

这是今天的重头戏。ConversationMemory 类的核心是 maybe_compress() 方法:

触发条件:总字符数 ≥ TRIGGER_CHARS(默认 3000)

保留策略:
  [0]        system prompt          ← 永远保留
  [1]        [对话历史摘要]          ← 如果压缩过,这里是一条 system 消息
  [2..N-K]   (被摘要吞掉的旧对话)
  [N-K..N]   最近 KEEP_RECENT_ROUNDS 轮完整对话(默认 3 轮)

关键坑:不能切在 tool_call 中间

⚠️ 一个"轮次"在 messages 里可能是多条:
user → assistant(tool_calls) → tool → assistant(tool_calls) → tool → assistant(final)
OpenAI API 要求 tool_calls 必须紧跟对应的 tool 响应。压缩时如果切在这中间,下一次调用直接 400 报错。

解法:只在 role == "user" 的位置切分(那是一轮的起点)。memory.py 里的 _find_round_boundaries 就是干这个的。

摘要提示词

摘要用的是同一个 LLM(make_llm_summarizer)。提示词着重让它:

  1. 说清用户问过哪些问题
  2. 助手做过哪些关键操作、给出了什么核心结论
  3. 涉及了哪些笔记文件(这个很重要 —— 恢复上下文用)

实测效果,一次真实压缩:

压缩前: 9 条 / 723 字符
✅ 触发压缩:
   9 条 / 723 字符 → 6 条 / 548 字符

LLM 生成的摘要:
用户询问笔记中 Go 错误处理的内容;助手说明 Go 用返回值传 error,
errors.Is/As 判断类型,%w 包裹底层错误,并为 go-tips.md 添加 backend
标签;涉及笔记文件 go-tips.md。

Part 2 · 3 个写入工具

工具作用典型触发场景
add_tag(filename, tags)给笔记加标签(去重合并)用户:"给这篇加个 go/backend 标签"
summarize_note(filename)让 LLM 总结这篇笔记,保存到 frontmatter用户:"给 xxx 生成摘要并保存"
list_tags()列出所有笔记的 tags + summary(不涉及 chunks)用户:"我笔记有哪些主题?"

ToolContext:让工具能用 LLM

summarize_note 本身需要调 LLM 生成摘要。Day 4 的 execute_tool 是纯函数,没法访问 client。Day 5 引入了 ToolContext

@dataclass
class ToolContext:
    client: object = None      # OpenAI-like client
    chat_model: str = ""

# 用法
tool_ctx = ToolContext(client=client, chat_model=CHAT_MODEL)
result = execute_tool(name, args, ctx=tool_ctx)

这样纯本地工具(search_notes、add_tag)就忽略 ctx,需要 LLM 的工具从 ctx 里取 client。比全局变量干净、比每个工具都注入 client 参数简洁

Prompt 里怎么"教育"Agent 用写入工具

写入工具很危险 —— 一旦 LLM 幻觉,你的笔记就被乱改了。System prompt 里加了这条:

6. 写入工具要谨慎:只有用户明确说"加标签"、"保存摘要"时才调用
   add_tag / summarize_note。仅仅想看总结时用 read_full_note 自己
   总结即可。

实测下来 LLM 严格遵守 —— 说"总结一下"它就自己 read + 总结,说"生成摘要并保存"它才调 summarize_note


Part 3 · YAML frontmatter

标签和摘要该存哪?三个候选(Day 5 前思考的时候排过序):

方案优点缺点
YAML frontmatter(选中)元数据和内容在同一个文件、可 git diff、跨工具通用(Obsidian/Hugo/Jekyll 都用它)需要写个小解析器
旁路 JSON 文件不改笔记要同步两份文件,用户可能改文件名后忘了同步 JSON
存进 ChromaDB metadata零新代码笔记的元数据被锁在向量库里,用户看不到、Chroma 数据丢了就没了

选 frontmatter 的关键理由:元数据应该跟着数据走。你 cat go-tips.md 或者用 Obsidian 打开,tags 和 summary 都是"人类可读、机器可读"的。

为什么手写解析器(不用 PyYAML)

Day 5 只需要三种值:字符串、字符串列表、多行字符串(暂时不支持)。写个 40 行的解析器就够了,不引入新依赖:

---
tags: [go, error-handling]
summary: Go 用返回值传错误
---

整个 frontmatter.py 就 80 行,纯正则 + 几个 if。原则:MVP 阶段不引入未来 90% 用不上的复杂度

💡 建库时要剥掉 frontmatter:否则 tags: [go, backend] 也会被切成一个 chunk,用户问"Go 的错误处理"时可能匹配到一行 tags: [go],答非所问。knowledge_base.pyrebuild()read_full_note() 都做了这个剥离。

Step · 跑起来试试

cd ~/Documents/practice/agents
cp -r day4 day5   # 或者手工建目录 + 拷文件
cd day5

# 环境
source venv/bin/activate  # 如果从 day4 复用 venv 就跳过 pip install

# 建库(因为新增了 frontmatter 剥离逻辑,重新建一次)
python ingest.py

# 开始对话
python agent.py

推荐提问顺序(覆盖新老能力)

# 1. 只读功能不回退(Day 4 老场景)
你: 我笔记里 Go 的错误处理是怎么讲的?

# 2. 触发写入工具:summarize_note
你: 给 go-tips.md 生成一份摘要并保存

# 3. 触发写入工具:add_tag(并测试去重合并)
你: 给 rag-notes.md 加两个标签:rag 和 embedding

# 4. list_tags:查看所有笔记元信息
你: 现在我笔记有哪些主题?

# 5. 触发记忆压缩(如果字符数够)
你: 继续聊多几轮 …… 观察 🧠 [记忆压缩] 事件

预期看到的输出

你: 给 go-tips.md 生成一份摘要并保存

  ─ 第 1 轮 ─
  🤔 LLM 决定调用 1 个工具
  🔧 summarize_note({"filename":"go-tips.md"})
     ↳ 已为 go-tips.md 生成并保存摘要:
       Go 学习备忘涵盖错误处理(返回值传 error)、并发(goroutine + channel)、
       项目组织(go.mod)、Slice 陷阱……

  ─ 第 2 轮 ─
  🤔 LLM 综合信息,给出回答

🤖 已为 go-tips.md 生成并保存摘要:……[来源: go-tips.md]

📊 本次任务:2 轮 LLM 调用 | 1 次工具调用 | 消耗 5989 tokens
💾 对话历史:11 条 / 2771 字符 | 累计 tokens: 12707

然后 head -5 notes/go-tips.md 会看到:

---
summary: Go 学习备忘涵盖错误处理(返回值传 error、显式风格)、并发……
---
# Go 学习备忘
...

观察记忆压缩

当对话累计字符超过阈值(默认 3000),你会在某次回答后看到:

🧠 [记忆压缩] 15 条 / 3241 字符 → 6 条 / 812 字符
   摘要预览: 用户先问 Go 错误处理,助手给出错误处理说明;接着让助手为 go-tips.md 添加 backend 标签……

注意:压缩后再继续对话,Agent 还能"记得"之前聊过什么 —— 因为那段摘要以 system 消息形式插在了 messages 里。


🕳️ 今日踩坑

解决
压缩时切在 tool_call 中间 → 下一次调用 400 报错只在 role == "user" 处切分
LLM 频繁触发 summarize_note(用户只是想看总结)System prompt 明确"只有用户说'保存'才调 summarize_note"
frontmatter 被切进 chunk,检索命中"tags: [go]"这种噪声建库 + 读全文都做 frontmatter.parse() 剥离
list 类型标签 + 更新时重复update_meta 里 list 字段做保序去重合并
压缩阈值太高(如 6000 字符),日常对话根本触发不到调到 3000 字符(约 2000 tokens);keep_recent_rounds=3
压缩后 messages[1] 是 system 摘要,下次压缩要跳过它maybe_compressfirst_scan 循环跳过前置 system 消息

✅ 验收标准

  1. ✅ 能让 Agent 生成并保存摘要,head notes/xxx.md 看到 frontmatter 里有 summary:
  2. ✅ 能让 Agent 加标签,重复加不会重复出现
  3. list_tags 能列出所有笔记的 tags 和 summary
  4. ✅ 长对话(>3000 字符)触发压缩事件,压缩后仍能延续对话
  5. ✅ 建库/读全文时 frontmatter 不会被 LLM 当正文看到

📦 收工提交

cd ~/Documents/practice/agents
git status
git add day5/
git commit -m "Day 5: 记忆增强 + 打标签/摘要(frontmatter)"
git push

🎉 下一站:Day 6 · Streamlit Web UI

Day 5 结束时你已经有一个"真能用"的助手了 —— 但它还是命令行。Day 6 会: