Day 02 / 共 20 天 · 第 1 周 核心概念
Action 与 Observation
昨天画了"能行动+能观察"的四层大地图。今天钻进那条链里最基础的两块砖——Agent 想做的每件事都是一个 Action(动作),执行后的每个结果都是一个 Observation(观察)。认清它俩,明天讲"事件流"才不会懵。今天对着前端真实的 TypeScript 类型定义,看它们长什么样。
📍 第 1 周 核心概念 · 你在这里
Day1 全景→
Day2 Action/Obs→
Day3 事件流→
Day4 运行→
Day5 完整旅程
L01
一切都是"动作 + 观察"
🤔 痛点:Agent 在你看不见的地方"偷偷"操作,你放心吗?
如果 Agent 直接在后台调函数跑命令、改文件,你既看不到它每一步在干嘛,出了问题也没法回放追责,更没法在危险动作前喊停。黑箱操作一台能改文件的机器,太吓人了。
💡 本质:把"每一次交互"都变成可存、可看、可拦的数据
解法就是不让 Agent 直接动手,而是先产出一个描述"我想做什么"的数据对象(Action),执行后再把结果包成数据对象(Observation)。行为一旦变成数据,就能存档、回放、在 UI 实时看、执行前拦下来确认。这就像生活中的快递系统:Action 是一张"填好的快递单"(写清要寄什么、寄到哪),Observation 是"签收回执"(告诉你送没送到、谁签的收)——今天全篇都用这套"快递单/回执"类比。这和 Day 01 的
ExecuteBash / Observation 例子是同一件事,今天看它的类型定义。OpenHands 把 Agent 的一举一动都显式建模成数据结构。整个 Agent 循环就一句话:
Action 动作
Agent 想做什么
"运行这条命令""改这个文件" → 执行 → Observation 观察
执行后得到什么
"命令输出是…""改成功了"
Agent 想做什么
"运行这条命令""改这个文件" → 执行 → Observation 观察
执行后得到什么
"命令输出是…""改成功了"
为什么要把"动作"做成数据,而不是直接调函数?
你可能想:"Agent 要跑命令,直接调一个
run(cmd) 函数不就行了?"OpenHands 偏要先造一个 ExecuteBashAction 数据对象。好处有三:① 整条轨迹能存下来、能回放、能审计(每个动作都是一条记录);② 前端只要渲染这串数据就能"实时观看" Agent 干活;③ 动作在真正执行前能被拦截、要求人工确认(安全)。"把行为变成数据"是所有严肃 Agent 框架的共同选择。我们今天读的类型定义在前端
frontend/src/types/v1/core/base/。为什么读前端?因为 Agent 核心已拆到外部包(Day 01),而前端为了渲染,把这套事件类型完整地镜像了一份 TypeScript 定义——这是本仓库里最完整、最好读的"概念字典"。L02
Action 的判别式设计
所有动作都继承一个基类,靠一个 kind 字段区分是哪种动作(base/base.ts:40):
export interface ActionBase<T extends ActionEventType = ActionEventType> {
kind: T; // 判别式:告诉你这是哪种动作
}
读法:
kind 是"判别式字段"(discriminator)——像快递单上的"物品类型"栏。程序拿到一个动作,先看 kind 是 "ExecuteBashAction" 还是 "FileEditorAction",就知道该怎么处理、该读哪些字段。TypeScript 的联合类型 + kind 判别,是类型安全地处理"多种动作"的经典写法。判别式是什么?打个比方
想象一个"表单信封",信封外面写着类型(kind),里面装着对应的内容。看到 kind="跑命令",就知道里面装的是命令字符串;看到 kind="改文件",就知道里面装的是文件路径和新内容。程序按 kind 拆信,绝不会拿错字段。
L03动作全家福(
动作全家福(base/action.ts)
OpenHands 的 Agent 能发出这些动作(节选自 frontend/src/types/v1/core/base/action.ts):
| 动作 kind | 干什么 | 关键字段 |
|---|---|---|
ExecuteBashAction | 执行 bash 命令 | command, is_input, timeout |
FileEditorAction | 看/建/改文件 | command, path, old_str, new_str |
ThinkAction | 记录一段思考(不改环境) | thought |
FinishAction | 任务结束、回复用户 | message |
TaskTrackerAction | 维护 todo 任务清单 | command, task_list |
BrowserNavigate…(10个) | 浏览器操作 | url 等 |
GlobAction/GrepAction | 按文件名/内容搜索 | pattern, path |
TaskAction | 委派给子 Agent | prompt, subagent_type |
MCPToolAction | 调用 MCP 外部工具 | data |
⚠️ 常见误解:小白常误以为 Agent "想干什么就能干什么"。其实它的能力清单是固定的——只能从这张表里选动作(就像快递单上的品类只能勾选列出的选项),而且哪些可用还受配置开关控制。这正是 Agent 可控的关键。
这张表就是 Agent 的"能力清单"——它能做的一切都在这。注意分成几类:操作环境(bash/文件/浏览器/搜索)、思考与收尾(think/finish)、任务管理(tracker/委派子 agent)、扩展(MCP 工具)。哪些动作可用,由配置里的开关决定(
config.template.toml 的 [agent] 段:enable_browsing/enable_editor/enable_cmd… Day 04 讲)。L04
精读 ExecuteBashAction
简化版 → 真实版对照:如果让你自己设计"跑命令"动作,多半是这样——
// 你的朴素版:一个字段就够了吧?
interface RunCommand { command: string; }
能用,但真实终端会遇到三种麻烦:命令进入交互(问你 y/n)怎么办?命令跑 1 小时不结束怎么办?终端彻底卡死怎么办?看真实版怎么各补一个字段(action.ts:25-42):
export interface ExecuteBashAction extends ActionBase<"ExecuteBashAction"> {
command: string; // 要执行的 bash 命令(Ctrl-C 可中断)
is_input: boolean; // true=向"正在运行的进程"输入;false=执行一条新命令
timeout: number | null; // 超时秒数
reset: boolean; // true=重建终端会话(终端卡死时用)
}
📝 举个例子:Agent 想跑 pytest,产出的 Action 长这样
{ kind: "ExecuteBashAction",
command: "python -m pytest", // 要跑的命令
is_input: false, // 这是一条新命令(不是喂给交互进程)
timeout: 120, reset: false }
如果 pytest 卡在一个 [y/n] 提问上,Agent 会再发一个 is_input:true, command:"y" 把 "y" 输进那个正在运行的进程——而不是另开一条命令。读法:大部分时候
command="要跑的命令"、其余是 false/null。但 is_input 这个设计很妙——有些命令会进入交互(比如问你 y/n,或 python 交互解释器),这时 Agent 需要"向正在运行的进程输入内容",而不是"开一条新命令"。is_input=true 就表达了这个区别。reset 则是"终端卡死了,给我换个干净的"的逃生阀。看细节体会设计者的用心
真实世界的终端不是"一句命令一个结果"那么简单——有交互式程序、有长时间运行、有卡死。
is_input/timeout/reset 这几个字段就是为了让 Agent 能应付这些真实情况。好的抽象,是把真实世界的复杂性妥帖地装进字段里。L05
精读文件编辑动作(str_replace 模式)
改文件用一套"子命令"设计(action.ts:63-123),这是业界(Anthropic str_replace_editor)的经典模式:
export interface FileEditorAction extends ActionBase<"FileEditorAction"> {
command: "view" | "create" | "str_replace" | "insert" | "undo_edit";
path: string; // 文件路径
file_text?: string; // create 时的完整内容
old_str?: string; // str_replace:要被替换的原文
new_str?: string; // str_replace:替换成的新文
insert_line?: number; // insert:插在第几行
}
读法:一个动作类型,靠
command 子命令表达 5 种文件操作。重点是 str_replace——它不是"重写整个文件",而是"把文件里的 old_str 这段替换成 new_str"。为什么改文件要用"查找替换"而不是"重写全文"?
想象一个 2000 行的文件,Agent 只想改第 500 行的一个函数。如果每次都让大模型输出整个 2000 行,① 极其浪费 token(贵、慢);② 大模型很容易在重抄时改错别的地方。str_replace 只让它给出"要改的那一小段的前后对比"——精准、省钱、不误伤。
view 先看、str_replace 精改、undo_edit 可撤销,这套组合让 AI 改代码既安全又高效。这是 OpenHands(和 Claude)能可靠改大文件的关键技巧。L06
Observation 与 Action 一一对称
🤔 对话体 Q&A:为什么"结果"也要专门定义类型?
👶 小白:命令执行完,把输出文本直接丢回给 Agent 不就行了,为什么还要一个
👨🏫 老师:光有文本,Agent 分不清"这是成功的输出还是报错"。回执得结构化:
👶 小白:所以就像快递不能只回你一句"送了",得给带单号、签收人、时间的正式回执?
👨🏫 老师:对。而且每种"快递单"(Action)都有对应格式的"回执"(Observation),一一配对——这就是下面的孪生对称。
ExecuteBashObservation 类型?👨🏫 老师:光有文本,Agent 分不清"这是成功的输出还是报错"。回执得结构化:
exit_code 明确告诉它成败、timeout 告诉它是不是超时、metadata 告诉它当前在哪个目录。👶 小白:所以就像快递不能只回你一句"送了",得给带单号、签收人、时间的正式回执?
👨🏫 老师:对。而且每种"快递单"(Action)都有对应格式的"回执"(Observation),一一配对——这就是下面的孪生对称。
每种动作,基本都有一个对应的观察结果(base/observation.ts)。比如跑命令的结果(observation.ts:57-82):
Action ↔ Observation 孪生对称:左边"想做什么",右边"做完得到什么",一一配对。
export interface ExecuteBashObservation extends ObservationBase<"ExecuteBashObservation"> {
content: Array<TextContent | ImageContent>; // 命令的输出
command: string | null;
exit_code: number | null; // 退出码;-1 表示软超时、进程还没结束
error: boolean;
timeout: boolean;
metadata: CmdOutputMetadata; // 工作目录、用户名等(从终端提示符 PS1 抓的)
}
读法:动作
ExecuteBashAction ←→ 观察 ExecuteBashObservation,孪生成对。观察里带 exit_code(命令成功还是失败)、content(输出内容)——Agent 正是靠读这些判断"我上一步干得对不对、下一步该怎么办"。| 动作 | → 对应观察 | 观察里的关键信息 |
|---|---|---|
| ExecuteBashAction | ExecuteBashObservation | exit_code, content(输出) |
| FileEditorAction | FileEditorObservation | old_content, new_content(改前改后) |
| Browser*Action | BrowserObservation | output, screenshot_data(网页截图) |
| Glob/GrepAction | Glob/GrepObservation | files/matches(命中列表) |
浏览器观察带截图(
screenshot_data,base64)——所以前端能把 Agent"看到的网页"直接展示给你。这种"孪生对称"设计让运行时能用统一的方式处理"执行动作→产生观察",也让前端渲染有章可循(Day 03/16)。L07
为什么这样设计(回味)
- 可观测:一切行为都是数据 → 能存、能回放、能审计、能在 UI 实时看。
- 可安全管控:动作执行前可拦截、可要求人工确认(每个 ActionEvent 还带
security_risk风险评估,Day 03)。 - 可对称处理:Action/Observation 孪生 → 运行时逻辑统一、前端渲染统一。
- 省钱高效:像 str_replace 这种细粒度动作,避免大模型重复输出大段内容。
一句话复述今天所学
Agent 干活全靠"寄快递":每个想法先填成一张规范的快递单(Action,kind 是品类栏),沙箱执行后必回一张结构化回执(Observation,带 exit_code 等)——单据齐全,所以整个过程能看、能存、能拦。
一句话:OpenHands 把"Agent 与环境的每一次交互"都拆成一对结构化的 Action/Observation。这个看似简单的决定,撑起了整个系统的可观测性、安全性和工程可维护性。明天(Day 03)我们看这些 Action/Observation 怎么被"包装成事件"、串成一条"事件流",以及动作和观察靠什么配对。
L08
今日小结 + 动手
🧠 今天你应该能回答
- Action 和 Observation 分别是什么?循环怎么转?
- 为什么把"行为"做成数据结构而不是直接调函数?(三大好处)
- kind 判别式是干嘛的?
- 文件编辑为什么用 str_replace 而不是重写全文?
- Action 和 Observation 的"孪生对称"体现在哪?
✋ 动手:读真实类型定义
# 1. 动作全家福
sed -n '1,60p' frontend/src/types/v1/core/base/action.ts
# 2. 精读 bash 动作 + 文件编辑动作
sed -n '25,123p' frontend/src/types/v1/core/base/action.ts
# 3. 对称的观察
sed -n '57,140p' frontend/src/types/v1/core/base/observation.ts
明天预告 · Day 03:事件流 Event Stream——裸的 Action/Observation 还要被包成带元数据的"事件"(ActionEvent/ObservationEvent),靠
action_id 配对,按时间排成一条流。一次会话 = 一条事件流。我们还会看流式 token、历史压缩等辅助事件。