# 记忆机制分析
## 概述
nanobot 实现了一套**双层记忆系统**,赋予 AI agent 跨对话的持久记忆能力。该设计在三个相互制约的需求之间取得平衡:
1. **LLM 上下文窗口限制** — 对话会无限增长,但 LLM 的上下文窗口是有限的
2. **LLM prompt cache 效率** — 修改前面的消息会导致缓存前缀失效
3. **长期知识留存** — agent 需要跨会话记住事实和事件
解决方案:采用**只追加(append-only)的会话**配合滑动的**整合指针(consolidation pointer)**,底层由两个持久化的 Markdown 文件支撑 — 一个存储事实(每次 prompt 都会加载),另一个存储事件(通过 grep 搜索)。
## 架构
```mermaid
graph TB
subgraph "Runtime (Per Request)"
User[User Message]
Session[Session
messages: list]
History[get_history
unconsolidated tail]
Context[ContextBuilder
system prompt]
LLM[LLM Provider]
end
subgraph "Persistent Storage"
JSONL["sessions/{key}.jsonl
Append-only JSONL"]
MEMORY["memory/MEMORY.md
Long-term facts"]
HISTORYMD["memory/HISTORY.md
Event log"]
end
subgraph "Consolidation (Background)"
Trigger{Messages ≥
memory_window?}
Consolidator[MemoryStore.consolidate
LLM-driven summarization]
SaveTool[save_memory tool call]
end
User --> Session
Session --> History
History --> Context
MEMORY --> Context
Context --> LLM
LLM --> Session
Session --> JSONL
Session --> Trigger
Trigger -->|Yes| Consolidator
Consolidator --> SaveTool
SaveTool --> MEMORY
SaveTool --> HISTORYMD
style MEMORY fill:#e8f5e9
style HISTORYMD fill:#fff3e0
style Session fill:#e3f2fd
```
## 双层记忆
### 第一层:`MEMORY.md` — 长期事实
- **位置**:`~/.nanobot/workspace/memory/MEMORY.md`
- **内容**:结构化的 Markdown,存储持久性事实 — 用户偏好、项目上下文、关系、配置细节
- **更新方式**:**全量覆写** — 整合 LLM 会重写整个文件,将已有事实与新事实合并
- **加载方式**:通过 `ContextBuilder.build_system_prompt()` → `MemoryStore.get_memory_context()` 注入每次的 system prompt
- **大小**:增长缓慢(LLM 会对事实进行去重和合并)
内容示例:
```markdown
# User Preferences
- Prefers dark mode
- Timezone: UTC+8
# Project Context
- Working on nanobot, an AI assistant framework
- Uses Python 3.11+, pytest for testing
- API key stored in ~/.nanobot/config.json
```
### 第二层:`HISTORY.md` — 事件日志
- **位置**:`~/.nanobot/workspace/memory/HISTORY.md`
- **内容**:带时间戳的段落摘要,记录过去的对话
- **更新方式**:**只追加** — 新条目追加到文件末尾
- **加载方式**:**不加载到上下文中** — 文件太大;agent 通过 `exec` 工具使用 `grep` 搜索
- **大小**:持续增长(每次整合周期产生一条记录)
内容示例:
```markdown
[2026-03-10 14:30] User asked about configuring Telegram bot. Discussed
bot token setup, allowFrom whitelist, and proxy configuration. User chose
to use SOCKS5 proxy at 127.0.0.1:1080.
[2026-03-12 09:15] Debugged a session corruption issue. The problem was
orphaned tool_call_id references after a partial consolidation. Fixed by
deleting the session file and restarting.
```
### 两者如何协同工作
| 维度 | MEMORY.md | HISTORY.md |
|--------|-----------|------------|
| 用途 | "我知道什么" | "发生了什么" |
| 类比 | 一个人的知识/认知 | 一个人的日记 |
| 在 prompt 中? | 是(始终加载) | 否(太大) |
| 可搜索? | 通过上下文(LLM 直接可见) | 通过 `grep -i "keyword" memory/HISTORY.md` |
| 更新方式 | 覆写(合并新旧内容) | 追加(新条目在末尾) |
| 增长速度 | 慢(去重合并) | 线性(每次整合一条记录) |
## 会话模型
### 只追加的消息列表
`Session` dataclass(`nanobot/session/manager.py`)将所有消息存储在 `list[dict]` 中:
```python
@dataclass
class Session:
key: str # "channel:chat_id"
messages: list[dict[str, Any]] # Append-only
last_consolidated: int = 0 # Consolidation pointer
```
**关键设计原则**:消息**永远不会被修改或删除**。这保证了 LLM prompt cache 前缀的有效性 — 如果前面的消息发生变化,整个缓存都会失效。
### `last_consolidated` 指针
`last_consolidated` 字段是一个整数索引,用于追踪整合进度:
```
messages: [m0, m1, m2, ..., m14, m15, ..., m24, m25, ..., m59]
↑ ↑ ↑
0 15 59
│ │
└─ already consolidated ┘ ← last_consolidated = 15
│ │
└── unconsolidated ──────┘
```
- `messages[0:last_consolidated]` — 已被整合处理(摘要已写入 MEMORY.md/HISTORY.md)
- `messages[last_consolidated:]` — 尚未整合(通过 `get_history()` 发送给 LLM)
### 历史消息检索
`Session.get_history()` 只返回**未整合**的消息,并附带安全检查:
1. 从 `last_consolidated` 切片到末尾
2. 从尾部截取 `max_messages`(默认:500)条消息
3. 对齐到 user 轮次(丢弃开头的非 user 消息)
4. 移除孤立的 tool result(`tool_call_id` 找不到对应的 assistant tool_calls)
5. 迭代移除不完整的 tool_call 组(assistant 有 tool_calls 但缺少对应的 result)
这些清理操作至关重要,因为整合可能将 `last_consolidated` 推进到 tool_call/tool_result 对的中间位置,导致它们被分割到边界两侧。
## 整合流程
### 触发条件
整合在 `AgentLoop._process_message()` 中触发,条件如下:
```python
unconsolidated = len(session.messages) - session.last_consolidated
if unconsolidated >= self.memory_window and session.key not in self._consolidating:
# Launch background consolidation task
```
`memory_window` 默认为 **100 条消息**(可通过 `agents.defaults.memoryWindow` 配置)。
### 执行流程
```mermaid
sequenceDiagram
participant Loop as AgentLoop
participant Store as MemoryStore
participant LLM as LLM Provider
participant FS as File System
Loop->>Loop: unconsolidated >= memory_window?
Loop->>Loop: asyncio.create_task()
Note over Loop: Background task starts
Loop->>Store: consolidate(session, provider, model)
Store->>Store: keep_count = memory_window // 2
Store->>Store: old = messages[last_consolidated:-keep_count]
Store->>Store: Format old messages as text
Store->>FS: Read MEMORY.md (current facts)
Store->>LLM: chat(system="consolidation agent",
user="current memory + conversation",
tools=[save_memory])
LLM-->>Store: tool_call: save_memory(
history_entry="[2026-03-15] ...",
memory_update="# Updated facts...")
Store->>FS: Append history_entry to HISTORY.md
Store->>FS: Overwrite MEMORY.md with memory_update
Store->>Store: session.last_consolidated = len(messages) - keep_count
Note over Loop: Background task completes
```
### 关键细节
1. **`keep_count = memory_window // 2`** — 在默认 `memory_window=100` 的情况下,整合后会保留最近 50 条消息不被整合。范围 `messages[last_consolidated:-50]` 会被发送给整合 LLM。
2. **LLM 驱动的整合** — 一次独立的 LLM 调用(使用相同的 provider 和 model)充当"整合 agent"。它接收:
- 当前 `MEMORY.md` 的内容
- 旧消息,格式化为 `[timestamp] ROLE: content`
- 一个 `save_memory` tool,包含两个必填参数
3. **`save_memory` tool** 返回:
- `history_entry`:一段 2-5 句的带时间戳摘要(追加到 HISTORY.md)
- `memory_update`:完整的更新后 MEMORY.md 内容(已有事实 + 新事实)
4. **指针推进**:整合成功后,`last_consolidated` 推进到 `len(messages) - keep_count`,将已整合的范围标记为已处理。
### 并发保护
agent loop 包含多重保护机制,防止并发整合:
| 保护机制 | 用途 | 实现方式 |
|-------|---------|---------------|
| `_consolidating: set[str]` | 防止同一会话的重复整合任务 | 创建任务前检查;执行前后设置/清除 |
| `_consolidation_locks: WeakValueDictionary[str, Lock]` | 对同一会话的整合进行串行化(普通整合与 `/new` 不会重叠) | 每个 session key 一个 `asyncio.Lock` |
| `_consolidation_tasks: set[Task]` | 强引用防止进行中的任务被 GC 回收 | 创建时加入集合,完成时移除 |
### `/new` 命令
`/new` 斜杠命令用于开启新会话:
1. **等待**进行中的整合完成(获取 consolidation lock)
2. **归档**剩余未整合的消息,设置 `archive_all=True`
3. **清空**会话消息并将 `last_consolidated` 重置为 0
4. **保存**空会话到磁盘
如果归档失败,会话**不会被清空** — 不会丢失数据。
## Memory Skill(始终激活)
`memory` skill(`nanobot/skills/memory/SKILL.md`)标记为 `always: true`,意味着其内容会被加载到每次的 system prompt 中。它指导 agent:
- **MEMORY.md** 已加载到上下文中 — 重要事实应立即写入
- **HISTORY.md** 不在上下文中 — 通过 `grep -i "keyword" memory/HISTORY.md` 搜索
- 自动整合会处理旧对话
- agent 也可以通过 `edit_file` 或 `write_file` 手动更新 MEMORY.md
## 数据流图
```mermaid
flowchart TD
subgraph "Each Request"
A([User Message]) --> B[Session.add_message]
B --> C{Get History}
C --> D[messages from last_consolidated]
D --> E[+ MEMORY.md via ContextBuilder]
E --> F[Send to LLM]
F --> G[LLM Response]
G --> H[Session.add_message]
end
subgraph "Background Consolidation"
H --> I{unconsolidated
≥ memory_window?}
I -->|No| J([Wait for next message])
I -->|Yes| K[Select old messages]
K --> L[Format as text]
L --> M[LLM: summarize + extract facts]
M --> N{save_memory tool called?}
N -->|No| O([Skip - consolidation failed])
N -->|Yes| P[Append to HISTORY.md]
N -->|Yes| Q[Overwrite MEMORY.md]
P --> R[Advance last_consolidated]
Q --> R
end
subgraph "Manual Access"
S[Agent uses grep on HISTORY.md]
T[Agent uses edit_file on MEMORY.md]
end
style E fill:#e8f5e9
style P fill:#fff3e0
style Q fill:#e8f5e9
```
## 边界情况与健壮性
### Provider 返回非字符串参数
部分 LLM provider 会将 `save_memory` 的参数以 dict 或 JSON 字符串的形式返回,而非纯字符串。整合代码对两种情况都做了处理:
```python
args = response.tool_calls[0].arguments
if isinstance(args, str):
args = json.loads(args) # JSON string → dict
if entry := args.get("history_entry"):
if not isinstance(entry, str):
entry = json.dumps(entry) # dict → JSON string
```
这是针对 [issue #1042](https://github.com/HKUDS/nanobot/issues/1042) 的修复。
### LLM 未调用 save_memory
如果整合 LLM 返回的是文本而非 tool call,`consolidate()` 会返回 `False`,指针不会推进。不会丢失数据 — 下次触发时会重试整合。
### 整合失败
`consolidate()` 中的所有异常都会被捕获并记录日志。会话指针不会推进,因此相同的消息会在下次成功整合时被重新处理。
### 整合后的孤立 Tool Result
当 `last_consolidated` 推进到 tool_call 序列中间时,`get_history()` 可能会遇到没有对应 assistant 消息的 tool result。`get_history()` 中的迭代清理算法通过以下方式处理:
1. 追踪当前窗口内所有 assistant 消息中的 `tool_call_id`
2. 丢弃 `tool_call_id` 不在追踪集合中的 tool result
3. 丢弃 tool_calls 没有全部获得 result 的 assistant 消息
4. 重复直到稳定(级联清理)
### 超大会话
对于 1000+ 条消息的会话,整合处理的范围是 `messages[last_consolidated:-keep_count]`,可能包含数百条格式化为文本的消息。这些内容会作为单次 LLM prompt 发送。LLM 的上下文窗口是实际的限制因素。
## 配置
| 配置项 | 路径 | 默认值 | 效果 |
|---------|------|---------|--------|
| Memory window | `agents.defaults.memoryWindow` | 100 | 未整合消息达到此数量时触发整合 |
| Keep count | (派生值) | `memory_window // 2` | 整合后保留的最近未整合消息数量 |
较低的 `memory_window` 值会导致更频繁的整合(更小的批次,更多的 LLM 调用)。较高的值会延迟整合,但每次处理更大的批次。
## 文件参考
| 组件 | 文件 | 关键函数 |
|-----------|------|--------------|
| MemoryStore | `nanobot/agent/memory.py` | `consolidate()`, `get_memory_context()`, `read_long_term()`, `write_long_term()`, `append_history()` |
| Session | `nanobot/session/manager.py` | `add_message()`, `get_history()`, `clear()` |
| SessionManager | `nanobot/session/manager.py` | `get_or_create()`, `save()`, `_load()` |
| ContextBuilder | `nanobot/agent/context.py` | `build_system_prompt()`(注入 MEMORY.md) |
| AgentLoop | `nanobot/agent/loop.py` | `_process_message()`(触发整合), `_consolidate_memory()` |
| Memory skill | `nanobot/skills/memory/SKILL.md` | Agent 指令(始终加载) |
| save_memory tool | `nanobot/agent/memory.py:_SAVE_MEMORY_TOOL` | 整合用的 LLM tool schema |
## 测试覆盖
| 测试文件 | 测试内容 |
|-----------|--------------|
| `tests/test_consolidate_offset.py` | `last_consolidated` 追踪、持久化、切片逻辑、边界条件、archive_all 模式、cache 不可变性、并发保护、`/new` 命令行为 |
| `tests/test_memory_consolidation_types.py` | String/dict/JSON-string 参数处理、无 tool call 的回退、消息过少时跳过整合 |
## 设计权衡
| 决策 | 收益 | 代价 |
|----------|---------|------|
| 只追加消息 | LLM cache 效率高;不丢数据 | 消息列表在内存中无限增长,直到会话被清空 |
| LLM 驱动的整合 | 高质量摘要;事实提取 | 每次整合额外消耗一次 LLM API 调用;有成本 |
| MEMORY.md 全量覆写 | 去重;文档保持连贯 | 如果 LLM 遗漏已有条目,存在事实丢失风险 |
| HISTORY.md 不加载到上下文 | 保持 prompt 体积小 | agent 必须主动 grep;可能遗漏相关历史 |
| 后台整合 | 非阻塞;不延迟用户响应 | 竞态条件需要并发保护机制 |
## 相关文档
- [架构](02-architecture.md) — 系统设计
- [数据模型](04-data-and-api.md) — 存储格式
- [工作流](03-workflows.md) — Agent loop 与 tool 执行
---
**最后更新**:2026-03-15
**版本**:1.0