Day 4 结束时,我们留了两个明显的问题:
list_tags 就能一眼看到笔记全景。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 学习备忘 ← 正文(唯一进入向量库的部分)
...
这是今天的重头戏。ConversationMemory 类的核心是 maybe_compress() 方法:
触发条件:总字符数 ≥ TRIGGER_CHARS(默认 3000)
保留策略:
[0] system prompt ← 永远保留
[1] [对话历史摘要] ← 如果压缩过,这里是一条 system 消息
[2..N-K] (被摘要吞掉的旧对话)
[N-K..N] 最近 KEEP_RECENT_ROUNDS 轮完整对话(默认 3 轮)
user → assistant(tool_calls) → tool → assistant(tool_calls) → tool → assistant(final)解法:只在 role == "user" 的位置切分(那是一轮的起点)。memory.py 里的 _find_round_boundaries 就是干这个的。
摘要用的是同一个 LLM(make_llm_summarizer)。提示词着重让它:
实测效果,一次真实压缩:
压缩前: 9 条 / 723 字符
✅ 触发压缩:
9 条 / 723 字符 → 6 条 / 548 字符
LLM 生成的摘要:
用户询问笔记中 Go 错误处理的内容;助手说明 Go 用返回值传 error,
errors.Is/As 判断类型,%w 包裹底层错误,并为 go-tips.md 添加 backend
标签;涉及笔记文件 go-tips.md。
| 工具 | 作用 | 典型触发场景 |
|---|---|---|
add_tag(filename, tags) | 给笔记加标签(去重合并) | 用户:"给这篇加个 go/backend 标签" |
summarize_note(filename) | 让 LLM 总结这篇笔记,保存到 frontmatter | 用户:"给 xxx 生成摘要并保存" |
list_tags() | 列出所有笔记的 tags + summary(不涉及 chunks) | 用户:"我笔记有哪些主题?" |
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 参数简洁。
写入工具很危险 —— 一旦 LLM 幻觉,你的笔记就被乱改了。System prompt 里加了这条:
6. 写入工具要谨慎:只有用户明确说"加标签"、"保存摘要"时才调用
add_tag / summarize_note。仅仅想看总结时用 read_full_note 自己
总结即可。
实测下来 LLM 严格遵守 —— 说"总结一下"它就自己 read + 总结,说"生成摘要并保存"它才调 summarize_note。
标签和摘要该存哪?三个候选(Day 5 前思考的时候排过序):
| 方案 | 优点 | 缺点 |
|---|---|---|
| YAML frontmatter(选中) | 元数据和内容在同一个文件、可 git diff、跨工具通用(Obsidian/Hugo/Jekyll 都用它) | 需要写个小解析器 |
| 旁路 JSON 文件 | 不改笔记 | 要同步两份文件,用户可能改文件名后忘了同步 JSON |
| 存进 ChromaDB metadata | 零新代码 | 笔记的元数据被锁在向量库里,用户看不到、Chroma 数据丢了就没了 |
选 frontmatter 的关键理由:元数据应该跟着数据走。你 cat go-tips.md 或者用 Obsidian 打开,tags 和 summary 都是"人类可读、机器可读"的。
Day 5 只需要三种值:字符串、字符串列表、多行字符串(暂时不支持)。写个 40 行的解析器就够了,不引入新依赖:
---
tags: [go, error-handling]
summary: Go 用返回值传错误
---
整个 frontmatter.py 就 80 行,纯正则 + 几个 if。原则:MVP 阶段不引入未来 90% 用不上的复杂度。
tags: [go, backend] 也会被切成一个 chunk,用户问"Go 的错误处理"时可能匹配到一行 tags: [go],答非所问。knowledge_base.py 的 rebuild() 和 read_full_note() 都做了这个剥离。
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_compress 里 first_scan 循环跳过前置 system 消息 |
head notes/xxx.md 看到 frontmatter 里有 summary:list_tags 能列出所有笔记的 tags 和 summarycd ~/Documents/practice/agents
git status
git add day5/
git commit -m "Day 5: 记忆增强 + 打标签/摘要(frontmatter)"
git push
Day 5 结束时你已经有一个"真能用"的助手了 —— 但它还是命令行。Day 6 会:
KnowledgeAgent 从 CLI 里抽出来(引擎/表现层分离)