记忆机制分析
概述
nanobot 实现了一套双层记忆系统,赋予 AI agent 跨对话的持久记忆能力。该设计在三个相互制约的需求之间取得平衡:
LLM 上下文窗口限制 — 对话会无限增长,但 LLM 的上下文窗口是有限的
LLM prompt cache 效率 — 修改前面的消息会导致缓存前缀失效
长期知识留存 — agent 需要跨会话记住事实和事件
解决方案:采用只追加(append-only)的会话配合滑动的整合指针(consolidation pointer),底层由两个持久化的 Markdown 文件支撑 — 一个存储事实(每次 prompt 都会加载),另一个存储事件(通过 grep 搜索)。
架构
graph TB
subgraph "Runtime (Per Request)"
User[User Message]
Session[Session<br/>messages: list]
History[get_history<br/>unconsolidated tail]
Context[ContextBuilder<br/>system prompt]
LLM[LLM Provider]
end
subgraph "Persistent Storage"
JSONL["sessions/{key}.jsonl<br/>Append-only JSONL"]
MEMORY["memory/MEMORY.md<br/>Long-term facts"]
HISTORYMD["memory/HISTORY.md<br/>Event log"]
end
subgraph "Consolidation (Background)"
Trigger{Messages ≥<br/>memory_window?}
Consolidator[MemoryStore.consolidate<br/>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 会对事实进行去重和合并)
内容示例:
# 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搜索大小:持续增长(每次整合周期产生一条记录)
内容示例:
[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 直接可见) |
通过 |
更新方式 |
覆写(合并新旧内容) |
追加(新条目在末尾) |
增长速度 |
慢(去重合并) |
线性(每次整合一条记录) |
会话模型
只追加的消息列表
Session dataclass(nanobot/session/manager.py)将所有消息存储在 list[dict] 中:
@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() 只返回未整合的消息,并附带安全检查:
从
last_consolidated切片到末尾从尾部截取
max_messages(默认:500)条消息对齐到 user 轮次(丢弃开头的非 user 消息)
移除孤立的 tool result(
tool_call_id找不到对应的 assistant tool_calls)迭代移除不完整的 tool_call 组(assistant 有 tool_calls 但缺少对应的 result)
这些清理操作至关重要,因为整合可能将 last_consolidated 推进到 tool_call/tool_result 对的中间位置,导致它们被分割到边界两侧。
整合流程
触发条件
整合在 AgentLoop._process_message() 中触发,条件如下:
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 配置)。
执行流程
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",<br/>user="current memory + conversation",<br/>tools=[save_memory])
LLM-->>Store: tool_call: save_memory(<br/> history_entry="[2026-03-15] ...",<br/> 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
关键细节
keep_count = memory_window // 2— 在默认memory_window=100的情况下,整合后会保留最近 50 条消息不被整合。范围messages[last_consolidated:-50]会被发送给整合 LLM。LLM 驱动的整合 — 一次独立的 LLM 调用(使用相同的 provider 和 model)充当”整合 agent”。它接收:
当前
MEMORY.md的内容旧消息,格式化为
[timestamp] ROLE: content一个
save_memorytool,包含两个必填参数
save_memorytool 返回:history_entry:一段 2-5 句的带时间戳摘要(追加到 HISTORY.md)memory_update:完整的更新后 MEMORY.md 内容(已有事实 + 新事实)
指针推进:整合成功后,
last_consolidated推进到len(messages) - keep_count,将已整合的范围标记为已处理。
并发保护
agent loop 包含多重保护机制,防止并发整合:
保护机制 |
用途 |
实现方式 |
|---|---|---|
|
防止同一会话的重复整合任务 |
创建任务前检查;执行前后设置/清除 |
|
对同一会话的整合进行串行化(普通整合与 |
每个 session key 一个 |
|
强引用防止进行中的任务被 GC 回收 |
创建时加入集合,完成时移除 |
/new 命令
/new 斜杠命令用于开启新会话:
等待进行中的整合完成(获取 consolidation lock)
归档剩余未整合的消息,设置
archive_all=True清空会话消息并将
last_consolidated重置为 0保存空会话到磁盘
如果归档失败,会话不会被清空 — 不会丢失数据。
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
数据流图
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<br/>≥ 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 字符串的形式返回,而非纯字符串。整合代码对两种情况都做了处理:
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 的修复。
LLM 未调用 save_memory
如果整合 LLM 返回的是文本而非 tool call,consolidate() 会返回 False,指针不会推进。不会丢失数据 — 下次触发时会重试整合。
整合失败
consolidate() 中的所有异常都会被捕获并记录日志。会话指针不会推进,因此相同的消息会在下次成功整合时被重新处理。
整合后的孤立 Tool Result
当 last_consolidated 推进到 tool_call 序列中间时,get_history() 可能会遇到没有对应 assistant 消息的 tool result。get_history() 中的迭代清理算法通过以下方式处理:
追踪当前窗口内所有 assistant 消息中的
tool_call_id丢弃
tool_call_id不在追踪集合中的 tool result丢弃 tool_calls 没有全部获得 result 的 assistant 消息
重复直到稳定(级联清理)
超大会话
对于 1000+ 条消息的会话,整合处理的范围是 messages[last_consolidated:-keep_count],可能包含数百条格式化为文本的消息。这些内容会作为单次 LLM prompt 发送。LLM 的上下文窗口是实际的限制因素。
配置
配置项 |
路径 |
默认值 |
效果 |
|---|---|---|---|
Memory window |
|
100 |
未整合消息达到此数量时触发整合 |
Keep count |
(派生值) |
|
整合后保留的最近未整合消息数量 |
较低的 memory_window 值会导致更频繁的整合(更小的批次,更多的 LLM 调用)。较高的值会延迟整合,但每次处理更大的批次。
文件参考
组件 |
文件 |
关键函数 |
|---|---|---|
MemoryStore |
|
|
Session |
|
|
SessionManager |
|
|
ContextBuilder |
|
|
AgentLoop |
|
|
Memory skill |
|
Agent 指令(始终加载) |
save_memory tool |
|
整合用的 LLM tool schema |
测试覆盖
测试文件 |
测试内容 |
|---|---|
|
|
|
String/dict/JSON-string 参数处理、无 tool call 的回退、消息过少时跳过整合 |
设计权衡
决策 |
收益 |
代价 |
|---|---|---|
只追加消息 |
LLM cache 效率高;不丢数据 |
消息列表在内存中无限增长,直到会话被清空 |
LLM 驱动的整合 |
高质量摘要;事实提取 |
每次整合额外消耗一次 LLM API 调用;有成本 |
MEMORY.md 全量覆写 |
去重;文档保持连贯 |
如果 LLM 遗漏已有条目,存在事实丢失风险 |
HISTORY.md 不加载到上下文 |
保持 prompt 体积小 |
agent 必须主动 grep;可能遗漏相关历史 |
后台整合 |
非阻塞;不延迟用户响应 |
竞态条件需要并发保护机制 |
相关文档
最后更新:2026-03-15 版本:1.0