拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Agent 执行命令为什么总卡死:一行 exec 签名底下的八个坑

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 smax_output_tokens控制带回的输出预算。为什么拆成两个工具而不是一个带 timeout 参数的 exec因为长命令真正的困境不是等多久而是等的时候干不了别的。会话化把命令的生命周期和一次工具调用的生命周期解耦模型拿到 session_id 后可以决定继续等、先去改代码、或者收掉它。outexec_command(npm run dev)# 启动开发服务器ifout.session_id:# 命令还活着服务器不会自己退出logwrite_stdin(out.session_id,chars,yield_time_ms5000)# 轮询 5 秒assertlisteninginlog.output# 等到就绪信号再继续write_stdin(out.session_id,\x03)# Ctrl-C 收掉会话我第一次写 exec 时用的是subprocess.run(cmd, timeout30)。跑npm run dev的结果是等满 30 秒、抛 TimeoutExpired、进程被杀、模型拿到一句执行失败然后它又试了一次。三次之后步数用尽任务结束用户看到的是一句已达步数上限。它不是模型笨是工具的形状错了。二、PTY 还是管道一个参数决定命令的人格先记住一个事实大量 CLI 的行为取决于 stdout 是不是 tty。--colorauto、进度条、交互提示全都靠isatty检测切换。所以给不给 PTY不是性能取舍是行为取舍管道pipePTY交互式命令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 spawntty: 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 MiBDEFAULT_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_BYTES32*1024# 单次观察进入上下文的预算deffold_output(raw:bytes,head:int4000,tail:int8000)-str:iflen(raw)MAX_BYTES:returnraw.decode(utf-8,errorsreplace)head_partraw[:head].decode(utf-8,errorsreplace)tail_partraw[-tail:].decode(utf-8,errorsreplace)omittedlen(raw)-head-tailreturn(f{head_part}\n…[中间省略{omitted}字节]…\n{tail_part}\nf[输出共{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 124yield_time_ms归还控制权的节奏命令继续跑返回 session_id前者终结命令后者只是别让模型干等。自研时这两个参数必须分开命名、分开实现——合并成一个 timeout 是这类事故的起点要么长命令全被误杀要么 hang 命令全部逍遥。明确反对的做法为长任务把命令级超时全局调大。正确出路是后台化会话化 exec write_stdin轮询不是把所有命令的等待都拉长——前者只慢在需要的命令上后者让每条 hang 命令都多烧十分钟。中断语义kill 进程组。为什么单位是组而不是进程bash -c make test的进程树里make 派生 gccgcc 派生 cc1——只 kill 直接子进程 bashmake 会变孤儿继续跑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 预算放宽rexec_command(cargo test --all,yield_time_ms30000,max_output_tokens50000)# 还没跑完r.session_id 存在转轮询# 3. 预期不退出启动即返回轮询到就绪信号就继续干活devexec_command(cargo run --bin server)whilelisteningnotin(chunk:write_stdin(dev.session_id,,5000)).output:pass同一个工具三种等待策略全靠参数区分——这就是决策点暴露成参数的价值策略由模型按命令性质现场选择实现只保证每种选择都安全。五、跨平台与 arg0一个二进制演 N 个命令Windows 三坑每个都有真实代价编码。默认代码页 GBKcp936与 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_stdinWindows 中文编码乱码输出里中文变问号或乱码GBK 代码页与 UTF-8 混用按假设解码会话级强制 UTF-8读输出按实际代码页解码孤儿进程占着端口和 CPUturn 结束后端口仍被占用下次启动失败只 kill 直接子进程孙进程变孤儿会话结束时按进程组 TERM → KILL 全组本章产出把这一章落成代码你的 exec 工具至少要有这五样东西一个会话表session_id - {进程组, 缓冲区, 状态}状态至少四态active / idle / exited / reaped。两个独立的等待参数命令级超时杀进程返回 124与yield_time_ms归还控制权返回 session_id。绝不合并。一条输出管线字节上限 → null byte 拒绝 → ANSI 清洗 → 保头保尾折叠 → token 预算。一个进程组级的 killTERM → 等待 → 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同名「源码派」转载请保留本声明与作者信息。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门