我以前会把“在终端里成功跑了一次 Codex”理解成 CLI 已经接好了。真正把它放进脚本后,才发现还差很远。
人盯着交互界面时,可以临时补 Prompt、批准命令、忽略一条噪声,再凭经验判断结果是否可信;调度器只看得到进程:用什么参数启动、在哪个目录运行、输出了什么、何时结束、退出码是多少。
因此这篇不再重复 Codex CLI 修 bug 的操作流程,而是回答另一个问题:
怎样把一次 Agent 对话,变成调度器可以重复调用、观察、中止和验收的进程接口?
先约定两个容易混淆的称呼:本文把启动 CLI、设置目录、控制超时并收集输出的外部程序称为“调度器”;把 CLI 内部组织上下文、循环和工具调用的部分称为“Agent 执行框架(Agent Harness)”。两者都可能被笼统叫作 Harness,但责任并不相同。
| 项目 | 说明 |
|---|---|
| 内容类型 | Agent CLI 运行时与进程合同实战 |
| 适合读者 | 已经使用 Codex 或 Claude Code,准备接入脚本、CI、定时任务或内部平台的开发者 |
| 阅读 / 跟做时间 | 约 18 分钟 / 35-45 分钟 |
| 环境要求 | Python 3.10+;真实探针需要已登录的 Codex CLI |
| 本地实测 | Windows 11、Python 3.11、codex-cli 0.146.0 |
| Claude Code 边界 | 按官方文档核对,本文未在本机安装和执行 Claude Code |
| 代码检查点 | 13df6f4 |
| 实验下载 | agent-cli-runtime-lab-0.1.0.zip |
| SHA-256 | 086C04854D1A22A4FD21ADEBCE77F17CFD99ECA3843FC3121BA847C3D8675293 |
| 资料核对日期 | 2026-08-10 |
一分钟概览
先记住六点:
- 模型不会直接“进入终端”;调度器启动 CLI,CLI 内的 Agent 执行框架再组织模型、工具、Shell 和权限。
- 交互 CLI 适合探索,非交互 CLI(Headless CLI)适合边界明确、可以自动结束的任务。
- 一个可调度 Agent 至少要固定版本、目录、输入、环境、权限、输出和终态。
exit 0只表示进程成功结束,不等于现实任务已经完成;仍要验证结构化结果和产物(Artifact)。- stdout、stderr、JSONL 事件和最终回答不是同一种输出,不应混在一个文本解析器里。
- 超时不是“程序不再等待”这么简单,还要处理子进程、部分输出和结果未知状态。
图 1:Agent CLI 从命令经过执行框架、权限和子进程抵达一次性工作区,并留下事件、产物和退出状态
图 1:CLI 的工程价值不在黑色终端,而在它把一次 Agent 运行压成了可以被操作系统和调度器管理的进程边界。动画表示事件和状态流动。
1. 先分清五层:模型并不直接运行 Shell
一条 codex exec 背后至少有五层:
调度器 / CI
-> Codex CLI 进程
-> Agent 执行框架(Agent Harness)
-> 权限与工具路由
-> Shell 子进程 / 文件系统 / Git
模型负责提出下一步行动,Agent 执行框架负责把行动映射为工具调用,权限层决定是否允许,操作系统才真正启动命令。把这些都叫“模型执行了 Shell”,会掩盖三个关键事实:
- 相同模型在不同 Agent 执行框架下可能表现不同;
- CLI 进程结束时,仍可能留有它启动的后台进程;
- 目录、环境变量、权限和退出码都属于模型外部的工程合同。
图 2:模型、Agent 执行框架、权限层、Shell 子进程和工作区的责任边界
图 2:Prompt 只进入 Agent 执行框架的输入面。真正决定可读写范围和进程生命周期的是外层控制面。
2. 交互模式和非交互模式不是高低级关系
Codex 的交互入口是 codex,非交互入口是 codex exec;Claude Code 对应 claude 与 claude -p。两种模式共享 Agent 能力,但承担的工作不同。
| 判断 | 交互模式 | 非交互模式(Headless) |
|---|---|---|
| 需求还在变化 | 合适,可以补上下文 | 不合适,输入会漂移 |
| 需要人工批准 | 合适 | 需要预先写好权限策略,否则会中断 |
| 要进入 CI | 不合适 | 合适 |
| 输出给人阅读 | 终端 UI 更自然 | 应保存最终摘要 |
| 输出给程序消费 | 很难稳定解析 | 使用 JSONL 与 JSON Schema |
| 失败后继续追问 | 直接对话 | 保存并恢复会话(Session),或重新执行幂等任务 |
图 3:交互式探索和非交互自动化从同一 Agent 能力分流
图 3:当任务仍需要人不断改写目标时,自动化只会把模糊放大。非交互运行的前提是任务合同已经足够清楚。
我会用一个简单门槛决定是否切到非交互模式:
能否在运行前写清输入、权限、停止条件和机器可检查的输出?
能 -> 可以考虑非交互运行
不能 -> 继续在交互模式探索
3. 一次 Agent 运行要固定七部分合同
配套实验把运行合同拆成七部分。
| 合同 | 最少记录什么 | 常见失败 |
|---|---|---|
| 可执行文件 | 产品与确切版本 | 更新后参数或默认行为变化 |
| 工作目录 | 主目录、附加目录、Git 基线 | Agent 读错仓库或混入旧改动 |
| 输入 | Prompt、stdin、附件摘要 | 调度器无法重放同一次任务 |
| 环境 | 允许继承的变量、认证来源 | Secret 被子进程或日志意外继承 |
| 权限 | Sandbox、Approval、网络和写入边界 | 自动化停等批准,或权限过大 |
| 输出 | stdout、stderr、事件流、最终消息、产物 | 人类文本被当 JSON 解析 |
| 终态 | 退出码、超时、取消、结果状态 | 进程结束被误判为任务完成 |
图 4:七项不是为了多写配置,而是为了让失败能归因。少一项,通常就会多一种“这次为什么不一样”的争论。
3.1 先写一张 CLI 运行卡(Run Card)
在接入任何 Agent CLI 前,可以先填这张卡:
run:
executable: "codex-cli 0.146.0"
mode: "headless"
cwd: "一次性 Git fixture 的绝对路径"
input: "只读检查两个固定文件"
environment: "PATH 等最小白名单;认证由本机 Codex 管理"
permissions:
sandbox: "read-only"
dangerous_bypass: false
outputs:
events: "stdout JSONL"
diagnostics: "stderr"
final: "artifacts/final.json"
limits:
timeout_seconds: 180
output_bytes: 256000
acceptance:
exit_code: 0
final_schema: "codex-probe.schema.json"
workspace_unchanged: true
注意 workspace_unchanged 和 final.json 并不冲突:最终消息由 CLI 调用者写入工作区之外的产物目录,Agent 的只读工作区仍保持不变。
这张运行卡使用本机已登录的 Codex 凭证,只用于本地探针。进入 CI 时,不要把 ~/.codex/auth.json 复制进公共 Runner,也不要把 API Key 作为整个作业都可见的环境变量暴露给会执行仓库代码的步骤。OpenAI 当前文档建议 GitHub Actions 优先使用 Codex GitHub Action;其他环境则把 CODEX_API_KEY 只注入单次 codex exec 进程。
4. 实验一:先验证普通进程,不急着调用模型
下载并解压实验后:
cd agent-cli-runtime-lab-0.1.0/phase-8-agent-cli-engineering
python -m unittest discover -s tests -v
python run_lab.py runtime-demo --output reports/runtime-demo.json
runner.py 使用 Python 标准库完成几件事:
- 检查工作目录存在;
- 使用参数数组而不是拼接 Shell 字符串;
- 只继承显式环境变量白名单;
- 分开捕获 stdout 和 stderr;
- 记录退出码、耗时和超时状态;
- 给输出设置上限并标记截断。
生成的 run-record.json 结构接近:
{
"argv": ["python", "-c", "..."],
"cwd": ".../agent-cli-runtime-xxxx",
"status": "completed",
"exit_code": 0,
"duration_ms": 31,
"stdout": "{\"status\": \"ok\", \"count\": 3}\n",
"stderr": "",
"stdout_truncated": false,
"stderr_truncated": false
}
先用普通子进程验证调度器,是因为模型调用昂贵且不确定。若目录检查、超时和输出截断在普通命令上都不可靠,换成 Agent 只会更难调试。
5. 实验二:真实运行一次 Codex exec
确认下面命令可用:
codex --version
python run_lab.py codex-probe --mode read-only --output reports/codex-probe.json
实验实际构造的核心命令为:
codex exec \
-C <disposable-workspace> \
--sandbox read-only \
--ephemeral \
--ignore-user-config \
--strict-config \
--output-schema schemas/codex-probe.schema.json \
--json \
--output-last-message <artifact-dir>/final.json \
"Inspect this disposable repository without changing files..."
这段命令包含路径占位符,只用于解释参数结构。Windows 读者直接运行前面的 run_lab.py codex-probe 即可,脚本会用参数数组构造命令,不需要把反斜杠续行复制到 PowerShell。
这些参数分别解决不同问题:
-C固定 Agent 的工作根目录;--sandbox read-only限制模型发起的工具操作;--ephemeral不持久化会话记录;--ignore-user-config不读取用户config.toml,但不应把它误解成操作系统级“纯净容器”;--strict-config遇到当前版本不认识的配置就失败;--json把过程事件写成 JSONL;--output-schema约束最终回答;--output-last-message把最终结果单独保存,避免从事件流里猜哪一行是结论。
本机成功探针得到:
codex-cli 0.146.0
exit_code: 0
status: completed
final_message: {"status":"ok","file_count":2,...}
workspace_files_after: [README.md, data.txt]
第一次运行并没有成功。Schema 写成了:
"status": { "const": "ok" }
API 返回 invalid_json_schema,指出该属性缺少 type。修正为下面这样后才通过:
"status": { "type": "string", "const": "ok" }
这个失败比一张成功截图更有用:结构化输出不是“Prompt 里叫它返回 JSON”,而是调用链上的真实协议合同。
6. JSONL、stderr 和最终回答应该怎样分工
一次非交互运行通常有四类输出:
| 输出面 | 面向谁 | 适合保存什么 |
|---|---|---|
| stdout JSONL | 调度器 | 线程、Turn、工具调用和完成事件 |
| stderr | 运维与开发者 | 连接、配置、重试和运行时诊断 |
| final message | 下游任务 | 通过 Schema 验证的最终结论 |
| 产物文件 | 人与后续步骤 | 报告、diff、数据导出等较大结果 |
不要把 stderr 合并进 stdout 后再逐行 json.loads。一条诊断信息就足以破坏整个事件流。也不要只看 JSONL 中出现过一条漂亮的中间回答;本文实测事件里,模型在真正检查文件前曾产生临时结构化消息,最终应以 turn.completed 和保存的最终消息为准。
7. exit 0 只是第一道门
至少把完成判断拆成三层:
进程完成:exit_code == 0,未超时
协议完成:出现完成事件,最终 JSON 通过 Schema
任务完成:产物和现实状态满足验收条件
反过来,非零退出也要分类:
- 参数错误属于调用者合同问题;
- 权限拒绝可能是策略正确工作;
- Schema 错误属于接口版本问题;
- 网络失败可能可以重试;
- 工具执行失败不一定意味着整个任务不可恢复。
把所有失败都交给模型“再试一次”,会让确定性错误白白消耗预算。
8. 超时和取消:最容易被低估的部分
Python 的 subprocess.run(timeout=...) 能停止等待,但真实 Agent 可能已经启动测试服务器、浏览器或构建进程。可靠取消至少要回答:
- 向主进程发送什么信号?
- 子进程是否处于同一进程组?
- 优雅停止多久后升级为强制终止?
- 已产生的产物标记为失败、部分完成还是结果未知?
- 写操作是否能通过回执或幂等键判断实际状态?
本实验验证主进程的超时、终止和部分输出回收,但没有宣称覆盖 Windows Job Object、容器 PID namespace 或任意后台进程树。生产系统应在容器、任务运行器或平台级隔离里再做一层生命周期管理。
9. Claude Code 的接口怎样映射
按 2026-08-10 的 Claude Code 官方文档,非交互入口使用:
claude -p "What does the auth module do?"
它也提供 text、json、stream-json 输出,--json-schema、--max-turns、--max-budget-usd、--no-session-persistence、--allowedTools 和权限模式等控制。官方还推荐脚本调用考虑 --bare,以跳过本机 Hooks、Skills、Plugins、MCP、Memory 与 CLAUDE.md 的自动发现;此模式需要显式认证配置。
概念映射如下:
| 进程合同 | Codex | Claude Code |
|---|---|---|
| 非交互入口 | codex exec | claude -p |
| 事件输出 | --json | --output-format stream-json |
| 最终结构 | --output-schema | --json-schema |
| 临时运行 | --ephemeral | --no-session-persistence |
| 工作目录 | -C / --cd | 在目标目录启动,或使用 --add-dir 扩展 |
| 工具权限 | Sandbox、Approval、Rules | --tools、--allowedTools、Permission Mode |
| 预算控制 | 由调度器和调用环境组合约束 | --max-turns、--max-budget-usd |
这张表只比较公开接口,不比较模型能力。Claude Code 命令在本文环境中未本地执行。
10. 一份失败诊断顺序
遇到“Agent CLI 卡住了”,按这个顺序检查比盯着最终回答更快:
- 启动前:可执行文件和版本是否正确?工作目录存在吗?
- 配置层:是否意外继承用户配置、Hooks、Skills 或 MCP?
- 权限层:非交互任务是否停在无法显示的批准点?
- 传输层:stdout 是否仍有事件?stderr 是否在重连?
- 工具层:最后一个开始但未完成的工具是什么?
- 进程层:主进程、子进程和超时状态分别怎样?
- 结果层:最终消息是否存在并通过 Schema?
- 现实层:目标文件、测试、PR 或外部系统是否真的变化?
收藏清单
- 固定 CLI 版本并把版本写进运行记录。
- 使用参数数组启动进程,不拼接未经验证的 Shell 字符串。
- 显式设置工作目录、权限、环境变量白名单和超时。
- stdout、stderr、事件、最终消息和产物分开保存。
- 给自动消费的结果使用 JSON Schema。
- 同时检查进程完成、协议完成和现实任务完成。
- 把输出产物放在工作区之外的受控目录。
- 超时后记录部分结果,并处理子进程生命周期。
- 不在没有外部隔离时使用危险权限绕过。
下一篇会把视角反过来:这篇是“用 CLI 运行 Agent”,下一篇讨论“让 Agent 调用一个 CLI”,并把同一组能力同时暴露成 CLI 和 MCP,看看“万物皆 CLI”到底在哪些地方成立。