Elixir轮询循环一夜烧掉63美元:一次代码审查的教训

一个电平触发式PR轮询器因检查历史审查状态而非当前状态,一夜触发243次代理运行,浪费63美元。
作者在某天早晨发现一张看板卡片异常活跃,追查后发现前一晚8小时内触发了243次代理运行,消耗约3100万输入token、花费64.25美元,其中63.10美元完全浪费,零次有效提交。根本原因是一个用自然语言生成、看似无聊可靠的GitHub轮询器:它每两分钟检查一次PR,但判断逻辑是"曾经是否被请求修改"而非"当前是否需要修改",而GitHub reviews API返回的是历史审查列表,一旦出现`CHANGES_REQUESTED`就永远为真。三道本应阻止循环的防线全部失效:新鲜度检查在审查分支上不可达、自动修复计数器被传参`0`重置、分支顺序让批准分支永远到不了。修复方案是将审查与最后一次提交时间对比,加上过期守卫,并降级处理可选API调用。作者由此反思了AI代理代码的审查方式。
从看板上的异常卡片说起
某个早晨我打开看板,发现一张卡片状态异常——不是崩溃,而是异常活跃。卡片背后堆积了从前一晚开始的大量活动记录,而我自从午餐后就再没碰过它。
出于直觉,我开始追查那晚到底发生了什么。结果令人震惊:8小时内243次代理运行,约3100万输入token,花费64.25美元——其中63.10美元纯属浪费,零次有效提交。
更让我难以接受的是:这些代码不是我写的,而是Claude Code在Camelot平台上根据我创建的任务自动生成的。我阅读了diff,批准了合并。当时它看起来完全正常。
循环停止的原因不是被我发现或拦截,而是运行器最终因为畸形输出崩溃,将任务推入错误状态。事后看来,连这次崩溃都像是团队中最负责任的成员。

一个两分钟的GitHub轮询器
在Camelot AI中,任务通过看板流转。当代理打开Pull Request后,任务进入pr阶段,轮询器接管:每两分钟检查一次GitHub并做出反应。已合并→完成;CI失败→让代理重新介入;审查者请求修改→让代理重新介入。
这个轮询器本身就是一个Camelot任务。我用自然语言描述需求,代理自动生成了PR。使用Elixir的Ash和Oban框架,触发器几乎不需要编写代码:
triggers do
trigger :check_pr_status do
action(:check_pr_status)
scheduler_cron("*/2 * * * *")
queue(:github)
max_attempts(3)
where(
expr(
not is_nil(pr_number) and
stage == :pr and
project.status == :active
)
)
end
end
这就是全部代码。AshOban每两分钟根据where表达式查找匹配行,并对每一行执行动作。我手写过几十个这样的触发器,它们无聊但可靠——正是这种熟悉感让我快速浏览了这份diff。
问题在于,这个无聊可靠的触发器背后,是一个会启动容器并消耗LLM token的操作。
级联触发的代价
问题的核心在于代码测试的是状态("有人请求修改")而非事件("有人请求修改,且我尚未处理")。
GitHub的reviews端点返回所有历史审查记录。一条CHANGES_REQUESTED审查一旦提交,就永久存在于列表中。即使审查者后来批准,旧的CHANGES_REQUESTED仍然保留。
所以has_review_state?(reviews, "CHANGES_REQUESTED")实际在问"这个PR是否曾经被请求过修改?"而非"这个PR当前需要修改吗?"。一旦为真,永远为真。
每两分钟,触发器匹配;每两分钟,条件为真;每两分钟,Camelot派遣一个代理去修复几小时前就已解决的反馈。代理尽职地查找、发现无事可做、干净退出——243次。
这就是所谓的电平触发检查(level-triggered check)。在普通的Elixir应用中,冗余的电平触发只意味着每两分钟多一次数据库查询,你永远不会注意到。但这里的执行器会启动容器、克隆仓库,并向语言模型发送约13万token的上下文。
冗余轮询在普通应用中是四舍五入误差,在这里却是账单。
还有一个二阶效应让情况更糟:提示词包含PR对话历史。每次无用运行都会向对话添加自己的噪音,导致每次运行的成本从早期的约0.12美元稳步上升到后期的0.45美元。循环不仅在浪费,还在加速浪费。
电平触发与边沿触发是电子工程与数字系统设计中的基本概念,字面含义来自信号电平(持续的高/低电位)与信号边沿(从高到低或从低到高的瞬间跳变)。电平触发的含义是"只要信号处于某个状态就持续响应",边沿触发的含义是"只在信号发生变化的瞬间响应一次"。这个区分在分布式系统和事件驱动架构中同样适用:把"存在某条未处理的反馈"当作触发条件(电平触发),会不停重复响应;而把"刚刚提交了一条新反馈"当作触发条件(边沿触发或基于事件的处理),则每条反馈只被消费一次。实践中要安全地做电平触发,通常需要配合一个持久化的"已处理水位"标记,例如记录 last_seen_at 时间戳或消息ID,用它把"状态存在"收敛为"状态存在且尚未处理"。缺少水位标记的电平触发,在状态长期为真时就会退化成无限循环,这正是本文事故的根本原因。
三道防线为何全部失效
这个系统并非没有限制。有三道机制本应阻止它,但循环全部绕过了。
新鲜度检查在该路径上是死代码。 系统有一个pr_comments_seen_at时间戳,专门用于避免重复处理反馈。但它只在评论分支中被查询,而评论分支位于同一cond中审查分支的下方。审查分支永远不会触及它。守卫存在、正确,但不可达。
分支重置了本应限制它的计数器。 看这个调用:
request_changes(task, comments, 0)
第三个参数是自动修复尝试计数器。系统对连续自动重派遣有上限(默认2次),但该上限只计算合并冲突和CI失败的修复,而这个分支传入0,每次通过都重置计数器。守卫被代码主动解除武装,每小时两次,整夜不停。
分支顺序让恢复变得不可能。 CHANGES_REQUESTED在APPROVED之前被检查。即使审查者后来批准了PR,循环仍会继续触发——前面的分支先匹配,批准分支永远不会到达。
这些都不是奇特的错误。每一个在代码审查中读起来都很正常——我对此有切身体会,因为每一行都曾出现在我的屏幕上,而我都读得很"顺"。
GitHub PR reviews API 的一个关键设计特性常被误读:GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews 返回的是该 PR 上所有已提交的审查记录列表,每条记录包含 state(APPROVED、CHANGES_REQUESTED、COMMENTED、DISMISSED 等)、submitted_at 时间和审查者身份。也就是说它是一个追加型的日志,而不是"当前裁决快照"。同一个审查者可以多次提交审查,列表里会同时保留旧的和新的记录。要判断"这个 PR 现在是否被请求修改",必须对每条记录按审查者分组、取每人最新的有状态审查,再结合最后一次 commit 的时间判断该裁决是否已被后续提交冲销。直接对列表做 Enum.any?(state == "CHANGES_REQUESTED") 等同于问"历史上是否曾有人请求过修改",一旦为真就永远为真——这类错误在 GitHub 自建工具中非常普遍。
修复方案:将审查与最后提交对比
两处修改,重要的不是显而易见的那处。
第一,在询问任何问题之前,先将审查列表缩减为每个审查者的当前裁决:
@review_states ~w(APPROVED CHANGES_REQUESTED DISMISSED)
def latest_reviews(reviews) do
reviews
|> Enum.filter(&(&1["state"] in @review_states))
|> Enum.group_by(&reviewer_login/1)
|> Enum.map(fn {_login, submitted} ->
Enum.max_by(submitted, &review_date/1)
end)
end
按审查者分组,取每个审查者最新的有状态审查。COMMENTED和PENDING不携带裁决,永远不覆盖之前的裁决。
第二,也是真正阻止循环的部分——对审查应用与评论路径相同的过期守卫:
def changes_requested?(reviews, last_commit_date, seen_at) do
reviews
|> latest_reviews()
|> Enum.any?(fn review ->
review["state"] == "CHANGES_REQUESTED" and
unaddressed?(review_date(review), last_commit_date, seen_at)
end)
end
裁决必须新于最后一次提交(推送修复即视为已处理)且未被处理过。两个独立条件让审查被视为已处理。
在实际事件中,仅此一项就能一次通过终止循环。审查提交于11:46:46Z,而头提交是11:52:34Z。审查在循环开始前就已经过期。一次时间戳比较,这就是全部悲剧。
第二个Bug:with链中的可选调用
同一PR中还有第二个失败,同样的味道。
轮询器在一个with中链接了五次GitHub调用:
with {:ok, pr_data} <- Client.get_pull_request(...),
{:ok, reviews} <- Client.list_pull_request_reviews(...),
{:ok, comments} <- Client.list_pull_request_comments(...),
{:ok, commits} <- Client.list_pull_request_commits(...),
{:ok, check_runs} <- fetch_check_runs(...) do
# ...
else
{:error, reason} ->
Logger.warning("Failed to check PR for task #{task.id}: #{inspect(reason)}")
end
这看起来是好的Elixir代码。问题在于最后一次调用需要GitHub App安装的Checks: Read权限,没有它GitHub返回403。
一个403,整个with就塌缩成一行日志。合并冲突处理、审查处理、评论处理、已合并检测——全部停止,对pr阶段的所有任务静默失效,只因为一个可选的增强调用。
修复方法是明确允许这个调用失败:
@spec best_effort_check_runs({:ok, [map()]} | {:error, term()}) :: {:ok, [map()]}
def best_effort_check_runs({:ok, runs}), do: {:ok, runs}
def best_effort_check_runs({:error, reason}) do
Logger.warning("check-runs unavailable (#{inspect(reason)}); treating as no checks")
{:ok, []}
end
降级为"无检查"并继续运行。CI失败在权限授予前不会被发现,这很烦人但可存活。其他一切继续工作,这才是重点。
with为整个链提供一个错误通道——这是特性。当链中某一环并非必需时,它就变成了bug,而且会静默失败。
Elixir 的 with 表达式用于串联多个可能失败的操作:它按顺序匹配每个子句,只要有一个子句匹配到 else 分支的模式,整条链就会短路跳到 else。这个设计的价值在于把"第一个错误"作为统一的失败出口,避免层层嵌套的错误处理代码。但它也带来一个隐患:with 隐含地把链上所有步骤视为"必需的成功条件",一旦某一步是可选的(例如仅仅为了增强信息而调用的 API),一个非致命的失败就会静默地摧毁整个链后续的所有逻辑。惯用的处理手法是为可选调用包一层总是返回 {:ok, ...} 的适配函数(如文中 best_effort_check_runs/1),把它从"成功/失败"语义降级为"成功/降级",并在 @spec 里明确表达这一契约。这条经验适用于任何把错误通道统一的链式写法,包括 Rust 的 ? 运算符组合和 Promise 链中的 catch 边界。
工作方式的改变
我一直在用错误的方式审查代理diff。 我审查命名、结构、是否契合代码库、是否会用同样方式写。这些在这里都正常,这正是我合并的原因。现在我对任何涉及循环的diff问三个问题:什么让它触发?什么让它停止?一次通过的成本是多少?
电平触发检查需要终止条件,而不仅仅是触发条件。 "这个状态为真吗?"不够。"这个状态为真且我尚未处理吗?"才是真正的问题,后半部分必须是持久的。
永远不要在守卫保护的分支内重置守卫。 如果必须指出让我损失63美元的那一行,就是那个0。一个字符,我没有写,也没有质疑。
尽力而为的调用不属于with链。 如果某步骤是可选的,写一个让它总是成功的函数,并在类型签名中说明。
成本是一等信号。 我有日志,有会话记录。但我没有的是能告诉我"这个任务今天运行了243次"或"这个任务花了60美元"的东西。一个计数器和阈值本可以在20分钟内捕获问题,而不是8小时。
AI 代理生成的代码审查正在形成一个独立于传统代码审查的新规范。传统 CR 关注命名、结构、测试覆盖率、是否符合团队约定;这些维度对代理生成的代码仍然需要,但远远不够,因为代理代码的失败模式来自它"看起来很熟悉"的幻觉——它会照搬常见模式,却对模式所处的上下文不敏感。针对这类代码,业界逐渐在推荐几个额外的检查点:循环/调度逻辑的触发条件(什么让它启动)、终止条件(什么让它停止或去重)、单次执行的真实成本(是否启动容器、是否调用 LLM、是否产生计费事件)、错误路径是否被静默吞掉、以及守卫(guard)是否有不可达或可被绕过的分支。把这些问题显式写进审查清单,比提高对代码风格的关注更能缩小代理代码的事故面。
结论
触发器三行,循环分支四行。这个事件中没有任何复杂的东西,在普通CRUD应用中也不会造成任何影响——冗余轮询只会花费几百次廉价查询,我永远不会知道。
变化的是循环另一端的执行器。当执行器花钱时,"无害的冗余工作"不再是一个类别。每一次通过都必须证明自己的价值。
还有更难写的部分。Camelot的存在是因为我认为代理的工作应该经过人类关卡才能成为现实。关卡在那里,它完全按设计工作。关卡就是我,在一个周二晚上,看着一份像是我自己会写的diff,点击批准。人类审批循环不是保证,而是一个需要人类真正在场的地方——这比我以为自己在做的承诺要难得多。
我的练习说:注意重复的想法,停止喂养它。看来我的轮询器需要这个教导,我的阅读也是。
相关推荐

2026年AI编程工具横评:Claude、ChatGPT、Gemini谁最强?
2026年五款主流AI编程工具横评:Claude Code、ChatGPT Codex、Gemini、DeepSeek和GitHub Copilot在真实项目、定价与代码质量上的全面对比,帮你决定每月20美元该花给谁。

ChatGPT Codex克隆任意软件?实测风险与真相
YouTube短视频宣称用ChatGPT Codex或Claude Code即可克隆Adobe、Excel、Figma等软件并终身免费使用。本文拆解其技术可行性、法律风险与私信引流套路,说明AI编程工具真正适合的用法。

Kilo Code免费安装但API收费:成本估算与配置指南
Kilo Code 开源 AI 编程代理安装免费,但接入的 AI 模型按 token 收费。本文详解 VS Code 扩展与 CLI 配置步骤,并用假设用量演示 API 成本估算方法,对比按量付费与订阅制的选择逻辑。