Hermes源码走读:提示组装与缓存如何减少推理token开销

一条请求进来,为什么有时候快有时候慢?很多人第一反应是显卡算力波动、模型排队,但真正拖慢响应的地方,往往藏在你看不见的预处理阶段。我读Hermes源码的时候,第一块看的就是“消息是怎么拼出来的”——也就是提示组装和提示缓存这部分。看完之后才意识到,一次推理请求的token开销,可能有一半都花在了“把消息拼成模型认识的格式”这件事上。

这篇是源码走读笔记的第二篇,聚焦两条主线:一是提示组装(prompt assembly)是怎么把零散的消息文本拼接成模型所需输入的;二是提示缓存(prompt cache)是怎么让重复前缀不用重复计算的。适合对推理引擎有基础了解、想深入源码但找不到下手的点的开发者。

1. 一条消息从进来到进入模型前,都经历了什么

1.1 一次请求的完整生命周期

先捋一下整体流程。请求从API入口进来之后,经过鉴权、限流、请求体解析,进入框架内部的消息表示层。这时候消息还是“业务态”的,也就是用户视角里的对话记录、系统指令、工具调用结果等。接下来进入预处理管线,依次经过角色归一化、模板匹配、文本拼装、token化、前缀检索,最终得到一段模型真正认识的token序列,再交给调度器排队进入执行器。

很多源码教程喜欢一上来就讲注意力机制、KV cache,但对想真正动手改框架的人来说,预处理这块才是最常打交道的地方。因为生产环境里的性能问题,有一半出在“拼接重复”上,另一半出在“缓存不命中”上。

1.2 为什么需要两级缓存

读代码之后你会发现,Hermes把缓存拆成了两层:

第一层是提示组装缓存,也叫预处理缓存。它缓存的是“文本拼装结果”,也就是系统提示词、对话历史上一次组装后的成品文本。命中这一层,能省去角色标签拼接、模板渲染这类字符串操作。

第二层是前缀缓存,也就是常说的KV cache复用。它缓存的是“已经算好的键值对状态”。命中这一层,能省去真正的前向计算。

两层缓存的目标完全不同:第一层省CPU,第二层省GPU。生产环境里,我建议把这两层分开监控,因为它们的命中率反映的问题完全不同。如果第一层命中率很低,多半是模板使用不当或者消息对象被反复拷贝;如果第二层命中率很低,那就要检查请求并发时的前缀对齐逻辑。

2. 代码走读:提示组装模块

2.1 消息角色的归一化处理

Hermes内部定义了一套统一的消息结构,它把上游传进来的各种消息风格统一成固定的角色枚举。源码里能看到这样一个简化模型:

# 简化自 Hermes 内部消息模型 class Message: def __init__(self, role: str, content: str, name: str | None = None): self.role = role # 'system' | 'user' | 'assistant' | 'tool' self.content = content self.name = name def __repr__(self): return f"Message({self.role}, {self.content[:20]})"

原始请求里的角色字段五花八门,有的叫“system”,有的叫“system_prompt”,有的把系统指令直接塞进“user”消息里。归一化这一步的作用,就是把它们全部映射到标准角色集合里。

def normalize_role(raw_role: str) -> str: lowered = raw_role.strip().lower() mapping = { "system": "system", "system_prompt": "system", "human": "user", "prompter": "user", "bot": "assistant", "assistant": "assistant", "tool": "tool", "function": "tool", } return mapping.get(lowered, "user")

这段逻辑看似简单,但坑特别多。最典型的是工具消息:不同框架对函数调用结果的角色命名完全不同,有的叫“function”,有的叫“tool”,有的甚至直接塞进“user”角色里。如果没有统一归一化,后面拼接出来的模板就是错的,模型会分不清“工具返回的结果”和“用户新输入”的边界,导致回答质量断崖式下降。

2.2 拼接规则与角色标签

拼接逻辑是提示组装的核心。不同的模型厂商对对话模板有完全不同的设计,Hermes把这部分抽象成PromptTemplate接口,每种模板实现定义自己的角色标签和拼接顺序。

# 简化自 Hermes 的 PromptTemplate 实现 class PromptTemplate: def __init__(self, role_tags: dict): self.role_tags = role_tags # 如 {"user": "<|im_start|>user\n", "assistant": "<|im_start|>assistant\n"} self.bos_token = "<|begin_of_text|>" self.eos_token = "<|end_of_text|>" def apply(self, messages: list[Message]) -> str: parts = [self.bos_token] for msg in messages: tag = self.role_tags.get(msg.role, self.role_tags.get("user")) parts.append(tag) if msg.name: parts.append(f"name={msg.name}\n") parts.append(msg.content.strip()) parts.append(self.eos_token + "\n") return "".join(parts)

这个apply方法走一遍下来,就是完整的提示文本。大多数人写业务代码不会碰这里,但如果你要做模型微调或者新模型适配,这里就是你唯一需要动刀的地方。

我在读代码时特别注意到了一个细节:每条消息后面都追加了EOS token,而不是只在整个提示最后追加。这个设计不是随意的,它让每条消息在token空间里的边界变得清晰。对模型来说,“用户消息结束”和“助手消息结束”是两个完全不同的状态,分开处理能让注意力在长对话里更稳定。

2.3 token化与特殊token的处理

提示组装完成之后,文本要交给tokenizer转成token序列。这块的坑比想象中多,尤其是特殊token的处理。

# token化的过程中,特殊token不能被粗暴拆分 tokens = [] for text_segment in assembled_segments: if text_segment.startswith("<|") and text_segment.endswith("|>"): token_id = tokenizer.convert_tokens_to_ids(text_segment) if token_id is not None and tokenizer.is_special_token(text_segment): tokens.append(token_id) continue tokens.extend(tokenizer.encode(text_segment))

也就是说,像<|im_start|>这类标记必须作为整体映射成单个token id,而不能被tokenizer按照子词切碎。否则模型看到的就不是一个完整的控制标记,而是几个碎片,推理结果会变得不可预测。

为了这个目的,Hermes在组装阶段会维护一个“特殊token保护集合”。凡是命中的字符串片段,一律跳过普通encode流程。这个细节也解释了为什么源码里很多地方都强调“使用官方配套tokenizer”,自己随意换一个分词器,特殊token表对不上,组装结果就会整个乱掉。

2.4 组装结果的缓存key设计

组装缓存的意义在于:很多请求的前缀是完全相同的,比如系统提示词、few-shot示例、前几轮对话。如果每次请求都重新做一遍字符串拼接和token化,CPU时间就被浪费了。

看代码里缓存key的构造逻辑:

def build_cache_key(model: str, template: PromptTemplate, messages: list[Message]) -> str: # 只取消息角色的序列和内容长度,避免整段内容做hash role_seq = "|".join(m.role for m in messages) # 实际上需要对消息内容做hash,这里简化为使用内容摘要 content_hash = hash(tuple((m.role, m.content) for m in messages)) return f"{model}:{template.version}:{role_seq}:{content_hash}"

一个重要的代码细节是:缓存key里放了模板版本号。因为模板规则可能升级,一旦版本号变化,旧缓存必须全部失效。很多线上缓存命中率突降的案例,根源就是版本号没有参与key计算,导致新规则的组装结果命中了旧规则的缓存。

不过组装缓存只缓存到token序列为止,不会缓存KV值。KV值仍然要交给前缀缓存去处理。

3. 代码走读:提示缓存模块

3.1 为什么要缓存前缀

通俗理解,提示缓存解决的问题是:用户A和用户B同时请求模型,他们的系统提示词和开场白完全一致。如果没有缓存,两个请求都要从头计算前向传播;有了缓存,第二个请求可以直接复用第一个请求算好的中间状态。

这里有个经验数据:在一个典型的多轮对话应用里,请求的有效前缀长度占整条输入长度的60%到80%。缓存命中一次,能省掉的算力是相当可观的。

3.2 前缀树的数据结构

Hermes的前缀缓存不是简单的 key-value 缓存,因为对话场景里前缀是动态的——用户可能在任意一轮插入新问题,导致后续分支不同。KV cache必须按token的先后顺序组织成树状结构,才能最大化复用。

源码中缓存块的数据结构大致如下:

class PrefixNode: __slots__ = ('token_ids', 'block_size', 'kv_state', 'children', 'ref_count', 'last_access_time') def __init__(self, token_ids, kv_state, block_size): self.token_ids = token_ids self.block_size = block_size # 每个节点的固定token块大小 self.kv_state = kv_state # 对应的key-value状态 self.children = {} # 下一个block的索引 self.ref_count = 0 # 被引用次数 self.last_access_time = 0 # 最近访问时间

每个节点保存一段固定长度的token块,以及对应的KV状态。节点之间通过children形成一棵前缀树。新请求到达时,沿着token序列逐个块匹配,能匹配多少就复用多少。

3.3 命中判断与回退策略

当请求的token序列到达时,缓存模块会做贪心匹配:

def match_prefix(cache_root: PrefixNode, request_tokens: list[int], block_size: int) -> int: node = cache_root matched_tokens = 0 for chunk_start in range(0, len(request_tokens), block_size): chunk = tuple(request_tokens[chunk_start:chunk_start + block_size]) child = node.children.get(chunk) if child is None: break node = child matched_tokens += len(chunk) return matched_tokens

这段逻辑是“贪心”的:只要下一个块匹配不上,就立即停止,不会跳过当前块去匹配更后面的内容。选择贪心策略是因为,KV cache的复用必须严格满足前缀一致,如果中间断了一截,后面的KV状态即使在内容上看起来可以复用,计算上下文也已经不同了。

在生产环境里,有一种常见场景能暴露出这个策略的局限:用户插入了新问题,后面的内容全部后移。这个时候前缀树里能匹配的只有前面的一小段,后面的块因为位置偏移了,全部失效。这是前缀缓存的固有限制,不是实现bug,理解这个才能正确评估缓存收益。

3.4 缓存淘汰与内存管理

前缀缓存很吃内存,所以淘汰策略直接决定了服务稳定性。代码里用的是基于访问时间的近似LRU,加引用计数保护:

  • 每条prompt在解码期间会固定引用其前缀节点,防止被淘汰。
  • 引用计数降为0之后,节点进入可淘汰队列。
  • 淘汰时优先选择last_access_time最久、ref_count为0的节点释放。

有几个细节值得注意:节点的淘汰和释放是异步的,不会在前向计算路径上进行,否则把GPU算子都卡住了;每个节点的内存占用是 block_size * 隐藏层维度 * 存储精度,假设某个模型隐藏层是4096维,块大小64,精度2字节,那一个块就要占512KB内存。一条长请求很容易积累几十个块,所以前缀缓存的内存占用上升速度非常吓人,必须靠淘汰策略控制在高水位线以内。

4. 与采样执行阶段的联动

4.1 命中缓存后如何继续生成

提示缓存不是只服务预填充阶段,它与解码阶段的协作才真正体现价值。当请求的token前缀命中缓存后,引擎在调度时会把任务的起点从第0个token调整到命中长度的位置,然后只对新增的token部分执行前向计算。

调度核心逻辑大致如下:

def schedule_request(request, cache_node, max_batch_size): matched_len = match_prefix(cache_node, request.input_ids, request.block_size) if matched_len > 0: request.start_position = matched_len request.kv_state = cache_node.kv_state else: request.start_position = 0 request.kv_state = None return request

这一步最大的收益点在于:多轮对话和同前缀请求的首次返回时间,直接从秒级降到毫秒级。我在读代码的时候专门对照了性能数据,在系统提示词固定的业务场景下,缓存命中后的首token延迟通常可以下降80%以上。

4.2 对齐与回退

和很多人的直觉不同,前缀缓存很少做精确到单个token的复用,而是以块为单位。这种设计是有意的:一个块对应一个固定的矩阵分块,KV cache的存储布局天然适合按块复用。如果非要做到单个token复用,存储碎片化和寻址开销都会陡增,不划算。

块的大小不是拍脑袋定的,它跟模型的层数、注意力头数、块矩阵对齐要求都有关系。在Hermes里,这个值会在启动阶段根据模型参数自动推导,一般保证块大小乘以隐藏层维度能被至少一个attention头的工作区间整除,从而避免GPU算子出现尾部对齐开销。

4.3 动态前缀的分叉处理

每次请求如果要在已有缓存树的某个节点上产生新分支,比如同一个多轮对话里用户问了不同的问题,就会在这个节点下挂出新的子节点。这棵树会不断壮大,直到某些分支被淘汰。

分叉处理也带来了一个潜在问题:缓存树的重复分支可能占用大量内存。源码里设置了分叉数上限,如果某个前缀节点的子节点数超过阈值,就会对最久未访问的子树执行剪枝操作。这个阈值同样可以在配置项里调整,太大会导致内存爆炸,太小会导致命中率下降,需要在测试环境里压测出合适的值。

5. 生产中容易踩的坑与排查技巧

5.1 系统提示词带动态字段导致缓存全miss

真实线上项目里,最常见的问题就是系统提示词里拼接了时间戳、日期、随机用户ID。比如有个团队在系统提示词末尾加了“当前日期:2026-01-15”,实际上这个字段每次请求都在变,导致前缀缓存全部失效,命中率直接归零。

排查方式不难:把线上请求的prompt抓下来,对比前几十个token是否一致,不一致就说明有动态字段混进来了。修复方式是把这类信息从系统提示词中剥离,或者只在必要时刻通过消息片段注入。

5.2 多轮工具消息的角色错配

工具调用场景下的提示组装特别容易出错。典型错误是,把工具返回结果拼成了user消息,导致模板识别错乱。模型的输入格式一旦错位,轻则回答质量下降,重则直接输出非法格式。

这个问题的排查看组装日志最直观:打印出最终拼好的提示文本,检查每个<|im_start|>标签后面跟的消息角色是否和实际对应。我在排查这类问题时通常直接检查这段文本中“tool”标签的数量是否和工具调用次数一致,不一致就是组装逻辑有bug。

5.3 并发请求互相挤占缓存

前缀缓存是全局共享的,处理完一个请求之后,节点会暂时保留。但如果并发请求的前缀大相径庭,缓存树会被大量无共享关系的分支撑大,挤占真正能复用的公共前缀空间。

缓解方案有两条:一是给缓存树按业务场景划分独立的命名空间,比如不同应用使用不同的前缀存储区,互不干扰;二是根据业务并发模型调整淘汰阈值,让高频重复前缀更容易留在缓存里。

为了方便排查,我把这几类常见问题整理成了速查表:

现象可能原因排查命令/手段解决建议
缓存命中率突降模板版本变化检查template.version确认升级预期,预热新缓存
命中率长期为0系统提示词含动态字段抓取前20个token对比去除动态字段,改为结构化字段注入
内存占用持续上涨淘汰阈值过大或分叉过多观察缓存树节点数调低分叉阈值,增大回收频率
多轮对话偶发错乱工具消息角色错配打印最终提示文本修正角色归一化映射
同前缀毫秒级返回,但效果变差缓存未失效时被截断复用对比解码输出长度检查是否有截断策略导致输出不完整

6. 代码走读的几个实操心得

读Hermes这块源码,我个人的体感是:提示组装和提示缓存是理解整个推理引擎的最佳入口。因为它们处于请求链路的最前端,逻辑相对独立,不涉及复杂的分布式调度,却足以让人掌握框架的模块划分、数据流和缓存思想。

有一点我觉得特别值得提:读代码的时候不要一上来就扎进最底层的算子实现,先抓住数据结构的生命周期。就拿前缀节点来说,它从创建、被引用、进入淘汰队列到释放,整个生命周期走一遍,基本就把缓存设计吃透了。

另外,调试这类代码有个偷懒技巧:在组装缓存和前缀缓存的接口处分别加一个简单的命中计数器,配合日志里打印命中后的start_position,就能很快定位性能瓶颈在哪一层。我在做性能分析时习惯把“组装命中率”“前缀命中率”“平均命中长度”三个指标放到同一张监控图里,哪个指标掉了,问题基本就锁定在对应的模块。

这套源码读下来,最直接的收获,是再遇到线上响应慢的时候,我能快速判断到底是该调模板、调缓存阈值,还是去查GPU算力,而不是像以前一样只能盲目扩容。

相关新闻

赶考小状元AI智能学习机深度测评:AI精准学真的能提分吗?

赶考小状元AI智能学习机深度测评:AI精准学真的能提分吗?

1. 先搞清楚一件事&#xff1a;家长为什么开始关注AI智能学习机说实话&#xff0c;前两年提起学习机&#xff0c;很多家长的第一反应还是“不就是个平板加上一堆网课嘛”。但近两年情况真的变了&#xff0c;尤其是“赶考小状元AI智能学习机”这类产品出现之后&#xff0c;它把传…

2026/10/22 12:54:36 阅读更多 →
同城物流调度系统全链路解析:从订单接入到运力优化

同城物流调度系统全链路解析:从订单接入到运力优化

简介&#xff1a;一份面向安卓开发学习者的同城物流派送系统完整项目&#xff0c;覆盖从订单发起、司机抢单到货物送达的全流程管理&#xff0c;适合作为移动端课程设计或毕业设计的参考蓝本。压缩包内共275个文件&#xff0c;大小约53.42MB&#xff0c;以Java源码、XML布局、P…

2026/10/22 9:04:02 阅读更多 →
Anthropic就业报告拆解:Agent工时6倍与初级招聘9%的统计边界

Anthropic就业报告拆解:Agent工时6倍与初级招聘9%的统计边界

Anthropic就业报告拆解&#xff1a;Agent工时6倍与初级招聘9%的统计边界 Anthropic 2026年10月内部就业研究提供了一组少见的数据&#xff1a;招聘记录、agent会话遥测、代码提交和面试评分。本文重点检查三个容易混淆的口径——初级招聘份额与人数、agent运行时间与劳动生产率…

2026/10/21 22:33:55 阅读更多 →

最新新闻

Electron桌面应用开发实战:从环境搭建到打包发布全流程

Electron桌面应用开发实战:从环境搭建到打包发布全流程

做前端这些年&#xff0c;我大部分时间都在跟浏览器标签页打交道&#xff0c;直到第一次把桌面应用跑起来&#xff0c;才发现原来自己一直被限制在地址栏和刷新按钮围起来的框框里。Electron这个词&#xff0c;最开始是在某段时间频繁出现在同事的讨论里&#xff0c;一个用HTML…

2026/10/22 10:18:38 阅读更多 →
滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

简介&#xff1a;亚太天能T109滑盖智能锁产品说明书是一份PDF格式的官方技术文档&#xff0c;主要面向智能锁安装人员、终端用户及售后维护者&#xff0c;帮助快速掌握产品安装、配置与常见故障处理。全包仅1个PDF文件&#xff0c;大小4.37MB&#xff0c;内容结构清晰&#xff…

2026/10/22 11:01:53 阅读更多 →
PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

上个月我在调试一台高速AGV的驱动系统&#xff0c;整车要求空载跑到8m/s&#xff0c;电机转速已经拉到额定点&#xff0c;驱动器的输出电压也顶到了母线电压极限附近。再往上踩速度指令&#xff0c;转速纹丝不动&#xff0c;三相电流反而开始震荡。那会儿我的第一反应就是&…

2026/10/22 9:44:47 阅读更多 →
C++核心知识点:从内存管理到STL的实战指南

C++核心知识点:从内存管理到STL的实战指南

C 这门语言我前前后后折腾了十几年&#xff0c;用它写过嵌入式驱动、做过游戏服务端&#xff0c;也搭过基础组件库。每次看到新人一头扎进"C 核心知识点"这个搜索词&#xff0c;我其实挺感慨的——这门语言的难点从来不是语法多&#xff0c;而是知识点之间的耦合太深…

2026/10/22 10:42:00 阅读更多 →
T109滑盖智能锁怎么装?安装调试与故障排查全攻略

T109滑盖智能锁怎么装?安装调试与故障排查全攻略

简介&#xff1a;亚太天能T109滑盖智能锁产品说明书以PDF格式发布&#xff0c;适合智能锁用户、安装人员与售后维修人员阅读。内容围绕T109系列指纹密码刷卡锁展开&#xff0c;先介绍前面板、后面板、锁体、机械钥匙、电池盒盖等部件名称与功能&#xff0c;再给出完整安装步骤&…

2026/10/22 10:40:08 阅读更多 →
LLM知识降熵:从信息垄断到理解权下放

LLM知识降熵:从信息垄断到理解权下放

你有没有过这种经历&#xff1a;翻开源码注释、论文摘要或者一份专业报告&#xff0c;每个字都认识&#xff0c;连在一起却像天书。想找人聊聊&#xff0c;身边要么没人懂&#xff0c;要么懂的懒得跟你解释。后来我试着把同一段内容丢给一个大语言模型&#xff0c;几秒钟之后&a…

2026/10/22 10:56:37 阅读更多 →

日新闻

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/22 1:47:45 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/21 21:32:28 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/22 5:12:39 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 21:50:56 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 21:51:55 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 16:24:48 阅读更多 →