调各家模型,响应里经常冒出一行:命中了多少缓存。cached_tokens、cache read 九折,产品页也爱写。
容易理解成两件事,都不对:
- 模型把上次的答案记住了,这次直接复读
- 你发的 JSON 被网关存了一份,撞上就少走一遭
命中的是 计算。同一段输入开头,注意力已经算过,Key / Value 还在那张 GPU 的显存里,这次不用重算。答案仍是当场一个字一个字生成的。
这件事为什么必须「开头一模一样」?因为模型根本不看 JSON。缓存比的是翻译之后的 token 线。要把命中讲清楚,就得把这条线从训练一路走到 data:。BPE、KV、prefill 这些词文末有一份表。
命中到底命中了什么
一次生成在 GPU 上分两段:
- prefill:把整段输入从头跑一遍注意力,为每个 token、每一层算出 K 和 V。输入越长越贵。
- decode:每出一个新字,只算这个字的 K/V,前面的查表。
第 N 个位置的 K/V 依赖前面 1…N 的全部内容。所以能跨请求复用的,只能是 从头开始、完全相同的那一截,术语叫前缀。中间像不像没用。
产品化之后叫 prompt cache:
- 命中:这张卡上还有这份前缀的 KV,跳过那段 prefill,输入按折扣价,首字更快
- 未命中:整段重算,再(可能)把新前缀写进这张卡
它不是 Redis 里的一小段字符串。一份长 prompt 的 KV 可以是几百 MB 到几十 GB,默认堆在显存。下一请求必须再落到 还握着那份显存的那台推理机。OpenAI 用 prompt 开头大约 256 个 token 做哈希粘性路由;prompt_cache_key 并进哈希。打到另一张卡,前缀再对也是 miss。本地 vLLM / llama.cpp 同理:缓存在你正在跑的那块 GPU 上,进程一换就没了。
到这里还缺一环:API 里你交的是 JSON,messages、tools 整整齐齐。引擎比的却是 token id。谁来保证「JSON 看起来差不多」会变成「id 前缀真的一样」?
模型只看见一条线
Transformer 的输入永远是:
[t0, t1, t2, ..., tn]
你发的是:
[
{"role": "system", "content": "你是助手"},
{"role": "user", "content": "今天星期几"}
]
对权重没有意义。对话要先压成带边界的线性文本,再切成 token。缓存比的就是切完的那串整数。JSON 差一个空格、模板换一套记号,id 从头对不上,引擎只能 miss。
训练完的权重也不会突然开始收 JSON。它是一张「下一个 token」对照表,不会读 HTTP,不会写 data: {"choices":...}。能收 JSON 的是门口的推理服务。直接把 JSON 字节丢进 generate(),模型会把花括号当普通字符瞎续,缓存也无从谈起。
训练为什么排成 ChatML
预训练先不管对话。文档和文档之间塞 <|endoftext|>,学「下一个字是什么」。
指令微调才把对话排成 ChatML。Qwen 几乎原样用了 OpenAI 当年那份:
<|im_start|>system
你是助手<|im_end|>
<|im_start|>user
今天星期几<|im_end|>
<|im_start|>assistant
今天星期三。<|im_end|>
<|im_start|> 一轮开始,<|im_end|> 一轮结束。标注文件常常是 JSON,那只是给人看的存储格式。进训练循环前会用 chat template 渲成上面这段,再变成 token id。模型从未看见 {"role":"user"}。
为什么不用 User: / Assistant:?早期开源就是那么干的。硬伤是:
- 普通词不是原子的。
User:会被 BPE 拆开,用户正文里出现同样的字,模型和格式撞车。<|im_start|>在词表里是一个 ID,不会拆。边界稳,前缀才稳,缓存才比得了。 - 结束符要能当 stop。 训练时助手段在
<|im_end|>结束。用英文冒号当停词,用户文本里随时误伤。 - 角色是字符串。 特殊标记只包头尾,以后加
tool不用改词表。
训练样本里 assistant 是写完并闭合的。loss 通常只打在助手正文和结尾的 <|im_end|> 上,system / user 整段 mask 掉。模型学会的是:看到「ChatML 历史 + <|im_start|>assistant 换行」之后,吐出正文再吐结束符。
推理时服务端故意不闭合最后一轮:渲染到 <|im_start|>assistant\n 就停。多轮对话里,已经说过的轮次带着它们的 <|im_end|> 原样排在前面——这截正是缓存最想命中的前缀。
Llama 3、Mistral 记号不同。换错模板,前缀分布变了,回答变差,缓存也一起崩。
上线:JSON 进门,缓存发生在引擎
一次请求过五层。命中只发生在第三层。
客户端
POST /v1/chat/completions
{ messages, tools, stream: true }
│
▼
① HTTP 路由 挂上 /v1/chat/completions,假装自己是 OpenAI
│ 不缓存
▼
② 请求翻译 OpenAI JSON → 同一套 ChatML → token id
│ 不缓存;它决定引擎能不能命中
▼
③ 推理引擎 prefill + decode
│ 前缀 id 相同 → 复用这张卡上的 KV
▼
④ 后处理解析 从纯文本里抠 tool_call、<think>
│
▼
⑤ 响应包装 yield "data: {...}\n\n"
把引擎的 cached_tokens 抄进 usage
三家里层数一样:
| 层 | vLLM | llama.cpp | Ollama |
|---|---|---|---|
| 路由 | api_server.py | tools/server/server.cpp | server/routes.go |
| 入站翻译 | _preprocess_chat | oaicompat_chat_params_parse + Jinja | ChatMiddleware → FromChatRequest |
| 推理(KV 缓存在这) | engine_client.generate | slot / llama 解码 | 自家 ChatHandler |
| 出站包装 | ChatCompletionStreamResponse | to_json_oaicompat_chat_stream | ToStreamChunks |
① 只负责像 OpenAI。vLLM 流式挂 text/event-stream;llama.cpp 同一条 handler 挂 /v1/chat/completions;Ollama 的 ChatHandler 仍吃内部格式,OpenAI 字段全在中间件里翻。
② 是缓存的前提。SGLang 先 apply_chat_template(..., tokenize=False) 再 encode。llama.cpp 把 messages / tools 丢进 common_chat_templates_apply,结果塞进引擎的 prompt。两次请求只有在这里渲出 同一串 id 前缀,③ 才有东西可比。
chatcmpl- 这种请求 id 是字符串拼接,跟缓存无关。vLLM 写死前缀;llama.cpp 是 "chatcmpl-" + random_string();Ollama 甚至 fmt.Sprintf("chatcmpl-%d", rand.Intn(999))。
③ 的对象叫 RequestOutput 或 server_task_result_cmpl_partial:text、token_ids、finish_reason、num_cached_tokens。没有 choices,没有 delta。账单上的命中从这里来。
走两趟就清楚了:
请求 A
JSON:system「你是助手」+ user「今天星期几」
→ ChatML → [id_system ...][id_user 今天星期几][id_assistant头]
→ 引擎 prefill,KV 留在这张卡上
→ decode「今天星期三。」
→ ⑤ 包成 data:,usage 里 cached 接近 0
请求 B:系统提示、工具、历史一字不改,只把最后的 user 换成「明天呢」
→ 同一张模板,前面那些 id 完全相同
→ 引擎命中前缀 KV,只 prefill 「明天呢」那一截
→ decode 新答案
→ ⑤ 的 cached_tokens 变大,输入按折扣计
模板一换、tools 顺序一乱、系统提示最前面插一个时间戳,② 渲出来的 id 从头就对不上。缓存比的是 token 前缀,不是「JSON 看起来差不多」。
请求 B 的问题已经换了,却仍能命中。这就解释了另一件看起来更怪的事:产品页动不动写命中率 90% 以上,你每次输入明明都不一样。
90% 说的不是「十分之九的问题撞上了同一道题」,而是 这一次 prompt 里十分之九的 token 不用重算:
命中率 ≈ cached_tokens / prompt_tokens
一次请求里真正在变的,往往只是最后那一句。前面那一大截几乎没动:
[ tools 定义 几千 token,几乎每次一样 ]
[ 系统提示 / 人设 几千到几万,几乎每次一样 ]
[ 仓库说明、手册 几千,几乎每次一样 ]
[ 历史对话 越聊越长,只追加不改 ]
[ 本轮用户问题 几十到几百 ← 唯独这里每次不同 ]
[ <|im_start|>assistant 头 ]
假设工具 + 系统 + 手册 18,000,历史 1,500,新问题 200,合计 19,700。引擎认出前面 19,500 和上次一样:
19,500 / 19,700 ≈ 99%
问题百分之百是新的,命中率仍然可以到 90% 以上。变的那 200 个 token 付全价 prefill,前面那 1.9 万走缓存。Agent 越重(工具多、系统提示长、还带着文件),新问题占的比例越小,这个数字越好看。短聊天、每次系统提示都不同,就会很难看。
多轮会越来越好命中。第二轮起,上一轮的「问题 + 回答」变成历史,整段原样排在前面:
第 1 轮: [系统 18k][问题 A 200] → 系统可命中,问题 A 是新的
第 2 轮: [系统 18k][A + 回答][问题 B 200] → 系统和 A 都命中,只有 B 是新的
第 3 轮: 前面更长,新问题还是那一两百
不是「同一道题问了九次」,而是同一条越来越长的前缀,每次只在尾巴上接一句。ChatML 多轮只追加,缓存只比前缀,两条叠在一起,就是这个数字。
厂商说的「缓存读 0.1 倍价」,也只作用在 被命中的那部分 token 上,不是整单打一折。还是上面那次 19,700:19,500 按缓存价,200 按原价;输出(新生成的字)永远原价,没有缓存折扣。
提高命中率,是在保护 ② 渲出来的那串 id:
- 静态的放最前面:tools、system、手册
- 变的放最后面:本轮问题、最新 tool result
- 多轮只追加,不要改已经渲过的历史
- 时间、user id 塞进最后一条 user message,不要写进系统提示开头
引擎算完带上 num_cached_tokens。⑤ 填进 usage.prompt_tokens_details.cached_tokens。你看见的数字,是外壳在汇报刚才跳过了多少 prefill。
外壳怎么把字推进来,和缓存无关
权重如果被训成用特殊标记调工具,③ 吐出的仍是文本:
<tool_call>
{"name":"get_weather","arguments":{"city":"上海"}}
</tool_call>
④ 的 parser 抠成 tool_calls。没开 --enable-auto-tool-choice 时,"tool_choice":"auto" 直接报错——说明 OpenAI 的工具字段是解析器填的。Qwen 的 <think> 同理,拆成 reasoning_content。这些发生在缓存之后,不参与前缀比对。
⑤ 才把每个增量塞进壳。vLLM 每个新 token 做一次 ChatCompletionStreamResponse,yield "data: " + json + "\n\n",结束补 data: [DONE]\n\n。llama.cpp 用 format_oai_sse 加 data: 和空行。Ollama 拦截内部 NDJSON 再改写成 SSE。
线上看到的一帧:"你" 来自 decode;id、object、choices、data: 来自 ⑤。<|im_start|>assistant 不会出现在 delta.content 里。缓存更不会出现在这些字里——KV 还在 ③ 的显存。
Content-Type: text/event-stream 比这套模型协议更早。2004 年 Hickson 提出服务器往页面推事件,2008 年 MIME 定名为 text/event-stream,2015 年 W3C 做成 Recommendation。LLM 只是借用这条一直开着的 HTTP 管子。管子里装什么 JSON,是 OpenAI 后来定的;管子本身不负责记 KV。
一张全路线
语料
│
▼
训练文本 预训练:文档 + <|endoftext|>
SFT:ChatML(assistant 闭合)
│
▼
tokenizer 字符串 → token id ← 缓存将来要比的就是这个
│
▼
权重 .safetensors / GGUF ← 训练到这里结束
│
▼
推理服务
①② JSON → 同一套 ChatML → token id
前缀稳不稳,在这里就定了
③ GPU prefill / decode
id 前缀相同且落在同一张卡 → 命中 KV
④⑤ 新 token → 纯文本 → data: {...}
cached_tokens 只是把 ③ 的统计抄进 JSON
方向相反的两次翻译,中间夹着引擎,缓存夹在引擎里:
客户端 JSON --② 模板--> ChatML token --③ 权重 + KV--> 新 token --⑤--> data: {...}
↑
命中缓存在这里
一句话:训练用 ChatML,是为了让对话落在一条稳定的 token 线上;命中缓存比的也是这根线从头开始的那一截。JSON 和 data: 是两端的壳,不参与比较。
文里用到的词
按正文出现的顺序。不求教科书定义,只求这篇里它在干什么。
prompt cache / 命中缓存
把已经算过的注意力状态留下来,下次同一段输入开头就跳过重算。发生在推理引擎,不在 JSON 层。命中省的是 prefill,答案仍当场生成。
命中率
通常是 cached_tokens / prompt_tokens,不是「相同问题次数 / 总请求数」。系统提示和历史很长、本轮问题很短时,问题每次都不同,这个比例也可以到 90% 以上。
prefill / decode
一次生成的两段。prefill 把整段输入从头算一遍,贵,产出 KV。decode 每出一个字只算新字,前面查表。长系统提示的成本主要砸在 prefill 上。
注意力 / K / V / KV cache
每个 token 会变成 Query、Key、Value。生成后面的字时,拿新字的 Q 去对前面所有字的 K,再取对应的 V。前面那些 K、V 算过就不变,存下来就是 KV cache。文里的「缓存」指这份东西,不是上次的答案。
前缀
一段 token 序列从头开始的连续一截。缓存只比前缀。时间戳、用户名放在系统提示最前面,后面整段作废。
显存
GPU 上的内存。KV 默认堆在这里,可以到几百 MB 甚至几十 GB。所以缓存活在某一张卡上,不在 Redis,也不在你的笔记本。
粘性路由 / 哈希
要把后续请求再次送到存着那份 KV 的机器,网关用 prompt 开头若干 token 做哈希,映射到同一台卡。prompt_cache_key 并进这个哈希。流量太大仍会溢到别的机器,那次就是 miss。
token / token id
模型吃的是整数编号。一段话先切成小块,每块对应词表里一个 id。缓存比较的就是这串 id,不是 JSON 文本。
Transformer
现在主流大模型的骨架。注意力要求输入是从左到右的一条序列,不能直接塞 JSON 对象。
tokenize / tokenizer / 词表
「字符串 ↔ token id」的对照。词表在训练前定好,既有普通字词,也有 <|im_start|> 这种特殊标记。encode 是人话变 id,decode 是 id 变回字。
BPE
Byte Pair Encoding,常见切词算法。按语料里常见的字节对切子词,不保证一个英文单词等于一个 token。User: 往往被切成 User 和 :,所以拿普通英文当对话边界不稳,前缀也稳不住。特殊标记整串对应一个 id,BPE 不会拆。
ChatML
把多轮对话排成带边界的纯文本:<|im_start|>角色 开头,<|im_end|> 收尾。给模型看,不是给 HTTP 看。Qwen 几乎照抄 OpenAI 当年的写法。
chat template
把 messages 数组排成训练时那串文本的模板,常见是 Jinja。推理必须用和训练同一张。它决定 ② 渲出来的 id 前缀,也就决定 ③ 能不能命中。
预训练
用海量文本学「下一个 token 是什么」。通常还没有 user / assistant。
指令微调 / SFT
再用「问题 + 回答」训一轮,让模型当助手。对话在这里排成 ChatML。训完权重仍然只认识 token id。
loss / mask
loss 是预测错了罚多少。对话训练时常把 system、user mask 掉,只罚助手正文和 <|im_end|>。
停词 / stop
生成时看到某些 token 就停。ChatML 里通常是 <|im_end|>。
权重 / safetensors / GGUF
训练得到的参数文件。里面没有 HTTP 路由,也没有 JSON 解析器,更没有缓存实现。
Jinja
填空模板语言。chat template 用它写「遇到 system 输出什么、怎么循环 messages」。apply_chat_template 就是在跑这张模板。
delta
流式接口里这一帧新增的那一点。delta.content 往往是一个字,客户端拼起来才是整句。和 KV 缓存不是一回事。
SSE / text/event-stream
HTTP 连接不关,服务器按帧推 data: ...。LLM 借用这条管子把 decode 出来的字推给客户端。管子不存 KV。
MIME typeContent-Type 声明正文是哪种材料。application/json 是一整份 JSON,text/event-stream 是一直开着的事件流。
NDJSON
一行一个 JSON 对象。Ollama 内部聊天接口用这个,再改写成 SSE。
tool-call-parser
从模型吐出的文本里抠函数名和参数,填进 OpenAI 的 tool_calls。发生在缓存之后,不参与前缀比对。
base_url
SDK 把请求打到哪台服务器。指到本地 vLLM,协议外形仍是 OpenAI 那套,缓存发生在那台机器的 GPU 上。