AG-16_会话存储:Append-only 状态持久化
会话存储:Append-only 状态持久化
当 Agent 执行到一半崩溃了,你该怎么办?从头重来,还是从断点继续?答案藏在存储设计里。
前言
AI Agent 的会话状态是整个系统中最脆弱也最关键的部分。一次典型的 Agent 执行可能跨越数十轮工具调用、数千个 token 的上下文,以及数分钟乃至数小时的执行时间。在这漫长的生命周期中,任何环节的异常——网络中断、进程崩溃、用户主动暂停——都可能导致状态丢失。
传统的数据库方案采用"最新值覆盖"(Last-Write-Wins)策略来存储状态,这在 Agent 场景下存在根本性缺陷:你无法知道"什么时候的值"才是正确的,也无法回溯到某个中间状态重新开始。本文将深入探讨 Append-only 状态持久化 模式,这是 Claude Code 等主流 AI Agent 系统在实践中采用的核心设计哲学。
会话状态的挑战

状态的多维性
Agent 会话状态远不止"对话历史"这么简单。一个完整的会话状态至少包含以下维度:
- 对话历史:用户消息、助手回复、系统提示词
- 工具调用记录:每个工具的输入、输出、执行状态
- 中间决策:Agent 的推理链、分支选择、回退记录
- 环境快照:文件系统状态、代码变更、外部 API 响应
- 元数据:创建时间、最后修改时间、token 计数、模型参数
这些维度之间存在复杂的依赖关系。如果你只保存了对话历史而丢失了工具调用记录,那么后续的工具调用可能会产生重复副作用(比如重复发送邮件、重复创建文件)。
一致性困境
在分布式环境中,会话状态的一致性面临三大挑战:
- 并发写入:用户可能在 Agent 执行过程中发送新消息
- 部分失败:某个工具调用成功但后续步骤失败
- 因果顺序:消息的物理到达顺序可能与逻辑顺序不一致
传统的事务模型(ACID)在这种场景下过于重量级,而最终一致性模型又无法满足 Agent 对"可恢复性"的强需求。我们需要一种介于两者之间的方案。
Append-only 设计哲学

核心思想:只追加,不修改
Append-only(仅追加)存储的核心原则极为简单:任何状态变更都以新记录的形式追加到日志末尾,永不修改已有记录。这看似低效,却解决了 Agent 状态管理中的几乎所有核心问题。
Event Sourcing 模式
Append-only 存储本质上是 Event Sourcing(事件溯源)模式在 Agent 系统中的应用。Event Sourcing 最早由 Martin Fowler 在 2005 年提出,其核心思想是:不存储实体的当前状态,而是存储导致状态变化的所有事件。
在 Agent 上下文中,每个"事件"代表一次状态变更:
message_added:新增一条消息tool_invoked:发起一次工具调用tool_completed:工具调用完成context_summarized:上下文被摘要压缩checkpoint_created:创建恢复检查点
通过重放(replay)事件序列,你可以精确重建任意时间点的会话状态。
为什么不用 WAL?
Write-Ahead Log(预写日志)是数据库领域常见的持久化策略,但 WAL 和 Append-only 存储有几个关键区别:
| 特性 | WAL | Append-only 日志 |
|---|---|---|
| 目的 | 保证事务原子性 | 保证状态可追溯性 |
| 生命周期 | 事务完成后可清理 | 永久保留或按策略归档 |
| 查询能力 | 不支持直接查询 | 支持按时间/类型查询 |
| 存储格式 | 二进制、内部格式 | 结构化、可读格式 |
| 适用场景 | 数据库内部机制 | 应用层状态管理 |
对于 Agent 系统,我们既需要 WAL 的原子性保证,也需要 Append-only 的可追溯性。实际上,很多 Agent 框架会同时使用两者:WAL 用于保证单次操作的原子性,Append-only 日志用于跨操作的状态追踪。
存储格式与序列化

JSONL:简单但有效
Claude Code 的会话存储采用了 JSONL(JSON Lines)格式,每行一个 JSON 对象,代表一个事件。这种格式有几个显著优势:
- 流式写入:每个事件可以独立序列化和写入,无需等待整个文件完成
- 容错性:某一行损坏不影响其他行的读取
- 可读性:人类可以直接阅读和调试
- 跨语言:任何编程语言都能轻松解析
一个典型的会话日志文件可能看起来像这样:
{"type":"session_start","timestamp":"2025-01-15T10:30:00Z","model":"claude-sonnet-4-20250514","session_id":"sess_abc123"}
{"type":"message_added","role":"user","content":"帮我分析这段代码的性能问题","timestamp":"2025-01-15T10:30:05Z"}
{"type":"message_added","role":"assistant","content":"我来分析这段代码...","timestamp":"2025-01-15T10:30:08Z","usage":{"input_tokens":150,"output_tokens":80}}
{"type":"tool_invoked","tool_name":"read_file","input":{"path":"main.py"},"timestamp":"2025-01-15T10:30:10Z","invocation_id":"inv_001"}
{"type":"tool_completed","invocation_id":"inv_001","output":"def process_data(data):...","duration_ms":45,"timestamp":"2025-01-15T10:30:11Z"}
{"type":"checkpoint","state_hash":"a1b2c3d4","event_count":5,"timestamp":"2025-01-15T10:30:12Z"}
消息序列化策略
对话历史是会话状态中体积最大的部分。对于长对话,需要精心设计序列化策略:
import json
from dataclasses import dataclass, asdict
from typing import List, Optional
from datetime import datetime
from enum import Enum
class EventType(Enum):
SESSION_START = "session_start"
MESSAGE_ADDED = "message_added"
TOOL_INVOKED = "tool_invoked"
TOOL_COMPLETED = "tool_completed"
CHECKPOINT = "checkpoint"
CONTEXT_SUMMARIZED = "context_summarized"
@dataclass
class SessionEvent:
"""会话事件基类 - 所有状态变更都通过此结构记录"""
type: EventType
timestamp: str
event_id: str # 全局唯一事件ID,用于去重和排序
def to_jsonl(self) -> str:
"""序列化为 JSONL 格式(单行 JSON)"""
data = asdict(self)
data['type'] = self.type.value # Enum 转字符串
return json.dumps(data, ensure_ascii=False)
这段代码定义了事件的基础结构。注意 event_id 字段——它是保证事件幂等性的关键。即使同一个事件被意外写入两次,系统也能通过 event_id 去重。
索引与压缩
随着会话日志增长,直接顺序扫描的效率会下降。实践中通常会维护一个索引文件,记录关键事件的偏移量:
@dataclass
class SessionIndex:
"""会话索引 - 记录关键事件的位置,加速随机访问"""
session_id: str
checkpoints: List[dict] # 检查点列表:{event_id, offset, timestamp}
message_offsets: List[dict] # 消息偏移量:{event_id, offset, role}
last_event_id: str # 最后一个事件的ID
total_events: int # 总事件数
def find_nearest_checkpoint(self, target_event_id: str) -> Optional[dict]:
"""找到目标事件之前最近的检查点"""
candidates = [
cp for cp in self.checkpoints
if cp['event_id'] <= target_event_id
]
return max(candidates, key=lambda x: x['event_id']) if candidates else None
索引文件的存在使得"从断点恢复"变得高效——系统不需要扫描整个日志文件,只需要找到最近的检查点,然后从该位置开始重放后续事件。
断点恢复机制
检查点策略
检查点(Checkpoint)是断点恢复的基础。它本质上是一个状态快照,记录了到某个事件为止的完整会话状态。恢复时,系统只需要加载最近的检查点,然后重放后续事件即可。
检查点的创建时机有几种策略:
- 固定间隔:每 N 个事件创建一次检查点
- 时间间隔:每 M 分钟创建一次检查点
- 事件类型触发:在特定事件(如工具调用完成)后创建检查点
- 混合策略:结合以上多种条件
import hashlib
from typing import List, Dict, Any
class CheckpointManager:
"""检查点管理器 - 负责创建和管理会话检查点"""
def __init__(self, max_events_between_checkpoints: int = 50,
max_seconds_between_checkpoints: int = 300):
self.max_events = max_events_between_checkpoints
self.max_seconds = max_seconds_between_checkpoints
self.events_since_checkpoint = 0
self.last_checkpoint_time = None
def should_create_checkpoint(self, event: SessionEvent) -> bool:
"""判断是否应该创建检查点 - 混合策略"""
self.events_since_checkpoint += 1
# 策略1:工具调用完成后立即创建(关键恢复点)
if event.type == EventType.TOOL_COMPLETED:
return True
# 策略2:固定事件间隔
if self.events_since_checkpoint >= self.max_events:
return True
# 策略3:时间间隔(如果上次检查点时间已知)
if self.last_checkpoint_time:
elapsed = (datetime.fromisoformat(event.timestamp) -
self.last_checkpoint_time).total_seconds()
if elapsed >= self.max_seconds:
return True
return False
def create_checkpoint(self, events: List[SessionEvent],
current_state: Dict[str, Any]) -> SessionEvent:
"""创建检查点事件 - 包含状态哈希用于校验"""
state_json = json.dumps(current_state, sort_keys=True, ensure_ascii=False)
state_hash = hashlib.sha256(state_json.encode()).hexdigest()[:16]
checkpoint = SessionEvent(
type=EventType.CHECKPOINT,
timestamp=datetime.utcnow().isoformat() + 'Z',
event_id=f"cp_{state_hash}",
)
# 附加检查点元数据
checkpoint.state_hash = state_hash
checkpoint.event_count = len(events)
checkpoint.state_snapshot = current_state
self.events_since_checkpoint = 0
self.last_checkpoint_time = datetime.utcnow()
return checkpoint
这段代码展示了混合检查点策略的实现。注意 should_create_checkpoint 方法中的三重判断:工具调用完成后总是创建检查点(因为工具调用可能有副作用,需要精确恢复),同时还有基于事件数量和时间间隔的兜底策略。
恢复流程
当系统需要恢复会话状态时,执行以下流程:
- 定位检查点:从索引文件中找到最近的检查点
- 加载快照:读取检查点中保存的状态快照
- 重放事件:从检查点之后开始,依次重放每个事件
- 校验状态:对比最终状态的哈希值,确保恢复正确
class SessionRecovery:
"""会话恢复引擎 - 从检查点和事件日志重建会话状态"""
def __init__(self, storage: 'SessionStorage'):
self.storage = storage
async def recover_session(self, session_id: str) -> Dict[str, Any]:
"""恢复会话状态 - 从最近检查点开始重放"""
# 第一步:加载索引,找到最近的检查点
index = await self.storage.load_index(session_id)
if not index:
raise ValueError(f"Session {session_id} not found")
checkpoint = index.find_nearest_checkpoint(index.last_event_id)
if checkpoint:
# 第二步:从检查点加载状态快照
state = checkpoint['state_snapshot']
start_event_id = checkpoint['event_id']
print(f"[Recovery] Loaded checkpoint at event {start_event_id}")
else:
# 无检查点,从头开始
state = self._create_empty_state(session_id)
start_event_id = None
print(f"[Recovery] No checkpoint found, replaying from beginning")
# 第三步:重放检查点之后的所有事件
events = await self.storage.load_events_after(session_id, start_event_id)
replayed_count = 0
for event in events:
state = self._apply_event(state, event)
replayed_count += 1
print(f"[Recovery] Replayed {replayed_count} events, "
f"session state restored successfully")
return state
def _apply_event(self, state: Dict[str, Any],
event: SessionEvent) -> Dict[str, Any]:
"""将单个事件应用到状态上 - 状态机的核心转换函数"""
if event.type == EventType.MESSAGE_ADDED:
state['messages'].append({
'role': event.role,
'content': event.content,
'timestamp': event.timestamp
})
elif event.type == EventType.TOOL_INVOKED:
state['pending_tools'][event.invocation_id] = {
'tool_name': event.tool_name,
'input': event.input,
'started_at': event.timestamp
}
elif event.type == EventType.TOOL_COMPLETED:
tool_record = state['pending_tools'].pop(event.invocation_id, {})
state['completed_tools'].append({
**tool_record,
'output': event.output,
'duration_ms': event.duration_ms
})
elif event.type == EventType.CONTEXT_SUMMARIZED:
# 上下文摘要事件:替换旧的对话历史为摘要版本
state['messages'] = event.summarized_messages
state['summary_applied_at'] = event.timestamp
return state
这段恢复引擎的代码揭示了 Append-only 模式的核心优势:恢复过程是确定性的。给定相同的事件序列,_apply_event 总是产生相同的状态。这意味着你可以:
- 在不同机器上恢复同一个会话
- 对恢复过程进行单元测试
- 在恢复过程中检测状态不一致
增量恢复与流式恢复
在实际生产环境中,完全重放所有事件可能很耗时。为此,可以采用两种优化策略:
增量恢复:维护多个检查点,恢复时只重放最近一个检查点之后的事件。这要求检查点创建策略更加积极。
流式恢复:在恢复过程中同时接收新事件。恢复引擎维护一个"追赶"状态——当历史事件重放完成时,新事件已经排好队等待处理。这对于用户在 Agent 恢复过程中继续发送消息的场景非常有用。
与传统数据库方案对比
关系型数据库方案
传统的关系型数据库(如 PostgreSQL)也可以用来存储会话状态,但存在以下问题:
| 对比维度 | Append-only 日志 | 关系型数据库 |
|---|---|---|
| 写入性能 | 追加写入,O(1) 复杂度 | 需要索引维护,写放大严重 |
| 恢复能力 | 天然支持任意时间点恢复 | 需要额外实现 WAL 或备份机制 |
| 存储效率 | 可压缩,事件粒度灵活 | 行级存储,元数据开销大 |
| 查询灵活性 | 按时间/类型查询高效 | 复杂查询能力强 |
| 运维复杂度 | 文件系统级别,极低 | 需要 DBA 维护 |
| 适用规模 | 单会话级别,GB 级别 | 多会话级别,TB 级别 |
NoSQL 方案
MongoDB、DynamoDB 等 NoSQL 数据库在灵活性上更接近 Append-only 日志,但在"可追溯性"方面仍然不足。NoSQL 的文档模型天然适合存储会话记录,但要实现事件溯源,需要额外的变更为日志(Change Stream)或自定义事件表。
文件系统方案
Claude Code 等工具选择了最朴素的方案:直接使用文件系统。会话日志以 JSONL 文件存储在本地磁盘上,索引文件与之并列。这种方案的优势在于:
- 零依赖:不需要安装和维护数据库
- 可移植:用户可以直接复制会话文件
- 可调试:用
cat或jq就能查看会话内容 - 隐私友好:数据完全在本地,不经过第三方服务
当然,文件系统方案的劣势也很明显:不支持跨设备同步、缺乏并发控制、大文件性能问题。但对于大多数 Agent 使用场景(单用户、单设备),这些劣势是可以接受的。
总结
Append-only 状态持久化是 AI Agent 会话存储的最佳实践,其核心优势在于:
- 可追溯性:完整记录每个状态变更,支持任意时间点回溯
- 容错性:系统崩溃后可以从检查点精确恢复,不丢失任何信息
- 确定性:事件重放是确定性的,恢复结果可验证
- 简洁性:基于文件系统的实现几乎零依赖
在设计自己的 Agent 系统时,建议从 JSONL + 检查点的简单方案开始,随着规模增长再逐步引入索引、压缩和分布式同步等高级特性。记住:状态管理的第一原则是不丢失状态,而不是优化查询性能。
参考资料
- Claude Code 源码 - Anthropic 的 CLI Agent 实现,展示了生产级会话存储设计(GitHub: anthropics/claude-code)
- Martin Fowler, “Event Sourcing” (2005) - Event Sourcing 模式的经典定义和设计原则
- Greg Young, “CQRS Documents” (2010) - 事件溯源与 CQRS 模式的深度结合
- Anthropic, “Claude Code: Best practices for agentic coding” (2025) - 官方文档中关于会话管理的工程实践
- Jay Kreps, “The Log: What every software engineer should know” (2013) - 关于日志作为数据基础设施的经典论文
本系列覆盖 AI 大模型基础、Agent 开发、MCP 协议、Skill 开发、RAG、模型微调、部署推理 七大方向,从入门到实战的全栈内容持续更新中。
所有文章的 Markdown 源文件、可运行代码、高清配图已整理成完整资料包。
👍 点赞 + ⭐ 关注,评论区扣「1」,挨个发你领取方式 👇
更多推荐


所有评论(0)