☰
深入解析Hermes Agent框架:提示组装与缓存机制源码走读
2026/10/20 23:30:37 网站建设 项目流程

1. 从一次“模型答非所问”的排查说起

如果你正在读一个开源Agent框架的源码,大概率已经过了“跑通Demo”的阶段,开始关心一个更底层的问题:我传给模型的那一大段提示词,到底是怎么被拼出来的?为什么有时候模型表现得很聪明,有时候又像失忆了一样?我在第一次读Hermes的消息组装模块时,就踩过一个很典型的坑——明明对话历史里已经包含了关键信息,模型却反复追问同一个问题。排查了半天才发现,问题出在提示组装阶段的截断策略上:历史消息被按字符数硬切,恰好把最关键的那条工具返回结果切掉了。

这件事让我意识到,提示组装(Prompt Assembly)和提示缓存(Prompt Caching)这两个模块,虽然在整个Agent框架里看起来不起眼,但它们直接决定了模型每一轮推理时“看到的世界”是什么样的。Hermes在这块的实现思路相当清晰,代码量不大,但设计上有不少值得细品的地方。这篇就带你把这两个模块的源码走一遍,搞清楚消息是怎么从一堆零散的状态里被拼成最终提示的,缓存又是怎么在保证正确性的前提下省下真金白银的。

适合谁看?如果你已经用过Hermes或者类似的Agent框架,想深入理解它的消息流转机制,或者你正在自己写Agent框架、纠结提示词该怎么组织,这篇应该能给你一些可以直接抄作业的思路。我会尽量把代码走读和设计意图结合起来讲,不只说“它做了什么”,更说“它为什么这么做”。

2. 消息组装前的状态盘点:谁在往提示里塞东西

2.1 系统提示、对话历史、工具结果三股来源

在Hermes的架构里,最终送给模型的消息列表并不是凭空产生的,它由三个主要来源汇聚而成。第一股是系统提示(System Prompt),这部分通常在Agent初始化时就确定下来,包含角色设定、行为规范、可用工具列表等相对静态的内容。第二股是对话历史(Conversation History),也就是用户和助手之间一来一回的消息记录,这部分是动态增长的。第三股是工具调用结果(Tool Results),当模型决定调用某个工具时,工具执行完的返回值需要以特定格式追加到消息列表里,让模型在下一轮能看到执行结果。

这三股来源在代码里分别对应不同的数据结构。系统提示一般是一个字符串或者消息对象,对话历史是一个按时间顺序排列的消息数组,工具结果则往往以特殊的消息角色(比如tool或function)插入。理解这一点很关键,因为后面所有的组装逻辑,本质上就是在处理这三股来源的合并、排序和裁剪。

我刚开始读的时候有个疑问:为什么不直接把所有东西拼成一个大字符串?答案在于结构化消息比纯文本更利于模型理解角色边界。每条消息带上role字段(system、user、assistant、tool),模型在训练时就见过这种格式,能更准确地判断“谁在说话”。Hermes坚持用结构化消息列表而不是字符串拼接,这个选择在后续做缓存和截断时也带来了不少便利。

2.2 消息对象的字段设计与角色约定

Hermes里的消息对象字段设计得比较克制,核心就是role、content,再加上工具调用场景下的tool_calls和tool_call_id。role的取值遵循主流对话模型的约定:system用于系统提示,user用于用户输入,assistant用于模型回复,tool用于工具执行结果。这个约定不是随便定的,它直接对应模型API的输入格式要求。

这里有个容易忽略的细节:工具结果消息必须带上tool_call_id,用来和之前助手发出的tool_calls里的某个调用一一对应。如果这个ID对不上,模型就不知道这条结果是在回应哪个工具调用,轻则困惑,重则直接报错。我在调试时就遇到过因为ID生成逻辑有bug导致工具结果“悬空”的情况,模型的表现是反复重新调用同一个工具,陷入死循环。

另外,content字段在不同角色下的语义也不一样。对于assistant消息,content可能是空的,而实际内容在tool_calls里;对于tool消息,content就是工具返回的原始文本。这种“同字段不同语义”的设计在代码走读时需要特别留意,不然很容易看漏分支。

2.3 组装入口函数的调用链路

从调用链路来看,消息组装通常发生在每次请求模型之前。Hermes里会有一个类似build_messages或prepare_prompt的函数作为入口,它接收当前会话状态,返回一个准备好发送给模型的消息列表。这个函数的调用时机很关键——它必须在工具执行完成之后、模型请求发起之前被调用,否则工具结果就来不及进入提示。

我梳理了一下大致的调用顺序:用户输入触发一轮对话,框架先把用户消息追加到历史,然后调用组装函数生成提示,发给模型;模型返回工具调用请求,框架执行工具,把结果追加到历史,再次调用组装函数,再次请求模型。这个循环会一直持续到模型不再请求工具、直接给出最终回复为止。所以组装函数在一轮完整对话里可能被调用多次,每次的输入状态都不同。

理解这个链路之后,再看组装函数内部的逻辑就顺了:它要做的就是“把当前所有该给模型看的东西,按正确顺序和格式整理出来”。听起来简单,但“该给模型看的东西”这个判断本身就包含了不少策略,比如历史要不要截断、系统提示要不要动态调整、工具结果要不要做摘要,这些都会在后面的章节展开。

3. 提示组装的完整代码走读

3.1 系统提示的构建:静态模板与动态注入的边界

系统提示的构建是组装流程的第一步。Hermes的做法是把系统提示分成两部分:静态模板和动态注入。静态模板就是那段基本不变的角色设定文字,比如“你是一个乐于助人的助手,可以使用以下工具……”;动态注入则包括当前可用的工具列表、当前时间、用户偏好等会变化的信息。

为什么要做这个区分?因为静态部分可以预先编译好、甚至缓存起来,而动态部分每次都要重新生成。如果全部混在一起每次重新拼字符串,虽然功能上没问题,但会带来不必要的开销,而且在做提示缓存时也不利于命中。Hermes把工具列表这类“半静态”内容单独处理,工具定义没变时就直接复用之前的字符串,变了才重新序列化。

这里有个实操中值得注意的点:工具列表的序列化顺序要稳定。如果每次生成的工具描述顺序都不一样,即使内容相同,也会导致缓存失效。Hermes在生成工具描述时会对工具按名称排序,确保同样的工具集合总是产出同样的字符串。这个细节看起来微不足道,但在高频调用场景下对缓存命中率的影响相当可观。

我在自己的项目里就吃过这个亏:工具是从一个字典里遍历出来的,而字典的遍历顺序在某些语言版本里不保证稳定,结果就是缓存时灵时不灵。后来改成显式排序才解决。所以如果你也在做类似的事情,务必保证序列化输出的确定性。

3.2 对话历史的裁剪策略:按轮次还是按Token

对话历史是组装里最“重”的部分,也是最需要策略的地方。模型有上下文窗口限制,不可能无限往里塞历史,所以必须裁剪。Hermes提供了两种裁剪思路:按轮次裁剪和按Token裁剪。

按轮次裁剪比较粗暴,就是保留最近N轮对话,更早的直接丢掉。实现简单,但问题是每轮的长度可能差异巨大——有的一轮就是一句“你好”,有的一轮可能包含几千字的工具返回结果。按轮次裁剪容易导致要么浪费窗口,要么关键信息被误删。

按Token裁剪更精细,它会估算每条消息的Token数,从最新的消息往前累加,直到接近窗口上限就停止。Hermes默认走的是这条路线,因为它在保证不超限的同时,尽可能多地保留了近期信息。但Token估算本身是个近似值,不同模型的Tokenizer不一样,Hermes用的是一个通用的估算函数,会稍微保守一点,留出安全余量。

提示:裁剪时一定要保证消息的完整性,不能把一条消息切成两半。Hermes的做法是以消息为最小单位进行裁剪,宁可少留一条,也不产生半截消息。

还有一个容易被忽略的边界情况:工具调用和它的结果必须成对保留。如果裁剪时把assistant的tool_calls消息留下了,却把对应的tool结果消息裁掉了,模型就会看到一个“悬空”的工具调用,行为会变得很奇怪。Hermes在裁剪逻辑里专门处理了这种情况,会检查消息之间的依赖关系,确保成对的消息要么都留、要么都删。这个处理在代码里体现为一个后置校验步骤,裁剪完再扫一遍,发现不成对就继续往前删。

3.3 工具结果的格式化:原始文本还是结构化包装

工具执行完返回的结果,并不是原封不动塞进消息列表就完事了。Hermes在这里做了一层格式化,把工具返回的原始数据包装成模型更容易理解的形式。比如一个搜索工具返回的是JSON数组,Hermes会把它转成带编号的文本列表,而不是直接把JSON字符串丢给模型。

为什么要多这一步?因为模型对结构化程度适中的文本理解得更好。纯JSON虽然信息完整,但模型解析起来容易出错,尤其是嵌套层级深的时候。转成自然语言风格的列表,模型能更准确地提取关键信息。当然这个转换要适度,不能过度简化导致信息丢失。Hermes的策略是保留所有字段,只是改变呈现形式。

另一个细节是工具结果的截断。有些工具会返回超长文本,比如读取一个大文件的内容。如果直接塞进去,可能一下子就把上下文窗口占满了。Hermes会对工具结果做长度检查,超过阈值就截断,并在末尾加上“内容已截断”的提示。这个阈值是可以配置的,默认值设得比较保守。我在实际使用中会把这个阈值调大一些,因为很多场景下工具返回的完整内容对后续推理很关键,截断反而导致模型反复调用同一个工具去取剩余部分。

3.4 消息顺序的最终校验:几个必须成对出现的约束

组装完消息列表后,Hermes会做一次最终校验,确保消息顺序和配对关系都正确。这个校验主要检查几件事:第一条消息是不是system(如果有的话),tool消息前面是不是紧跟着对应的assistant工具调用消息,有没有连续出现两条同角色的消息(某些模型API不允许)。

连续同角色消息这个问题值得展开说。有些模型API要求消息角色必须交替出现,不能连续两条都是user或都是assistant。但实际对话中,用户可能连续发两条消息,或者框架可能连续追加两条工具结果。Hermes的处理方式是在组装阶段做合并,把连续的同角色消息合并成一条,内容用换行拼接。这样既满足了API要求,又不会丢失信息。

我在读这段代码时觉得这个设计挺巧妙的:它没有在消息产生时就强制交替,而是在组装时统一处理,这样上层的逻辑可以更自由地追加消息,不用时刻操心角色交替的问题。这种“把约束集中到一处处理”的思路,在框架设计里很值得借鉴。

4. 提示缓存机制的设计与实现

4.1 缓存的是什么:前缀复用与增量计算

提示缓存的核心思想是前缀复用。在多轮对话中,每一轮的消息列表都是上一轮的基础上追加新消息,前面大部分内容是不变的。如果每次请求都把完整提示重新计算一遍,既浪费计算资源,也浪费网络传输。缓存机制就是把这部分不变的前缀缓存起来,只计算和传输变化的部分。

Hermes的缓存粒度是消息列表的前缀。它会记录上一次请求时的消息列表,当新请求的消息列表是旧列表的“扩展”(即旧列表是新列表的前缀)时,就可以复用缓存。判断前缀关系的方法很简单:逐条比较消息内容,直到发现不一致为止,一致的部分就是可复用的前缀。

这里的关键在于消息内容的比较必须是深比较,不能只比引用。因为消息对象可能在传递过程中被重新创建,引用不同但内容相同的情况很常见。Hermes实现了一个消息等价判断函数,逐字段比较role、content、tool_calls等,确保内容一致才算命中。

4.2 缓存键的生成:哪些字段参与哈希

缓存键的生成直接决定了缓存能否正确命中。Hermes把参与哈希的字段限定在影响模型输出的那些字段上,具体包括每条消息的role和content,以及工具调用相关的tool_calls和tool_call_id。像消息的时间戳、内部ID这类不影响模型理解的字段,是不参与哈希的。

这个选择很有讲究。如果把时间戳也算进去,那每次请求的缓存键都不一样,缓存永远命中不了。反过来,如果漏掉了某个影响输出的字段,又可能导致缓存错误命中,模型看到的是过时的提示。Hermes在字段选择上做了仔细的权衡,只保留语义相关字段。

哈希算法本身用的是常见的摘要算法,输出一个固定长度的字符串作为缓存键。这个键会作为索引去查找之前缓存的KV状态。如果找到,就直接复用;找不到,就重新计算并存入缓存。整个流程在代码里就是一个“查表-命中则复用-未命中则计算并写入”的标准模式。

4.3 缓存失效的触发条件:什么时候必须重算

缓存失效的判断是缓存机制里最需要小心的地方。Hermes在以下几种情况会强制缓存失效、重新计算:系统提示发生变化、工具列表发生变化、消息前缀不再匹配、缓存条目过期。

系统提示和工具列表的变化比较好理解,它们变了意味着模型看到的基础环境变了,缓存自然不能复用。消息前缀不匹配则是更常见的情况,比如用户编辑了历史消息,或者框架做了历史裁剪导致前缀被破坏。缓存条目过期是出于内存管理的考虑,长时间不用的缓存会被清理掉,避免占用过多资源。

我在实际使用中遇到过一次缓存“假命中”的问题:系统提示里包含当前时间,而时间精确到秒,导致每次请求系统提示都不同,缓存从来没命中过。后来把时间精度降到分钟,命中率才上来。这个坑提醒我,任何进入系统提示的动态内容都要考虑它对缓存的影响,精度过高、变化过频的字段最好单独处理或者干脆不放进系统提示。

4.4 缓存与正确性的权衡:省Token不能省掉关键信息

缓存带来的最大诱惑是省Token,但省Token不能以牺牲正确性为代价。Hermes在设计上把正确性放在第一位:宁可缓存不命中、多花点Token,也不能让模型看到错误的提示。所以在缓存命中判断上,它做得比较保守,只要有一点不确定就重新计算。

这种保守策略在大多数场景下是合理的,因为缓存错误导致的模型行为异常,排查起来非常困难——你很难意识到模型表现不好是因为它看到的提示是过时的。相比之下,缓存不命中只是多花点钱,问题直观得多。所以如果你在自己实现缓存,我的建议是先保证正确,再优化命中率,不要为了追求高命中率而放松命中条件。

另外,缓存的有效性还依赖于消息列表的稳定性。如果框架在每轮都重新生成系统提示、重新排序工具列表,那缓存基本形同虚设。Hermes通过前面提到的“静态模板+稳定序列化”来保证这一点,这也是为什么组装阶段的确定性对缓存如此重要。

5. 组装与缓存的联动:一次完整请求的生命周期

5.1 从用户输入到模型请求的完整时序

把组装和缓存放在一起看,一次完整请求的时序大致是这样的:用户输入到达,框架把用户消息追加到会话历史;调用组装函数,生成当前的消息列表;用消息列表生成缓存键,查询缓存;如果命中,复用缓存的KV状态,只把新增部分发给模型;如果未命中,把完整消息列表发给模型,同时把这次的状态写入缓存;模型返回结果,框架处理结果,可能触发工具调用,然后进入下一轮循环。

这个时序里,组装和缓存是紧密耦合的:组装产出的消息列表既是发给模型的输入,也是缓存键的来源。所以组装逻辑的任何变化都会影响缓存。这也是为什么Hermes把这两个模块放在相近的代码位置,方便一起维护。

我在读代码时注意到一个细节:缓存查询发生在组装之后、请求之前,而不是在组装过程中。这个顺序保证了缓存键总是基于最终的消息列表生成,不会因为组装中途的状态变化而产生不一致。如果把缓存查询提前到组装中途,就可能出现“缓存键基于半成品消息列表”的问题,导致命中判断错误。

5.2 工具调用轮次中的缓存复用特点

工具调用轮次是缓存复用最有价值的场景。因为工具调用往往需要多轮往返,每一轮都在上一轮基础上追加消息,前缀复用率很高。Hermes在这块的缓存命中率通常不错,尤其是工具结果比较长的时候,复用前缀能省下大量Token。

但工具调用轮次也有它的特殊性:工具结果消息的tool_call_id必须和之前的调用匹配,这意味着缓存键里必须包含这个ID。如果ID生成逻辑不稳定,每次生成的ID不同,缓存就无法命中。Hermes的ID生成用的是确定性的方法,基于调用序号和工具名称生成,保证同样的调用序列产出同样的ID。

另一个特点是工具调用轮次的消息增长模式比较规律:一般是assistant(带工具调用)→tool(结果)→assistant(带工具调用)→tool(结果)这样的交替。这种规律性让前缀匹配变得容易,只要前面的轮次没变,后面的追加就能复用。我在自己的项目里也利用了这一点,把工具调用的历史单独维护,确保前缀稳定。

5.3 多轮对话中缓存命中率的实测观察

我在一个中等规模的项目里对缓存命中率做过一段时间的观察,场景是客服对话机器人,平均每轮对话涉及2到3次工具调用。实测下来,首轮对话的缓存命中率是0(因为没有任何前缀可复用),从第二轮开始命中率明显上升,稳定在70%到85%之间。命中率没到更高,主要原因是系统提示里包含了一些会变化的上下文信息,以及偶尔的历史裁剪破坏了前缀。

这个观察让我意识到,缓存命中率的天花板往往不在缓存机制本身,而在组装逻辑的稳定性。如果组装阶段引入了太多动态内容,缓存再优化也白搭。后来我把系统提示里的动态部分抽出来,放到单独的消息里,让系统提示本身保持稳定,命中率就上了一个台阶。这个经验分享出来,希望对正在调优缓存的朋友有参考价值。

6. 自己动手复现时的几个关键决策点

6.1 消息列表用可变还是不可变结构

在复现这套机制时,第一个要做的决策是消息列表用什么数据结构。可变列表(比如Python的list)操作方便,追加、删除都很直接;不可变结构(比如元组或者持久化数据结构)则在缓存和并发场景下更安全,因为不用担心被意外修改。

Hermes内部用的是可变列表,但在传给缓存模块之前会做一次拷贝,避免缓存持有的引用被后续修改影响。这个做法兼顾了操作便利和安全性。如果你追求更高的并发安全性,可以考虑用不可变结构,每次追加都返回新列表,代价是内存开销稍大。我的建议是先用可变列表加拷贝的方式,简单直接,等遇到性能瓶颈再考虑优化。

6.2 Token估算的精度与性能取舍

Token估算的精度直接影响裁剪的准确性和缓存的命中判断。精确的Token计算需要加载对应模型的Tokenizer,开销不小;粗略估算(比如按字符数除以某个系数)则快但不够准。Hermes用的是折中方案:一个轻量的估算函数,对不同语言用不同的系数,中文按字符数算,英文按空格分词算,整体误差控制在可接受范围内。

这个取舍的关键在于你更怕超限还是更怕浪费。如果估算偏大,会过早裁剪,浪费窗口;如果估算偏小,可能超限导致请求失败。Hermes选择稍微偏大一点,宁可浪费一点窗口也不冒超限的风险。我在自己的项目里也是这个策略,因为请求失败重试的成本远高于浪费一点窗口。

6.3 缓存存储选内存还是持久化

缓存存哪里也是个决策点。内存缓存最快,但进程重启就没了;持久化缓存(比如存到本地文件或数据库)能跨重启复用,但读写有开销。Hermes默认用内存缓存,因为Agent会话通常是短生命周期的,进程重启后旧缓存的价值也不大。

但如果你的场景是长会话、频繁重启,持久化缓存就有意义了。我见过一个场景是把缓存存到本地键值库,进程重启后还能复用之前的KV状态,省下了重新计算的开销。不过持久化缓存要注意失效策略,不然会读到过期的状态。总的来说,先上内存缓存,有明确需求再考虑持久化,不要一上来就搞复杂。

6.4 与模型API的缓存机制如何配合

现在不少模型API自身也提供了提示缓存功能,服务端会缓存你发过的前缀。这和框架层的缓存是互补关系:框架层缓存省的是本地计算和网络传输,API层缓存省的是服务端的计算。两者配合好了,效果是叠加的。

配合的关键是保持前缀稳定,因为API层的缓存也是基于前缀匹配的。如果你的框架层缓存做得好,前缀稳定,API层缓存也容易命中。反过来,如果框架层每次组装都产生不同的前缀,API层缓存也帮不上忙。所以无论用不用框架层缓存,保持组装输出的确定性都是值得的。Hermes在这点上做得不错,它的组装逻辑本身就追求确定性,天然对API层缓存友好。

7. 走读之后的一些个人体会

把Hermes的提示组装和缓存模块读完,最大的感受是:这两个模块的复杂度不在于算法,而在于对各种边界情况的处理。前缀匹配、消息配对、角色交替、Token估算,每一个单独看都不难,但组合在一起,再加上缓存正确性的要求,就需要相当细致的代码来保证不出错。

另一个体会是,确定性是这一切的基础。无论是缓存命中还是调试复现,都依赖于同样的输入产生同样的输出。Hermes在工具列表排序、ID生成、消息序列化等环节都刻意保证了确定性,这些细节单独看很不起眼,但正是它们让缓存机制能够可靠工作。如果你在实现类似功能,建议把“确定性”作为一个明确的设计目标,而不是事后补救。

最后分享一个调试小技巧:当你怀疑缓存导致模型行为异常时,可以临时禁用缓存,对比两次请求的输出。如果禁用后行为正常,那问题大概率在缓存命中判断或缓存键生成上。Hermes的代码里缓存开关是一个配置项,调试时关掉很方便。这个技巧帮我定位过好几次“看起来像模型问题、实际是缓存问题”的故障,省了不少排查时间。

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

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

立即咨询