Day 03 / 共 20 天 · 第 1 周 核心概念

事件流 Event Stream

昨天(Day 02)我们认识了裸的 Action/Observation——但它们还要被包成带元数据的"事件"才能在系统里流动。一次会话 = 一条按时间排列的事件流。今天看事件怎么包装、动作和观察靠什么配对、还有哪些辅助事件;理解了"事件流",明天(Day 04)才能看懂运行时里数据到底在传什么。

📍 第 1 周 核心概念 · 你在这里
Day1 全景 Day2 Action/Obs Day3 事件流 Day4 运行 Day5 完整旅程
L01

会话 = 一条事件流

OpenHands 里,一次对话(conversation)就是一条按时间排列的事件(Event)序列。你发的消息、Agent 的动作、环境返回的结果,全都是流里的一个个事件:

USER MessageEvent:「帮我修登录 bug」
AGENT ActionEvent:想跑 cat app.py(附带 thought)
ENV ObservationEvent:文件内容返回
AGENT ActionEvent:str_replace 修改第 42 行
ENV ObservationEvent:编辑成功
AGENT ActionEvent:FinishAction「修好了」
为什么"一切皆事件流"这么关键? 事件流就像生活中的微信群聊天记录:谁(你/AI/运行环境)都能往里发消息、每条消息都自动带着"谁发的、几点发的"、任何人翻一遍历史就能还原整场对话。OpenHands 的一次会话就是这么一个"群"。
因为它带来三个超能力:① 能存能回放——整个任务过程是一串数据,存下来随时能重现、审计"AI 到底干了啥";② 前端只要订阅这条流就能实时展示——来一个事件画一个卡片;③ 能压缩——对话太长时把老事件折叠成摘要(L07)。事件流是 OpenHands 一切能力的载体。
L02

BaseEvent:所有事件的共同三字段

每个事件都继承 BaseEventfrontend/src/types/v1/core/base/event.ts:10):

export interface BaseEvent {
  id: EventID;        // 唯一 id(用于配对、去重)
  timestamp: string;  // ISO 时间字符串(用于排序)
  source: SourceType; // 事件来源:agent / user / environment / hook
}
读法:三个共同字段:唯一 id、时间戳、来源。id 让事件能互相引用(观察靠它找到对应动作,L05);timestamp 让事件能排成时间流;source 告诉你"这事件是谁产生的"。
⚠️ 小白常误以为 id 是"第几条"的顺序号,其实它是全局唯一标识(像微信每条消息的内部消息号),负责被别人引用和去重;排时间顺序是 timestamp 的活,两者分工不同。
L03

事件的四种来源(source)

base/common.ts:56 定义了 4 种来源:

user你产生的
发消息、暂停
agent大模型产生的
动作、思考、系统提示
environment运行时产生的
观察结果、状态、报错
hook钩子脚本产生的
PreToolUse 等
这四种来源覆盖了系统里所有"会说话的角色"。前端渲染时靠 source 快速分流:user 画成你的气泡、agent 画成 AI 的卡片、environment 画成执行结果面板。观察类事件的 source 永远是 environment(因为它来自沙箱运行时),这个约定后面判断事件类型时会用到。
L04

ActionEvent:给动作套上"信封"

🤔 痛点:光有"要做的事"还不够假设你只把裸命令 cat app.py 丢进流里——过一会你回看历史,根本不知道 AI 当时为什么要读这个文件、这是它第几次工具调用、这个动作危不危险。就像收到一张没写寄件人、没写日期的快递单。
💡 本质:ActionEvent = 给动作套一个"信封"它把昨天(Day 02)的裸 Action 装进信封,信封上再贴齐元数据(想法 thought、工具名、调用 id、风险评级)。裸 Action 是"信件内容",ActionEvent 是"贴好标签、能追溯的整封信"——这才是真正在流里流动的东西。放回微信群的比喻:裸 Action 就像你想说的那句话,ActionEvent 则是群里真正发出去的那条消息——自动带上了头像(谁发的)、时间戳,还标注了"这是条转账消息,金额较大"(security_risk)。

Day 02 的 Action 只是"要做的事"本身。真正在流里流动的是 ActionEvent——把 Action 连同一堆元数据包起来(events/action-event.ts:11):

export interface ActionEvent<T extends Action = Action> extends BaseEvent {
  thought: TextContent[];      // Agent 采取此动作前的"思考"
  action: T;                   // ★ 真正的动作(Day 02 那些)
  tool_name: string;           // 被调用的工具名
  tool_call_id: ToolCallID;    // 大模型返回的 tool call id
  llm_response_id: EventID;    // 同一次 LLM 响应里的并行动作归组
  security_risk: SecurityRisk; // ★ 大模型对这个动作的风险评估
}
读法:一个 ActionEvent 就是"大模型的一次工具调用"。它比裸 Action 多了:thought(把 AI 的心理活动和动作绑在一起,前端能显示"AI 在想什么才决定这么做")、tool_call_id(对应大模型的函数调用)、以及关键的 security_risk
security_risk 是安全的第一道闸 大模型在产出动作时,会同时给这个动作打一个"风险等级"。比如"读个文件"是低危,"rm -rf"或"往数据库写"是高危。系统可以配置成:高危动作执行前先暂停、弹出来问你"确定吗?"(confirmation mode,Day 17 细讲)。这个字段就是这套机制的数据基础。让 AI 自己评估风险 + 人工确认高危动作 = 敢把执行权交给 AI 的底气。
L05

ObservationEvent 与 action_id 配对

观察事件(events/observation-event.ts:6)同样包装,且带一个关键字段 action_id

export interface ObservationEvent<T extends Observation = Observation> extends ObservationBaseEvent {
  source: "environment";  // 观察永远来自环境
  observation: T;         // 真正的观察结果(Day 02)
  action_id: EventID;     // ★ 这个观察,回应的是哪个 ActionEvent
}
📝 举个例子Agent 发出 ActionEvent{ id: "abc", action: ExecuteBash("pytest") } → 沙箱跑完 pytest → 环境产出 ObservationEvent{ action_id: "abc", observation: "5 passed" }。前端凭 action_id === "abc" 就知道这条输出属于刚才那条命令。
ActionEvent id = "abc" · 跑 pytest ObservationEvent action_id = "abc" action_id 回指 → 观察认领它的那条动作 前端据此把两者渲染成同一张卡片的"意图→结果"
图注:action_id 是动作与观察配对的钥匙
读法:action_id 指向"我是哪个动作的结果"——这是动作与观察配对的钥匙。Agent 发出 id=abc 的 ActionEvent(跑命令),命令执行完,环境产出一个 ObservationEvent,它的 action_id=abc,表示"这是 abc 那个命令的输出"。
配对有什么用?看前端的妙用 前端渲染时(utils/handle-event-for-ui.ts)有条核心规则:观察到达时,用 action_id 找到之前那张"动作卡片",原地替换它。所以你在界面上看到的是:先出现一张卡片"⏳ 要运行命令 pytest",命令跑完,同一张卡片变成"✅ 命令 pytest 的输出:..."。不是两张卡片,是一张卡片的"状态更新"。action_id 就是让"意图"和"结果"能在 UI 上合体的黏合剂。
L06

事件全家族(openhands-event.ts

所有事件汇成一个联合类型 OpenHandsEventopenhands-event.ts:25)。三个"主角" + 一堆"配角":

  • 主角ActionEvent(动作)、ObservationEvent(观察)、MessageEvent(纯聊天消息,user 提问 / assistant 回复)。
  • 系统SystemPromptEvent(会话开头发一次,携带系统提示 + 所有可用工具的定义)。
  • 控制/状态ConversationStateUpdateEvent(执行状态:idle/running/paused/finished…)、PauseEvent(用户暂停)。
  • 错误AgentErrorEventConversationErrorEventServerErrorEvent
  • 进阶StreamingDeltaEvent(流式 token)、Condensation*Event(历史压缩)、HookExecutionEvent(钩子)、ACPToolCallEvent(第三方 agent)。
SystemPromptEvent 里的 tools 字段(OpenAI 格式的工具定义)就是"Agent 能用哪些工具"的正式声明——它对应 Day 02 的动作全家福,也对应配置里的 enable_* 开关。会话一开始发这一条,等于告诉大模型"你有这些能力"。
L07

两个重要的进阶事件

① StreamingDeltaEvent(流式增量)streaming-delta-event.ts):大模型是一个字一个字生成的。为了让你看到"打字机效果",每生成一小段就推一个 delta 事件。关键:它不持久化——只用于实时渲染。中途重连的客户端拿不到这些碎片,只会拿到最终完整的 MessageEvent

② CondensationEvent(历史压缩)condensation-event.ts):对话越来越长,迟早超出大模型的上下文窗口。压缩事件负责"把一批老事件折叠成一段摘要"——forgotten_event_ids 记录哪些事件被移出上下文、summary 是它们的替代摘要。

历史压缩为什么必须有? 压缩就像把一长段微信群聊"折叠成一句摘要":几百条消息你不可能条条重看,于是有人总结一句"前面讨论定了方案 A、B 待办",往上翻的负担一下就小了。
大模型能"记住"的内容有上限(上下文窗口,比如 20 万 token)。一个复杂任务可能产生几百个事件,早就超了。如果不处理,要么报错,要么把最早的直接砍掉(丢失重要信息)。压缩 = 把"跑了 50 步的详细过程"浓缩成"前面我做了 A、B、C,结论是 D"的摘要,既省 token 又保留关键信息。配置里 [condenser] 段能选压缩策略(Day 04/14 提)。这是长任务 Agent 的必备能力。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • "一次会话是一条事件流"意味着什么?带来哪三个超能力?
  • BaseEvent 的三个共同字段?事件的四种 source?
  • ActionEvent 比裸 Action 多了什么?security_risk 干嘛?
  • action_id 怎么把观察和动作配对?前端如何用它?
  • StreamingDelta 为什么不持久化?历史压缩解决什么?

✋ 动手:读事件类型

# 1. 所有事件的联合类型
sed -n '25,47p' frontend/src/types/v1/core/openhands-event.ts

# 2. BaseEvent + source
sed -n '10,25p' frontend/src/types/v1/core/base/event.ts

# 3. ActionEvent / ObservationEvent 包装
sed -n '11,72p' frontend/src/types/v1/core/events/action-event.ts
sed -n '6,49p' frontend/src/types/v1/core/events/observation-event.ts
明天预告 · Day 04:概念够了,该跑起来了!运行一个 OpenHands——Docker/CLI 三种启动方式、config.template.toml 的关键配置(runtime/agent 工具开关/sandbox 镜像/安全),以及配置如何决定 Agent 的能力。
← Day 02 Action Day 04 · 运行一个 OpenHands →