【从 0 到 1 动手造 Agent】(原理篇)04、给 Agent 一双手:沙盒、会话与自我纠错

306 阅读14分钟

前三章我们给了 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 发出「列出这个目录」的指令后,需要知道两件事:

  1. 输出到此为止了吗?
  2. 它是成功了还是失败了?

在终端里,这两件事对人来说是显而易见的——提示符回来了,就是跑完了。但程序看不见提示符的「含义」。

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 护栏:坏格式不是灾难,是信号

模型输出的命令,通常是包在代码块里的。解析器负责把它剥出来。

铁律是:解析永不抛异常,只降级。 解析结果被锁定为三种:

结构体 解析结果(三选一):
    | 可执行   : { 命令内容 }                  # 找到合法的命令块
    | 格式错误 : { 原始输出, 原因, 纠正建议 }    # 出现过代码块但解不出来
    | 纯文本   : { 文本内容 }                  # 模型就是在说话,没想执行

判定顺序是:

  1. 有合法闭合的命令块 → 可执行
  2. 出现过围栏但解不出合法块(没闭合 / 标签拼错 / 内容为空)→ 格式错误,带上原因和纠正建议;
  3. 完全没有围栏 → 纯文本(模型就是在说话)。

而那个「格式错误」,会被控制器改写成一条格式错误观测,扔回事件流。于是就有了这样一个闭环:

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 的四个关键能力——跑起来、不乱跑、记得住、干得动——每一个背后,都是一套朴素到可以用伪代码写清的确定性工程。魔法从来不在模型里,魔法在你为它搭的那间厨房里。