最新资讯
wechat AI短剧门槛降降降!实测即梦短剧工作室:剧本分镜预演一条龙,伙伴计划长期护航 dev.to 让模型改写文档前,先量化事实保真度 dev.to 回放 153 个 Claude Code 与 Codex 会话,审计 Agent 的生产影响范围 dev.to 银行 77 意图分类最便宜 LLM:GPT-6.1 Terra 每次成功任务 $0.000727 theinformation.com Chesky的副业:Airbnb向CEO新设AI实验室出售IP theverge.com OpenAI 宣布数据中心项目不再与公职人员签署保密协议 dev.to NVIDIA 发布四个开放 Cosmos 3 仿真模型 dev.to 真实 MCP 客户端击穿我们服务器的五种方式 theverge.com Airbnb 正在开发 AI 旅行 agent,计划 2027 年上半年推出 wechat 这可能是美国AI最魔幻的一天。 nytimes.com 美众议院共和党领袖要求 Nvidia 黄仁勋闭门简报 pub.towardsai.net 忘掉我:为记忆而设计的系统中的删除难题
多元动态
登录 注册
智能体 dev.to · @jidonglab

Prompt Caching 为何只写不读:cache_control 的前缀匹配陷阱

原文 我的收藏

作者为每轮重发 12K token 系统提示的 agent 循环开启 prompt caching,结果账单反而上涨约四分之一,日志中请求全部 200、延迟不变,只有 usage 对象暴露真相:cache_creation_input_tokens 为 12184,cache_read_input_tokens 为 0。原因是 prompt caching 只做前缀匹配,缓存键是渲染后提示到各 cache_control 断点的精确字节,第 N 位一个字节变化就会使该断点及之后全部失效;渲染顺序为 tools → system → messages,任何易变内容(时间戳、会话 ID、用户输入)都会破坏缓存。

AI推荐理由 作者以 agent 循环中 12K token 系统提示的实测账单说明 prompt caching 只做前缀匹配,一个字节变化即令断点失效,导致 cache_creation_input_tokens 为 12184 而 cache_read_input_tokens 为 0、成本反升约四分之一;问题具体、有 usage 数据支撑,对构建 Agent 的开发者有直接参考价值。

78

正文

I turned on prompt caching for an agent loop that resends a 12K-token system prompt on every turn. Obvious win, right? Input tokens are the whole bill in a tool loop. The bill went up. Not a little. Roughly a quarter. And nothing in the logs looked wrong. Every request returned 200. Latency was the same. The only place the truth was written down was the usage object: cache_creation_input_tokens: 12184 cache_read_input_tokens: 0 input_tokens: 291 Enter fullscreen mode Exit fullscreen mode Twelve thousand tokens written to cache. Zero read back. Every single request. I paid the write premium 40 times in a row and never once collected. Prompt caching has exactly one invariant, and I had broken it in the most boring way possible. TL;DR Prompt caching is a prefix match. The cache key is the exact bytes of the rendered prompt up to each cache_control breakpoint. One changed byte at position N invalidates every breakpoint at or after N. Render order is tools → system → messages. Anything volatile (timestamps, session IDs, the user's actual question) must sit after your last breakpoint, not before it. Cache writes cost 1.25× base input (2× for the 1-hour TTL); reads cost about 0.1×. A cache that only ever writes is not a cache, it is a 25% surcharge. Ground truth is usage.cache_read_input_tokens. If it is 0 across repeated requests with "identical" prompts, something upstream is rewriting your prefix. Two silent killers that produce byte-identical payloads and still miss: the 20-position lookback window in long tool turns, and parallel fan-out, where an entry is only readable after the first response starts streaming. Why does prompt caching never read from the cache? Because your cache_control breakpoint is placed after content that changes every request, so each request writes a brand-new entry that nothing will ever match again. The mental model that fixes this: the cache is not a key-value store keyed on "my system prompt." It is a prefix match over the rendered request. The API renders tools, then system, then messages, hashes the byte stream up to each breakpoint, and looks for an existing entry. First divergence wins. Everything downstream of it is cold. So all three of these are the same bug: # 1. The dynamic header system = f"You are a support agent. Current time: {datetime.now()}.\n\n{PLAYBOOK}" # PLAYBOOK is 11K stable tokens sitting behind a string that changes every request. # 2. The nondeterministic serializer system = "Schema:\n" + json.dumps(schema) # no sort_keys=True # Same dict, different key order, different bytes. # 3. The per-user tool list tools = build_tools_for(user) # tools render at position 0 # Nothing caches across users, ever. Enter fullscreen mode Exit fullscreen mode My case was #1, wearing a disguise: the timestamp was injected by a helper three call frames away that I had written months earlier for logging. The pricing turns this from "suboptimal" into "actively worse than doing nothing." Writes are 1.25× base input price, reads are roughly 0.1×. Two requests that share a prefix break even (1.25 + 0.1 = 1.35 versus 2.0 uncached). One request that writes and is never read is 1.25× for nothing. Forty of them is a line item. How do I tell whether prompt caching is working? Read three fields off usage and remember that they partition your prompt: input_tokens is only the uncached remainder. total prompt tokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens Enter fullscreen mode Exit fullscreen mode That last part trips people up in both directions. An agent that ran for an hour showing input_tokens: 4200 is not magically cheap, and it is not broken either. Check the sum. In a healthy multi-turn loop, the shape looks like this: cache_read_input_tokens covers the whole prior prefix and grows turn over turn. cache_creation_input_tokens is small, roughly last turn's output plus the newly appended input, because writes only bill the delta past the highest hit. input_tokens is just the tail after your last breakpoint. If cache_creation_input_tokens is instead near the full conversation size on every turn, the prefix is being rewritten upstream. Make this a standing check, not a one-time look. The expensive failure mode in production is not a bad first implementation. It is a working implementation that regresses six months later, when someone adds a feature flag to the system prompt, and nothing errors, and the bill just drifts. One integration test earns its keep: r1 = client.messages.create(**payload) r2 = client.messages.create(**payload) # byte-identical assert r2.usage.cache_read_input_tokens > 0, "cache prefix broke" Enter fullscreen mode Exit fullscreen mode To localize a break, log several consecutive request bodies and diff adjacent pairs. In a growing conversation the tail legitimately differs, so what you are checking is the overlap: the previous request's prompt should reappear unchanged as a prefix of the next. Strip cache_control markers before diffing, since the moving marker always differs and is not the culprit. The first divergence inside the overlap is your invalidator. Where should the cache_control breakpoint go? At the end of the shared portion of the prompt, never at the end of the whole prompt. This is the mistake that produced my zero-read logs, and it is the same mistake automatic caching makes on your behalf. A top-level cache_control on the request places one breakpoint on the last cacheable block and slides it forward as the conversation grows. Perfect for a chat thread. Wrong for the pattern where a big fixed preamble is followed by a unique per-request question, because the breakpoint lands after the unique tail, so every request writes a distinct entry over bytes nobody will read. messages = [{"role": "user", "content": [ {"type": "text", "text": RETRIEVED_DOCS, # 9K shared tokens "cache_control": {"type": "ephemeral"}}, # breakpoint HERE {"type": "text", "text": question}, # unique, unmarked, after ]}] Enter fullscreen mode Exit fullscreen mode Four more placement rules worth internalizing: Freeze the system prompt. Date, mode, user name and feature flags do not belong at the front of the prefix. On Opus 5 and Opus 4.8 you can append {"role": "system", "content": "..."} inside messages[] instead, which sits after the cached history and leaves it intact. Otherwise put it in a user turn. Serialize tools deterministically and do not add, remove or reorder them mid-conversation. Tools render at position 0, so a reorder is a full rebuild. Caches are model-scoped. Switching models mid-loop for a "cheap" side task forfeits the whole prefix. Give the subagent its own thread instead. Minimum cacheable prefix is model-dependent and not monotonic (512 tokens on the newest models, 1024 on Opus 4.8 and Sonnet 5, 4096 on Opus 4.6 and Haiku 4.5). Below the minimum you get no error, just cache_creation_input_tokens: 0. A 3K-token prompt caches on some models and silently will not on others. Max 4 breakpoints per request. What makes prompt caching miss on byte-identical requests? Two mechanisms, and both of them make you doubt your own diff. The 20-position lookback. Each breakpoint walks backward at most 20 positions looking for a prior entry. A run of consecutive tool_use blocks counts as one position, and so does a run of consecutive tool_result blocks, so heavy parallel tool calling is fine. A long sequential loop that appends more than 20 positions of other content in one turn is not: the next request's breakpoint never finds the previous entry, and you rewrite the whole conversation with a payload that diffs clean. Fix by placing an intermediate breakpoint every ~15 positions. Parallel fan-out. An entry becomes readable only once the first response begins streaming. Fire 10 identical-prefix requests at once and all 10 pay full price, because none of them can read what the others are still writing. Send one, await the first streamed token, then fire the remaining nine. Same arithmetic applies to multi-agent designs: N workers each assembling a slightly different prompt over the same context write N entries and read none of each other's. And on TTL: the default 5-minute entry has its timer refreshed for free by every read, measured from the start of the request. A four-minute generation leaves you about a minute for the next request to begin. The 1-hour TTL doubles the write cost, so it only pays off in the 5-to-60-minute gap, and needs at least three requests to break even. So why does cache_control write but never read? Because prompt caching is a prefix match, and a write-only cache means your breakpoint has volatile bytes in front of it. Something before the marker changes on every request (a datetime.now() in the system prompt, an unsorted json.dumps, a per-user tool list) or the marker itself sits after per-request content instead of at the end of the shared prefix. The fix is ordering, not more markers: put everything stable first, put the breakpoint at the last stable byte, put everything volatile after it, then verify with usage.cache_read_input_tokens > 0 on a second identical request. If that field stays at zero, no amount of cache_control will save you, and you are paying 1.25× for the privilege. Written by the developer behind Preterview, an interview prep platform.

来源报道 · 1 篇 · 以下链接均前往原始媒体

← 返回精选动态 原文

更多内容