1. 为什么 Agent 需要一副“物理手脚”
很多人把 Agent 的开发理解成“套一个 Prompt、接一个大模型 API、能对话就算完事”。但真正做过 Agent 项目的人应该都有同感:对话能力只是 Agent 的“大脑皮层”,真正让 Agent 从“聊天机器人”升级为“能办事的智能体”的,是它能不能在真实环境里执行命令、读写文件、操作软件。这一步迈不过去,Agent 永远只是个高级的问答玩具。
OpenHands(曾经叫 OpenDevin)这套开源框架,核心思路就是把“执行层”认真做扎实。它给 Agent 开了一个PTY 沙盒,让大模型不仅“想”还能“做”——在隔离出来的 Linux 环境里跑命令、跑脚本、改代码,然后根据执行结果决定下一步动作。换句话说,这一步是给 Agent 装上“手和脚”的关键工程。
这套机制适合谁去研究?一个是正在做 Agent 框架设计的开发者,一个是想理解“AI 如何操作真实系统”的进阶爱好者。我建议至少要有基础的 Linux 命令和 Python 异步编程经验,不然读下来会有一点吃力。不过我会尽量把原理讲透,把坑都提前标注出来,跟着走一遍下来收获会非常明显。
从架构上看,PTY 沙盒解决的是一个非常核心的问题:Agent 与大模型之间对话只是“思考”,Agent 与操作系统之间交互才是“行动”。思考需要的是语言模型的能力,行动需要的是可控、可观测、可回滚的“物理层”。OpenHands 的 PTY 沙盒就是这一层的关键实现。
2. 先搞清楚 PTY 到底是什么东西
PTY 的全称是 Pseudo-Terminal,翻译过来叫伪终端。如果你以前只用 Windows 的 CMD 或者 PowerShell,这个概念可能有点陌生;但在 Linux 和 macOS 上,一切终端窗口的背后都是 PTY 在起作用。
我尽量用大白话讲一下它的本质。你在电脑上打开一个终端窗口,敲下ls然后回车,这个过程中其实发生了两件事:终端窗口把ls这个输入交给 shell 进程去执行,shell 执行完了把结果输出回终端窗口显示。这个“终端窗口”和“shell 进程”之间的桥梁,就是 PTY。
PTY 分成两端:主端(master)和从端(slave)。从端接的是 shell 进程,主端接的是用户终端程序(或者像 Agent 这样的调用方)。从端看起来就像一台真实的终端设备,shell 所有的输入输出都走这个“假终端”;而主端是控制方,可以写入输入、读取输出,甚至发送终端控制信号。
为什么要绕这么一圈,直接subprocess.run("ls")不好吗?这里面有几个很关键的差异,你得理解透:
第一,很多交互式程序(比如 Python 交互式解释器、vim、top 这类工具)会检测自己是不是连在一个终端上。如果检测不到终端,它们会拒绝以交互模式启动,或者行为完全不一样。用subprocess直接跑,拿不到交互式效果。
第二,PTY 能处理终端控制序列,比如 ANSI 颜色、光标移动、清屏操作。这些对于 Agent 解读程序输出特别重要——你总不希望 Agent 面对一堆乱七八糟的\x1b[2J转义序列直接蒙圈吧。
第三,PTY 天然就是一个字节流通道,读写非常接近真实终端行为。你可以把 Agent 对终端的操作,想象成一个人在一台实体机器前面敲键盘、看屏幕——只不过这个“人”是大模型,这台“机器”是 Docker 容器。
提示:PTY 不是 OpenHands 发明的技术,早在几十年前的 UNIX 时代就有了。OpenHands 的贡献在于把 PTY 封装成了 Agent 可以理解、可以控制、可以观测的“工具层”。
3. OpenHands 沙盒的设计思路拆解
OpenHands 的沙盒架构,一句话总结就是:用 Docker 做隔离,用 PTY 做交互通道,用事件流(Event Stream)做 Agent 大脑与执行环境之间的通信协议。这三个部分组合起来,才构成了一个真正“可操作”的 Agent。
3.1 为什么沙盒选择 Docker 而不是虚拟机
安全隔离这件事情,Agent 比人更“危险”——因为大模型的执行路径不可预测,你根本不知道它会因为一个模糊指令去执行什么命令。给它一台虚拟机?太重了,起一个环境要好几秒甚至更久。直接跑在宿主机上?风险太大,万一模型“抽风”执行了rm -rf /,哭都来不及。
Docker 的优势很明显:轻量、快速、隔离性好、环境可重复构建。OpenHands 默认的做法是运行一个 Ubuntu 容器,里面预先装好 Python、Node.js、Git 等常用开发工具,然后通过 Docker API 来管理容器的生命周期。每次任务结束,整个容器直接销毁,任何脏数据都不留在宿主机上。
我个人在实际测试中的体验是:容器从启动到可用大概在 1 到 2 秒之间。这个速度对 Agent 的“操作型任务”至关重要——如果一个命令要等十几秒才能执行,Agent 的推理链路会被拖得很长,体验非常差。
3.2 沙盒、事件流与 Agent 的三角关系
OpenHands 的内部结构里,Event Stream是一个全局总线,所有组件之间的通信都通过事件来进行。沙盒执行了一条命令,产出了一个输出事件;Agent 看到一个输出事件,决定下一步要发什么指令,又产出一个行动事件。这就像一个异步的消息队列,把 Agent 的“决策”和沙盒的“执行”完全解耦。
这个设计最大的好处是可追踪、可回放、可恢复。Agent 在思考的过程中,整个执行轨迹都会被记录下来。如果 Agent 中途崩了,或者你想复盘它为什么做出某个决定,可以把事件流重新播放一遍,精确到每一步。
从工程实践的角度看,事件流的设计比你直接“大模型调函数、函数调用 shell”要稳健得多。因为后者的调用链是同步的、脆弱的;前者天然支持并发、重试、暂停、恢复。
3.3 PTY 在 OpenHands 中的具体位置
在 OpenHands 的代码中,PTY 的执行由sandbox模块管理,核心类是PtyProcess。它负责在 Docker 容器里开启一个 PTY 会话,并暴露一个类似文件操作的接口给上层调用。上层只需要做三件事:写命令、读输出、关闭连接。
写到这我想起一个对比——你去用市面上很多“AI 自动操作电脑”的工具,会发现它们的实现非常粗暴:要么用截图识别然后模拟鼠标点击,要么直接调用一个 shell 命令拿结果。OpenHands 的做法更底层、更通用:它不去猜屏幕上的内容,而是直接操作终端这个最通用的“数字交互界面”。这就像一个是隔着玻璃操作机器,一个是直接把手伸进控制台——效率和可控性完全不是一个级别。
4. 核心逻辑拆解:一个最小可用的 PTY Agent 沙盒
光说理论容易飘,我直接带着你从零写一个最小可用的 PTY Agent 沙盒。这里我会用 Python 作为实现语言,因为 OpenHands 本身就是 Python 写的,生态比较成熟。
4.1 技术选型:选择哪个 PTY 库
Python 里和 PTY 相关的库其实有好几个,我自己踩完坑之后的建议是:
| 库名 | 特点 | 推荐度 |
|---|---|---|
pty(标准库) | 最底层,控制粒度最细,但处理异步很痛苦 | 不推荐直接使用 |
ptyprocess | 基于标准库封装,提供超时控制和 expect 模式 | 简单场景可用 |
asyncio-pty | 支持 async/await,适合 Agent 这种异步密集型任务 | 推荐 |
node-pty | Node.js 生态,配合 Electron/前端很顺手 | 前端方案可考虑 |
我最终选择的是asyncio-pty,因为它能无缝集成到asyncio事件循环里。Agent 的运行本质就是一个异步过程:模型在等响应、命令在跑、输出在流动,这些都不能用阻塞式调用来处理。
4.2 初始化一个 PTY 进程
先用 Python 代码初始化一个 PTY 会话。假设我们已经有一个正在运行的 Docker 容器,容器 ID 存在container_id变量里:
import asyncio import asyncio_pty async def create_pty_session(container_id: str): """在指定容器内创建 PTY 会话""" # 关键点:在容器内执行 /bin/bash,并且分配一个 PTY # -i 表示交互模式,-t 表示分配终端 cmd = ["docker", "exec", "-i", "-t", container_id, "/bin/bash"] # 创建 PTY 进程 pty_process = await asyncio_pty.open(cmd, cols=120, # 终端宽度 rows=40, # 终端高度 cwd="/workspace") # 工作目录 return pty_process这里有几个细节要特别注意:
cols和rows是终端的尺寸。为什么要设置这个?因为很多程序会根据终端宽度自动换行或格式化输出(比如ls -l的列数计算)。如果你不设置,默认可能是 0,有些程序就会出 bug。cwd设成/workspace是为了让 Agent 一进去就在工作目录里,不用执行cd命令。- 用
docker exec -it而不是docker exec,是因为-t才会分配 PTY,没有-t的话交互程序和颜色输出都会失效。
4.3 命令写入与输出读取
有了 PTY 进程之后,写命令和读输出就非常简单了:
async def run_command(pty_proc, command: str, timeout: float = 30.0): """向 PTY 写入命令并读取输出""" # 在命令后面加换行符,通知 shell 执行 pty_proc.write((command + "\n").encode()) # 读取输出,这里用了 asyncio 的超时控制 output = bytearray() try: while True: chunk = await asyncio.wait_for(pty_proc.read(4096), timeout=timeout) if not chunk: break output.extend(chunk) # 这里可以根据输出内容判断命令是否执行完成 # 比如检测到 shell 提示符(如 $ 或 #) except asyncio.TimeoutError: print(f"命令 {command} 执行超时,已强制停止") return output.decode("utf-8", errors="replace")等等,上面的代码有一个很隐蔽的问题,我得专门拿出来说。
直接读输出到超时的做法,在实际使用中是有问题的。因为对交互式 shell 来说,read()只要终端里有数据就会立即返回,不会傻等着你命令执行完。比如你执行ls,输出很短,几毫秒就返回了;但如果你执行sleep 5 && echo done,sleep期间终端里没有输出,read()就会一直阻塞,直到echo done输出了才返回。
所以你不能单纯靠“读不到输出了”来判断命令跑完了。实践中一般有三种做法:
- 在命令后面加结束标记:比如
echo __CMD_DONE__,然后读到这个标记就认为命令执行结束。 - 检测提示符:读取到
$或#开头就认为 shell 回到了空闲状态。 - 固定等待 + 超时:用一个较短的时间片轮询输出,没有新数据了就认为结束(这个方案不推荐,不够可靠)。
OpenHands 内部是结合了第一和第二种方案,还会解析终端控制序列来过滤掉转义字符,让输出的可读性更强。我们做最小实现的时候,用第一种方案最可靠:
async def run_command_with_marker(pty_proc, command: str): """用结束标记来判断命令执行完成""" marker = "__CMD_DONE_7f3a9__" # 执行命令并在末尾输出标记 pty_proc.write(f"{command}; echo {marker}\n".encode()) output = "" while True: chunk = await pty_proc.read(4096) if not chunk: break output += chunk.decode("utf-8", errors="replace") if marker in output: # 已经把标记包含进去了,可以裁剪掉 output = output.split(marker)[0] break return output这个方法简单粗暴,但非常管用。唯一要注意的是,如果你执行的命令本身也嵌套执行命令,里面的命令也会继承这个 shell 会话,所以标记会在任何输出之后出现,不会漏掉。
4.4 完整的最小实现
把上面的逻辑串起来,一个最小可用的 PTY Agent 沙盒差不多长这样:
import asyncio import asyncio_pty import uuid class PTYAgentSandbox: def __init__(self, container_id: str): self.container_id = container_id self.pty_proc = None self.session_id = str(uuid.uuid4()) async def start(self): """启动 PTY 会话""" cmd = ["docker", "exec", "-i", "-t", self.container_id, "/bin/bash"] self.pty_proc = await asyncio_pty.open(cmd, cols=120, rows=40) # 等待 shell 完全就绪 await asyncio.sleep(0.5) # 清掉 shell 初始化输出 await self._clear_output() async def _clear_output(self): """清空 shell 启动时的 banner 输出""" try: await asyncio.wait_for(self.pty_proc.read(4096), timeout=0.2) except asyncio.TimeoutError: pass async def execute(self, command: str, timeout: float = 60.0): """执行命令并返回输出""" marker = f"__CMD_DONE_{self.session_id[:8]}__" full_cmd = f"{command}; echo {marker}\n" self.pty_proc.write(full_cmd.encode()) output = "" try: while True: chunk = await asyncio.wait_for( self.pty_proc.read(4096), timeout=timeout ) if not chunk: break output += chunk.decode("utf-8", errors="replace") if marker in output: output = output.split(marker)[0] break except asyncio.TimeoutError: output += "\n[ERROR] Command execution timeout" return output.strip() async def close(self): """关闭 PTY 会话""" if self.pty_proc: self.pty_proc.close() await self.pty_proc.wait()这个类已经具备了最基本的能力:启动会话、执行命令、读取输出、超时控制、清理会话。你在实际项目中完全可以拿它作为起点继续扩展。
注意:上面的代码为了演示做了大量简化,生产环境还需要处理信号中断、终端大小调整、并发写锁等问题。但核心的执行链路就是这样。
5. 实操过程:把 PTY 沙盒跑起来
光有代码还不能跑,你还需要一个 Docker 容器作为沙盒的“底盘”。我把自己实操的完整流程写在下面,每一步都是验证过的。
5.1 准备基础容器镜像
我用的是 OpenHands 官方的运行时镜像,不过你也可以自己做一个轻量版。先拉取镜像:
docker pull ghcr.io/all-hands-ai/runtime:0.9.0如果你想用更轻量的方案,也可以直接用 Python 官方镜像自己装工具:
docker pull python:3.11-slim docker run -d --name agent-sandbox python:3.11-slim tail -f /dev/null用tail -f /dev/null保持容器在前台运行,这样容器不会因为没有任何进程而退出。这是 Docker 实战里常用的小技巧,适合需要一个“闲置”容器做实验的场景。
5.2 在容器内安装必要工具
进入容器安装一些常用工具:
docker exec -it agent-sandbox bash apt-get update apt-get install -y git curl wget vim build-essential pip install --upgrade pip exit这一步不是必选的,但如果你希望 Agent 能完成“写代码 -> 测试 -> git 提交”这类完整任务,这些工具早晚要装。OpenHands 的官方镜像里已经预装好了这几百 MB 的工具链,直接用会更省事。
5.3 跑通最小沙盒
把上面的PTYAgentSandbox类保存成sandbox.py,然后写一个简单的测试脚本:
import asyncio from sandbox import PTYAgentSandbox async def main(): sandbox = PTYAgentSandbox("agent-sandbox") await sandbox.start() # 测试基本命令 output = await sandbox.execute("pwd") print(f"pwd 输出: {output}") output = await sandbox.execute("python --version") print(f"Python 版本: {output}") # 测试稍微复杂一点的操作 output = await sandbox.execute("echo 'hello agent' > /tmp/test.txt && cat /tmp/test.txt") print(f"文件操作输出: {output}") # 测试交互式程序 output = await sandbox.execute("python3 -c \"print('from python')\"") print(f"Python 执行输出: {output}") await sandbox.close() asyncio.run(main())跑一下看看输出:
python test_sandbox.py如果一切正常,你会看到 pwd 输出的是/,Python 版本显示的是容器里的版本号,所有命令都能正常执行。到这里,一个最小可用的 PTY Agent 沙盒就已经跑通了。
5.4 接入大模型做简单的任务闭环
跑通了命令执行,下一步就是把大模型接进来,形成一个“思考-行动-观察”的循环。这一步是整个 Agent 开发的核心模式,我给你写一个最简单的例子:
import asyncio from openai import AsyncOpenAI from sandbox import PTYAgentSandbox client = AsyncOpenAI() # 假设已经设置了 API Key sandbox = PTYAgentSandbox("agent-sandbox") SYSTEM_PROMPT = """你是一个运行在 Linux 沙盒中的 AI 助手。 你有能力执行 shell 命令来完成任务。每次只输出一条命令,不要解释。 当任务完成时,输出 __TASK_COMPLETE__。""" async def agent_loop(task: str, max_steps: int = 10): await sandbox.start() history = [] for step in range(max_steps): # 让大模型决定下一步做什么 messages = [ {"role": "system", "content": SYSTEM_PROMPT}, *history, {"role": "user", "content": f"任务: {task}"} ] resp = await client.chat.completions.create( model="gpt-4o", messages=messages, temperature=0 ) action = resp.choices[0].message.content.strip() if "__TASK_COMPLETE__" in action: print(f"任务完成,共执行了 {step} 步") break # 执行命令并观察结果 print(f"执行: {action}") result = await sandbox.execute(action) print(f"结果: {result[:200]}") # 把执行结果作为观察追加到对话历史 history.append({"role": "assistant", "content": action}) history.append({"role": "user", "content": f"观察结果:\n{result}"}) await sandbox.close() asyncio.run(agent_loop("查看 /etc/os-release 的内容"))这个例子非常朴素,但已经完整展示了 Agent 的核心闭环。大模型基于你的任务描述,一步步决定要执行什么命令,然后把命令执行的结果反馈给它,它再基于观察结果做下一步决策。这就是 ReAct(Reasoning + Acting)模式在工程上的落地。
6. 套接字层与 OpenHands 的容器化实现
很多人在读 OpenHands 源码时,会注意到一个比较绕的点:OpenHands 并不是直接从主进程去连容器的 PTY,而是通过一个在容器内运行的套接字服务来中转命令读写。
这个设计初看有点多余,但仔细想会发现非常聪明。直接通过 Docker API 或docker exec去操作容器,每一次交互都要经过 Docker 守护进程,性能和灵活性都不够好。而且容器内的进程如果直接和主进程通信,网络层面也不好隔离。
OpenHands 的做法是:沙盒容器内跑一个runtime服务,它监听一个 Unix 套接字(或者 TCP 端口)。主进程把要执行的命令封装成请求,发到套接字上;容器内的runtime服务收到请求后,在本地开一个 PTY 会话去执行,再把输出结果通过套接字返回。
好处有三个:
- 性能更好:请求不需要每次经过 Docker daemon 转发,容器内直接处理,路径短、延迟低。
- 部署更灵活:容器内的
runtime服务可以独立升级、独立扩展,不依赖宿主机的 Docker API 版本。 - 安全边界更清晰:宿主机侧只知道“有一个套接字端口”,不暴露 Docker 控制能力给任何内部组件。
从这个角度看,OpenHands 的沙盒设计其实分了两层:外层是 Docker 容器的隔离,内层是 runtime 进程对 PTY 的二次封装。两个层面各司其职,组合起来才形成完整的执行环境。
7. 常见问题与排查技巧实录
这个部分我重点聊聊实际操作中容易踩的坑。每一个我都亲自遇到过,并确认了解决方案。
7.1 Agent 执行命令后返回空输出
现象:命令明明执行了,但返回的输出是空字符串。
原因:大多数情况下,是因为 shell 的默认提示符输出被当成普通输出处理了,或者命令的输出内容太短,被_clear_output()方法误清了。
排查思路:先手动在终端里执行同样的命令,看输出到底是什么。然后检查你的 PTY 会话是否真的连上了 shell,可以在初始化后直接读一下有没有 banner 输出。
解决方法:把_clear_output()的等待时间调短一点,或者干脆不用这个逻辑,改用在每条命令前加一个随机标记。另一个办法是,执行命令时加-l参数让 bash 以登录 shell 方式启动,这样环境变量更完整,不容易出现输出异常。
7.2 命令一直阻塞,触发超时
现象:Agent 执行sleep 100这类长时间命令,整个流程卡住,最后只能超时退出。
原因:这是预期的行为,但问题在于你没有给 Agent 一个“中断”的手段。人眼看情况不对可以按Ctrl+C,Agent 也需要这个能力。
解决方法:在execute方法里增加一个interrupt方法,通过向 PTY 写入\x03(即Ctrl+C的 ASCII 码)来中断当前命令:
async def interrupt(self): """向当前进程发送 Ctrl+C 中断信号""" self.pty_proc.write(b"\x03") await asyncio.sleep(0.1)然后在超时逻辑里,先尝试发Ctrl+C,看命令是否停止;如果还不停,再考虑强制关闭会话。
7.3 容器内无法启动交互式程序
现象:在沙盒里执行vim或htop,报错说“TERM environment variable not set”之类的。
原因:这是环境变量的问题。虽然分配了 PTY,但容器的基础镜像可能没有设置TERM环境变量。交互式程序需要通过这个变量来确定终端类型。
解决方法:在命令前手动设置环境变量,或者启动容器时配置好:
docker exec -i -t -e TERM=xterm-256color agent-sandbox bash也可以在初始化 PTY 时写入export TERM=xterm-256color再开始会话。
7.4 输出里混入大量 ANSI 转义序列
现象:输出里面有大量\x1b[01;32m这样的乱码。
原因:shell 默认会给文件类型用颜色区分(比如目录是蓝色、可执行文件是绿色),这些颜色信息通过 ANSI 转义序列传递。
解决方法:让 shell 不带颜色输出。最直接的方法是在启动命令时加--noprofile --norc,不加载使用颜色的配置文件;另一种是手动过滤 ANSI 转义序列。分享一个我常用的正则:
import re ANSI_ESCAPE_PATTERN = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]') def strip_ansi(text: str) -> str: return ANSI_ESCAPE_PATTERN.sub('', text)在生产项目中我建议保留原始输出和清洗后的输出两个字段,原始数据用于调试,清洗后的数据用于给大模型观察。因为有些场景下,ANSI 序列里其实藏着语义信息(比如光标位置),直接删掉可能会影响对程序状态的判断。
7.5 Agent 连续执行命令时输出错乱
现象:Agent 快速连续执行两条命令,上一条命令的输出和下一条命令的输出混在一起。
原因:这是典型的竞态条件。PTY 本质是一个共享的字节流,两条命令的写入和读取如果并发了,就分不清哪段输出属于哪条命令。
解决方法:在沙盒类中加入一个asyncio.Lock,保证同一时间只有一个命令在执行:
import asyncio class PTYAgentSandbox: def __init__(self, container_id: str): self.container_id = container_id self.pty_proc = None self._lock = asyncio.Lock() async def execute(self, command: str): async with self._lock: # 执行命令的逻辑 pass用了锁之后,Agent 的命令都是串行执行的,天然避免了输出错乱。这个方案简单、可靠,性能上也不会有什么损失,因为绝大多数 Agent 任务的瓶颈根本不在命令执行,而在大模型的推理延迟。
7.6 Docker 容器启动后无法删除
现象:测试过程中容器卡住了,docker rm -f都删不掉。
原因:通常是因为有 PTY 会话仍然连接到容器,Docker 的进程管理认为容器还在“忙碌”状态。
解决方法:先清掉宿主机上持有 PTY 连接的进程,再删容器:
# 找出连接到容器 ID 的进程 ps aux | grep <container_id> # 杀掉对应进程 kill -9 <pid> # 强制删除容器 docker rm -f <container_id>实战里最稳妥的方式是,在 Python 代码里给close()方法加上finally块,确保异常时也能执行清理逻辑,不要依赖手动删容器。
8. 围绕 PTY 沙盒的几个进阶思考
写到这里,我再说一说自己在实际项目中围绕 PTY 沙盒积累的几个经验,供你参考。
8.1 Agent 连接终端这件事,比想象中更有价值
很多人容易低估“交互式终端”这个能力。其实大多数实用工具和服务,都提供了交互式的管理界面(redis-cli、psql、pythonREPL)。如果你的 Agent 只能非交互地执行一次性命令,很多工具的高级能力就用不上。有了 PTY,Agent 可以和这些程序持续对话,真正做到“人怎么操作,Agent 就能怎么操作”。
8.2 不要忽视终端尺寸对输出的影响
我可以负责任地告诉你,终端宽度真的会影响程序的行为。ls -l在不同宽度下的换行方式不一样;一些 TUI 程序在窗口过小时会直接拒绝启动。OpenHands 默认用 120 列,实践下来比较合理。但如果你的 Agent 习惯生成很长的代码行,可以考虑用 160 列,减少不必要的换行符出现在输出里。
8.3 沙盒里要有“痕迹管理”
建议你在沙盒的~/.bash_history、/workspace文件变动日志上都做一点留痕。这样 Agent 出错时,你可以快速回溯它到底做了哪些操作。就像看监控录像一样,能把现场还原出来,排查问题的效率能提升一个档次。
8.4 超时策略要分层
一个很隐蔽的问题:你在 Python 层设了 60 秒超时,但如果容器内执行的是apt-get update这种长时间网络操作,60 秒可能不够;可如果 Agent 只是在跑一个快速测试,60 秒又太长,等半天才发现命令出问题了。
我的经验是按命令类型差异设置超时。文件操作类命令 10 秒,代码执行类 30 秒,包管理/网络类 120 秒,这样既不会因为超时太短误伤长任务,也不会因为超时太长拖慢 Agent 的节奏。
8.5 会话复用比每次新建更高效
如果你的 Agent 需要多次和同一个沙盒交互(比如先装依赖、再跑测试、后看日志),建议在整个任务期间复用一个 PTY 会话,而不是每个命令都重新创建一个。会话复用的好处是工作目录、环境变量、shell 状态都是连续的,就像一个人在同一个终端窗口里连续操作,体验更自然、性能也更好。
9. 最后一小块蛋糕:让沙盒“返回状态码”
我的 PTYAgentSandbox 目前只返回了标准输出和标准错误混在一起的内容,但没有返回命令的退出状态码。这在很多场景下很致命——Agent 需要知道命令是成功还是失败。
获取退出码的办法是借助 shell 特性,在命令执行后用特殊变量$?拿到上一条命令的退出码:
async def execute_with_status(self, command: str): marker = f"__DONE_{self.session_id[:8]}__" full_cmd = f"{command}; echo {marker}:$?\n" ...然后在解析输出时,把marker后面的内容拆分出来:
if marker in output: output, status = output.split(marker) status_code = status.strip().split(":")[1] return output.strip(), int(status_code)这样每条命令的执行结果就变成了(输出内容, 退出码)二元组。给大模型观察时,把退出码作为一项重要信息传进去,模型就能判断这个操作是否真的成功了,决策会更准确。这一步看似是个小细节,但对 Agent 的实际效果影响很大。
10. 下一步可以做什么
你如果能自己动手把上面的代码跑通,说明你已经摸到了“Agent 物理手脚”的门道了。接下来几个方向都值得深入:
- 把 PTY 输出中的ANSI 转义序列完整解析出来,做成结构化的终端事件流,让 Agent 更准确地理解终端状态。
- 给沙盒增加文件快照和回滚能力,每次执行任务前打个快照,Agent 跑挂了还能恢复到之前的状态。
- 做一个简单的Agent 监控面板,实时显示 Agent 正在执行的命令、输出内容、耗时等指标,调试体验会好很多。
- 让沙盒支持多会话并行,一次跑多个 Agent 实例做不同子任务,最后汇总结果。这已经触及多智能体协作的范畴了,后续可以单独写一篇。
从 0 到 1 搭出 PTY 沙盒,其实只是给 Agent 装上“手”的第一步。后面还有环境感知、任务拆解、长时记忆很多硬骨头要啃。但有了这一层的底子,后面每一步都走得稳。