想象一个场景:你下班前给 Codex 丢下一句「给订单服务加一个下单事件,再让通知服务发确认邮件」,然后关掉电脑回家。第二天早上,功能已经写好、测试通过、甚至已经在 preview 环境里跑起来了,你只需要扫一眼 diff、点一下合并。这个画面,就是很多人对「AI 无人值守编程」的终极想象。

但真正试过的人会告诉你,事情远没有这么美好。作者 Ivan 在过去几个月里一直在搭这样一个环境,让 Codex 在没人盯着的情况下自己写后端代码,审批关闭、输出也不看。他给出的结论有点反直觉:这件事里,提示词几乎不是重点。真正会出问题的,是 agent 在补全那些代码库里根本没告诉它的运维细节,而这些错误往往要等几周后流量涨上来才爆发,而不是在 diff 里就现形。

于是他不再纠结怎么把提示词写得更漂亮,而是把精力全部放到了「护栏」上。所谓护栏,就是让 agent 就算跑偏了,也翻不出什么大浪。这套护栏一共六层:一份每次运行都会读的 AGENTS.md、一个限制 shell 权限的沙箱、用 codex exec 做无人值守运行、一套让基础设施无需被 agent 猜测的技术栈、一个能用 trace 自查的本地环境,以及一个在代码真正进入生产账户之前先落地的 preview 环境。下面一层一层拆开讲。

真正的坑:agent 会替你把运维细节补全#

先讲一个贯穿全文的核心判断:无人值守编程里,大部分致命错误不是模型写错了业务逻辑,而是它「发明」了一些代码库里根本没有依据的运维参数。

Ivan 举了一个很形象的例子。如果你让 Codex 在一个 Express + Terraform 的项目里加一个事件队列,它可能会生成这样一段 Terraform:

resource "aws_sqs_queue" "order_created" {
  name                       = "order-created"
  visibility_timeout_seconds = 60
  message_retention_seconds  = 1209600

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.order_created_dlq.arn
    maxReceiveCount     = 5
  })
}

看起来挺像那么回事。但仔细看这几个数字:visibility_timeout 必须大于 handler 的实际运行时长,而这个时长应用代码里根本没有写;maxReceiveCount 等于 5 只有在 handler 幂等的时候才正确,否则就是一次线上事故。更麻烦的是,terraform validate 会接受这一切,沙箱也不会拦它,因为在工作目录里写文件本来就是 workspace-write 模式的职责。换句话说,这段「看起来完全合法」的代码里,藏着三个纯靠猜的参数,而它们要到生产环境流量上来之后才会引爆。

这正是 Ivan 的核心洞察:agent 在补全那些代码库「没告诉它」的东西时,是最危险的。你没法靠更好的提示词消除这种风险,因为问题不在于它没听懂,而在于它太擅长把一个没有答案的问题,填上一个看起来合理的答案。所以护栏的目标只有一个:把「需要凭空猜测」的空间,从 agent 手里拿走。

第一层:AGENTS.md 只写「有非显然答案的约定」#

Codex 每次运行都会读 AGENTS.md,而且是从外到内逐层合并:先是 ~/.codex/AGENTS.md,然后是 git 根目录下的那个,再是根目录到当前工作目录之间的各个子目录,越靠里的文件在冲突时优先级越高。

Ivan 的原则是:这份文件里只放两类东西。第一类是「答案不显然、但值得写下来」的约定,比如迁移文件必须 append-only、查询必须用 tagged template 而不是 raw 版本、凭证只能从 secret() 取。第二类是能让 agent 判断「我到底算不算做完」的验证命令。

他给出的完整例子很短:

# Orders API

Encore.ts application. One directory per service.

- Infrastructure is declared in code. Do not add Terraform, Dockerfiles, or docker-compose.
- Migrations are numbered N_description.up.sql and append-only.
- Queries use tagged templates, never the raw* variants.
- Credentials come from secret("Name"), never process.env.

Before reporting done, run:
  encore check
  encore test

就这么多,别的什么都不放。他特别强调,一个没有验证命令的 agent,会在「代码看起来写完了」的时候就停下来,而基础设施类代码离「真正写完」通常还差着老远。至于那些 skills 之类的打包方案,他试下来的感觉是,它们对上下文的膨胀远大于帮助。

这一点我个人很认同。AGENTS.md 的价值不在于给模型塞更多信息,而在于把那些「你不写它就会自己猜」的边界钉死。写约定的时候,值得反复问自己一个问题:这条规则,模型光看周围的代码能自己推断出来吗?如果答案是「能」,那就不用写。

第二层:沙箱,让危险的命令跑不出去#

审批关掉的 agent,迟早会跑一条你不会批准的命令,所以隔离是必须的。市面上的常见做法有:一次性虚拟机、容器、或者 Daytona、Fly Sprites 这类远程环境,这些都行。但 Codex 自己就带了一个沙箱,而且是零配置的最低摩擦选项。

Codex 在 Linux 上用 bubblewrap + seccomp,在 macOS 上用 Seatbelt,来执行模型生成的命令,一共有三种模式:

  • read-only:禁止一切写入。
  • workspace-write:允许在工作目录内写入,但 .git 只读,同时封锁网络访问。
  • danger-full-access:把上面两条限制都去掉。

审批策略是另一个独立设置,和沙箱模式是分开的。这里有个很实用的技巧:codex sandbox 子命令可以把某个策略直接套在任意命令上,而不经过模型,正好用来验证某种模式到底放行了什么:

$ codex sandbox -c sandbox_mode=workspace-write -- bash -c 'echo hi > ~/escape.txt'
bash: line 1: /home/andout/escape.txt: Read-only file system

退出码 6 是 curl 的「无法解析主机」,说明这次 DNS 查询根本没离开沙箱。

隔离只覆盖文件和 socket,不覆盖 shell 继承的环境变量,所以 shell_environment_policy 值得和模式一起配。Ivan 用的是 workspace-write 加 on-request 审批,network_access 关掉,环境变量只继承 core 那一组,也就是 PATH、HOME、USER、SHELL、TMPDIR 和 locale,剩下的全都丢掉。

第三层:codex exec,把运行变成无人值守#

文件和沙箱都就位之后,就没有什么需要盯着看的了,所以运行走 codex exec 而不是 TUI。它把任务当作参数传入,沙箱模式作为 flag:

$ codex exec -s workspace-write \
    "Add an order-created event. Publish it from the orders service..."

这时候依赖已经装好,本地 Encore 环境也不需要外呼,所以这些运行的网络访问保持关闭,真正需要网络的场景其实很少。还有一个细节值得记下来:如果结果「接近但不对」,codex exec resume --last 可以带着完整上下文继续那个会话。裸的 codex resume 默认会跳过非交互式会话,得手动加上 --include-non-interactive 才能找到 codex exec 跑过的会话。

第四层:让基础设施「无需被发明」#

回到开头那个 SQS 的例子。那些被猜出来的参数,之所以会存在,是因为 Terraform 要求你显式写出每一个值,而应用代码里又没有这些值的出处。Ivan 的解法是换一个技术栈,用 Encore 这类把基础设施直接声明在代码里的框架。

在 Encore 里,同一个「下单事件」的需求,写出来是另一个样子:

import { Topic, Subscription } from "encore.dev/pubsub";

export interface OrderCreated {
  orderID: string;
  customerID: string;
}

export const orders = new Topic<OrderCreated>("order-created", {
  deliveryGuarantee: "at-least-once",
});

new Subscription(orders, "send-confirmation", {
  handler: async (event) => {
    await sendEmail(event.customerID, event.orderID);
  },
});

关键在于,发布者和订阅者共享同一个类型。你在一边加了一个字段、另一边漏了,结果不是运行期拿到 undefined,而是直接编译失败。而 visibility_timeout、maxReceiveCount 这类参数在 Encore 里都有默认值,声明里根本不用出现,除非你真的想改其中一个。

Encore 还会在编译期把应用静态分析成一张图,把服务、端点、topic、数据库之间的依赖关系都建模出来,然后从这个模型去 provisioning。于是没有第二套文件需要 agent 去同步,一份声明解析不过就是构建错误,而不是一个悄悄没出现的资源。这层护栏的精髓在于:它把「猜」这个动作从流程里直接删掉了。

第五层:用 trace 自查,而不是用「编译通过」#

静态类型能定契约,但行为层面的东西都得靠真跑起来。本地跑起来只需要一条命令 encore run,它会拉起 Postgres、Pub/Sub broker 和对象存储,还带一个 dashboard,里面有服务目录、API explorer 和分布式追踪。本地环境默认禁用 cron,所以 agent 在调一个定时任务的时候不会误触发。

真正关键的是:agent 发布一个事件之后,能读到这条发布、订阅的投递、handler 跑的查询,以及每一步的耗时。这是一种和「编译通过」完全不同的证据等级。再加上 AGENTS.md 里已经写好的 encore check 和 encore test,验证闭环就闭合了,而且全程不需要部署任何东西。

第六层:让 diff 被 review,让成本够不到 agent#

Codex 有一个独立的非交互式 review 命令,值得拿它去审 agent 自己的活,因为 review 是从「没有生成代码的上下文」开始看的,相当于换了一双眼睛:

$ codex review --uncommitted
$ codex review --base main

而代码真正进入生产 AWS 账户之前,还有最后一道关:preview 环境。一个 PR 会自动创建一个 preview 环境,跑的是完整的基础设施。这里才是 Ivan 真正去「看」agent 干了什么的地方,因为 diff 显示不出慢查询,也显示不出一个永远不触发的订阅。

还有个很精妙的边界:代码决定「生产里存在哪些资源」,但决定不了「这些资源花多少钱、开多大」。一个 Topic 在本地是内存队列,在 AWS 里是 SQS + SNS;一个 SQLDatabase 本地是 Docker Postgres,生产是 RDS。实例规格、数据库配置这些,都放在 Encore dashboard 或云控制台里,两边保持同步。这样一来,成本相关的决策就落在了 agent 够不到的地方。

总结:护栏拿走的是「凭空捏造」,剩下的才是人该看的#

这套搭建下来,最大的收获是消除了「agent 捏造没有依据的数值」这一类错误,而这恰恰是让无人值守难以落地的最大根源。但 Ivan 也说得清楚,护栏不是万能的,有些东西他仍然会亲自读:迁移是否可回滚、handler 在消息重投时行为是否正常、以及这个功能到底是不是他要的那个。

把这几层连起来看,会发现一条很清晰的思路:能自动验证的交给机器和类型系统,能物理隔离的交给沙箱,能编译期建模的交给框架,而最后剩下来的那点判断,才是人应该花时间的地方。无人值守编程的目标从来不是「完全不管」,而是把人的注意力从盯屏和猜数字里解放出来,放到真正需要判断力的那一小块上。


参考来源:Building in the cloud with Codex, safely(ivan.codes)