Datawhale Easy Data × AI:构建知识与记忆驱动的 Agent笔记8
Task 8 · D4(Agent 记忆系统开发)+ I7(PowerContext 设计与实现)· 打卡笔记
📚 D4 学习笔记:Agent 开发与记忆系统
Agent 的两种架构本质区别
| 维度 | 单次 API 调用(D1-D2) | Agent 推理循环(ReAct,D4) |
|---|---|---|
| 调用方式 | llm.chat() 一次返回 |
for step in range(max_steps): 多步循环 |
| 工具调用 | 单次 Tool Use | 可能多步、多次、并行 |
| 结果驱动 | 直接消费 | 工具结果反馈回 Agent 进入下一轮推理 |
| 安全阀 | 无特殊需要 | max_steps 限制防止无限循环 |
| 适用场景 | RAG 问答、单轮任务 | 复杂问题拆解、多工具协同 |
ReAct 循环伪代码(I6 第 5 节的动手练习代码 1 对应这里):
python
def agent_loop(user_input, tools, max_steps=10):
messages = [system_prompt, user_input]
for step in range(max_steps):
response = llm.chat(messages=messages, tools=tools)
if response.has_tool_calls():
for tool_call in response.tool_calls:
result = execute_tool(tool_call)
messages += [tool_call_msg, tool_result_msg(result)]
else:
return response.content
return "抱歉,无法在有限步骤内完成。"
四种记忆在 ReAct 循环中的位置
┌──────── 推理循环之前(Prompt 注入阶段)────────┐
│ │
│ ① 程序记忆 → System Prompt(行为规则) │
│ ② 语义记忆 → search_memory(query=...) │
│ 检索用户事实和偏好注入 System │
│ ③ 情景记忆 → search_episodes(query=...) │
│ 检索历史成功经验作为 few-shot │
│ │
└──────────────────────┬────────────────────────┘
│
▼
┌──── ReAct 推理循环 ────┐
│ agent_loop(...) │
└───────────┬────────────┘
│
┌───────────▼────────────┐
│ 推理后(提炼存储阶段) │
│ extract_facts(...) │
│ save_memory(facts) │
└────────────────────────┘
Note :所有长期记忆都是在 Agent 推理循环之前被注入到 System Prompt 中的。它们的作用是让 Agent 在开始推理时就已经"认识"这个用户、"记得"过去的经验。推理循环结束后再提炼新事实存入记忆库。
语义记忆的三个核心操作
| 操作 | 代码函数 | 实现机制 | D4 对应函数 |
|---|---|---|---|
| 提炼(记) | extract_facts_from_conversation |
LLM 从对话中 JSON 数组格式返回结构化事实 | d4_3: 8~12 条事实提炼 |
| 检索(想起) | search_memory |
向量检索 + 时效性权重(艾宾浩斯简化版) | query_texts=[query] + math.exp(-age_days/30) |
| 降权(忘) | age_days / 30 指数衰减 |
30 天半衰期 + access_count × 0.1 加分 | recency_weight + access_bonus |
艾宾浩斯简化公式(d4_3 第 228 行):
python
age_days = (current_time - metadata["created_at"]) / 86400
recency_weight = math.exp(-age_days / 30) # 30天半衰期
access_bonus = metadata.get("access_count", 0) * 0.1 # 每次被想起加分
weight = recency_weight + access_bonus
🔑 关键代码 + 运行输出(Windows 适配版)
环境适配说明 :课程原代码依赖 pyseekdb embedded client(只支持 Linux),Windows 上不可用。本笔记使用 ChromaDB + 自定义 seekdb_runtime 兼容层 实现完全等价的 API。
代码 1:ReAct 推理循环(I6 第 5 节第 3 小节动手练习)
python
# d4_1_react_loop.py --- Agent 自主决定调用工具、调用几次
answer = agent_loop(
"我想从北京去东京旅游,帮我查一下两个城市的天气,以及人民币兑日元的汇率。"
)
运行输出(2026-10-09 重跑):
============================================================
ReAct 推理循环演示
============================================================
【测试 1】简单问题:单次工具调用
----------------------------------------
[开始] 用户提问:北京今天天气怎么样?
[步骤 1] 调用工具:get_weather({'city': '北京'})
结果:晴天,气温 22°C,湿度 45%
[步骤 2] Agent 给出最终回答(共 2 步)
回答:北京今天天气如下:
- **天气状况**:晴天 ☀️
- **气温**:22°C
- **湿度**:45%
今天北京天气晴朗,气温舒适,湿度适中,非常适合外出活动。不过晴天紫外线可能较强,建议外出时做好防晒措施。
【测试 2】复合问题:需要多次工具调用
----------------------------------------
[开始] 用户提问:我想从北京去东京旅游,帮我查一下两个城市的天气,以及人民币兑日元的汇率。
[步骤 1] 调用工具:get_weather({'city': '北京'}) ← 并行 3 次工具调用
结果:晴天,气温 22°C,湿度 45%
[步骤 1] 调用工具:get_weather({'city': '东京'})
结果:小雨,气温 18°C,湿度 85%
[步骤 1] 调用工具:get_exchange_rate({'from_currency': 'CNY', 'to_currency': 'JPY'})
结果:1 CNY = 20.83 JPY
[步骤 2] Agent 给出最终回答(共 2 步)
回答:以下是您从北京去东京旅游所需的查询结果:
## 🌤️ 天气情况
| 城市 | 天气 | 气温 | 湿度 |
|------|------|------|------|
| **北京** | ☀️ 晴天 | 22°C | 45% |
| **东京** | 🌧️ 小雨 | 18°C | 85% |
- **北京**:天气晴朗,气温舒适(22°C),湿度较低(45%),出行体感干爽。
- **东京**:正在下小雨,气温稍凉(18°C),湿度较高(85%),建议携带雨具和一件薄外套保暖。
## 💱 汇率信息
**1 人民币(CNY)= 20.83 日元(JPY)**
也就是说,如果您兑换 ...
【测试 3】闲聊问题:不需要工具
----------------------------------------
[开始] 用户提问:你好,你能做什么?
[步骤 1] Agent 给出最终回答(共 1 步) ← 自主决定不调工具
回答:你好!我是一个智能助手,可以帮你查询两类信息:
1. **天气信息** ------ 查询指定城市的当前天气,包括天气状况、气温和湿度。比如:北京、上海、东京、纽约等城市。
2. **汇率信息** ------ 查询两种货币之间的汇率,支持美元(USD)、人民币(CNY)和日元(JPY)。
如果你的问题需要同时用到这两类信息,我也可以分步查询后综合回答你。
有什么我可以做的?
============================================================
关键要点:
- Agent 的核心是一个推理循环,不是单次 API 调用
- Agent 自主决定调用哪个工具、调用几次
- max_steps 是安全阀,防止 Agent 无限循环
- 每次工具调用的结果都会反馈给 Agent,驱动下一轮推理
Note:
- 测试 2:Agent 在同一步里并行调用了 3 个工具(北京天气、东京天气、CNY→JPY 汇率)
- 测试 3:Agent 自主判断闲聊不需要工具,1 步直接返回回答
max_steps=5安全阀防止无限循环
代码 2:无记忆 vs 有记忆 效果对比(I6 第 5 节第 5 小节动手练习)
同样的四轮对话,两个版本的回答对比:
| 轮次 | 用户输入 | 无记忆 Agent | 有记忆 Agent |
|---|---|---|---|
| 1 | 我是 Python 后端,喜欢简洁 | 明白 | 好的,记住了 |
| 2 | 推荐 Web 框架 | 罗列 Spring/Express/Django/Rails... | 直接推荐 FastAPI,备选 Django/Flask |
| 3 | 加缓存 | 泛泛而谈各种缓存方案 | 推荐 Redis + redis-py,Python 生态 |
| 4 | 继续昨天话题,选数据库 | "我没有昨天的记录" | (跨会话持久化后记得) |
运行的记忆提炼(d4_3 对话第 1 轮):
[记忆] 存入 3 条新记忆:['用户是 Python 开发者', '用户主要做后端开发', '用户喜欢简洁的回答']
第 2 轮 System Prompt 自动注入:
python
memory_context = "\n".join([
"- 用户是 Python 开发者",
"- 用户主要做后端开发",
"- 用户喜欢简洁的回答"
])
system_prompt = f"""你是一个友好的技术助手。...你对这位用户有以下了解:
{memory_context}
请根据你了解的信息,提供个性化的、有针对性的回答。"""
代码 3:跨进程持久化验证(d4_4)
写入阶段(进程 1)--- 5 轮对话 → 9 条记忆:
>>> 首次运行,记忆库已创建
[记忆提炼] ['用户是一名 Python 开发者', '用户在一家做 SaaS 产品的创业公司工作']
[记忆提炼] ['用户喜欢简洁的回答,不需要太多解释']
[记忆提炼] ['用户所在的团队之前尝试过 RabbitMQ', '用户认为 RabbitMQ 配置太复杂']
[记忆提炼] ['用户在做 SaaS', '用户之前嫌 Celery 配置烦']
--- 写入阶段完成,当前记忆库 ---
[记忆库] 共 9 条记忆
读取阶段(全新进程 2 --- 重新启动程序):
>>> 已加载历史记忆库,当前记忆数量:9 ← 不是"首次运行"!
[记忆库] 共 9 条记忆
--- 验证查询 ---
用户:我之前说过我在哪种公司工作来着?
Agent:(从持久化记忆中检索出 SaaS 创业公司)
--- 持久化验证总结 ---
记忆库总数(含本轮新提炼):16
关键记忆验证:
✓ SaaS 创业公司:True
✓ Python 开发者:True
✓ 讨厌 RabbitMQ:True
✓ 喜欢简洁:True
为什么能持久化 :ChromaDB PersistentClient(path=...) 将记忆自动写入磁盘目录(memory_persistent.db/),进程结束时无需显式关闭,下次启动 has_collection() 返回 True → get_collection() 加载已有集合 → 记忆在。
代码 4:时效性降权(艾宾浩斯简化)
python
# d4_3 第 226-230 行
age_days = (current_time - metadata["created_at"]) / 86400
recency_weight = math.exp(-age_days / 30) # 30 天半衰期
access_bonus = metadata.get("access_count", 0) * 0.1
weight = recency_weight + access_bonus
| 记忆年龄 | recency_weight | access_count=0 | access_count=5 | access_count=10 |
|---|---|---|---|---|
| 1 天 | 0.967 | 0.967 | 1.467 | 1.967 |
| 7 天 | 0.794 | 0.794 | 1.294 | 1.794 |
| 30 天 | 0.500 | 0.500 | 1.000 | 1.500 |
| 60 天 | 0.250 | 0.250 | 0.750 | 1.250 |
| 90 天 | 0.125 | 0.125 | 0.625 | 1.125 |
含义:30 天前的记忆自然衰减一半,但如果在这 30 天里被检索了 10 次(每次 +0.1),总分 1.250 → 反而比新记忆更重。这就是 P3 讲的"经常被想起的记忆不会真正遗忘"的工程落地。
🏗️ I7 学习笔记:PowerContext 的设计与实现
一图总览:核心数据流
Source → Artifact ← Trigger
Source(来源) Artifact(产物) Trigger(触发)
┌──────────────┐ ┌───────────────────┐ ┌────────────────┐
│ 材料快照 │ │ Memory(事实/决定)│ │ 按事件+状态 │
│ 外部引用 │───▶│ Topic Memory(主题)│───▶│ 决定是否处理 │
│ Task Outcome │ │ Handoff(交接) │ │ │
└──────────────┘ │ Profile(背景) │ └────────────────┘
│ Experience(经验)│
└───────────────────┘
写入路径取决于内容是否明确
I7 课程第 1 节的三条主要写入路径:
text
Explicit entries ----------------------> Memory ← 直接写入(已确认内容)
Source --+--> Extract ------------------> Memory ← 提取路径(原始材料)
|
+--> Topic processing ---------> Topic Memory ← 多材料共同主题
Task Outcome --> Candidate --approve--> Experience ← 审核后才成为经验
关键设计决策:Task Outcome 也是 Source,它生成的是 Candidate(待审核提案),只有经过 ReviewService.approve() 批准后才变成正式 Experience。这保证了"未经证实的做法不会被自动写成成功经验"。
共用引用机制,各自决定如何使用
为什么要共用引用? 因为修订会生成新版本,但旧版本仍需精确读取。引用与内容一起流转,让后续操作有确定的核对对象。
ArtifactRef 三元组(I6 动手练习里见过):
python
# family + artifact_id + revision 定位修订
ArtifactRef(family="memory", artifact_id=..., revision=...)
# MemoryCitation 在此基础上再加条目级粒度
MemoryCitation(
artifact_ref=ArtifactRef(...),
entry_id=..., # 条目的稳定身份(修订前后不变)
entry_version_id=... # 每次修订变化
)
PreparedContext 装配流程(I7 第 3 节)
Recall hits + Profile snapshot
│
assembly ───▶ Select # 按类型选择,用引用去重
│
max_bytes ──▶ Fit # 裁剪时保留引用和可读内容
│
▼
Render # 渲染成可交付文本(含不可信历史标记)
│
▼
PreparedContext # status=ready/empty,content_bytes 精确
去重为什么用引用而不是正文相似度?
- 同一内容的两个版本即使文字相近,也有不同的
entry_version_id - 不同 revision 是否参与本次请求由准入规则决定,不是靠相似度猜测
预算裁剪的两条硬规则(I7 第 3 节)
- 先测后装:放入一条候选前先测完整输出(正文+引用+格式)是否还能放进预算
- 短条目不被长条目挡:靠前的长条目放不下 → 放弃它,继续试后面的短条目
I6 动手练习 6.1 对照实验(已在 Task 7 运行):
max_bytes=8000→ 正常装配返回status=readymax_bytes=512→ 如果连必要结构都装不下,返回status=empty,content=null
交接与经验的不同完成条件(I7 第 4 节)
python
# 交接可以走两条路:
# 路径 A:临时读取
handoff = handoff_service.prepare(target, evidence)
ready_handoff = handoff_service.finalize(handoff)
continue_handoff(prepared=ready_handoff) # 只读,不保存修订
# 路径 B:持久提交
revision_ref = handoff_service.commit(ready_handoff) # 保存修订
continue_handoff(revision=revision_ref, selection="exact") # 新进程也能读到
为什么要分 prepare/finalize/commit 三步?
- prepare:根据证据生成草稿,可能需要人工核对
- finalize:校验证据完整性,返回 PreparedHandoff(含版本基线)
- commit:持久化修订,系统检查基线是否仍有效(防止旧交接覆盖新进度)
实验:核对上下文接口的行为(I7 第 6 节)
6.1 对照预算,检查装配结果
bash
# 8000 字节预算(默认)
curl -d '{"scope_id":"...","query":"Context lab entry",
"max_bytes":8000,"assembly":{"sections":[{"family":"memory","limit":3}]}}'
http://127.0.0.1:8000/v1/context/prepare
# → status=ready, content_bytes ≤ 8000
# 512 字节(最小允许值)
curl -d '{"scope_id":"...","query":"Context lab entry",
"max_bytes":512,...}'
# → 如果装不下 → status=empty, content_bytes=0
要核对的 4 个字段:
| 字段 | 预期值 | 含义 |
|---|---|---|
schema |
powercontext.prepared-context.v1 |
数据版本标识 |
status |
ready 或 empty |
有无可交付内容 |
content_bytes |
≤ max_bytes 的精确 UTF-8 字节数 |
包含引用和格式 |
content |
含历史材料标记 + 精确引用 | 缩短的是正文,不是引用 |
6.2 临时交接 vs 持久交接
bash
# prepare → finalize:临时读取(不提交)
POST /v1/work/handoff_current_work
POST /v1/work/continue_handoff {"selection":"prepared","prepared":...}
# → 能读到,但没有新 revision
# commit:持久保存
POST /v1/work/commit_handoff
POST /v1/work/continue_handoff {"selection":"exact","revision":"..."}
# → 新会话也能精确读到同一份交接
6.3 停用条目,再读取原引用
三条验证语句:
| 读取方式 | 预期结果 |
|---|---|
search_memory(query=关键词) |
不再返回被停用的条目 |
list_memory_entries(include_inactive=true) |
该条目状态 = inactive |
get_memory_entry(citation=旧引用) |
仍能读到当时的正文和状态 |
为什么第三条能行? 停用控制的是当前召回 ,精确引用保留历史核对。这就是 Task 7 里代码 4-5 验证的"entry_id 不变、entry_version_id 变化、旧 citation 仍可读"的工程落地。
实验:矛盾条目的处理(可选 6.4)
PowerContext 不会自动裁决冲突。它的设计哲学是:
- 两条互不兼容的约束 → 都作为正式 Memory 保存
- 搜索时两条都返回,让 Agent 或用户决定用哪条
- 最终选择通过 revise + retire 执行(不是系统自动决策)
🌳 一张图收束:记忆系统的完整闭环
写 ──▶ 提炼(LLM JSON 提取)
│
├──▶ 压缩(事实化,20:1 压缩比)
│
├──▶ 存储(向量 + 元数据,含 created_at/access_count)
│
├──▶ 热层(近 7~30 天活跃) ← 为延迟买单
│
├──▶ 温层(偶发被问起)
│
├──▶ 冷层(长期未访问) ← 为容量买单
│
├──▶ 定期清理
│ ├── dry-run 先观测
│ ├── 降级权保留 ← 硬淘汰只删低价值+零访问+非保护
│ └── 级联清理(主库+向量+缓存)
│
└──▶ 命名空间隔离
└── WHERE user_id='xxx' (查询时强过滤)
记 ←── 推理后提炼 + save_memory()
│
▼
起 →── 推理前 search_memory() 注入 System Prompt
│
▼
忘 →── 时效性降权 + 冷热分层 + 定期清理
🔧 Windows 环境适配
| 问题 | 根因 | 解决方案 |
|---|---|---|
seekdb_runtime 模块不存在 |
课程代码引用 seekdb 包装层,但 pyseekdb 1.4.0 没有这个模块 | 自定义 code/seekdb_runtime.py 用 ChromaDB 实现等价 API |
| pyseekdb embedded client Windows 不可用 | pylibseekdb 只在 Linux 上发布 |
改用 ChromaDB PersistentClient |
Config.require_api_key 不存在 |
config.py 只有 check_api_key,课程代码调用 require_api_key |
给 config.py 加 require_api_key 方法 |
| 模型名硬编码 SiliconFlow 别名 | deepseek-ai/DeepSeek-V3 是 SiliconFlow 别名 |
4 个 d4_*.py 全改 MODEL = "deepseek-chat" |
| API Provider 硬编码 SILICONFLOW | Config.SILICONFLOW_API_KEY 和 Config.SILICONFLOW_BASE_URL 写死 |
PowerShell 脚本里设 $env:SILICONFLOW_API_KEY = $env:DEEPSEEK_API_KEY + $env:SILICONFLOW_BASE_URL = "https://api.deepseek.com/v1" |
| ChromaDB 下载默认 embedding 模型 | 首次 query 时自动下载 all-MiniLM-L6-v2 (80MB) | 第一次跑一次后模型缓存在 ~/.cache/chroma/,后续秒开 |
wrapper 初版 close() 调 reset() |
reset 清掉所有数据,持久化验证全 False | 改成 pass,ChromaDB PersistentClient 自动持久化 |
💡 Note
-
记忆系统 = 数据生命周期闭环(D4 第 6 节)。"记/想/忘" 全是数据操作:提炼是结构化、想起是检索、忘是生命周期管理。LOCOMO 基准上有选择地记忆(78.7%)远好于全量死记(52.9%)。
-
跨进程持久化不是"特性",是验证门槛 (d4_4 输出)。进程 1 写入 9 条记忆 → 进程 2 启动时
has_collection()返回 True →get_collection()加载 → 4 个关键记忆全 True。如果没有这一步验证,任何"持久化"都是纸上谈兵。 -
PreparedContext 的预算裁剪比检索排序更难 (I7 第 3 节)。检索只要排序,装配还要按类型去重、按预算裁剪、按顺序让后面的短条目不被前面的长条目挡。
status=empty不等于"没检索到内容",可能只是预算不够。 -
停用 ≠ 删除的工程含义(I7 第 6.3 节)。Memory 条目有三种操作:
retire→ 停用(退出当前召回,旧 citation 仍可读)revise→ 修订(同 entry_id,新 entry_version_id)delete→ 删除(物理清除,所有引用失效)
实际产品中 revise + retire 是最常用的组合。