Agent 执行器设计笔记:隔离命令、代码与进程
echo-agent 前身为 2025 年 11 月启动的个人助理项目 fubot,最初面向长期陪伴型个人智能体,围绕认知记忆、上下文延续、用户偏好沉淀、任务闭环与持续自我优化展开。随着真实场景迭代,项目逐步形成多入口接入、统一事件模型、消息总线、Agent Loop、多模型抽象、工具调用、MCP 接入、任务调度、权限审批、运行轨迹、长期记忆和受控自演进等能力。目前已支持微信、QQ、CLI、Gateway、Webhook、Cron 等入口,服务用户超过 20 万、累计下载超过 50 万,是面向长期运行、记忆增强和可持续成长智能体的开源 Agent Runtime。

你让 Agent “帮我把测试跑一下,顺手修掉失败用例”。
一个普通脚本会直接拼出 pytest 或 npm test,然后在当前机器上执行。看起来很自然,直到你发现测试脚本会写缓存、安装依赖、访问网络,甚至启动一个长期占用端口的开发服务。
对工程师来说,问题从来不是“模型会不会生成命令”,而是:这条命令在哪里运行、继承谁的权限、能访问哪些文件、能不能联网、失败后留下什么状态。
这就是执行器要解决的问题。
问题入口
工具系统解决的是“模型可以调用什么能力”。执行器解决的是“这些能力在哪里、以什么边界运行”。
如果把命令执行直接写在 ShellTool 里,短期看很快:工具收到 command,本地 subprocess 一跑,stdout 返回给模型。但这样会把四件事揉在一起:工具协议、运行环境、安全策略和部署形态。
结果是系统很难回答几个关键问题:
| 问题 | 直接写在工具里的后果 |
|---|---|
| 本地能跑,云端怎么跑 | 工具和宿主环境强耦合 |
| 需要隔离时怎么办 | 每个工具都要重写沙箱逻辑 |
| 是否允许联网 | 策略散落在工具内部 |
| 凭证怎么注入 | 工具容易读取全局环境变量 |
| 后台进程谁清理 | 一次调用结束后状态不可见 |
执行器不是为了让 Agent 更敢执行命令,而是为了让每一次执行都有明确的副作用半径。
为了不停留在抽象层面,下面以 echo-agent 的实现为例。它把 ShellTool、CodeExecTool、ProcessTool 和真实运行环境之间拆出一层 executor:工具只表达“我要执行什么”,executor 决定“在哪里执行、怎么隔离、如何返回结果”。
执行契约
echo-agent 的执行器代码位于 echo_agent.agent.executors。基础契约由两个数据结构承担:ExecRequest 和 ExecResponse。
ExecRequest 表示一次执行请求,包含命令、工作目录、环境变量、超时、标准输入和凭证。ExecResponse 表示执行结果,包含 stdout、stderr、返回码、耗时、执行器名称和审计 ID。
这组契约的意义不在于字段多,而在于它把“执行命令”从某个具体 subprocess 调用中抽离出来:
@dataclass class ExecRequest: command: str cwd: str = "" env: dict[str, str] = field(default_factory=dict) timeout: int = 30 stdin: str = "" credentials: dict[str, str] = field(default_factory=dict) @dataclass class ExecResponse: success: bool = True stdout: str = "" stderr: str = "" return_code: int = 0 duration_ms: int = 0 executor: str = "" audit_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
所有执行器继承同一个 BaseExecutor,对工具层暴露统一的 execute()、setup() 和 teardown()。
class BaseExecutor(ABC): name: str = "base" @abstractmethod async def execute(self, request: ExecRequest) -> ExecResponse: ... @abstractmethod async def setup(self) -> None: ... @abstractmethod async def teardown(self) -> None: ...
这个抽象让工具层不必知道底层是本地进程、临时沙箱、Docker 容器,还是远程 SSH。它只需要构造一次执行请求,剩下的边界问题交给执行器。

这里还有一个容易被忽略的设计:executor 是长期存在的。create_executor() 会根据配置创建 local、sandbox、container 或 remote 执行器,并让它随 AgentLoop 复用。
原因很现实。沙箱目录、容器和远程连接的初始化都有成本。如果每次工具调用都重新 setup,Agent 会变慢,也会留下更多临时状态。长期存在意味着 setup 成本只付一次;同时也要求 executor 在停止时通过 teardown() 释放资源。
环境选择
执行器选择不是性能偏好,而是信任模型。
echo-agent 当前提供四类执行器:
| 执行器 | 运行位置 | 适合场景 | 主要风险 |
|---|---|---|---|
| LocalExecutor | 当前宿主机 | 个人 CLI、受信任工作区、外部已有隔离 | 直接影响真实文件和进程 |
| SandboxExecutor | 临时目录副本 | 默认隔离、运行测试、避免污染原工作区 | 隔离强度有限,仍依赖宿主环境 |
| ContainerExecutor | Docker 容器 | 不可信代码、依赖隔离、镜像化环境 | 需要 Docker,生命周期更复杂 |
| RemoteExecutor | SSH 远程主机 | 部署、远程计算、集中资源管理 | 凭证、审计和结果可信度要求更高 |
LocalExecutor 最直接。它会在宿主机上用 asyncio.create_subprocess_shell 执行命令。执行前会检查网络策略,如果 network_policy == "deny" 且命令命中网络行为,就返回失败结果。
它适合受信任的本机场景,但不适合公共入口或多租户环境。因为它继承的是当前用户权限,误操作会直接落在真实文件系统和真实进程空间。
SandboxExecutor 会在 sandbox root 下创建临时目录,并把 workspace 复制进去。复制时会忽略 .git、__pycache__、.pytest_cache、.ruff_cache、.venv、node_modules、data/logs 等目录,减少成本,也避免把不必要的大目录带入沙箱。
执行时,它会把 HOME 和 TMPDIR 指向沙箱工作目录,并限制 PATH。如果请求的 cwd 不在源 workspace 下,会回退到沙箱 workdir。这使得命令即使传入外部路径,也不容易跳出受控目录。
ContainerExecutor 通过 Docker 创建长期容器,挂载 workspace 到 /workspace,再通过 docker exec 执行命令。如果网络策略是 deny,容器使用 --network none;否则使用 bridge。Docker 不存在时,setup 会明确抛出错误。
RemoteExecutor 通过 SSH 执行远程命令。它会构造 StrictHostKeyChecking、ConnectTimeout、key path 等参数,并对 cwd、环境变量和凭证做 shell quoting。书稿里特别提到测试覆盖了带空格 cwd、特殊字符 env、密钥路径和连接参数,这说明远程执行关注的不只是“能 ssh”,还包括命令拼接安全。

同一个 pytest,在不同环境里的意义完全不同。在沙箱副本中运行,主要影响临时目录;在真实 workspace 中运行,可能写缓存、更新快照或生成报告;在远程机器上运行,影响范围可能跨出本机和当前用户。
执行环境决定副作用半径。不能只问命令能不能跑,还要问它跑错时会影响哪里。
工具适配
执行器不是替代工具层安全检查。它和工具层是前后两道边界。
ShellTool 的工具名是 exec,参数包括 command、timeout 和 cwd。它在调用 executor 之前会先做几类检查:内置危险模式、allowed/blocked 列表、安全策略评估,以及审批上下文。
例如,书稿中提到内置阻断模式会覆盖 /etc/passwd|shadow|sudoers|gshadow、根目录破坏性删除、文件系统格式化和系统关机等行为。之后还会检查 allowlist,未命中的命令会被拒绝。
cwd 也不能随意指向系统目录。ShellTool 会解析传入目录,把相对路径映射到 workspace 下,再调用 cwd 策略检查。违反边界时,工具返回类似 cwd is outside workspace 的失败结果。
最后,它才把命令交给 executor:
response = await executor.execute(ExecRequest(
command=command,
cwd=cwd,
timeout=timeout,
env={"WORKSPACE": workspace},
credentials=ctx.credentials if ctx else {},
))
这段最小代码说明了三件事。第一,工具仍负责理解自己的参数和风险。第二,执行上下文里的凭证按请求传入,而不是让工具读取全局密钥。第三,真实执行统一落到 executor,便于切换运行环境。
CodeExecTool 的边界类似,但重点不同。它的工具名是 execute_code,支持 Python、JavaScript 和 Bash 三种 runner:python3 -、node、bash。初始化时可以限制允许语言;模型请求不允许的语言会直接失败。
这里的语言 allowlist 是一个很朴素但有效的边界。用户让 Agent 写一段 Python 数据处理脚本,并不等于授权它运行 Bash 安装脚本;允许执行 JavaScript,也不等于允许它调用任意系统命令。语言本身就是风险信号。
代码片段通过 stdin 传入 executor,而不是拼接进 shell 命令字符串:
await executor.execute(ExecRequest(
command="python3 -",
cwd=str(workspace),
timeout=timeout,
stdin=code,
env={"WORKSPACE": str(workspace)},
credentials=ctx.credentials if ctx else {},
))
这个细节很重要。长代码如果塞进命令字符串,会引入 quoting、转义和截断问题。stdin 更接近“把代码交给 runner”,也更容易统一记录和审计。
后台进程
一次性命令退出后,返回码和输出就能描述大部分状态。后台进程不一样。它会继续运行,继续占用端口、写日志、消耗资源,甚至在当前回答结束后仍然影响系统。
echo-agent 用 ProcessTool 管理后台进程。它的工具名是 process,支持四个动作:start、list、poll、stop。
启动前,ProcessTool 同样会调用 shell 安全策略。启动成功后,进程记录在模块级 _PROCESSES 中,保存 process 对象、启动命令、开始时间、stdout/stderr 缓冲等信息。系统会异步收集输出;poll 返回最后一段 stdout/stderr;stop 先 terminate,超时后 kill。

这说明后台进程不是“长一点的 shell 命令”,而是生命周期对象。
一个生产级 Agent 如果能启动开发服务器,就必须能回答:它启动了什么、属于哪个会话、监听哪个端口、日志在哪里、现在是否存活、什么时候应该停止、停止失败怎么办。
否则,Agent 的行动会在宿主机里留下不可解释的状态。你看见端口被占用,却不知道是哪次任务启动的;你看见日志滚动,却不知道它属于哪个用户;你让 Agent 停止,它也不知道该停哪一个进程。
会启动后台进程,只说明 Agent 有运行时能力;能管理后台进程,才说明它有运行时边界。
输出治理
命令和代码执行会产生大量输出。测试日志、构建日志、安装日志、后台进程日志都可能很长,也可能包含密钥、连接串、用户路径和隐私数据。
所以输出截断不是简单的体验优化,而是上下文治理和安全机制。
echo-agent 的 ShellTool 和 CodeExecTool 都会截断 stdout/stderr。原因有两个:一是保护模型上下文窗口,二是避免把大量无关或敏感输出原样灌回对话通道。
更合理的输出策略应该分两层:给模型的是摘要、退出码、关键错误、少量上下文和日志引用;给工程师追溯的是完整原始日志或审计记录。
这也是 ExecResponse 中 return_code、duration_ms、executor、audit_id 有价值的地方。一次执行不是一段字符串输出,而是一条可追踪的工作单元。
如果后续要把执行器做得更生产化,还需要继续补齐产物管理:哪些文件被创建或修改,哪些报告需要返回给用户,哪些沙箱产物要同步回真实 workspace,哪些临时文件应该清理,哪些输出需要脱敏。
生产可用性
判断一个 Agent 执行器是否接近生产级,不能只看“能不能跑命令”。更硬的检查项应该是这些:
| 检查项 | 可验证标准 |
|---|---|
| 统一契约 | 命令、cwd、env、stdin、timeout、credentials 都进入 ExecRequest |
| 环境隔离 | 至少能区分本地、沙箱、容器或远程执行 |
| 生命周期 | executor 有 setup/teardown,后台进程有 start/list/poll/stop |
| 文件边界 | cwd 不能越过 workspace 或策略允许范围 |
| 网络策略 | 工具层和 executor 层都尊重 network policy |
| 凭证注入 | 凭证从执行上下文按请求传入,不读取全局密钥 |
| 输出治理 | stdout/stderr 有截断、摘要和完整日志分层 |
| 审计追踪 | 每次执行有 executor 名称、耗时、返回码和 audit_id |
| 测试约束 | 覆盖 allowlist、网络 deny、stdin、cwd quoting、env quoting 和审批路径 |
这些标准背后的共同原则是:模型可以提出行动,但不能直接拥有宿主环境。
执行器把自然语言驱动的意图变成受控物理动作。它要处理文件系统边界、网络边界、进程边界、凭证边界和资源边界。哪怕模型判断错了,错误也应该被限制在工作区、副本、容器或受控远端内,而不是直接扩散到最敏感环境。
小结
执行器是 Agent 架构中最接近真实风险的位置。
工具层告诉模型“你可以请求什么能力”,执行器层决定“这项能力以什么身份、在哪个环境、在什么限制下发生”。这个区分越清楚,系统越容易根据任务风险切换执行环境,也越容易审计和复盘。
echo-agent 的 executor 抽象把本地、沙箱、容器和远程执行统一到同一套 ExecRequest / ExecResponse 契约下,再由 ShellTool、CodeExecTool 和 ProcessTool 分别承接一次性命令、代码片段和后台进程。
这不是把执行命令做复杂,而是承认一个现实:Agent 一旦能行动,风险就不再停留在文本里。真正的工程设计,必须在行动落地之前先画出边界。
(全篇完)
本文为 echo-agent 设计笔记系列第 14 篇。项目源码已开源至 GitHub。如果你对工业级 Agent 的工程落地感兴趣,欢迎加入技术交流群参与日常讨论。下一篇我们将探讨 《Agent 的安全边界:从可见性到执行控制》,敬请期待。
更多推荐

所有评论(0)