一次看似正常的 Codex CLI 接入,4 天产生了约 1386 美元费用,其中 85% 不是模型推理,而是 prompt cache write。这个案例提醒我们:接入支持提示词缓存的模型时,看到“缓存已开启”远远不够,还要确认缓存标识是否真正穿过客户端、provider 和云端 API,并最终转化为 cache hit。
问题不在请求次数,而在每次都重写前缀
Codex 工作时会维护 prompt_cache_key,用于标记可复用的提示词前缀。对于持续数十轮的编码会话,这类前缀通常包含系统指令、工具定义、项目上下文和历史对话,体积可能远大于用户刚输入的那一句话。
理想情况下,一轮请求写入缓存,后续请求读取同一前缀:
第 1 轮:写入 100,000 tokens
第 2 轮:读取 100,000 tokens + 推理新增内容
第 3 轮:读取 100,000 tokens + 推理新增内容
如果 Bedrock 原生 provider 没有正确传递或映射 Codex 维护的缓存身份,云端就可能把后续请求继续视为新的缓存写入:
第 1 轮:写入 100,000 tokens
第 2 轮:再次写入 105,000 tokens
第 3 轮:再次写入 110,000 tokens
随着会话增长,账单呈现的不是线性推理成本,而是不断重写长前缀的成本。来源案例中 85% 的费用落在 cache write,正是需要立即停用或降级该链路的信号。
需要注意,prompt_cache_key 是 Codex 侧的缓存标识,而 Bedrock 最终如何表达缓存检查点、缓存身份和计费指标,取决于具体 provider 实现及模型 API。仅凭配置文件里出现“cache”不能证明两端已经正确对接。
用账单指标验证缓存,而不是相信配置
排查时至少要回答四个问题:
- 连续请求是否使用稳定的
prompt_cache_key? - provider 发出的 Bedrock 请求里,是否存在对应的缓存字段或缓存检查点?
- 响应 usage 中,cache read 是否在第二轮后增长?
- cache write 与 cache read 的比例是否符合会话模式?
最直接的办法是保留每轮 usage 数据并计算费用构成。下面的脚本可以直接运行;它假设日志是 JSON Lines,每行包含 input_tokens、output_tokens、cache_write_tokens 和 cache_read_tokens。实际字段名不同的话,修改 usage.get(...) 四处映射即可。
#!/usr/bin/env python3
import argparse
import json
from pathlib import Path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("log", type=Path, help="JSONL usage log")
parser.add_argument("--input-rate", type=float, required=True,
help="USD per 1M uncached input tokens")
parser.add_argument("--output-rate", type=float, required=True,
help="USD per 1M output tokens")
parser.add_argument("--write-rate", type=float, required=True,
help="USD per 1M cache-write tokens")
parser.add_argument("--read-rate", type=float, required=True,
help="USD per 1M cache-read tokens")
args = parser.parse_args()
totals = {"input": 0, "output": 0, "write": 0, "read": 0}
with args.log.open(encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
usage = json.loads(line)
totals["input"] += usage.get("input_tokens", 0)
totals["output"] += usage.get("output_tokens", 0)
totals["write"] += usage.get("cache_write_tokens", 0)
totals["read"] += usage.get("cache_read_tokens", 0)
rates = {
"input": args.input_rate,
"output": args.output_rate,
"write": args.write_rate,
"read": args.read_rate,
}
costs = {k: totals[k] / 1_000_000 * rates[k] for k in totals}
total_cost = sum(costs.values())
for key in ("input", "output", "write", "read"):
share = costs[key] / total_cost * 100 if total_cost else 0
print(f"{key:>6}: {totals[key]:>12,} tokens ${costs[key]:>9.2f} {share:>6.2f}%")
print(f" total: {'':>12} ${total_cost:>9.2f}")
reusable = totals["write"] + totals["read"]
hit_rate = totals["read"] / reusable * 100 if reusable else 0
print(f"cache token hit rate: {hit_rate:.2f}%")
if total_cost and costs["write"] / total_cost >= 0.50:
print("WARNING: cache writes exceed 50% of estimated cost")
if __name__ == "__main__":
main()
例如,把当前模型在 Bedrock 计价页上的每百万 token 单价填进去:
python3 audit_cache_cost.py usage.jsonl \
--input-rate 0 \
--output-rate 0 \
--write-rate 0 \
--read-rate 0
示例中的 0 必须替换成当前区域、模型和计费层级的真实价格。不要从旧账单或其他模型复制单价,因为缓存写入与读取可能采用不同费率。
抓住“第二次相同请求”这个最小证据
正式启用前,可以这样实践:建立一个隔离会话,固定系统提示词、工具列表和大段上下文,只改变末尾的一小句用户输入,连续请求两到三次。预期现象是第一次产生 cache write,后续请求产生明显的 cache read。
测试期间同时记录三层数据:
- Codex 调试日志中的
prompt_cache_key,确认它没有每轮随机变化。 - provider 发出的请求体,确认缓存语义没有在适配层丢失。
- Bedrock 响应 usage 与成本指标,确认服务端确实命中缓存。
记录请求体时要先脱敏。系统提示词、源代码、工具参数和临时凭证都可能进入日志,不能为了排查成本问题而制造新的数据泄露风险。
还应测试会破坏缓存的变化,例如工具定义重新排序、系统提示词插入时间戳、工作目录元数据变化,以及把动态内容放在稳定前缀中间。即使 key 保持不变,前缀内容发生变化也可能降低复用率。
上线前给缓存链路加保险丝
这类问题最危险的地方是功能仍然可用:模型照常回答,开发者只会在数天后从账单发现异常。接入 Bedrock 原生 provider 时,建议设置以下门槛:
- 用小额度、短会话完成 cache write/read 验收,再开放长时间代理任务。
- 为 cache write 成本和 token 数分别设置小时级告警。
- 按 provider、模型、区域和客户端版本拆分成本标签。
- 升级 Codex CLI 或 provider 后重新运行缓存回归测试。
- cache write 占比持续异常时自动停止任务,而不是等待月度预算告警。
- 无法证明 cache hit 时,限制上下文长度、压缩工具定义,或暂时改用已验证的接入路径。
提示词缓存不是一个布尔开关,而是一条端到端协议。客户端生成稳定标识、provider 正确映射、请求前缀保持一致、服务端返回读取指标,这四个条件缺一不可。对长上下文 Agent 来说,缓存验收应该和鉴权、限流、超时测试处在同一个上线清单里。