Cloudflare 把 AI 代码审查的账单砍掉 68%,钱主要花在缓存写上
这是一个很少见的、把 AI 代码审查的账算到小数点后两位的公开复盘。Cloudflare 4 月写第一篇时,一次审查平均 $1.19;到 8 月底,这个数字变成约 $2.46,而他们并没有做重大架构调整——同一个协调器、同一批专家审查器、同样的风险分级。9 月中,他们让 Agent 去查 token 到底花在哪,然后往死里砍。现在一次审查 $0.77,比之前最贵的两周平均值 $2.43 降低 68%;顺带把中位耗时从 4 分 09 秒压到 1 分 32 秒。省下的钱里,约 40% 来自审查器代码的优化,其余来自「用哪个模型、给多少 effort」的实验。
单次审查成本:3 月 $1.22、6 月 $1.44、7 月 $1.74、8 月 $2.32。
一、先看曲线:钱不是被一个更大的模型吃掉的
把审查分级的构成固定住之后,单次成本是这样爬上去的:3 月 $1.22、6 月 $1.44、7 月 $1.74、8 月 $2.32。一部分原因是更多审查落进最贵的档次(3 月 59%,8 月 65%),而他们认为原因很简单:模型变强了,工程师产出的改动也更大更复杂。但成本上升的主要来源是换模型——8 月初他们把每个审查器都迁到 Anthropic 模型上,单任务成本从 $1.38 涨到 $2.03;内部标准审查器 Codex 换到 Claude Opus 4.7 之后,从每次约 $0.25 涨到 $0.88,比协调器本身还贵。同一时期缓存命中率从 85.7%(4 月)升到 97%(9 月),token 量从 0.91M 涨到 2.99M——命中率高不等于便宜,三倍的 token 量照样要付三倍的钱。
# Step 1: instrument the first call of every session.
# You cannot fix prefix caching until you know whether the
# first request read from cache or paid to write.
def log_first_call(session, response, system_blocks):
usage = response.usage
log.info(
"first_call",
repo=session.repo,
cache_read_tokens=usage.cache_read_input_tokens,
cache_write_tokens=usage.cache_creation_input_tokens,
input_tokens=usage.input_tokens,
# hash the first system block exactly as sent
prefix_hash=sha256(system_blocks[0].text.encode()).hexdigest()[:16],
breakpoints=[b.cache_control for b in system_blocks],
)
# What Cloudflare saw for September 14 onward: the first request of a
# session found a cached prefix only 14% to 26% of the time, even though
# 11k-21k tokens of tool definitions and agent prompt are identical
# in every repository being reviewed.二、钱到底花在哪:缓存写 43%,缓存读 34%,输出 23%
开始优化之前,他们的支出结构是输出 token 约 23%、缓存写 43%、缓存读 34%。也就是说大部分钱花在「模型已经看过或即将再看一遍的上下文」上。放进单个会话里看就很好理解:代码质量审查器一次审查平均发起 16.6 次 LLM 调用,这些调用一共往缓存写了 72k token、读回 1.08M token,因为每次调用都要把整段对话当上下文再发一遍。会话成本随调用次数和上下文长度同时增长,而这两个数他们几个月里都在悄悄变大。要理解后面的所有改动,得先记住前缀缓存的定价方式:写缓存是普通输入价的 1.25 倍,读缓存是 0.1 倍,缓存条目在五分钟内没人读就过期——所以只有「会话第一请求能读到缓存」才有意义。
# Step 2: put the breakpoint where the sharing actually ends.
# BEFORE (OpenCode v1): one block, breakpoint at the very end.
# [ agent prompt + cwd + date + repo-specific banners ][ breakpoint ]
# -> the shared part is never stored on its own, so a review in
# repo A can never reuse what a reviewer in repo B wrote.
SYSTEM = [
{"type": "text", "text": agent_prompt + environment_section,
"cache_control": {"type": "ephemeral"}},
]
# AFTER: split at a marker every job shares.
# The first half ends on bytes that are identical across repositories
# and gets its own breakpoint.
SYSTEM = [
{"type": "text", "text": agent_prompt + "\n<!--SHARED-END-->\n",
"cache_control": {"type": "ephemeral"}}, # shared prefix
{"type": "text", "text": repo_specific_section}, # no breakpoint
]
# To keep the first half byte-stable: repo-specific content must come
# after the marker, the skills list is disabled, and tool descriptions
# cannot change between jobs.三、改动一:断点放错了位置,共享前缀从来没被单独缓存
从 9 月 14 日起,他们开始记录每个审查器第一次 LLM 调用的 token 用量,以及首个系统块原样发送时的哈希。数据很难看:会话第一请求找到已缓存前缀的概率只有 14% 到 26%。可是每个会话开头都是同一批工具定义和同一段 agent 提示词,11k 到 21k token 在他们审查的每个仓库里都完全一样,理论上几乎都该是命中。问题出在 OpenCode v1 构造系统提示词的方式:它把 agent 提示词和一段包含工作目录、日期的环境信息拼成同一个块,唯一一个缓存断点放在这个块的末尾。结果是共享部分从来不会被单独存下来,A 仓库的审查永远无法复用 B 仓库的审查写过的东西。9 月 16 日他们做了两件事:在 agent 提示词末尾加一个标记,再加一个插件钩子把块在这个标记处切成两半,让前半段结束在「每个任务都相同的字节」上并单独拿一个断点;为了保持字节稳定,标记之前的仓库相关内容被清掉,skills 列表禁用,工具描述不允许在不同任务间变化。同一天他们把内部标准审查器的参考索引(一张内部标准表、一个 skill 文件、一份参考文件清单)搬进了提示词——此前每个会话都要用前三次调用去读这些文件。这一个改动的效果是:该审查器每次运行的 LLM 调用从 15.9 降到 9.6,成本从 $1.12 降到 $0.72。两项加起来,单次审查从 $2.47 降到 $2.02。
缓存断点放错位置,等于每次会话都为「本该读到」的前缀付了写入价。
# Step 3: one prefix, three reviewers.
# Code quality, security and performance send identical first requests:
# same tools, same shared rules file, then the merge request with all
# patches. Each reviewer's own instructions follow after that.
# Exactly one of them writes the cache; the other two read it.
async def first_turn(reviewers, request_payload):
writer, readers = reviewers[0], reviewers[1:]
write_task = writer.send(first_request(request_payload))
# A cache entry cannot be read until the response that wrote it
# has started streaming, and all three start at the same time.
await writer.wait_for_first_token(timeout=20)
await asyncio.gather(
*(r.send(first_request(request_payload)) for r in readers)
)
# Costs each reader a small delay; saves about 9 cents per reviewer.四、改动二:不发无用的请求,也不做无用的轮次
9 月 21 日和 23 日那批改动单看都不惊艳,合起来却从每次审查里拿掉约 $0.20。挑几条最有普适价值的:第一,把审查器不能用的工具删掉——子审查器能看到「发布最终审查意见」的工具,在 57% 到 76% 的会话里它们试着调用、被服务端拒绝、然后再花一次调用恢复;删掉之后少一次废调用,每个子审查器请求还少约 2.9k token 的工具定义。第二,并行读文件:代码质量审查器有 64% 的轮次只发一次工具调用,通常就是读一个文件。第三,复审时跳过没事可做的审查器——大约三分之二的作业是同一个 MR 的第二次或更多次审查,全档复审时 AGENTS.md 审查被跳过 56%、性能 23%、文档 13%。第四,意见发布后就停:OpenCode 之前会把结果再交回模型跑一轮,读完整段对话写一个没人看的总结,约 $0.07/作业。第五,把「超时」和「提供商故障」分开处理,并给更长的时间上限,安全审查器的超时率从 1.8% 降到几乎为零。第六,真正的取消:GitLab 的 Kubernetes executor 在作业被取消时不发信号,446 个被取消的作业里有 358 个一直跑到审查结束;现在作业每 15 秒自查一次状态。最能说明「架构级」三个字的是共享前缀:代码质量、安全、性能三个审查器现在发完全相同的首请求(同工具、同规则文件、然后整份 MR 加全部 patch),各自的私有指令放在后面;因为缓存条目在写入响应开始流式返回之前不可读,而三者又是同时启动的,所以第二、第三个会等第一个的响应开始输出,最多等 20 秒,换来每个审查器约 9 美分的节省。
# Step 4: stop paying for calls the model cannot make,
# and stop reading files one at a time.
reviewer_tool_policy:
code_quality:
tools:
- read_file
- read_files_parallel # 64% of turns made exactly one call
- post_review_comment
sub_reviewer:
tools:
- read_files_parallel
- post_finding
# do NOT expose post_review_comment: 57%-76% of sub-reviewer
# sessions tried to call it, were rejected server-side, and spent
# another call recovering. Removing it also drops ~2.9k tokens
# of tool definitions from every sub-reviewer request.
stop_after_review_posted: true # was: one extra turn summarising
# a summary nobody read (~$0.07/job)
timeout_is_not_provider_failure: true
recheck_cancellation_every_seconds: 15 # 358 of 446 cancelled
# jobs kept running五、模型与 effort,以及「便宜了是不是变差了」
把 9 月以来的每个发布和配置变更都切一刀比较,结论有点反直觉:代码改动占 $1.67 里的 $0.65,模型和 effort 的改动占 $1.06。协调器从 8 月起跑三组模型和 effort 组合;同一模型下,全档作业用 medium effort 结束得更快 20%,于是砍掉另外两组。后来一次随机分组显示,Opus 5.5 在 medium 下的审查结果和关键发现与 Opus 5 相比没有可测差别,就切了;Opus 5.5 在 high 下更贵,但发现并没有显著增多。Opus 5.5 的单 token 价格确实更低,但价格只解释了约 $0.20 的降幅,其余来自「发得更少、生成得更少」。10 月 3 日他们把每个 Sonnet 审查器按 50/50 分流到 Sonnet 5 和 Sonnet 5.5,两天半后数据是:代码质量便宜 62%、性能 58%、安全 47%,而两个模型单价相同——全部来自 token 用量。接着是最难的一步:证明便宜不等于差。表面数字确实吓人,代码质量在 high 下的严重和警告级发现少了 40%,安全和性能(仍是 medium)少了 56% 和 71%。所以他们从两个方向验证:一是看作者有没有照做——对照被审查的提交和作者的下一次推送,改动落在被指出行附近的算「有行动」(基线是 15% 的巧合率),严重问题的处理率从 34% 升到 59%,每千次审查「有行动的严重发现」从 145 变成 132,在误差范围内;四个审查器合计则从 229 升到 321。二是让两个不同提供商的模型做盲评,657 条发现、不知道来自哪个时期,分歧交给第三个模型仲裁:每条标为重要/次要/重复/错误/无法验证——每次审查的「重要发现」从 0.28 变成 0.30,区间重叠;真正明显变化的是严重度虚高,从每次 0.46 条降到 0.14 条,降幅 70%。所以他们把代码质量退回 high effort,因为 medium 在随机分组里只带来了大约四分之一的「有行动发现」——那已经不是砍噪音,是砍真问题。
最关键的质量指标:作者对严重问题的处理率从 34% 升到 59%。
# Step 5: attribute the saving, then check the quality.
spend_split_before = {"output": 0.23, "cache_write": 0.43, "cache_read": 0.34}
saving = {
"code_changes": 0.65, # breakpoints, dead tools, fewer calls
"model_and_effort": 1.06, # which model, how much effort
} # total $1.67 per review
# Per-token price was only about $0.20 of the drop. The rest came from
# sending and generating fewer tokens: cache reads -82%, cache writes
# -54%, output -67% since the start of September.
quality_gate = {
"acted_on_rate": (0.34, 0.59), # before -> after
"acted_on_per_1k_reviews": (132, 145), # within noise
"inflated_severity_rate": (0.46, 0.14), # blinded judge, 657 findings
}
# Cheaper findings are fine only if authors still act on them.
# If acted-on rate falls, you cut findings, not noise.📌 常见问题 FAQ
为什么缓存命中率从 85.7% 升到 97%,账单反而更高?
因为命中率是比例,账单看绝对量。同期每次审查的 token 量从 0.91M 涨到 2.99M,命中率再高,三倍 token 的读取成本仍然是三倍。这也是他们后来把指标从「命中率」换成「首请求的缓存读取 token 数」的原因。
前缀缓存为什么必须把断点放在共享部分结尾?
因为缓存以「前缀」为单位:只有断点之前的字节会被存下来。如果把 agent 提示词和仓库相关信息拼在一个块里、断点放在块尾,那存下来的整块就带上了仓库差异,任何其它仓库都无法复用。把断点提前到共享字节结束的地方,共享前缀才会被单独缓存。
为什么删掉一个工具能省钱?
两个层面。一是工具定义本身占 token:删掉一个工具,每个子审查器请求少约 2.9k token。二是行为层面:57% 到 76% 的会话会尝试调用一个它其实没有权限用的工具,被服务端拒绝后还得再花一次调用恢复,等于为一次注定失败的调用付费两次。
把 effort 降到 medium 是不是更划算?
要看审查器。代码质量在 medium 下只有 high 大约四分之一的「有行动发现」,所以被调回 high;安全和性能仍留在 medium,但数据显示性能的「重要发现」少了约 64%,他们自己的下一步就是测 high。结论不是「低 effort 更省」,而是「省下的必须是噪音,不是发现」。
这套经验对自建 agent 的团队有什么用?
三条可以直接抄:第一,先量首请求的缓存读写,再谈优化——大部分团队根本不知道自己的会话有没有命中;第二,把「共享前缀」当成一个要被设计的资产,而不是提示词拼接的副产品;第三,优化后用「作者有没有照做」来衡量质量,而不是只看发现数量的增减。