十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

DeepSeek API缓存命中率从27%到99%的九轮优化实践

DeepSeek API缓存命中率从27%到99%的九轮优化实践 如果你也在用 DeepSeek 的 API 做生产级服务多半见过prompt_cache_hit_tokens和prompt_cache_miss_tokens这两个字段。我这次要说的是我是怎么把 Orca 这个内部网关上的 DeepSeek 缓存命中率从刚开始的 27% 一路优化到稳定 99% 以上的。整个过程没有用到什么奇技淫巧就是九轮排查、改配置、加监控、定规范中间踩了不少“看起来没问题实际上每次都导致缓存失效”的坑。如果你正在接 DeepSeek API或者你的团队也维护了一层自己的 LLM API 网关这套思路基本可以直接抄。先说明一下背景Orca 是我们内部维护的 LLM API 网关负责统一鉴权、限流、请求转发、用量打点和成本统计定位类似 OpenRouter 或者 One-API。平时所有业务线的大模型请求都会经过它再转发给上游厂商DeepSeek 是其中最主力的供应商之一。问题暴露得很直接接入后成本一直偏高而且响应首字延迟偶尔会飘翻监控才发现是缓存命中率长期在低位徘徊。1. 先别聊优化把缓存命中的账算清楚1.1 DeepSeek 的缓存机制自动命中、按前缀匹配DeepSeek 官方 API 的上下文缓存是服务端自动生效的不需要在请求里额外传开关也不需要你在本地维护缓存池。它的匹配规则非常朴素拿当前请求的输入 tokens 去和远端已缓存的内容做最长前缀匹配前缀完全相同的内容就算命中后续新内容则继续计为未命中。这意味着一个很残酷的现实只要你的请求开头部分稍微变了一个 token之前所有相同内容都白缓存了。就好比你给一本书加了一段新的序言整本书前面的页码全部偏移目录、索引全部作废——这就是前缀匹配的特点前面动一点后面全部失效。每次请求的usage字段里会返回两个关键计数{ usage: { prompt_tokens: 1300, prompt_cache_hit_tokens: 1180, prompt_cache_miss_tokens: 120 } }命中率就是prompt_cache_hit_tokens / prompt_tokens注意这里应该用prompt_tokens作为分母如果直接拿 hit 除以 hitmiss 也差不多但建议统一口径后面做监控才不会自相矛盾。1.2 最初的命中率读数27%账单比我预期贵了不少接入 Orca 之后我在 Grafana 上建了一块面板按小时聚合所有转发给 DeepSeek 的请求统计全量命中率。第一天数据出来整体命中率只有 27% 左右。更麻烦的是不同场景差异极大多轮对话稍微好一点能到 40% 上下RAG 问答类请求几乎全军覆没Agent 工具调用场景也是惨不忍睹。这里需要做一个简单的成本测算。DeepSeek 的缓存命中价格大约是未命中价格的十分之一假设平均每天输入 5000 万 tokens未命中按 1 元/百万、命中按 0.1 元/百万来估算一个月下来命中率每月输入成本估算20%约 1230 元99%约 164 元这还只是纯输入费用没算输出也没算由于缓存未命中导致的更高链路延迟。看到这个差距就值得投入九轮优化了。1.3 九轮优化后的结果一览为避免你后面看得没耐心先放一张整体数据表这是九轮优化过程中各阶段的命中率变化阶段干预内容整体命中率优化前无27%第 1 轮去掉 system prompt 尾部的 trace_id 和时间戳62%第 2 轮统一多轮消息重组时的序列化规则68%第 3 轮prompt 模板化动态字段后置85%第 4 轮tools 定义稳定化88%第 5 轮RAG 场景前缀分层92%第 6 轮历史截断策略调整95%第 7 轮会话重放与摘要注入格式固定96.5%第 8 轮请求参数归一化98%第 9 轮监控告警与回归防守99.2%这组数字不是看完即忘的表演每一轮都有明确的问题根因和对应的代码改动下面拆开讲。2. 第一轮和第二轮把 Orca 转发层“污染前缀”的锅先背走2.1 第一轮system prompt 尾部为什么会出现 trace_id 和当前时间第一轮优化的切入点非常简单但也最反直觉。当时我拿一个固定请求直连 DeepSeek 官方 API再通过 Orca 转发同样的请求两边的usage对不上。把两个请求的 body 打出来一 diff发现问题出在 Orca 的中间件逻辑上——它会在转发前往 system prompt 的末尾追加一行[request context] trace_idxxxx user_ip1.2.3.4 ts2026-01-05T18:30:0008:00这一行对业务本身没有任何实际作用因为模型根本不需要知道这些信息它只是 Orca 为了链路追踪和审计加进去的。但放在 system prompt 尾部等同于每次请求的前缀都在变。即使你其他所有内容都完全相同后端也会因为最后一个 token 不同而无法命中该位置的缓存更隐形的是这行内容位于请求最靠前的位置一旦它变了后面所有内容都不会再进入“已缓存前缀”的范围。修复方案是把这类链路元数据彻底移出 messages放到 HTTP header 或者请求体里的独立字段例如x-orca-request-id、x-orca-trace-id。如果需要模型感知请求上下文也应该放到 messages 最后而不是开头。改完之后整体命中率从 27% 提升到了 62%第一轮效果最大因为相当于拆掉了一个对全局所有请求生效的“前缀炸弹”。2.2 第二轮多轮消息重组的序列化规则逼疯缓存的关键细节第二轮的问题更隐蔽。Orca 对上游业务传过来的多轮会话会做一些归一化处理当检测到第一条 user 消息只是打招呼或者 system prompt 与后续用户消息存在明显重复时网关会自动合并。合并本身问题不大但合并逻辑里有个序列化顺序的坑——它使用字符串拼接时对换行和空格的处理不一致。比如业务 A 传进来的原始 system prompt 是你是一个助手。第一条 user 是你好。Orca 内部统一成system: 你是一个助手。\nuser: 你好再发给 DeepSeek。这个格式本身没有错但不同服务的代码里有的用\n有的用\n\n有的是中文冒号、有的是英文冒号。从业务角度看都是“同一个意思”但从缓存角度看它们完全是不同的 token 序列。DeepSeek 服务端缓存的是“已经 token 化后的输入”不是“JSON 字符串”。你无法要求它理解“哦这两种写法语义一样”。任何一丁点文本差异都会让前缀匹配在那一处中断。这一轮的改动是在 Orca 转发层定义一套公认的消息序列化规范所有请求在发给 DeepSeek 之前必须经过同一个 renderer 处理统一换行符、冒号、空格、特殊字符转义规则。同时把合并逻辑从“智能拼接”降级为“固定模板”不做语义层面的自由发挥。改完后命中率升到 68%。2.3 怎么发现这两个问题跟直连 API 做差分对比这两轮问题的定位套路值得单独提一下因为后续很多问题我都用了同一种方法。具体做法准备一个稳定的请求体比如固定 system prompt、固定一段用户输入直接构造 HTTP 请求发给 DeepSeek 官方 API记录usage。然后让同一个请求经过 Orca 转发再记录usage。两边比对如果直连的命中率高、经网关的命中率低那问题一定出在网关对请求的改写上。接着用 diff 对比两次请求的 JSON body。你会发现很多时候“看起来”差异极小无非是一个多出来的字段、一个不同的换行、一个被移动的位置。但缓存层面差异就是差异。这个方法的另一个好处是能快速判断问题是不是出在请求构造层而不是模型本身。我们当时还怀疑过是不是业务侧每次生成的 system prompt 都不同对比之后发现至少在这个例子里罪魁祸首正是网关。3. 第三轮到第五轮让公共前缀真正“公共”起来3.1 第三轮prompt 模板化动态字段一律后置现金流问题解决后命中率到了 68%再往上涨就要开始动业务侧的逻辑了。当时 Orca 下面挂了多个业务线每个业务线都有自己的一套 system prompt。有些业务的 system prompt 是写死在代码里的有些会带上用户昵称、当前日期、业务环境等动态信息而且位置不统一。这导致最典型的缓存浪费是同一个业务同一个用户连续两次请求因为 system prompt 里的时间戳不同前面几千个 token 完全无法命中。第三轮优化的核心是引入 prompt 模板能力在 Orca 的配置中心里定义每一类请求的标准结构。模板分三层[GLOBAL_SYSTEM] {全局固定规则例如角色设定、通用行为约束} [BIZ_RULES] {业务固定规则例如客服话术规范、代码生成风格} [OUTPUT_FORMAT] {输出格式要求例如 JSON Schema 约束} [DYNAMIC] {可变字段集中区user_id、current_time、request_id 等}固定部分在最前面动态部分全部挪到最后。这里要注意一个细节动态字段不能散落在 system prompt 中间必须集中放在一个区域里最好是放在所有固定指令之后。原因很简单前缀匹配是从头开始的前面的固定公共部分越稳定能命中的 token 数越多。至于动态信息会不会影响后续内容命中只需要知道一个原则越靠近请求头的内容越要稳定越可变的内容越要往后放。改完模板并让业务侧切到新的 prompt 结构后命中率从 68% 直接拉到 85%。这一步的收益非常可观因为它影响的是所有请求的“地基”。3.2 第四轮tools 定义稳定化连描述文案都要冻结再往下走我们开始盯 Agent 工具调用场景。这个场景的请求通常会携带一大段 tools 定义比如[ {type: function, function: {name: get_weather, description: 获取某个城市的天气信息}}, {type: function, function: {name: search_news, description: 根据关键词搜索最新新闻}} ]本来以为 tools 是从注册中心读的天然稳定结果排查下来发现三个坑第一个坑是工具列表的生成顺序不稳定。有些服务从数据库读工具定义默认按创建时间排这意味着新增工具后旧工具的相对顺序可能不变但整体列表已经不是同一个请求前缀了。第二个坑是描述文案里嵌入了动态信息。比如某团队为了让模型知道当前环境在工具的 description 里加了envstaging这看似无关紧要但每次请求若环境变量一样其实还行可如果环境变量变更频繁、或者 description 里带了当天日期整个 tools 块就全变了。第三个坑是 JSON 序列化格式不统一。有的语言默认对 dict 按键排序有的不排有的会把ensure_ascii设为 true导致中文变成\uXXXX。从模型服务端看这是完全不同的 tokens。第四轮的修复方案比较机械但非常有效工具列表从注册中心读取后统一按name做稳定排序tools 描述文案冻结不允许运行时拼接动态信息序列化统一走同一个函数sort_keysTrue、ensure_asciiFalse在 CI 里加一个检查把同一服务的工具列表序列化两次diff 必须为空。这轮改完工具调用类请求的命中率明显提升整体指标到 88%。3.3 第五轮RAG 场景的前缀分层设计第五轮针对的是 RAG 问答场景这也是最初命中率最低的一类请求因为检索到的上下文几乎每个 query 都不一样天然不满足前缀命中条件。但“天然不满足”不代表不能优化。RAG 请求的 messages 结构通常是这样system: 你是问答助手基于以下知识回答。如果知识库中没有相关内容请明确说明。 user: 用户问题如果直接把检索结果拼在 system 里比如system: 你是问答助手基于以下知识回答。知识【文档A片段】【文档B片段】... user: 用户问题那么即使 system 开头部分稳定知识片段一变化后面能命中的部分也有限。而且更糟糕的是有些实现会把检索结果的排序搞成按相关度动态排列导致同一篇文档在不同请求里出现在不同位置所有内容都变成未命中。第五轮的策略是把 RAG 请求的 prompt 也分层[固定层] 角色设定、回答规则、引用格式要求 [知识层] 检索到的知识片段按 score 降序score 相同时按 doc_id 排序 [问题层] 用户的具体问题这样的好处是即使知识层每次都会变但固定层永远存在且稳定这部分 tokens 可以稳定命中知识层的可变部分即使 miss也只是 miss 那一小段。最终 RAG 场景的命中率虽然不可能做到百分百但固定层占了总输入一半以上时整体指标也能被拉动。第五轮改完后整个 Orca 的全局命中率到了 92%。到这里已经能明显感觉到剩下的优化都是“抠细节”。4. 第六轮和第七轮多轮对话与重放最容易翻车也最出效果4.1 第六轮历史截断尽量别动前缀顺序多轮对话是另一类大头。原理上多轮对话的请求按时间顺序追加消息天然具备“前缀稳定、尾部增长”的特征缓存命中率应该很高。但实际中很多网关喜欢压缩历史特别是对话超过窗口长度后会把最旧的消息截掉只保留最近 N 轮。问题就出在这里。假设一个会话第 8 轮请求的 messages 是system, user1, assistant1, user2, assistant2, ... user8第 9 轮如果网关决定只保留最近 6 轮截断后的 messages 可能是system, user3, assistant3, user4, assistant4, ... user9对比上一轮的完整请求前面虽然都有 system但紧随其后的内容从 user1 变成了 user3。虽然 system 本身还能命中但原来从 user1 开始的一大段缓存前缀被丢弃了。更糟的是有些网关会在最前面插入一条“对话摘要消息”来替代被丢弃的历史而摘要内容是模型生成的每次生成都会有小差异导致前缀从摘要开始就分叉了。第六轮的改动是截断策略从“保留最近 N 条消息”改为“保留最早一条 user 消息 最近 N 条消息”并且保证截断只发生在尾部或次尾部不破坏请求头的顺序。如果确实需要做超长对话摘要摘要消息不要放在 system 之后、旧历史之前而是放到所有历史消息的后面、最新用户消息的前面。这样即使摘要在变也不会污染最宝贵的前缀区域。这轮改完多轮会话类请求的命中率提升非常明显全局指标到 95%。4.2 第七轮会话重放与摘要注入的固定格式第七轮是我自己也没想到会踩的坑。某业务上线了“重新回答”按钮用户点了之后前端会把同样的对话重发一遍。从直觉上看这应该是最容易命中的场景因为请求完全一样。但监控显示这类请求的命中率并不理想有时候甚至是 0。查到最后问题出在重放链路上。Orca 在收到重放请求时会生成一个全新的request_id而且开发者在接业务时把request_id也拼进了 user 消息的开头。于是同一个对话内容的两次请求第一次的 user 开头是request_ida1...第二次是request_idb2...整个前缀直接错位。这类问题的教训是任何每次请求都会变的值都不能出现在 messages 里尤其是不能出现在消息开头。重放场景的修复很简单——把request_id从 user 消息里挪走放到 Orca 的 HTTP header 上。同时我们给所有业务定了一条新规消息体的第一个 role 必须是 systemsystem 之后的前几条消息应当尽量是历史中的固定内容凡是需要变更的上下文都往后放。第七轮之后全局命中率来到 96.5%。5. 第八轮和第九轮参数序列化、监控与回归防守5.1 第八轮让“看起来一样”的请求真正以同一面貌到达服务端第八轮的主题是参数序列化归一化。在接入 Orca 之前各个业务团队直接调用 DeepSeek 或通过不同 SDK 调用久而久之代码里的写法五花八门。比如有的会传temperature有的不传有的传max_tokens有的传max_completion_tokens有的会在 body 里多塞一个user字段有的传null有的直接不传该字段。虽然 DeepSeek 服务端缓存 key 主要是 prompt tokens 前缀理论上这些采样参数不会影响缓存但问题在于 Orca 在统一请求 body 时如果把多个字段做“归一化重排”或者对空值字段做了不同的处理最终发出去的 messages 数组在 JSON 层面的组织方式可能就不一致进而导致 token 序列不同。第八轮做的事情是在 Orca 转发层加一个请求体规范化函数对所有字段做收敛。messages 数组只保留 role 和 content临时字段全部移到顶层可空字段统一丢弃而不是传 nullJSON 序列化固定sort_keysTrue和ensure_asciiFalse。同时建立了一个字段白名单不在名单里的字段一律不转发从根上杜绝“自定义字段被塞进 messages”的可能。这轮的收益不像前面那么爆发但它把 96.5% 推到 98% 以上更重要的是保证了后续新增业务的请求也会遵守同一套规则而不是每个新服务都把命中率拉低一点。5.2 第九轮用监控和回归测试把 99% 锁死第九轮其实不是一次“修复”而是一整套防守机制。因为前面几轮改完短时间内的命中率确实高但过了一周、有人改了个 prompt 模板、有人新增了工具描述、有人又把 trace_id 拼回去了命中率就会悄悄掉下去。没有防护网的话前八轮做的事会不断被回滚。先做监控。Orca 的日志里已经有每个请求的prompt_cache_hit_tokens和prompt_cache_miss_tokens我用 Prometheus 做聚合指标大概是rate(deepseek_cache_hit_tokens_total[5m]) / (rate(deepseek_cache_hit_tokens_total[5m]) rate(deepseek_cache_miss_tokens_total[5m]))按业务线、按请求类型、按 prompt 模板版本三个维度拆开看任何一个维度出现命中率下滑都能第一时间发现。告警阈值设置为整体命中率低于 97%、重点业务低于 95% 时触发因为 99% 是目标但不是所有场景都能到。再做回归测试。我们在 CI 里加了一个“前缀稳定性测试”核心逻辑是对于同一个业务场景构造两个类似的请求一个不带动态内容一个带有动态内容然后把它们经过 Orca 序列化后的消息逐 token 对比找出第一个不同的位置。第一个差异点越靠后说明前缀越稳定。def first_diff_pos(a: list[str], b: list[str]) - int: for i, (ta, tb) in enumerate(zip(a, b)): if ta ! tb: return i return min(len(a), len(b))比如我希望 system 部分 100% 稳定那么这个检测函数返回的位置至少要在固定层结束之后如果有人在 system prompt 里塞了时间戳测试马上就会红。第九轮之后整体命中率稳定在 99.2%并且在这个区间维持住了。多轮对话场景能到 99.5% 以上RAG 场景稍低但也在 98% 左右。成本的下降非常直观同样的业务量输入费用比优化前少了一个量级顺带首字延迟也稳下来了。回头看我个人的体会这九轮里面收益最大的其实是第一轮和第三轮都是把“动态内容从请求前缀里请出去”的典型动作后续几轮更像是把容易回潮的地方一个接一个地堵住。最让我意外的反而是第二轮的序列化问题因为它完美诠释了“人眼看起来一样”和“token 序列一样”之间的巨大鸿沟。做缓存优化必须时刻记住一个原则你是让你的请求以完全相同的 token 面貌出现在服务端的 token 前缀里而不是让人类看起来“差不多”。最后再分享一个小技巧任何针对缓存命中率的改动上线前都先用直连 API 的小脚本验证一遍别直接在网关里霸王硬上弓否则你根本分不清是哪个中间环节又把前缀弄脏了。
返回列表