☰
PTC前缀缓存省Token:三种口径与Harness压测验证
2026/11/4 17:35:34 网站建设 项目流程

省 Token 这三个字,在大模型应用真正进入工程化阶段之后,被用得太随意了。我把 PTC 模式(Prompt Token Caching,提示词前缀缓存)和 DeepSeek Harness 这套压测装置放在一起看的时候,第一反应并不是「又能白嫖算力了」,而是「它按哪个口径算的这笔账」。因为在实际项目里我见过太多「省了 80% Token」的案例,拆开一看,省的只是 prefill 的算力时间,账单上一分没少;也见过反向的,缓存确实命中了,但因为前缀设计得太碎、请求切得太散,命中率低到完全覆盖不了额外维护成本。这篇东西想聊的就是这件事:PTC 模式在机制上到底省了什么,DeepSeek Harness 这类装置凭什么能拿出「省 Token」的结论,以及你自己怎么搭一套能说服自己的验证流程。适合正在做大模型应用成本优化、准备把长上下文能力接进生产环境的开发者,也适合只是想搞明白「缓存命中」这个词到底指什么的新手。

1. 先把「省 Token」这句话拆成三种口径

1.1 计费口径:命中缓存是打折,不是免单

很多人对前缀缓存的第一印象是「重复的部分不用付钱了」,这个理解需要纠正一下。绝大多数提供 PTC 能力的推理服务,对命中缓存的输入 token 采用的是折扣计价,而不是零计价。常见的折扣区间大致在标准输入价的十分之一到一半之间,具体比例各家不同,而且会随模型版本、上下文长度、缓存类型变化。

这意味着什么?意味着「省 Token」在计费口径上是一个乘法问题,不是一个减法问题。举个虚构的算例:某次请求的 prompt 是 20000 token,其中 18000 token 是固定前缀、2000 token 是本次变量。假设标准输入价是每百万 token 若干元,缓存命中价是标准价的 0.1 倍,那么无缓存时的输入成本对应 20000 个单位,有缓存时对应 18000×0.1 + 2000 = 3800 个单位,节省比例约 81%。这个数字看起来很漂亮,但它成立的前提是那 18000 token 严格逐字节一致,并且落在缓存有效期之内。

而如果前缀只有 5000 token,剩下 15000 都是变量,同一套算法算下来节省比例只有 3%。这就是为什么我强调先分清口径:同样一句「省 Token」,在长前缀短变量的场景里是质变,在短前缀长变量的场景里几乎是噪音。

1.2 算力口径:KV Cache 复用省掉的是 prefill

从推理引擎的角度看,PTC 省掉的不是「Token 数量」,而是「重复的 prefill 计算」。Transformer 解码时,每个已处理的 token 都会在每一层留下 Key 和 Value 向量,也就是常说的 KV Cache。输入越长,KV Cache 越大,prefill 阶段要把这几万个 token 全部过一遍注意力计算,这才是长上下文请求又慢又贵的原因。

前缀缓存做的事情,本质上是把某段前缀对应的 KV Cache 保存下来,下次遇到完全相同的前缀,直接把这批张量取回来接着算,跳过重复的 prefill。所以它在算力口径上的收益非常直接:prefill 的 FLOPs 大幅下降,GPU 显存带宽压力下降,同一张卡能扛的并发上去了。

但这里有个容易被忽略的点:KV Cache 是要占显存的。缓存命中率越高,引擎需要留出的缓存空间越大,能同时服务的并发数可能反而被挤压。所以「省算力」不等于「吞吐一定涨」,它是一个需要压测才能定论的权衡,而不是一个必然结论。

1.3 体验口径:首字延迟和总耗时的改善幅度不一样

第三个口径是用户能感知到的延迟。这里必须分开看两个指标:TTFT(首字延迟)和整体生成耗时。

前缀缓存主要改善的是 TTFT,因为省掉的是输入侧的计算。一个 3 万 token 的 prompt,无缓存时 TTFT 可能要好几秒,命中缓存后能压到几百毫秒级别。但输出阶段是一个 token 一个 token 自回归生成的,那部分完全不受前缀缓存影响,该多长时间还是多长时间。

我见过有团队拿「总耗时下降 40%」来宣传缓存效果,追问下去发现他们测的是短输出任务,输出只有几十个 token,输入占了大头,所以整体耗时被 prefill 主导。这种测法本身没错,但如果你的真实业务是长文生成、输出动辄两三千 token,那缓存带来的端到端改善可能只有个位数百分比。

提示:对外汇报缓存收益时,一定把 TTFT 和总耗时分开列,把输入侧成本和输出侧成本分开列。混在一起讲,短期好看,长期一定会在复盘时被打回。

2. PTC 模式的运行机制:前缀为什么必须「一字不差」

2.1 从「每次重算」到「按前缀寻址」

要理解 PTC,得先理解推理引擎怎么判断「这段内容我见过」。常见做法是把前缀按固定长度的 token 块做切分,对每个块计算一个内容指纹,然后用这个指纹去索引已缓存的 KV 块。请求进来时,引擎从头开始逐块比对,直到遇到第一个指纹不匹配的位置,前面匹配上的部分就算命中。

这个机制决定了三件事。第一,匹配是从第一个 token 开始的,中间断开就全断,不存在「跳过开头匹配中间」这种能力。第二,匹配粒度是块级别的,通常一块几十到上百个 token,前缀末尾那点零头大概率匹配不上。第三,只要有一个字符不同,指纹就变了,缓存的整条链就断了。

所以「省 Token」能不能成立,说到底是一个前缀工程问题,而不是一个接口调用问题。你把 prompt 拼得越稳定、越靠前,省得越多;你把易变内容往前塞,省得越少,甚至可能因为额外的缓存查找开销变得更慢。

2.2 头号杀手:把时间戳和随机 ID 塞在 system prompt 开头

这是我在实际项目里见过频率最高、破坏力最大的一个错误。某些团队为了做链路追踪或者「让模型知道现在几点」,会在 system prompt 的最前面拼一段类似「当前时间:2025-XX-XX XX:XX:XX,请求 ID:abc123」的内容。这段内容本身只有几十个 token,看起来无关紧要,但它一旦出现在第 0 个位置,整个前缀的指纹就变了,后面几万 token 的公共指令、知识库片段、few-shot 示例全部命中不了。

正确做法很简单:把易变内容全部后置到用户消息里,system prompt 保持绝对纯净和稳定。如果模型确实需要知道当前时间,就把时间放在最后一条 user message 的末尾,或者干脆通过工具调用让模型自己去取。追踪 ID 更不应该进 prompt,它属于日志层的事情。

同样的坑还有几个变体:在 system prompt 里插入用户名、插入租户 ID、插入 A/B 实验分组标记、插入动态排序的 few-shot 示例。只要这些内容在开头,命中率基本归零。

2.3 缓存的生命周期:它是有保质期的

前缀缓存不是永久存储,几乎所有的实现都有回收策略。常见的是「空闲一段时间后释放」加上「按最近最少使用淘汰」,空闲窗口从几分钟到几小时不等,具体取决于服务方的显存压力。

这个特性对业务节奏有直接影响。如果你的应用是典型的办公场景,上午九点到十点请求密集,中午几乎没量,下午两点又开始放量,那么中午那段空闲很可能把上午的缓存全部回收掉,下午的第一批请求会全部冷启动。这时候压测报告上那个 70% 的命中率就是假的,因为压测是连续跑的,缓存一直热着。

我的做法是在压测脚本里显式设计「冷启动轮次」和「热态轮次」,分别统计,并且在报告里明确标注两者的比例。真实业务的命中率大概率介于两者之间,具体落在哪,取决于你的流量曲线形状。

2.4 最小前缀长度门槛

还有一个容易被忽略的细节:很多实现会设一个最小可缓存长度,低于这个长度直接不走缓存逻辑。这个阈值通常是几百到一千多 token 的量级,目的是避免为了一小段前缀付出管理成本。

这就带来一个判断标准:如果你一次请求的稳定前缀连一千 token 都不到,那追求 PTC 收益基本没有意义,不如把精力放在缩短输出、减少轮次、压缩无关上下文这些更直接的地方。PTC 是一个吃「长前缀」的模式,前缀越长,边际收益越明显。

3. Harness 的价值:为什么「省 Token」需要一个专门的测量装置

3.1 自证省钱最容易掉进去的陷阱

我先说清楚一件事:我手上没有这套 Harness 的源码,下面关于它设计思路的分析,是基于公开可观察的行为特征和通用推理压测框架的实现方式做的合理推断,属于「一个合格从业者在同样目标下最可能采用的方案」。

在它出现之前,大家是怎么证明自己省钱的?基本上是三种做法。第一种是单次调用对比:跑一次不带缓存的,再跑一次带缓存的,看返回的 usage 字段里缓存命中多少 token,然后算个比例。第二种是纸面推算:根据前缀长度和官方折扣比例直接乘出来。第三种是抽样日志:从线上捞一批请求,看看缓存命中字段的分布。

这三种做法的共同问题是没有控制变量。单次对比忽略了冷热差异,纸面推算忽略了命中率,抽样日志忽略了流量分布和任务类型。最后得出的数字,乐观得让人不敢拿去跟财务对账。

3.2 一个能打的压测装置必须锁死哪些变量

我认为一套可信的 Token 账单验证装置,至少要锁住下面这几组变量,缺一个结论就站不住。

变量类别具体内容不锁住的后果
输入分布前缀长度、变量长度、前缀占比命中率完全失真
调用时序冷启动轮次、热态轮次、间隔时长把热态数据当常态
并发模型串行、固定并发、梯度并发混淆单请求收益与系统吞吐
任务类型短输出、长输出、结构化输出混算成本,夸大端到端改善
统计口径中位数、均值、P95、异常剔除被少数极端值带偏

这张表里我个人最看重的是「调用时序」和「输入分布」这两行。因为绝大多数夸大的结论,都是从这两处漏出来的。串行跑一万次,前缀写死不变,命中率能跑到 95% 以上,但这个数字对真实业务几乎没有参考价值。

3.3 指标怎么定义才算数

指标定义这块,我建议直接抛弃「省了百分之多少」这种单一数字,改用一组能互相印证的指标。

第一个是前缀命中率,等于命中缓存的输入 token 数除以输入 token 总数。这个指标反映 prompt 工程质量,不反映钱。

第二个是有效输入单价,等于输入侧总花费除以输入 token 总数。这个指标直接反映钱,而且能把折扣比例和命中率合在一起体现。

第三个是端到端单任务成本,等于一段时间内的总花费除以成功完成的任务数。这个指标最贴近业务,因为它把重试、失败、多轮交互全算进去了。

第四个是TTFT 分位数,通常看 P50 和 P95。缓存对延迟的改善极不均匀,P95 往往比 P50 更能说明问题,因为长尾请求受缓存命中与否的影响最大。

这四个指标放在一起看,才能回答「到底省没省、省在哪、值不值得」这个完整的问题。只看其中任何一个,都容易被自己的数据骗到。

4. 自己搭一套最小可用的验证流程

4.1 环境与前置准备

不需要什么复杂工具链。一台能跑 Python 的机器、一个支持返回缓存命中字段的接口、一份真实的业务 prompt 样本,就够了。关键是把接口返回的 usage 结构摸清楚,不同服务方字段名不一样,常见的命名包括 cached tokens、prompt cache hit tokens、cache read tokens 之类,含义大同小异,都指「本次输入中有多少 token 命中了缓存」。

我的建议是先写一个探测脚本,发两次完全相同的请求,把两次的完整 usage 打出来对比,确认哪些字段会变化、变化是否符合预期。这一步花十分钟,能省掉后面几个小时的困惑。因为我遇到过接口把缓存命中数放在嵌套对象里、外层还有个同名但含义不同的字段,直接读外层会得出完全错误的结论。

4.2 构造一份有代表性的测试集

测试集的设计决定了结论的可信度。我的做法是准备三组数据,分别对应三种业务形态。

第一组叫「强前缀型」:一个 8000 到 20000 token 的固定前缀,配上 200 到 500 token 的变量输入。典型场景是长文档问答、代码库助手、固定人设的客服。这组数据用来测 PTC 的理论上限。

第二组叫「弱前缀型」:前缀只有 500 到 1500 token,变量输入 2000 到 5000 token。典型场景是通用聊天、内容改写。这组数据用来验证「短前缀到底值不值得上缓存」。

第三组叫「混合型」:从前两组里按真实流量比例抽样混合,模拟一个多业务线的应用。这组数据用来得出最终可以对外汇报的数字。

每组至少准备 200 条样本,前缀部分要保证同组内完全一致,变量部分要保证真实多样。千万不要用「测试 1」「测试 2」这种模板化变量,那样会人为制造出前缀相似度,把命中率抬虚。

4.3 压测循环的写法

下面这段是最小可用的循环骨架,语言是 Python,接口按常见的兼容格式写,端点用占位符,实际跑的时候换成自己的即可。

import os import time import json import statistics from collections import defaultdict BASE_URL = os.environ["LLM_BASE_URL"] API_KEY = os.environ["LLM_API_KEY"] MODEL = os.environ.get("LLM_MODEL", "your-model-name") def call_once(system_prefix: str, user_var: str, max_tokens: int = 256): payload = { "model": MODEL, "messages": [ {"role": "system", "content": system_prefix}, {"role": "user", "content": user_var}, ], "max_tokens": max_tokens, "temperature": 0, } t0 = time.perf_counter() resp = http_post_json(f"{BASE_URL}/chat/completions", payload, API_KEY) ttft_proxy = time.perf_counter() - t0 usage = resp.get("usage", {}) return { "prompt_tokens": usage.get("prompt_tokens", 0), "cached_tokens": extract_cached_tokens(usage), "completion_tokens": usage.get("completion_tokens", 0), "latency": ttft_proxy, } def run_round(samples, rounds, gap_seconds): records = [] for r in range(rounds): for s in samples: rec = call_once(s["prefix"], s["var"]) rec["round"] = r rec["sample_id"] = s["id"] records.append(rec) time.sleep(gap_seconds) return records

这段代码里有两个我特意保留的设计。一个是temperature=0,压测阶段要尽量消除随机性对输出长度的影响,不然 completion_tokens 波动会让成本对比失真。另一个是gap_seconds参数,用来模拟真实业务的请求间隔,同时也是控制缓存冷热的手段。

extract_cached_tokens这个函数需要你自己按接口实际情况写。我强烈建议在里面加一段兜底逻辑:如果找不到任何缓存相关字段,就返回None而不是0,因为「没有这个字段」和「命中 0 个」是完全不同的两件事,混在一起会让统计结果毫无意义。

4.4 怎么算、怎么读

统计部分,我一般会算三个数。

命中率= Σcached_tokens / Σprompt_tokens。注意是按总量算,不是按请求数算,也不是先算每条再平均。因为长请求和短请求的权重不一样,先平均会把结果带偏。

有效输入单价= 输入侧总花费 / Σprompt_tokens。这里需要你自己按计费规则把 cached 部分和未命中部分分开算,再求和。

相对节省比例= 1 −(有缓存总花费 / 无缓存总花费)。无缓存总花费是一个反事实数字,需要你在同样的样本上,用完全不同的前缀(比如每条样本前缀里混入不同随机串)跑一遍,或者用纸面规则推算。我更推荐前一种,虽然跑起来麻烦,但它把「缓存查找本身的开销」也算进去了。

举个虚构的判读例子。假设强前缀组的命中率跑到 85%,有效输入单价降到标准价的 0.22 倍,那这组结论很扎实。弱前缀组命中率只有 12%,有效单价 0.95 倍,基本等于没有,这时候如果你还把两组混在一起汇报「整体省了 40%」,那就是在用强场景的数据给弱场景贴金。

注意:所有数字都要带上样本量和时间窗口。只写「命中率 85%」不写「基于 200 条样本、连续 3 轮、间隔 30 秒」,等于没写。

5. 常见翻车现场与排查手册

5.1 命中率奇低,九成是这五个原因

排查命中率问题,我一般按下面的顺序走,从最常见到最罕见。

第一个,前缀里有动态内容。前面说过了,时间戳、请求 ID、用户名、随机种子,只要出现在前缀里就是灾难。验证方法很简单:把同一个前缀连续发两次,看第二次的 cached_tokens 是不是接近前缀长度。如果不是,就把前缀逐段二分,找出哪一段一变就断,问题立刻暴露。

第二个,前缀被截断。有些团队为了控制成本会设置最大输入长度,超了就从中间截断。一截断,前缀就变了,缓存自然全废。这个坑很隐蔽,因为请求本身是成功的,只是命中率低。

第三个,缓存过期。请求间隔超过缓存保留时间,每次都冷启动。验证方法是把间隔从 5 秒逐步拉到 10 分钟,画出命中率随间隔变化的曲线,你就能看出保留窗口大概在哪。

第四个,负载均衡打到不同实例。如果缓存是节点本地存储的,请求被分发到不同节点,各节点各自缓存一份,命中率会被摊薄。这种情况下要么改路由策略做前缀亲和,要么接受这个损耗。

第五个,前缀长度不够门槛。这个不用多解释,把前缀加长或者干脆放弃。

5.2 省钱省成亏钱的几种情形

这是我更想提醒的部分,因为「上了缓存反而更贵」这件事,很多人压根没想过。

情形一:短前缀 + 高频小请求。缓存管理本身有开销,块指纹计算、索引查找、显存调度都要花资源。当前缀只有几百 token,命中带来的收益小于管理开销,结果就是延迟不降反升。我用过一套方案,在短请求场景下开缓存比不开平均慢 8%,关掉之后一切正常。

情形二:前缀太多太散。如果系统里有几百种不同的 system prompt,每种用的人都不多,那缓存空间会被大量低频前缀占满,互相挤兑,谁都不容易命中。这种情况下收敛 prompt 模板,比上缓存有用得多。

情形三:只优化输入,忽略输出。输入 token 便宜,输出 token 贵,这是普遍的定价结构。一个请求如果输出两千 token,输入省一半也只影响总成本的一小部分。我见过团队花两周优化前缀,总成本只降了 4%,而他们把输出里重复的客套话砍掉之后,一周就降了 15%。优先级要算清楚。

情形四:为了命中缓存牺牲了正确性。最极端的情况,为了保持前缀一致,把本该动态更新的知识库片段冻在 system prompt 里,导致模型回答过时信息。这种「省钱」是负收益,一定要在流程里设红线。

5.3 排查速查表

现象最可能的原因快速验证方法处理方式
命中率长期低于 5%前缀含动态内容同前缀连发两次比对 cached 字段清理前缀,动态内容后置
首轮全不命中,后续正常缓存冷启动观察第一次与第二次差异属正常现象,报告中分列冷热
上午命中率高,下午归零缓存被回收拉长间隔测命中率衰减曲线优化流量平滑度或接受冷启动
开了缓存延迟反而升高前缀太短,管理开销占优同场景开关缓存各跑一轮短前缀场景直接关闭
单机测试很好,线上很差负载分散到多实例查请求落点分布做前缀亲和路由
输入成本降了,总成本没降输出占成本大头拆分输入输出账单优先优化输出长度

这张表里最后一行是我最想让人记住的。优化动作一定要对准成本结构里最大的那一块,不然做得再精细也只是在边角料上折腾。

6. 我在实际项目里踩过的几个坑

第一个坑是「压测数据自我催眠」。早期我写压测脚本,前缀写死在代码里,循环一万次,跑出来命中率 93%,兴冲冲地拿去汇报。上线之后真实命中率只有 30% 出头。原因很简单,真实用户的输入千差万别,而我的测试集里变量部分就那二十几条,前缀还完全一样。后来我强制要求测试集必须从线上日志抽样,前缀按业务线分组,才把数字修回可信区间。这件事给我的教训是:压测的难度不在写代码,在设计样本。

第二个坑是「把缓存命中当成免费的午餐」。我一度以为只要前缀稳定,成本就会线性下降。实际跑下来发现,缓存命中率从 60% 提到 85%,账单只降了不到 10%。原因是那 60% 已经覆盖了最长的公共前缀,后面再多命中的都是短前缀,边际收益递减得非常快。现在我评估缓存方案,会先算「前缀长度分布」而不是「前缀是否存在」,前者才是决定收益的关键。

第三个坑和指标口径有关。有段时间我盯「平均有效单价」盯得很紧,数字一直很漂亮,但月底对账总是超预算。后来把数据拆开才发现,平均单价被大量短请求拉低了,而真正吃钱的是少数超长上下文请求,它们的命中率并不高。从那以后我固定用「总花费除以成功任务数」作为主指标,平均单价只作为辅助参考。

第四个坑比较隐蔽:结构化输出和缓存会打架。有一段时间我在 prompt 末尾追加一段 JSON Schema 约束,位置在 user 消息之后。这本身没问题,但后来有人把这段 Schema 挪到了 system prompt 末尾,导致 system prompt 变成一个微调就变的长文本,前缀稳定性被破坏。这类改动在代码 review 里极难发现,因为它只改了几行字符串拼接的顺序。我的应对办法是在 prompt 组装函数里加断言,把稳定段和动态段用明确的变量名分开,任何往稳定段里塞变量的行为直接抛异常。

最后分享一个我现在固定用的习惯:每次改 prompt 模板,都顺手跑一次十分钟的小压测,只看两个数——前缀命中率和有效输入单价。这两个数一旦出现异常波动,基本就说明有人在稳定段里动了手脚。这个习惯帮我拦下过好几次「为了修一个小 bug 顺手改了 system prompt 开头」的提交,成本极低,收益很实在。回到最开始那个问题,一套 Harness 敢说自己省 Token,靠的从来不是某个漂亮的百分比,而是它把冷热、分布、口径这些东西都摆到台面上让你自己判断。你自己搭的那套小流程,只要能守住同样的纪律,结论一样站得住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询