尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
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 fMessage({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(fname{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算力而不是像以前一样只能盲目扩容。
RELATED

相关推荐

原生Canvas截图:50行实现DOM区域PNG下载

原生Canvas截图:50行实现DOM区域PNG下载

简介:本资源是一份面向前端开发者与JavaScript初学者的轻量级网页截图工具实现方案,聚焦于不依赖第三方库的原生技术路径,解决网页可视区域快速截取并一键下载的实用需求。压缩包仅含1个HTML文件(4KB),完整…

📅 2026/10/20 6:49:33
基于Debian的桌面Linux定制:从驱动到应用商店的开箱即用实践

基于Debian的桌面Linux定制:从驱动到应用商店的开箱即用实践

还记得我第一次在 Debian 上装完桌面之后的状态吗?显卡、字体、输入法、无线网卡,一样一样折腾下来,最后只剩下“能用”两个字的感慨。说实话,Debian 的底子非常适合做桌面,可默认给到的是一个只保证系统能启动的基础环…

📅 2026/10/20 6:49:33
PHP淘宝客源码搭建与二次开发:API接入、商品同步与避坑指南

PHP淘宝客源码搭建与二次开发:API接入、商品同步与避坑指南

简介:这份PHP淘宝客网站源码面向希望搭建淘宝联盟推广平台的站长与PHP开发者,提供从用户注册登录、商品浏览、推广链接生成到订单跟踪与佣金结算的完整实现,适合具备一定PHP基础、想研究电商返利系统架构的中高级学习者。压缩包共2037个文件&…

📅 2026/10/20 6:49:33
MORE NEWS

更多资讯

📰

从OpenClaw到Moltbook:让纸质书变身AI可调用的个人知识库

不知道大家有没有刷到过一条帖子,标题写得相当上头:"彻底疯狂了,OpenClaw 社区:Moltbook 硅基觉醒中..."。我习惯性地划过去,又忍不住划回来,仔细把标题拆了一遍。说实话,这种表达方式…

📰

Spring单例注入多例Bean失效?四种方案彻底搞懂Bean作用域

作为常年跟Spring打交道的人,一定遇到过这种诡异情况:明明把某个Bean定义成了Scope("prototype"),信心满满地注入到另一个单例Bean里,结果每次调用拿到的却是同一个对象,内部状态乱得让人怀疑人生。我最早掉…

📰

C++内存管理实战:从RAII到内存池,根治泄漏与性能问题

内存管理是C开发者绕不开的一座山。不管是刚写完第一行new的新手,还是在线上环境被Segment Fault折磨过几轮的老人,最终都会发现:真正决定C代码质量的,往往不是算法有多巧妙,而是对内存的控制有多精细。这篇东西我不打…

📰

Python+HTML四足机器人DIY:舵机抖动与步态调参实战

简介:这份资源是面向零基础爱好者的四足机器人DIY完整指南,以Python编程与HTML界面开发为核心,帮助新手从理论到实践搭建可操控的四足机器人。包内共38个文件,约66.39MB,包含13个Python脚本负责运动控制与算法实现&…

📰

开源AI助理OpenClaw接入飞书:从零搭建省下年费几百元

前阵子我把开源助理项目OpenClaw接到了飞书上,团队群里原来按月付费的那个外挂机器人当天就退役了。算下来一年省了六百多,而且接的是自己的模型,数据也不会经过第三方平台,体验反而比之前更顺手。这篇文章就是把整个安装和对接过…

📰

AI写作工具怎么选?从产品思路到真实体验的千笔AI评测

今年年初那阵子,我把市面上能叫得上名字的AI写作工具挨个试了一遍,最后长期留在工作流里的,是千笔AI。不是因为闲,而是当时我的状态真的很狼狈——公众号一周要出四篇原创,短视频脚本还得另算,每次坐到电脑…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬