让 Agent 写代码时,最容易出现的叙事是“模型不够聪明”:需求理解错了、上下文丢了、工具调用不稳定,所以任务失败。

但我把自己本机的会话日志做了一次归因,看到的画面完全不同。2026-09-10 的那次扫描覆盖 Claude 和 Codex 两条链路。Codex 侧有 2,281 个会话、约 4.33 万次 exec、3,624 次失败;Claude 侧有 587 个会话、4.97 万个工具结果、1,431 次失败。按当时的错误模式和修复路径归类,大约六成失败与环境前置有关,和业务代码无关。

这不是一个可以外推到所有机器的统计实验。它来自一台真实工作站、一组真实项目和一次自动归因,样本也会被同一类重复错误放大。但它足够说明一个问题:Agent 的第一道业务逻辑,不在代码里,而在它有没有获得一个可解释、可重复的执行环境。

一、非登录 shell:二进制存在,但 PATH 里没有

最常见的失败长得很荒谬:bun、gh、psql 明明已经安装,命令却返回 command not found。

原因是 Agent 启动的通常是 non-login shell,不读取 ~/.zprofile。而本机的 ~/.zprofile 第一行是 eval "$(brew shellenv)",PATH 还没补齐,命令查找就已经失败。

另一个更隐蔽的问题是版本遮蔽:~/.local/bin/node 可能先于 nvm 的 Node 出现在 PATH 里。于是一个 node 能运行,另一个 node 才是项目需要的版本;pnpm 报“至少需要 Node.js 18”,看起来像 pnpm 坏了,实际是拿到了错误的运行时。

当时有效的修复不是在每个任务里临时 source nvm,而是把环境初始化放到 non-login shell 也会读取的位置,并让 agent-env 根据 .nvmrc 明确选版本、调整 PATH。

这件事给 Agent 的教训很直接:不要用“我这边能跑”验证环境。 要在 Agent 真正使用的 shell 类型、PATH、工作目录和用户权限下验证。

二、代理不是越早设置越好

网络失败出现时,人的第一反应常常是“把代理加上”。但如果当前环境已经有一个可用代理,强行覆盖成某个固定端口,会把本来正常的网络打断。

Git 还有一个额外陷阱:~/.gitconfig 里的 http.proxy / https.proxy 优先级高于环境变量。只要全局配置钉着一个没有监听的端口,沙箱注入的代理再正确,git 仍然会去连那个死端口。测试里,git ls-remote 直接失败;临时让 GIT_CONFIG_GLOBAL=/dev/null 后恢复正常。

最终规则应该反过来:

  1. 先直接执行;
  2. 只有看到明确的连接失败或证书错误,才设置代理;
  3. Git 的代理优先由环境变量控制,不在全局配置里永久钉端口;
  4. 报证书错误时,先判断是 HTTP 代理、TLS 根证书还是 macOS 平台校验,不要把所有网络错误都归到代理。

把 7890 写进所有配置看起来像是在“修网络”,实际是在制造一个新的单点故障。

三、文件锁:git 写命令需要和清锁发生在同一条链路里

在装有文件系统代理的机器上,.git/worktrees/<name>/index 被外部写入后,可能预先生成 0 字节的 *.lock 占位文件。git 下一次使用 O_EXCL 创建锁时就失败:

fatal: Unable to create '...lock': File exists

这不是代码冲突,也不是远端权限问题。处理方式也不是手动删掉所有锁文件,因为非 0 字节的 index.lock 可能是一次中断的 git add 留下的完整暂存索引,删掉会丢现场。

比较稳的做法是使用一个 gitw 包装器:先清理没有活跃 git 进程时的 0 字节锁,再立刻执行真正的 git 写命令。清锁和写操作必须在同一条 shell 命令里连续完成,中间插入其他步骤,占位锁可能又生成。

这类问题会严重误导 Agent:它看到 commit 失败,可能开始检查用户身份、分支、远端认证,甚至尝试改写历史。正确的第一步应该是识别错误类型,确认这是锁创建失败,而不是仓库或业务问题。

四、包管理器不是普通子进程

pnpm 在沙箱里失败得很典型:项目注册 symlink 会遇到 EEXIST,报 CODEBUDDY_BROKER_DENY。这个 symlink 出现在 pnpm 的上下文初始化阶段,所以几乎任何子命令都可能失败。

解决办法不是全局 unset NODE_OPTIONS。那会让后续所有命令都失去文件写保护,包括 rm。正确做法是只对本次包管理器调用使用 nobroker:去掉这一条子进程链上的 shim 和 broker hook,shell 自身仍保留保护。

另一个看似无关的问题是 TTY。非交互 shell 下,pnpm 可能拒绝重装 node_modules,报 ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY。这时加 CI=true 比伪造一个终端更稳定。

原则可以概括成两句话:

  • 只在必要的最小进程树上降级保护,不要修改整个 shell。
  • 包管理器的错误可能来自权限、TTY、代理、Node 版本或文件系统 hook,不要只盯着 lockfile。

五、macOS 沙箱里,Go 工具可能需要一条 mach-lookup 权限

还有一类错误几乎不可能从应用层猜出来:gh 在沙箱中恒报 TLS 证书无法验证,x509: OSStatus -26276。

curl 和 git 正常,只有 Go 工具失败。根因不是证书过期,也不是代理没设,而是 Go 在 macOS 上会走 Security.framework 的平台校验器;验证 443 证书需要访问 com.apple.trustd.agent,而沙箱没有放行这个 mach lookup。

因此下面这些常见修复都无效:

  • 设置 SSL_CERT_FILE;
  • 使用 fallback roots;
  • 给命令再加一层代理。

正确处理是给沙箱配置精确的 mach lookup 权限,而不是把 com.apple.* 整体放开。安全边界应该逐个服务增加,每次都说明为什么需要、影响是什么。

这也说明 Agent 的“网络正常”不能只用 curl 验证。不同语言、不同 TLS 栈、不同沙箱策略可能得到完全不同的结果。

六、还有一类失败发生在初始化脚本本身

pyenv rehash 在受限文件系统里会失败;补全脚本如果被第三方初始化器重复触发,可能向用户目录写 ~/.zcompdump,在沙箱里触发大量审批并留下孤立文件。

这些问题看起来都属于“shell 环境”,但它们有一个共同特征:命令还没进入业务,就已经在执行副作用。

Agent 很难从最终错误反推出这是初始化脚本写的文件,因为报错可能只是某个命令启动失败。最好的修复也不是让 Agent 多点几次“允许”,而是把初始化改成幂等、可重入、写入固定缓存目录,并尽量避免交互式门控。

七、我给 Agent 的开工前检查清单

现在我会把环境当成项目的一部分,先过下面这些条件:

  1. Shell 类型明确。 知道当前是 login、non-login 还是交互式 shell,关键初始化文件是否会被读取。
  2. 运行时版本可解释。 node、python、bun、pnpm 来自哪里,和 .nvmrc 是否一致。
  3. 网络先直连。 代理只在明确失败后启用,Git 配置不永久钉死端口。
  4. 写操作有包装器。 Git 锁、文件系统占位锁、权限降级都有明确的入口。
  5. 包管理器环境可重复。 非交互 TTY、CI 标志、broker hook 和项目注册路径都在验证范围内。
  6. 错误按层归因。 先分 shell、PATH、网络、权限、锁、运行时、业务,不要看到一个红字就改代码。
  7. 无法确认时 fail closed。 尤其涉及沙箱降级、生产环境和凭据时,不知道就停止,不靠猜继续。

最后

Agent 写得快,不代表它已经在一个可靠的环境里工作。真正让任务反复失败的,可能不是模型不会重构,而是它连正确的 node 都没拿到,连 git 的锁都创建不了,或者网络请求根本走错了 TLS 栈。

Prompt 决定 Agent 想做什么,环境决定它能不能把这件事变成事实。

把失败日志当成系统日志看,而不是当成聊天记录看,很多“模型问题”会立刻变成可修复的工程问题。