ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

为AI Agent装上PTY沙盒:从命令执行到安全隔离的实战指南

为AI Agent装上PTY沙盒:从命令执行到安全隔离的实战指南 把大模型接上 API 不难难的是让它在真实环境里替你把活干完。做过 AI Agent 的同学应该都有同感模型再聪明如果只能输出文字本质上还是「纸上谈兵」。只要牵扯到读写文件、跑脚本、装依赖、启动服务这几件事就绕不开一个问题Agent 的「手」和「脚」到底长在哪里这个系列做到第 04 期重点就不再是模型本身的调教而是执行环境。这次说说我用 OpenHands 的思路给 Agent 装上一个真正的物理执行通道——PTY 沙盒。所谓 PTY就是伪终端可以让 Agent 像真人坐在服务器前一样往终端里敲命令、看输出、等结果。配合 Docker 沙盒既能放开手脚干活又能保证宿主机不被搞坏。这篇文章会把设计思路、最小可运行方案、集成时的关键细节、以及我踩过的坑全部摊开讲适合正在搭建 Agent 执行层的开发者参考。1. 为什么说 PTY 沙盒才是 Agent 的「四肢」1.1 Agent 从「只说不做」到「下场实操」的分水岭Agent 的形态分很多档。最简单的文档问答本质上是一个带检索的聊天框不需要执行任何命令。但只要你开始让 Agent 完成「任务型」工作——比如帮你在服务器上排查问题、把一个 Git 仓库跑通、或者修复一个自动化测试脚本——它就必须具备传统编程范式里「工具链」的能力。工具链可以分为三层第一层是调用外部 HTTP API这个最容易模型只要学会生成 JSON 格式的动作指令就行第二层是文件系统操作比如读写、删除、重命名需要给 Agent 暴露一个受限的文件访问接口第三层则是真正棘手的——任意命令行执行。为什么说第三层棘手因为它意味着你允许模型直接接触操作系统自由度极高但同时也代表着失控风险同样极高。我见过很多人一开始偷懒直接在宿主机上给 Agent 开了 os.system 权限结果没跑几天就翻车。模型在推理时可能因为一个误导性的指令把整个项目的依赖目录删掉也可能生成一个死循环脚本把系统资源占满。这时你才会意识到让 Agent 干活之前先得给它盖一间「独立操作间」。沙盒的意义就在于此它不是限制 Agent 的能力上限而是把破坏性的物理半径控制在一个可回收的容器里。1.2 PTY 和普通命令执行有什么本质区别很多人问过一个问题我要执行命令直接用 subprocess.run() 不就完了为什么要折腾 PTY答案是subprocess 解决的是「一次性交互」PTY 解决的是「持续会话」。举个具体的例子你在 Agent 的一条指令里执行 pip install numpy这是一个标准的一次性任务输入结束、等待输出、拿到退出码。但真实场景往往不这么简单。你可能需要 Agent 在同一个工作目录里先激活虚拟环境再 export 几个环境变量然后启动一个服务进程这个进程会持续输出日志,而 Agent 需要一边看日志一边决定下一步操作。这种连续的、有状态的、需要交互的终端会话subprocess 是模仿不来的。再往深一层说很多命令行程序在检测到自己不是终端时行为会发生变化。比如有些程序遇到非 TTY 环境会自动禁用彩色输出有些交互式调试器干脆拒绝启动。最典型的是 docker exec 本身它的 -it 参数就是用来申请一个伪终端。用 PTY 给 Agent 套上一层「假装是人在操作」的壳很多古怪问题就自然消失了。PTY 的另一个价值是统一化。无论 Agent 执行的是 bash、Python 交互式解释器还是进入一个 node 的 REPL底层都只是在这个伪终端上读写字节流。模型视角变得非常简单往终端里写字符串再从终端里读字符串整个世界都变成了一个「会回话的黑盒子」。1.3 为什么 OpenHands 要把运行时放进沙盒OpenHands 这类项目做得比较好的地方是把 Agent 的「大脑」和「身体」彻底拆开。大脑负责规划决策身体只负责执行。身体部分会生成一个运行环境里面预装好各类语言运行时、包管理器并通过一段协议与大脑通信。这种设计解决了几个实际问题。第一个是可重现性你换一台机器跑同一个任务环境完全一致不会出现「在我电脑上是好的在你电脑上就炸了」的经典悲剧。第二个是可回收性任务结束或者环境被搞坏直接销毁容器再起一个全新的成本极低。第三个是权限收敛容器里默认使用非 root 用户限制 CPU、内存、磁盘配额即使 Agent 把容器内部搅得天翻地覆宿主毫发无损。我当时照着 OpenHands 的思路自己搭了一套最小实现用 Docker 做隔离用 PTY 做交互通道最后把读写接口封装成 Agent 可调用的工具整个过程比想象中简单但细节比想象中多。下面把这套方案拆开来讲。2. 搭建一套最小可用的 PTY 沙盒2.1 组件选型Docker、tmux 和 Python 的 pty 模块搭建 PTY 沙盒之前先把基础组件选型想清楚。我最终用的三件套是Docker、tmux、Python 标准库的 pty 模块。为什么是这三样Docker 主要提供隔离和镜像管理能力。Ubuntu 镜像里自带了大多数基础工具再配合我自定义的 Dockerfile把 Python、Node.js、Git、curl 这些常用执行环境一次性装好。镜像构建出来后每次启动容器都是一个干净的沙盒实例。相比直接在本机跑命令这一层隔离是安全底线。tmux 解决的是「会话持久化」问题。容器其实可以一直开着但终端连接会断开尤其是 Agent 在长时间任务中途我需要随时把输出捞回来。tmux 提供了一个后台 session即便终端读写连接暂时中断或者 Agent 内部超时tmux 里的进程仍然在稳定运行。这相当于给 Agent 的执行环境加了一个缓冲区后续排查、断点续跑都非常方便。Python 的 pty 模块则是实现 PTY 读写的底层工具。它允许我在 Python 进程里创建一对伪终端设备一个 master一个 slave。write 到 master 的内容会像真实键盘输入一样出现在 slave 端slave 端程序产生的输出又会回到 master 端供我读取。整个链路是Agent - Python 程序 - PTY master - tmux - Docker 容器内的 shell - 具体命令。之所以不用 node-pty 而用 Python纯粹是因为这个项目其他部分已经用 Python 写了减少跨语言的沟通成本。如果你用 Node 技术栈node-pty 同样没毛病抽象逻辑完全一致。2.2 自定义镜像准备先准备镜像。这一步的核心是「让容器里有一个可交互的 bash 常用工具」同时禁止 root 操作。下面是我的 Dockerfile实际用下来够稳。FROM ubuntu:22.04 # 避免 apt 安装时交互卡住 ENV DEBIAN_FRONTENDnoninteractive RUN apt-get update apt-get install -y --no-install-recommends \ bash \ curl \ git \ vim \ python3 \ python3-pip \ nodejs \ npm \ tmux \ iputils-ping \ ca-certificates \ rm -rf /var/lib/apt/lists/* # 创建一个非 root 用户后续所有命令都以此用户身份执行 RUN useradd -m -s /bin/bash agent USER agent WORKDIR /workspace CMD [/bin/bash]注意几个细节。第一DEBIAN_FRONTENDnoninteractive 很重要不设的话某些 apt 包安装时会弹交互式配置界面导致镜像构建卡死。第二特意创建了 agent 用户避免默认 root 身份。容器里用 root 的风险在于有些脚本会顺手修改系统级目录一旦 Agent 拿到这个权限沙盒隔离的意义就削弱了一半。第三WORKDIR 放在 /workspace后面所有命令都在这个目录里有据可循。构建镜像的命令docker build -t agent-sandbox:latest .构建时间取决于网络速度通常几分钟。由于后面频繁要起容器建议一次构建多次复用。2.3 用 Python 绑定 PTY镜像准备好之后重点来了让 Python 程序能够往容器内的 bash 写入命令并接收输出。我这里不用 docker attach 的方式而是用一个更简单粗暴的思路——在容器内部启动一个 tmux session然后用 Python 的 pty.fork() 创建一个伪终端在这个伪终端里启动 tmux attach从而把控制权交给 Python 程序。听着绕但实现出来非常简洁。import os import pty import time import select def make_pty(rows30, cols120): 创建一个伪终端并返回 master fd pid, master_fd pty.fork() if pid 0: # 子进程在伪终端中执行 tmux attach os.environ[TERM] xterm-256color os.execvp(tmux, [tmux, attach, -t, agent-session]) return master_fd这个函数的核心在 pty.fork()。调用后父进程返回 master_fd子进程则被绑定到 slave 设备上。如果子进程执行 execvp 的命令它的标准输入输出全部都会被重定向到伪终端里。这里的 tmux attach 是为了连接到一个已经存在的 tmux session这样即使 Python 进程意外退出容器内的 tmux 不会终止。启动容器时你需要保证有一个 tmux session 在跑。可以在容器启动命令中直接开docker run -d --name agent-pty \ -v agent_workspace:/workspace \ agent-sandbox:latest \ tmux new-session -d -s agent-session这样容器起来后tmux 已经在后台运行。Python 程序随后进入容器用同一套伪终端机制 attach 上去。2.4 读取与写入的封装有了 master_fd剩下的问题就变成两个怎么往里面写怎么从里面读。写很简单os.write 即可。但读不能随便 os.read因为终端输出可能没完没了如果没有合适的等待逻辑一个 read 可能直接把主流程卡死。我一般用 select 模块设定超时在固定时间内没有新数据就认为输出结束。def write_command(master_fd, command): os.write(master_fd, command.encode(utf-8) b\n) def read_output(master_fd, timeout5): output b end_time time.time() timeout while time.time() end_time: r, _, _ select.select([master_fd], [], [], 0.5) if r: try: chunk os.read(master_fd, 4096) except OSError: break if not chunk: break output chunk else: # 连续 0.5 秒无新数据默认输出已经稳定 break return output.decode(utf-8, errorsreplace)这个封装虽然简单但已经足够干活。这里最关键的是「超时后截断」的思路而不是一直等到 EOF。因为终端本身是持续存在的理论上永远不会有 EOF如果你傻等就永远回不来。提示实际生产环境里建议把 timeout 做成参数并且根据命令类型动态调整。比如 sleep 之后再输出大文件内容的命令5 秒可能不够但单纯的 ls 之类1 秒都多。3. 把 PTY 沙盒接入 Agent 运行循环3.1 Agent 工具层的接口设计PTY 沙盒在底层提供了读写能力但 Agent 并不能直接操作 master_fd那样太危险也太底层次。我们需要在上层封装一层「工具接口」让模型通过结构化的 JSON 指令调用来触发。我的做法是定义两个核心工具BashRun 和 BashInterrupt。BashRun 接受 command 参数模型传入终端命令字符串工具负责写入 PTY、等待输出、返回结果。BashInterrupt 用于中断正在运行的长时间命令底层是向 master_fd 发送 CtrlC 字节。TOOL_SCHEMA { name: bash, description: 在沙盒终端中执行命令并返回输出结果, parameters: { type: object, properties: { command: { type: string, description: 要执行的完整命令 } }, required: [command] } }模型一旦生成一个包含 command 字段的调用请求我们的执行引擎就把它翻译成一次 PTY 写入。整体调用链如下模型输出带工具调用的文本 - 解析器识别 bash 工具 - 调用 write_command - 轮询 read_output - 把输出拼接回模型的对话上下文。这一步是整个执行循环的闭环。3.2 会话状态与工作目录的保持PTY 沙盒有一个很大的优势也是一个大坑它有「记忆」。这里的记忆不是模型记忆而是终端的工作状态。你在第一条指令里执行 cd /workspace/project第二条指令再跑 ls看到的就是新目录下的文件因为 shell 会话本身没断。这对 Agent 的连续操作非常有利你可以让 Agent 先 cd 到项目目录再 git pull再装依赖每一步都基于上一步的结果。但状态保持也意味着风险如果某条命令把环境变量搞坏了或者把目录切到了一个奇怪的地方后续所有命令都会受到影响。我建议在每次 BashRun 的 prompt 前缀里显式带上当前路径提示。比如强制命令前缀PROMPT_PREFIX PS1[agent-sandbox]\\w ; 这样 Agent 每次拿到的输出第一行都带着当前工作目录模型就能判断自己现在在哪儿减少路径混乱导致的错误。实测下来这个细节很值越长的任务越明显。3.3 流式日志与异步输出同步等待模式有一个明显短板长任务期间Agent 拿不到中间进度。如果命令是编译大型前端项目或者跑一个需要十几分钟的测试脚本5 秒超时直接截断会把进程搞成僵尸任务。我的解决方案是把 read_output 改成异步回调模式。PTY 读取线程持续从 master_fd 读数据每读到一块就回调给上层处理上层再把日志实时添加到 Agent 的事件流里。这样模型可以在输出到达时逐步感知进展而不是一次性拿一个可能被截断的最终快照。实现上只需要开一个后台线程循环 select 读数据遇到超时则挂起等待不退出线程。import threading class PTYReader(threading.Thread): def __init__(self, master_fd, callback): super().__init__(daemonTrue) self.fd master_fd self.callback callback self.running True def run(self): while self.running: r, _, _ select.select([self.fd], [], [], 0.5) if r: try: data os.read(self.fd, 4096) except OSError: break if data: self.callback(data.decode(utf-8, errorsreplace))异步之后Agent 可以在命令执行中途做出响应比如看到测试失败日志后立即中断命令而不是傻等结束。这个操作让整个 Agent 的行为方式从「发号施令-等结果」进化为「感知-判断-调整」的实时闭环。4. 实战中的典型问题与排查记录4.1 阻塞读到天荒地老第一次把 PTY 接入 Agent 时我遇到的最恶心的问题就是阻塞。代码跑到 os.read 之后程序纹丝不动过了 10 分钟都没返回。原因很简单终端里有一个正在运行的交互式程序它会持续输出或者等待输入我的读取线程永远等不到数据结束。这个问题的本质是「你无法知道终端输出什么时候算完」。后面我改成了「静默判断」逻辑如果连续 1.5 秒没有新数据就认为当前命令的输出已经稳定可以返回。这个经验值需要根据场景调整网络命令或数据库查询可能波动更大建议做成可配置项。另外还要给每个 BashRun 调用加一个硬性超时比如 120 秒。一旦超时就主动向 PTY 发送 CtrlC并收回已经获得的输出。这样即使命令卡死Agent 也能从上一次状态继续推进而不是永远挂起。4.2 ANSI 转义序列污染输出终端输出的内容不只有纯文本还有大量控制字符。颜色、光标移动、删除行、清屏全部通过 ANSI 转义序列实现。这些序列在真实终端里人类看不到但直接被文本模型吃掉后会形成噪音。模型可能在判断输出是否成功时被一串\x1b[31m干扰。解决方法是做一层清洗。把常见 ANSI 序列剥掉再考虑输出是否需要保留行尾信息。我一般用正则剥控制序列再把\r\n统一成\n。import re ANSI_RE re.compile(r\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][0-9A-Z]) def clean_terminal_output(text): text ANSI_RE.sub(, text) text text.replace(\r\n, \n).replace(\r, \n) return text清洗后输出会变得很干净Agent 也更容易从文本中提取关键信息。但要提个醒如果你未来要做 GUI Agent或者需要识别终端尺寸、光标位置等信息清洗函数就得做更细的保留处理不能一刀切。4.3 容器重启后工作目录丢失跑长任务时可能会遇到 Docker 容器因为内存超限被 OOM Kill或者容器被手动重启。容器重启后tmux session 没了Agent 与终端之间的连接断了之前 cd 进去的目录、设置的环境变量、后台启动的进程全部归零。对于 Agent 来说它感觉自己「失忆了」。这里有两个应对策略。第一是尽可能把「状态」显式化让 Agent 的命令前缀里带 cd /workspace/project 这种绝对路径而不是依赖相对路径。第二是持久化关键数据到文件比如用一个 state.json 记录当前工作目录、最近修改的文件、当前分支等信息启动容器后先读取这个文件再让 Agent 快速恢复现场。这些分支状态的维护比让 Agent 每次重新探索要可靠得多。另外Docker 容器自身的数据卷要挂好。我在 docker run 里加了-v agent_workspace:/workspace这样容器销毁后工作区文件依然在磁盘上重新起一个容器还能继续操作。没有这一步每次重启都是亡羊补牢。4.4 安全红线不能忽视的权限收敛沙盒最大的价值就是安全隔离但如果你不加限制沙盒也会被击穿。我见过有人赋予 Agent 完整的容器 root 权限还挂了宿主机的 Docker socket结果从容器内直接映射出了宿主机的文件系统。这种操作基本等于没隔。至少要守住几条底线第一容器内不要挂宿主关键目录只挂你需要让 Agent 访问的数据卷第二不要映射 /var/run/docker.sock 到容器里否则 Agent 等于有了宿主机的控制权第三给容器设置资源限制尤其是内存和 CPU 配额防止脚本失控拖垮宿主机。docker run -d --name agent-pty \ --memory2g \ --cpus2 \ --pids-limit512 \ -v agent_workspace:/workspace \ agent-sandbox:latest \ tmux new-session -d -s agent-session--pids-limit 这个参数很多人忽略但它能限制容器内的进程总数防止 Agent 通过 fork 炸弹之类的手段搞垮整个环境。安全这块宁可一开始收得紧也不要等出问题再补。4.5 常见问题速查表以下是我在整个搭建过程中经常遇到的问题整理成一张速查表方便你直接对号入座。问题表现可能原因解决方案读取输出时程序卡死终端没有 EOF输出一直没停改用 select 超时机制不要用阻塞 readAgent 看到一堆乱码颜色ANSI 转义序列未清洗用正则剥除控制序列统一换行符命令执行正常但 Agent 说没输出超时设得太短输出被截断增大静默判断窗口或改用异步流式读取容器重启后 Agent 路径全乱tmux 会话丢失状态没恢复持久化 state.json启动时重新读取Agent 能用 root 操作宿主机文件Docker socket 挂载或 root 用户禁止挂载 docker.sock使用非 root 用户命令被莫名中断硬性超时设置过短排查每条命令的耗时设定差异化超时5. 从单机沙盒再往前跨一步5.1 多容器与多 Agent 协作到现在为止这套 PTY 沙盒只支撑了一个 Agent 在一个容器内干活。如果任务是并行部署多个 Agent比如一个 Agent 写代码、一个 Agent 在测试环境跑验证、一个 Agent 负责部署那就要考虑多容器管理的问题。一个粗略的方案是给每个 Agent 分配一个独立容器容器 ID 与任务 ID 绑定Agent 之间不直接共享文件系统而是通过消息队列或者共享数据卷进行有限的协作。这样做的好处是互相隔离一个 Agent 把容器搞崩了不影响其他任务。缺点是需要一个调度器统一管理生命周期。OpenHands 里的 runtime 调度逻辑就是这个路子你可以先用最简单的「任务地图」来记账任务 ID - 容器 ID - 当前状态。单个沙盒变成沙盒池之后眼前的问题就从 PTY 变成编排了。状态同步、容器回收、资源配额、任务调度每一个展开都是大工程。我的建议是先跑通单容器的最小闭环再慢慢往上叠复杂度。5.2 让沙盒额外具备 GUI 与文件编辑能力终端能力是 Agent 的执行基础但并不是全部。很多任务需要直接编辑文件。你可以选择让 Agent 通过 bash 调用 vim 或 sed 来修改文件但这种方式在终端输出里很难精确反馈「改了什么、改对没有」。更可靠的方式是另外封装一个文件编辑工具用语法树级别的操作做到可控修改。如果任务涉及到浏览器自动化比如打开页面、点击按钮、抓取数据那么还需要在沙盒里装浏览器环境和无头浏览器工具。这时 PTY 沙盒就演变成了一个完整的虚拟工作站。这个方向我不展开但想提醒一点Agent 的物理手脚是可以不断扩展的而 PTY 只是连接这些手脚的神经管线。管线一通后面挂什么工具都不愁。5.3 最后的小技巧给 Agent 留一个逃生舱最后分享一个我个人的小习惯。我会在 PTY 里常驻一个特殊的逃生命令比如在 bash 里定义agent_abort为强杀当前进程并回到干净的 shellecho alias agent_abortkill -9 -1; cd /workspace; exec bash ~/.bashrc当 Agent 把容器状态搞成一团乱麻时通过这个命令可以快速「重开」当前终端会话再配合容器级别的重启能把损失控制到最小。这个细节看着笨但在实际跑长任务时救过我好几次。从最底层的 PTY 连接到上层的 Agent 工具调用这套执行链路的核心就是四个字稳定可控。模型负责聪明沙盒负责兜底终端负责承重。一个能真正放开手脚干活的 Agent从来不是靠模型单方面强大而是靠这套隐藏在执行层背后的物理基础设施撑着。我的建议是别急于堆功能先把 PTY 这一层跑透后面的路自然就顺了。
返回列表