☰
Agent 执行命令为什么总卡死:一行 exec 签名底下的八个坑
2026/10/7 10:21:58 网站建设 项目流程

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.
workdircwdWorking directory for the command. Defaults to the turn cwd.
ttyPTY 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”——中断的是任务,不是环境。用户按停不该连带杀掉已经跑起来的依赖服务;这个取舍值得抄,前提是配合会话回收,别让"保留"变成"泄漏"。

长命令三条原则:

  1. 预期不退出的命令(dev server、watch 模式):会话化启动 + 轮询,永远不要同步等它——它不会退,等就是死锁。
  2. 预期退出但很慢(全量测试、大构建):调高该次yield_time_ms或后台化后定期轮询,流式回传让用户看到进度。
  3. 绝不许一条命令 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 三坑,每个都有真实代价:

  1. 编码。默认代码页 GBK(cp936)与 UTF-8 混用,中文文件名和输出一言难尽。对策:会话启动时强制 UTF-8,读输出按实际代码页解码而不是假设——"按假设解码"就是乱码的来源。
  2. shell 差异。PowerShell、cmd、bash 的引号、转义、变量语法互不兼容,跨 shell 组合命令是重灾区。Codex 在 Windows 上给模型注入专门的 shell 指引,三条规则都值得抄:不要跨 shell 组合破坏性操作;递归删除前先验证解析后的绝对路径确实在目标目录内;启动后台助手默认隐藏窗口。
  3. 路径。分隔符、盘符、大小写不敏感——路径拼接永远走库,字符串拼接是 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,读输出按实际代码页解码
孤儿进程占着端口和 CPUturn 结束后端口仍被占用,下次启动失败只 kill 直接子进程,孙进程变孤儿会话结束时按进程组 TERM → KILL 全组

本章产出

把这一章落成代码,你的 exec 工具至少要有这五样东西:

  1. 一个会话表:session_id -> {进程组, 缓冲区, 状态},状态至少四态(active / idle / exited / reaped)。
  2. 两个独立的等待参数:命令级超时(杀进程,返回 124)与yield_time_ms(归还控制权,返回 session_id)。绝不合并。
  3. 一条输出管线:字节上限 → null byte 拒绝 → ANSI 清洗 → 保头保尾折叠 → token 预算。
  4. 一个进程组级的 kill:TERM → 等待 → KILL → 限时排空,四级逐级兜底。
  5. 一份跨平台矩阵: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(同名「源码派」),转载请保留本声明与作者信息。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询