做Agent框架这些年,我有一个越来越强烈的感觉:模型本身的能力曲线已经拉不开差距了,真正让一个Agent“好用”还是“难用”的,是围绕模型外围那一圈工程能力。Hermes Agent 这个项目就是在这种背景下从零搭起来的。它不是什么全新的理论体系,而是一个把会话、工具、MCP、记忆四件事拧在一起的工程集合体。
这四件事,我从第一天就开始研究。会话能力负责接住用户的每一句话,让多轮对话不“断片”;工具能力让模型不只会说还能动手操作外部系统;MCP把工具接入这件事从“万国码”变成标准接口;记忆则让Agent从“每次都是陌生人”进化为“越聊越懂你”。这篇就按照我的实际搭建顺序,把这四块能力的职责、设计与踩坑记录完整拆开。适合正在做Agent框架、做智能客服、做个人助理类项目,或者准备把一堆工具接入LLM的开发者参考。强烈建议按顺序读,因为这些能力是互相咬合的。
1. Agent的底座:四件事为什么缺一不可
先说结论:一个成熟的Agent,不是“把模型API包一层”就完事了。模型只负责文字生成,而Agent要负责的是——在正确的时间、用正确的工具、调取正确的记忆、生成正确的回复。这四件事拆分下来,正好对应会话、工具、MCP、记忆。
1.1 没有工具,模型只是纸上谈兵
只靠模型自己,它无法查实时天气、无法读写数据库、无法操作第三方系统。它的所有知识都停在训练数据那一时刻。工具调用的本质是给模型“外挂手脚”:你说一句话,模型决定调用哪个函数,系统真正执行函数,再把结果回传给模型。没有这层闭环,Agent就只是个高级聊天机器人。
我在Hermes Agent里最早实现的其实就是工具调用。当时用得非常朴素:定义好函数的名称、描述和参数格式,塞进系统提示词里,让模型输出JSON形式的调用请求,然后解析执行。后来用上了正式的Function Calling机制,但这套思路至今没变。
1.2 没有MCP,工具接入就是“万国码”
随着工具越来越多,问题来了:每个外部系统都有自己的API格式、鉴权方式、数据返回结构。每接一个工具就要写一套适配逻辑,Agent核心代码里堆满了if else。这就好比插座和插头互相不兼容。MCP出现以后,整个局面改变了:工具提供方把能力包装成标准协议,Agent这边只需要一个通用客户端,就能对接任意MCP Server。工具接入从“开发”变成了“配置”。
1.3 没有记忆,对话永远是“陌生人”
记忆能力解决的是另一个维度的痛点。没有记忆的Agent,每轮对话都是清空重来,用户上一轮说过“我住在上海,通勤靠地铁”,下一轮Agent就完全不记得。这种体验放在聊天里勉强能忍,放在AI助手、AI客服场景里就是灾难。记忆分层的价值在于:短期记忆承载当前任务,长期记忆承载用户的偏好和历史事实,核心档案则记录重要的画像信息。
1.4 会话是贯穿以上一切的通道
会话能力不能单独看。它既是用户与Agent的交互通道,也是工具调用和记忆召回的上下文容器。一个会话里发生了多少次工具调用、每个工具的结果是什么、用户当前关注点在哪里,这些都要被记录下来。会话管理做得不好,工具调用、MCP、记忆全部会跟着出问题。因此Hermes Agent的整体架构里,会话层是绝对的基础服务,其他模块都依赖它提供上下文。
2. 会话能力:上下文是Agent的隐形骨架
会话能力看似简单——只要把消息传进模型、把模型回复传出来就行。但真正做深之后,你会发现它要处理的问题非常多:状态存哪里、上下文太长怎么截断、多个会话之间怎么隔离、Agent内部多个子任务之间怎么切换上下文。
2.1 多轮会话的状态到底存在哪里
我的第一版实现里,会话状态就是内存里的一个字典:session_id对应一个消息列表。本地跑通没问题,一旦部署成服务就露馅——进程一重启,用户会话全丢。后来老老实实做了会话存储层,把状态抽出来。
一个标准会话记录我建议至少包含这些字段:
| 字段 | 说明 |
|---|---|
| session_id | 会话唯一ID,建议UUID |
| user_id | 关联用户,用于权限和记忆归属 |
| messages | 消息列表,含消息角色、内容、时间戳 |
| tool_calls | 当前轮次涉及的工具调用记录 |
| memory_refs | 该会话引用过的记忆ID列表 |
| meta | 扩展字段,存会话标签、状态机变量等 |
存储选型上,Redis是首选,因为会话读写频繁且有自动过期能力。我给每个会话设置了TTL,比如30分钟不活跃就清理,避免会话池无限膨胀。需要注意的是,TTL不能设太短,因为用户可能隔几分钟回来看之前的进度。
会话状态一定要和记忆分开。会话是会过期的临时上下文,记忆是要长期沉淀的资产。把两者混在一起,后期清理成本和误召回风险都很高。
2.2 上下文窗口不够用怎么办
这是绕不开的硬问题。你的业务逻辑、工具描述、历史对话都要占token,而窗口是有限的。捏在一段长对话里,系统提示词加历史消息很可能直接溢出。
我试过几种方案,效果从低到高排序:
- 暴力截断:只保留最近N条消息。简单但会丢失早期关键信息,用户两轮前提过的需求可能就被截掉了。
- 关键消息保留:给消息打重要性标签。用户明确表达过诉求的消息标为高优先级,闲聊内容优先级低。截断时优先丢弃低优先级消息。
- 滚动摘要:积累到一定轮次后,调用模型把之前的对话压缩成一段摘要,塞回上下文里。这是我最推荐的做法。摘要本身不完美,但比丢信息强太多。
- 结构化精简:系统提示词里的工具描述、业务规则,尽量用精炼表达。中文写工具描述时,一句话说清楚“你什么时候该用这个工具”,比长篇大论有效。
预估值上,我通常按中文一个字约1.5~2个token、英文一个词约1.3个token来粗算。不是绝对准确,但足以做截断预判。
2.3 会话隔离与多Agent会话路由
单个Agent场景下,会话管理没那么复杂。真正麻烦的是Agent内部还存在“多角色”或“子代理”分工。我在一个项目里做了“主控Agent + 多个专业Agent”的架构:主控负责理解用户意图,判断该把请求转给哪个专业Agent。这时每个子Agent自己也有上下文需求。如果共用一个会话消息列表,各Agent的消息互相穿插,模型很容易混淆。
我的做法是:会话层支持“子上下文”概念。每个子Agent拥有自己的消息窗口,主控窗口只保留路由决策和最终输出。子窗口的上下文独立维护,可以单独截断和处理摘要。会话ID统一,但用source字段区分上下文来源。这招实测很稳,尤其是做客服场景时,售后和技术支持两个子窗口互不干扰。
3. 工具能力:从Function Calling到工具编排
工具调用是整个Agent的“手脚”。但我发现,很多人第一次接Function Calling时都过于乐观——觉得把函数定义给模型,它就会按预期调用。实际情况是:模型会选错工具、填错参数、在不需要工具的时候强行调用。工具能力这层,一定要做足工程防护。
3.1 工具注册与Schema设计的实战细节
工具注册的输入是一份类JSON Schema的定义。核心四项:
- 函数名:一定要语义清晰且唯一。别用do_thing1这种命名。模型是在字符串层面理解函数名的,一个叫query_weather_by_city的函数,比叫func_101240的函数容易被正确选中得多。
- 描述:重点写“什么场景下用这个函数”,以及“用这个函数前需要什么前提”。例如“当用户询问某个城市的天气时使用本函数,仅在用户提供了城市名称时才可调用,城市名称未知时应先向用户确认。”
- 参数Schema:每个参数都要写description,能加enum就加enum,能用format限定就用format限定。给模型越少的自由发挥空间,参数就越可控。
- 返回值描述:让模型提前知道自己会拿到什么结构的数据,分支判断时才不会瞎猜。
代码层面我是这样组织的:
def register_tool_func_registry(): registry = {} def decorator(func, name=None, description="", parameters=None): registry[name or func.__name__] = { "handler": func, "description": description, "parameters": parameters, "execute_count": 0 } return func return decorator这是基础框架,实际生产还会给每个工具加权限标识、超时时间、是否需要人工确认等元数据。
3.2 参数校验与错误处理:不要信模型的参数
这是全篇最重要的一个提醒:模型返回的function call参数,一定要当成“用户输入”来校验,绝不能直接透传给底层API。模型可能构造出符合格式但语义错误的参数,比如把日期格式写成“2025/1/3”而不是“2025-01-03”,把城市名写成“上海市市辖区”这种数据库里查不到的值。
我在Hermes Agent里加了一层统一校验器。做法很简单:注册工具时同时指定一个Pydantic模型,调用前先用它做类型校验和枚举校验,不通过就返回错误信息给模型,让它重新生成参数。这就是“模型自纠错”的闭环。
异常处理同样重要。工具执行可能抛异常,可能超时,可能返回空数据。我总结了一个规则:工具的错误信息必须结构化回传给模型,让模型理解发生了什么,以便换一种方式处理。例如:
{ "tool_name": "query_weather_by_city", "error": "city_not_found", "message": "系统中未找到城市[上海市市辖区],请尝试输入标准城市名,如上海" }这种信息回传后,模型会自己反思并修正参数,一多半的参数错误都能被它自己解决。
3.3 工具编排:多工具组合与无限循环止损
业务里很少有单个工具能解决的事。比如用户要“查上海明天的天气,如果晴天就帮我订一间外滩附近的酒店”。这至少涉及天气查询、地点搜索、酒店预订三个工具。我采用“计划-执行-观察”的循环来编排:
- 模型先生成一个计划,列出需要的工具和调用顺序。
- Agent按计划逐个调用工具,每调完一步都把结果反馈给模型。
- 模型结合结果调整后续步骤。
- 循环直到任务完成或触发终止条件。
实际操作中最大的坑是模型陷入死循环:同一个工具反复调用,或者A工具和B工具互相兜圈。不设置止损机制,一次任务可能烧掉大量token还拿不到结果。Hermes Agent的处理方式相当简单粗暴却很有效:
- 设定max_iterations,默认5步,复杂任务最多10步;
- 同一个流程内,完全相同的工具调用参数只允许出现3次,第三次直接终止;
- 若连续多次工具调用失败,中断并主动向用户坦白:“当前需要的信息暂时无法获取”。
止损机制必须放在Agent框架内部,不能指望模型自己判断“我该停下来了”,模型在这件事上并不可靠。
4. MCP:工具接入的标准化协议
如果说工具能力是“Agent的手”,那MCP就是“手的USB接口”。没有MCP之前,接一个外部系统就要写一套专门的Adapter。有了MCP后,只要对端提供了MCP Server,Agent侧用同一个客户端就能接通。我在Hermes Agent里落地MCP时,核心改动量比想象中少,因为大部分复杂度被协议本身消化了。
4.1 MCP解决了什么
跨系统工具接入的痛点,本质上是一个多对多适配问题。有N个Agent框架、M个外部系统,传统做法是每个框架都要为每个系统写适配代码,复杂度是N×M。MCP采用了Client-Server架构,统一了工具发现、调用、鉴权和数据传输方式,复杂度变成N+M。
放到Hermes Agent的具体场景:内部有天气服务、订单系统、知识库、日历系统,每个服务都可以独立部署成一个MCP Server。Agent这边只维护一个MCP Client Manager,运行时动态加载配置好的服务端。
传输层方面,本地工具用stdio(通过子进程启动),远程服务用Streamable HTTP(早期是SSE)。我的经验是:需要低延迟、高频繁调用的工具,优先用stdio;跨网络、需要独立部署的工具,用HTTP方式更灵活。
4.2 MCP三大原语:Resources、Tools、Prompts
MCP协议围绕三个原语展开,它们分别解决不同的问题。放在表格里看最清楚:
| 原语 | 作用 | 对应场景 |
|---|---|---|
| Resources | 提供可读取的数据资源,类似“文件” | 读取数据库Schema、读取文档、读取配置文件 |
| Tools | 提供可执行的函数,类似“API” | 创建订单、发送消息、执行查询 |
| Prompts | 提供标准化的提示词模板,类似“配方” | 预置一套Prompt,让Agent按照规范处理某类任务 |
我在初学MCP时,常常混淆Resources和Tools。后来我自己定义一个判断标准:模型拿到一个东西时,“只是看看”的属于Resources,“执行后有副作用或者需要外部系统配合动作”的属于Tools。比如读天气API的缓存数据是Resources,但调用天气服务刷新一次实时数据就是Tools。判断清楚才能让模型做出正确的选择。
4.3 Hermes Agent里如何落地MCP
落地配置我做成了一份JSON文件驱动的架构。不需要改代码,只需要在配置文件里声明要启用哪些MCP Server:
{ "mcp_servers": [ { "name": "weather_server", "transport": "stdio", "command": "python", "args": ["-m", "weather_mcp_server"], "tools": ["get_weather", "get_forecast"] }, { "name": "knowledge_base", "transport": "http", "url": "https://internal.example.com/mcp", "headers": { "Authorization": "Bearer ${KNOWLEDGE_BASE_TOKEN}" } } ] }启动时,MCP Client Manager会逐个建立连接,拉取服务端的工具列表,合并到工具注册表里。这里有个容易踩的坑:多个MCP Server可能暴露同名工具。我在合并工具注册表时加了命名空间机制,比如weather_server.get_weather和knowledge_base.get_weather可以共存,模型侧通过带前缀的完整名称来调用。
鉴权方面,MCP协议本身就带OAuth成熟的流程,但企业内部使用很多是自定义Header加Token。这份Token建议放在环境变量或密钥管理系统里,不要落到配置文件里入库,否则密钥就等同于代码泄露了。
5. 记忆能力:短期、长期与语义检索
记忆是让Agent真正“越用越顺手”的关键能力,同时也是最容易翻车的地方。记忆设计不好,要么什么都记不住,要么什么都记导致上下文污染严重。Hermes Agent的记忆体系我分为三层:工作记忆、长期记忆、核心档案。
5.1 记忆分层的设计思路
工作记忆:当前会话内产生的临时信息,比如用户这次打开网页准备预订酒店,正在选房型。工作记忆跟随会话层存储,会话过期即销毁。它对应的是人类的“短期记忆”。
长期记忆:跨会话沉淀的重要事实和偏好,如“用户偏好高层无烟房”“用户上一次预订时间是去年春节”。长期记忆使用向量化存进向量数据库,通过语义检索召回。它对应“长期记忆”。
核心档案:经过提炼、更新频率较低的高置信度用户画像。例如“用户是商务出行为主,公司政策要求酒店价格不超过500元/晚”。核心档案一般用结构化字段存储,与向量库解耦,查询走精确匹配。
三层的写入路径完全不同。工作记忆不用特判,每个会话消息都会进上下文;长期记忆需要显式提取——我会在每轮对话结束后后台跑一个“记忆提取摘要”,让模型判断“这轮对话里有哪些值得以后记住的信息”;核心档案的变更则需要较高置信度门槛,通常需要用户明确确认或多次行为一致后才写入。
5.2 向量检索与记忆召回:不能把向量库当聊天记录
记忆召回最容易犯的错误,是把用户历史聊天记录全部向量化存入库中,然后在每轮对话开始前做全文检索,把Top3碎片直接塞进prompt。实际效果是灾难——模型会从各种历史碎片里东拼西凑,得出不靠谱的结论。
我调整后的召回策略是这样的:
- 先意图判断,再记忆召回。每轮对话开始,先判断“当前任务是否需要历史信息”。比如用户问“这周末上海天气怎么样”,不需要任何历史偏好,直接跳过记忆召回。只有用户明确提到“按我上次的口味”“我还是想要靠窗的位置”等指向历史信息的表达,才触发召回。
- 召回结果必须经过重排。向量检索Top20候选,再用一个轻量级重排器,结合当前会话主题做排序,最终取3~5条注入上下文。
- 每条被注入的记忆必须带来源信息。比如“来源:用户2024年3月2日对话”,让模型在不确定的时候可以追问,而不是盲目采信。
我用过一个生活化类比:向量检索像是图书馆管理员帮你找到了一堆可能相关的书,重排是快速翻书确认哪些页面真的有用,注入Prompt则是把有用的内容抄在纸条上递进考场。跳步就等着出低级错误。
5.3 写入、更新与遗忘策略
写入什么内容,比怎么写入更重要。Hermes Agent的记忆提取摘要只关注三类信息:
- 用户明确表达的事实和偏好(“我家里人开新能源车”“我每个月底出差”);
- 与当前任务强相关的决策依据(“上次选的是离会议地点步行10分钟内的酒店”);
- 用户表达过强情绪或强否定态度的内容(“我不喜欢高分贝环境”)。
更新方面遇到最多的问题是旧记忆与新记忆冲突。比如用户说“我家的车卖了,以后通勤坐地铁”,系统需要把旧的“偏好自驾”标记为过期,而不仅是追加新记录。我只信任带时间戳的记忆结构。每条记忆有created_at、updated_at、expired_at三个字段,冲突时按时间戳和置信度综合裁决。
遗忘策略我也很看重。长期记忆如果没有遗忘机制,向量库里很快就会积累大量垃圾数据,既浪费存储又干扰检索。我做了两级遗忘:TTL软过期,超过一定时间的记忆先标记为“候选遗忘”;定期做一次重要性衰减评分,低价值的记忆直接物理删除或归档。
5.4 实操中的记忆安全问题
记忆能力做得越好,隐私责任越重。用户在对话里说的私密信息,可能被模型提取并长期存储。这个问题必须在架构层面就考虑,不能事后补救。
我的几个基本配置:
- 记忆写入前需要脱敏。手机号、身份证、住址等敏感信息,默认不进入长期记忆层,需要时只存脱敏后的哈希值。
- 记忆访问需要权限控制。同一个人在不同业务线之间的记忆不互通,避免跨场景信息泄露。比如客服Agent和生产研发Agent之间,记忆存储彻底隔离。
- 提供“记忆遗忘”出口。用户提出删除记忆请求时,要能精确定位其所有记忆条目并清除。所以每一条记忆必须带user_id标签,这个话题在功能上线前就得定好。
记忆安全做不好,Agent能力再强也不敢放出去给真实用户。在这件事上,我宁可功能少一点,也要把边界焊死。
6. 常见问题与排查实录
最后这一部分,把我在开发Hermes Agent过程中踩过的几个典型问题整理成速查。这些问题比较隐蔽,网上资料也少,我尽量把当时的排查思路交代清楚。
6.1 会话上下文莫名其妙丢失
现象:用户聊了几轮后,Agent突然像失忆了一样,重复问已经提供过的信息。
排查思路:第一步查会话存储是否还在。Redis key是否过期?我当时遇到的是测试环境没有给会话设置TTL,但因为使用了本地内存Store,服务一次重启就全部清空。第二步查截断逻辑,看是不是历史消息被截断了。我的经验是,消息列表的长度限制一旦设得太低,会话会在用户完全没察觉的情况下悄悄“断片”。解决办法:不要直接限制消息条数,按token总量来控制,并给关键消息打不可截断标记。
6.2 工具调用参数错位
现象:模型调用了工具,但参数明显不符合业务规格,比如把金额单位“分”传成了“元”。
排查思路:先看工具描述里是否明确写了单位要求。很多时候模型传错参数,是因为工具描述里压根没提单位。补充描述后错误率大幅下降。再检查参数校验规则是否太宽松。我建议对关键数值字段一律加范围校验,比如金额必须大于0、日期必须匹配ISO标准格式。校验失败后,确保错误信息回传给模型,让它二次修正。
6.3 MCP服务连接不稳
现象:Agent有时能调用工具,有时提示MCP服务不可用,重启后恢复正常。
排查思路:stdio传输方式下最常见的原因是子进程意外退出。MCP Server本身没做异常捕获或崩溃保护,Agent这边也没有做重连。Hermes Agent解决方案:在MCP客户端里加了心跳检测,如果连续三次心跳无响应,触发重连;子进程模式下,还要监听退出码,异常退出时自动拉起新进程。远程HTTP服务则不稳定往往出在鉴权Token过期,排查时先看日志里有没有401错误。
6.4 记忆污染与误召回
现象:Agent在某个任务中突然引用一段完全无关的旧记忆,回答变得奇怪。
排查思路:正常流程是“意图判断-召回-重排”,问题多半出在前面两步。打开记忆召回日志,看是否在无关意图下做了召回。我遇到过的是“查天气”场景竟然召回了几条历史酒店偏好,原因是我当时给“天气类意图”也配置了通用召回策略。修复办法:给每个意图定义独立的召回范围和召回字段。最后检查重排逻辑,确认重排分数最高的记忆与当前会话主题的语义相似度是否超过阈值,低于则不下发。
做一个通用的排查清单:
| 问题 | 第一检查点 | 根治方案 |
|---|---|---|
| 会话失忆 | 会话过期时间、截断逻辑 | Redis持久化+按token截断+关键消息保护 |
| 参数错位 | 工具描述与校验规则 | 描述补全+Pydantic强校验+错误回传 |
| MCP连接异常 | 子进程退出码、Token过期 | 心跳检测+自动重连+密钥管理 |
| 记忆误召回 | 意图判断与召回范围 | 分意图召回+重排阈值+带来源信息 |
这四类问题,我基本每周都会在社区里看到有人再踩一遍。提前做防护,比事后修命中率高得多。
最后分享一个实操心得
做Hermes Agent到现在,我最大的体会是:单独把会话、工具、MCP、记忆某一项做好,都只能算完成了20%。真正让你觉得“这个Agent能用了”的瞬间,是四者开始协同的那一刻——会话里的上下文承载了工具调用的结果,工具返回的数据经过提炼写入了长期记忆,MCP让我毫不费力地接入第三个、第四个外部系统,记忆又把每一轮对话串成持续进化的脉络。
开发这个项目的过程中,我把很多周末都花在排查那些“看起来像玄学”的问题上。翻到最后,几乎所有问题的根因都很基础:一个没设好的TTL,一段写得不准确的工具描述,一次忘记处理的分页。所以我也建议正在做Agent框架的朋友,遇到诡异问题先不要怀疑框架本身,先检查底座配置,多半是某个基础环节的细节没有压实。