第 12 章:成本与延迟
通过模型分层、提示缓存、批处理和提前终止,把成本和延迟当作 PR 审查 agent 的一等设计约束。
小满 · 账房
小满靠得住了,可它越来越贵、越来越慢。今天教它过日子。
草稿章节。跑通格式用的第一版,正式索引前会再打磨。
本章目标
做一个把同样的活干得更省钱、更快的 PR 审查 agent 版本。读完本章,你会有:一个按步骤挑选模型的路由器、一段被缓存的提示前缀、一次对改动文件的并发扇出、以及一个一拿到结论就立刻收尾的停止条件。你还会有一份成本核算脚手架,把每次审查实际花了多少打印出来,让下一步优化由数字驱动,而不是凭感觉。
想清楚一件事就行。每次模型调用都有两种成本:钱(输入加输出 token,按模型计价)和时间(一次往返,它主导了用户感知到的延迟)。随手写的 agent 把这两样当免费的,量一大就得为此付钱。生产里的 agent 会把它们当成一笔要省着花的预算。
前置准备
- 前几章已经跑通、并带有 token 与耗时日志的 agent 主循环。如果你现在还看不到每次调用的输入 token、输出 token、墙钟耗时,先把这件事做好。
- 浏览器里开着服务商的定价页。把价格当作随时查阅的输入,而不是要背下来的常量。模型与费率会变,当前数字请看官方定价页。
- 一组固定的评测集:十到二十个结果已知的真实 PR。它是你衡量每次改动的那把尺子。
动手做
1. 建立基线
不先量一遍,就无从优化。在固定 PR 集上跑一遍,每次审查记四个数:输入 token、输出 token、墙钟秒数、模型调用次数(工具调用轮数)。求和再相除,得到每次审查的均值。把它放进一张能反复重跑的小表里,每改一项就重跑。Microsoft 的生产课讲的是同一件事:每次运行的 token 成本和延迟,就是让你能看清 agent 内部、而不是只盯着一个黑盒的指标。
import time
import anyio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
# 成本核算脚手架。账单不用自己算价格:ResultMessage 直接给你
# 这次跑的总成本、轮数与 token 用量(usage 的具体字段名以官方为准)。
async def review_with_cost(pr_id, diff, options):
start = time.monotonic()
async for message in query(prompt=f"审查这段 diff:\n{diff}", options=options):
if isinstance(message, ResultMessage): # 终态:成本与用量都在这里
elapsed = time.monotonic() - start
print(f"review={pr_id} turns={message.num_turns} "
f"cost=${message.total_cost_usd:.4f} secs={elapsed:.1f} "
f"usage={message.usage}")
anyio.run(review_with_cost, pr_id, diff, options)
import { query } from "@anthropic-ai/claude-agent-sdk";
// 成本核算脚手架。账单不用自己算价格:result 消息直接给你
// 这次跑的总成本、轮数与 token 用量。
async function reviewWithCost(prId, diff, options) {
const start = Date.now();
for await (const message of query({ prompt: `审查这段 diff:\n${diff}`, options })) {
if (message.type === "result" && message.subtype === "success") {
const secs = (Date.now() - start) / 1000;
console.log(`review=${prId} turns=${message.num_turns} ` +
`cost=$${message.total_cost_usd.toFixed(4)} secs=${secs.toFixed(1)}`,
message.usage);
}
}
}
2. 给模型分层
不是每一步都需要最强的模型。最终的审查判断用强模型,因为漏掉一个真 bug 代价很高;机械性的子任务用更便宜、更快的模型:判断改了哪些文件、概括一段 diff、决定某个文件值不值得读。这就是生产课里的「路由模型」模式:按复杂度路由,把大模型留给真正需要推理的地方。省下来的钱会累加,因为便宜模型那几步每次审查通常要跑很多次,而强模型那一步只跑一次。
from claude_agent_sdk import ClaudeAgentOptions
# 路由表:哪一步用哪个模型。model 接受别名 "haiku"/"sonnet"/"opus"。
MODEL_FOR = {
"triage_changed_files": "haiku", # 便宜,跑一次
"summarize_hunk": "haiku", # 便宜,逐文件跑
"is_file_worth_reading": "haiku", # 便宜,逐文件跑
"final_review_verdict": "opus", # 强,跑一次
"security_deep_dive": "opus", # 强,仅在 triage 标出风险时
}
def options_for(step):
return ClaudeAgentOptions(model=MODEL_FOR[step], allowed_tools=["Read"])
// 路由表:哪一步用哪个模型。model 接受别名 "haiku"/"sonnet"/"opus"。
const MODEL_FOR = {
triage_changed_files: "haiku", // 便宜,跑一次
summarize_hunk: "haiku", // 便宜,逐文件跑
is_file_worth_reading: "haiku", // 便宜,逐文件跑
final_review_verdict: "opus", // 强,跑一次
security_deep_dive: "opus", // 强,仅在 triage 标出风险时
};
function optionsFor(step) {
return { model: MODEL_FOR[step], allowedTools: ["Read"] };
}
取舍在于:便宜模型做 triage 可能误判某文件低风险而跳过。防住这点的办法是让 triage 偏向升级(拿不准就交给强模型),并对便宜模型标过风险的任何文件保留强模型的安全复查。
3. 缓存稳定前缀
你的系统提示、工具 schema、审查清单在每次调用里都一模一样。不开缓存,你就要在每次审查的每一轮里都按全价重新处理它们。提示缓存让你一次性付一笔写入费,之后在缓存有效期内以很深的折扣读回同一段前缀。用 Agent SDK 时你不必手动标 cache_control 断点:把稳定内容放进 system_prompt,CLI 会替你缓存这段前缀,每个 PR 各异的 diff 走 prompt。
要点只有一条:稳定的放 system_prompt,每个 PR 变的放 prompt。把易变内容混进 system_prompt,前缀就会失配,永远命中不了。命中与否,看 ResultMessage.usage 里的缓存读字段是否非零(具体字段名以官方为准)。
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
options = ClaudeAgentOptions(
system_prompt=REVIEW_RUBRIC, # 稳定前缀:CLI 缓存它
allowed_tools=["Read"],
model="opus",
)
async for message in query(
prompt=f"审查这段 diff:\n{pr_diff}", # 每个 PR 都在变
options=options,
):
if isinstance(message, ResultMessage):
print(message.usage) # 看缓存读字段是否非零,确认命中
import { query } from "@anthropic-ai/claude-agent-sdk";
const options = {
systemPrompt: REVIEW_RUBRIC, // 稳定前缀:CLI 缓存它
allowedTools: ["Read"],
model: "opus",
};
for await (const message of query({
prompt: `审查这段 diff:\n${prDiff}`, // 每个 PR 都在变
options,
})) {
if (message.type === "result" && message.subtype === "success") {
console.log(message.usage); // 看缓存读字段是否非零,确认命中
}
}
两点运维提醒。默认缓存有效期较短(5 分钟,每次命中刷新);对那些不是每隔几分钟就复用一次的前缀,存在一个 "ttl": "1h" 选项,代价是更高的写入费。还有一个最小可缓存长度(当前的中大型模型量级在 ~1k token 左右):低于它,缓存会被静默跳过。验证是否命中就看 cache_read_input_tokens 是否非零。准确的阈值与字段请看提示缓存文档。
4. 批量处理独立的工作
如果你要审查十个彼此无依赖的文件,不要做十次串行往返。串行调用的延迟是每次往返之和;并发调用的延迟由最慢的那个决定。把逐文件的概括与风险检查并发扇出去,再汇总它们交给唯一一次最终判断。对多文件 PR 来说,这一招对墙钟时间的提升最大。
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, TextBlock
async def summarize_and_flag(f):
opts = ClaudeAgentOptions(model="haiku", allowed_tools=["Read"])
out = ""
async for message in query(prompt=f"概括并标注风险:{f}", options=opts):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
out += block.text
return out
async def review_pr(files):
# 把便宜、独立的逐文件工作并发扇出:每个文件一次 query()
findings = await asyncio.gather(*[summarize_and_flag(f) for f in files]) # haiku,并行
# 一次强模型调用看到全部汇总上下文
return await final_verdict(findings) # opus,一次
import { query } from "@anthropic-ai/claude-agent-sdk";
async function summarizeAndFlag(f) {
const opts = { model: "haiku", allowedTools: ["Read"] };
let out = "";
for await (const message of query({ prompt: `概括并标注风险:${f}`, options: opts })) {
if (message.type === "assistant") {
for (const block of message.message.content) {
if (block.type === "text") out += block.text;
}
}
}
return out;
}
async function reviewPr(files) {
// 把便宜、独立的逐文件工作并发扇出:每个文件一次 query()
const findings = await Promise.all(files.map(summarizeAndFlag)); // haiku,并行
// 一次强模型调用看到全部汇总上下文
return finalVerdict(findings); // opus,一次
}
扇出时要留意限流:用有界并发(在 gather 外套一个信号量)能防止你撞上服务商的每分钟上限。这一点我们在部署章里收紧。
5. 提前终止并限制轮数
一旦 agent 拿到足够信息可以给出结论,就停。这里有两种浪费预算的失败模式:agent 继续去读它根本不需要的文件;agent 陷入循环(反复读、反复想)却不收敛。设一个显式的停止条件和一个硬性的工具调用轮数上限。生产课点名了死循环这个问题;清晰的终止条件就是解法。
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
options = ClaudeAgentOptions(
system_prompt=REVIEW_RUBRIC,
allowed_tools=["Read"],
max_turns=6, # 轮数硬上限:迷糊的循环到顶就停
max_budget_usd=0.50, # 预算上限:超了返回 error_max_budget_usd
)
async for message in query(prompt=f"审查这段 diff:\n{pr_diff}", options=options):
if isinstance(message, ResultMessage):
if message.subtype == "error_max_budget_usd":
# 触顶仍未收敛:标记交人工
handle_capped(message, reason="预算上限")
# 完成:拿到结论自然收尾,不灌水
import { query } from "@anthropic-ai/claude-agent-sdk";
const options = {
systemPrompt: REVIEW_RUBRIC,
allowedTools: ["Read"],
maxTurns: 6, // 轮数硬上限:迷糊的循环到顶就停
maxBudgetUsd: 0.50, // 预算上限:超了返回 error_max_budget_usd
};
for await (const message of query({ prompt: `审查这段 diff:\n${prDiff}`, options })) {
if (message.type === "result") {
if (message.subtype === "error_max_budget_usd") {
// 触顶仍未收敛:标记交人工
handleCapped(message, "预算上限");
}
// 完成:拿到结论自然收尾,不灌水
}
}
习得「精打细算」小满不再每步都掏顶配模型,琐碎的活派给便宜模型,能复用的前缀缓存起来,独立的文件并发着读,一拿到结论就停,每次审查花了多少 token、多少秒它都记得清清楚楚。
如何验证
每改一项就重跑基线集并对比那张表。确认每次审查的 token 与墙钟下降,且强模型的调用次数大致稳定在一次。最关键的是,确认在一组留出的 PR 上审查质量没有退化:一个会漏掉真 bug 的便宜 agent 不是更便宜,而是坏了。对缓存而言,在一次审查的第二次调用上断言 cache_read_input_tokens > 0;如果它是零,说明你的前缀在变,或者短于最小长度。
习得「拿数字说话」你现在能在一组留出的 PR 上确认省下来的钱没拿质量换,每改一项就重跑基线表,看 token 和延迟有没有真降、缓存有没有命中、有没有漏掉真 bug。
原理
这四招各管一头,而且能叠加用,互不干扰。分层降低每一步的单价。缓存降低重复前缀的单价。批处理不改变总 token,只降墙钟。提前终止减少步数。按上面的方式做,它们都不会用质量换省钱,因为每一个砍掉的,都是本来就对结论没用的工作。
小结
成本与延迟是设计约束,不是事后才想的事。先测一个基线,把便宜的活交给便宜的模型,把不变的前缀缓存住,把独立的步骤并行,一拿到结论就停。每改一项都重新测一遍,下一步改什么看数字,别凭感觉。
常见坑
- 缓存了每次都在变的内容(缓存块里塞了时间戳或每个 PR 各异的上下文),结果哈希永远对不上,永远命中不了。
- 因为顶配模型已经接好了,就拿它去做琐碎分类。
- 一边抠 token,一边悄悄把审查质量做差了;务必每次重查留出集。
- 无界并发扇出撞上服务商限流,把一次提速变成一墙的 429。
- 把价格当事实写死在代码或正文里;费率会变,请去定价页查。
小满学会了省着花,甚至会主动说:「这活不值得用大模型,我用小的就行。」它开始像个会过日子的人。账房,亮了。
刚点亮 账房 · 地图已点亮 13 / 16
来源
- Anthropic 文档:提示缓存 · official
- Microsoft:AI Agents in Production(成本管理) · official