AI 执行脚本没日志,可能卡在输出缓冲
脚本在终端持续打印,交给 Claude Code 执行却可能长时间没有可见输出,原因是非终端环境改变了 stdout 的缓冲方式。文章从 isatty 检测及 C、Python 的输出缓冲解释这一现象。
AI 深度解读
脚本在终端里每秒打印进度,交给 Claude Code 后却沉默了两分钟。作者指出,一个容易漏掉的原因是 stdout 缓冲:程序执行了打印,内容却还留在进程内存里,没有进入工具读取的输出文件。
这篇最有用的地方,是把「脚本是否运行」「日志是否写出」「Agent 是否读到」拆开了。排查 AI 编程工具中的长任务,先确认输出链路,往往比催模型继续等、让它重写脚本更有效。
为什么加了换行,Agent 还是看不到进度
C 标准 I/O 和 Python 都会根据 stdout 是否连接终端,选择不同的缓冲方式。关键检查是 isatty()。
连接终端时,stdout 通常按行缓冲,遇到 \n 就把这一行送出去;连接管道或文件时,通常按块缓冲,内容会积攒到缓冲区满、程序主动刷新,或者正常退出时才写出。
调用打印函数,不等于日志已经离开进程。
作者用下面的 C 程序演示这个差别。它每隔一秒打印一行:
#include <stdio.h>
#include <unistd.h>
int main(void) {
for (int i = 1; i <= 5; i++) {
printf("step %d\n", i);
sleep(1);
}
return 0;
}
要看的是 printf("step %d\n", i);:即使带了换行,输出也不保证立即可见。
用 cc -o tick tick.c 编译,直接执行 ./tick,五行会逐秒出现;执行 ./tick | cat,则会先沉默五秒,再一次性出现。改变发生在 tick 的 stdout 接入管道之后。

Python 也有同样的现象:python3 tick.py 持续输出,python3 tick.py | cat 等退出后集中输出。更麻烦的是 Linux 下的 timeout 3 python3 tick.py | cat:三秒后默认发送 SIGTERM,示例可能一行都看不到,因为信号终止没有走正常退出时的刷新流程。
这里不能把所有语言混在一起。Python 从 3.9 起,stderr 在交互和非交互环境中都按行缓冲;原文引用的 C 文档则说明 stderr 默认无缓冲。Node 的陷阱又不同:向管道写入时,POSIX 下是异步写入,Windows 下是同步写入,调用 process.exit() 可能丢掉尚未完成的输出。
Claude Code 的两分钟超时,为什么不代表脚本结束
按原文引用的工具文档,Claude Code 的 Bash 工具会把命令输出持续写入工作文件,再读取文件中的内容。这个输出目标不是终端,因此会触发前面的缓冲差异。
其默认前台超时是 120s,也就是两分钟,对应 BASH_DEFAULT_TIMEOUT_MS;单次调用可以请求更长时间,上限由 BASH_MAX_TIMEOUT_MS 控制,原文给出的上限是十分钟。
到达前台超时后,普通命令通常会转入后台,返回任务 ID 和输出文件路径,而不是立即被终止。原文还列出了以 sleep 开头的命令这一例外。
此时读取空文件,只能证明文件里没有已经写出的内容,不能证明脚本没执行。

另外两种情况需要分开看:
- 设置
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1,或者使用 bare mode 时,超时命令会停止。如果停止方式是信号终止,缓冲中的日志可能丢失。 - Monitor 工具会在后台运行脚本,按到达的输出行接收消息,默认期限是五分钟。生产日志的一方迟迟不刷新,Monitor 就只能延迟收到,甚至在期限内完全收不到。
最快的检查是分别让 Agent 和本地终端执行 [ -t 1 ] && echo "tty" || echo "not a tty"。其中 -t 1 检查文件描述符 1,也就是 stdout,是否连接终端。
作者自己的 Hermes IDE 也没有绕过这层关系。即使 Claude Code 本身运行在真实终端里,它通过 Bash 工具启动的命令,输出去向仍由 Bash 工具决定。父进程有终端,不代表子命令的 stdout 也连接终端。
修复要落在输出端,延长等待只是拖后问题
Python 最直接的处理是 python3 -u script.py,或者设置 PYTHONUNBUFFERED=1;只想控制某个输出点,可以用 print(..., flush=True)。
团队可以把 PYTHONUNBUFFERED 以字符串 "1" 写入项目 .claude/settings.json 的 env 对象。按原文说明,该配置会作用于会话及其子进程,提交到仓库后,队友信任项目目录才会应用相关设置。
自己维护的 C 程序,可以在 main 开头调用 setvbuf(stdout, NULL, _IOLBF, 0);,明确启用行缓冲,或者在每条进度后调用 fflush(stdout)。
对于 Linux 上使用 C stdio 的外部二进制,可以尝试 stdbuf -oL ./tick | cat。但 stdbuf 不是通用修复器:自行设置缓冲的程序可以覆盖它,dd、cat 这类不使用 stdio 流进行 I/O 的程序也不适用。Python 有自己的 I/O 缓冲层,应优先使用 -u。原生 macOS 不附带 stdbuf。
最后才考虑伪终端(PTY)。util-linux 的用法是 script -q -c "./tick" /dev/null,macOS 所用 BSD 版本则是 script -q /dev/null ./tick。获得终端后,程序可能同时启用颜色、进度条和交互提示;原文引用的手册也不建议在非交互 shell 中使用 script,并提醒管道场景存在问题。
自己用 Node 写命令工具,同样会遇到这件事。child_process.spawn 默认的 stdio 是 ['pipe', 'pipe', 'pipe']。作者给出的实现为 Python 子进程设置环境变量,并监听输出:
import { spawn } from "node:child_process";
export function runCommand(cmd: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn("sh", ["-c", cmd], {
env: { ...process.env, PYTHONUNBUFFERED: "1" },
});
let out = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (out += d));
child.on("error", reject);
child.on("close", () => resolve(out));
});
}
重点是 PYTHONUNBUFFERED: "1":它让 Python 子进程及时写出内容。仅仅注册 data 监听器,不能催出还留在子进程缓冲区里的日志。
我的判断
作者对缓冲机制的解释,有文档依据,也给了可以复现的最小程序,适合作为「终端正常、Agent 沉默」的排查入口。
但「本地持续输出、Agent 没输出」还不足以直接判定缓冲故障。工作目录、环境变量和交互输入也可能不同。作者把这种对照直接归结为缓冲,判断略快,仍需要检查 stdout 和运行条件。
上面的 TypeScript 示例也有明确边界:它在 close 时才返回完整字符串,没有把中间输出主动推送给模型。要实现「仍在运行,最后一行是 X」,还需要在 data 事件里接入进度通道。此外,它没有根据退出码区分成功与失败,会把 stdout、stderr 混合积存在内存中,不能直接当成完整的生产命令执行器。
相比一律加 PTY,先使用 -u、显式刷新或行缓冲更容易控制。PTY 会改变程序对运行环境的判断,适合难以修改的特定命令。
作者展示了 Hermes 2.3.4,并说明它采用 BSL 1.1、支持 macOS、Windows、Linux。但分屏提高排查效率的部分,没有给出对照数据,属于使用经验和产品展示。任何能并排运行普通 shell 与 Agent 的环境,都能完成这项比较。
能拿来做什么
- 遇到 Agent 中的导入脚本长期无输出,先执行 stdout 终端检查,再对比直接运行和接入管道的表现,确认日志是否被缓冲。
- 项目里的 Python 长任务可以统一设置
PYTHONUNBUFFERED=1;需要精确控制输出频率时,在关键进度点使用flush=True。 - 用 Node 或 TypeScript 开发命令工具时,把「接收输出」和「向模型报告进度」分别实现,并处理退出码,避免把收齐文本当成命令成功。
- 排查 Node 脚本末尾日志丢失时,检查是否过早调用
process.exit(),不要直接套用 Python 的无缓冲参数。