做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_tokens=1024,那输入预算就是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( conversation=conversation, system_prompt=system_prompt, tool_schema=tool_schema, max_input_tokens=calculate_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, hit=True) 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, hit=False)逻辑很清楚:先查缓存,命中就只处理新消息;没命中就全量渲染,截断,写入缓存。注意在缓存命中的分支里,_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_keys=True), 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(key=lambda 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%的提示缓存坑。如果你也在做类似的服务,建议先把组装器的监控打全:命中率、组装耗时、截断条数、缓存键分布。看到这些指标,再回头看自己的缓存键设计,多半能发现隐藏的问题。
做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_tokens=1024,那输入预算就是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( conversation=conversation, system_prompt=system_prompt, tool_schema=tool_schema, max_input_tokens=calculate_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, hit=True) 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, hit=False)逻辑很清楚:先查缓存,命中就只处理新消息;没命中就全量渲染,截断,写入缓存。注意在缓存命中的分支里,_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_keys=True), 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(key=lambda 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%的提示缓存坑。如果你也在做类似的服务,建议先把组装器的监控打全:命中率、组装耗时、截断条数、缓存键分布。看到这些指标,再回头看自己的缓存键设计,多半能发现隐藏的问题。