01 · The question
轮询本身没有错,模型陪着轮询才贵
这次问题来自一张 Grok 终端截图。Agent 已经工作了九分多钟,输入框上方还挂着 1 monitor still running。主会话看起来停了,后台的监控却没有消失。它像是在等 monitor 自己结束,随后再继续做事。
把一条耗时两分钟的测试放到后台,几次查询没有多大影响。换成模型训练、视频生成或大型构建,任务一跑就是半小时。Agent 每隔十几秒读一次日志,每次都要发起工具调用,输出随后进入会话,模型再判断一次。GPU 没快一秒,聊天记录已经长了一截。
这里要分清三个动作。
普通进程轮询
shell 每隔一段时间查文件、进程或接口。没有对话上下文,也不调用模型。
Agent 查询工具
模型调用工具读后台状态,拿到结果以后判断要不要继续等。
事件推进会话
普通进程先等条件,状态变化以后发通知,主会话才重新开始工作。
前两种都叫轮询,成本差得很远。一个安静的 shell 循环每 45 秒查一次远端 worker,只花本机进程和一次 SSH。模型每 45 秒醒一次,会多出工具调用、上下文和推理。本文比较的就是这个位置。
02 · Grok Build
Grok 把等待交给 monitor,主会话等输出
Grok Build 的公开代码给得最完整。配置注册表里有 Feature::AutoWake,对应 features.auto_wake,默认开启。后台命令完成、subagent 返回、monitor 产生事件时,空闲的主会话都可以再开一个回合。
Monitor 工具的说明写得很直接。脚本的每一行标准输出都会成为一次主 Agent 唤醒。说明同时要求脚本控制输出量,只打印完成、失败或取消这类值得处理的状态。原始日志不能整段灌进去,带管道的过滤还要用行缓冲,免得事件在用户态缓冲几分钟。
MonitorTool.run把命令作为后台任务启动,并保存输出文件run_monitor_pipeline读取新增内容,按行送进速率限制器MonitorEvent每个保留下来的事件进入会话通知AutoWake空闲主会话收到事件后重新运行截图里的 GPU 监控脚本正好符合这个设计。它每 45 秒通过 SSH 进入机器和容器,检查四个 worker。全部完成时打印 ALL_DONE,进程提前消失时打印 WORKERS_DEAD,其余时间保持安静。
while true; do
check_workers
if all_finished; then
echo ALL_DONE
exit 0
fi
if workers_died_early; then
echo WORKERS_DEAD
exit 1
fi
sleep 45
done
这段脚本仍然在轮询。模型没有跟着它每 45 秒醒来。状态没变,shell 继续睡。最后一行出现以后,Grok 才回来检查产物。用户这时也可以继续输入别的问题,后台 monitor 不会占着当前对话回合。
1 monitor still running 是一条活任务提示。
它表示后台还有能够唤醒 Agent 的工作。脚本还在跑,主 Agent 已经可以停下来。monitor 完成或产生事件时,会话再接着走。
03 · Claude Code 2.1.241
Claude Code 先整理 stdout,再把通知放进主队列
Claude Code 没有公开同等范围的 CLI 源码。只看 Agent SDK 类型,可以知道 Monitor 接受哪些字段,却看不到进程怎样启动、输出怎样进入会话。我对本机安装的 2.1.241 可执行文件做了只读静态检查,没有执行未知代码,也没有附加调试器或拦截网络。
目标文件位于 ~/.local/share/claude/versions/2.1.241。它是 arm64 Mach-O,可执行文件大小为 325,055,632 字节。SHA-256 如下。
1495eb7c42d3b4451f5f1cd38b6d498d22a4a38c802bc2be5c1cf1795e64820d
这个哈希与 Anthropic 随 Agent SDK 0.3.241 发布的 darwin-arm64 清单一致。清单对应 Claude Code 2.1.241,Git 提交为 c87e2742fc9ad269ec8920460d00a091b1e410f0。文件签名的标识是 com.anthropic.claude-code,签发方为 Anthropic PBC。
实际代码藏在 __BUN 段里
这个 Mach-O 有一个约 255 MB 的 __BUN 段,里面保存了 Bun 打包后的 JavaScript。变量名经过压缩,Monitor 的入口、stdout 回调和消息队列仍能连成一条可达路径。下面是几个关键函数在文件里的字节偏移。
| 函数 | 字节偏移 | 负责什么 |
|---|---|---|
PBi | 296308601 | 处理 stdout、分批和速率限制 |
PTT | 296867034 | 启动命令型 Monitor |
U3t | 298622624 | 注册本地后台任务并接上完成回调 |
命令型 Monitor 从 MTT.call 进入 PTT。PTT 调用 QFe 启动 Bash,把 PBi.onData 交给 onStdout,随后用 U3t 把任务登记为 type: "local_bash" 和 kind: "monitor"。
onStdout: p.onData
// ...
kind: "monitor"
MTT.call → PTT选择命令型 Monitor,并启动 BashQFe → PBi.onDatastdout 进入本地事件处理器HSl → xwe按行切开、分批、限流,再生成 Monitor eventenqueuePendingNotification通知以 task-notification 模式进入队列getCommandsByMaxPriority("next")主查询循环接走通知,开始或继续模型回合一行输出不会粗暴地变成一次立即中断
SDK 的注释说每一行 stdout 都是事件。实际实现多了本地整理。HSl 按换行切开内容,非空行成为事件候选。短时间内连着到达的几行可以合成一条通知,原始缓冲区也有硬上限。
PBi 还维护一个速率桶。Monitor 可以短时间连发 10 次,之后每两秒恢复一个名额。输出过快时,多余事件会被压住。超限状态持续 30 秒,Claude Code 会停掉 Monitor,并送出内部提示。训练脚本即使疯狂刷日志,也不会无限制造模型回合。
next 决定事件什么时候进入模型
xwe 生成 Monitor event,随后调用 enqueuePendingNotification。实际代码里的队列字段只有两行,却把唤醒方式交代得很清楚。
mode: "task-notification",
priority: "next"
主查询循环调用 getCommandsByMaxPriority("next"),把通知折进待发送的消息。最高优先级的 now 可以中止当前查询,Monitor 使用的 next 通常会等当前回合走到边界。它不需要模型反复查询,也不会在 stdout 一有字符时就打断正在进行的请求。
进程退出以后,ALm 等到命令结果,YIl 根据退出状态生成完成、失败或停止通知,仍以 task-notification 和 next 进入同一个队列。中途事件和最终完成由此汇到一处。
公开 SDK 类型能证明哪些事
Agent SDK 0.3.241 的 MonitorInput 定义了 command、timeout_ms 和 persistent。默认超时五分钟,最大一小时。SDKTaskNotificationMessage 公开了 completed、failed 和 stopped 三种最终状态。
这些类型与二进制里的路径一致。CLI 怎样启动进程、怎样合并输出、怎样限流和入队,仍以实际发布代码为依据。
04 · Codex 0.149.1
Codex 把一次 poll 等得很久,下一次仍由模型发起
Codex 的实现可以直接在公开 Rust 源码里追。我没有用不断变化的 main 分支代替本机版本,检查的是 rust-v0.149.1 标签,对应提交 ff29a44391deccde0aba0f8390337d7f3c319ea4。
write_stdin 处理器把空输入定义为后台 poll。继续进入 process_manager.rs,空输入会走单独的等待范围。
if request.input.is_empty() {
time_ms.clamp(
MIN_EMPTY_YIELD_TIME_MS,
self.max_write_stdin_yield_time_ms,
)
} else {
time_ms.min(MAX_YIELD_TIME_MS)
}
unified_exec/mod.rs 给出的空查询下限是 5,000 毫秒,默认后台终端上限是 300,000 毫秒。本机配置同样是五分钟。一次空查询因此可以挂住很久,原始命令若在这段时间结束,当前 poll 能直接拿到最终状态。
后台命令返回 session ID
终端进程继续运行,模型拿到后续读写所需的会话标识。
空 write_stdin 挂起
一次查询可以等待五秒到五分钟,减少短间隔刷状态。
模型读取结果再决定
没结束就安排下一次查询,结束后继续检查产物。
这套实现已经比每十秒刷一次状态安静很多。它仍由查询推动。当前回合若已经交还用户,普通后台终端完成通常不会仅凭这个状态自行开一个新回合。
源码里还能看到一个 awaiter.toml。它把后台终端上限提高到一小时,并要求 awaiter 重复调用工具,逐步增加等待时间。agent/role.rs 上方写着 Awaiter is temp removed,整段角色注册已经注释。0.149.1 默认角色表没有启用它,普通后台终端继续走 write_stdin。
Codex 也能运行安静的 watcher。
watcher 可以只在成功或失败时打印,减少日志噪声。主会话仍要处在等待这条终端的回合里,或者等用户下次输入后回来读取结果。watcher 改善输出量,没有改变普通后台终端由工具查询续接的事实。
05 · Side by side
三家都能等,模型参与等待的程度不同
| 项目 | Grok Build 1.0.5 | Claude Code 2.1.241 | Codex 0.149.1 |
|---|---|---|---|
| 主路径 | Monitor event + AutoWake | Monitor event + next queue | write_stdin poll |
| 谁观察状态 | 本地监控脚本 | 本地监控脚本和 stdout 处理器 | 后台终端加模型工具调用 |
| 没有变化时 | 脚本继续等待 | 脚本继续等待,不生成通知 | 一次 poll 挂起,超时后模型决定是否再查 |
| 状态变化时 | 一行输出唤醒主 Agent | 输出经过分批和限流后进入 next 队列 | 当前或下一次查询读到结果 |
| 当前回合边界 | 空闲时可自动开新回合 | next 通知通常等当前回合结束 | 需要当前等待回合或新的用户输入 |
| 噪声保护 | 工具端有限流,文档要求只打印关键事件 | 200 ms 合并、速率桶、持续超限停止 | 长挂起减少查询次数,输出仍会进入上下文 |
| 跨会话存活 | 受当前会话生命周期约束 | 受当前会话生命周期约束 | 普通 terminal session 同样不是外部调度器 |
如果只看截图里的监控体验,我更偏向 Grok 和 Claude Code。普通进程负责盯明确条件,模型只处理有意义的变化。Codex 的长 poll 已经把查询频率压得很低,长任务跑得越久,事件通知省下的回合越明显。
这个判断只覆盖后台长任务监控,不代表三款工具的整体能力排名。Codex 的公开源码最容易核验,Grok 的事件语义最直观,Claude Code 在发布二进制里做了最细的输出整理。三家处理的是同一类麻烦,取舍各有侧重。
06 · Practical watcher
长任务监控脚本应该尽量少说话
事件驱动也会被坏脚本拖累。训练进度每变一次就打印,Monitor 便会不断制造事件。实用的 watcher 只关心能改变 Agent 下一步动作的条件。
| 值得通知 | 留在日志里 |
|---|---|
| 完成文件已经写好 | 进度从 42% 变成 43% |
| worker 提前退出 | 每轮 loss 的普通波动 |
| 质量门槛没有通过 | 重复出现的健康状态 |
| 外部任务进入终态 | 没有改变下一步动作的中间值 |
while true; do
if test -f output/final.json; then
echo DONE
exit 0
fi
if ! kill -0 "$worker_pid" 2>/dev/null; then
echo FAILED
exit 1
fi
sleep 30
done
远端接口可以把间隔放到 30 秒以上,本地文件和进程可以更短。管道要留意缓冲,stderr 也要按工具语义处理。最要紧的一条很朴素。没有需要 Agent 处理的事,就别打印。
07 · Boundary
Monitor 适合一段会话,长期作业仍要调度系统
Grok 和 Claude Code 的 Monitor 都受会话生命周期约束。关闭终端以后,后台任务不会随着恢复会话自动重建。Codex 的后台 terminal session 也不适合承担跨设备、跨重启的长期可靠运行。
半小时训练、一次 CI、一个 PR 状态或当前会话里的日志观察,很适合交给 Monitor。几天后的定时任务、机器重启后还要继续的训练、需要审计和重试的生产作业,应该交给 CI、守护进程或调度系统。Agent 接收终态,再做解释和后续动作。
那行 1 monitor still running 的意思到这里已经很清楚。任务还在运行,主 Agent 已经可以停下来。等值得处理的事件出现,它再回来。
08 · Sources
源码与版本依据
- Grok Build Monitor 工具实现Monitor 描述、后台任务和 stdout 处理管线
- Grok Build 后台任务说明Monitor 事件、输出控制和 still-running 状态
- Grok Build AutoWake 配置
features.auto_wake的默认值和注册位置 - Claude Agent SDK 0.3.241 发布清单Claude Code 2.1.241 的平台二进制校验值
- Claude Agent SDK Monitor 类型公开输入、输出和超时契约
- Claude Agent SDK 后台任务消息类型任务通知与最终状态
- Codex 0.149.1 的 write_stdin 处理器空输入作为后台 poll 的入口
- Codex 0.149.1 后台终端等待实现空查询的等待范围
- Codex awaiter 配置独立 awaiter 的轮询方式
- Codex 默认角色注册0.149.1 中 awaiter 暂时移除的状态