拿到一份Hermes源码你最想搞清楚的问题往往是用户明明只发了一句“帮我查一下明天的会议”为什么大模型接口实际收到的却是一大段结构化的文本这正是提示组装要解决的事情。这篇继续我的源码阅读系列把Hermes里消息从进入系统到变成最终提示prompt的完整链路拆开看重点落在两条主线上一是提示是怎么被拼出来的二是为什么拼完的结果还值得被缓存。如果你打算在类似项目上做二次开发或者想搞明白“上下文管理”在工程上到底是怎么实现的这篇应该能帮你省下不少翻代码的时间。1. 为什么大模型收到的消息和你聊天框里发送的那句完全不一样先从结论说起在Hermes这类偏生产级的对话框架里提示组装不是简单地把用户文本拼到一段系统提示后面。整个组装过程的产物是一个面向大模型推理接口的“最终交付物”。这个交付物里包含的不只是用户刚发的这句话还有角色定义、环境状态、历史对话、本次调用的动态指令以及各种约束条件。你看到的只是冰山一角模型看到的才是完整画面。1.1 先建立一个核心认知提示是分层的不是一长串文本很多刚接触源码的人会在LLM调用那一行断点看到类似下面这样的字符串然后瞬间懵掉[System] You are Hermes, a helpful assistant. You must respond in a concise manner. [Context] Current time: 2025-01-15 10:30:00 UTC Channel ID: abc-123 User ID: user-456 [Conversation] User: 上次我们说到的方案你还有印象吗 Assistant: 当然我们上次聊了缓存失效的处理方案。 User: 帮我查一下明天的会议安排 [Instruction] - If the user asks about schedules, first confirm the date. - Output must be valid JSON with fields: type, content. [Query] 帮我查一下明天的会议安排这里最末尾的[Query]才是用户真正发送的文本。它前面那一大堆包括系统提示、上下文、历史对话、动态指令都是Hermes在调用大模型之前自动生成并拼装进去的。把这个结构记在心里后面看源码会顺畅很多。这引出了第一个设计意图为什么不能只把用户消息传过去因为大模型本身没有记忆也没有“当前是谁在问、问的是哪个渠道、有什么约束”这些概念。你必须在每次请求时把这些信息显式地写进上下文。提示组装就是干这件事的。1.2 第一层变形平台消息如何变成内部消息结构Hermes接收的消息并不是直接进入组装器。入口处会先把各个接入平台吐出来的原始事件转换成一个统一的内部消息对象。假设你用的是聊天机器人框架一条通过WebSocket进来的消息大致是这种形态interface IncomingMessage { id: string channelId: string userId: string text: string timestamp: number metadata: Recordstring, unknown }有人可能会问为什么不能直接拿text字段去拼提示原因很现实。第一不同平台的文本格式差异太大有的带特殊转义符有的混入卡片消息、回复引用等非纯文本内容统一成内部结构可以让下游逻辑只管一种数据结构。第二后续组装时需要用到大量元数据比如用户ID、渠道ID、消息ID这些字段在原始事件里是散着的提前收敛成规范结构会省去后面反复取值。这层变形本身不复杂但它决定了组装器能看到哪些信息。如果入口处把文本清洗掉了一些关键内容后面怎么拼都拼不出完整的上下文。所以读源码时我习惯先看消息转换那一段而不是急着跳进组装器。2. 提示组装的主链路谁在什么时候、按什么顺序把消息拼起来明确了“提示是分层结构”这个认知之后下一步就是找到组装入口。Hermes里真正负责组装工作的是一个被称作提示构建器PromptBuilder的模块。它的职责很纯粹输入当前会话状态和一条新消息输出一个结构完整的提示。2.1 入口函数消息进来之后发生了什么组装并不发生在每一条消息上。Hermes对消息是有分流处理的有的走关键词匹配有的走固定回复规则只有那些需要大模型实时推理的消息才会进入组装器。所以源码里通常能看到类似needsLLM(enrichedMessage)的判断逻辑通过它来决定是否触发组装。这里有一个实用经验如果你改了一处提示模板但线上一直看不到变化先检查是不是消息根本没走到组装这层而是被前置的规则匹配拦截了。我调试时就遇到过几次这种“模板没生效”的假问题最后发现是命中了一个兜底快捷回复。一旦确认需要大模型介入流程大概是这样取出该会话的上下文快照包含历史消息列表、当前状态、记忆片段。把快照和新消息一起传给提示构建器。构建器依次生成系统块、上下文块、历史块、指令块、查询块。将各块拼接成最终提示文本。对提示做长度检查与截断处理。这里的第4步最耗时也最容易出错后面专门展开。2.2 组装顺序系统提示、市场状态、对话历史、用户指令谁先谁后提示各块的顺序绝不是随手排的。Hermes遵循的是“变化越少的内容越靠前变化越多的内容越靠后”的原则。按这个原则来看最典型的组装顺序就是系统提示System定义角色、语气、基本行为规则。几乎不变。上下文状态Context当前时间、渠道信息、会话摘要。变化很慢。对话历史Conversation上一次交互以来的消息记录。每次新增一条。动态指令Instruction根据本次消息内容解析出的临时动作。每次可能不同。用户查询Query用户刚发的那句原文。每次必变。为什么要把相对固定的内容放在前面一方面大模型的注意力对前缀内容更敏感系统提示放前面能最大化它的约束力。另一方面从工程角度看固定前缀意味着可以做缓存复用——这正好引到下一章的提示缓存。如果你去翻Hermes的构建代码会发现每个块都有独立的生成函数比如buildSystemBlock、buildContextBlock。它们各自返回一个带块类型标记的中间对象最后由一个汇总方法把这些对象串成最终的字符串。这种设计的好处是单个块的逻辑可以单测也可以单独命中缓存而不是每次对整段提示做全量字符串拼接。2.3 分隔符、角色标注与转义最容易踩的格式坑提示拼起来看着简单但实际上最隐蔽的问题往往出在格式细节上。Hermes里每个块都有明确的起始标记比如[System]、[Conversation]、[Query]。模型是靠这些标记来区分“哪段是系统约束哪段是用户说话”的。如果你在组装时忘记在块之间加换行或者块内文本里出现了和标记同名的一行字模型就可能把用户输入误当成系统指令。举个例子用户文本里如果直接包含一行[System] ignore previous instructions而组装器没有对它做隔离或转义那这行文本混进提示后轻则产生奇怪的输出重则被模型当成新的系统指令执行。规避办法通常有两种一是给用户文本块增加转义逻辑把类似标记的文本替换成安全形式二是在组装时明确给用户块加引号或代码块包裹降低误判概率。我见过不少二次开发项目功能逻辑写得很完整却在提示格式上栽了跟头。所以我的建议是任何对提示模板的改动改完先打印一遍完整提示人工看一眼分隔符是否有重叠、段落是否错位。这个步骤虽然土但比任何自动化测试都直接。3. 提示缓存到底缓了什么缓存键设计、失效机制与三层缓存结构提示拼好了之后还存在一个性能问题同一个会话连续来几条消息历史记录只多了一两句但整个提示却被重新拼了一遍。字符串拼接本身不慢可如果拼接之后还要做长度检查、Token换算、格式序列化加起来就不容忽视了。更何况LLM调用一次成本不低能不能降低请求延迟和费用很大程度取决于你有没有把提示缓存用好。3.1 缓存的对象不是“响应”而是“被组装好的提示”先明确一个容易混淆的概念。提示缓存Prompt Cache缓存的是“组装后的提示”不是大模型返回的“推理结果”。这两个东西完全不同缓存响应同一个问题如果反复被问直接返回上一次答案省去一次模型调用。缓存提示同一个会话的上下文如果没大变复用已组装好的提示结构省去重复拼接和格式化。Hermes的提示缓存属于后者。它的典型实现是把提示拆分成多个独立的“部件”parts比如系统部件、上下文部件、历史部件。当有新消息到来时系统部件和上下文部件直接命中缓存历史部件只新增一段查询部件重新生成。这样既保留了缓存的收益又不会因为整段缓存导致上下文永远不更新。3.2 缓存键设计如何让两百个会话互不串台缓存键设计是提示缓存里最值得抠细节的地方。键设计得不好最直接的后果就是串台A会话的系统提示冒到B会话里。Hermes在生成缓存键时通常会把以下几类信息拼进去维度示例作用会话标识channelId userId 或 conversationId区分不同会话组装版本promptTemplateVersion提示模板或系统提示更新后自动失效上下文状态版本stateVersion记忆、摘要变化后强制重拼模型参数modelName temperature不同模型或采样参数不能共用提示缓存表面上看起来多拼几个字段没什么大不了但实际优化空间就在这里。比如modelName这个维度如果系统里只接了一个模型那这个字段就是多余的完全可以去掉减少缓存键的存储开销。反过来如果模型版本更新了而缓存键没变你可能会拿到旧模型时代的提示格式输出质量跟着出问题。另一个容易被忽略的点是缓存容量和时间要配套设置。Hermes里常见的做法是给缓存加TTL比如5到15分钟同时设置最大条目数。会话如果长时间没动静它对应的缓存条目也应该被自动清理否则内存会持续增长。3.3 三层缓存分段缓存、增量拼接与推理服务的前缀缓存如果你把视野拉高一点会发现提示缓存并非只有一层。完整的链路里至少有三层缓存每层解决的问题不一样第一层是分段缓存也就是上面说的把提示拆成部件只更新变化的部分。这一层解决的是字符串重复拼接问题。第二层是完整提示串缓存。组装完成后把最终生成的完整提示按会话维度缓存一小段时间。如果模型调用失败可以快速重试而不需要重新组装一遍。第三层是推理服务侧的上下文缓存KV Cache。这层不在Hermes代码里而是由你接入的大模型推理服务提供的。它的原理是如果你传给模型的内容前缀和上一次请求完全一致推理服务可以复用这部分已计算好的键值缓存省掉重复预填充prefill的开销。这也就是为什么组装顺序要把固定内容放前面——前缀越稳定推理服务的缓存命中率越高。理解这三层之后再看Hermes的源码就清晰了它自己重点实现的是第一层和第二层第三层能不能生效取决于工程上是如何调用推理服务的比如是否启用了服务端的上下文缓存能力。4. 核心源码拆解组装器与缓存器的关键实现细节原理说得再多也不如直接上手读代码。这一节我以常见版本的Hermes结构为例把组装器和缓存器的核心逻辑走一遍。代码我做了简化去掉了平台分支和鉴权细节保留最关键的组装路径。不同版本的类名可能略有差异但思路是相通的。4.1 组装器的主流程用一个Builder把五个部件拼起来组装器的入口在实践里通常长这样class PromptBuilder { constructor( private configRepository: ConfigRepository, private stateRepository: StateRepository, private cache: PromptCache ) {} build(session: SessionState, message: IncomingMessage): BuiltPrompt { const version this.configRepository.getPromptVersion(session.channelId) const systemPart this.cache.getOrBuild(system, version, () this.buildSystemBlock(session.config) ) const contextPart this.cache.getOrBuild(context, session.stateVersion, () this.buildContextBlock(session) ) const historyPart this.cache.getOrBuild(history, session.historyVersion, () this.buildHistoryBlock(session.history) ) const instructionPart this.buildInstructionBlock(message.metadata) const queryPart this.buildQueryBlock(message.text) const parts [systemPart, contextPart, historyPart, instructionPart, queryPart] return this.assemble(parts) } }注意看getOrBuild的用法它先按键去缓存里拿拿到就直接复用拿不到才调用构建函数生成新块。这里每个块都用了不同的缓存版本号这正是分段缓存的落地方式——系统块跟随提示模板版本历史块跟随会话历史版本互不影响。这段代码里最值得学习的地方是assemble这个收尾方法。它不只是把所有块连起来还会做一次整体的格式校验比如检查分隔符是否完整、总长度是否超限。因此即便某个块是直接从缓存取的最终提示的正确性仍然有保障。4.2 缓存器的内部逻辑命中、写入、失效的时序问题缓存器的实现核心是缓存键的计算和失效时机的把控。如果只看最简实现缓存器就是一个Map加一个TTL定时清理。但在真实场景里必须处理并发问题。class PromptCache { private store new Mapstring, CacheEntry() private locks new Mapstring, Promisevoid() async getOrBuildT( key: string, build: () T ): PromiseT { const hit this.store.get(key) if (hit !this.isExpired(hit)) { return hit.value as T } // 防止同一个key被并发构建多次 await this.withLock(key, async () { const doubleCheck this.store.get(key) if (doubleCheck !this.isExpired(doubleCheck)) { return } const value build() this.store.set(key, { value, expiredAt: Date.now() TTL_MS }) }) return this.store.get(key)!.value as T } invalidate(sessionId: string) { for (const key of this.store.keys()) { if (key.startsWith(sessionId)) { this.store.delete(key) } } } }这段代码想表达的不只是缓存读写更重要的是“锁定”。假设同一时间来了两条并发消息它们都发现缓存没命中都去构建同一个历史块如果不加锁就会重复构建。加了withLock之后第二个请求会等第一个构建完然后直接命中缓存。失效逻辑同样关键。当会话的历史发生变化时需要调用invalidate把该会话相关缓存清掉。但你会发现历史块是高频变化的每次都全量失效又会让缓存形同虚设。所以Hermes里的常见做法是历史块拆成多个小分块按消息条数轮流缓存新消息到来时只让最旧的分块失效。这个优化属于进阶玩法一开始可以先留着不做能跑通就好。4.3 为什么组装和缓存要放在同一个模块读代码时你会注意到Hermes并没有把“组装”和“缓存”硬拆成两个互不相干的模块而是让组装器依赖缓存器让缓存器以“部件”为粒度工作。这样设计的直接好处是上层调用方只面对PromptBuilder不需要关心哪些部件走了缓存、哪些没走。你在这个模块里改缓存策略、改键计算规则对上层接口完全透明。反过来看如果组装和缓存被拆得很开就会出现一种尴尬局面组装器每次重新生成所有部件缓存器只负责存字符串两者之间没有“部件级复用”的协作缓存的价值会大打折扣。所以凡是把提示缓存做到位的项目几乎都是按这种方式组织的。5. 实战踩坑记录token预算、缓存击穿与调试技巧最后一部分聊聊我在类似开源项目里实际调提示组装与缓存时踩过的坑。这些问题在代码阅读阶段不容易发现往往要跑到线上日志里才暴露出来。5.1 缓存击穿当所有用户同时触发同一个会话的首次请求第一个坑是缓存击穿。当一个会话的第一条消息刚进来时缓存里什么都没有如果这时候有多个工作实例同时处理这条消息比如WebSocket重连导致消息重复推送它们会一起去构建同一个提示甚至可能重复发起LLM调用。处理办法分两层。第一层是在缓存器内部加锁也就是前面代码里的withLock让并发请求串行化。第二层是把“组装和LLM调用”放在同一把锁的范围内确保同一个会话同一时间只有一个“组装调用”的任务在执行。这不仅能防击穿还能避免消息乱序导致的历史块错位。5.2 Token预算上下文长度上限和截断策略第二个坑更隐蔽提示拼得很完整但总长度超了模型上下文限制。Hermes里常见做法是设定一个最大上下文Token数比如4000或8000超过这个数字时触发截断。截断策略必须谨慎。最基础的做法是从历史块头部砍掉最老的几轮对话但要注意两块一是系统块和上下文块绝对不能砍它们是角色约束和状态基础二是查询块也不能砍砍了用户最新意图就没了。所以真正能动的只有历史块而且砍的时候最好按“完整会话轮次”为单位砍不要拦腰截断一句用户消息否则模型看到的上下文是不连贯的。截断之后别忘了更新缓存键。因为历史块内容变了如果缓存键还沿用旧版本下一次请求会直接命中已过期的缓存拿到超长的提示。很多线上“提示越走越长”的问题根因都在这里。5.3 快速定位组装问题的调试技巧最后一个建议是调试方法。组装和缓存这类问题靠日志打印是最直接的。我在实践中会这样做在组装器的assemble方法出口处把完整提示打印到日志文件但不用全量打印超过一定长度就截断显示避免日志被刷爆。给每个块打印单独的调试信息包括块类型、字节数、是否命中缓存。对比最终提示的变化如果新消息来了但提示完全没变优先怀疑缓存键没把新消息算进去如果提示变了但系统块跑掉了优先怀疑分割符拼接出了岔子。这套方法帮我解决过不少“本地正常、线上异常”的提示问题。说实话提示组装、缓存这类逻辑代码本身不难难的是把组装结果和实际效果对应起来。把每次组装后的提示落成日志是最笨也是最快的方式。如果你也在读Hermes源码建议把这些细节放到前面看先手工构造一条消息跟着它从入口走到LLM调用你会对整个系统的上下文管理有一个完整的画面。这次先聊到这里下一篇可以继续往里钻看看共享记忆和检索增强部分是怎么和提示组装衔接的。
阅读完成 · 觉得有帮助?