前三章我们给了 Agent 一颗能思考的脑子,但一直没有给它一副能干活的手脚。这一章,我们把 Agent 放进一个「装配防护套件」里,让它真的去改文件、跑命令、看输出------而且跑不坏这台机器。
Swarm 给了循环,LangGraph 给了确定性,Letta 给了记忆,而 OpenHands 给的,是让 Agent 第一次拥有"物理世界入口"的 Harness。
1. 前言:大脑有了,手脚呢?
先合上书,看看我们造到哪一步了:
- 第 01 篇(Swarm) :417 行代码里的那个
while循环,证明了 Agent 的骨架就是"思考 → 行动 → 观测 → 再思考"。但它只会在对话里转圈,伸不出手。 - 第 02 篇(LangGraph) :150 行代码把控制流变成显式图,流程不再乱跑。但它画的是逻辑图 ,不是物理世界。
- 第 03 篇(MemGPT/Letta) :给 Agent 装了一块会自我演化的内存条。但记忆再完美,改不了一个真实文件,一切归零。
三章合起来,恰好把整个行业最扎眼的一个问题逼到台前:
Agent 不会干物理世界的活。
不是"不想干",是物理上没法安全地干 。你让它去改真实代码,它跑一句 rm -rf /,系统就毁了;你让它 cd src,下一句它又退回根目录------因为它的每条命令都开了一个互不相识的新进程。这两件事,是决定一个"演示级 Agent"和一个"能上生产的 Agent"之间那道鸿沟的全部。
那么问题来了:Agent 要伸进真实世界,需要什么?
答案是一个词:Harness。
不是一行代码,是一整套"装配防护套件"------隔离、会话、剪裁、容错,四件套把"让 Agent 干活"从惊险杂技变成例行公事。
本篇的主角是 OpenHands(前身 OpenDevin) ------一个真正把 Agent 塞进 Docker 容器、让它长年累月改真实仓库的工业级系统。我们把它最本质的 Harness 原语剥出来,用 JDK 25 零依赖 复刻成约 500 行强类型代码,然后亲眼看着 Agent 在一个长连接的 bash 会话里继承状态、剪裁日志、自我纠错。
2. 概念蒸馏:什么是 Harness?它不是某行代码,是架构套件
很多教程讲"给 Agent 装工具",讲的是注册函数 ------def ls(path) 然后塞进 tools 列表。但 OpenHands 教的不是这个。它讲的是物理支柱:Agent 的行动力,立在这四根柱子上。
2.1 第一支柱:物理沙盒隔离(Sandbox Isolation)
rm -rf / 之所以恐怖,是因为它真的会删。工业级 Agent 的第一反应不是"教育模型别这么干",而是物理上让它干不成------把整个执行环境装进一个容器,容器挂了你换一个新的,宿主机毫发无损。
OpenHands 的默认做法:每个会话一个 Docker 容器 (openhands-runtime-{sid}),模型的所有命令都在容器里跑,容器的文件系统、网络、权限都和你隔离。这就是"跑坏世界"这件事被物理地取消掉了。
隔离不是安全补丁,是架构前提。 有了它,模型才有资格"放手去干"。
2.2 第二支柱:PTY 会话状态继承(Session Persistence)
这一条,很多人第一次听说时会愣住:cd src 之后,下一句 pwd 必须还在 src 里。
听起来理所当然?可惜如果每条命令都 bash -c "cmd" 新起一个进程,这就做不到 ------两个进程互相不认识,cd 是上一个进程的临终遗言。OpenHands 的解法是一个字:长连接 。容器里始终躺着一个常驻 bash,所有命令都喂给这同一个进程,于是工作目录、环境变量、shell 函数在多次执行之间物理继承。
OpenHands 官方文档的原话(DeepWiki 收录),代码落点就是第 3 节那个常驻 tmux 窗格:
"Runtimes maintain shell state across multiple actions, allowing sequential command execution... variables or directory changes persist." ------ 运行时在多次动作之间维持 shell 状态,变量和目录切换都会延续。
一个会话 = 一个活着的 bash。 这就是"PTY 交互"的本质:不是伪装终端,而是让 Agent 拥有一个记得上一次对话的 shell。
2.3 第三支柱:日志防暴涨剪裁(Log Trimming)
真实命令的输出是恐怖片:tail -f 一次可以刷出几十万行,一轮 CI 日志动辄上百 MB。如果照单全收塞进 Context,token 窗口秒爆,Agent 当场失忆------比不执行还糟。
harness 的策略:保留头 N 行(意图与开始)+ 尾 N 行(结果与状态)+ 命中关键词的行(Traceback / Error / Exception 关键栈帧一帧不丢),中间裁剪,并留一条显式占位说明省了多少行。
裁剪不是丢信息,是换一种更省内存的方式保住信息。
2.4 第四支柱:容错护栏(Defensive Guardrails)
模型不是编译器,它的输出随时会烂:围栏只开不关、bash```` 拼成 she bash````、命令永不结束。真正的 harness 对这一切的态度是:
- 解析永不崩溃,只降级 ------格式烂了,就产出
InvalidFormatObservation扔回事件流,让模型自己看到错误、自己纠错; - 执行永不永久挂死------超时就杀进程、重建沙盒,留下一条"超时"观测;
- 退出码永不缺席 ------用哨兵协议精确拿到
$?,而不是靠猜。
四根柱子,一句话总结:
隔离让 Agent 敢干,会话让 Agent 记得住,剪裁让 Agent 看得完,护栏让 Agent 坏不了。
3. 剥离杂音:OpenHands 源码里真正重要的 5%
先交代本文读的是哪一份源码:OpenHands 在 2026 年拆分了仓库,经典时代的 openhands/runtime/ 已经不在当前分支。我们分析的是新一代 OpenHands/software-agent-sdk 主仓库(commit 88afa9af,2026-08),本地路径 harness/openhands/software-agent-sdk/,想对照原文直接打开即可。
现在的 OpenHands 是一个巨无霸:REST API、Web 前端、Playwright、helm chart、几十种 runtime 实现、海量的遥测埋点。如果 clone 下来逐行读,大约两周后你会阵亡在第 2000 行某个 Docker SDK 的参数拼接里。
大框架 90% 的代码是工程设施,真正决定它"长什么样"的,只有一小撮设计思想。
对 OpenHands 来说,这一小撮只落在三个地方:
bash
software-agent-sdk/
├── openhands-sdk/openhands/sdk/event/base.py # ★ Event 基类(判别联合):Agent 的事件中枢
├── openhands-sdk/openhands/sdk/event/llm_convertible/action.py # ★ Action:命令执行意图
├── openhands-sdk/openhands/sdk/event/llm_convertible/observation.py # ★ Observation:执行观测
├── openhands-tools/openhands/tools/terminal/constants.py # ★ 哨兵标记 ###PS1JSON###/###PS1END### + 输出上限
├── openhands-tools/openhands/tools/terminal/metadata.py # ★ PS1 里嵌 JSON 元数据(exit_code=$? / pid / pwd)
├── openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py # ★ 真实 PTY = tmux 窗格,PS1 注入
├── openhands-tools/openhands/tools/terminal/terminal/terminal_session.py # ★ 持久会话 execute():读到 PS1END 才算命令结束
├── openhands-sdk/openhands/sdk/utils/truncate.py # ★ 输出防暴涨:裁中保头尾 + 落盘续读
└── openhands-workspace/openhands/workspace/docker/workspace.py # DockerWorkspace:容器沙盒
3.1 第一件:事件体系------Agent 的中枢神经系统
OpenHands 的一切都不是"直接调用",而是事件。模型吐一个 Action(想跑命令)→ 写进事件流 → runtime 消费它 → 产出 Observation(看到了什么)→ 模型读到。Action 与 Observation 像呼吸一样在事件流里交替。
在现行 SDK 里,事件基类是判别联合 (openhands-sdk/openhands/sdk/event/base.py):
python
class Event(DiscriminatedUnionMixin, ABC):
"""事件基类;判别字段 = 具体类名。"""
命令 Action 现在叫 TerminalAction(openhands-tools/openhands/tools/terminal/definition.py),核心字段就一个 command: str------它的前身是经典时代的 CmdRunAction。模型对终端说话的唯一通道,就是这个结构化的 Action 对象。
这个抽象的价值:模型的"想法"和环境的"反馈"被解耦成两种事件,谁都能订阅、谁都能重放、谁都能审计。我们复刻时只留它最瘦的形态------一个线程安全的双端队列 + 一组订阅者。
3.2 第二件:持久 bash 会话 + 哨兵协议(PS1 注入 JSON 元数据)
这是 OpenHands 执行层的灵魂,也是本篇标题里"物理手脚"的直接来源。两个硬事实:
事实一:会话是长连接,状态物理继承。 命令不是每次新起进程,而是在一个常驻的 tmux 窗格 里执行(terminal/tmux_terminal.py,TMUX_SOCKET_NAME = "openhands")。cd / export 在多次 execute 之间自然延续------这就是"会话持久化"。
事实二:哨兵不是 echo,是改 PS1 提示符。 OpenHands 把 bash 的 PS1 整个换成一段 JSON 元数据块(metadata.py),标记就是这两个常量(constants.py):
python
CMD_OUTPUT_PS1_BEGIN: Final[str] = "\n###PS1JSON###\n"
CMD_OUTPUT_PS1_END: Final[str] = "\n###PS1END###"
而那段 JSON 里,退出码和目录都是 bash 自己求值的:
python
json_str = json.dumps({
"pid": "$!",
"exit_code": "$?", # bash 渲染 PS1 时把 $? 求值成真实退出码
"working_dir": r"$(pwd)", # pwd 同理
# ...
})
bash 每执行完一条命令就会渲染一次 PS1------于是 ###PS1JSON### 包着的 JSON 带着真实的退出码 出现在屏幕末尾。会话层在 while True 里轮询屏幕,只要看到新的 ###PS1END### 出现,就知道这条命令结束了 (terminal_session.py):
python
if (not sent_command or output_changed_since_command) and (
current_ps1_count > initial_ps1_count
or cur_terminal_output.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip())
):
return self._handle_completed_command(...)
随后 metadata.py 用正则把 JSON 抠出来。exit_code 的默认值是 -1 ,解析失败也置 -1------这正是那句约定 "If a bash command returns exit code -1, this means the process is not yet finished" (OpenHands PR #3653)的代码来源:前一条命令还在跑时,新命令会被拒绝并返回带 -1 的观测,模型只能发空命令 捞后续日志、发 is_input=true 喂 STDIN、或发 C-c 打断(TIMEOUT_MESSAGE_TEMPLATE)。
3.3 第三件:输出防暴涨 + 超时护栏
命令输出不是照单全收。真实代码里两层保护:
- 裁剪(
utils/truncate.py的maybe_truncate) :超过MAX_CMD_OUTPUT_SIZE = 30000字符就裁中间、保头尾 ,并在切口插一句<response clipped><NOTE>...only part of the full response has been shown to you.</NOTE>;还可以把完整输出落盘 (save_dir),模型需要时再用别的工具去读完整文件。这和我们的 ObservationTrimmer 是同一个思路。 - 超时(
constants.py+terminal_session.py) :NO_CHANGE_TIMEOUT_SECONDS = 30(输出 30 秒没变化判超时)、POLL_INTERVAL = 0.5(每 0.5 秒轮询一次)、HARD_TIMEOUT(硬超时)。超时不会被伪装成成功------模型会收到一条明确说明的观测,并被告知下一步选项。
另外,模型的输出在现行版本里已经不再靠解析 ```````bash```` 代码块了------它是强类型工具调用 ,TerminalAction.command 的 schema 就是协议。防御式解析的残影还在 definition.py 的 looks_like_python_literal_argument():检测模型把 Python/JSON 字面量塞进 command 字段,一眼识破、直接拒绝。代码块解析是我们复刻的 CodeAct 时代形态,这里保留它,是为了讲清"格式即协议"的演化起点。
这三件加起来,才是那"5%"。剩下的一切------Docker SDK 参数、k8s 编排、前端 UI、遥测打点------都是围绕这三件核心思想的工程脚手架。
4. 最小复刻:基于 JDK 25 手写 OpenHands 核心 Harness
概念清楚了,动手。全套复刻在 harness/openhands/java_openhands_harness/,零依赖 ,约 600 行核心代码。没有一行"AI 魔法"------全是进程、管道、队列、正则。四个组件,正好对应四根柱子。另外我还写了一份 Groovy 版(groovy_openhands_harness/),同一套原语、一个文件 333 行,见 4.6。
对标的就是第 3 节那套现行 SDK,但做了两处教学简化:把 PS1 注入 + JSON 元数据 的哨兵简化成一行 echo ___HARNESS_END___ $?;把强类型 TerminalAction 简化成 ```bash 代码块解析(CodeAct 时代形态)。
bash
java_openhands_harness/src/harness/
├── HarnessEvent.java # 事件类型:sealed 接口 + 4 个 record(Action / Observation)
├── EventStream.java # 事件总线:线程安全双端队列 + 订阅者
├── PersistentBashSandbox.java # ★ 长连接 bash 沙盒 + 哨兵协议(第一、二支柱)
├── ObservationTrimmer.java # ★ 日志防暴涨剪裁器(第三支柱)
├── DefensiveParser.java # ★ 防崩溃降级解析器(第四支柱)
├── MockLlm.java # 假 LLM:真的去读事件流做决策
└── HarnessAgentController.java # ★ 主控循环:虚拟线程驱动 ReAct
4.1 组件一:PersistentBashSandbox(长连接 + 哨兵协议)
先看骨架。关键在构造器:进程只 spawn 一次,用两条虚拟线程把 stdout / stderr 持续排空到同一个共享队列------这两条 drainer 是整台沙盒不死的保证:
java
private void spawn() {
ProcessBuilder pb = new ProcessBuilder("/bin/bash");
pb.redirectErrorStream(false); // 必须分开:要精确拦截 stdout / stderr
process = pb.start();
stdin = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8);
// 两条虚拟线程:一个排 stdout,一个排 stderr。它们只进队、永远不阻塞 bash。
Thread.ofVirtual().name("drain-" + sessionId + "-out").start(() -> drain(process.getInputStream(), "out"));
Thread.ofVirtual().name("drain-" + sessionId + "-err").start(() -> drain(process.getErrorStream(), "err"));
}
为什么必须开两条 drainer? 因为管道是阻塞的。如果 bash 往 stderr 写了一堆、而你只读 stdout,stderr 缓冲区一满,bash 就被钉死,你读 stdout 也永远等不到下一条------死锁。两条 drainer 各自把流逐行搬进内存队列,bash 永远不会因"没人读我"而停下。这是任何长连接进程的必修课。
然后是哨兵协议(Sentinel Protocol)------本篇的绝对主角。执行一条命令前,先把命令改写成"命令 + 哨兵":
java
/** 把命令改写成「命令本体 + 哨兵」一行,哨兵捕获真实退出码 $?。 */
private String buildScript(String command) {
String trimmed = command == null ? "" : command.stripTrailing();
if (trimmed.endsWith(";")) {
trimmed = trimmed.substring(0, trimmed.length() - 1).stripTrailing();
}
if (trimmed.isBlank()) {
return "echo " + sentinel + " 0";
}
return trimmed + "; echo " + sentinel + " $?"; // 哨兵行 = 结束标记 + 退出码
}
于是 ls /nope 在 bash 里实际执行的是 ls /nope; echo ___HARNESS_END___s0 $?,stdout 的最后一行必然是 ___HARNESS_END___s0 2。读端读到这行,就精确地 知道了两件事:到此为止是这条命令的输出,2 就是退出码。执行循环:
java
if ("out".equals(tagged.stream())) {
// 先判哨兵再入列:哨兵行是"结束标记",绝不能漏进业务输出
if (isSentinel(tagged.line())) {
sentinelLine = tagged.line();
break;
}
stdout.add(tagged.line());
} else {
stderr.add(tagged.line());
}
注意那个先判哨兵、再入 stdout 的顺序------这是我在跑测试时被真实 bug 教育过的:最初我把哨兵行先 add 进 stdout 再判断,结果哨兵行漏进了业务输出,___HARNESS_END___s0 混进日志里,还有一次因为它恰好含子串 err 污染了 stderr 的断言。协议的正确做法,是让标记与数据彻底分帧。
超时也是硬护栏:sleep 5 配 300ms 超时,读端等不到哨兵,就返回一条 timedOut=true 的观测,并销毁整个进程、重建沙盒------绝不把一条卡死的命令留在线程里。
4.2 组件二:EventStream + 事件类型(sealed + record 模式匹配)
事件是 Agent 的"呼吸"。我们先用 sealed 接口把事件类型锁死成四种:
java
public sealed interface HarnessEvent permits HarnessEvent.CmdRunAction,
HarnessEvent.CmdOutputObservation,
HarnessEvent.InvalidFormatObservation,
HarnessEvent.AgentTextMessage {
record CmdRunAction(String id, String command) implements HarnessEvent {}
record CmdOutputObservation(String actionId, String output, String stdout, String stderr,
int exitCode, long tookMillis, boolean timedOut,
boolean trimmed, int rawLineCount) implements HarnessEvent {}
record InvalidFormatObservation(String actionId, String rawOutput, String reason,
String guidance) implements HarnessEvent {}
record AgentTextMessage(String role, String content) implements HarnessEvent {}
}
▍Highlight:sealed + record,把"穷尽匹配"变成编译期保证。 因为事件类型被 sealed 锁死,控制器里对事件的
switch可以不需要 default ------四种事件全列完,编译器知道没有遗漏;将来新增一种事件,编译器会在所有switch处报错逼你补全。错误在编译期现形,而不是在半夜的生产环境里。 这就是强类型的意义:不是多打几个字,是把一类 bug 从运行时抹掉。
EventStream 瘦到只有两个成员:一个 ConcurrentLinkedDeque 装事件,一个 CopyOnWriteArrayList 装订阅者。publish() 进队 + 通知,history() 返回不可变快照供模型读取。
4.3 组件三:ObservationTrimmer(防暴涨剪裁)
十万行日志进 Context 之前,先过剪裁器。策略与第二支柱完全一致------头 N 行 + 尾 N 行 + 关键行,中间显式占位:
java
// 头(命令的开始,往往是意图)
// 中间:命中关键词的行(Traceback / Error / Exception...一帧不丢)
sb.append("... [ObservationTrimmer] 已裁剪 ")
.append(removed).append(" 行(命中关键行已保留在下方)...\n");
// 尾(命令的结尾,往往是结果与状态)
结果用一条 record 回传------留了什么、裁了什么,一目了然:
java
public record TrimmedResult(String text, int keptHead, int keptTail, int keptImportant,
int removedLines, int totalLines, boolean truncated) { ... }
控制器把剪裁后的观测写回事件流时,还会带上 [ObservationTrimmer] 保留 头40 + 尾40 + 关键1 行,共裁剪 99920/100001 行 的摘要------让模型知道自己看到的是被裁剪过的世界,这是"剪裁"与"丢数据"的本质区别。
4.4 组件四:DefensiveParser(防崩溃降级解析器)
模型输出 ```````bash```` 代码块,解析器负责剥命令。铁律:解析永不抛异常,只降级,用 sealed 接口把解析结果锁定为三种类型:
java
public sealed interface ParsedAction permits ParsedAction.RunBash,
ParsedAction.Malformed,
ParsedAction.PlainText {
record RunBash(String codeBlock) implements ParsedAction {}
record Malformed(String rawOutput, String reason, String guidance) implements ParsedAction {}
record PlainText(String text) implements ParsedAction {}
}
判定顺序:有合法闭合的 bash```` 块 → `RunBash`;**出现过围栏但解不出合法块** (没闭合 / 标签不是 bash 或 sh / 内容为空)→ `Malformed`,带原因 + 纠错指引;完全没有围栏 → `PlainText`(模型就是在说话)。正则只认 `bash` / `sh` / 空标签,python```` 一律不当命令。
而那个 Malformed 会被控制器改写成 InvalidFormatObservation 扔回事件流------坏输入没有杀死 Agent,反而变成一条驱动自我纠错的信号。这是第四支柱的全部奥义。
4.5 主控循环:HarnessAgentController(虚拟线程驱动 ReAct)
最后把四根柱子焊起来。一轮 run() 就是一个标准的 ReAct 循环,而每一步命令执行都跑在虚拟线程上:
java
/** 虚拟线程池:每条任务一个新虚拟线程,阻塞式 I/O 不再占住系统线程。 */
private static final ExecutorService VIRTUAL =
Executors.newVirtualThreadPerTaskExecutor();
事件流怎么被消费?用 sealed 接口 + record 模式匹配的 switch------这是 JDK 21+ 最直白的写法,穷尽、无 default、模式即解构:
java
switch (parsed) {
// 合法命令:先把 Action 写进事件流 → 虚拟线程执行 → 剪裁防暴涨 → 观测写回事件流
case ParsedAction.RunBash run -> {
var action = new HarnessEvent.CmdRunAction("run-" + stepCount, run.codeBlock());
stream.publish(action);
HarnessEvent.CmdOutputObservation raw = executeOnVirtualThread(run.codeBlock());
HarnessEvent.CmdOutputObservation obs = trimmer.maybeTrim(raw);
stream.publish(obs);
}
// 格式损坏:降级成 InvalidFormatObservation,让模型自纠
case ParsedAction.Malformed malformed -> {
stream.publish(new HarnessEvent.InvalidFormatObservation(
"parse-" + (++parseSeq), malformed.rawOutput(),
malformed.reason(), malformed.guidance()));
}
// 纯文本:最终答复,本轮结束
case ParsedAction.PlainText plain -> {
stream.publish(new HarnessEvent.AgentTextMessage("assistant", plain.text()));
return plain.text();
}
}
▍Highlight:虚拟线程是给 I/O 密集型 Agent 准备的解药。 命令执行是纯阻塞 I/O------等 bash、等管道、等超时。在传统线程模型里,十个并发 Agent 就是十根被钉死的 OS 线程;虚拟线程把"等待"折叠到极小的载体上,几千条阻塞任务可以在几十个平台线程上排队。JDK 25 下这句
newVirtualThreadPerTaskExecutor()就把并发底座搭完了。
再加上一条自我纠错闭环:模型吐坏格式 → 解析器 Malformed → 控制器发 InvalidFormatObservation → 模型读到这条观测 → 下一轮重发正确格式 → 执行成功。整个过程没有抛一个异常,Agent 自己把自己修好了。
运行清单(Java):
| 示例 | 命令 |
|---|---|
| 四幕核心演示 | ./run.sh harness.examples.HarnessDemo |
| 28 项断言测试 | ./run.sh harness.test.TestHarness |
4.6 同一个核心,Groovy 版有多短?
像前三篇一样,同一套核心我另写了一份 Groovy 版(groovy_openhands_harness/)。Java 版 8 个文件 793 行逻辑,Groovy 版一个文件 333 行,短了近六成。 差距不在"功能",在"仪式感"。
sealed 接口 + record → 一个 HarnessEvent(type + data map):
groovy
class HarnessEvent {
String type // 'cmd_run' / 'cmd_output' / 'invalid_format' / 'text'
Map data
static HarnessEvent action(String id, String command) { new HarnessEvent(type: 'cmd_run', data: [id: id, command: command]) }
static HarnessEvent observation(CmdOutputObservation o) { new HarnessEvent(type: 'cmd_output', data: [observation: o]) }
static HarnessEvent text(String role, String content) { new HarnessEvent(type: 'text', data: [role: role, content: content]) }
// ...
}
沙盒拉起两条 drainer,Java 要写整个 spawn(),Groovy 两行:
groovy
Thread.ofVirtual().name("drain-${sessionId}-out").start { drain(process.getInputStream(), 'out') }
Thread.ofVirtual().name("drain-${sessionId}-err").start { drain(process.getErrorStream(), 'err') }
控制器依赖注入,Java 写满构造器,Groovy 一行 map:
groovy
new HarnessAgentController(stream: stream, sandbox: sandbox, llm: new MockLlm(),
trimmer: new ObservationTrimmer(), parser: new DefensiveParser())
同一个哨兵协议,Groovy 版的核心四行:
groovy
if (tagged.stream == 'out') {
if (tagged.line.startsWith(sentinel + ' ')) { sentinelLine = tagged.line; break }
stdout << tagged.line
} else {
stderr << tagged.line
}
跑的是同一套四幕剧情,输出与 Java 版逐字一致(见下节)。运行清单(Groovy):
| 示例 | 命令 |
|---|---|
| 四幕核心演示 | ./run.sh examples/01_harness_demo.groovy |
| 25 项断言测试 | ./run.sh test/TestHarness.groovy |
为什么工业上爱用动态语言搭原型、生产底座却回归强类型? Groovy 的 map 构造和闭包把"配置"与"回调"的样板抹掉,验证思想最快;但 sealed + record 的穷尽匹配把"漏掉一种事件"从运行时错误变成编译期错误------当你的 harness 要支撑几十种 Action 时,这笔账是算得过来的。
5. 运行验证:看见 Agent 在真实 PTY 里继承状态与自我纠错
跑 ./run.sh harness.examples.HarnessDemo。下面这段是真实控制台输出(pid 随运行环境变化)。
开场:一个常驻 bash 被拉起,哨兵协议就位。
ini
== HarnessDemo:给 Agent 装上物理手脚 ==
[harness] 常驻 bash 已拉起:pid=9906,哨兵协议 = echo ___HARNESS_END___s0 $?
[harness] 后续所有命令都喂给这同一个进程,不另起子进程。
Step 1:建目录 + 导出环境变量
ini
──────── 幕1 · 建目录 + 导出 ENV_KEY=JDK25 ────────
[user] 请把项目建到 /tmp/harness_workspace,并导出环境变量 ENV_KEY=JDK25
[llm] 决策 → ```bash mkdir -p /tmp/harness_workspace && cd /tmp/harness_workspace && export ENV_KEY=JDK...
[controller] 命中 bash 动作,虚拟线程提交执行...
[observation] run-1 exit=0 124ms(stdout 1 行)
| READY
注意 exit=0 不是猜的,是哨兵行 ___HARNESS_END___s0 0 里提取的。
Step 2:下一句 pwd,见证状态物理继承
bash
──────── 幕2 · 追问:目录与变量还在吗? ────────
[user] 告诉我你现在在哪个目录,ENV_KEY 是什么?
[llm] 决策 → ```bash pwd && echo "ENV_KEY=$ENV_KEY" ```
[controller] 命中 bash 动作,虚拟线程提交执行...
[observation] run-2 exit=0 120ms(stdout 2 行)
| /tmp/harness_workspace
| ENV_KEY=JDK25
[assistant] 当前目录是 /tmp/harness_workspace,ENV_KEY=JDK25(上一条命令的 export 被我记在同一个 bash 进程里了)。
✅ 状态被同一个常驻 bash 物理继承:目录与 ENV_KEY 都还在
这就是"PTY 状态继承"的全部真相。 上一条命令 cd /tmp/harness_workspace && export ENV_KEY=JDK25 没有死在那个进程里------因为压根没有"那个进程",只有一个 从开场活到现在的 bash。pwd 打出来的是它的物理工作目录,$ENV_KEY 读的是它的真实环境变量。如果你用 bash -c 每句新起进程,这一行永远打印不出 JDK25。
Step 3:十万行日志,被 ObservationTrimmer 防暴涨
bash
──────── 幕3 · 打印 100000 行日志 ────────
[user] 帮我打印 100000 行日志
[llm] 决策 → ```bash for i in $(seq 1 100000); do echo "log line $i"; if [ "$i" = "50000" ]; then echo ...
[controller] 命中 bash 动作,虚拟线程提交执行...
[observation] run-3 exit=0 920ms(stdout 100001 行,已被 ObservationTrimmer 裁剪)
| [ObservationTrimmer] 保留 头40 + 尾40 + 关键1 行,共裁剪 99920/100001 行
| log line 1
| log line 2
| ... 共 83 行,已省略 80 行
100001 行真实日志,进 Context 前被剪成 头40 + 尾40 + 关键1 ------那 1 行关键,正是埋在日志第 50000 行的模拟 Error: simulated exception。Traceback/Error 一帧不丢,中间十万行灰飞烟灭。 如果不是这个剪裁器,这 100001 行会把 Context 窗口直接冲爆。
Step 4:坏格式 → InvalidFormatObservation → 模型自我纠错
ini
──────── 幕4 · 坏格式 → InvalidFormatObservation → 自我纠错 ────────
[user] 检查一下环境是否正常
[llm] 决策 → 好的,我来检查一下环境: ```bash ls -la /tmp/harness_workspace
[controller] ⚠ InvalidFormatObservation → 代码块围栏未闭合(缺少结尾的 ```)(已写回事件流,等待模型自纠)
[llm] 决策 → 抱歉,刚才的代码块围栏没闭合。重发: ```bash ls -la /tmp/harness_workspace ```
[controller] 命中 bash 动作,虚拟线程提交执行...
[observation] run-4 exit=0 110ms(stdout 3 行)
| total 0
| drwxr-xr-x@ 2 jobslee wheel 64 Aug 28 00:30 .
| drwxrwxrwt 10 root wheel 320 Aug 28 00:31 ..
[assistant] 环境检查完成,目录结构正常: total 0 drwxr-xr-x@ 2 jobslee wheel 64 Aug 28 00:30 . drwxrwxrwt 10 ro...
✅ 模型收到 InvalidFormatObservation 后自我纠错并完成检查
看这四行,就是第四支柱的完整演出:模型第一次输出围栏未闭合的坏格式 → 解析器没有崩溃,而是降级成一条 InvalidFormatObservation(⚠ 代码块围栏未闭合)写回事件流 → 模型读到这条观测 ,道歉并重发正确格式 → 执行成功、环境检查完成。没有任何一行异常堆栈,Agent 自己把自己修好了。
收尾的事件流汇总,正好展示这台 Agent 的全部行为就是事件在流里进出:
diff
-------- 事件流汇总 --------
EventStream 共 17 条事件:4 CmdRunAction / 4 CmdOutputObservation / 1 InvalidFormatObservation / 8 文本消息
✅ HarnessDemo 全部断言通过
测试兜底:28 项断言,全绿
diff
---- TestHarness 结果:通过 28 / 28 ----
覆盖了哨兵协议退出码(false → 1)、stdout/stderr 精确分离(哨兵不泄漏)、cd/export 跨 execute 继承、十万行剪裁(Traceback 中段也不丢)、解析器三路降级(含 ```````she bash```` 这类错标签)、sleep 5 配 300ms 超时自愈、以及三次 execute 前后 pid 不变------那句"一直是同一个 bash"是有实据的。
6. 小结
6.1 四篇专栏,构建完整的 Agent 图纸
现在把四章叠在一起,一张完整的 Agent 架构全景图浮出水面------每一篇递来一把钥匙,四把钥匙合起来,才是一台"能思考、不乱跑、记得住、干得动"的 Agent:
| 维度 | 01 · Swarm(ReAct) | 02 · LangGraph(FSM) | 03 · MemGPT/Letta(OS Memory) | 04 · OpenHands(Harness) |
|---|---|---|---|---|
| 核心抽象 | 一个 while 循环 + 交接 |
一张图 + State + reducer | 内存分层 + 自我编辑工具 | 长连接沙盒 + 哨兵协议 + 事件流 |
| 记忆 | context_variables 自由字典 |
State(可落盘快照) | Core / Recall / Archival 三级 | shell 进程状态 + 剪裁后的日志 |
| 谁控制循环 | 模型(tool_calls) |
图结构 + 条件边 | 模型(heartbeat)+ 规则 | 事件流 + 控制器 |
| 上下文爆了怎么办 | 无解,硬塞 | Checkpointer / 压缩 | 换页 + SystemAlert 自我清洗 | 头尾 + 关键行剪裁 |
| 干了坏事怎么办 | 无物理后果(也干不了活) | 无 | 无 | 容器隔离 + 超时自愈 + 降级纠错 |
| 这一章递来的钥匙 | 循环 | 确定性 | 自我演化 | 物理手脚 |
一句话串起这四篇:
**Swarm 证明了 Agent 的最小骨架是"循环";
LangGraph 证明了"控制流"可以是显式的图;
MemGPT 证明了"记忆"可以像操作系统一样分层、自我演化;
而 OpenHands 证明了最后一块拼图------一个 Agent 真正的价值,不在于它想得多好,而在于它能否安全地、持续地、不把机器搞坏地,把想法变成物理世界的改变。**
一个成熟 Agent 的完整解剖,就是这四个系统装进同一副躯壳:

6.2 OpenHands 还做了什么
和前几篇一样,列一下工业级 OpenHands 有、而我们没复刻的东西------如果真要上生产,缺的正是这几块:
| 没做的设施 | 真实 OpenHands 里是什么 | 解决什么问题 |
|---|---|---|
| 真实容器隔离 | DockerWorkspace:每个会话一个跑预构建 agent-server 镜像的容器(openhands-workspace/.../docker/workspace.py) |
我们本地直跑 bash,"跑坏世界"只隔离了进程没隔离文件系统 |
| 真实 PTY 分配 | tmux 窗格 (terminal/tmux_terminal.py)------常驻 tmux 会话就是那个真 PTY,支持交互式程序 |
我们的长连接是管道模拟,vim/htop 这类要 TTY 的程序装不下 |
| PS1 JSON 元数据 | 哨兵藏在 bash 的 PS1 里,exit_code 默认/解析失败都置 -1;命令未结束时返回 -1,可发空命令捞日志、is_input=true 喂 STDIN、C-c 打断 |
我们的 echo ___HARNESS_END___ $? 只带退出码,没有 pid / 工作目录等元数据 |
| 输出裁剪 + 落盘 | maybe_truncate:裁中保头尾 + <response clipped> 提示 + 完整输出存文件可续读 |
我们只在内存里裁,裁掉的十万行真的没了 |
| 进程树管理与 SIGINT | 超时优雅降级:先 SIGINT 再 SIGKILL,保留现场 | 我们直接 destroyForcibly,暴力但干净 |
| 日志去向审计 | 事件全量落库,可回放、可审计、可 diff | 我们的历史只活在这个进程里 |
| 多沙盒并行 | 每会话独立容器,横向可扩 | 我们一个进程一张嘴 |
现在四把钥匙齐了:
- 循环(Swarm)------让它能跑;
- 确定性(LangGraph)------让它不乱跑;
- 自我演化(MemGPT)------让它越跑越懂你;
- 物理手脚(OpenHands)------让它真的能改世界,而且改不坏世界。
造 Agent 这件事,说到底不是魔法,是把"思考"与"落地"之间那条物理鸿沟,一段一段地用工程填平。