前三章我们给了 Agent 一颗能思考的脑子,却一直没给它一副能干活的手脚。
这一章,我们把它放进一间「一次性厨房」——让它真的去切菜、开火、尝味道,而且搞不坏这家店。
一、脑子有了,手呢?
先合上书,看看我们造到哪一步了:
- 第一章(循环):
while循环 + 交接,证明了 Agent 的骨架就是「思考 → 行动 → 观察 → 再思考」。但那个循环只会在对话里转圈,伸不出手。 - 第二章(确定性图):把控制流变成了显式的图,流程不再乱跑。但它画的是逻辑图,不是物理世界。
- 第三章(记忆分层):给 Agent 装了一整套会自我演化的记忆。但便签写得再漂亮,改不了一个真实文件,一切归零。
三章合起来,恰好把整个行业最扎眼的一个问题逼到台前:
Agent 不会干物理世界的活。
不是「不想干」,是物理上没法安全地干。
用厨房的话说:
- 你让这位大厨去切菜,他一刀下去把整张料理台劈了;
- 你让他「去隔壁拿瓶酱油」,他回来的时候站在了大街上——因为他的每一条指令,都是一个互不相识的新人重新执行的。
这两件事,就是「演示级 Agent」和「能上生产的 Agent」之间那道鸿沟的全部。
那么,Agent 要伸进真实世界,需要什么?
答案是一个词:Harness(承载套件)。
它不是一行代码,是一整套「装配防护套件」——隔离、会话、剪裁、容错。四件套把「让 Agent 干活」从惊险杂技,变成例行公事。
这一章的主角是 OpenHands——一个真正把 Agent 塞进容器、让它长年累月改真实代码仓库的工业级系统。我们把它最本质的原语剥出来讲透。
二、四根柱子
很多教程讲「给 Agent 装工具」,讲的是注册函数——定义一个 读文件(path),然后塞进工具列表。
但 OpenHands 教的不是这个。它讲的是物理支柱:Agent 的行动力,立在这四根柱子上。
flowchart TB
P[Agent 的行动力]
P --> P1[1 隔离<br/>跑不坏世界]
P --> P2[2 会话<br/>记得住上下文]
P --> P3[3 剪裁<br/>看得完输出]
P --> P4[4 护栏<br/>坏了自己爬起来]
P1 --> P1a[一次性的料理台<br/>搞砸了整台换掉]
P2 --> P2a[同一口锅从头用到尾<br/>而不是每道菜换一口]
P3 --> P3a[择菜:一百斤里<br/>只留下锅的那几两]
P4 --> P4a[防烫手套 + 灭火器<br/>出错不等于停工]
style P fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A
style P1 fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
style P2 fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
style P3 fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
style P4 fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
2.1 第一根:隔离 —— 让 Agent 敢干
「一刀劈了料理台」之所以恐怖,是因为它真的会劈。
工业级 Agent 的第一反应,从来不是「教育模型别这么干」,而是——物理上让它干不成。
做法是:每一个会话,给它一间独立的厨房。 这间厨房有自己的一套刀具、自己的灶、自己的食材储备,和水电煤气全部独立。它在里面怎么折腾都行,搞坏了,整间换掉,主店毫发无损。
隔离不是安全补丁,是架构前提。 有了它,模型才有资格「放手去干」。
2.2 第二根:会话 —— 让 Agent 记得住
这一条,很多人第一次听说时会愣一下:
「去隔壁拿瓶酱油」之后,下一句「看看冰箱里还有没有鸡蛋」——他必须还在隔壁那间厨房里。
听起来理所当然?可惜如果每条指令都换一个新人来执行,这就做不到——两个人互不相识,第一个人走到哪儿了,第二个人完全不知道。
解法只有一个字:长连接。
厨房里始终站着同一个人,所有指令都交给他。于是他的位置、他手上的活、他知道的一切,在多次指令之间物理地继承下来。
一个会话 = 一个活着的、记得住上一次对话的执行者。
2.3 第三根:剪裁 —— 让 Agent 看得完
真实的输出是恐怖片。一条命令刷出几十万行日志,一轮构建动辄上百 MB。
如果照单全收塞进上下文,窗口秒爆,Agent 当场失忆——比不执行还糟。
Harness 的策略是择菜:
保留头 N 行(意图与开始)+ 尾 N 行(结果与状态)+ 命中关键词的行(报错栈帧一帧不丢),中间裁掉,并且明确留下一条占位说明:裁了多少行。
剪裁不是丢信息,是换一种更省内存的方式保住信息。
关键在于最后那半句:Agent 必须知道「我看到的是被裁剪过的世界」。 这是「剪裁」和「丢数据」的本质区别。
2.4 第四根:护栏 —— 让 Agent 坏不了
模型不是编译器,它的输出随时会烂:代码围栏只开不关、标签拼错、命令永不结束。
真正的 harness 对这一切的态度是:
- 解析永不崩溃,只降级——格式烂了,就产出一条「格式错误」的观测扔回给它,让模型自己看到错误、自己纠错;
- 执行永不永久挂死——超时就杀掉进程、重建厨房,留下一条「超时」观测;
- 退出码永不缺席——用哨兵协议精确拿到退出码,而不是靠猜。
四根柱子,一句话总结:
隔离让它敢干,会话让它记得住,剪裁让它看得完,护栏让它坏不了。
三、支柱一:Agent 的神经系统是「事件」,不是函数调用
先讲一个容易被跳过的设计前提。
在 OpenHands 里,一切都不是「直接调用」,而是事件:
sequenceDiagram
participant M as 模型
participant S as 事件流
participant R as 运行环境
M->>S: ① 写入一个 Action(我想跑这条命令)
S->>R: ② 环境消费这个 Action
R->>S: ③ 写入一个 Observation(我看到了什么)
S->>M: ④ 模型读到这个 Observation
M->>S: ① 再写入下一个 Action
Note over M,R: Action 与 Observation<br/>像呼吸一样交替
模型的「想法」和环境的「反馈」,被解耦成了两种事件。
这个抽象的价值在于:谁都能订阅、谁都能重放、谁都能审计。想加一个「所有危险命令先弹窗确认」的功能?订阅事件流就行,不用改模型、也不用改执行器。
结构体 事件(四选一):
| 执行动作 : { 编号, 命令 }
| 执行观测 : { 编号, 输出, 标准输出, 错误输出, 退出码, 耗时, 是否超时, 是否被裁剪 }
| 格式错误观测 : { 编号, 原始输出, 原因, 纠正建议 }
| 助手发言 : { 角色, 内容 }
事件类型被穷尽地列出来,意味着处理事件的那段代码必须覆盖全部四种情况——漏掉一种,写代码的时候就会报错,而不是半夜在生产环境里才暴露。
这是强类型语言给 Agent 工程带来的一个实打实的好处:把一类 bug 从运行时抹掉。
四、支柱二:哨兵协议 —— 怎么知道一条命令跑完了?
这是本篇技术含量最高、也最巧妙的一个设计。
4.1 问题:命令什么时候结束?成功了还是失败了?
Agent 发出「列出这个目录」的指令后,需要知道两件事:
- 输出到此为止了吗?
- 它是成功了还是失败了?
在终端里,这两件事对人来说是显而易见的——提示符回来了,就是跑完了。但程序看不见提示符的「含义」。
4.2 解法:让命令自己报数
OpenHands 的解法极其朴素:在执行命令的时候,尾巴上挂一个哨兵。
函数 组装脚本(用户命令):
命令 = 去掉末尾分号(用户命令)
如果 命令 是空的:
返回 "echo " + 哨兵 + " 0"
返回 命令 + "; echo " + 哨兵 + " $?" # ★ 哨兵行 = 结束标记 + 退出码
于是 列出不存在的目录 这条命令,真正被执行的是:
ls /不存在的路径 ; echo ___结束标记___ s0 $?
它的输出必然是:
ls: /不存在的路径: No such file or directory
___结束标记___ s0 2
读输出的一方看到最后那一行,就精确地知道了两件事:
- 到此为止是这条命令的输出;
2就是它的退出码(在终端里,0 代表成功,非 0 代表失败)。
4.3 读循环:先认哨兵,再收数据
函数 执行(命令):
写入标准输入(组装脚本(命令))
标准输出 = []
错误输出 = []
循环 只要 没超时:
行 = 从队列取一行()
如果 行 来自 标准输出:
如果 行 是哨兵行:
退出码 = 解析出退出码(行)
跳出循环 # ★ 先判哨兵,再决定收不收
标准输出.追加(行)
否则:
错误输出.追加(行)
返回 执行观测(标准输出, 错误输出, 退出码, 耗时)
注意那个顺序:先判断是不是哨兵,再决定要不要收进输出。
这是一个很容易踩的坑。如果反过来——先把行收进输出、再判断是不是哨兵——哨兵行就会漏进业务数据里,模型会看到一行莫名其妙的 ___结束标记___ s0 0,甚至可能因为它含有某些子串而污染日志分析。
协议的正确做法,是让「标记」和「数据」彻底分帧。
4.4 超时:绝不把一条卡死的命令留在那里
如果 等待超过了时限:
杀掉进程()
重建厨房() # ★ 整间换掉,不留残局
返回 执行观测(输出="命令超时被终止", 是否超时=真)
超时不会被伪装成成功。模型会收到一条明确说明的观测,并且被告知下一步有哪些选项。
五、支柱三与四:择菜,以及「坏了自己爬起来」
5.1 择菜:一百斤里只留下锅的那几两
函数 剪裁(原始输出):
行们 = 原始输出.按行拆分()
如果 行们.数量 <= 上限:
返回 原始输出
头部 = 行们.取前 N 行() # 命令的开始,往往是意图
尾部 = 行们.取后 N 行() # 命令的结尾,往往是结果
关键行 = 行们.筛选(包含 "Error" / "Traceback" / "Exception" ...)
# ★ 报错栈帧一帧不丢
中间 = 关键行
占位 = "… 已裁剪 " + 被裁行数 + " 行,关键行已保留在下方 …"
返回 头部 + 占位 + 中间 + 尾部
举个真实的场景:让 Agent 打印 100000 行日志,其中第 50000 行埋了一句模拟报错。
剪裁之后,进到模型上下文里的是:头 40 行 + 尾 40 行 + 关键 1 行——那一行关键,正是埋在第 50000 行的 Error: simulated exception。
报错一帧不丢,中间十万行灰飞烟灭。 如果不是这个择菜的动作,十万行会把上下文窗口直接冲爆。
而且注意那条占位说明——它不是可有可无的装饰,它是让模型知道「世界被裁剪过了」的唯一线索。
5.2 护栏:坏格式不是灾难,是信号
模型输出的命令,通常是包在代码块里的。解析器负责把它剥出来。
铁律是:解析永不抛异常,只降级。 解析结果被锁定为三种:
结构体 解析结果(三选一):
| 可执行 : { 命令内容 } # 找到合法的命令块
| 格式错误 : { 原始输出, 原因, 纠正建议 } # 出现过代码块但解不出来
| 纯文本 : { 文本内容 } # 模型就是在说话,没想执行
判定顺序是:
- 有合法闭合的命令块 → 可执行;
- 出现过围栏但解不出合法块(没闭合 / 标签拼错 / 内容为空)→ 格式错误,带上原因和纠正建议;
- 完全没有围栏 → 纯文本(模型就是在说话)。
而那个「格式错误」,会被控制器改写成一条格式错误观测,扔回事件流。于是就有了这样一个闭环:
flowchart LR
A[模型输出<br/>围栏没闭合] --> B[解析器<br/>不崩溃,降级]
B --> C[写回事件流<br/>格式错误:围栏未闭合]
C --> D[模型读到这条观测]
D --> E[道歉并重发<br/>正确格式]
E --> F[执行成功]
style A fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
style B fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A
style C fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A
style D fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A
style F fill:#E6F4EA,stroke:#34A853,color:#1A1A1A
坏输入没有杀死 Agent,反而变成了一条驱动自我纠错的信号。 这是第四根柱子的全部奥义。
而且请注意——整个过程没有抛出一个异常。
六、主控循环:把四根柱子焊起来
最后,把所有零件组装成一个完整的循环:
函数 跑一轮(用户输入):
循环 只要 没超过最大步数:
模型输出 = 大厨.作答(岗位守则, 事件流.历史())
结果 = 解析(模型输出)
分支 结果:
情况 可执行:
动作 = 执行动作(编号, 结果.命令)
事件流.写入(动作) # ① 先记录意图
原始观测 = 在独立线程里执行(结果.命令) # ② 走沙盒、带超时
观测 = 剪裁(原始观测) # ③ 择菜
事件流.写入(观测) # ④ 再记录结果
情况 格式错误:
事件流.写入(格式错误观测(结果.原因, 结果.纠正建议))
# ★ 不抛异常、不中断,让模型下一轮自己修
情况 纯文本:
事件流.写入(助手发言(结果.文本))
返回 结果.文本 # 本轮结束
用厨房的话翻译一遍:
循环:
大厨说下一步做什么
如果他说的是「给我刀」 → 递刀(记录 → 执行 → 择菜 → 记录)
如果他说的是句废话 → 告诉他「格式不对」,让他重说
如果他在跟客人说话 → 本轮结束
这就是 Harness。 不是某一行神奇的代码,而是四根柱子撑起来的一整套环境。
七、四章合起来,是一张完整的图纸
到这一章为止,原理篇结束。把这四章叠在一起,一张完整的 Agent 架构全景图浮出水面:
flowchart TB
subgraph AGENT[一台完整的 Agent]
direction TB
B[循环 - 第 1 章<br/>让它能跑]
C[确定性图 - 第 2 章<br/>让它不乱跑]
D[记忆分层 - 第 3 章<br/>让它越跑越懂你]
E[Harness - 第 4 章<br/>让它真的能改世界,而且改不坏]
end
B --> C --> D --> E
style AGENT fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A
style B fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
style C fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
style D fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
style E fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
| 维度 | 01 · 循环 | 02 · 确定性图 | 03 · 记忆分层 | 04 · Harness |
|---|---|---|---|---|
| 核心抽象 | 一个 while 循环 + 交接 | 一张图 + 状态 + 合并规则 | 内存分层 + 自我编辑 | 长连接沙盒 + 哨兵协议 + 事件流 |
| 记忆 | 自由字典 | 可落盘的快照 | 便签 / 灶台 / 冰箱 / 储藏室 | 会话状态 + 剪裁后的日志 |
| 谁控制循环 | 模型 | 图结构 + 条件边 | 模型(心跳)+ 规则 | 事件流 + 控制器 |
| 上下文爆了怎么办 | 无解,硬塞 | 检查点 / 压缩 | 换页 + 告警,自己清 | 头尾 + 关键行剪裁 |
| 干了坏事怎么办 | 无物理后果(也干不了活) | 无 | 无 | 容器隔离 + 超时自愈 + 降级纠错 |
| 这一章递来的钥匙 | 循环 | 确定性 | 自我演化 | 物理手脚 |
一句话串起这四篇:
第一章证明了 Agent 的最小骨架是「循环」;第二章证明了「控制流」可以是显式的图;第三章证明了「记忆」可以像操作系统一样分层、自我演化;第四章证明了最后一块拼图——一个 Agent 真正的价值,不在于它想得多好,而在于它能否安全地、持续地、不把机器搞坏地,把想法变成物理世界的改变。
真实 OpenHands 还做了什么
照例列一下工业级 OpenHands 有、而这一章简化掉的东西:
| 没做的设施 | 真实 OpenHands 里是什么 | 解决什么问题 |
|---|---|---|
| 真实容器隔离 | 每个会话一个跑在预构建镜像里的容器 | 我们只隔离了进程,没隔离文件系统 |
| 真实终端分配 | 常驻的终端会话,支持交互式程序 | 我们的长连接装不下需要真终端的程序 |
| 元数据注入 | 哨兵藏在提示符里,带退出码、进程号、工作目录 | 我们的哨兵只带退出码 |
| 输出落盘续读 | 裁剪的同时把完整输出存成文件,需要时再读 | 我们只在内存里裁,裁掉的真的没了 |
| 进程树管理与优雅终止 | 先温和中断、再强杀,保留现场 | 我们直接强杀,暴力但干净 |
| 全量事件落库 | 事件可回放、可审计、可 diff | 我们的历史只活在这次进程里 |
| 多沙盒并行 | 每会话独立容器,横向可扩 | 我们一个进程一张嘴 |
八、第一幕结束,第二幕开始
到这里,原理篇结束了。四章下来,我们没有写一行真正能跑的业务代码,只做了一件事:把 Agent 的骨架看清楚。
现在你应该能回答这几个问题了:
- Agent 引擎的心脏是什么?— 一个循环。
- 怎么防止它乱跑?— 把控制流变成显式的图。
- 它怎么记住你?— 记忆分层 + 自我改写。
- 它怎么安全地动手?— 隔离、会话、剪裁、护栏。
但「看清楚」和「造出来」之间,还隔着很远。
从下一章开始,进入第二幕 · 组件篇。我们不再满足于伪代码——每一个零件,我们都要用 Python 亲手造一遍,而且只用标准库,你复制就能跑。
第一个零件,就是第三章里还空着的那间储藏室——记忆系统。
第一幕核心结论:Agent 的四个关键能力——跑起来、不乱跑、记得住、干得动——每一个背后,都是一套朴素到可以用伪代码写清的确定性工程。魔法从来不在模型里,魔法在你为它搭的那间厨房里。