OpenHands 源码解析系列
第 5 讲:事件溯源系统 (Event Log + 3 Backends)
基于 OpenHands 源码 · 2026-08-01
一、事件溯源:OpenHands 的数据基石
在 OpenHands V1 架构中,事件(Event)是会话中所有交互的唯一真相来源。用户的每一条消息、Agent 的每一次工具调用、沙箱的每一个输出片段——全部被序列化为 JSON 事件,持久化到不同的存储后端。
这种事件溯源(Event Sourcing)模式意味着:
🔹 会话状态不是直接存储的,而是由事件序列回放重建
🔹 每个事件是不可变的 JSON 文档,存储在独立文件中
🔹 三种存储后端(文件系统 / AWS S3 / Google Cloud Storage)通过同一抽象接口切换
🔹 事件回调系统(Event Callback)在事件产生时触发下游处理
📦 源码位置
openhands/app_server/event/ — 事件存储服务
openhands/app_server/event_callback/ — 事件回调系统
openhands/app_server/conversation_paths.py — 路径构造工具
openhands/app_server/utils/environment.py — 存储提供商检测
二、事件存储架构总览
事件溯源系统架构
+----------------------------------------------------------+
| EventService (ABC) |
| 抽象接口: get / search / count / save / batch_get |
+---------------------------+------------------------------+
|
+---------------+----------------+
| | |
+-----------v----------+ +---v----------+ +--v-------------+
| EventServiceBase | | (future: | | (future: |
| (带路径逻辑的基类) | | RedisEvent | | PostgresEvent |
+-----------+----------+ +-------------+ +---------------+
|
+-------+--------+
| | |
+v1 | v2 | v3 |
| | |
+---v----+ +v-------v+ +v------------v+
|Filesys | | AWS | | Google Cloud|
|Event | | S3 | | Storage |
|Service | | Service | | Service |
+--------+ +---------+ +--------------+
| | |
+-------+--------+
|
{prefix}/{user_id}/v1_conversations/
{conversation_id_hex}/{event_id_hex}.json
三、EventService 抽象接口
所有事件存储后端的共同契约,定义了事件操作的完整 API:
📄 event_service.py (第 18-71 行)
class EventService(ABC):
"""Event Service for getting events."""
@abstractmethod
async def get_event(self, conversation_id: UUID, event_id: UUID) -> Event | None:
"""Given an id, retrieve an event."""
@abstractmethod
async def search_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None, # 按事件类型过滤
timestamp__gte: datetime | None = None, # 时间范围过滤
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
page_id: str | None = None, # 分页游标
limit: int = 100,
) -> EventPage:
"""Search events matching the given filters."""
@abstractmethod
async def count_events(...) -> int:
"""Count events matching the given filters."""
@abstractmethod
async def save_event(self, conversation_id: UUID, event: Event):
"""Save an event. Internal method intended not be part of the REST api."""
async def batch_get_events(
self, conversation_id: UUID, event_ids: list[UUID]
) -> list[Event | None]:
"""Batch retrieval — asyncio.gather 并行获取多个事件"""
关键设计:接口纯粹基于 UUID 寻址,不暴露任何存储细节。调用方(路由层、会话服务、导出工具)完全不知道事件存在于本地磁盘、S3 还是 GCS。
同时定义了 EventServiceInjector 依赖注入抽象:
📄 event_service.py (第 73-74 行)
class EventServiceInjector(DiscriminatedUnionMixin, Injector[EventService], ABC):
pass
四、EventServiceBase — 路径与并发核心
这是最关键的中间层,封装了路径构造和并发加载的通用逻辑:
📄 event_service_base.py (第 24-28 行)
def _event_load_concurrency() -> int:
try:
return max(1, int(os.getenv('EVENT_SERVICE_LOAD_EVENT_CONCURRENCY', '10')))
except ValueError:
return 10
默认并发度为 10,可通过环境变量 EVENT_SERVICE_LOAD_EVENT_CONCURRENCY 调整。这是性能调优的关键参数。
4.1 路径构造逻辑
每个事件的存储路径遵循统一的层级结构:
📄 event_service_base.py (第 66-84 行)
async def get_conversation_path(self, conversation_id: UUID) -> Path:
"""Get a path for a conversation. Ensure user_id is included if possible."""
path = self.prefix
if self.user_id:
path /= self.user_id
elif self.app_conversation_info_service:
# 异步懒加载:如果 user_id 不在上下文中,
# 从会话信息中查找 created_by_user_id
task = self.app_conversation_info_load_tasks.get(conversation_id)
if task is None:
task = asyncio.create_task(
self.app_conversation_info_service.get_app_conversation_info(
conversation_id
)
)
self.app_conversation_info_load_tasks[conversation_id] = task
conversation_info = await task
if conversation_info and conversation_info.created_by_user_id:
path /= conversation_info.created_by_user_id
path = path / V1_CONVERSATIONS_DIR / conversation_id.hex
return path
路径结构:
📄 conversation_paths.py (第 12 行)
V1_CONVERSATIONS_DIR = 'v1_conversations'
# 最终路径格式:
# {persistence_dir}/{user_id}/v1_conversations/{conversation_id_hex}/{event_id_hex}.json
注意懒加载设计:app_conversation_info_load_tasks 缓存了异步任务,避免重复查询数据库。
4.2 并发事件加载
📄 event_service_base.py (第 56-64 行)
async def _load_events_from_paths(self, paths: list[Path]) -> list[Event | None]:
loop = asyncio.get_running_loop()
semaphore = asyncio.Semaphore(_event_load_concurrency())
async def load_event(path: Path) -> Event | None:
async with semaphore:
return await loop.run_in_executor(None, self._load_event, path)
return await asyncio.gather(*(load_event(path) for path in paths))
三个设计要点:
🔹 信号量限流:Semaphore 控制最大并发,防止 I/O 风暴
🔹 线程池执行:run_in_executor 将阻塞的文件/网络 I/O 移到线程池,不阻塞事件循环
🔹 并行聚合:asyncio.gather 等待所有事件加载完成
4.3 搜索与过滤
📄 event_service_base.py (第 94-143 行)
async def search_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
page_id: str | None = None,
limit: int = 100,
) -> EventPage:
"""Search events matching the given filters."""
loop = asyncio.get_running_loop()
prefix = await self.get_conversation_path(conversation_id)
paths = await loop.run_in_executor(None, self._search_paths, prefix)
events = await self._load_events_from_paths(paths)
# 时间过滤:datetime 转为 ISO 字符串与 event.timestamp 比较
timestamp_gte_str = timestamp__gte.isoformat() if timestamp__gte else None
timestamp_lt_str = timestamp__lt.isoformat() if timestamp__lt else None
items = []
for event in events:
if not event:
continue
if kind__eq and event.kind != kind__eq:
continue
if timestamp_gte_str and event.timestamp < timestamp_gte_str:
continue
if timestamp_lt_str and event.timestamp >= timestamp_lt_str:
continue
items.append(event)
if sort_order:
items.sort(
key=lambda e: e.timestamp,
reverse=(sort_order == EventSortOrder.TIMESTAMP_DESC),
)
# 分页逻辑
start_offset = 0
next_page_id = None
if page_id:
start_offset = int(page_id)
items = items[start_offset:]
if len(items) > limit:
next_page_id = str(start_offset + limit)
items = items[:limit]
return EventPage(items=items, next_page_id=next_page_id)
五、三大存储后端实现
OpenHands 通过三个后端实现同一套抽象接口,每个后端只需覆写三个方法:
| 方法 | FilesystemEventService | AwsEventService | GoogleCloudEventService |
|---|---|---|---|
_load_event |
path.read_text() | s3_client.get_object() | bucket.blob().open('r') |
_store_event |
path.write_text() | s3_client.put_object() | blob.open('w') |
_search_paths |
glob.glob() | list_objects_v2() | bucket.list_blobs() |
| 认证方式 | 无需认证 | IAM Role (无显式凭证) | Application Default Credentials |
5.1 FilesystemEventService — 本地文件系统
📄 filesystem_event_service.py (第 17-43 行)
@dataclass
class FilesystemEventService(EventServiceBase):
"""Event service based on file system"""
limit: int = 500
def _load_event(self, path: Path) -> Event | None:
try:
content = path.read_text()
content = Event.model_validate_json(content)
return content
except Exception:
if path.exists():
_logger.exception('Error reading event', stack_info=True)
return None
def _store_event(self, path: Path, event: Event):
path.parent.mkdir(parents=True, exist_ok=True)
content = event.model_dump_json(indent=2)
path.write_text(content)
def _search_paths(self, prefix: Path, page_id: str | None = None) -> list[Path]:
search_path = f'{prefix}/*'
files = glob.glob(str(search_path))
paths = [Path(file) for file in files]
return paths
最简实现:每个事件是一个独立的 JSON 文件。Pydantic 的 model_validate_json 和 model_dump_json 负责序列化/反序列化。
5.2 AwsEventService — AWS S3
📄 aws_event_service.py (第 27-77 行)
@dataclass
class AwsEventService(EventServiceBase):
"""AWS S3-based implementation of EventService.
Uses role-based authentication, so no explicit credentials are needed."""
s3_client: Any
bucket_name: str
def _load_event(self, path: Path) -> Event | None:
try:
response = self.s3_client.get_object(
Bucket=self.bucket_name, Key=str(path)
)
with response['Body'] as stream:
json_data = stream.read().decode('utf-8')
event = Event.model_validate_json(json_data)
return event
except botocore.exceptions.ClientError as e:
if e.response['Error']['Code'] == 'NoSuchKey':
return None
_logger.exception(f'Error reading event from {path}', stack_info=True)
return None
def _search_paths(self, prefix: Path, page_id: str | None = None) -> list[Path]:
kwargs = {'Bucket': self.bucket_name, 'Prefix': str(prefix)}
if page_id:
kwargs['ContinuationToken'] = page_id
response = self.s3_client.list_objects_v2(**kwargs)
contents = response.get('Contents', [])
paths = [Path(obj['Key']) for obj in contents]
return paths
关键点:使用 IAM Role 认证,无需显式密钥。S3 Key 路径与文件系统路径完全一致,实现了存储透明切换。
5.3 GoogleCloudEventService — GCS
📄 google_cloud_event_service.py (第 26-71 行)
@lru_cache(maxsize=1)
def _get_shared_storage_client() -> Client:
"""Return a process-wide shared GCS client.
google.cloud.storage.Client is thread-safe and manages its own urllib3
connection pool. Creating one per request leads to pool exhaustion under
load ("Connection pool is full, discarding connection")."""
return storage.Client()
@dataclass
class GoogleCloudEventService(EventServiceBase):
"""Google Cloud Storage-based implementation of EventService."""
bucket: Bucket
def _load_event(self, path: Path) -> Event | None:
blob: Blob = self.bucket.blob(str(path))
try:
with blob.open('r') as f:
json_data = f.read()
event = Event.model_validate_json(json_data)
return event
except NotFound:
return None
def _search_paths(self, prefix: Path, page_id: str | None = None) -> list[Path]:
blobs: Iterator[Blob] = self.bucket.list_blobs(
page_token=page_id, prefix=str(prefix)
)
paths = list(Path(blob.name) for blob in blobs)
return paths
性能优化:注意到 @lru_cache(maxsize=1) 装饰器——GCS Client 是进程级共享的,避免每个请求创建连接池导致连接耗尽。
六、存储提供商自动检测
系统启动时自动检测环境配置,选择对应的后端:
📄 utils/environment.py (第 13-42 行)
class StorageProvider(str, Enum):
"""Storage provider types for event and shared event storage."""
AWS = 'aws'
GCP = 'gcp'
FILESYSTEM = 'filesystem'
def get_storage_provider() -> StorageProvider:
provider = os.environ.get('SHARED_EVENT_STORAGE_PROVIDER', '').lower()
if not provider:
provider = os.environ.get('FILE_STORE', '').lower()
if provider in ('aws', 's3'):
return StorageProvider.AWS
elif provider in ('gcp', 'google_cloud'):
return StorageProvider.GCP
else:
return StorageProvider.FILESYSTEM
配置链:SHARED_EVENT_STORAGE_PROVIDER → FILE_STORE → 默认 FILESYSTEM
📄 config.py (第 307-327 行)
if config.event is None:
provider = get_storage_provider()
if provider == StorageProvider.AWS:
bucket_name = os.environ.get('FILE_STORE_PATH')
if not bucket_name:
raise ValueError(
'FILE_STORE_PATH environment variable is required for S3 storage'
)
config.event = AwsEventServiceInjector(bucket_name=bucket_name)
elif provider == StorageProvider.GCP:
bucket_name = os.environ.get('FILE_STORE_PATH')
if not bucket_name:
raise ValueError(
'FILE_STORE_PATH environment variable is required for Google Cloud storage'
)
config.event = GoogleCloudEventServiceInjector(bucket_name=bucket_name)
else:
config.event = FilesystemEventServiceInjector()
七、事件回调系统
事件产生后,Event Callback 系统负责触发下游处理。这是一个可插拔的回调框架:
7.1 回调模型
📄 event_callback_models.py (第 40-94 行)
class EventCallbackStatus(Enum):
ACTIVE = 'ACTIVE'
DISABLED = 'DISABLED'
COMPLETED = 'COMPLETED'
ERROR = 'ERROR'
class EventCallbackProcessor(DiscriminatedUnionMixin, ABC):
event_kind: ClassVar[EventKind] = 'MessageEvent'
@abstractmethod
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult | None:
"""Process an event."""
class EventCallback(CreateEventCallbackRequest):
id: OpenHandsUUID = Field(default_factory=uuid4)
status: EventCallbackStatus = Field(default=EventCallbackStatus.ACTIVE)
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
每个回调绑定一个 会话 ID + 事件类型 + 处理器。当匹配的事件产生时,处理器被调用。
7.2 SQL 回调服务 — 短会话设计
SQL 实现的核心设计哲学是不持有长连接:
📄 sql_event_callback_service.py (第 92-108 行)
@dataclass
class SQLEventCallbackService(EventCallbackService):
"""SQL implementation of EventCallbackService.
The service does **not** hold a long-lived ``AsyncSession``. Each public
method opens a short-lived session via ``self.async_session_maker`` for the
duration of its own DB work. This means:
* The pool connection is only checked out while the method runs an
actual SQL statement — it is released as soon as the method returns,
so callers never accidentally pin a connection across slow logic
(e.g. webhook deliveries, ``asyncio.gather`` of callback processors).
* Methods are independent: a failure in one (e.g. a flush error) cannot
leave another with a session in an unknown transactional state.
* The service itself is no longer an async-context-manager. There is
no per-service ``__aenter__``/``__aexit__`` lifecycle to get wrong.
"""
async_session_maker: async_sessionmaker
三个保证:
🔹 连接池只在实际执行 SQL 时占用,方法返回即释放
🔹 方法间独立,一个方法的失败不会影响其他方法的会话状态
🔹 服务本身不再是异步上下文管理器,没有生命周期管理的风险
7.3 回调执行流程
📄 sql_event_callback_service.py (第 248-281 行)
async def execute_callbacks(self, conversation_id: UUID, event: Event) -> None:
"""Run all active callbacks for the event and persist their results."""
callbacks = await self.get_active_callbacks(conversation_id, event)
if not callbacks:
return
outcomes = await asyncio.gather(
*[invoke_callback(cb, conversation_id, event) for cb in callbacks],
return_exceptions=True,
)
normalised: list[StoredEventCallbackResult | None] = []
for callback, outcome in zip(callbacks, outcomes, strict=False):
if isinstance(outcome, BaseException):
_logger.exception(
f'Exception in callback {callback.id}', stack_info=True
)
normalised.append(
StoredEventCallbackResult(
status=EventCallbackResultStatus.ERROR,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=str(outcome),
)
)
else:
normalised.append(outcome)
await self.persist_callback_results(callbacks, normalised)
三个步骤各自独立的数据库会话:
🔹 get_active_callbacks:查询活跃回调(短会话,立即释放)
🔹 invoke_callback:执行回调处理器(无数据库连接!)
🔹 persist_callback_results:持久化结果(新的短会话)
7.4 实际回调:SetTitleCallbackProcessor
一个真实的回调处理器——自动为会话设置标题:
📄 set_title_callback_processor.py (第 28-80 行)
# Delay between attempts to poll title
_POLL_DELAY_S = 3
# Number of attempts to poll title
_NUM_POLL_ATTEMPTS = 4
# Avoid starting one slow title poll per webhook event.
_CONVERSATIONS_BEING_POLLED: set[UUID] = set()
async def _poll_for_title(
httpx_client: httpx.AsyncClient,
url: str,
session_api_key: str | None,
) -> str | None:
"""Poll the agent server for the conversation title."""
for _ in range(_NUM_POLL_ATTEMPTS):
await asyncio.sleep(_POLL_DELAY_S)
try:
headers = {'X-Session-API-Key': session_api_key} if session_api_key else {}
response = await httpx_client.get(url, headers=headers)
response.raise_for_status()
except httpx.HTTPError as exc:
_logger.warning('Title poll failed for conversation %s: %s', url, exc)
else:
title = response.json().get('title')
if title:
return title
return None
设计要点:最多重试 4 次,每次间隔 3 秒;使用全局 _CONVERSATIONS_BEING_POLLED 集合防止重复轮询。
八、API 路由层
事件系统的 REST API 入口:
📄 event_router.py (第 18-67 行)
router = APIRouter(
prefix='/conversation/{conversation_id}/events',
tags=['Events'],
dependencies=get_dependencies(),
)
event_service_dependency = depends_event_service()
@router.get('/search')
async def search_events(
conversation_id: str,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
page_id: str | None = None,
limit: int = 100,
event_service: EventService = event_service_dependency,
) -> EventPage:
"""Search / List events."""
return await event_service.search_events(...)
@router.get('/count')
async def count_events(...) -> int:
"""Count events matching the given filters."""
@router.get('')
async def batch_get_events(
conversation_id: str,
id: list[str],
event_service: EventService = event_service_dependency,
) -> list[Event | None]:
"""Get a batch of events given their ids."""
if len(id) > 100:
raise HTTPException(status_code=400, ...)
event_ids = [UUID(id_) for id_ in id]
return await event_service.batch_get_events(UUID(conversation_id), event_ids)
九、架构总结
| 设计维度 | 实现方式 |
|---|---|
| 存储抽象 | EventService ABC + 3 个具体后端 |
| 路径一致性 | 所有后端使用相同 Path 格式 |
| 并发控制 | Semaphore + run_in_executor + asyncio.gather |
| 懒加载优化 | app_conversation_info_load_tasks 缓存异步任务 |
| 连接池保护 | 短会话设计,回调执行时不持有 DB 连接 |
| 容错机制 | return_exceptions=True + 异常转为 ERROR 状态 |
| 扩展性 | EventCallbackProcessor 抽象 + DiscriminatedUnionMixin |
事件溯源系统是 OpenHands 的数据骨干——每一个用户交互、Agent 行为都被不可变地记录下来,支撑了会话回放、轨迹导出、回调处理等核心功能。下一讲我们将深入沙箱生命周期管理。
📚 系列导航
← 第 4 讲:会话管理核心
→ 第 6 讲:沙箱生命周期
关注公众号「AI技术推荐官」获取更多源码解析内容
夜雨聆风