资讯动态

提示组装与提示缓存:LLM请求时延优化的关键路径

发布时间:2026/10/20 23:37:06 来源:尧图企业网站定制
做LLM服务的人应该都有同感一次请求进来真正让模型跑起来之前的那些准备动作反而是最容易出问题的地方。尤其是提示组装系统提示、历史消息、工具定义、用户最新的问题全都要在短短几十毫秒里拼成一个结构正确的上下文拼完之后还得考虑提示缓存否则每个请求都重新渲染一遍同样的前缀耗时和成本都扛不住。Hermes源码里这块我反复读了几遍这周干脆把“消息是怎么拼出来的”这条链路完整走读了一遍顺便把提示缓存的设计思路也聊透。这篇文章不聊模型推理只聊从请求进入组装器到最终拿到完整上下文的这段路适合正在写Chat服务、做推理网关或者想优化请求时延的开发者参考。我先把结论放在前面提示组装和提示缓存不是两个独立模块而是一条流水线里的两个环节。缓存不是“组装完之后再缓存”而是“先查缓存再决定要不要组装”。理解了这句话后面看代码会顺很多。1. 先搞清楚“拼消息”到底在解决什么问题1.1 一次请求进入后消息要过几道关很多人以为拼消息就是简单地把字符串拼在一起实际完全不是这样。Hermes里一次聊天请求进入后至少要经过这么几关协议解析、消息归一化、历史合并、模板渲染、长度截断、token编码。每一关都可能改变最终结果而且这些改变往往是叠加的。先说消息归一化。外部进来的消息格式很杂OpenAI系、Anthropic系、内部协议字段叫法都不一样。有的是messages有的是conversation消息里的角色可能是user、assistant、system也可能带tool_calls、tool_call_id、name这些附加字段。Hermes在入口处会把这些全部转成统一的内部结构后续所有逻辑只认这一种结构。这一步看着简单但漏掉任何一个字段后面渲染出来的模板就是错的。然后是历史合并。多轮对话时前端通常会传全量历史但并不是每条历史都应该原样进上下文。有些消息可能被截断过有些已经被工具结果替代。Hermes在这一步做的是把消息列表“整理干净”去掉不该出现的内容同时保证角色顺序是合法的。实际生产里经常出现两个user消息连续、中间没接assistant回复的情况这种列表直接塞进模板很多模型的对话模板会输出奇怪的结果所以必须在组装前做一轮角色清洗。1.2 为什么不能直接拿模板硬套再来说模板渲染。现在的开源模型普遍使用聊天模板也就是一类带分支逻辑的模板引擎像[INST]、|im_start|、s这些都是模板里的特殊标记。不同模型的模板差异很大同一个模型不同版本之间也可能不兼容。所以不能用简单的 f-string 去拼必须用模板引擎解析。模板里通常还有条件分支有工具定义的时候渲染一部分没工具定义的时候渲染另一部分最后一条消息是assistant时可能不需要结束标记是user时又得补上。这些逻辑全部揉在模板字符串里只有真正按模板渲染一遍才能得到正确结果。我见过不少团队为了省事直接用f{system_prompt}\n{user_message}这种粗暴方案短期内没问题一旦模型换成需要特殊标记的对话模板立刻崩。更隐蔽的是用户消息里如果带了模板的关键字比如|im_end|不经过转义处理模型上下文里会出现伪造的结束标记直接影响生成质量。这些坑都要在组装模块里统一处理。2. 提示组装模块的核心设计2.1 核心数据结构一切都围绕消息列表展开Hermes的组装模块核心数据结构并不复杂但字段划分得很讲究。消息列表里的每条消息是一个ChatMessage包含role、content、tool_calls、tool_call_id、name、sequence这些关键字段。sequence是这条消息在原始会话中的序号后续做截断时非常有用没有它你只能按数组下标猜。组装上下文AssemblyContext则负责保存整个组装过程中的临时状态比如当前预算剩多少、已经丢了几条消息、有没有命中缓存、模板版本号是多少。AssemblyResult是最终产物包含渲染好的字符串、对应的 token ids、命中的缓存键、实际使用的 token 数、是否发生了截断等。这些结果不只是给模型用的也是给监控和排障用的。为什么这么设计我自己的体会是如果组装结果里只有一段字符串后续想排查问题基本靠猜。加上了缓存键、截断标记、token分布线上出了问题至少能定位是哪个环节坏了。字段类型作用sequenceint消息原始序号截断时按它排序rolestringsystem/user/assistant/toolcontentstring渲染后的文本内容tool_callslist辅助消息里的工具调用信息tool_call_idstring工具结果消息关联的ID2.2 全量组装与增量组装两条路径缺一不可Hermes把组装分成两条路径全量组装和增量组装。全量组装很好理解就是没有任何可复用内容时从头渲染所有消息。增量组装则是多轮对话里的核心优化。增量组装怎么理解假设当前会话有10条历史消息用户又发了第11条。如果每次请求都把10条历史全部重新渲染一遍不仅浪费CPU还会让缓存的收益打折扣。正确的做法是前10条历史已经渲染过了只要它们没有变化直接复用上一次的渲染结果只渲染第11条新消息然后把两段拼起来。这里的难点是判断“前10条到底有没有变化”。角色变了、内容变了、工具结果变了都算变化。Hermes的做法是给每条消息维护一个内容指纹增量组装前先比对这些指纹只要某条消息之后的指纹链没变前缀就可以安全复用。2.3 截断预算怎么算不能简单地从前往后丢截断是整个组装模块里最容易被低估的环节。很多人以为截断就是从最旧的消息开始丢丢到长度够为止。实际要考虑的事情多得多系统提示不能丢、最后一条用户消息不能丢、工具调用的上下文尽量整套保留、模型输出还要预留空间。Hermes里有一个TokenBudget对象来专门算这笔账。模型上下文窗口是固定的比如8192但我们不能把这8192全给输入因为模型还要留空间生成输出。假设max_tokens1024那输入预算就是8192减1024等于7168。然后按优先级分配系统提示占用800工具定义占用1200最新用户消息占用500剩下4668留给历史消息。如果历史消息总长到了7000就得丢掉2332。丢的时候从最旧的消息开始但要注意尽量按整条消息丢而不是从某条消息中间截一半。因为中间截断会破坏对话结构导致模板渲染出非法序列。注意截断阈值计算的时机很关键。token数必须先估算再渲染渲染后再重新精确计算。Hermes的实践是先用一个快速估算函数粗算一遍如果超了就直接进入截断流程省得渲染完才发现超长再重来一遍。3. 提示缓存让相同前缀不必重新组装3.1 为什么缓存必须放在组装模块里做如果缓存放在下游比如在模型服务层做KV Cache那缓存的粒度是整个上下文的KV状态对于前缀复用的收益确实更大但成本和复杂度也更高。Hermes在消息组装层做的提示缓存目标不是替代KV Cache而是省掉“重复渲染和重复编码”的开销。提示缓存放在组装模块里的理由很简单只有组装器最清楚消息的边界。我们知道哪些是系统提示、哪些是历史消息、哪些是刚进来的新消息知道模板版本是否变化知道截断策略会不会影响前缀。这些信息下游统统不知道所以缓存逻辑必须长在组装模块内部。实际收益也很明显。很多场景下同一批用户的会话历史在短时间内的重复请求里高度相似系统提示、工具定义、甚至前几轮历史消息都是完全相同的。提示缓存命中后组装器只需要渲染最后一条新消息整体时延能降一个数量级token编码的开销也省了不少。3.2 缓存键怎么设计才稳定缓存键设计是提示缓存最核心的决策也是最容易翻车的地方。我见过太多团队因为缓存键里多带了一个字段导致命中率长期在低位徘徊。Hermes的缓存键有几个原则第一键里只放真正影响渲染结果的稳定输入第二必须带模板版本号第三绝对不包含时间戳、请求ID这类跟渲染无关的元数据。一个典型的缓存键大概是这样一个哈希值cache_key sha256(version \x00 system_prompt \x00 tool_schema \x00 rendered_prefix_boundary)这里version是模板版本号只要模板文件一改所有老缓存自动失效system_prompt和tool_schema是字符串直接参与哈希rendered_prefix_boundary是历史前缀的边界标记用来表示“这一段前缀渲染到了第几条消息”。这里有一个细节分隔符我用的是\x00不是普通逗号。因为system_prompt或tool_schema里完全可能包含逗号如果用逗号拼接两个内容可以拼出相同的中间结果造成哈希碰撞。用空字符虽然看着别扭但能有效避免这个问题。3.3 缓存里存什么存字符串还是 token ids很多人想当然觉得缓存里存渲染好的字符串就行。但Hermes的实践是存结构化的“片段列表”每个片段包含这段文本、对应的token ids、角色、消息序号。这么做有几个好处。第一增量组装时不需要重新编码。如果缓存里只存字符串命中缓存后还要把整段字符串重新tokenize一遍省掉的只是模板渲染时间编码开销一点没省。存了token ids拼接时直接把token ids接起来就行。第二截断时可以直接从片段层面丢弃。假设缓存里存了20条消息对应的20个片段截断策略决定保留后15条我们只需要从缓存列表里取出后15个片段完全不需要重新渲染。这个能力让截断路径也变得非常快。第三可观测性好。片段列表可以在监控系统里展示每段消息的token分布排查问题的时候能清楚看到是哪段消息超长导致截断。字符串只有一个大块什么都看不出来。3.4 缓存的生命周期和失效策略提示缓存的失效策略是一个容易被忽略的点。Hermes的做法是本地进程内LRU加分布式缓存两层结构。本地LRU命中速度最快适合高频同前缀请求分布式缓存适合多实例共享尤其是有负载均衡时A机器算好的缓存B机器也能直接用。失效条件有三类模板版本变化、系统提示变化、对应会话的消息变化。模板版本变化通过缓存键里的version天然处理系统提示变化也是通过缓存键处理消息变化则通过消息指纹链处理。这里要特别注意缓存不能没有上限。提示缓存的key如果是按会话维度算的每条缓存都可能很大毕竟存了整个历史前缀的token ids。所以内存限制必须严格LRU淘汰策略要按“总字节数”而不是“条目数”来算否则一条超大缓存就可能把整个缓存池占满。4. 代码走读从入口到缓存命中的完整链路4.1 入口与三步主流程下面进入代码走读。我把Hermes里组装提示的核心逻辑简化成伪代码保留主干方便看清整个链路。async def handle_chat_request(raw_request): conversation normalize_messages(raw_request.messages) system_prompt load_system_prompt(raw_request) tool_schema load_tool_schema(raw_request.tools) result prompt_builder.build( conversationconversation, system_promptsystem_prompt, tool_schematool_schema, max_input_tokenscalculate_input_budget(raw_request), ) return resultbuild方法内部是典型的三段式查缓存、增量渲染、截断收尾。我先说整体流程再逐个展开。def build(self, conversation, system_prompt, tool_schema, max_input_tokens): cache_key self._compute_cache_key(system_prompt, tool_schema, conversation) cached self.prompt_cache.lookup(cache_key) if cached: final_segments self._handle_cache_hit(cached, conversation) return AssemblyResult.ok(final_segments, cache_key, hitTrue) rendered_segments self._render_segments(conversation, system_prompt, tool_schema) final_segments self._truncate_if_needed(rendered_segments, max_input_tokens) self.prompt_cache.store(cache_key, final_segments) return AssemblyResult.ok(final_segments, cache_key, hitFalse)逻辑很清楚先查缓存命中就只处理新消息没命中就全量渲染截断写入缓存。注意在缓存命中的分支里_handle_cache_hit仍然要把最后一条新消息渲染出来因为缓存只覆盖了历史前缀新消息不在里面。4.2 缓存键计算为什么要在渲染前算_compute_cache_key必须在渲染前执行。这样才能做到“先查缓存再决定是否渲染”。计算过程里最重要的一步是找到“渲染前缀的边界”。def _compute_cache_key(self, system_prompt, tool_schema, conversation): # 找到最后一条已稳定渲染过的消息序号 stable_boundary find_last_stable_message(conversation) stable_prefix conversation[:stable_boundary] content_parts [ self.template_version, system_prompt, json.dumps(tool_schema, sort_keysTrue), serialize_messages(stable_prefix), ] raw_key \x00.join(content_parts) return hashlib.sha256(raw_key.encode(utf-8)).hexdigest()这里的find_last_stable_message是核心逻辑。它从消息列表尾部向前找跳过最后一条或多条可能与本次请求强相关的消息。比如用户刚发的这条消息以及紧接着要执行的工具结果都不应该计入“稳定前缀”因为它们在本次请求里必然变化。从工程角度把这个计算逻辑单独抽成方法还有一个好处调试时可以单独打点。我们曾经在一个出问题的环境里给缓存键计算加过日志发现某个会话的stable_boundary一直在跳变顺着这个线索才定位到角色清洗逻辑里有个隐藏分支。4.3 缓存命中后的增量组装缓存命中并不代表什么都不做。看_handle_cache_hit的实现def _handle_cache_hit(self, cached, conversation): # cached 里存的是历史消息的 segment 列表 segments list(cached.segments) new_messages conversation[stable_boundary:] for msg in new_messages: rendered self._render_message(msg) segments.append(rendered) return segments这段逻辑的妙处在于历史前缀是直接复用的只有最后几条新消息需要走模板渲染。真实多轮对话里历史越长增量组装的收益越明显。实际操作中有个细节值得提渲染新消息的时候是否要重新拼接一遍系统提示答案是不需要。因为系统提示已经在缓存前缀里了新消息渲染时只需要关心它自己的角色和内容。这也是为什么缓存存的是segment列表而不是扁平字符串——每一段消息都是独立渲染的拼接时才需要知道顺序。4.4 截断收尾最后一道闸门截断是组装完成之后、写入缓存之前的最后一道闸门。伪代码如下def _truncate_if_needed(self, segments, max_input_tokens): total sum(seg.token_count for seg in segments) if total max_input_tokens: return segments # 永远保留最后一个片段 keep_last segments[-1] candidates segments[:-1] # 除了最后一个其余都能丢 # 按 sequence 从旧到新排序后从头开始丢 candidates.sort(keylambda seg: -seg.sequence 0) # 简化示意 while total max_input_tokens and candidates: victim pop_oldest(candidates) total - victim.token_count return candidates [keep_last]这段逻辑要特别注意截断之后缓存里存的是“截断后的片段列表”而不是原始列表。如果这个会话下一次请求又超长可以直接拿这个截断后的结果接着算不用从头再丢一遍。这也是为什么缓存存储结构和截断逻辑必须放在一起设计。提示截断完成之后建议再做一次精确token计数。之前的估算可能不太准精确计数虽然贵但只在截断路径上执行大部分请求走缓存命中路径整体收益是正的。4.5 并发场景下的缓存读写并发是缓存模块绕不开的话题。Hermes在写缓存时用了双检锁加单飞模式避免多个并发请求同时渲染同一个前缀。def lookup_or_build(self, cache_key, builder): cached self.prompt_cache.lookup(cache_key) if cached: return cached # 本地锁只锁当前进程 with self.local_lock(cache_key): cached self.prompt_cache.lookup(cache_key) # 双检 if cached: return cached result builder() self.prompt_cache.store(cache_key, result) return result第一次查没有锁命中就直接返回开销极小。未命中时先抢锁抢到后再查一次缓存确认没有别人写入才真正执行构建。这个模式能有效防止并发场景下大量重复渲染。分布式环境下如果有多实例建议再加一层分布式锁或者接受短暂的重叠构建因为渲染本身很快分布式锁的复杂度有时候不划算。5. 实战中踩过的坑与排查方法5.1 缓存命中率长期在30%以下这是提示缓存上线后最常见的故障。缓存键计算逻辑看着没问题但命中率就是上不去。后来排查发现缓存键里包含了原始消息里的时间戳信息。有些场景下用户消息是从网页端传上来的前端会在系统提示里塞一个“当前时间”字段。这个字段每秒都在变系统提示一变化缓存键立刻失效整个前缀缓存全废。解决办法很直接把“当前时间”这类动态字段从系统提示里拆出来放到消息列表的最后一条让它可以单独渲染不参与前缀缓存。原则是凡是会随请求变化的字段都放到“后缀”里只有真正稳定的公共前缀才进入缓存键。5.2 最后一条消息角色变了缓存键震碎多轮对话里有一种很容易触发的情况上一条是user消息模型给出了assistant消息但随后工具执行返回了一个tool消息这时最新消息变成了tool。按缓存键的计算逻辑stable_boundary会往回退很多因为最后一条工具结果必须被当作“新消息”处理导致前面一大段历史都无法使用缓存。处理方式是把工具调用和工具结果单独建模不要把tool消息混在普通历史消息里。这样stable_boundary仍然可以停在工具调用之前的用户消息上工具结果作为可变尾部参与增量渲染前面的历史缓存不受影响。5.3 缓存穿透同一前缀同时没命中当一批请求同时到达且都没命中缓存时如果没做单飞所有请求都会同时执行全量渲染。这个问题在突发流量场景下特别明显CPU瞬间拉满请求时延集体飙升。解决思路就是上面代码里的双检锁。另一个补充手段是给空结果也做短暂缓存防止某些高频但内容为空的消息反复走全量渲染。空缓存的TTL可以设得很短比如3到5秒只挡突发不占内存。5.4 线上组装结果和本地测试不一致这类问题排查起来最头疼。本地跑得好好的线上就是有莫名其妙的上下文结构错误。后来发现是多实例之间模板文件版本不同步造成的A机器已经用新版模板渲染B机器还在用旧版。解决办法是在发布流程里把模板版本写成硬编码字符串并参与缓存键计算。模板文件内容变更时必须同步更新版本号。这样即使某台机器没拉最新代码它生成的缓存键也和别的机器不一样不会出现混用缓存导致的结果错乱。另外建议给每个组装结果加一个debug字段把缓存键的前8位写进去。线上查问题的时候直接看日志里的缓存键前缀就知道一个请求用的是哪个版本模板、哪一段前缀缓存省掉大量猜测时间。最后再分享一点个人经验我读Hermes这块源码最大的收获不是某个具体算法而是“组装”和“缓存”必须一起设计的思路。很多人习惯先把消息拼好再单独加一层缓存结果缓存键要么不完整要么过度复杂线上问题一大堆。后来我把缓存键的设计前置到组装流程里问题少了很多。实际写的时候有一点让我印象很深缓存键的分隔符、序列化方式、模板版本的命名这些看起来微不足道的细节才是线上稳定性的真正决定因素。一个\x00分隔符的取舍背后是哈希碰撞的考量一段片段列表的存储结构决定了截断能不能走增量路径。这些东西不读源码很难注意到但留意到之后基本能避免80%的提示缓存坑。如果你也在做类似的服务建议先把组装器的监控打全命中率、组装耗时、截断条数、缓存键分布。看到这些指标再回头看自己的缓存键设计多半能发现隐藏的问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑