Files
2026-08-18 18:32:20 +08:00

291 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
+++
author = "FlintyLemming"
title = "vLLM 推理 DeepSeek + New API 前缀缓存两起故障的定位与处置"
slug = "3c0779ab6468801d9d92e4025c0b9cd0"
date = "2026-08-18"
description = "需要对 Claude Code 做一些单独的适配"
categories = ["AI"]
tags = ["DeepSeek"]
image = "https://assets.flinty.moe/blog/posts/2026/08/vLLM%20%E6%8E%A8%E7%90%86%20DeepSeek%20%2B%20New%20API%20%E5%89%8D%E7%BC%80%E7%BC%93%E5%AD%98%E4%B8%A4%E8%B5%B7%E6%95%85%E9%9A%9C%E7%9A%84%E5%AE%9A%E4%BD%8D%E4%B8%8E%E5%A4%84%E7%BD%AE/anthony-nEn0V7fnQLw-unsplash.jpg"
+++
## 摘要
例行巡检发现,某些用户 Claude Code 的前缀缓存命中率长期停在 14% 左右,而同一渠道、同一模型的其他客户端在 83%–91%。定位结论是:Claude Code 每个请求都会在 `system[0]` 放一段每次都变的计费探测头,网关做协议转换时把它拼到了上游 prompt 的最前端,而 vLLM 的前缀缓存是链式哈希,首块一变、后续全废。网关侧增加渠道级过滤后,该入口命中率恢复到 83%,会话内连续轮次可达 98%–99%。
同一轮排查中核实了二级缓存的实际行为,发现主机内存侧的 KV offload 自 08-07 启动以来只写不读:写入指标点累计 198422 次,回读 0 次,External 命中率恒为 0.0%。原因不是 compose 漏配,而是 vLLM `OffloadingConnector` 的 lookup 对混合 KV 采取全组 AND 判定,DeepSeek-V4-Flash 的滑动窗口组与投机解码组按设计不落盘尾块,导致查询整体返回 0,已经写到内存里的 MLA 前缀也不会被提回显存。该问题只能改 vLLM 或等上游,本次未在生产上处理。
此外补记一项 8/13 已合入的相关修正:网关回给 Claude Code 的 `input_tokens` 此前包含缓存命中部分,导致客户端高估上下文占用、过早触发自动压缩。修正后按 Anthropic 语义扣除缓存。这一项在第 1 节修好之前意义有限,修好之后才真正生效。
| 编号 | 问题 | 状态 |
| --- | --- | --- |
| 一 | 计费探测头打断上游前缀缓存 | 已解决,命中率 14.29% → 83.09% |
| 二 | 二级 KV 缓存(主机内存)只写不读 | 根因已定位,未修复,需改 vLLM |
| 三 | 回包 usage 口径导致上下文过早压缩 | 已解决(8/13 合入),依赖问题一才见效 |
---
## 一、系统构成与关键指标
Claude Code 通过 Anthropic 的 `/v1/messages` 接口接入。网关将请求转换为 OpenAI Chat 格式,转发到 8 卡 H200 上的 DeepSeek-V4-Flash-0731。Reasonix、opencode 等客户端则直接走 `/v1/chat/completions`,落到同一渠道、同一模型。
推理侧设计了两级前缀缓存:
| 层 | 位置 | 作用 | 观测方式 |
| --- | --- | --- | --- |
| L1 | GPU KV | 同一前缀的后续轮次免于重复预填充 | usage 的 `cached_tokens`,日志字段 `insight_cache_tokens` |
| L2 | pinned 主机内存约 960 GB | GPU 池放不下而被挤出的前缀,命中时按需 DMA 回显存 | vLLM 指标 `kv_offload_load_bytes``External prefix cache hit rate` |
命中率按 token 计算,即 `sum(insight_cache_tokens) / sum(prompt_tokens)`。这个指标直接决定 H200 的预填充开销:命中的部分不需要重算,未命中的部分要按完整长度重新做一次 attention 预填充。在十万量级的上下文里,两者的耗时差一个数量级。
需要先说明的是,Claude Code 的自动上下文压缩(compact)读取的是接口返回的 `input_tokens`,而不是网关后台记录的 `prompt_tokens`。这一点使得三个问题在用户观感上是耦合的:前缀不命中导致预填充变慢变贵;回包把缓存计入 `input_tokens` 导致客户端以为窗口更满;提前压缩后整段历史被摘要替换,前缀全部改变,又开出一条几乎没有缓存的新会话。
---
## 二、问题一:计费探测头打断上游前缀缓存
### 2.1 现象
2026-08-17,样本用户(`user_id=xx`)当日整体命中率约 48%,明显低于其 8/10–8/13 的 73%–91%。整体数字下降通常有两种形态:全局劣化,或若干维度被拉低后的加权平均。按请求入口拆开后属于后者。
| 8/17 入口 | 请求数 | prompt | 缓存 | 命中率 |
| --- | --- | --- | --- | --- |
| `/v1/chat/completions` 直打 `deepseek-v4-flash-0731`Reasonix | 444 | 5669 万 | 4744 万 | 83.67% |
| `/v1/messages``llm-prime`Claude Code | 451 | 4811 万 | 687 万 | **14.29%** |
| `/v1/chat/completions``llm-prime` | 7 | 132 万 | 19 万 | 14.31% |
两条链路使用同一 channel、同一 token、同一上游模型,`llm-prime` 只做模型名映射。回溯更早日期,该形态可复现,不是偶发抖动:8/14 首次大量使用 Claude Code 当天,`/v1/messages` 为 15.96%,而同日同模型的 `/v1/chat/completions` 仍有 93.11%。
### 2.2 定位过程
**第一步,看分布而不是均值。** 对 8/17 的 451 次 `/v1/messages` 请求按缓存长度分桶,结果高度反常:
| 缓存 token | 请求数 | 含义 |
| --- | --- | --- |
| 0 | 28 | 完全冷启动或被挤出 |
| 256 | 40 | 仅剩模板级极短前缀 |
| 恰好 17920 | **383** | 固定前缀命中,之后全部 miss |
当天 prompt 从 4 万涨到 18 万,缓存长度却纹丝不动地钉在 17920。这排除了"用户内容太碎"或"KV 争抢"这类概率性解释——概率性因素不会产生一个精确常数。
**第二步,从常数反推断点位置。** 17920 = 280 × 64DeepSeek 的 block 按 64 token 对齐,说明这是一个真实的、稳定的前缀命中。Claude Code 的 23 个工具定义 JSON 约 7.4 万字符,按约 4 字符/token 估算约 1.85 万 token,与 17920 吻合。据此可以判断:请求模板中排在工具定义之后的第一段内容,每一轮都在变。平均 prompt 约 10.7 万,17920 / 106667 ≈ 14.3%,与当日实测的 14.29% 完全一致,反推链条闭合。
**第三步,抓转换前的原始请求,对各段取哈希。** 从 Langfuse `events_full` 取出原始 Claude 请求,对 `system` 三块和 `tools` 分别做 sipHash64
| 块 | 内容 | 8/17 全天稳定性 |
| --- | --- | --- |
| 0 | `x-anthropic-billing-header: cc_version=2.1.116.d8c; cc_entrypoint=claude-vscode; cch=<5 位十六进制>;` | **每请求唯一** |
| 1 | `You are Claude Code, Anthropic's official CLI...` | 同一哈希 `88D95ABDDD0F5572` |
| 2 | 主系统提示(工作区、分支、git status 快照等) | 同一哈希 `363BFB9E19A6BCC3` |
| tools | 23 个工具定义 | 同一哈希 |
连续几拍的 `cch` 取值分别为 `8c7f0``7ee55``490dc`,无规律。块 2 虽然包含 git status,但 Claude Code 在会话内不刷新它,当天哈希未变,因此"git status 每轮在变"这一常见猜测也被排除。
**第四步,确认转换层行为。** `/v1/messages` 转 OpenAI Chat 时,非 OpenRouter 路径把所有 system 段顺序拼成一条 string
```
} else {
systemStr := ""
for _, system := range systems {
if system.Text != nil {
systemStr += *system.Text
}
}
openAIMessage.SetStringContent(systemStr)
```
实际发给 H200 的 system 因此以这段头开始:
```
x-anthropic-billing-header: cc_version=2.1.116.d8c; cc_entrypoint=claude-vscode; cch=XXXXX;You are Claude Code...
```
### 2.3 根因
这段头是 Claude Code 给 Anthropic 官方账单用的探测字段,new-api 与本地 DeepSeek 都不读取(全库检索无该字段名)。问题不在于它存在,而在于它每请求变化,且经拼接后落在了上游 prompt 的最前端。
vLLM 的前缀缓存是链式哈希:block N 的 key 由 block N-1 的 key 与本块内容共同决定。首块内容一变,其后所有 block 的哈希随之改变。`cch=` 位于 token 0 附近,因此同一会话的下一轮请求既无法复用上一轮的 system,也无法复用上一轮的对话历史。只有排在这段头之前的工具定义仍能命中,这就是那个精确的 17920。
作为对照,Reasonix 的 system 以固定的 `You are Reasonix, a coding agent.` 开头,327 次以上请求的前 500 字节几乎不变,缓存随对话长度增长到 12 万以上,命中率因此维持在 80% 以上。
补充两项次要因素,二者均不构成根因:当天 Claude 会话几乎全部是 compact 续聊,compact 会整段改写第一条 user 消息,这解释了压缩周期边界处的冷启动,但解释不了周期内每一轮都 miss09:0014:00 Claude 与 Reasonix 并发打同一块 H200 存在 KV 争抢,但 Reasonix 在同样争抢下仍能维持高命中,说明争抢不是决定性因素。
### 2.4 处置
new-api 原本没有"丢弃指定 system 前缀"的能力。`system_prompt_override` 只能向前插入,`RemoveDisabledFields` 只处理顶层 JSON 键,`param_override``regex_replace` 可以workaround但不是产品功能。
最终实现为渠道级开关 `strip_anthropic_billing_header`,默认关闭。设计上做了几处收敛:
- 只在 Claude Messages → 非 Claude 上游、且把 system 压成一条 string 的路径生效。OpenRouter 的 Claude 分块路径不过滤,因为那条链路上的头可能被真正的 Anthropic 端识别。
- 识别规则写死:text 块 `TrimSpace` 后以 `x-anthropic-billing-header:` 开头则整块丢弃;字符串形态的 system 则去掉第一行。不做成用户可填的通用前缀编辑器——目前只有这一个已知的、每轮都变且排在最前的头,通用能力会带来更大的误伤面。
- 不修改 `ClaudeRequest.System` 原文,因此预扣估算与 Langfuse 原始抓包仍能看到这段头。
- 不改计费公式,不影响走 `/v1/chat/completions` 的其他客户端。
- 回滚只需关闭渠道开关,不必回滚镜像。
上线后在连接 H200 vLLM 对应的渠道打开此功能。
### 2.5 效果验证
验证的前提是确认用法没变,否则前后对比不成立。8/18 当日 Langfuse 记录显示:Claude Code 仍走 `/v1/messages`,每个请求的 `cch` 仍在变化,块 1/2 哈希与 8/17 相同,会话仍然全部是 compact 续聊。也就是说,唯一变量是网关剥掉了这段头。
| | 请求数 | 命中率 | 缓存钉在 17920 的次数 |
| --- | --- | --- | --- |
| 8/17(修复前) | 451 | 14.29% | 383 |
| 8/18(修复后,截至回看时) | 54 | **83.09%** | **0** |
缓存长度分布也从"单点钉死"变回正常形态:5 次 0、4 次 256、3 次不足 64k、14 次 64k128k、28 次 128k 以上。高桶中 `avg_cache ≈ avg_prompt`,说明命中的是对话前缀而非仅工具定义。
同一小时内连续几轮的实际数据(上海时间):
```
09:28 prompt 109974 → cache 256 0.2% 冷启动
09:28 111232 → 109824 98.7%
09:28 111422 → 111104 99.7%
09:36 118702 → 0 0% 间隔较久,KV 被挤出后再冷
09:37 119455 → 118528 99.2%
```
需要说明的是 8/18 样本仅 54 次请求,为部分日数据。但结合分布形态、连续轮次曲线以及"钉死次数归零"三项证据,可以认定行为已经改变,而不是样本波动。另一位仅使用 Claude Code、同样携带该头的用户当日命中率约 91%,可作旁证。
剩余未达 95% 以上的部分,主要来自多客户端并发、超长上下文把 GPU KV 挤爆后的整段冷启动,占请求约 15%。这一部分本应由二级缓存兜底,见下一节。
---
## 三、问题二:二级 KV 缓存只写不读
### 3.1 现象
compose 中配置了 `OffloadingConnector`,约 960 GB pinned 主机内存,设计意图是 GPU 池放不下的前缀在命中时从内存 DMA 回显存。
对容器自 08-07 02:48 启动至记录日的完整日志做只读计数(`docker logs | rg -c`,未改动运行中进程):
| 模式 | 命中行数 |
| --- | --- |
| `kv_offload_store_bytes`(写入) | 198422 |
| `kv_offload_load_bytes`(回读) | **0** |
| `External prefix cache hit rate: 0.0%` | 413174 |
| `External prefix cache hit rate` 非 0.0% | **0** |
11 天,近 20 万次写入指标点,零次回读。
### 3.2 排除配置缺失
启动参数确认 L2 是开启的(`08-07 02:48:01`):
```
kv_transfer_config=KVTransferConfig(
kv_connector='OffloadingConnector',
kv_role='kv_both',
kv_connector_extra_config={'cpu_bytes_to_use': 960000000000},
)
disable_hybrid_kv_cache_manager: False
speculative_config: {'method': 'dspark', 'num_speculative_tokens': 7, ...}
block_size: 256
enable_cumem_allocator: True
```
`expandable_segments` 与 connector 的互斥已通过 `--enable-cumem-allocator` 解开,KV 页物理地址稳定才能 pin 给 DMA,这部分是正确的。8 个 worker 与 EngineCore 均创建了 CPU offload`02:50:05` / `02:54:45`),写入每秒数 GB,说明 `cpu_bytes_to_use` 确实生效。改用 `--kv-offloading-size` 也无济于事,那只是同一套 connector 的另一个入口,lookup 语义相同。
### 3.3 指标口径澄清
这个问题此前之所以没被发现,一部分原因是指标容易被误读。`cpu_cache_usage_perc` 经常掉回 0,看上去像"内存池是空的",但这个 gauge 的定义是**正在 pin 的传输**占池子的比例,写完 `complete_store` 的驻留块会转为 evictable,按设计不计入。因此写完立刻归零是正常现象。
判断"有没有回读"的正确判据只有两个:`kv_offload_load_bytes` 是否大于 0,以及 `External prefix cache hit rate` 是否非 0。
### 3.4 排除"时机不对"
一种自然的怀疑是:GPU 一直有空间,所以还没到该走 L2 的时候。这个假设在 L1 已命中的时刻成立,但在下面这种时刻不成立。取记录日 `08-18 09:25` 附近的日志:
```
09:25:22 GPU KV 0.5%, Prefix 88.3%, External 0.0%
09:25:24 prompt throughput 34844 tok/s (大量预填充正在发生)
Prefix 88.3%, External 0.0%
store_bytes=20646236160
09:25:30 Prefix 88.5%, External 0.0%
```
GPU KV 占用仅 0.5%、同时有 3.4 万 tok/s 的预填充在跑,正是 L2 最该出手的时刻,External 依然是 0.0%。启动后第一分钟的日志形态也一样:`lookup_sync_delay_seconds_count` 有计数,说明 lookup 被调用过,只是结果全是 miss。
### 3.5 根因
生产挂载的 `patch/scheduler.py` 中,`_lookup` 先查全量 MLA 组,再逐个查滑动窗口组与 eagle 组:
```
if num_hit_chunks == 0:
return 0
```
任意一组零命中,整次 L2 load 直接放弃,已经写到 CPU 的 MLA 前缀也不会被 promote。调度器侧将该结果记入 External 统计(`hits = num_external_computed_tokens`),lookup 返回 0External 便恒为 0%。
而 DeepSeek-V4-Flash + DSpark 的 KV 结构恰好必然触发这个否决。模型 `config.json``sliding_window=128``compress_ratios` 含大量 4 / 128`dspark_target_layer_ids=[40,41,42]`vLLM 据此拆出多组 KV
| Group | 内容 | block 大小 | 落盘行为 |
| --- | --- | --- | --- |
| 0 | 全量 MLA | 256 | 正常 store,即那几十 GB/s 的写入 |
| 1+ | compressor 滑动窗口(C4 / C128 | 4 或 8 | 每个 256-token 段只保留尾部,前段按设计跳过 |
| 2 | DSpark/MTP,启动时标记为 eagle | 同上 | **decode 阶段故意不存最后一块** |
最后一条在启动日志里写得很明确:
```
[scheduler.py:194] KV offloading: EAGLE/MTP draft attention groups [2] detected.
The trailing chunk of these groups will be excluded from offloading due to volatility.
```
续聊请求的前缀 = 旧 prompt + 上一轮 decode 出来的 assistant token。eagle 组的尾块按设计没有落盘,lookup 从尾部往前匹配必然对不上,于是 `return 0`,MLA 那一大坨已写入的数据随之作废。这与"缓存过期"无关:即使 CPU 中的 MLA 数据完好,调度器也不接受只加载 MLA。
### 3.6 处置决策
**本次不在生产上关闭 L2,也不热改 vLLM。** 定位清楚即可,修改 lookup 要动调度器,不适合在业务时段碰这套 8 卡服务。
两条后续路径,可二选一或并行:
1. 等上游修复。诉求是 OffloadingConnector 在混合 KV 场景下,SWA/eagle miss 时不应否决已命中的 MLA。
2. 自行修改同一处 lookupmiss 时仍 promote 全量 MLASWA 部分现场重算。
修复成功的判据:日志中出现 `kv_offload_load_bytes > 0`;且在 GPU 占用很低时喂入一条刚被挤出的长前缀,External 不再是 0%new-api 侧同类长会话的 `cache=0` 整段 miss 应明显下降。
在此之前,这 960 GB pinned 内存对命中率没有任何贡献,只消耗宿主机内存与 PCIe 写带宽。若决定先卸掉,关闭 OffloadingConnector 不会让 L1 变差。
---
## 四、问题三:回包 usage 口径与上下文压缩时机
这一项与前两项不在同一层,但用户观感是绑定的,因此一并记录。
OpenAI 兼容上游(包括本地 DeepSeek)习惯把缓存命中计入 `prompt_tokens`。网关转成 Anthropic Messages 时若原样写入 `input_tokens`Claude Code 会把整段已命中的上下文当作"新输入"。流式场景还多一层放大:`message_start` 会先带一版预估 prompt,客户端取它与终态 usage 的较大值,而预估值不含缓存信息。
Claude Code 的自动压缩正是读取这套 `input_tokens`。数值被抬高,客户端就以为窗口更满,从而比真实上下文更早触发 compact。compact 之后整段历史被摘要替换,前缀全部改变,L1 再好也接不上——这构成一个自我强化的劣化环。
8/13 合入的渠道开关 `anthropic_messages_exclude_cache`(channel 5 已开)只修改回给客户端的 usage:
- 终态 `input_tokens = prompt_tokens cache_read cache_creation`,下限 0,缓存部分单独放在 `cache_read_input_tokens`
- 流式 `message_start` 不再发送预估值,避免取 max 抬高
计费与网关后台日志不变,仍使用上游原始 usage。
值得说明的是这一项与问题一的依赖关系:在计费头问题修复之前,真实 `cache_read` 几乎为零,扣不扣区别不大,所以当时看不出效果。修复后命中率到 80% 以上,若不扣除,客户端会把十几万已命中 token 当作新输入,压缩会明显提前。两项修改必须同时到位才有意义。
---
## 五、影响
| 项 | 修复前 | 现状 / 下一步 |
| --- | --- | --- |
| Claude Code 前缀命中 | 每轮改写首块,仅约 1.8 万工具 token 能命中,整日 14% | 会话内 98%99%,整日约 83% |
| 长会话被挤出 GPU | 只能整段重算,L2 从未回读 | 根因已定位,等上游或自行修改 lookup;在此之前并发多条 20 万级会话仍会有冷启动 |
| 回包 usage | 含缓存 + 流式预估取 max,用量虚高、压缩偏早 | 按 Anthropic 语义扣除,压缩按真实新输入触发 |
对日常使用 Claude Code 的同事:同一会话内续聊应更快,因为省掉了大部分预填充;也不应再频繁出现上下文压缩问题。如果仍然频繁压缩,更可能是会话确实很长,或同时开了多个 Agent 把 GPU KV 挤满,而不是网关又把数字报错了。
---
## 六、结论与后续
问题一已闭环。这个案例的可复用之处在于定位路径:整体指标下降先按维度拆分而不是直接归因;看分布而不是均值;出现精确常数时优先从常数反推结构而不是找概率性解释;最后用转换前的原始请求做哈希对照来锁定唯一变量。整条链路上真正的排查成本集中在第三步,而它之所以可行,是因为 8858 的 Langfuse 从 8/15 起开始记录完整请求结构——8/14 其实已经出现过同一症状,当时因为没有抓到请求正文而未能定位。
问题二未修复,根因清晰,属于框架层限制而非配置错误,后续走上游或自改两条路径之一。
问题三已随问题一一并生效。
明确不做的事项:不在业务时段重启或热补 vLLM;不把"去头"做成用户可填的通用前缀编辑器;不为提升命中率而改动计费表达式。