☰
大模型推理框架源码走读:提示组装与提示缓存的设计与实现
2026/10/20 5:23:06 网站建设 项目流程

1. 从一次“模型答非所问”说起:为什么提示组装值得单独拎出来讲

很多人第一次翻大模型推理框架的源码,注意力几乎全被注意力机制、KV Cache、采样策略这些“硬核”部分吸走了,觉得提示组装无非就是把几段字符串拼一拼,没什么技术含量。我一开始也是这么想的,直到有一次在调试一个多轮对话场景时,模型突然开始“答非所问”——明明用户问的是订单状态,模型却一本正经地介绍起了退货政策。排查了半天才发现,问题出在提示拼接的顺序上:系统提示、历史对话、当前用户输入这三段内容的拼接位置错了一处,导致模型把上一轮的助手回复当成了本轮的用户输入。

这件事让我意识到,提示组装远不是“拼字符串”那么简单。它决定了模型看到的上下文长什么样,直接影响推理结果的正确性。而提示缓存则是在这个基础上进一步解决性能问题——当大量请求共享相同的前缀提示时,如果每次都重新计算,算力浪费会非常惊人。

这篇内容围绕 Hermes 源码中提示组装与提示缓存这两块展开,属于源码走读系列的第二篇。我会带着你逐层拆解:一条用户请求进来之后,提示是怎么从零散的字段一步步拼成最终送进模型的完整序列的,缓存又是在哪个环节介入的,以及在实际使用中有哪些容易踩的坑。适合已经对大模型推理流程有基本了解、想深入框架内部实现细节的开发者阅读。即使你之前没接触过 Hermes,跟着走读也能理解这套设计的通用思路。

2. 提示组装的完整链路:从请求对象到最终序列

2.1 请求进入推理引擎时的原始形态

当一个推理请求到达引擎入口时,它携带的信息其实是相当“松散”的。通常包含几个核心字段:系统提示(system prompt)、对话历史(messages 列表)、当前用户输入、以及一些采样参数(temperature、top_p 等)。这些字段在进入组装流程之前,各自独立存在,并没有形成一个统一的序列。

在 Hermes 的设计中,这个原始请求会被封装成一个内部数据结构,我把它理解为“待组装的原料包”。这个原料包里,对话历史通常是一个有序列表,每个元素包含角色(role)和内容(content)两个字段。角色一般有 system、user、assistant 三种。系统提示可能单独存放,也可能作为列表的第一个元素存在,这取决于上层调用方的习惯。

这里有一个容易被忽略的细节:不同来源的请求,其对话历史的完整性是不一样的。有的调用方会把完整的多轮对话传进来,有的只传最近几轮,还有的会把系统提示混在历史列表里。组装逻辑必须能兼容这些不同的输入形态,否则就会出现前面提到的那种“角色错位”问题。

2.2 模板渲染:把结构化数据变成模型认识的文本

原料包准备好之后,下一步是模板渲染。这一步的核心任务是把结构化的角色-内容对,转换成模型能理解的纯文本序列。不同模型对格式的要求不一样,有的用特殊标记区分角色,有的用自然语言描述,还有的依赖特定的分隔符。

Hermes 在这一步采用的是可配置的模板机制。模板本质上是一段带有占位符的字符串,占位符会在渲染时被实际内容替换。比如一个典型的对话模板可能长这样:

<|system|> {system_prompt} <|user|> {user_message} <|assistant|>

渲染过程就是按顺序把各个字段填充进去。但这里有个关键决策点:系统提示是单独渲染,还是作为对话历史的一部分渲染?这两种做法在不同模型上的效果差异很大。我实测下来,对于指令遵循能力较强的模型,把系统提示单独放在最前面效果更稳定;而对于一些对角色标记敏感的模型,把系统提示作为第一条 system 角色的消息混入历史列表反而更好。

Hermes 的做法是把这个选择权交给配置。模板定义里可以指定系统提示的插入位置,组装逻辑会根据配置决定渲染顺序。这个设计的好处是灵活,坏处是如果配置写错了,排查起来比较费劲——因为最终生成的文本看起来“差不多”,但模型的表现可能天差地别。

2.3 多轮对话的拼接顺序与角色交替规则

多轮对话的拼接是组装环节里最容易出问题的地方。表面上看,就是按时间顺序把 user 和 assistant 的消息交替排列,但实际场景中会遇到各种边界情况。

第一种情况是连续相同角色的消息。比如用户连续发了两条消息,中间没有助手回复。这时候是合并成一条,还是保留两条?合并的话用什么分隔符?Hermes 的处理方式是保留两条独立消息,但在渲染时会在它们之间插入一个空行或特定标记,避免模型把它们当成一条超长输入。

第二种情况是对话历史被截断。当历史太长超出模型上下文窗口时,需要从最早的轮次开始丢弃。但丢弃的时候不能把 user 和 assistant 的配对拆散——如果丢了 user 消息却保留了对应的 assistant 回复,模型就会看到一段“无源之水”的回复,容易产生困惑。Hermes 在截断时会以“轮”为单位进行,保证每一轮 user-assistant 配对要么都在,要么都不在。

第三种情况是工具调用消息的插入。在一些支持函数调用的场景中,对话历史里会夹杂工具调用的请求和返回结果。这些消息的角色标记和普通对话不同,渲染时需要特殊处理。Hermes 为这类消息定义了独立的模板片段,确保它们不会和普通对话混淆。

2.4 特殊标记与分隔符的处理细节

模型能正确理解对话结构,很大程度上依赖于特殊标记。这些标记就像是给模型看的“路标”,告诉它哪里是系统提示结束、哪里是用户输入开始。不同模型使用的标记体系差异很大,有的用尖括号包裹的标签,有的用特殊 Unicode 字符,还有的用纯自然语言描述。

Hermes 在组装时会根据目标模型的配置,自动选择对应的标记体系。这个映射关系维护在一份配置表里,新增模型支持时只需要添加一条映射记录。但这里有个坑:有些模型的标记在训练时和推理时不一致,或者不同版本之间有过调整。如果配置表里的标记和模型实际期望的不匹配,模型虽然不会报错,但输出质量会明显下降。

我在实际使用中总结了一个验证方法:拿一个简单的问答对,分别用正确标记和错误标记各跑一次,对比输出质量。如果错误标记下的输出出现格式混乱、角色混淆、或者答非所问,就说明标记配置有问题。这个方法虽然笨,但很有效。

3. 提示缓存的设计动机与介入时机

3.1 为什么需要提示缓存:算力浪费的真实账

要理解提示缓存的价值,先算一笔账。假设一个推理服务每天处理十万次请求,每次请求的平均提示长度是两千个 token,其中系统提示和固定前缀占了八百个 token。如果不做缓存,这八百个 token 每次都要重新走一遍前向计算。按当前主流硬件的算力水平,这八百个 token 的预填充阶段大约消耗几十毫秒。十万次请求累积下来,光是重复计算固定前缀就浪费了大量算力。

更关键的是,这部分算力本来可以用来处理更多请求。在并发量高的场景下,预填充阶段的算力占用会直接影响吞吐量。提示缓存的核心思路就是:把固定前缀的中间计算结果(主要是 KV Cache)保存下来,后续请求如果前缀相同,直接复用这部分结果,只需要计算新增部分。

这个思路听起来简单,但实现起来有几个关键问题需要解决:怎么判断两个请求的前缀是否相同?缓存存多久?缓存满了怎么淘汰?不同请求的采样参数不同,缓存能不能复用?Hermes 在这些问题上都有自己的取舍。

3.2 前缀匹配的粒度:从 token 级到块级

判断前缀是否相同,最直接的做法是逐 token 比较。但 token 级比较的效率太低,而且对缓存的存储和检索都不友好。Hermes 采用的是块级匹配策略:把提示序列按固定大小切分成块(比如每块十六个 token),然后对每个块计算哈希值。两个请求的前缀如果在前若干个块的哈希值上完全一致,就认为可以复用这部分缓存。

块大小的选择是个权衡。块太小,哈希计算和比较的开销上去了;块太大,匹配的粒度太粗,可能明明只有一小段不同却要整块重算。Hermes 默认的块大小是十六,这个值在大多数场景下表现比较均衡。如果提示中经常出现长距离的重复片段,可以适当调大块大小;如果提示变化很频繁,调小一些可能更划算。

这里有个实现细节值得注意:块哈希的计算不仅要包含 token 本身,还要包含 token 的位置信息。否则两个内容相同但位置不同的块会被误判为相同,导致缓存复用出错。Hermes 在哈希计算时会把块内每个 token 的 ID 和其绝对位置一起纳入,确保匹配的准确性。

3.3 缓存命中后的复用逻辑

当一个新的请求到来时,组装完成的提示序列会先经过缓存匹配模块。匹配模块从序列开头开始,逐块计算哈希并查询缓存。一旦遇到第一个未命中的块,匹配就停止,前面命中的部分对应的 KV Cache 会被直接加载到当前请求的推理上下文中。

这个过程有一个关键约束:缓存复用要求前缀完全一致,不能有“部分匹配”。也就是说,如果前十个块命中但第十一个块不同,那么只有前十个块的缓存能被复用,从第十一个块开始的所有内容都需要重新计算。这个约束是由注意力机制的特性决定的——KV Cache 是按位置索引的,如果中间某个位置的内容变了,后面所有位置的注意力计算结果都会受影响。

在实际使用中,这个约束意味着提示的设计要尽量把固定内容放在前面,变化内容放在后面。比如系统提示、角色设定、通用指令这些不变的部分应该放在最前面,用户的具体问题放在最后。这样缓存命中率最高,性能收益最大。

3.4 缓存失效与淘汰策略

缓存不可能无限增长,必须有淘汰机制。Hermes 采用的是基于最近使用时间(LRU)的淘汰策略,同时结合了缓存条数和总 token 数的双重限制。当缓存条目数或总 token 数超过阈值时,最久未被使用的缓存会被清除。

这个策略在大多数场景下够用,但有一种情况需要注意:如果服务同时处理多个不同来源的请求,且这些请求的前缀差异很大,缓存可能会频繁地被不同来源的请求“挤来挤去”,导致命中率下降。我遇到过这种情况,解决办法是按请求来源或租户做缓存分区,每个分区独立管理自己的缓存,避免相互干扰。

另一个需要注意的点是缓存的失效。当模型本身更新了(比如换了新版本的权重),之前缓存的 KV 值就不再有效了,必须全部清除。Hermes 在缓存条目里记录了模型版本标识,版本不匹配的缓存会被自动忽略。这个机制在模型热更新时特别重要,否则会出现新旧模型混用缓存导致的输出异常。

4. 代码走读:组装与缓存的关键实现片段

4.1 组装入口函数的主流程

组装逻辑的入口通常是一个名为build_prompt或类似名称的函数。它的输入是原始请求对象和模型配置,输出是组装好的 token 序列。主流程大致分为三步:第一步是规范化输入,把不同形态的请求统一成标准的消息列表;第二步是模板渲染,把消息列表转换成文本;第三步是 token 化,把文本转换成模型需要的 token ID 序列。

规范化输入这一步看似简单,但处理了很多边界情况。比如系统提示可能为空,对话历史可能只有一条用户消息,消息内容可能包含需要转义的特殊字符。这些情况在规范化阶段都会被统一处理,确保后续步骤拿到的数据是干净的。

模板渲染阶段会读取模型配置中的模板定义,按顺序渲染每条消息。渲染时会根据消息的角色选择对应的模板片段,把内容填充进去。如果配置了系统提示单独渲染,会先渲染系统提示,再渲染对话历史。

token 化阶段调用的是模型对应的分词器。这里有一个优化点:如果启用了缓存,token 化之后会先做缓存匹配,命中的部分直接跳过后续计算。这个优化把缓存匹配的时机放在了 token 化之后、推理之前,既保证了匹配的准确性,又避免了不必要的计算。

4.2 模板渲染函数的参数与返回值

模板渲染函数通常接收三个参数:消息列表、模板配置、以及一些可选的渲染选项。模板配置里定义了各种角色对应的模板片段,以及系统提示的插入位置。渲染选项可能包括是否在消息之间添加额外分隔符、是否对内容做转义等。

返回值是一个字符串,也就是渲染后的完整提示文本。这个字符串会直接送给分词器。这里有一个容易忽略的细节:渲染后的文本末尾通常会有一个“助手起始标记”,告诉模型该它说话了。这个标记如果漏掉,模型可能会继续以用户的口吻输出,或者干脆不输出。

我在调试时遇到过一次因为漏掉末尾标记导致的问题:模型把用户的提问重复了一遍,然后才给出回答。排查后发现是模板配置里末尾标记被误删了。这个教训说明,模板配置的每一个字符都有意义,修改时一定要谨慎。

4.3 缓存匹配的哈希计算与查询

缓存匹配的核心是一个哈希表,键是块哈希值,值是对应的 KV Cache 数据。匹配时,组装好的 token 序列会被切分成块,逐块计算哈希并查询哈希表。查询到命中时,记录命中的块数,继续查下一块;查询不到时,停止匹配。

哈希计算使用的是滚动哈希算法,这样在序列滑动时不需要重新计算整个块的哈希,只需要增量更新。这个优化在长序列上效果很明显,能把哈希计算的开销降到很低。

查询到命中块之后,对应的 KV Cache 会被加载到推理上下文中。加载的过程就是把缓存里的键值对复制到当前请求的注意力缓存里。这里有一个内存管理的细节:如果多个请求同时命中同一份缓存,需要确保缓存数据是只读的,或者每个请求持有独立的副本。Hermes 采用的是写时复制策略,多个请求共享同一份缓存直到某个请求需要修改它时才复制,这样既节省内存又保证安全。

4.4 缓存写入的时机与条件

缓存写入发生在推理完成之后。当一个新的请求完成了预填充阶段,它的 KV Cache 会被检查是否符合缓存条件。条件通常包括:提示长度超过某个阈值(太短的提示缓存收益不大)、缓存未满、以及该前缀之前没有被缓存过。

写入时,会把 KV Cache 按块切分,计算每块的哈希,然后存入哈希表。如果哈希表已满,会触发淘汰逻辑,清除最久未使用的条目。写入完成后,这个请求的后续生成阶段就可以直接使用这份缓存了。

这里有一个性能上的权衡:缓存写入本身也有开销,包括哈希计算和数据复制。如果提示很短或者缓存命中率很低,写入缓存反而可能拖慢整体性能。Hermes 通过阈值控制来避免这个问题——只有提示长度超过一定值时才启用缓存,短提示直接跳过。

5. 实际使用中容易踩的坑与排查思路

5.1 提示顺序错乱导致的角色混淆

这是最常见也最难排查的问题。表现是模型输出格式混乱,或者把助手的回复当成用户输入来回应。根本原因通常是组装时消息顺序错了,或者角色标记渲染错了。

排查方法:把组装后的完整提示文本打印出来,逐段检查角色标记和内容是否对应。特别要注意系统提示的位置——如果系统提示被放在了对话历史中间,模型可能会把它当成一条普通的用户消息来处理。

预防措施:在模板配置里明确指定系统提示的插入位置,不要依赖默认行为。同时,在组装完成后加一个校验步骤,检查消息列表的角色交替是否符合预期(比如 user 后面应该跟 assistant,除非是最后一条)。

5.2 缓存命中率低的常见原因

缓存命中率低意味着性能收益不明显。常见原因有几个:一是提示前缀变化太频繁,比如每次请求都带一个时间戳或随机 ID 在开头;二是缓存容量太小,频繁淘汰导致刚写入的缓存马上就被清掉了;三是块大小设置不合理,导致匹配粒度过粗或过细。

排查方法:在缓存模块加一些统计指标,记录每次请求的命中块数和总块数,计算命中率。如果命中率低于预期,检查提示前缀是否包含变化内容。把变化内容移到提示末尾通常能显著提升命中率。

我遇到过一个案例:某服务的系统提示里包含了一个动态生成的会话 ID,导致每次请求的前缀都不同,缓存完全失效。把会话 ID 移到用户消息里之后,命中率从接近零提升到了百分之八十以上。

5.3 缓存与采样参数的交互问题

缓存复用的是 KV Cache,也就是注意力机制的中间结果。这部分结果和采样参数(temperature、top_p 等)无关,所以不同采样参数的请求可以共享同一份缓存。这个特性在大多数情况下是好事,但有一种情况需要注意:如果某个请求使用了特殊的注意力掩码或位置编码方式,缓存可能不能直接复用。

Hermes 在缓存条目里记录了生成缓存时使用的注意力配置,匹配时会检查配置是否一致。不一致的缓存会被跳过。这个检查增加了少量开销,但避免了潜在的输出异常。

5.4 长对话场景下的截断与缓存协同

长对话场景下,对话历史可能超出模型上下文窗口,需要截断。截断之后,提示的前缀就变了,之前缓存的 KV Cache 可能不再适用。这时候缓存匹配会从截断点之后开始,前面被截掉的部分对应的缓存无法复用。

这个问题的解决办法是尽量保持截断策略的稳定性。比如固定从最早的轮次开始截断,而不是根据某种动态规则截断。这样不同请求之间的截断点更一致,缓存复用的机会更大。另外,可以把系统提示和最近几轮对话固定保留,只截断中间部分,这样前缀的稳定性更好。

6. 几个提升组装效率与缓存命中率的实用技巧

第一个技巧是把所有固定内容前置。系统提示、角色设定、通用指令、少样本示例,这些内容在所有请求中都是一样的,应该放在提示的最前面。用户的具体输入放在最后。这样缓存能覆盖最长的公共前缀,命中率最高。

第二个技巧是避免在提示开头放动态内容。时间戳、请求 ID、会话标识这些每次都在变的内容,如果放在开头,会导致缓存完全失效。把它们移到提示末尾或者用户消息内部,对模型理解的影响很小,但对缓存命中率的提升非常明显。

第三个技巧是合理设置缓存容量。缓存容量太小,命中率上不去;太大,内存占用高且淘汰开销大。一个经验法则是:缓存容量至少能容纳服务在峰值时段一分钟内处理的请求所对应的提示前缀。具体数值需要根据实际请求模式和硬件资源来调。

第四个技巧是定期监控缓存指标。命中率、平均命中块数、缓存淘汰频率,这些指标能帮你及时发现缓存配置的问题。如果命中率突然下降,通常意味着请求模式发生了变化,或者有新的调用方在提示开头放了动态内容。

第五个技巧是在开发阶段关闭缓存。缓存会让调试变得复杂,因为同样的输入可能因为缓存状态不同而产生不同的行为。开发新功能时先关掉缓存,确保逻辑正确后再开启,对比性能差异。

7. 从组装与缓存看推理框架的设计取舍

走读完 Hermes 的提示组装与缓存实现,我最大的感受是:这部分代码看起来不复杂,但每一个设计决策背后都有明确的权衡。模板渲染的灵活性换来了配置的复杂性,块级缓存的高效换来了匹配粒度的限制,LRU 淘汰的简单换来了特定场景下的命中率波动。

这些取舍没有绝对的对错,关键是要理解它们适用的场景。如果你的服务请求模式比较固定,前缀变化少,那缓存收益会很大,值得花时间调优。如果请求模式高度动态,每个请求的提示都不同,那缓存的收益有限,不如把精力放在其他优化上。

另外,组装逻辑的正确性比性能更重要。一个组装错误的提示,即使缓存命中率百分之百,输出也是错的。所以在优化缓存之前,先确保组装逻辑经过充分测试,覆盖各种边界情况。我在实际项目中会为组装逻辑写专门的单元测试,覆盖空历史、单轮对话、多轮对话、超长对话、特殊字符等场景,确保每次修改模板配置后都能快速验证。

最后分享一个调试小习惯:在组装完成后,把最终提示的前若干字符和后若干字符打印到日志里。这样当出现问题时,能快速判断是组装阶段的问题还是推理阶段的问题。这个习惯帮我省了很多排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询