资讯动态

从零搭建OpenHands风格PTY沙盒:让Agent真正动手执行命令

发布时间:2026/9/8 23:35:14 来源:尧图企业网站定制
1. 从想法到动手为什么我选择给 Agent 装“手脚”做 Agent 开发的人大概都有过这种感觉模型再聪明推理链条再长如果最后那一步只是“输出一段文字告诉你它想怎么做”那这个 Agent 始终是瘫在沙发上的理论家。真正好用的 Agent 得能自己去操作环境、执行命令、读写文件、验证结果——这才是从“大脑”到“手脚”的跨越。我第一次接触 OpenHands曾用名 OpenDevin的时候最吸引我的并不是它的对话能力或任务规划能力而是它那个藏在底层的 PTY 沙盒机制。简单说这是一个让 Agent 真正“动手”的核心基座Agent 在沙盒里执行 Shell 命令、运行代码、安装依赖然后从结果中决定下一步动作整个过程既灵活又可控。这篇文章就是记录我从 0 到 1 自己搭建这套机制的全过程以及踩过的那些坑。想入门 Agent 开发的、对 OpenHands 架构感兴趣的、或者苦恼于“模型不会动手”这个问题的人这篇都值得你花十来分钟看完。2. 动手之前先弄清楚PTY、终端模拟和沙盒到底是什么关系2.1 你可能已经用过 PTY只是没意识到PTYPseudo-Terminal伪终端这名字听起来很底层但其实你每天都在用。比如你在 macOS 的 Terminal、Windows 的 PowerShell 或 VS Code 的集成终端里敲命令终端程序背后就维护着一个 PTY。它的作用就是模拟出一个“终端设备”让那些需要交互式输入输出的程序比如python解释器、bash、vim以为自己在和真实的用户终端对话。普通命令执行和 PTY 执行最大的差别在哪里我打个比方普通执行像是给一个仓库管理员递张纸条上面写着“把三号货架的箱子搬下来”他按指令做完就不理你了中途发生了什么他不会主动告诉你而 PTY 执行像是你直接站在这位管理员旁边他每搬一个箱子、每喘一口气你都能看见你还能随时打断他、追问一句“那个红色箱子先别动”。这种实时、双向、可交互的特性正是 Agent 需要的——它能一边执行一边观察输出动态修正自己的操作。2.2 沙盒给 Agent 盖一间“安全操作室”让 Agent 随便在你的电脑上执行命令这是绝对不可接受的。万一它执行了rm -rf、下载了不安全的依赖包、或者意外修改了系统配置后果不堪设想。所以我需要给它划出一块独立的“操作地盘”——这就是沙盒的用途。我用 Docker 容器作为沙盒的执行边界。容器把文件系统、网络、进程空间都隔离起来Agent 在里面怎么折腾都不影响宿主机。你甚至可以限制容器的 CPU 和内存上限、把宿主机的某个目录只读挂载进去、通过只开放内网来阻断外联。这样一来Agent 的“手”伸得很长但它的“身体”始终被关在一个安全可控的房间里。2.3 OpenHands 为什么选“浏览器里的终端”这一路线OpenHands 的核心交互模式就是在前端页面上渲染出一个模拟终端界面用户能看到 Agent 正在敲什么命令、输出是什么。它背后连接着一个运行在 Docker 沙盒里的 PTY 进程。这套设计的好处是可观察性强Agent 每一步操作都有据可查支持交互式程序Agent 不只是执行静态命令还能和程序持续对话环境标准化每次任务都可以从同一个干净的镜像启动。如果只是普通的 subprocess 调用虽然轻量但 Agent 一旦遇到需要持续交互的场景例如进入某个 REPL 环境逐步调试就很容易卡死或误判。所以从架构上看PTY 沙盒几乎是目前开源 Agent 方案的标配。3. 核心组件拆解一个最小可用的 PTY 沙盒需要哪些零件3.1 第一块零件进程、伪终端和会话控制实现 PTY 的关键其实是 Linux 系统编程里那套经典机制。Python 的pty模块封装了底层的openpty等系统调用让我不用碰 C 代码也能快速搭出骨架。核心逻辑是用pty.openpty()获取一对主从设备文件描述符启动子进程比如bash并把它的 stdin/stdout/stderr 都重定向到从设备主进程我们的 Server负责读写主设备文件描述符实现实时通信。为了让子进程稳定运行还需要用setsid()让它脱离当前控制终端成为一个新的会话组长。这一步看似不起眼实际却决定了 CtrlC 等信号是否正确传递、以及进程能不能独立存活。3.2 第二块零件Shell 类型和历史交互细节不是所有任务都用bash。有时候我想让 Agent 进 Python REPL有时候想让它用 Node.js。为了避免频繁切换容器我一般用环境变量指定默认 Shell。实际测试下来bash的兼容性最好各种 shell 内建语法都稳定但如果任务里需要复杂的字符串处理我会让 Agent 显式调用python3而不是指望系统中有各种命令行工具齐全。还有个容易踩坑的细节终端宽高。PTY 是有“几何尺寸”的很多程序比如top、vim会根据终端宽高来决定输出布局。刚创建时默认 80x24 其实够用但如果前端模拟终端可以拖拽大小你就得把行数和列数实时传给后端否则会出现输出错位或显示截断的问题。3.3 第三块零件与 Docker 容器打通PTY 进程不能直接跑在宿主机上必须放进 Docker 容器中。实现方案是在容器创建时把 Host 的一个 Socket 或目录共享进去让容器内的 PTY 进程与宿主机的控制端通信。OpenHands 使用了更成熟的 Docker SDK 来管理容器生命周期拉取镜像如python:3.11-slim或ubuntu:22.04创建容器时设置工作目录、网络模式、内存限制容器启动后在内部执行 PTY 会话脚本。我第一次尝试时图省事直接docker run -it ubuntu bash看起来好像连上了但并没有把输入输出桥接给我自己的控制端。正确做法是用 Docker API 为容器分配一个 TTY然后我将容器的主进程标准流接到我自己的 PTY 主设备上。这个桥接层是整条链路中最容易出 bug 的地方后面我会详细说。4. 把头脑和手脚连起来Agent 如何“看懂”终端输出并决策4.1 模型交互中“观察 - 思考 - 行动”的循环Agent 的决策循环本质上就是一个强化学习里的经典闭环观察读取终端里新累积的输出思考把观察结果和当前任务目标一起发给大模型让模型给出下一步计划行动解析模型输出的命令或代码写入 PTY 并执行回到第一步循环直到任务完成。这个过程说起来简单但工程上必须处理一个关键问题模型并不知道终端输出的完整历史。如果每次都把几千行日志一股脑发给模型Token 消耗大不说模型还会被无关信息干扰。OpenHands 的做法是只保留最近若干行有效输出或者让模型按需查看上下文。我在自己的实现里则用了一个环形缓冲区保存最近 50 行并给每行加上时间戳让模型能感受输出的“节奏”。4.2 结构化输出如何让模型安全地“说人话”模型不能直接输出裸命令否则解析容易出错而且无法约束它使用危险命令。更稳妥的方案是让模型输出结构化的 JSON比如{ thought: 我需要先看看当前目录下有什么文件。, command: ls -la, wait: true }后端解析出command字段后再调用写入函数发送到 PTY。这样模型虽然“有手”但每次动手都需要经过一道“审批层”。我在这里加了一个敏感命令检测模块如果解析出的命令包含rm -rf /、mkfs、dd这类高危操作就直接拦截并要求模型给出替代方案。4.3 超时与反馈让模型学会“等一等”另一个实际问题是很多命令执行时间远大于模型单次推理时间。比如pip install可能要跑两分钟如果模型以为它已经“结束”了就会过早读取输出并做出错误判断。我的解决思路是给每次行动设置“等待策略”模型可以在结构化输出中声明wait为true意思是执行完这个命令后暂停一下等我主动推送“命令已完成”信号后再进入下一轮。如果命令是持续性的如启动一个 Web 服务模型就声明wait为false并定时查看输出。这个机制让 Agent 的行为接近真实的人类开发者有些活你盯着它完成有些活你让它后台跑着回头再看结果。5. 从零搭一个 OpenHands 风格 PTY 沙盒实操记录5.1 环境准备清单我建议在 Linux 服务器或本地 Linux 虚拟机上进行实验。Windows 下用 WSL2 也能跑但 Docker 内嵌和文件挂载会有一些额外配置。我的环境是组件版本 / 配置操作系统Ubuntu 22.04 LTSDocker24.0.xPython3.11.x核心依赖docker,ptyprocess,websockets,openai安装命令很简单sudo apt update sudo apt install -y python3 python3-pip docker.io sudo systemctl enable --now docker pip install docker ptyprocess websockets openai pandas为了不让后续构建镜像时的下载操作断断续续拖慢流程我提前拉好了基础镜像docker pull python:3.11-slim5.2 Docker 侧定制一个“Agent 专用操作间”官方 OpenHands 有专门构建的运行时镜像但我为了理解和定制选择从基础镜像自己搭建。Dockerfile 大致是这样的FROM python:3.11-slim # 避免交互式安装卡住 ENV DEBIAN_FRONTENDnoninteractive # 安装常用工具 RUN apt-get update apt-get install -y \ git curl wget vim nano htop net-tools dnsutils \ build-essential \ rm -rf /var/lib/apt/lists/* # 创建专用工作目录 WORKDIR /workspace # 预装 Python 工具链 RUN pip install --no-cache-dir \ pip setuptools wheel \ requests beautifulsoup4 pandas flask \ openai CMD [bash]注意这里没有加任何 Agent 代码容器只承担“执行环境”职责。Agent 大脑大模型调用、决策逻辑跑在宿主机上两者通过 PTY 桥接层对话。这是目前比较清爽的架构分层。5.3 核心代码让 Docker 容器里跑起一个可交互的 Shell我用 Python 的dockerSDK 创建容器并绑定 TTY这部分是整个实验的骨架。先放一段核心代码import docker import pty import os import select import time import json client docker.from_env() # 创建容器开启 TTY保持 stdin 开启 container client.containers.run( imageagent-sandbox:latest, command/bin/bash, stdin_openTrue, ttyTrue, detachTrue, working_dir/workspace, network_disabledFalse, mem_limit512m, nano_cpusint(0.5 * 1e9), environment{TERM: xterm-256color}, namesandbox-demo ) print(f容器已启动: {container.id[:12]})容器创建成功不代表可以直接交互。Docker 的 TTY 绑定是一种特殊的流式通道我需要用attach_socket()拿到字节流socket container.attach_socket( params{stdin: 1, stdout: 1, stderr: 0, stream: 1} )拿到 socket 后读写两端就打通了。不过更接近 OpenHands 的做法是用ptyprocess这类库去托管交互。我实际使用中觉着直接裸调 Docker SDK 也能实现基础通信但遇到命令回显、控制字符时会很痛苦。后来我换成pexpect系列的思路才相对稳定。5.4 WebSocket 桥让浏览器里的终端和后端互通为了直观看到 Agent“动手”的效果我又加了一层轻量 WebSocket Server。浏览器是用户的“观察窗口”后端是 Docker 沙盒的管理员。消息流的简化模型是用户/模型 - WebSocket - Python Bridge - Docker TTY - /bin/bash 用户/模型 - WebSocket - Python Bridge - Docker TTY - 命令输出用 Python 的websockets库实现import asyncio import websockets import json async def shell_loop(websocket, path): # websocket 收到消息后把用户命令写入容器 socket async for message in websocket: data json.loads(message) if data[type] command: cmd data[content] sock.send((cmd \n).encode(utf-8)) elif data[type] resize: rows data[rows] cols data[cols] # 调整容器 TTY 尺寸 container.resize(ttyTrue, widthcols, heightrows) start_server websockets.serve(shell_loop, 0.0.0.0, 8765) asyncio.get_event_loop().run_until_complete(start_server) asyncio.get_event_loop().run_forever()一开始我没写resize处理然后前端一旦调整终端宽高后端输出就错乱。后来加上这段逻辑后整个交互体验立刻顺畅了。如果你只是做 Agent 的自动执行任务不搞前端展示这段可以忽略但如果你想手动介入观察 Agent 行为就非常有用。5.5 接入模型让 Agent 真正开始“自主操作”环境搭好后最让人兴奋的一步就是接入大模型。我使用 OpenAI 兼容的接口把系统提示词设计成“你有权在 Linux 沙盒环境中执行命令请一步一步完成任务并通过 JSON 格式输出你的操作”。from openai import OpenAI client OpenAI( api_keyyour-api-key, base_urlhttps://api.openai.com/v1 ) def agent_step(user_task, history): response client.chat.completions.create( modelgpt-4o, messages[ {role: system, content: SYSTEM_PROMPT}, *history, {role: user, content: user_task}, ], response_format{type: json_object}, ) return json.loads(response.choices[0].message.content) def execute_command(cmd): sock.send((cmd \n).encode(utf-8)) # 读取一段时间内输出 time.sleep(1) output sock.recv(65536).decode(utf-8, errorsreplace) return output我设定的系统提示词非常关键里面会明确说“你是运行在 Ubuntu 容器中的自动化 Agent当前工作目录为 /workspace。所有输出必须使用 JSON 格式。” 提示词里也强调“一次只执行一条命令等待执行结果后再继续”。我第一次跑通完整循环时让它去“创建一个 Python 脚本计算斐波那契数列前 20 项并保存到文件”。模型先ls再cat fibonacci.py最后python3 fibonacci.py。看着终端里一行行命令自动跑起来那种“模型真的在操作电脑”的感觉特别强烈。6. 踩坑实录这五个问题差点让我摔键盘6.1 命令回显导致模型误读PTY 模式下输入的命令本身会被终端回显出来。比如我发送ls -la读回的数据里会同时包含这行命令和它的输出。模型如果没被告知这一点会误以为ls -la是某个程序打印的结果。解决方案有两种一是在读回数据时过滤掉刚才发送的命令行二是在 Prompt 里明确告诉模型“终端输出可能包含你输入的命令本身请忽略以$开头的回显行”。6.2 docker attach socket 读不到数据有时容器正常启动了但 attach 后读不到任何输出。原因通常是 Docker 的 attach socket 有缓冲或者容器里的 bash 没有分配 TTY。解决办法是在run时强制设置ttyTrue和stdin_openTrue并用rawTrue模式去读。还有一个细节容器内程序是行缓冲还是全缓冲也会影响实时输出必要时在命令前加stdbuf -oL强制行缓冲。6.3 超时问题模型等待过长或过早判断完成模型等待时间长了会浪费 Token短了会拿不到结果。我后来引入一个简单的轮询机制每 1.5 秒读一次输出如果连续两次读到的内容相同就认为命令已结束。对于已知的长命令如pip install我会单独给一个 30 秒上限并在超时后主动返回“命令仍在执行中输出如下……”避免模型误判。6.4 高危命令拦截与用户确认模型毕竟是概率模型即使提示词里严格约束也有概率写出危险命令。我的做法是在命令执行前先走一个黑白名单检查器。黑名单包括mkfs、dd if、shutdown、rm -rf /白名单包括ls、cat、python3、pip、git。不在白名单但不在黑名单的命令如果涉及写操作就弹一次用户确认。这个机制在生产环境尤为重要否则一次模型“鬼畜”可能让你所有容器数据付之东流。6.5 容器内没有 nginx、vim 等工具基础镜像都是精简版很多命令缺失。第一次跑任务时模型执行vim结果提示 command not found直接导致任务中断。后来我在镜像里预装了一批常用工具包括curl、wget、git、vim、nano、htop、net-tools、dnsutils以及常用的数据处理 Python 包。宁可镜像大一点也不要让 Agent 在任务中途被“缺工具”卡住。7. 更进一步OpenHands 里那些值得借鉴的细节设计7.1 事件流架构不只是命令的搬运工OpenHands 没有把“命令输入/输出”当成孤立事件而是把它纳入统一的事件流——包括用户的聊天消息、Agent 的思考过程、命令执行结果、文件变更通知等。每类事件都有时间戳和类型标记前端可以完整回放整个任务过程。这个设计对我影响很深我后来给日志模块加了结构化字段不仅存命令和输出还存模型当时的思考摘要。7.2 多 Agent 协作时的沙盒共享与隔离在更复杂的场景里你可能想让多个 Agent 分工协作比如一个负责写代码、一个负责跑测试、一个负责整理文档。每个 Agent 都应该有独立的沙盒环境但它们又可能需要共享某些产出物。OpenHands 在底层实现了可插拔的沙盒后端在我自己的实验里我用 Docker volume 来实现部分共享每个 Agent 容器都挂载同一个/shared目录但/workspace相互隔离。7.3 安全边界的扩展网络限制与文件系统权限有些 Agent 任务根本不需要外网那我就会在创建容器时加上networknone彻底阻断外联。如果任务必须访问特定 API就只开放白名单域名做法是启动一个代理容器或使用 iptables 规则。文件系统方面给容器挂载只读目录是个好习惯防止 Agent 误改重要数据。7.4 从“单步执行”到“长时任务”的升级最初我的沙盒只支持单条命令的问答式执行后来发现很多真实任务是需要十几分钟甚至更长的长时操作。一个可行方案是把 Agent 的执行状态持久化——将模型的历史消息、当前工作目录、环境变量和容器 ID 存入数据库。这样即使宿主机重启也能从断点恢复任务。这项改造虽然复杂但对真正落地 Agent 很有必要。8. 实操经验我在多次实验中总结出的优化技巧日志先行第一版我就开始完整记录每次命令输入、输出、Token 消耗和耗时后期排查问题时这些日志救了我非常多回。建议用 JSON Lines 格式每行一条事件记录方便后续检索和分析。用“任务完结信号”替代盲目 sleep读取输出不能全靠固定延迟最好在命令尾部附加一个独特标记比如echo __SANDBOX_DONE__后端读到这个标记就知道命令结束了。这个技巧能显著提升长命令判断的准确性。给模型提供“最小必要上下文”终端输出几万行时直接全部塞进模型既不经济也容易让模型迷失。我一般取最后几十行并让模型可以主动说output_tail来查看更早的日志或指定文件的某一段。超时重试要优雅模型调用偶尔会超时或返回异常这时候千万不要直接重发一模一样的问题因为 Agent 可能已经执行了一部分操作。先读取当前终端输出把状态同步给模型再让它决定下一步。容器不要每次重建如果 Agent 需要在多个任务之间保留环境比如已经安装好的包启动容器后尽量复用而不是每次新建。我通常用一个长期运行的“工作容器”通过重置/workspace内容来模拟新任务。小心 Prompt 中关于终端的误导性描述不同模型对“终端”“Shell”“命令行”的理解差异很大。有的模型会把python3误以为是执行 Python 代码块的标记。我后来在 Prompt 里写得非常具体“你需要输入 shell 命令而不是 Python 表达式。”9. 不止于 OpenHands这个沙盒还能做哪些事9.1 自动化数据抓取与清洗让 Agent 自己写爬虫脚本、跑数据清洗、输出结构化文件。因为沙盒里网络可控我可以限制它只访问指定站点同时能随时阻断异常外呼。整个过程不需要人盯非常省心。9.2 代码仓库的自动测试与修复我给容器挂载了一个 Git 仓库的副本让 Agent 负责运行测试、查看失败用例、修复代码、再次运行测试直到全部通过。由于每一步都能在终端被完整记录这个所谓的“自主修复机器人”在部分中小项目上效果惊人。9.3 作为教学演示工具如果你想给别人展示“大模型是如何使用工具的”这个 PTY 沙盒是绝佳的 demo 环境。前端模拟终端实时输出观众能直观看到模型在敲命令、看结果、调整方案理解成本比纯文本对话低得多。10. 我踩过坑之后最想提醒你的一件事如果只允许我分享一条经验我会说不要一上来就追求“全自动、无人看管”的 Agent先把它当成一个“需要远程遥控的实习生”数据权限、命令白名单、超时控制、输出日志都必须提前准备好。等你在沙盒里反复验证过它的行为边界再逐步放宽权限也不迟。PTY 沙盒表面上是技术组件但本质上它是“把判断交给模型、把行为锁在笼子里”的安全哲学。你想让 Agent 飞得更高就得先确保它脚下的笼子足够牢固。容器、TTY、命令解析、日志流控每一层看似基础却决定了整个 Agent 系统能走多远。这次从 0 到 1 的搭建过程让我彻底摆脱了“模型只会聊天”的刻板印象。下一步我打算把记忆模块接入沙盒让 Agent 在不同任务之间保留经验并尝试多容器并行协作。如果你也在折腾 Agent 开发或者对这套 PTY 沙盒方案有什么想法欢迎来交流。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价