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 要跑命令,直接调一个 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委派给子 Agentprompt, 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 不就行了,为什么还要一个 ExecuteBashObservation 类型?
👨‍🏫 老师:光有文本,Agent 分不清"这是成功的输出还是报错"。回执得结构化:exit_code 明确告诉它成败、timeout 告诉它是不是超时、metadata 告诉它当前在哪个目录。
👶 小白:所以就像快递不能只回你一句"送了",得给带单号、签收人、时间的正式回执?
👨‍🏫 老师:对。而且每种"快递单"(Action)都有对应格式的"回执"(Observation),一一配对——这就是下面的孪生对称。

每种动作,基本都有一个对应的观察结果(base/observation.ts)。比如跑命令的结果(observation.ts:57-82):

Action(想做) Observation(得到) ExecuteBashAction FileEditorAction BrowserClickAction …BashObservation …EditorObservation BrowserObservation 执行→exit_code,content 执行→改前/改后内容 执行→输出+网页截图 每个动作都有一个孪生的观察,运行时/前端因此能用统一方式处理
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 正是靠读这些判断"我上一步干得对不对、下一步该怎么办"。
动作→ 对应观察观察里的关键信息
ExecuteBashActionExecuteBashObservationexit_code, content(输出)
FileEditorActionFileEditorObservationold_content, new_content(改前改后)
Browser*ActionBrowserObservationoutput, screenshot_data(网页截图)
Glob/GrepActionGlob/GrepObservationfiles/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、历史压缩等辅助事件。
← Day 01 全景 Day 03 · 事件流 →