从0打造编码Agent 阅读 + 实验 ≈ 40 分钟 页内实验 ×1
A8 模型接入:换模型、控成本怎么办? 第 3 课

每轮都重发全部历史,钱花在哪?缓存能省多少?

钱花在哪、缓存能省多少
CoreCoder 的成本估算把每一轮的全部输入按全价算:T5 那次运行在 Claude Sonnet 5 的价目下,它估出的是按「命中 / 写入 / 未缓存 / 输出」四段算的 2.64 倍;读错 usage 字段的口径又是另一种多算。按官方价格快照逐轮现算之后,账单的形状取决于负载:关思考时输入占大头、缓存省下一半以上;开思考时输出占 94%,缓存几乎无关紧要;前缀一变(T10 刷新组第 7 轮 system 重建),命中从 2,072 掉到 294。token 数是本地 Qwen3-8B 的,价格是云端的——页面上的每一笔都是「如果按这个价目」的现算。
❓

本课一问:A1 第 2 课算过:每一轮都把全部历史整份重发。这些 token 要花多少钱?服务端的前缀缓存能省多少?钱到底花在哪一段?

这一课先不碰:KV cache 本身怎么省计算(LLM 课 C3 第 7 课);system 前缀怎么布局才不破坏缓存(A6 记忆那一章);Batch 接口(文末「你可能会问」)。

从已有出发

前几章已经各自看到一块:A1 第 2 课,9 轮请求的输入合计是最后一轮的好几倍;A2 岔路 B1,每一轮的新请求开头原封不动是上一轮;A5 第 1 课,命中缓存的 token 照样占着窗口;A6 的两天会话,「冻结」组的 system 一字不变、缓存一路命中,「刷新」组写完记忆一重建 system,缓存就断了。这一课把它们换算成钱。

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

本机服务的 usage 只有三个数:prompt_tokens(全部输入,含命中)、cached_tokens(命中)、completion_tokens(输出,含思考)。它不报缓存写入,导出时 cache_creation_input_tokens 记为 null——本课不编这个数,写入量一律由规则推算并标出来。

先自己设计:按全价估,或者按字段名加

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案按 prompt 全价估账(CoreCoder 的 estimated_cost);或看到字段就加
CoreCoder v0.7.0:成本估算 CoreCoder@v0.7.0:corecoder/llm.py:118-128需联网 MIT
118 @property
119 def estimated_cost(self) -> float | None:
120 """Rough cost estimate in USD. Returns None if model not in pricing table."""
121 pricing = _PRICING.get(self.model)
122 if not pricing:
123 return None
124 input_rate, output_rate = pricing
125 return (
126 self.total_prompt_tokens * input_rate / 1_000_000
127 + self.total_completion_tokens * output_rate / 1_000_000
128 )
👆 带下划线的行可以点:点哪行,讲哪行。
⚠
常见误解:「usage 里的 input_tokens,各家一个意思。」

第 1 课见过:Anthropic 的 input_tokens 不含缓存,OpenAI 的 prompt_tokens / input_tokens 与 OTel 约定的 gen_ai.usage.input_tokens 都含。上面第二种朴素算法就是把含缓存的数当成「未缓存」,再把命中加一遍。OpenCode 在源码里专门写了注释防这个坑:AI SDK v6 把各家的 inputTokens 统一成含缓存,算钱之前要把命中与写入减掉。

OpenCode:从各家 usage 里拆出四段 opencode@v1.18.32:packages/opencode/src/session/session.ts:338-366需联网 MIT
338export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?: ProviderMetadata }) => {
339 const finite = (value: number) => (Number.isFinite(value) ? value : 0)
340 const safe = (value: number) => Math.max(0, finite(value))
341 const inputTokens = safe(input.usage.inputTokens ?? 0)
342 const outputTokens = safe(input.usage.outputTokens ?? 0)
343 const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
344
345 const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0)
346 const cacheWriteInputTokens = safe(
347 Number(
348 input.usage.cacheWriteInputTokens ??
349 input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
350 // google-vertex-anthropic returns metadata under "vertex" key
351 // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
352 input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
353 // @ts-expect-error
354 input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
355 // @ts-expect-error
356 input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
357 0,
358 ),
359 )
360
361 // AI SDK v6 normalized inputTokens to include cached tokens across all providers
362 // (including Anthropic/Bedrock which previously excluded them). Always subtract cache
363 // tokens to get the non-cached input count for separate cost calculation.
364 const adjustedInputTokens = safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens)
365
366 const total = input.usage.totalTokens
👆 带下划线的行可以点:点哪行,讲哪行。

改进:按四段、按官方价目

两家官方价格页(快照 2026-09-26)的相关几行,单位是美元 / 百万 token:

模型输入命中(倍率)写入 5 分钟写入 1 小时输出最小可缓存
Claude Fable 5.1100.25(0.025×)12.52050512
Claude Opus 5.540.2(0.05×)5820512
Claude Sonnet 520.2(0.1×)2.54101024
Claude Haiku 4.510.1(0.1×)1.25254096
gpt-6-sol20.2(0.1×)2.5(30 分钟)—101024
gpt-5.6-terra20.2(0.1×)2.5(30 分钟)—121024

几条规则都来自两家的 prompt-caching 文档(同日抓取):缓存按前缀精确匹配;Anthropic 的写入按 5 分钟(1.25×)或 1 小时(2×)计,命中默认 0.1×,Opus 5.5 与 Fable 5.1 这类新旗舰更低;前缀不到「最小可缓存长度」就不缓存,也不报错;自动缓存把断点放在最后一个可缓存块上,随对话前移。OpenAI 从 GPT-5.6 起写入也要收 1.25×,有效期至少 30 分钟,最小前缀 1,024 。

极小真数字:第 4 轮的一笔账

T5 第 4 轮:服务端报告输入 1,743、命中 1,296、输出 28。按 Claude Sonnet 5,未命中的部分按自动缓存写进 5 分钟缓存:

T5 第 4 轮 · Claude Sonnet 5 · 未命中部分写入 5 分钟缓存 = $0.0017
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
本组件里这个公式走的是 Pi 的 calculateCost(pin 31b513e3 models.ts:878-897 的逐行移植);它与 bun 跑的 Pi 原文、python 跑的项目 cost.py 在 276 个用例上逐项一致(误差 < 1e-9)。

一整次运行逐轮摊开,就是下面这张账单:

缓存命中账单:同一次真实运行,按官方价目逐轮现算
价目可以改(美元 / 百万 token,默认值来自 2026-09-26 官方价格页快照)
插入一个事件(规则推算):
缓存命中写入缓存 未缓存输入输出(含思考) 累计推算的轮
🎯

试四件事:① 勾上「按最小可缓存长度推算」:Sonnet 5 的门槛是 1024,T5 前两轮不够、第 3 轮时缓存里还没有条目,三根柱子变成虚线「推算」;再把价目换成 Claude Haiku 4.5:单价是 Sonnet 5 的一半,可它的门槛是 4096,T5 最长的一轮才 2,389 token——每一轮都够不到,全按原价;两者的总账谁高?② 把运行换成 pd-v2-unlimited-s3(开思考):灰色的输出条压倒一切,输出占比那一行是多少?③ 换成 t10-refresh-day1:第 7 轮底下有紫字「重建」——这是真实发生的 system 重建,命中从 2,072 掉到 294,不是推算;④ 回到 T5,插入「从第 2 轮起 system 里放时间戳」:之后每一轮的命中都归零、整段重写,总账变成多少?

⚠
常见误解:「编码 agent 的钱,都花在每轮重发的上下文上。」

调研里这句话只有第三方博客级的证据,本课不把它当结论,而是用自己的运行现算:按 Sonnet 5,关思考的 T5 输出占 38%、另一次关思考的运行 46%;开思考的 pd-v2-unlimited-s3 输出占 94%,分层压缩那次 94%。账单的形状取决于负载:上下文长、输出短时输入占大头,缓存是省钱的主力;模型想得多时输出占大头,缓存几乎无关紧要。这些是本地 8B 模型在一个小任务上的比例(上下文最多两千多 token),换成长会话与云端模型,比例会变;要知道自己的账,得像这样用自己的 usage 算 。

前缀一变,后面全付写入价

缓存按前缀匹配,所以 harness 能做的主要是两件事:让前缀保持不变,和把断点放在不变的那一段末尾。

A6 的两天会话给了一个真实的反例:刷新组在第 6 轮写了一条记忆,第 7 轮 system 被重建(多了 51 个字符),而 system 在最前面——命中从上一轮的 2,072 掉到 294(剩下的是跨运行共享的基础前缀)。分层压缩那次运行更明显:前 4 轮命中率 98%,开始压缩之后的 17 轮只有 68%——每次压缩都换掉了历史,前缀随之改变 。

OpenCode 给 Anthropic 一类模型放断点的办法:前两条 system 消息、最后两条非 system 消息,各挂一个 cacheControl——正好用满 Anthropic 的 4 个断点:

OpenCode:断点放哪(transform.ts applyCaching) opencode@v1.18.32:packages/opencode/src/provider/transform.ts:358-407需联网 MIT
358function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
359 const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
360 const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
361
362 const providerOptions = {
363 anthropic: {
364 cacheControl: { type: "ephemeral" },
365 },
366 openrouter: {
367 cacheControl: { type: "ephemeral" },
368 },
369 bedrock: {
370 cachePoint: { type: "default" },
371 },
372 openaiCompatible: {
373 cache_control: { type: "ephemeral" },
374 },
375 copilot: {
376 copilot_cache_control: { type: "ephemeral" },
377 },
378 alibaba: {
379 cacheControl: { type: "ephemeral" },
380 },
381 }
382
383 for (const msg of unique([...system, ...final])) {
384 const useMessageLevelOptions =
385 model.providerID === "anthropic" ||
386 model.providerID.includes("bedrock") ||
387 model.api.npm === "@ai-sdk/amazon-bedrock"
388 const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
389
390 if (shouldUseContentOptions) {
391 const lastContent = msg.content[msg.content.length - 1]
392 if (
393 lastContent &&
394 typeof lastContent === "object" &&
395 lastContent.type !== "tool-approval-request" &&
396 lastContent.type !== "tool-approval-response"
397 ) {
398 lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions)
399 continue
400 }
401 }
402
403 msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions)
404 }
405
406 return msgs
407}
👆 带下划线的行可以点:点哪行,讲哪行。

Codex 走 OpenAI,那边的缓存靠「缓存键」把相关请求路由到同一处:每个请求都带 prompt_cache_key,默认就是会话 id;Guardian 审查用 guardian:{父会话 id} 共享一条缓存:

Codex:缓存键默认 = 会话 id codex@8cabf5a:codex-rs/core/src/client.rs:484-488需联网 Apache-2.0
484 fn prompt_cache_key(&self, responses_metadata: &CodexResponsesMetadata) -> String {
485 self.prompt_cache_key_override
486 .clone()
487 .unwrap_or_else(|| responses_metadata.session_id.clone())
488 }
👆 带下划线的行可以点:点哪行,讲哪行。
Codex:审查子会话共享父会话的缓存键 codex@8cabf5a:codex-rs/core/src/guardian/review_session.rs:246-258需联网 Apache-2.0
246pub(crate) fn prompt_cache_key_override_for_review_session(
247 session_source: &SessionSource,
248 parent_thread_id: Option<ThreadId>,
249) -> Option<String> {
250 let SessionSource::SubAgent(SubAgentSource::Other(name)) = session_source else {
251 return None;
252 };
253 if name != GUARDIAN_REVIEWER_NAME {
254 return None;
255 }
256 let parent_thread_id = parent_thread_id?;
257 Some(format!("guardian:{parent_thread_id}"))
258}
👆 带下划线的行可以点:点哪行,讲哪行。
⚠
常见误解:「开了缓存,模型的回答就可能不一样。」

缓存复用的是同一段前缀已经算好的中间结果;请求内容一字不差,模型看到的输入完全相同,变的只是价格和首 token 时间。LLM 课的 KV cache 是一个请求内部复用前面 token 的 K、V;前缀缓存是把这件事延伸到跨请求:下一轮的开头就是上一轮,那部分不用再算 。

证据三问

问本课的回答
① 它解决什么问题?算清每一轮的钱花在哪一段;知道缓存在自己的负载上能省多少、会在哪里失效
② 为什么可能有效?每轮整份重发时,前缀部分与上一轮相同;按前缀复用计算,这部分只收 0.1× 左右
③ 实验上呢?本课项目六次运行:关思考时按四段算比全价估少 62%;开思考时输出占 94%;真实的 system 重建让命中掉到 294

演进地图:你在这里

模型接入层的演进:每一步都被上一步的真数字逼出来
  1. CoreCoder(OpenAI 兼容 + litellm 兜底)、本课项目 miniagent(标准库 urllib)
    解决了最短的路:CoreCoder 的 llm.py 净 259 行,换 provider 只改 base URL 与 key。
    卡在同一段带工具调用的对话,换成 Anthropic Messages,9 个语义位置里 9 个换了路径或类型(参数从字符串变成对象,5 条消息变 3 条);Codex 干脆不认 Chat:配置 wire_api = "chat" 直接报错。
  2. Pi 自建 pi-ai · OpenCode 用 Vercel AI SDK · Codex 只留 Responses
    解决了Pi 把 10 种协议与 40 个 provider 做成正交的两维;OpenCode 挂了 19 个 @ai-sdk/* 依赖;Codex 的 WireApi 只剩 1 个变体。
    卡在抽象层得替每一家补差异:OpenCode 逐 provider 改写消息的 transform.ts 有 1,909 行,还给 8 个 @ai-sdk 包打了补丁;而且抽象挡不住流式与缓存的细节。
  3. Anthropic 流式文档 · OpenAI function-calling 流式指南 · Codex 只收 output_item.done
    解决了开思考的一轮,第一帧 125.9 ms 就到;整包等回复的话,正文要等到 6,631 ms、工具调用 6,798 ms。
    卡在流式只让回复来得早,不让它便宜:T5 的 9 轮每轮整份重发,输入累计 15,921 token,是最后一轮 2,389 的 6.7 倍。
  4. 第三步 缓存感知的账单 + 稳定前缀 你在这里
    Anthropic / OpenAI prompt caching · Codex prompt_cache_key · OpenCode applyCaching
    解决了按 usage 四段计价:T5 在 Claude Sonnet 5 价目下 $0.0140;CoreCoder 按全价估 $0.0371,是它的 2.64 倍。
    卡在缓存只省输入:开思考的 pd-v2-unlimited-s3 账单里输出占 94%;前缀里一处变、后面全失效——T10 刷新组第 7 轮 system 重建,命中从 2,072 掉到 294。(→ A9:能跑、能算账了,怎么知道它好不好?)

数字口径:pin 源码计数(Codex 8cabf5a、OpenCode v1.18.32、Pi 31b513e3、CoreCoder v0.7.0)、项目录制 T5 / T10 与逐轮 usage(本机 Qwen3-8B,token 按它的 tokenizer 数)、官方价格页快照 2026-09-26;由 a08-model-access.json 构建时现算。

一句话

📌
缓存命中率就是输入侧的钱:前缀一变就归零;开思考时,大头在输出。
🚦

先停在这里:记住账单的四段,和 input_tokens 在各家口径里含不含缓存。
再深一层:去实验课,把六次运行都放进账单里换价目、插事件,找出「单价低的模型反而更贵」的那一组。

下一个缺口

能跑、能换模型、能算清账了,怎么知道它到底修得好不好? → A9 评测

T5 这次运行花了 $0.0140(按 Sonnet 5 现算),验收没过;pd-v2-unlimited-s3 花了 $0.0631,过了。「便宜」和「好」要放在一起比——下一章讲怎么量「好」。

你可能会问