1. 总览:双层记忆模型
项目中的「Memory」不是单一组件,而是 两条清晰分离的链路:
| 层级 | 类型 | 生命周期 | 主要职责 |
|---|---|---|---|
| 工作区记忆 | WorkspaceMemory | 单次 AgentLoop.run() 内(可随 AgentLoop 实例复用,但每轮 run() 会重置部分状态) | run_dir、工具调用计数;为上下文压缩提供可注入的「状态摘要」 |
| 持久记忆 | PersistentMemory | 跨进程、跨会话,落盘于用户目录 | 索引 + 多条目 Markdown 文件;系统提示中的「冻结快照」+ 每轮用户消息上的「自动召回」 |
WorkspaceMemory 的模块文档中写得很明确:单次运行内的共享状态由 WorkspaceMemory 承担;跨会话由 src.memory.persistent.PersistentMemory 承担。``1:5:agent/src/agent/memory.py
"""Workspace memory: shared state across tool calls within a single run.
Lightweight runtime state — survives within one AgentLoop.run() invocation only. Cross-session persistence is handled by src.memory.persistent.PersistentMemory. """
---
## 2. 工作区记忆(WorkspaceMemory)
### 2.1 数据结构与职责
`WorkspaceMemory` 是一个轻量 `@dataclass`:
- **`run_dir`**:当前这次 agent 运行的根目录(绝对路径字符串),供工具解析相对路径。
- **`counters`**:按工具名聚合的调用次数(`Dict[str, int]`)。
核心 API:
- **`increment(key)`**:每次工具执行结束后由 `AgentLoop` 调用,用于统计。
- **`to_summary()`**:生成给模型看的短文本(`run_dir` + `counters`),注释中明确说明其用途之一是在**上下文压缩**后仍能让模型知道「在干什么」。
13:53:agent/src/agent/memory.py
@dataclass
class WorkspaceMemory:
"""Shared workspace state between tools during a single agent run.
...
"""
run_dir: Optional[str] = None
counters: Dict[str, int] = field(default_factory=dict)def increment(self, key: str) -> int: ... def to_summary(self) -> str: """Generate a state summary for the LLM. ... This summary survives context compression and helps the LLM remember what it was working on. ... """
### 2.2 在 AgentLoop 中的生命周期
1. **`run_dir` 的确定**(在构造消息列表之前)
- 若 `memory.run_dir` 已存在且路径在磁盘上存在,则沿用。
- 否则通过 `RunStateStore.create_run_dir` 新建,并写回 `self.memory.run_dir`。
2. **工具参数中的 `run_dir` 归一化**
`_normalize_tool_run_dir` 使用 `memory.run_dir` 作为基准,把相对路径(如 `"."`、子目录名)解析为绝对路径,保证工具在一致根目录下读写。
3. **计数更新**
每次工具结果落盘后调用 `_update_memory(tool_name)` → `self.memory.increment(tool_name)`。
4. **与 `_auto_compact` 的配合**
压缩对话时保留原始 `system` 消息对象,但在压缩产生的「交接摘要」用户消息末尾追加 `Current agent state:\n{state_summary}`,其中 `state_summary = self.memory.to_summary()`。这样**即使长对话被折叠,工作区状态仍有一条独立注入路径**。
**注意**:初始 system 里的 `{memory_summary}` 是**构造 `build_messages` 那一刻**的快照;中间迭代的工具计数不会自动写回该条 system,除非触发 `_auto_compact`(会在压缩块中再次注入 `to_summary()`)。
相关代码位置:`agent/src/agent/loop.py`(`run_dir` 与 `ContextBuilder` 创建、`_auto_compact` 内 `state_summary`、`_update_memory`)。
---
## 3. 持久记忆(PersistentMemory)
### 3.1 设计目标与存储布局
- **零外部依赖**:不依赖向量库或数据库,纯文件系统。
- **默认目录**:`~/.vibe-trading/memory/`(常量 `MEMORY_BASE`)。
- **文件角色**:
- `MEMORY.md`:人类可读的**索引**(列表形式,带链接与一行描述)。
- 多个 `{memory_type}_{slug}.md`:**具体记忆条目**,带 YAML-like frontmatter + 正文。
1:8:agent/src/memory/persistent.py
"""PersistentMemory: file-based cross-session memory, zero external dependencies.Storage layout: ~/.vibe-trading/memory/ +-- MEMORY.md # Index (< 200 lines) +-- user_prefs.md # Individual memory entries with YAML frontmatter
### 3.2 内存中的「快照」语义
`PersistentMemory` 在 `__init__` 时调用 `_load_snapshot()`:若存在 `MEMORY.md`,读取全文后**只保留前 `MAX_INDEX_LINES`(200)行**作为 `_snapshot`,且之后**不会在 `add`/`remove` 时更新该快照**。
设计意图(源码注释):
- **会话开始时**把索引注入 system prompt,有利于 **prompt 缓存稳定**。
- **磁盘**会即时反映 `save`/`forget`,但**当前会话内的 snapshot 字符串不变**;**下一次**新建 `PersistentMemory()` 会重新加载。
67:77:agent/src/memory/persistent.py
class PersistentMemory:
"""File-based persistent memory that survives across sessions.Design:
- Frozen snapshot injected into system prompt at session start (preserves prompt cache).
- Disk writes via add()/remove() update files immediately but do NOT change the snapshot.
- Next session picks up the updated state.
### 3.3 条目模型与扫描
`MemoryEntry` 为不可变数据类,聚合路径、标题、描述、`memory_type`、正文(截断至 `MAX_ENTRY_CHARS = 8000`)、`mtime`。
`_scan_entries()` 遍历目录下所有 `*.md`,跳过 `MEMORY.md`,用与技能文件共用的 `parse_frontmatter`(`agent/src/agent/frontmatter.py`)解析 frontmatter,得到结构化元数据 + body。
### 3.4 写入:`add`
1. **文件名 slug**:`name.lower().strip()` 后经正则清洗:保留字母数字、`_`、`-` 及 **CJK 范围**(避免纯 CJK 标题被压成相同 slug 导致静默覆盖)。
2. 文件名:`{memory_type}_{slug}.md`(slug 最长 60)。
3. Frontmatter 字段:`name`、`description`(默认用 title)、`type`。
4. 写文件后调用 `_update_index`:在 `MEMORY.md` 中按标题查找并替换行,或追加新行;整体仍受 `MAX_INDEX_LINES` 截断。
### 3.5 删除:`remove`
按**精确标题** `entry.title == name` 匹配,删除对应文件并 `_rebuild_index()` 全量重建索引。
### 3.6 检索:`find_relevant`
**非向量检索**,基于 `_tokenize` 的集合交集打分:
- **ASCII**:长度 ≥ 3 的 `[a-zA-Z0-9]+` 小写 token(**下划线不作为 token 的一部分**,从而 `mcp_wiring_test` 可被 “mcp wiring” 这类查询命中)。
- **CJK**:单字纳入 token 集合。
- 对每条目:`meta_tokens = tokenize(title + description)`,`body_tokens = tokenize(body)`。
- **得分** = `|query ∩ meta| * METADATA_WEIGHT(2.0) + |query ∩ body| * 1.0`。
- 排序:得分降序,同分按 `modified_at` 降序(较新优先)。
- 默认最多 `MAX_RESULTS = 5` 条(`ContextBuilder` 里自动召回用 3)。
48:64:agent/src/memory/persistent.py
def _tokenize(text: str) -> set[str]:
"""Split text into searchable tokens. ASCII words >= 3 chars + CJK individual characters. Underscores are
treated as word boundaries so snake_case titles (e.g. mcp_wiring_test)
match natural-language queries ("mcp wiring") as well as verbatim
lookups.
"""
ascii_tokens = set(re.findall(r"[a-zA-Z0-9]{3,}", text.lower()))
cjk_tokens = set(re.findall(r"[\u4e00-\u9fff\u3400-\u4dbf]", text))
return ascii_tokens | cjk_tokens
132:147:agent/src/agent/context.py
memory_section = ""
if self._persistent_memory and self._persistent_memory.snapshot:
memory_section = _MEMORY_SECTION.format(
snapshot=self._persistent_memory.snapshot,
)---
## 4. 与 LLM 上下文的集成(ContextBuilder)
### 4.1 System Prompt 中的两块「记忆」占位
`_SYSTEM_PROMPT` 模板中有:
- **`{memory_summary}`**:来自 `WorkspaceMemory.to_summary()`(当前 run 的目录与工具计数)。
- **`{memory_section}`**:仅当 `persistent_memory.snapshot` 非空时,拼接 `## Persistent Memory (cross-session)` + 冻结索引全文。
return _SYSTEM_PROMPT.format( ... memory_summary=self.memory.to_summary(), memory_section=memory_section, current_datetime=now.strftime("%A, %B %d, %Y %H:%M (local)"), )
系统提示中还包含对模型的**行为指引**:在适当时机使用 `remember` 工具保存偏好与洞见。
### 4.2 用户消息侧的「自动召回」
`build_messages` 在追加本轮用户消息前,若存在 `persistent_memory`,则对用户原文做 `find_relevant(user_message, max_results=3)`,若有结果,将摘要包在 `<recalled-memories>...</recalled-memories>` 中并置于用户消息**前缀**。
设计意图:
- **保持 system prompt 稳定**(利于缓存);
- **按查询动态**注入相关记忆,避免把全部正文塞进 system。
169:180:agent/src/agent/context.py
enriched = user_message
if self._persistent_memory:
try:
recalls = self._persistent_memory.find_relevant(user_message, max_results=3)
if recalls:
lines = [f"- {r.title} ({r.memory_type}): {r.body[:500]}" for r in recalls]
recall_block = "\n".join(lines)
enriched = (
f"异常被吞掉并打 debug 日志,避免召回失败阻断主流程。
---
## 5. 工具层:`remember` 与注册表依赖注入
### 5.1 RememberTool
- 工具名:`remember`。
- `action`:`save` | `recall` | `forget`。
- `save` 需要 `title` + `content`,可选 `memory_type`(`user` / `feedback` / `project` / `reference`)。
- `recall` 需要 `query`。
- `forget` 需要 `title`(与持久层按标题删除一致)。
- `is_readonly = False`:参与写盘,在 `AgentLoop` 的批处理里会走**串行**路径。
构造器可注入 `PersistentMemory`;若未注入则自行 `PersistentMemory()`(默认目录)。实现见 `agent/src/tools/remember_tool.py`。
### 5.2 `build_registry` 的单例共享
`build_registry(persistent_memory=pm)` 时,对 `RememberTool` **特殊处理**:用同一个 `pm` 实例注册,保证「上下文里的 `PersistentMemory`」与「工具写盘的 `PersistentMemory`」是同一对象。
89:90:agent/src/tools/__init__.py
if cls is RememberTool and persistent_memory is not None:
registry.register(cls(memory=persistent_memory))
### 5.3 服务端与 CLI 的装配
- **`agent/src/session/service.py` `_run_with_agent`**:`pm = PersistentMemory()`,`AgentLoop(..., persistent_memory=pm)` 且 `build_registry(persistent_memory=pm, ...)`。
- **`agent/cli.py`**:同样模式,并可设置 `agent.memory.run_dir` 覆盖。
---
## 6. 端到端数据流(概念架构)
mermaid
flowchart TB
subgraph session_start [会话开始]
PM[PersistentMemory 构造]
PM --> Snap[加载 MEMORY.md 前 200 行 → snapshot 冻结]
PM --> Disk[(~/.vibe-trading/memory/*.md)]
end subgraph run [AgentLoop.run]
WM[WorkspaceMemory: run_dir + counters]
WM --> CB[ContextBuilder]
PM --> CB
CB --> Sys[System: memory_summary + memory_section]
CB --> UserMsg[User: recalled-memories + 用户输入]
LLM[ChatLLM]
Sys --> LLM
UserMsg --> LLM
LLM --> Tools[ToolRegistry]
Tools --> Remember[remember → PM.add/find/remove 写磁盘]
Tools --> Other[其他工具 → WM.run_dir 归一化]
Other --> WM
Remember --> Disk
Tools --> Compact[_auto_compact 时注入 WM.to_summary]
end
`
---
7. 设计权衡与行为要点
1. 快照冻结 vs 即时写盘
同一轮会话中,新保存的记忆会出现在磁盘和下一次 find_relevant(因扫描目录)中,但不会进入当前已发出的 system 里的 memory_section。若希望模型在同一会话后半段立刻在 system 里看到新索引,当前实现不会自动满足——这是为 prompt 前缀稳定 / 缓存 做的取舍。
2. 自动召回 vs 显式 recall
每轮用户消息都会尝试关键词召回(最多 3 条、正文最多 500 字符),与工具 recall(最多 5 条、正文最多 2000 字符)形成「轻量自动 + 深度显式」组合。
3. WorkspaceMemory 与 system 中 memory_summary 的时效
memory_summary 仅在 build_system_prompt 调用时从当前 WorkspaceMemory 读取。单次 run() 内若未触发 _auto_compact,工具计数在内存中持续增长,但 system 首条消息中的文字不会自动刷新;压缩路径会把手写摘要与 to_summary() 一并塞进新的 user 消息,作为补偿。
4. 索引长度上限
MEMORY.md 读写均限制 200 行,在记忆条目极多时需要依赖「自动召回扫文件」而非完整索引进 prompt。
5. 检索模型局限 基于 token 交集,无语义相似度;短英文词(少于 3 个字母)不参与 ASCII token,可能影响部分查询。
---
8. 测试与质量护栏
- agent/tests/test_persistent_memory.py
:覆盖 add、索引更新、_tokenize、snake_case 召回、remove、_rebuild_index、snapshot在新实例间持久等。 - agent/tests/test_remember_tool.py
:覆盖 save/recall/forget 的 JSON 契约与 PersistentMemory集成。
9. 小结
| 问题 | 结论 |
|---|---|
| Memory 分几层? | 工作区(单次 run) + 持久(跨会话文件) |
| 持久存在哪? | 默认 ~/.vibe-trading/memory/,MEMORY.md + 多条目 md |
| 如何进模型上下文? | System:memory_summary + 可选冻结索引;User: 自动召回 |
| 模型如何写入? | remember 工具 → 与 AgentLoop 共享的 PersistentMemory` 实例 |
| 检索怎么做? | 正则分词 + 元数据加权交集评分,非向量 |