主题
10 — Memory & Retrieval Architecture
Status: 📋 Design proposal Extends:
03-agent-framework.md(§3.4 State and Memory) Inspired by: TencentDB Agent Memory design patterns (not direct integration)
1. Why This Document
当前架构(03-agent-framework)在 Memory 层面定义了两件事:
- Session memory(会话记忆)— 对话内的临时数据,会话结束就清空
- Per-user persistent memory(用户持久记忆)— 长期跟踪,但 MVP 默认关闭
这两层在实际运行中暴露了几个问题:
- MEMORY.md 全量加载 — 所有记忆每次对话都塞进 prompt,记忆多了占用 context 窗口
- Competences 硬编码 — 领域知识平铺在
competences.py的 dict 里,手动注册,ID 硬编码 - Trace 不持久 — 工具调用记录只在内存里,会话结束后无法回溯 "上次为什么得出这个结论"
- 研报检索是空的 —
search_reports()返回NotImplementedError,Agent 没有非结构化知识源
这份文档提出一个分层记忆 + 检索注入的架构来解决这些问题。核心思想借鉴了 TencentDB Agent Memory 的设计模式(L0-L3 分层、混合检索、上下文卸载),但不直接集成该库(技术栈不匹配:它是 TypeScript/OpenClaw 生态,我们是 Python/Hermes)。
2. 核心概念(非技术解释)
在深入技术细节之前,先用日常语言解释我们要解决的核心问题。
2.1 为什么 Agent 需要"记忆"?
想象你带一个研究助手。第一天上班,你告诉他:
"我只看白酒和新能源,不碰银行股。"
如果没有记忆:
- 第二天他又给你推荐银行股 — 他忘了
- 你每次都要重复同样的偏好 — 你烦了
如果有记忆:
- 他记住了你的偏好 — 不用再重复
- 他还能记住 "你上个月问过茅台的估值" — 有连续性
记忆让 Agent 从 "每次都是陌生人" 变成 "了解你的老搭档"。
2.2 为什么记忆要"分层"?
还是那个助手。如果你把所有信息混在一起:
"用户不吃辣、茅台 Q1 营收增长 12%、2023 年 3 月问过伊利、PE 计算公式是…"
助手每次回答问题前要从这一堆东西里翻找 — 慢、容易漏、还占地方。
分层就是把信息分类放好:
| 层级 | 放什么 | 类比 | 加载方式 |
|---|---|---|---|
| L3 画像 | 用户偏好、风格、禁忌 | 助手的"用户手册" | 每次都看(量少,几行) |
| L2 场景 | 当前话题的上下文 | "你上周刚分析过白酒板块" | 需要时才翻 |
| L1 事实 | 具体数据、结论 | "茅台 Q1 营收 +12%" | 搜索后注入 |
| L0 日志 | 原始对话和操作记录 | 工作日志,可回溯 | 需要回溯时才查 |
分层的核心原则:不是所有信息每次都需要。按需提供,省空间、提质量。
2.3 什么是"检索注入"(Retrieval Injection)?
没有检索注入:
用户: "茅台现在值得买吗?"
Agent: 只靠训练数据回答 → 可能过时、可能编造 ❌
有检索注入:
用户: "茅台现在值得买吗?"
→ 系统自动检索: 3 份最新研报 + 用户偏好(价值投资) + 最近财报数据
→ 把这些"参考资料"交给 Agent
→ Agent 基于资料回答 → 有依据、有时效 ✅检索注入 = 让 Agent "查资料后回答",而不是"凭记忆瞎编"。
2.4 什么是"混合检索"(Hybrid Recall)?
这是本架构的关键技术点。假设文档库里有一堆研报,用户搜 "600519 2025 估值"。
方式一:只看关键词(BM25)
就像在 Word 里 Ctrl+F 搜索 — 必须词面完全匹配。
| 文档 | 结果 | 原因 |
|---|---|---|
| "600519 2025 年 PE 预计 28 倍" | ✅ 命中 | 词都对上了 |
| "白酒行业估值合理" | ❌ 漏掉 | 没有"600519"、"2025"这些词 |
| "茅台 2024 年报分析" | ⚠️ 误匹配 | 有"茅台"但年份不对 |
问题: 用户搜"便宜",文档写的是"估值低" — 意思一样但用词不同,搜不到。
方式二:只看语义(Vector / 向量检索)
把文字转成"语义指纹",比较意思相不相似。
| 文档 | 结果 | 原因 |
|---|---|---|
| "白酒行业估值分析" | ✅ 命中 | 语义相关 |
| "600887 伊利最新行情" | ⚠️ 可能误匹配 | 都是"行情类"文档,向量可能混淆 |
| "茅台 2024 年报" | ⚠️ 可能排前面 | 向量对数字(2024 vs 2025)不敏感 |
问题: 股票代码、精确年份这种需要"精确匹配"的东西,向量检索容易混淆。
方式三:混合检索(Hybrid = BM25 + Vector + 排名融合)
两路都跑,然后合并结果:
用户搜 "600519 2025 估值"
BM25 找到: "600519 2025 年 PE 28 倍" (精确匹配了代码和年份) ✅
Vector 找到: "白酒行业估值分析" (语义上相关) ✅
混合后:
1. "600519 2025 年 PE 28 倍" ← 两路都命中,排第一
2. "白酒行业估值分析" ← 语义补上了 BM25 漏掉的
3. "600887 伊利行情" ← BM25 没命中(代码不对),排名靠后为什么混合更好: BM25 负责精确性(代码、年份必须对),Vector 负责召回性(同义词、相关话题也能找到)。两者互补,不是重复。
排名融合用 RRF(Reciprocal Rank Fusion / 倒排排名融合)算法: 不关心各自的绝对分数,只看排名。一个文档在两路检索中都出现,它的排名就会靠前。数学上很简洁,工程上 k=60 是经验最优值。
2.5 术语速查表
| 术语 | 一句话解释 | 类比 |
|---|---|---|
| RAG | 先检索资料,再让 AI 回答 | 开卷考试 vs 闭卷考试 |
| BM25 | 关键词搜索算法 | Ctrl+F 的加强版 |
| Embedding | 把文字转成"语义向量" | 把一句话压缩成"语义指纹" |
| Vector Recall | 用语义向量找相关文档 | "意思相近"的搜索 |
| Hybrid Recall | BM25 + Vector 合并结果 | "字面对 + 意思对"都照顾到 |
| RRF | 合并两路排名的算法 | 两个评审都打高分的选手排前面 |
| Context Window | AI 一次能"看到"的文本量 | 人的短期记忆容量 |
| Context Offloading | 把详细内容移出 AI 视线,只留摘要 | 笔记本放旁边,需要时翻开 |
| Citation | 每个断言标注来源 | 论文里的参考文献标注 |
| Competence | 注册在案的领域常识 | "A 股财年 12/31" 这种事实 |
3. 现状诊断
3.1 现有架构的 Memory 层
┌─────────────────────────────────┐
│ Hermes Gateway │
│ ├── Agent Loop │
│ ├── Tool Dispatch │
│ └── Context Loading: │
│ SOUL.md (全量) │
│ AGENTS.md (全量) │
│ SKILL.md (按需) │
│ MEMORY.md (全量) ← 问题在这 │
│ USER.md (全量) ← 问题在这 │
└─────────────────────────────────┘MEMORY.md / USER.md 的问题:
- 全量加载,不论当前话题是否相关
- 没有层次 — 用户偏好和某个股票的历史分析混在一起
- 记忆增多后占用大量 context 窗口
- 无法检索 — 只能靠 Hermes 自己的摘要机制
3.2 Competences 的问题
python
# core/core/competences.py — 现状
registry.register(Competence(
id="a_share_fiscal_year_end",
statement="A-share fiscal year ends December 31",
source="CSRC accounting standards"
))- 硬编码注册,手动维护
- 所有知识平铺在一个 dict 里,没有层次
- 新知识必须改代码、重新部署
- 无法从文档/研报中自动提取
3.3 ToolTrace 的问题
- 仅在内存中,会话结束即丢失
- 无法跨会话回溯 "上次分析为什么得出这个结论"
- Verifier 每次只能验证当前会话的 claim
3.4 研报检索的问题
python
# core/core/data/schemas.py — 现状
def search_reports(query: str, code: str | None = None) -> list[ReportChunk]:
raise NotImplementedError("report search is Phase 2 work")- 完全未实现
- Agent 没有卖方研报知识源
- 投研推理只靠价格和财报数据,缺"观点面"
4. 目标架构
4.1 整体拓扑
用户
│
▼
┌─────────────────────────────────────────────┐
│ Hermes(不变) │
│ Agent Loop / Tool Dispatch / Context Load │
│ SOUL.md → AGENTS.md → SKILL.md │
└──────────────────┬──────────────────────────┘
│
┌─────────┼─────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌────────┐ ┌───────────┐
│ L3 画像 │ │ 检索 │ │ 工具调用 │
│ MEMORY.md│ │ 注入点 │ │ Trace → │
│ (全量加载)│ │(新增) │ │ L0 持久化 │
│ │ │ │ │ (新增) │
└──────────┘ └───┬────┘ └─────┬─────┘
│ │
┌─────┴─────┐ ┌────┴──────┐
│ L1 事实 │ │ L0 日志 │
│ + L2 场景 │ │ 持久存储 │
│ DuckDB │ │ DuckDB │
└───────────┘ └───────────┘关键原则:Hermes 不动,Memory Layer 作为插件叠加在它上面。
4.2 四层存储定义
| 层级 | 名称 | 内容 | 存储 | 加载方式 | 日常类比 |
|---|---|---|---|---|---|
| L3 | Persona(画像) | 用户偏好、风格、禁忌 | MEMORY.md | 全量加载(量少) | 用户的"使用说明书" |
| L2 | Scenario(场景) | 当前话题的上下文 | memory/scenarios/*.md | 按话题检索注入 | "上次聊过这个的背景" |
| L1 | Fact(事实) | 具体数据、结论 | memory/facts.db (DuckDB) | 按 ticker/指标检索注入 | "数据卡片" |
| L0 | Log(日志) | 原始对话、工具调用记录 | memory/logs.db (DuckDB) | 按 ID 回溯 | "工作日志" |
4.3 四步流程详解
Step 1: L3 Persona — "了解你是谁"
什么时候发生: 每次会话启动时。
具体做什么:
- Hermes 加载
MEMORY.md(已存在的机制,不变) MEMORY.md里只放 L3 级别的画像信息(用户偏好、风格、禁忌)- 通常 5–10 条,全量加载也不占多少 token
实际效果:
MEMORY.md:
- 用户偏好:价值投资为主,关注白酒和新能源板块
- 不碰:ST 股、银行股
- 报告风格:简洁,先结论后论据
- 语言:中文沟通,英文输出(项目偏好)
→ Agent 从第一句话就知道 "这个人不要银行股推荐"Step 2: L2 Scenario — "想起上次聊过这个"
什么时候发生: 用户提出一个问题,Agent 准备开始分析之前。
具体做什么:
- 从用户问题中提取话题(如 "白酒板块"、"600519")
- 在
memory/scenarios/目录中检索相关场景文件 - 如果有匹配的场景,把场景摘要注入 context
实际效果:
用户: "茅台最近怎么样?"
→ 话题提取: ticker=600519, sector=白酒
→ 检索 memory/scenarios/ 目录:
✅ consumer_sector_q1_analysis.md
"2026Q1 白酒板块整体承压,龙头估值回调"
✅ moutai_valuation_context.md
"上次(4月)估值讨论,结论:PE 28x 略高"
→ 注入这两个场景摘要(每个约 200 字)
→ Agent 知道 "哦上次讨论过,结论是估值略高"Step 3: L1 Fact — "查到相关数据"
什么时候发生: 和 L2 一起,在分析开始前检索。
具体做什么:
- 按 ticker、指标类型在
facts.db中检索 - 返回最相关的 N 条事实
- 注入 context 供 Agent 引用
实际效果:
用户: "茅台最近怎么样?"
→ L1 检索: ticker=600519
→ 返回:
[1] "600519 2026Q1 营收同比 +12%,净利润 +9%"
(来源: Tushare 2026-04-28)
[2] "600519 最新 PE 26x"
(来源: 工具调用 tc_xyz789)
→ 注入这 2 条事实
→ Agent 回答时可以引用具体数字Step 4: L0 Log — "回溯上次为什么这么说"
什么时候发生: 用户问 "你上次为什么得出这个结论?" 或 Agent 需要自查时。
具体做什么:
- 按
tool_call_id或时间范围在logs.db中回查 - 返回原始工具调用记录和结果
- 用于审计、debug、回溯
实际效果:
用户: "你上个月说茅台 PE 28 倍,怎么算的?"
→ Agent 通过 cite 中的 tool_call_id 回查 L0
→ 找到:
tool_call_id: tc_abc123
tool: get_fundamentals
args: code=600519, metrics=[pe]
result: {"pe": 28.3}
timestamp: 2026-04-15T10:30:00Z
→ Agent 展示计算过程,完全可回溯5. 与现有系统的集成点
5.1 和 Hermes MEMORY.md 的关系
现在:
MEMORY.md — 什么记忆都往里塞,全量加载
集成后:
MEMORY.md — 只放 L3 Persona(用户画像)
memory/ — L0/L1/L2 的新家不需要改 Hermes: Hermes 加载 MEMORY.md 的机制不变。我们只是改变 MEMORY.md 的内容 — 从"什么都有"变成"只有用户画像"。
5.2 和 Competences 的关系
现在:
competences.py 硬编码注册:
register(Competence(id="xxx", statement="..."))
→ 平铺 dict,ID 硬编码
集成后:
Competences = L1 Facts 的一个子集 (type="competence")
memory/facts.db:
id | type | statement | source
--------------|-------------|----------------------|-------------
fiscal_end | competence | A股财年截止12/31 | 上交所规则
pe_formula | competence | PE = 股价 / EPS | 金融常识
600519_q1_rev | fact | 600519 Q1 营收+12% | Tushare好处:
- Competences 不再需要改代码注册 — 写入 facts.db 即可
- 可以按 topic/ticker 检索 competence,不再是全量加载
- 新的领域知识可以从文档中自动提取(未来)
5.3 和 ToolTrace 的关系
现在:
ToolTrace → 内存 list → 会话结束丢失
集成后:
ToolTrace → 写入 L0 logs.db (DuckDB)
memory/logs.db:
tool_call_id | tool_name | args | result | timestamp
tc_abc123 | get_daily | ts_code=600519 | {...} | 2026-05-13解决了 03-agent-framework §7 开放问题 #3: "Trace storage. Default: memory for MVP, persisted for production."
5.4 和 search_reports() 的关系
现在:
search_reports() → NotImplementedError
Phase 1 (FTS only):
研报 PDF → pdfplumber 解析 → DuckDB 全文搜索
→ BM25 关键词匹配
Phase 2 (Hybrid):
研报 chunk → bge-m3 embedding → 向量检索
→ BM25 + Vector + RRF 混合排序6. 实施阶段
Phase 0: 基础设施(1–2 周)
目标: 搭好 L0-L3 的骨架,先不接检索。
| 任务 | 具体做什么 | 产出 |
|---|---|---|
创建 memory/ 目录 | facts.db, logs.db, scenarios/ | 目录结构 |
| L0 日志持久化 | 每次工具调用写入 logs.db | logs.db 有数据 |
| L3 画像整理 | 把现有 MEMORY.md 精简为纯 Persona | 精简后的 MEMORY.md |
| L1 Schema 设计 | facts.db 的表结构 | CREATE TABLE facts (...) |
不涉及: 检索、embedding、任何智能注入。
Phase 1: 全文搜索 + 手动注入(2–3 周)
目标: 让 search_reports() 从 NotImplementedError 变成能返回结果。
| 任务 | 具体做什么 | 产出 |
|---|---|---|
| 研报入库 | 手工收集 50–100 份 PDF → pdfplumber 解析 → DuckDB | reports 表有数据 |
| DuckDB FTS | 利用 DuckDB 的全文搜索功能 | search_reports() 可调用 |
| Skill 集成 | 在 stock-research workflow 中调用 search_reports | Agent 开始引用研报 |
| L1 事实写入 | 工具调用结果自动存入 facts.db | facts.db 有数据 |
- 检索方式: 纯 BM25 全文搜索。不用 embedding。
- 验证标准: 给一个股票,Agent 能引用至少 1–2 份研报的观点。
Phase 2: 混合检索 + 自动注入(2–3 周)
目标: 从关键词搜索升级到语义搜索 + 自动 context 注入。
| 任务 | 具体做什么 | 产出 |
|---|---|---|
| Embedding 管线 | bge-m3 路由 → 研报 chunk → 向量入库 | 向量索引就绪 |
| Hybrid Recall | BM25 + Vector + RRF 融合 | 混合搜索 API |
| 自动检索注入 | SKILL.md workflow 中加 memory_retrieval 步骤 | 自动注入 L1/L2 |
| L2 场景提取 | LLM 从对话中自动提取场景 → 存为 Markdown | scenarios/ 有数据 |
- 检索方式: BM25 + Vector + RRF
- 验证标准: 搜 "便宜" 能匹配 "估值低"(语义),搜 "600519" 能精确匹配代码(关键词)。
Phase 3: Context Offloading + 自维护(持续迭代)
目标: 长对话不爆 context,记忆自动维护。
| 任务 | 具体做什么 | 产出 |
|---|---|---|
| Mermaid 上下文卸载 | 工具日志 offload 到 refs/*.md,context 只放摘要图 | 长对话 token 降低 |
| 记忆 TTL 管理 | L0/L1 自动清理过期数据 | 存储不无限增长 |
| 自维护循环 | Agent 自动从对话中提取新事实 → 写入 L1 | 记忆自动丰富 |
7. 技术选型
存储层
| 组件 | 选型 | 原因 |
|---|---|---|
| L0/L1 存储 | DuckDB(已有) | 项目已有,零新依赖,支持 FTS |
| L2 场景 | Markdown 文件 | 人类可读、可编辑、可 git 追踪 |
| L3 画像 | MEMORY.md(Hermes 原生) | 零集成成本 |
| 向量索引(Phase 2) | sqlite-vec 或 LanceDB | 嵌入式、Python-native、零运维 |
为什么不用 Qdrant / pgvector / Milvus: 这些都需要独立服务部署。项目现有基础设施是 DuckDB + SQLite,引入一个独立向量服务增加了运维复杂度。Phase 2 优先用嵌入式方案。
检索层
| 组件 | Phase 1 | Phase 2 |
|---|---|---|
| 关键词搜索 | DuckDB FTS (BM25) | DuckDB FTS (BM25) |
| 语义搜索 | — | bge-m3 embedding + 向量库 |
| 排名融合 | — | RRF (k=60) |
| 中文分词 | DuckDB 内置 | jieba(bge-m3 自带) |
注入层
| 组件 | 实现方式 |
|---|---|
| L2/L1 检索触发 | SKILL.md workflow 中加 memory_retrieval 步骤 |
| Context 注入格式 | Markdown block,标注来源 |
| 注入量控制 | 最多 N 条 L1 + M 个 L2,总量 ≤ context 窗口的 15% |
8. 风险控制
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| 注入的记忆污染 Agent 判断 | 错误的上下文导致错误结论 | 注入的记忆带 cite/来源,verifier 可验证 |
| 记忆过时 | 旧数据误导 Agent | TTL 机制,过期自动清理 |
| Context 窗口仍然不够 | 即使检索注入,相关记忆太多 | 限制注入比例 ≤ 15%,超过则截断 |
| 研报版权问题 | 入库的研报是否有 redistribution 权利 | Phase 1 仅用公开免费研报,标注来源 |
| Phase 2 embedding 成本 | bge-m3 本地模型有计算开销 | 批量离线计算,增量更新 |
9. 和 03-agent-framework 的对照
| 03 的设计 | 本文档的演进 | 变化类型 |
|---|---|---|
| Session memory (always on) | → L1 + L2 检索注入 | 重定义 — 从"全量"到"按需" |
| Per-user memory (opt-in) | → L3 Persona (MEMORY.md) | 细化 — 明确只放画像 |
| Trace: memory for MVP | → L0 logs.db 持久化 | 升级 — 从内存到持久化 |
| Competences: registered dict | → L1 facts (type=competence) | 迁移 — 从硬编码到可检索 |
| search_reports: deferred | → Phase 1/2 核心交付 | 激活 — 从 deferred 到执行中 |
| Multi-model routing | 不变 | 保持 — bge-m3 路由已存在 |
10. 开放问题
- Competences 迁移策略: 现有
competences.py中硬编码的 competence 是自动迁移到 facts.db,还是保持双轨运行一段时间? - Hermes 钩子: 是否需要在 Hermes 的
after-tool-call钩子中自动触发 L0 日志写入?还是需要 Hermes 支持自定义 hook 机制? - L2 场景自动提取: 是手动创建场景 Markdown,还是让 LLM 在会话结束时自动提取并写入
scenarios/?自动提取的质量如何保证? - 多用户隔离: 当前项目是单用户(profile 级)。如果未来多用户,memory 是按用户隔离还是共享?
- Embedding 模型部署: bge-m3 是本地跑还是调 API?本地需要计算资源,API 有延迟和成本。
11. 决策记录
| 决策 | 选择 | 拒绝的替代方案 | 原因 |
|---|---|---|---|
| Memory 分层 | L0-L3 四层 | 扁平向量库;全量 summary | 分层支持渐进式信息暴露,省 token |
| 检索策略 | Phase 1: BM25 only → Phase 2: Hybrid | 直接上 Vector;只用 BM25 | 先验证价值再投入,混合效果最优 |
| 向量库选型 | 嵌入式 (sqlite-vec / LanceDB) | Qdrant / pgvector / Milvus | 零运维,与 DuckDB 生态一致 |
| L3 存储 | 保持 MEMORY.md (Hermes 原生) | 迁移到 DB | 零集成成本,量少不需检索 |
| L0/L1 存储 | DuckDB (已有) | 引入新 DB | 零新依赖,已有 FTS 能力 |
| 是否集成 TencentDB 库 | 不直接集成,借鉴设计 | 直接安装使用 | TypeScript 生态不匹配,Python 重写更轻 |
| Competences 去向 | 迁移到 L1 facts (type=competence) | 保持硬编码 | 可检索、可扩展、可自动提取 |
| ToolTrace 去向 | 持久化到 L0 logs.db | 保持内存 | 解决 03-framework 开放问题 #3 |