news 2026/9/29 17:24:14

大模型上下文组装顺序:缓存命中率从个位数到六成的实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大模型上下文组装顺序:缓存命中率从个位数到六成的实践

先交代背景:CaptainWho是我在维护的一个大模型问答机器人,核心场景是把公司内部的知识库变成能对话的助手。项目上线三个月后,我最头疼的不是模型回答得准不准,而是每次提问响应都慢半拍、账单一路往上走。排查到最后,问题出在一个很多人日常不太关心的指标上:缓存命中。更直白地说,是我上下文的组装方式从一开始就不对。这篇文章我不讲提示词怎么写,也不讲怎么调模型参数,只讲一件事——你单纯为了提高缓存命中,应该怎么把上下文一层一层从前往后排好。

1. 缓存命中到底命中的是什么?先把底层逻辑掰开

1.1 为什么前缀几乎等于一切

LLM服务商在做推理时,会给输入tokens建立KV缓存。这个缓存的价值是:如果下一次请求的输入开头和某一次完全一致,从开头到那个位置之间的计算可以直接复用,不需要重新跑一遍网络。它匹配的单位不是“意思相近”,而是字节级完全一致。只要第一个token之后某个位置出现差异,后面的所有token缓存全部作废。

这个概念不复杂,但很多人在组装上下文时完全没当回事。我早期写CaptainWho时,习惯把用户当前问题拼在最前面,后面再跟上一大段历史记录。结果用户每次问的问题都不一样,相当于每次请求的前缀都在变,缓存命中率基本为零。后来我换了个顺序:系统说明、长期约束、知识库配置放在最前面,会话历史排在中间,最后才放用户当前输入。这改完以后,缓存命中立刻从个位数涨到了六成以上。

有个常见的误解是:只要system prompt没变,缓存就一定能命中。实际上缓存的是整个消息数组从第一个消息开始计算出来的token序列。如果你在system prompt后面、历史前面塞了一条实时变化的用户状态,那从这条状态开始,后面全部失效。所以第一件事,请记住:前缀等于一切,顺序决定缓存能不能生效。

1.2 命中一次能省多少?

拿CaptainWho的典型请求举例:一条请求输入大概5000个token,包含系统提示、用户画像、最近20轮对话和当前问题。如果缓存未命中,5000个token基本都要重新计算;如果命中,只有最后新增的几百个token需要算。

我按市面上常见的大模型输入价格粗略算一笔账。假设每百万输入token价格约为3美元,未命中时单次输入成本大约是0.015美元;命中的话,新计算的部分只有300 token,加上命中的token往往还有折扣,单次成本能降到0.001美元上下。一天按10万次请求算,成本差距是几百美元。响应速度差别更直观:未命中时首字可能要等2~4秒,命中后能压到500毫秒以内。对于交互型产品,这个体感差异是致命的。

状态输入计算量单次成本估算首字延迟
未命中5000 token全量计算0.015美元以上2~4秒
精确命中只计算新增token0.001美元上下0.2~0.5秒

所以缓存命中不是锦上添花,它是这类项目降本提速的核心杠杆。下面这套组装思路,就是围绕“让前缀尽可能稳定”来设计的。

2. CaptainWho的上下文组装思路:五层结构,稳定靠前

2.1 我把上下文拆成了五个“抽屉”

在CaptainWho里,我把一次请求的上下文拆成五层,每层按变化频率从低到高排列:

层级内容示例变化频率
L0系统提示词:角色定义、通用回复规则、输出格式要求几乎不变
L1项目级配置:知识库版本、可用工具开关、禁用词表按周/月变化
L2用户画像和长期事实:姓名、部门、权限范围、偏好按天/周变化,偶尔触发更新
L3会话历史:过去多轮消息或按窗口裁剪的近期对话每轮追加,但不回改
L4当前输入:本轮用户问题、实时状态、临时备注每次请求必变

这个分法不是拍脑袋。缓存是按顺序匹配的,所以稳定度越高的内容越要靠前。L0和L1是天然的前缀,L2属于低频变化,可以放在用户画像段,L3是天然可追加的日志型内容,L4必须放到最后。

这里要特别说下L3的定位。很多团队会把历史做成“每轮重新生成摘要”,但摘要一旦变化,前面的缓存就全废了。CaptainWho的做法是:只要会话没超窗口,就保留原始历史消息,每轮只追加新消息,不回头修改。只有当历史超过窗口上限时,才把较早的部分压缩成摘要,并且这个摘要要作为新的一段放到L2后面、L3前面。这样做的代价是摘要变化会导致缓存分段,但至少原来L0到L2的稳定前缀可以保住。

2.2 组装顺序的“一二三”原则

如果你不想记太复杂的分层,记住这个“一二三”原则:

  1. 从稳定到易变排序。
  2. 动态内容尽量后置。
  3. 每层内部保持固定模板。

拿一个反面案例说明。有人写CaptainWho的消息数组,第一句就是:

messages = [ {"role": "user", "content": f"用户{user_name}想咨询:{query}"}, {"role": "system", "content": system_prompt}, ... ]

这种写法等于亲手杀掉缓存。每个用户的user_name不同,每轮query不同,前缀从第一秒开始就不稳定。

正确做法是把固定内容放前面,动态内容放后面:

messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": PROJECT_CONFIG}, {"role": "system", "content": USER_PROFILE}, {"role": "user", "content": history[0]}, {"role": "assistant", "content": history[1]}, # ... 历史按原顺序展开 {"role": "user", "content": query}, ]

query虽然每次不一样,但它放在消息数组最后,完全不影响前面序列的缓存复用。如果当前输入里还要带实时信息,比如当前时间、页面来源、临时权限标记,也统一放在L4,别往前面塞。

2.3 别把动态内容硬嵌在前缀里

CaptainWho早期踩过一个典型坑:为了告诉模型“今天是几号”,我在system prompt里写了一句“今天是2025年4月10日”。结果每天早上都要失效一次,因为日期变了。这个变更发生在最前面,后面的所有内容都跟着白算。

后来我把所有这类高波动信息从固定模板里摘了出来,改为在L4动态段追加。比如:

messages.append({"role": "system", "content": f"当前时间:{current_time};临时说明:{temp_note}"})

如果某条系统消息里确实需要用户名、权限这些字段,也不要直接硬拼进固定模板。更好的做法是把它们模板化,但每次变动时接受一次缓存重建,而不是让它们高频变化。权限这类字段,如果变化频繁,干脆放动态段;只有真正稳定、不变的知识类信息,才适合放在L0和L1里。

3. 实操:用代码实现“能命中”的上下文组装

3.1 一个最小可用的组装函数

我做了个简化版的上下文组装类,你可以直接套用。它没有用到第三方缓存组件,核心是保证固定前缀只构造一次,历史不重排,动态内容最后追加。

class ContextBuilder: def __init__(self, system_prompt, project_config): # 固定前缀,只构造一次,不要每次请求重复拼接 self.fixed_prefix = [ {"role": "system", "content": system_prompt}, {"role": "system", "content": project_config}, ] self.user_profile_map = {} self.user_version_map = {} def _get_profile(self, user): uid = user["id"] current_version = str(user.get("version", 1)) # 只有版本号变化时才重新生成用户画像 # 版本号不变,直接复用上次构造结果 if self.user_version_map.get(uid) != current_version: self.user_version_map[uid] = current_version self.user_profile_map[uid] = [ { "role": "system", "content": ( f"用户基础信息:姓名={user['name']}," f"部门={user['dept']}," f"权限={user['permissions']}" ), } ] return self.user_profile_map[uid] def build(self, user, history, current_input): messages = list(self.fixed_prefix) messages.extend(self._get_profile(user)) messages.extend(history) messages.append({"role": "user", "content": current_input}) return messages

这段代码里最关键的是_get_profile里的版本号逻辑。用户权限、部门这些信息不会每一轮都变,一旦变了,你只需要把version递增,系统会重新构造用户画像,接受一次缓存失效,之后继续命中。千万别让这个画像块的内容每次实时去拉数据库拼接,否则每轮前缀都是新的。

3.2 在请求里观察命中率

组装完之后,怎么确认缓存有没有生效?最简单的方法是看API返回的usage字段。以OpenAI SDK为例:

resp = client.chat.completions.create( model="gpt-4o", messages=context, temperature=0.3, ) usage = resp.usage cached = usage.prompt_tokens_details.cached_tokens prompt_tokens = usage.prompt_tokens print(f"cached_tokens={cached}") print(f"prompt_tokens={prompt_tokens}")

如果cached_tokens一直是0,说明你的消息数组前缀没有稳定住。建议顺手打印出消息数组前5条消息的哈希,用来对比不同请求之间前缀是否一致。我习惯把这些字段打进日志,每天汇总一次命中率,公式很粗暴:

缓存命中率 = 当天所有请求cached_tokens之和 / 当天所有请求prompt_tokens之和

CaptainWho上线后,我靠这个指标发现了第一轮bug:系统提示词里有个随机生成的trace_id,每次请求都在变。去掉以后,命中率从7%跳到55%。没有这个字段,你根本不知道问题在哪。

3.3 低频信息更新时的“舍”与“得”

用户画像如果必须更新,比如用户改了部门,你有两条路:

第一条路:直接更新用户画像的内容。优点是后续请求语义准确,缺点是更新后的第一个请求缓存必然失效,后面的历史缓存也全没了。如果这种更新一天发生几百次,整体命中率肯定受影响。

第二条路:把改动的小字段拆出来,放到动态段。比如部门变了,但姓名、权限没变,你可以让固定用户画像里保留“姓名=张三;权限=普通用户”,然后在本轮消息的L4部分追加一条“用户部门已更新为市场部,后续回答以市场部视角为准”。这样固定前缀没变,缓存继续生效。

我的建议是:能后置的实时变化尽量后置,不能后置的才考虑重建前缀。分清哪些是真正稳定的长期事实,哪些只是临时状态。长期事实进L2,临时状态进L4。这个舍与得的逻辑,直接决定了缓存命中率是稳定在60%还是20%。

4. 踩坑实录:四个容易让缓存“破功”的问题

4.1 历史消息只能追加,不能修改

CaptainWho有段时间做知识库更新,系统会修正之前assistant回复里的过时内容。最开始我直接改历史消息里对应assistant那条的content,结果用户下一次请求时,前缀在改动位置发生了差异,后面所有缓存全部失效。

正确的处理方式是“追加式修正”,而不是“原地修改”。例如在同一会话里追加一条system消息:

messages.append({ "role": "system", "content": "注意:之前关于X产品的答案基于旧版本,请以最新知识库说明为准。" })

这样历史的主体没变,只是往后面追加了新指令。固定前缀不受影响,后面的修正内容也会被模型读到。缓存命中和信息修正都能保住。

4.2 缓存有效期和上下文窗口不是一回事

很多服务商对缓存有有效期限制,比如“5分钟没被使用就失效”。这意味着用户隔一小时再回来,第一次请求大概率还是未命中,这不是你的组装代码有问题,而是服务端机制决定的。应对办法是接受这个事实,不需要为了它做特殊优化,只需要把日志统计按“活跃session”去看,别让长期闲置用户把整体命中率拉低。

最近圈里常聊的“1M上下文”,是指模型可以支持100万token左右的上下文窗口。容量变大以后,你可以给模型塞更多历史,但也带来一个隐患:前缀越长、中间任何一个小改动导致失效的代价就越大。所以长上下文场景里,分层结构更重要。不要把实时数据、临时状态顺手塞在窗口前面,看起来省事,实际上等于把一颗定时炸弹埋在缓存中间。

4.3 多个业务场景共用一个前缀的坑

CaptainWho同时做了知识库问答和周报生成两个模式。最开始两个模式用的是完全不同的system prompt,一个是“你是企业内部知识库助手”,另一个是“你是周报写作助手”。看起来互不干扰,但实际上两套对话没有任何共享前缀,缓存收益非常有限。

后来我采取了“统一开头+场景扩展”的方式。两个模式的system prompt都改成同一个开头:

你是CaptainWho,一个企业内部助理。请遵循以下通用规则: ...

然后把场景专属规则作为下一条system消息。这样至少前面的通用段落可以跨场景命中,虽然比例不高,但总比一点都没有强。更彻底的做法是给不同场景建立独立的session,不要混用一个很长的上下文流。

4.4 没有数据指标的优化都是耍流氓

做缓存优化最怕的就是“我觉得应该命中了吧”,结果实际命中率惨不忍睹。一定把usage里的缓存字段统计成日常指标。我建议每天定时任务或者从日志里聚合出这几个数字:

  • 总请求数
  • 总prompt token数
  • 总cached token数
  • 按用户/场景拆分的命中率

如果发现某个用户会话命中率特别低,大概率是用户画像那部分内容每次都在变。如果某个场景命中率特别低,大概率是场景切换导致前缀不统一。把这些数据收集起来之后,很多问题一眼就能定位。

5. 一点实测后的体会

5.1 别把目标设成“100%命中”

我一开始总想着让所有请求都命中,后来发现做不到,也没必要。新用户第一次请求、会话超时后的首次请求、版本更新后的首个请求,这些天然缓存失效是无法消除的。把目标定在日命中率稳定在60%~70%,对CaptainWho这种交互型产品来说已经很健康了。如果你强行追求90%以上,反而会为了稳定前缀牺牲不少灵活性和产品体验。

5.2 一个我一直沿用的检查习惯

最后分享一个小习惯:每次联调时,我除了看返回延迟,还会打印两个数字,一个是cached_tokens,另一个是“本次消息数组前5条内容的哈希”。一旦发现命中率突然下跌,先对比哈希是不是变了。这个方法救过我好几次上线,尤其是当某个同事偷偷往系统提示里加了动态内容时,它能第一时间暴露问题。

组装上下文这件事,说透了就是一个顺序问题。把稳定的东西放在最前面,把容易变的东西放在最后面,然后想办法让中间层也尽量只追加、不修改。只要能做到这几点,缓存命中自然就上来了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 17:24:13

Nginx UI 可视化配置与运维实战:从图形化管理到证书自动化

1. Nginx UI 项目概述与核心设计理念1.1 项目定位与诞生背景Nginx 的安装和基础配置并不难,真正让人头疼的是后面那些繁琐的维护操作:改一个反向代理要去翻 conf 文件,加一个静态站点要计算 location 正则,申请 SSL 证书要在服务器…

作者头像 李华
网站建设 2026/9/29 17:23:30

考虑源荷两侧不确定性的含风电电力系统低碳调度Matlab实现

做电力系统调度优化这几年,类似的题目几乎每个学期都会在我这边出现一次:“考虑源荷两侧不确定性的含风电电力系统低碳调度(Matlab代码实现)”。如果你也正在做这类研究,大概率是卡在这几个地方:不确定性怎…

作者头像 李华
网站建设 2026/9/29 17:23:01

55873生态重构:多模型智能体平台的架构设计与实践

前阵子接手 55873 生态这套体系时,我第一感觉不是兴奋,而是头疼。零零散散十几个模型,各家的接口风格不一样,调用链路上还有一堆 if-else 在判断“什么时候该调谁”,加上业务方时不时过来说“这个需求用大模型能不能做…

作者头像 李华
网站建设 2026/9/29 17:22:58

iOS PDF电子签章实战:坐标换算、色彩管理与防篡改锁定

简介:这是一套面向iOS开发者的原生PDF电子签章轻量库,适用于需要在移动端快速展示PDF并完成电子签名、盖章的金融、法务、政务及合同管理类App。资源以zip压缩包提供,共7个文件,包含4个.a静态库、2个.h头文件和1个.mm实现文件&…

作者头像 李华
网站建设 2026/9/29 17:22:33

基于机器视觉的试卷分数智能识别系统设计与OCR实践

简介:这份PDF文档面向教育技术研究者、机器视觉方向的学生与教师,以及关注考试评分自动化的系统开发者,系统讲解了一套基于机器视觉的试卷分数智能识别系统设计方案。内容围绕图像获取、预处理、分数轮廓边缘提取、文字OCR识别与分数统计分析…

作者头像 李华
网站建设 2026/9/29 17:22:29

多模型统一调度平台:从成本失控到预算可控的工程实践

1. 从一张失控的账单说起:多模型接入为什么总在烧钱 去年下半年,我帮一家做智能客服的团队做技术复盘。他们同时接了四家模型服务商:一家做通用对话,一家做长文本摘要,一家做代码生成,还有一家专门跑多模态…

作者头像 李华