先交代背景: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秒 |
| 精确命中 | 只计算新增token | 0.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 组装顺序的“一二三”原则
如果你不想记太复杂的分层,记住这个“一二三”原则:
- 从稳定到易变排序。
- 动态内容尽量后置。
- 每层内部保持固定模板。
拿一个反面案例说明。有人写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条内容的哈希”。一旦发现命中率突然下跌,先对比哈希是不是变了。这个方法救过我好几次上线,尤其是当某个同事偷偷往系统提示里加了动态内容时,它能第一时间暴露问题。
组装上下文这件事,说透了就是一个顺序问题。把稳定的东西放在最前面,把容易变的东西放在最后面,然后想办法让中间层也尽量只追加、不修改。只要能做到这几点,缓存命中自然就上来了。