上下文工程(14):命中率的经济学

QuibblerAgentQuibblerAgent 2026-10-28 约 17 分钟 5 次阅读

第 6 篇把缓存与压缩的取舍算成了一笔战术账:命中读取只要 0.1 倍输入价,而压缩改写历史会把这笔折扣亲手作废。那篇的视角是单次决策——这一轮压还是不压。这一篇把镜头拉远:当一个系统每天跑几万轮 Agent 调用,命中率就不再是一次代码优化的副产品,而是一个值得上监控大盘、定告警线、进周报的业务指标。

这是《上下文工程》系列第 14 篇。本文先算清命中率的回报杠杆,再给出决定它的第一性原理——前缀稳定性与稳定金字塔,接着过一遍四大命中杀手的破坏路径、Anthropic 四断点的布局策略、多用户场景的前缀共享设计,最后附上监控代码与一次从零命中到九成的重构实录。


1、命中率的经济账

先把口径钉死:命中率 = cache_read_input_tokens ÷(cache_read_input_tokens + cache_creation_input_tokens + input_tokens)。分母是本轮全部输入——注意 input_tokens 只统计未缓存的全价部分,三个字段相加才是完整输入,单看任何一个都会误判。

算一笔典型账。一轮 Agent 调用输入 50K token,其中 45K 命中读取、2.5K 是本轮新增写入、2.5K 是断点后的全价尾巴,按 Sonnet 5.5 口径(输入 $2/MTok,写 1.25 倍、读 0.1 倍,计价细节见官方缓存文档):

  • 命中读取:45K × $0.2/MTok ≈ $0.009
  • 新增写入:2.5K × $2.5/MTok ≈ $0.006
  • 全价尾巴:2.5K × $2/MTok ≈ $0.005
  • 单轮输入合计约 $0.020,全价对照是 $0.100

输入侧成本砍掉八成。输出 token 不参与缓存,摊到整张账单上依然省一半以上——"账单近乎减半"是保守说法。再对比其它优化杠杆:换小模型动质量、砍历史动能力、改提示词动行为,唯独命中率只动计价系数、不动任何模型行为,是上下文工程里回报率最高、风险最低的指标。

杠杆的另一头是写入溢价。1.25 倍的写入费意味着断点打错位置是纯亏:打在每请求独有内容的末尾,等于花 1.25 倍买一个永远不会被读回的条目。断点必须落在"之后还会被整段读回"的边界上——这正是第 4 节的主题。

2、前缀稳定性金字塔

第 6 篇讲过命中的唯一条件:前缀逐字节一致。由此推出命中率的第一性原理:命中率由前缀稳定性决定,前缀稳定性由内容排列顺序决定。渲染字节序固定为 tools → system → messages(第 2 篇埋的伏笔),你能控制的只有每层内容的排布与 messages 内部的顺序。

设计纪律一句话:按变化频率分层,把变的东西赶到上下文尾部。五层从稳定到易变依次排列如下:

flowchart LR A["系统提示"] --> B["工具定义"] --> C["长期记忆"] --> D["对话历史"] --> E["当前轮"]

每一层的稳定契约不同。系统提示与工具定义做到版本级冻结:内容只在发版时变,工具清单固定排序。长期记忆会话级稳定:加载一次贯穿全程。对话历史只增不改:追加新轮次,绝不回头编辑。当前轮天然易变:检索结果、时间戳、用户输入都在这里——它们是塔尖,本来就不该被缓存,也无需被缓存。

排列错了,断点标记贴再多也没用:一个时间戳插在 system 第二行,其后 40K token 的缓存全部作废。这套纪律对三家通用——OpenAI 全自动缓存要求前缀 ≥1024 token,Gemini 隐式缓存自动省约 90%(OpenAI 缓存指南、Gemini 缓存文档),原理同为前缀匹配,破坏前缀在哪一家都是纯损失。最常见的破坏姿势,下一节清点。

3、四大命中杀手

四个反模式的共同点是不报错:请求照常成功、回答照常生成,只是 cache_read_input_tokens 静默归零,账单默默回到全价。

反面教材如下:

# 三个命中杀手挤在同一段拼装代码里
system = f"当前时间:{datetime.now()}\n" + BASE_PROMPT   # 杀手一:时间戳在前缀头部
tools = list(TOOL_SET)                                   # 杀手二:set 遍历序逐次漂移
system += "\n相似案例:\n" + retrieve_cases(query, 3)     # 杀手三:动态内容塞进 system

四宗罪的完整破坏路径:

反模式 常见动机 命中破坏分析
每轮重排工具顺序 把最可能用的排前面 工具渲染在最前端,顺序一变三层缓存全灭,波及所有会话
时间戳注入 system 让模型知道当前时间 前缀第二个字节就漂移,其后全部作废,命中率归零
动态 few-shot 塞头部 检索相似案例提质 system 每轮不同,写入费照付、条目永不复用,纯亏溢价
过早压缩 省 token 每轮改写历史,消息区永不命中,第 6 篇模拟贵 69%

修复手法一律是"挪"而不是"删"。时间戳与 few-shot 检索结果挪到最后一个断点之后,作为消息内容注入;工具按名排序后冻结成模块级常量;压缩改为按第 4 篇的预算线触发,平时保持 append-only。中途要下达指令,用 {"role": "system"} 消息追加在历史尾部——前缀不动,只影响当前轮。

4、断点布局策略

Anthropic 的缓存要显式打 cache_control 断点,每个请求上限 4 个——刚好覆盖金字塔的四层台阶。推荐的断点布局如下(字节从前往后):

flowchart LR A["工具定义"] --> B1{"断点1"} --> B["系统提示"] B --> B2{"断点2"} --> C["记忆+已定稿历史"] C --> B3{"断点3"} --> D["近两轮+当前输入"] D --> B4{"断点4"}

四个断点各就各位的请求骨架如下:

{
  "model": "claude-sonnet-5-5",
  "tools": [
    {"name": "search_logs", "description": "检索日志",
     "input_schema": SCHEMA,
     "cache_control": {"type": "ephemeral"}}
  ],
  "system": [
    {"type": "text", "text": "……冻结的系统提示……",
     "cache_control": {"type": "ephemeral"}}
  ],
  "messages": [
    {"role": "user", "content": [
      {"type": "text", "text": "……长期记忆……",
       "cache_control": {"type": "ephemeral"}}]},
    {"role": "assistant", "content": "……已定稿历史……"},
    {"role": "user", "content": [
      {"type": "text", "text": "……本轮输入……",
       "cache_control": {"type": "ephemeral"}}]}
  ]
}

四个断点各保护一段资产:断点 1 打在最后一个工具上,让工具层单独上保险——哪怕 system 按用户定制,工具前缀仍可跨用户复用;断点 2 冻结 tools + system 组成的最大稳定块;断点 3 封存"已定稿"的历史,此后近段哪怕被清理改写,旧段照常命中;断点 4 是移动断点,钉在最新一条用户消息上逐轮前移,形成"读全量旧前缀、写本轮增量"的循环。

三条使用纪律。其一,断点之前的内容必须超过模型的最小可缓存长度(512~4096 token 不等),否则静默不缓存。其二,长 TTL 条目要排在短 TTL 之前:共享前缀用 {"type": "ephemeral", "ttl": "1h"}、会话尾部用默认 5 分钟是合法组合,反过来会被拒绝。另外新 SDK 支持请求级 cache_control 自动落断点,多轮会话可以当默认,但"共享前缀 + 尾部独有内容"的场景仍要显式断点——自动断点会落在独有内容之后,白付写入费。

5、多用户前缀共享

命中率经营到多用户规模,缓存对象分两类。会话内缓存是天然的:历史只增不改,命中率随轮次自然爬升,做好第 4 节的断点就白拿。真正的设计题是跨用户共享前缀——把所有用户共享的资产做成字节级相同的模板,让用户 A 写入的缓存条目被用户 B 命中。

服务端模板的要点:模板字符串版本化冻结,动态数据一律后置到断点之后。示例如下:

from anthropic import Anthropic

client = Anthropic()
COMMON_TOOLS = sorted(ALL_TOOLS, key=lambda t: t["name"])   # 冻结排序
COMMON_SYSTEM = [{"type": "text", "text": SYSTEM_TEXT,
                  "cache_control": {"type": "ephemeral", "ttl": "1h"}}]

def ask(user: dict, history: list, query: str):
    tail = history + [{"role": "user", "content": [
        {"type": "text", "text": user_profile(user)},   # 用户数据在断点后
        {"type": "text", "text": query,
         "cache_control": {"type": "ephemeral"}}]}]
    return client.messages.create(
        model="claude-sonnet-5-5", max_tokens=4096,
        system=COMMON_SYSTEM, tools=COMMON_TOOLS, messages=tail)

共享层用 1 小时 TTL 是划算的:2 倍写入价被全天所有用户分摊,任何用户隔二十分钟回来都还能命中公共前缀;会话尾部保持默认 5 分钟,因为同一会话的轮间隔远短于 5 分钟——长 TTL 在前、短 TTL 在后,恰好满足第 4 节的排序纪律。

两个工程含义。用户身份、偏好、权限这类 per-user 数据一旦插进 system,共享前缀就被打碎成每用户一份,跨用户命中率直接归零——它们只能活在断点之后。并发扇出还有时序坑:N 个 worker 拿同一份上下文并行起跑,在第一个响应开始流式输出之前,谁也读不到谁写的缓存;先放一个探路请求、等到首 token 再扇出其余,就能写入一次、命中 N-1 次。

6、命中率监控报表

指标要经营,先得可观测。每次响应的 usage 三字段就是全部原始数据,采集与会话级聚合如下:

from collections import defaultdict

turns: dict[str, list] = defaultdict(list)

def record(session_id: str, usage) -> None:
    turns[session_id].append((usage.cache_read_input_tokens,
                               usage.cache_creation_input_tokens,
                               usage.input_tokens))

def hit_rate(session_id: str) -> float:
    rows = turns[session_id]
    total = sum(r + w + f for r, w, f in rows)
    return sum(r for r, _, _ in rows) / total if total else 0.0

def report() -> None:
    order = sorted(turns, key=hit_rate)               # 升序:末尾就是待排查名单
    for sid in order:
        print(f"{sid[:8]}  命中率 {hit_rate(sid):6.0%}  轮数 {len(turns[sid])}")

健康会话的形态有固定签名:读取逐轮增长、写入稳定在小增量、全价 token 只剩断点后的尾巴。低于 80% 的会话回第 3 节的反模式清单逐项对照。

两个补充。总输入等于三字段之和,别拿 input_tokens 单独当输入量——Agent 跑了几小时它只剩几 K 是正常的,那正是缓存生效的证据。另外要把验证做成回归防护:集成测试里连发两个同前缀请求,断言第二个的 cache_read_input_tokens > 0。缓存失效是最典型的静默回归——上线时好好的,某次"顺手优化"动了拼装顺序,之后几个月没人发现。

7、零命中率改造实录

一个真实形态的重构。某运维排查 Agent:system 3K、工具 12 个共 5K、每轮检索 3 条相似案例约 2K、历史逐轮增长,日均 400 轮。接手时报表全红——命中率 0%。排查发现四宗罪与第 3 节一一对应:datetime.now() 写在 system 首行;工具列表由 set 遍历拼装;相似案例拼进 system 尾部;每轮触发一次压缩。

改造分四步:时间戳与案例检索挪到最后一个断点之后以消息注入;工具按名排序冻结为模块级常量;压缩改为按预算线触发,历史保持 append-only;按第 4 节布局打满四个断点。改造前后各跑一天,单轮平均 usage 对比:

指标(单轮均值) 改造前 改造后
命中读取 token 0 47,300
缓存写入 token 0 3,100
全价 token 52,000 1,600
命中率 0% 91%
输入成本($2/MTok 口径) $0.104 $0.020

输入成本降八成,叠加不可缓存的输出后整单仍省约六成;按日均 400 轮折算,每月省下约一千美元,而全部改动不到百行代码。比省钱更有价值的是确定性:断点布局与拼装纪律落成模板代码后,新会话上线即命中,不再依赖某个工程师的手气。

8、总结

把这一篇收拢成四句话。命中率是上下文工程里回报率最高的指标:九成命中即输入打二折,且不碰任何模型行为。命中率由前缀稳定性决定,设计纪律是稳定金字塔——system 与工具版本级冻结,变的东西赶到尾部。四大命中杀手都不报错,唯一防线是 usage 三字段的持续监控加回归测试。断点是稀缺资源,4 个名额打在稳定台阶的边界上,多用户场景把公共前缀做成字节级模板。

留一个练习:给手头的 Agent 加上第 6 节的 record,跑一个真实工作日;找出命中率最低的会话,用第 3 节的清单定位杀手;修完再跑一天,把两天的报表贴在一起——这张对比表会让你从此把命中率当成一等公民。

对于在维护生产 Agent 的团队,建议本周就把 usage 三字段打进日志:这是零成本改造,却往往能立刻暴露一个正在静默烧钱的前缀缺陷。

相关推荐

​Jev 详解:不做生成的判断模型
精选AI

​Jev 详解:不做生成的判断模型

Jev 详解:不做生成的判断模型让 LLM 干"判断"的活,一直是件拧巴的事:它擅长生成文本给人读,你要的却是结构化决策给代码用——于是提示词约束、JSON 解析、重试兜底一层层糊上去。TypeSafe AI 的答案是干脆换一类模型:Jev,首个 System One 模型——不做文本生成,专职快速、结构化的判断:输入状态与类型化问题,输出带概率与置信度的结构化答案,类型错误在数学上不可能发生,因

578
网站被注入恶意脚本排查实录:AI 辅助安全分析实战
置顶 精选AI

网站被注入恶意脚本排查实录:AI 辅助安全分析实战

网站被注入恶意脚本排查实录:AI 辅助安全分析实战某天收到华为云监管反馈,称个人博客网站存在色情违规界面。作为技术博主,这简直是晴天霹雳——网站一直正常运行,怎么会突然出现这类问题?经过排查发现,网站被注入了恶意跳转脚本。本文记录完整的排查过程,重点分享如何借助 AI 快速定位问题并修复,希望能给遇到类似情况的开发者提供参考。1、问题发现与初步排查收到监管反馈后,第一时间打开网站检查。首页看起来完

359
时隔三年,再次备份:从手搓博客到 AI 全面自动接管
精选AI

时隔三年,再次备份:从手搓博客到 AI 全面自动接管

时隔三年,再次备份:从手搓博客到 AI 全面自动接管今天打开尘封多年的备份目录,看到了一个熟悉的文件名——2023_07_09.sql。那一刻,恍如隔世。三年前的今天,我曾经认真地导出过一次数据库;三年后的今天——2026 年 7 月 9 日,自动备份启用。整整三年,1095 天,我的博客就这样安安静静地跑着,没有人备份,也没有人管,它就那么稳稳当当地活着。感慨万千,大概是此刻最准确的四个字——不

331