系统提示词第一行写着「当前时间:{now}」,精确到秒。这行字对模型有用,对缓存却很伤:DeepSeek 的上下文硬盘缓存是拿输入从头往后比对的,开头每次都变,后面再长的固定内容也只能记成未命中。
命中率自己算一下
接口返回的 usage 里有两个字段。prompt_cache_hit_tokens 是这次输入里吃到缓存的 token 数,prompt_cache_miss_tokens 是剩下没命中的部分,两者加起来就是这次请求的全部 prompt token。单看一次请求说明不了什么,按会话或者按业务场景累加起来才看得出规律。思路大概这样:
from collections import defaultdict
stats = defaultdict(lambda: [0, 0]) # key -> [hit, miss]
def record(key, usage):
stats[key][0] += getattr(usage, "prompt_cache_hit_tokens", 0) or 0
stats[key][1] += getattr(usage, "prompt_cache_miss_tokens", 0) or 0
def report():
for key, (hit, miss) in stats.items():
total = hit + miss
rate = hit / total if total else 0
print(f"{key}: hit={hit} miss={miss} rate={rate:.1%}")
key 用会话 ID 或场景名都行。哪一类请求的比例特别难看,问题多半出在它的拼装方式上。
缓存是怎么落盘的
10 月的硬盘缓存文档把规则写得比以前细。每条缓存前缀是一个独立单元,后续请求要完整匹配上其中某一条才算命中。单元在这几处产生:
- 每次请求里,用户输入的末尾和模型输出的末尾各落一个单元。
- 系统发现多次请求之间有公共前缀时,会把这一段单独落盘。
- 很长的输入或输出,按固定的 token 间隔切出单元。
文档举的例子很说明问题:第一轮发 A+B、第二轮换成 A+C 时,后者不会命中,因为它没能完整匹配 A+B 这个单元。系统这时才把公共的 A 落盘,等 A+D 进来就能吃到。所以同一份长文档换着问题问,头一两次没命中是正常的,别急着改代码。
文档还写了两条:缓存是「尽力而为」,不保证 100% 命中;构建耗时是秒级,不再使用后一般几个小时到几天内被清空。照这个说法,几毫秒内并发打出去的同前缀请求,后面那几个很难赶上前一个的缓存。隔了好几天才跑一次的批任务,冷启动也不奇怪。
有一种做法可以试:批量任务开跑前先单发一条带完整固定前缀的请求,等几秒再并发放量。这是按「构建要秒级」这条推出来的,文档没这么建议过,效果得自己用上面的统计对比。
缓存也不会改变输出。文档说硬盘缓存只匹配输入前缀,输出照样是推理算出来的,受 temperature 等参数影响,效果和不走缓存时相同。所以不用担心命中缓存会拿到上一次的回答,测试用例里也别指望靠它把输出变稳定。
拼提示词时注意顺序
固定内容往前放,易变的往后挪:
- 系统提示词和长参考文档放最前面,每次逐字一致,空格、换行也别动。
- 时间、请求 ID、用户名这类每次都变的内容,挪到最后一条 user 消息里。
- 工具定义按理也是输入的一部分,列表别每次重排,从 dict 生成 JSON 时留意键的次序稳不稳定。
- 多轮对话只往后追加,不回头改历史消息。中途把前文压成摘要,前缀就从改动的位置断开了。
省下来的钱有多少
价格页上,输入按命没命中缓存分开计价。拿 deepseek-flash 的高峰时段说,吃到缓存的输入每百万 tokens 收 0.04 元,未命中的按 2 元算;deepseek-v4-pro 对应是 0.30 元和 9.0 元。空闲时段统一按一半收。高峰时段指北京时间工作日(不含法定节假日)9:00–12:00 和 14:00–18:00。价格页随时可能调整,动手算账前再点开看一眼。
Agent 那种每一轮都带着完整上下文回去的用法,输入 token 占大头,命中率每往上提一截,账单上都看得出来。
大家线上跑下来,命中率一般能到多少?