1. 项目缘起:一个 Agent 框架的四根支柱
先交代一件事:我最初想做这个 Agent 框架(后面就叫 Hermes Agent)时,其实并没有想到它会自然长成“会话、工具、MCP、记忆”这四个模块的形态。更多是从实际跑任务时被逼出来的。用传统写脚本的方式做一个自动化助手,最头疼的就是状态接不上、外部系统接不进去、上下文一长就丢记忆、换个服务又要重新写适配代码。这些问题单拎出来都不算无解,但搅在一起的时候,整体复杂度会呈指数级上升。
所以我在设计 Hermes Agent 时定了一个基础原则——把能力切成四个互不咬合的平面:会话(Session)负责状态流转和前后文承接,工具(Tool)负责可执行动作,MCP(Model Context Protocol)负责标准化接入外部能力,记忆(Memory)负责跨会话的数据沉淀与检索。四者之间只通过明确定义的接口通信,互不注入内部逻辑。
这么做对从业者最直接的好处就是:你不会因为换了一个对话模型、或者新增一个外部服务,就要把整个 Agent 的逻辑推倒重来。你可以在不改动框架主体的情况下,单独升级某一个平面。
这篇内容我打算从四个平面分别拆开讲,讲清楚它们各自承担什么、接口怎么设计、实现时有哪些坑。最后用一个完整的任务流转案例把它们串起来,再整理一份我之前调试时遇到的高频问题速查表。无论你是打算自研 Agent,还是只是想给自己现有的业务系统加一层智能调度,都有参考价值。
2. 会话层:Agent 的“状态容器”与“上下文管线”
2.1 会话状态建模的思路
会话是所有 Agent 能力的载体,不管是用户闲聊、执行某个 API 调用,还是跑完一个多步骤任务,都要落在某个会话里。我的第一版实现把会话设计得特别重:里面塞了用户信息、日志列表、临时变量、任务队列、结果缓存……结果做下来一个明显感受是,会话对象太重,几乎每次调接口都要做字段拷贝,测试时也分不清哪个状态是当前轮次引入的、哪个是历史遗留的。
后面我狠心做了一次简化。Hermes Agent 里每个会话只保留三类数据:
- 会话元信息:session_id、创建时间、来源渠道、模型配置、当前状态(active / paused / closed)。
- 上下文窗口状态:当前窗口内的消息列表、Token 估算值、压缩标记位。
- 临时变量区:仅存在于当前会话生命周期内的变量,比如某次搜索的临时结果、用户在某一步选择的选项。
这个设计的直接好处是状态流转路径变得非常清晰。任何一步失败了,我可以从“当前会话状态 + 上下文历史”重建现场,而不是去翻一堆混杂的日志字段。
2.2 上下文管理:窗口策略与压缩取舍
上下文窗口是所有会话模块里最容易让开发者翻车的地方。很多人直接一次性把全部历史消息塞给大模型,结果 Token 超限、模型开始乱答、接口报错。我自己的做法是三层策略:
- 滑动窗口:当前时刻只保留最近 N 轮对话原文,例如最近 20 轮,超出部分进入“待压缩区”。
- 摘要压缩:待压缩区的消息交给模型生成一段摘要,占据一个固定 Token 预算,例如 800 个 Token 左右。
- 记忆外置:如果摘要仍然太长,就把更早的信息写入记忆层,靠检索而不是靠“全文携带”。
这里有一个参数我建议你做足实验再定:窗口轮数 N。给一个参考值:如果任务是日常问答、工具调用,N 取 10 到 20 轮很稳;如果任务偏向长文档分析、代码仓库理解,滑动窗口反而不如“先检索后拼装”的方式,因为前面几轮的历史对当前任务几乎没有帮助,只会浪费 Token。我最初把 N 设成 50,结果发现模型经常被旧上下文带节奏,降回 16 之后问题少了很多。
顺带提一个细节:Token 估算不要用字符数去代替。中文字符、代码片段、Markdown 表格对应的 Token 数差异很大。如果不用 BPE 分词器估算,至少也要按“中文 1.5 Token/字、英文 0.3 Token/字”的系数做粗算,否则预算会偏得离谱。
2.3 多轮对话中的关键控制点
多轮场景里,我踩过最明显的坑是会话注入——不是安全方向的注入,而是上一轮上下文里的临时信息污染了下一轮的工具决策。比如用户第一轮问“帮我查一下天气”,第二轮问“那上海呢?”如果会话层直接把“天气”“上海”两组关键词都传给工具选择器,模型很可能把第一轮的工具也重新触发一遍。我的处理是:每一轮开始时,系统会把上一轮的“环境快照”清理一遍,只保留当前意图相关的上下文片段,再让模型重新决策。
另一个控制点是会话心跳与超时。Agent 任务经常不是一次请求就能完成的,异步管道里可能同时跑着多个子任务。Hermes Agent 里会为每个会话维护一个心跳时间戳,超过预定时间(比如 10 分钟无操作)就会冻结,避免用户挂机后任务还在空转消耗资源。
3. 工具层:让 Agent 从“会说”到“会做”
3.1 工具注册与描述:一切皆 Schema
Agent 的工具层本质上是“把外部能力描述成模型能理解的语言”。这句话的核心落点是工具描述的结构化。我在 Hermes Agent 里要求所有工具必须通过一个注册表注册,注册时附带一段 JSON Schema。这个 Schema 不只是参数说明,还会包含:
- 工具名称与用途简介(尽量在 50 字内说清做什么、何时用)。
- 输入参数的字段名、类型、必填与否、取值范围。
- 输出结果的格式说明。
- 一个或两个典型调用示例。
工具描述的质量,直接决定了模型选工具的正确率。我做过一次对比实验,把“获取用户下单记录”这个工具的描述从 30 个字扩写成包含“必须传 user_id,不传则自动取当前会话用户;结果按时间倒序返回”的详细版本之后,同样的任务准确率从 61% 提升到了 84%。
3.2 工具调用链设计与权限边界
一次真实任务经常需要多个工具配合,比如“查订单”要先用“用户识别工具”拿到身份,再调“订单系统工具”,最后可能还要用“通知工具”发结果。Hermes Agent 里我允许在工具 Schema 中声明next_possible_tools字段,表示这个工具执行完后通常需要衔接哪些工具。这个字段不是硬性约束,而是给模型一个搜索方向的先验,能让它少走弯路。
权限边界这里必须单独强调:工具层永远只认“最小权限原则”。每个工具在注册时就绑定了一组执行策略,比如“只读类工具可以自动执行”“写操作必须在回复中显式说明并征得确认”“涉及删除的操作一律加二次审批”。框架里有一个统一执行器,所有工具调用都经过它,不会让模型直接去拿内部连接串。
我在实际运行中反复确认过,权限控制这一步不能省,哪怕你只是内部用。模型幻觉一旦出现,没有权限墙的工具层就会变成捅娄子的出口。
3.3 工具执行的超时、重试与错误透出
工具调用不是永远成功的。外部 API 可能超时,可能返回格式异常,可能权限过期。我的统一执行器里做了三个机制:
- 超时分级:普通查询工具默认 15 秒,涉及外部 HTTP 调用的工具 30 秒,批量写操作 60 秒。
- 重试策略:对幂等操作自动重试 2 次,指数退避;非幂等操作绝不自动重试,只会返回“需要人工处理”状态。
- 错误标准化:所有工具的返回都用统一结构
tool_response(code, message, data)。code 为 0 表示成功,非 0 表示失败的具体分类,例如 1 为参数错误、2 为依赖服务不可用、3 为权限不足。
这套机制的好处是可观测性上来了。一段对话里哪个工具失败了、为什么失败,日志里一眼就能看到,而不是像早期版本那样错误信息五花八门,排查半天找不到根因。
4. 会话层:状态、上下文与多轮对话的骨架
这个章节我本来想放在最前面写,但后来想想,真正理解会话层价值的场景,还是要从整体框架的使用者视角切入。所以在这里把会话层的实现细节补完整。
4.1 session_id 与状态存储设计
会话层的第一件事是为每一次交互生成一个全局唯一的session_id。这个 ID 不只是普通随机串,它在 Hermes Agent 里承担了路由职能:后续所有工具调用、MCP 请求、记忆读写,都会把这个 ID 带过去,作为追踪和回溯的依据。
状态存储我选择了“Redis + SQLite 双写”的方式。Redis 用来存热状态,读写快、支持过期时间,适合会话进行中的高频访问;SQLite 用来做持久化归档,保存会话结束后的完整记录,方便后面做数据分析和记忆离线构建。
冷热分离的道理不复杂,但我发现很多开发者会忽略一个细节:状态项的序列化格式必须统一。在 Hermes Agent 里,所有状态字段都要求是 JSON 可序列化的基本类型,不允许直接在状态里塞自定义对象。因为跨语言的场景越来越多,一旦状态里混入某个特定语言的对象,后续做迁移和审计会头疼到不行。
4.2 多轮上下文管理
多轮上下文是会话层最难做优雅的部分。如果把每一轮模型输入都重新拼一次完整历史,成本高不说,还容易超过上下文上限。Hermes Agent 的做法是“按需构造上下文视图”:系统在每轮开始前会走一个管道,依次判断要携带哪些内容。
- 最近 2 轮对话原文:保证当前语义连贯。
- 与前文相关的摘要块:如果上一轮已经在运行某个长任务,就取出该任务的摘要。
- 当前活动工具的结果:如果模型这轮需要继续某个工具操作,就把该工具最近一次返回结果再放进去。
- 记忆层检索结果:按当前意图去记忆库做相似度查询,最多带 3 条相关记忆。
这个“拼接上下文”的管道写清楚之后,很多奇怪的问题都能从源头排查。例如之前遇到“模型答非所问”,一查上下文视图,发现是旧摘要块没有被替换,还是上一周的总结内容,自然会把话题带偏。
4.3 会话中的状态持久化与恢复
会话不是永远存活的。进程重启、容器迁移、超时回收,都可能让一个进行到一半的任务突然中断。Hermes Agent 为此设计了一个恢复机制:每个会话的“检查点”会在关键节点落盘,例如工具执行前、任务子步骤完成时。恢复时,系统从检查点重建上下文,并给模型附加一段说明,告诉它“上次执行到某一步,现在从这里继续”。
检查点不能太频繁,否则写入开销会拖垮系统。我测试下来,一个任务的关键节点大概是 3 到 5 个,只在节点处落一个轻量的 JSON 快照就够了,不需要做数据库级别的事务同步。
5. MCP 层:统一接入外部能力的“万能插座”
5.1 MCP 解决了什么问题
在引入 MCP(Model Context Protocol)之前,我接一个外部服务通常要做三件事:写一个自定义 API 调用函数、写一套鉴权逻辑、再针对返回结构写一段解析代码。每接一个新系统,这套流程就要重来一遍。如果外部系统换了接口版本,又要全部跟进改一遍。这其实就是标准的“适配器地狱”。
MCP 做的实际上是把“外部能力”抽象成一组标准化的元语。它定义了模型与外部能力之间的会话协议——如何发现能力、如何描述能力、如何请求执行。Hermes Agent 接入 MCP 之后,新增一个数据源或工具服务的成本从“几天”降到“几十分钟”,差别就在于不再需要手写适配层。
5.2 MCP Server 的注册与能力发现
在 Hermes Agent 里,每一个外部系统会被封装为一个 MCP Server,Server 内部可以暴露多种能力类型。我选用的协议的核心流程是:
- 初始化:Client 与 Server 建立连接,交换协议版本和能力清单。
- 能力发现:Client 获取 Server 支持的资源列表、工具列表、提示模板列表。
- 调用:Client 按 Schema 调用某个工具,并接收标准化结果。
我在实际接入时是把 MCP Server 做成独立进程的,通过 stdio 或 HTTP 与主框架通信。这样就避免了某个外部服务挂掉导致整个 Agent 崩溃的情况。主框架侧只需要保存一份 Server 注册表,记录各自的名称、地址、鉴权配置,启动时逐个做健康检查。
5.3 MCP 与工具层的关系
这里有一个容易混淆的问题:MCP 和工具层是什么关系?工具层是“Agent 内部可执行动作”的统一入口,MCP 是“外部能力接入的标准协议”。在 Hermes Agent 中,MCP Server 暴露的每个工具在注册时都会“代理”到内部工具层,也就是说,模型视角看到的仍然是一张工具列表,其中一部分工具的执行体是在远端 MCP Server 上。
这种“内部工具 + 外部 MCP 工具”合并注册的方式,让模型不需要感知工具到底在哪里运行,它只需要根据描述做出选择。这也意味着,工具选择策略只需要做一次,无论工具从何而来。
5.4 跨 MCP Server 的场景路由
多个外部系统同时接入之后,路由问题就出现了。比如“查询库存”这个需求,可能是由电商系统的 MCP Server 提供,也可能是由仓储系统的 MCP Server 提供,两个 Server 的词面描述高度相似。Hermes Agent 的处理方式是在工具描述的末尾追加一段“服务边界说明”,例如“本工具属于电商系统,只返回线上可售库存;线下仓库库存请调用仓储系统工具”。加这么一句之后,模型选错 Server 的概率显著下降。
另外,对于确实需要多个 Server 协作的任务,框架里支持在一个任务上下文中维护一个“服务连接池”,按需建立连接,用完释放。我建议不要让 MCP 连接长期保持空闲,尤其是外部系统有连接数配额时,按需连接释放是更稳妥的策略。
6. 记忆层:让 Agent 从“健忘”到“越用越懂你”
6.1 短期记忆与长期记忆的分工
会话层负责的是“当前对话内”的上下文,记忆层解决的是“跨对话、跨任务”的信息沉淀。Hermes Agent 的记忆模块分两层:
- 短期记忆:单次会话中的关键信息,随会话管理,通常在 24 小时后进入待清理状态。
- 长期记忆:经过筛选和加工后的信息,会持久化存储,后续任意会话都可以检索使用。
分层的原因是成本控制。长期记忆如果不做筛选,什么细节都存,索引会膨胀得很快,检索质量也会下降。我的策略是设置一个“记忆写入过滤器”:只有当前对话中被模型标记为“值得记住”的信息,才会进入长期记忆候选区。标记的来源可以是用户指令(“记住我住在杭州”),也可以是模型根据语义自动判断(比如用户连续两轮提及某个偏好)。
6.2 记忆存储与检索:向量化不是银弹
长期记忆我选用的是“向量索引 + 结构化元数据过滤”的组合方式。每条记忆由三个部分组成:
- 向量表示:通过嵌入模型将记忆文本转为向量。
- 结构化元数据:对话源、时间、用户 ID、主题标签、重要程度评分。
- 原始文本片段:用于最终展示和回溯时阅读。
检索时,并不是只做向量相似度检索就够了。Hermes Agent 的检索管道分三步:
- 先按元数据过滤:例如固定用户、固定时间范围、固定主题。
- 再做向量相似度召回 top 20。
- 最后用重排序模型挑选最相关的 3 到 5 条。
这样做能明显提升检索精度。我之前试过单独只用向量检索,结果经常命中一些语义相似但实际无关的记忆片段,比如用户问“帮我找一下之前说过的某个 PDF”,向量检索返回了另一份名字相似的文件,加上元数据过滤后情况好得多。
6.3 记忆的更新、遗忘与冲突处理
记忆不是只写不更新的。用户可能之前说“我喜欢安静的餐厅”,后来又改口“最近想试试热闹的夜市”。如果记忆系统不做更新,检索出来反而会误导 Agent。Hermes Agent 里对于每条记忆设置了一个“置信度”和“更新时间戳”。当新记忆与旧记忆在主题分类上发生冲突时,系统不会直接删除旧记忆,而是降低旧记忆的置信度,并插入新记忆,同时保留两者供模型权衡。
“遗忘”机制我也做成了显式策略:长期记忆超过 180 天没有被检索命中,会进入归档存储,不再参与常规召回。这个时间参数可以根据业务场景调,比如客服系统可能希望保留更久,而个人助手类场景可以缩短。
6.4 记忆写入时机:别把事情都交给模型
最后提醒一个问题:不要指望模型每一次输出结束之后都执行记忆提取。这个操作既耗时又消耗 Token,还容易产生幻觉式记忆。Hermes Agent 的做法是设置一个触发条件:
- 对话达到一定轮数(例如 8 轮);
- 用户明确要求“记住”;
- 任务闭环时(例如完成一次完整查询或生成)。
只有触发条件满足时,系统才会把本轮对话的核心信息送去做记忆提取和写入。这一步做完之后,我会快速做一次抽查,看看记忆库里有没有出现明显重复或矛盾的内容,有的话及时调整过滤阈值。概率不大,但一旦出现,影响是长期的。
7. 一个完整任务流转的实战拆解:从“帮我查上个月的销售数据”说起
7.1 任务初始化的完整流程
到这里可以看到四个模块已经准备就绪,但光有模块还不够,要让它们协同,需要一套编排逻辑。我拿一个典型的业务场景走一遍完整链路,你就能直观看到“会话——工具——MCP——记忆”是怎么配合的。
假设用户向 Hermes Agent 输入:“帮我查一下上个月的销售数据,顺便和上上月做个对比,最后生成一段简短小结。”
请求到达后,会话层先创建一个新会话,分配session_id,初始化上下文窗口。意图识别模块会判断这是一个“销售数据查询 + 对比分析 + 文本生成”的复合任务。
然后记忆层开始工作:系统先在长期记忆里检索“该用户过往是否查询过销售数据”,如果命中,会把历史查询偏好和常用口径附带到当前任务的额外上下文中。这一步就是“让 Agent 越用越懂你”的直观体现——同样的查询,老用户的体验会比新用户明显更顺。
7.2 工具与 MCP 的接力过程
复合任务被拆解后,工具层开始介入。系统向模型展示当前可用的工具列表,其中包括内部工具“获取业务指标”、MCP 代理工具“查询销售明细库”“读取报表服务”。模型根据描述选择了 MCP 代理工具“查询销售明细库”,带上参数“时间范围:上个月”。
此时工具执行器把这个调用转发给对应的 MCP Server,Server 连接销售数据库,返回原始数据。工具层把结果按标准格式封装,写回会话上下文。接着模型继续调用“读取报表服务”获取上上月数据,同样得到标准响应。
随后模型在内部临时变量区完成对比计算。这个计算过程我没有让它调模型,而是让工具层执行一段纯代码逻辑,因为数值比较本身不需要模型参与,还能避免大模型算数出错的老毛病。
最后生成小结这一步,才再次交回模型。模型利用当前上下文中的两组数据 + 会话摘要,生成一段面向决策者的小结。我把这段小结也作为一条“候选长期记忆”写入记忆层,下次用户再查数据时,系统可以直接参考上次的小结风格。
7.3 全链路追踪:从日志到审计
整个过程结束时,会话层会把所有节点记录串成一条跟踪链路。我习惯从入口开始导出每个环节的耗时与 Token 消耗。有一个数据很能说明问题:在这个任务中,实际模型调用只有 3 次,而工具层和 MCP 层完成了大部分计算和数据获取,这比我早期“所有事情都让模型干”的设计节省了大约 70% 的 Token。
全链路日志的字段我会固定为:session_id -> step -> action_type -> provider -> tool_name -> latency -> token_cost -> status。这个结构对复盘特别有用,哪个环节慢、哪个环节费钱、哪个环节失败,一目了然。
8. 常见问题与排查技巧实录
在 Hermes Agent 的迭代过程中,我积累了几个高频问题的排查经验,下面列成速查表,遇到同类问题可以直接照着做。
8.1 会话层:上下文越来越长、响应越来越慢
这类问题的根因一般是两个:滑动窗口没生效,或者摘要压缩没有被正确记录。排查时先看会话状态里的 Token 估算值,如果持续增长,说明压缩点没被触发。其次看摘要压缩的结果是否真正通过“下一次上下文拼装”加载。我遇到过的情况是,压缩摘要生成了,但后续上下文构造管道里忘了把摘要作为候选项,导致窗口越长越像“假压缩”。
还有一个容易被忽略的点:压缩操作本身也会调用模型产生 Token 开销。如果压缩频率太高,优化反而变成浪费。建议压缩频率不要超过每 10 轮一次。
8.2 工具层:合理描述但选错工具
症状是模型拿到正确意图,却选了功能相似但参数不匹配的工具。排查方向先看工具描述是否足够区分。两个工具如果描述里都有“查询订单”,模型区分不了很正常。我给两个工具分别加了“查询消费端订单”和“查询售后工单”的前缀后,误选率直接下降。
再往下就是参数名一致性问题。如果“订单 ID”在这个工具里叫order_id,在另一个工具里叫oid,模型需要额外推理才能搞清映射关系。最好的做法是统一所有工具的参数命名风格。
8.3 MCP 层:连接正常却拿不到数据
这类问题有一个很常见的原因:MCP Server 侧的网络策略。比如 Server 只能访问内网数据库,但请求流量从外部跳转进来被拦了。排查时建议在 Server 侧先做一次手工调用测试,确认 Server 自己能否拿到数据。
还有一个隐蔽问题:MCP Server 暴露了多个版本的能力,但主框架注册表里缓存的是旧能力列表。因此每次 Server 更新后,要强制刷新一次注册表缓存,不要依赖自动同步。
8.4 记忆层:检索结果不相关、信息互相冲突
排查顺序是“先看召回、再看重排、最后看写入”。如果召回阶段就没有命中的记忆,多半是索引没有更新,或者元数据过滤条件设得太严。如果召回命中但重排结果不相关,则可能是重排模型本身对短文本不敏感,换一个更简单的打分规则反而更稳。
信息冲突则要查写入时的去重逻辑。我在早期版本里遇到过同一条心理偏好被写入了 8 条近似记忆,检索时模型直接被搞晕。后来加入了“相似记忆合并”机制,相似度超过阈值的候选记忆不再新增,而是更新原有置信度与时间戳,冲突问题明显减少。
8.5 高频排查参考表
这里给出一张综合排查表,是我在实际项目里沉淀下来的,供你直接参考。
| 现象 | 首选排查点 | 次选排查点 | 兜底方案 |
|---|---|---|---|
| 上下文越来越长,响应变慢 | 滑动窗口轮数是否被覆盖 | 摘要压缩是否真正生效 | 调低窗口轮数,增加压缩频率 |
| 会话恢复后上下文错乱 | 检查点位置是否准确 | 上下文视图是否按序拼装 | 增加节点快照,强制重组上下文 |
| 工具调用报参数错误 | 参数 Schema 是否更新 | 参数命名是否统一 | 加入服务端参数二次校验 |
| 模型选错工具 | 工具描述区分度 | 工具定义边界是否清晰 | 增加典型调用示例,精简描述 |
| MCP 调用超时 | 网络连通性与 Server 状态 | 超时配置是否过短 | 分级超时,重试幂等操作 |
| MCP 返回数据格式异常 | 协议版本一致性 | Server 侧解析逻辑 | 统一错误标准化,记录原始响应 |
| 记忆检索不相关 | 元数据过滤条件 | 向量索引是否过期 | 放宽过滤条件,更新重排策略 |
| 记忆信息冲突 | 写入时相似度阈值 | 新旧记忆时间戳 | 增加合并机制,降低旧记忆置信度 |
| 整体链路耗时长、Token 消耗异常高 | 是不是所有事都走了大模型 | 工具层是否完成了数值计算 | 把确定性计算移到代码逻辑中 |
| 会话已经结束但后台任务还在跑 | 心跳时间戳是否更新 | 任务队列是否有超时回收 | 增加冻结检查,强制终止超时任务 |
9. 再聊一点我的个人体会
把 Hermes Agent 拆成会话、工具、MCP、记忆四层来设计,我最大的体会是:稳定性的提升不是靠某一个模块做得多完美,而是靠四个模块之间的接口边界修得足够干净。早期我总想在会话层里顺带做记忆,在工具层里顺带做会话恢复,结果就是每个模块都臃肿,出了问题要横跨三个模块排查。把边界拆开之后,每个模块的职责单一了,调试效率高了很多。
另一个体会是:方案选型宁可做“简单但能用”的版本,不要一上来就追求大而全。比如记忆层,如果你只有一个轻量级个人助手,完全没有必要先上向量数据库和重排序模型,用 SQLite + 关键词匹配 + 元数据过滤,也能撑起大部分场景。等实际用户量、数据量上来之后,再按需升级不迟。MCP 层也一样,外部服务不多的时候,手写适配器比接协议更省事;但当外部系统数量超过 4 到 5 个,统一走 MCP 的收益就会开始显现。
最后提醒一个容易被忽略的工程细节:无论你最终选择哪套架构,都要在第一天就把“全链路追踪日志”和“错误标准化”落地。我在 Hermes Agent 早期因为日志格式不统一,排查一个记忆检索问题花了整整一个下午。后来把日志统一成结构化的字段风格,同样的排查只要十来分钟。早一点做这件事,后续省下的时间绝对对得起投入。