Agent 执行命令为什么总卡死:一行 exec 签名底下的八个坑
备选标题:《我给 Agent 写了个 exec 工具,然后它把自己挂死了》《两个"10 秒"合并成一个 timeout,是这类事故的起点》
上一篇我们把 Agent 主循环压到了 218 行,其中run_command只有 30 行——一个拒绝清单加一个subprocess.run。
那 30 行能跑 demo,但撑不住真实使用。这一篇专门讲那个"多出来的部分"里最不起眼也最容易翻车的工具:exec。
它的签名可以只有一行:
defexec_command(cmd:str)->str:...但这一行底下藏着一个终端模拟器加一个进程管理器。我踩过的坑包括:一条npm run dev让整个 Agent 卡死四十分钟、一次cargo build吐了 12 万行进度条把上下文烧掉一半、sudo在管道里不是报错而是静默挂住、超时杀掉的进程第二天还在占着 3000 端口。
这篇按一次命令的生命周期走一遍,把八个决策点、四条输出纪律、两个容易混淆的"10 秒"全部拆开讲。
一、签名一行,决策点八个
先看这张表——每个决策点我都配了真实故障模式:
| 决策点 | 选项 | 为什么是坑 |
|---|---|---|
| shell 种类 | 用户默认 shell / 指定 shell | 语法差异直接决定命令能否跑通 |
| cwd | 每命令显式给 / 沿用会话的 cwd | 相对路径全依赖它;cd串命令会隐式改变它 |
| env | 继承 / 白名单 / 定向注入 | 全继承可能泄密(凭据进模型上下文);太干净则命令跑不起来 |
| 超时 | 无 / 默认值 / 每命令可调 | 无超时 = 一条 hang 命令烧完整个 turn |
| 输出上限 | 无上限 / 字节或 token 上限 | 无上限 = OOM 与上下文爆炸双杀 |
| 流式回传 | 结束后一次性回传 / delta 流 | 流式决定用户观感与审批时机 |
| 可否中断 | kill 直接子进程 / kill 进程组 | 只杀直接子进程,孙进程变孤儿 |
| 交互能力 | 一次性调用 / 持久会话 | 没有持久会话,交互式命令直接死锁 |
八个里我最初只想到了两个(超时、输出上限)。剩下六个全是在事故现场补的。
Codex 的做法很值得抄:它没把这些决策藏进实现里,而是把大半直接暴露成了工具参数。exec_command的 schema 逐项对应上表:
| exec_command 参数 | 对应决策点 | schema 描述原文 |
|---|---|---|
cmd(必填) | 命令本体 | Shell command to execute. |
workdir | cwd | Working directory for the command. Defaults to the turn cwd. |
tty | PTY vs 管道 | True allocates a PTY for the command; false or omitted uses plain pipes. |
yield_time_ms | 等待/超时 | Defaults to 10000 ms; effective range is 250-30000 ms. |
max_output_tokens | 输出预算 | Defaults to 10000 tokens; larger requests may be capped by policy. |
shell(可选启用) | shell 种类 | Defaults to the user’s default shell. |
表里没单列 env,但它在运行时请求里带着(UnifiedExecRequest有env字段)——env 属于"运行时关心、模型不直接编辑"的决策。自研 Agent 若把 env 暴露给模型,要守住一条底线:凭据类变量(token、密钥)不许经由模型的手往返——它们既会进模型上下文,也会进会话日志(工具调用会被原样记录)。通行做法是白名单加会话级注入:模型声明"这条命令需要代理",注入发生在运行时,值从不回到上下文里。
cwd 这一项还有个容易忽视的取舍:用 workdir 参数,还是让模型自己拼cd x && cmd。前者让 cwd 保持无状态——每条命令的工作目录显式声明,命令之间互不污染;后者会隐式改变会话的"当前目录",模型自己都记不住现在站在哪。我早期就吃过这个:模型先cd /tmp/build,三条命令之后它以为自己在项目根目录,结果把文件写到了/tmp。
再看返回语义。exec_command 的描述只有一句话:“Runs a command in a PTY, returning output or a session ID for ongoing interaction.”——返回值只有两种形态:要么完整输出(命令已结束),要么 session_id(命令还活着)。
配套的是第二个工具write_stdin:向已有会话写入字符(空字符串 = 纯轮询不写入),yield_time_ms控制本次等待(写入默认 250 ms、上限 30 s;轮询默认 5 s 起步、上限 300 s),max_output_tokens控制带回的输出预算。
为什么拆成两个工具,而不是一个带 timeout 参数的 exec?
因为长命令真正的困境不是"等多久",而是"等的时候干不了别的"。会话化把命令的生命周期和一次工具调用的生命周期解耦:模型拿到 session_id 后可以决定继续等、先去改代码、或者收掉它。
out=exec_command("npm run dev")# 启动开发服务器ifout.session_id:# 命令还活着:服务器不会自己退出log=write_stdin(out.session_id,chars="",yield_time_ms=5000)# 轮询 5 秒assert"listening"inlog.output# 等到就绪信号再继续write_stdin(out.session_id,"\x03")# Ctrl-C 收掉会话我第一次写 exec 时用的是subprocess.run(cmd, timeout=30)。跑npm run dev的结果是:等满 30 秒、抛 TimeoutExpired、进程被杀、模型拿到一句"执行失败",然后它又试了一次。三次之后步数用尽,任务结束,用户看到的是一句"已达步数上限"。
它不是模型笨,是工具的形状错了。
二、PTY 还是管道:一个参数决定命令的"人格"
先记住一个事实:大量 CLI 的行为取决于 stdout 是不是 tty。--color=auto、进度条、交互提示,全都靠isatty检测切换。
所以"给不给 PTY"不是性能取舍,是行为取舍:
| 管道(pipe) | PTY | |
|---|---|---|
| 交互式命令(vim、sudo 密码提示) | 等不到提示,直接死等 | 正常交互 |
| 进度条与颜色 | 程序检测到非 tty,自动关闭 | 全量输出:刷屏 + ANSI 转义污染 |
| 输出纯度 | 干净字节流 | 回显、控制序列混杂 |
| 测试与断言 | 容易 | 痛苦 |
| 适合 | 编译、测试、grep、cat | 长驻进程、需要 Ctrl-C 的、需要终端行为的 |
三个真实例子,感受一下"同一命令、两种人格":
git diff 管道:无分页无颜色,一次吐完;PTY:进入 pager,可能"卡住"等按键 cargo build 管道:无颜色,进度静默;PTY:彩色输出 + 进度行持续刷新 sudo 命令 管道:无法弹密码提示,直接失败;PTY:提示输密码,然后挂住等输入第三个例子最阴险:没给 PTY 时,sudo类命令不是"报错友好地失败",而是以各种出人意料的方式卡住或乱掉。我遇到的版本是它卡在超时上,而超时被杀后模型只看到 exit code 124,完全不知道发生了什么。
Codex 的选择是两条路都留,把选择权做成参数:同时提供管道 spawn 和 PTY spawn,tty: bool承接 exec_command 的 tty 参数;Windows 上走 ConPTY 伪终端。
会话化的 PTY 执行时序:
图解:注意第三步——第一次返回的就是 session_id 而不是等命令跑完。这是会话化 exec 与一次性 exec 的本质区别:命令的生命周期从此由模型接管,而不是由工具的等待策略绑架。
轮询的节奏也要设计。参数里已经把节奏写进了 schema:写入后的默认等待 250 ms、上限 30 s,纯轮询默认 5 s 起步、上限 300 s——短等待用于"发了输入立刻看反馈",长等待用于"挂机等它自己出结果"。自研时的等效协议是poll(session_id, wait_ms, max_tokens),三个参数各司其职;永远不要提供无参数的"给我全部输出"——那等于绕开第三节的全部上限。
一个实操推论:默认走管道(tty 缺省为 false),只在明确需要终端行为时才开 PTY。开了 PTY,就要同时接受输出清洗与截断的成本。两者是绑定的,没有"又要 PTY 的交互、又要管道的干净"的免费选项。
三、输出:先炸内存,再炸上下文
结论先行:大输出是双重炸弹——先炸内存,再炸上下文。
Codex 的输出上限是 1 MiB(DEFAULT_OUTPUT_BYTES_CAP = 1024 * 1024),源码注释写明了动机:防止单条失控命令把海量数据写进 stdout/stderr,把进程 OOM 掉。流式侧还有一条独立上限:实时 delta 事件封顶 10000 条——聚合端仍收集全量用于最终截断,但事件不能洪泛冲垮客户端。
上下文侧的账更直观:工具输出能占满窗口的 55%–75%。1 MiB 按 token 密度折算约 25 万 token——一条命令就能吃掉 128k 窗口的两倍。所以这个上限同时是 OOM 防线和上下文防线,两道墙砌在同一个数字上。
处理管线全景:
图解:CAP 一格藏着一个反直觉细节——超限后要继续排空而不是立刻杀进程;命令结束后对收集任务只做限时等待(Codex 的IO_DRAIN_TIMEOUT_MS = 2_000),源码注释点名了最阴险的场景:命令被 kill 后孙进程还握着 stdout 的 fd,读取任务会在 read 上永久阻塞,把整个 Agent 卡死。
四条纪律逐条展开:
1. 截断:保头保尾,中间折叠。报错的第一行(错误类型与位置)和输出的尾部(测试 summary、N failed)分居两端,中段的重复堆栈最可牺牲。截断必须显式告知,并给出恢复手段(“前 4000 字节与后 8000 字节已保留”),否则模型会对缺失部分开始臆测。
2. 二进制拒绝。检测到 null byte 直接拒绝回灌,提示改用专用命令(查包用包管理器、看图片说路径)。二进制喂进上下文,烧 token 且模型读不出任何东西,纯负收益。
3. ANSI 清洗:想清楚再做。一个如实的观察:Codex 这份 commit 的 exec 输出路径里没有独立的清洗层——PTY 字节流基本原样回灌(仓库里的 ansi-escape crate 是 TUI 渲染用的,不是 exec 输出清洗器)。自己实现时建议做两层:过滤控制序列(光标移动、清屏)、折叠回车重绘(进度条一行变千行)。但保留语义信息的余地:颜色有时承载错误级别,全剥掉也会丢信号。
重绘折叠的效果:
原始 PTY 字节流(伪): Building [#### ] 40%\rBuilding [##### ] 50%\rBuilding [###### ] 60%\rDone 折叠后回灌: Building … 40% → 50% → 60% → Done一次 5 分钟的构建能刷出几千行这种重复,折叠后只剩一行——这是"清洗省下的 token 超过清洗本身的成本"的典型场景;反过来,一次性命令(ls、cat)几乎没有重绘,清洗纯属白做。清洗策略按输出模式自适应,别一刀切。
4. token 预算层。max_output_tokens默认 10000 tokens 且可被策略压低——字节上限管进程安全,token 预算管上下文安全,两层独立存在。
保头保尾落到代码上就是十几行:
MAX_BYTES=32*1024# 单次观察进入上下文的预算deffold_output(raw:bytes,head:int=4000,tail:int=8000)->str:iflen(raw)<=MAX_BYTES:returnraw.decode("utf-8",errors="replace")head_part=raw[:head].decode("utf-8",errors="replace")tail_part=raw[-tail:].decode("utf-8",errors="replace")omitted=len(raw)-head-tailreturn(f"{head_part}\n…[中间省略{omitted}字节]…\n{tail_part}\n"f"[输出共{len(raw)}字节,中间已折叠;"f"需要完整内容请先重定向到文件再分段查看]")恢复手段必须真的可行:告诉模型"重定向到文件后分段查看"之前,确认它确实可以分段查看(read 工具带 offset,或再 exec 一条sed -n区间命令)——给出一条自己兑现不了的路,等于把臆测的借口递到模型手上。
四、超时与中断:两个"10 秒"千万别合并
分层超时,各管一段:
- 命令级:Codex 的默认命令超时是 10 秒(
DEFAULT_EXEC_COMMAND_TIMEOUT_MS = 10_000),超时的命令以传统 exit code124报告(EXEC_TIMEOUT_EXIT_CODE)——模型看到 124 就知道是超时被杀,而不是命令自己退了这个码。10 秒是个好默认:编译单文件、跑单测大多远低于此;撞超时不是异常,是"该后台化了"的信号。 - turn 级:用户随时发
Op::Interrupt中止整个 turn;预算耗尽走同一通道。命令级超时管单条命令,turn 级中断管整场任务。
这里有个极其容易踩的坑:yield_time_ms的默认值恰好也是 10000 ms。
但语义完全不同——
| 参数 | 语义 | 超时后发生什么 |
|---|---|---|
| 命令级超时 | 杀进程的判决 | 进程组被杀,返回 exit code 124 |
yield_time_ms | 归还控制权的节奏 | 命令继续跑,返回 session_id |
前者终结命令,后者只是别让模型干等。自研时这两个参数必须分开命名、分开实现——合并成一个 timeout 是这类事故的起点:要么长命令全被误杀,要么 hang 命令全部逍遥。
明确反对的做法:为长任务把命令级超时全局调大。正确出路是后台化(会话化 exec +write_stdin轮询),不是把所有命令的等待都拉长——前者只慢在需要的命令上,后者让每条 hang 命令都多烧十分钟。
中断语义:kill 进程组。为什么单位是组而不是进程:bash -c "make test"的进程树里,make 派生 gcc,gcc 派生 cc1——只 kill 直接子进程 bash,make 会变孤儿继续跑,CPU 照占,测试结果再也没人收。Codex 从 PTY 工具库引入进程组三件套(kill_child_process_group/kill_process_group/terminate_process_group),超时和取消都按进程组处置。
升级链的顺序有讲究——先 TERM 后 KILL:
图解:每一级都在修上一级留下的坑——TERM 解决"正在写文件的进程被硬杀会留垃圾",KILL 解决"不理 TERM 的顽固进程",限时排空解决"孙进程握住管道让收集任务永久阻塞"。
会话是资源,要设计它的生命周期。会话化 exec 引入了一个新问题:模型开了 dev server 会话就去干别的,session_id 可能再也没人管。会话状态机至少要有四个态:
图解:idle 与 exited 的分离是关键——进程退出不等于资源可释放,残余输出(可能含最后几行报错)要能被取走,全部排空后才算回收完毕。
turn 结束时的清理策略要想清楚:全杀掉(干净,但下个 turn 重建环境成本高)还是允许跨 turn 存活。Codex 的选择刻在协议注释里:Op::Interrupt的文档注释明写 “Abort current task without terminating background terminal processes”——中断的是任务,不是环境。用户按停不该连带杀掉已经跑起来的依赖服务;这个取舍值得抄,前提是配合会话回收,别让"保留"变成"泄漏"。
长命令三条原则:
- 预期不退出的命令(dev server、watch 模式):会话化启动 + 轮询,永远不要同步等它——它不会退,等就是死锁。
- 预期退出但很慢(全量测试、大构建):调高该次
yield_time_ms或后台化后定期轮询,流式回传让用户看到进度。 - 绝不许一条命令 hang 死 turn:命令级超时是兜底不是常态,频繁撞超时说明该把这条命令挪进第 1 或第 2 条的处理方式。
拼一个完整场景把三条串起来——任务"给项目加一个接口并验证":
# 1. 预期退出的快命令:默认参数直接跑exec_command("cargo build")# 2. 预期退出但慢:yield_time_ms 拉满 + token 预算放宽r=exec_command("cargo test --all",yield_time_ms=30000,max_output_tokens=50000)# 还没跑完?r.session_id 存在,转轮询# 3. 预期不退出:启动即返回,轮询到就绪信号就继续干活dev=exec_command("cargo run --bin server")while"listening"notin(chunk:=write_stdin(dev.session_id,"",5000)).output:pass同一个工具,三种等待策略,全靠参数区分——这就是"决策点暴露成参数"的价值:策略由模型按命令性质现场选择,实现只保证每种选择都安全。
五、跨平台与 arg0:一个二进制演 N 个命令
Windows 三坑,每个都有真实代价:
- 编码。默认代码页 GBK(cp936)与 UTF-8 混用,中文文件名和输出一言难尽。对策:会话启动时强制 UTF-8,读输出按实际代码页解码而不是假设——"按假设解码"就是乱码的来源。
- shell 差异。PowerShell、cmd、bash 的引号、转义、变量语法互不兼容,跨 shell 组合命令是重灾区。Codex 在 Windows 上给模型注入专门的 shell 指引,三条规则都值得抄:不要跨 shell 组合破坏性操作;递归删除前先验证解析后的绝对路径确实在目标目录内;启动后台助手默认隐藏窗口。
- 路径。分隔符、盘符、大小写不敏感——路径拼接永远走库,字符串拼接是 platform bug 工厂。
PTY 侧,Windows 10+ 走 ConPTY;不支持的老系统降级管道并放弃交互能力——在能力降级和兼容性事故之间,选前者。
arg0 技巧更值得单独说,因为它解决了一个很漂亮的问题:
- 问题:
apply_patch这类工具不是用户机器上安装的二进制,模型在 shell 里敲apply_patch怎么能跑通?沙箱助手、提权包装器同样面临"系统里没这个命令"的问题。 - 解法:会话目录里造一个 PATH 条目,放一批指向 Agent 自身可执行文件的符号链接;程序启动时按
argv[0]的文件名分发——argv[0] 是apply_patch就进 apply_patch 的主逻辑,是codex-linux-sandbox就进沙箱入口。一个 guard 结构体在整个进程生命周期里守着这个 PATH 条目。
PATH 注入目录: apply_patch -> 符号链接到 agent 二进制 codex-linux-sandbox -> 符号链接到 agent 二进制 codex-execve-wrapper -> 符号链接到 agent 二进制 进程启动: argv[0] == "apply_patch" => 执行 apply_patch 主逻辑 argv[0] == "codex-linux-sandbox" => 执行沙箱入口 其他 => 正常 CLI 启动本质是:一个二进制演 N 个命令。模型视角里apply_patch是个真实存在的命令行工具,用户机器上却什么都不用装。
代价在 Windows 上:符号链接语义不同,shim 要换.bat/.cmd实现,分发逻辑的测试矩阵直接翻倍——跨平台包装的复杂度是真实的,这也是为什么跨平台支持永远该排在核心功能之后。
最后一个工程建议:跨平台测试矩阵从第一天就跑。exec 是 IO 与进程语义的深水区——管道关闭时机、信号语义(Windows 没有真正的 SIGTERM)、fd 继承、ConPTY 版本差异,全都不是在 macOS 上能预判的。平台专用的测试文件各自成套,就是这类教训的沉淀形态:每个平台一个测试文件,差异行为就地记录。
六、避坑清单
| 坑 | 症状 | 根因 | 对策 |
|---|---|---|---|
| 命令 hang 死整个 turn | 一条命令永不返回,Agent 整体卡死 | 无命令级超时;或 kill 只杀直接子进程 | 默认超时 + exit code 124 标记 + kill 进程组 + 限时排空兜底 |
| 进度条刷屏吃掉 token | 一次轮询带回上千行重复的进度行 | PTY 下进度类程序全量重绘输出 | 清洗控制序列、折叠回车重绘,只保留最新进度 |
| 输出截断丢了关键报错 | 截断后模型拿不到 summary,开始瞎猜 | 只保头部或只保尾部,且未告知截断 | 保头保尾中间折叠 + 显式截断说明与恢复手段 |
| 交互式命令等待输入永不返回 | sudo / vim / 确认提示卡到超时 | 非交互环境下程序等 stdin,模型看不见提示也答不了 | 默认禁用交互或预置参数;确需交互走 PTY 会话 + write_stdin |
| Windows 中文编码乱码 | 输出里中文变问号或乱码 | GBK 代码页与 UTF-8 混用,按假设解码 | 会话级强制 UTF-8,读输出按实际代码页解码 |
| 孤儿进程占着端口和 CPU | turn 结束后端口仍被占用,下次启动失败 | 只 kill 直接子进程,孙进程变孤儿 | 会话结束时按进程组 TERM → KILL 全组 |
本章产出
把这一章落成代码,你的 exec 工具至少要有这五样东西:
- 一个会话表:
session_id -> {进程组, 缓冲区, 状态},状态至少四态(active / idle / exited / reaped)。 - 两个独立的等待参数:命令级超时(杀进程,返回 124)与
yield_time_ms(归还控制权,返回 session_id)。绝不合并。 - 一条输出管线:字节上限 → null byte 拒绝 → ANSI 清洗 → 保头保尾折叠 → token 预算。
- 一个进程组级的 kill:TERM → 等待 → KILL → 限时排空,四级逐级兜底。
- 一份跨平台矩阵:macOS / Linux / Windows 各一个测试文件,把"不是在本机预判得出来的"行为就地记下来。
第 05 章的mini_agent.py里那个 30 行的run_command,到这里变成了一个真正的工具。
下一章:命令会执行了,但"哪些命令允许跑"还没回答——OS 级沙箱与审批策略的双闸门设计。
我是源码派,正在连载《编程 Agent 开发避坑指南》——12 章、47 张图,讲清楚怎么造一个类 Codex 的编程 Agent:上下文工程、文件编辑、Shell 执行、沙箱审批、提示词注入攻防。每章都有源码证据(Codex commit2685e3a4/ opencode v1.18.34)。完整目录 + 可运行示例源码(mini_agent.py在内),见我的掘金/知乎主页「源码派」。
首发声明:本文首发于掘金/知乎/CSDN(同名「源码派」),转载请保留本声明与作者信息。