我从去年开始把 Hermes Agent 从能聊天的Demo往能干活、记得住、接得上外部系统的方向重构。改到第三轮的时候我意识到真正决定一个Agent能不能在生产环境站稳的不是某个花哨的提示词技巧而是四件基础能力会话、工具、MCP、记忆。这篇想把这四块能力的实现思路、关键代码逻辑和踩过的坑完整说一遍。先给结论会话是底盘工具是手MCP是标准插座记忆是硬盘。四者缺一个Agent都会在日常使用中露出疲态。这四件事不是并列关系而是层层依赖。会话负责让Agent和用户之间有一条连续的交谈链路工具负责让Agent真正操作外部系统MCP负责把外部系统本身变成统一的可插拔协议记忆负责让这一切不是每次从零开始。我见过很多项目只做了调大模型API 勉强凑一个工具循环看起来跑通了一遇到真实业务就碎——因为会话上下文乱了、工具参数没法校验、外部系统接一个写一套代码、用户下次进来它什么都不记得。这篇文章不讲理论全部来自我实际改造 Hermes Agent 的过程涉及的模块、配置、代码都是可直接抄走的。如果你正在做自己的Agent项目或者打算给现有机器人加上工具与记忆能力这篇会比较对路。1. 整体设计先把四种能力摆进一张图里1.1 为什么必须是这四件事很多人在设计Agent时喜欢先想智能再想接口顺序反了。我一开始也这样先把大模型接进来连续对话测试通过就开始兴奋然后撞上第一个真实需求用户说帮我查一下上周的会议记录然后发摘要到团队群里。这个需求同时涉及多轮上下文理解、文档检索工具、第三方系统对接、以及记住用户偏好四个环节。任何一个环节缺失整个请求都会失败。所以我把Hermes Agent的核心能力固定为四层会话层负责多轮对话状态、上下文窗口、会话隔离与持久化。工具层负责把模型想调用的函数变成系统真实执行的动作核心是函数注册、参数校验、执行生命周期管理。MCP层负责统一接入外部数据源与系统能力避免每接一个系统就写一套新协议。记忆层负责把历史对话中的高频信息、用户偏好、长期事实进行抽取、存储与召回。这四层在代码里各自是独立模块但运行时必须共享同一个请求上下文。我用的方式是定义一个RequestContext对象从会话恢复开始一直传递到记忆更新结束所有模块都从里面读取request_id、user_id、session_id。这是最容易做对也最容易忽略的一步后面排查问题也会靠这个链路ID。1.2 一次任务请求的完整数据流我把一次典型请求拆成八个阶段写在这方便后面展开请求进入网关用session_id拉取最近N轮会话记录。会话模块把历史消息与当前消息拼装成模型上下文同时做token预算控制。模型基于系统提示词和工具描述决定是否需要调用工具。如果需要则根据模型输出的结构化参数经过校验后调用对应工具工具可以是本地函数也可以是通过MCP连接的远程服务。工具执行结果作为新消息回填给模型模型生成最终回复。回复生成后记忆模块抽取值得记住的信息写入长期记忆。会话历史持久化包括本轮输入、中间工具调用与最终回复。清理请求上下文释放资源。这个链路看起来简单但每一步都有坑。比如阶段2如果直接暴力截断早期消息用户提到我刚才说过的事就会失忆阶段4如果参数校验不严可能把时间范围这种字符串类型传成数字类型导致下游服务报错阶段6如果每条对话都写记忆向量库里全是噪声召回质量直接崩掉。我会在下面各小节把这些坑逐个说清楚。2. 会话能力Agent 的聊天底盘2.1 会话隔离与状态管理怎么做才不串线会话模块最基础也最容易做错的是会话边界。一个Agent往往同时服务多个用户、多个会话如果session_id设计不严格就出现A用户的历史消息被B用户看到的事故。我早期用UUID直接生成session_id看似挺随机但有一个隐患用户在同一个设备上长时间反复进入同一主题会积累出上千轮历史而后台无法区分同一个人和同一个会话。我的做法是引入两层概念user_id稳定用户标识用于记忆层聚合。session_id单次会话标识用于对话状态隔离每个新话题或新时间段可以生成新的session。会话本身可以带元数据比如topic标签、开始时间、最近的最后活跃时间。Hermes Agent的会话数据结构是这样设计的dataclass class SessionState: session_id: str user_id: str title: str created_at: datetime None updated_at: datetime None message_count: int 0 meta_data: dict None这里最关键的是message_count。我会在每次追加消息时自增用于后面做会话截断和摘要触发判断而不是每次实时统计历史条数。原因很简单在长会话上万条消息时实时统计会消耗大量数据库查询而自增计数在写入时同步完成成本几乎为零。会话隔离的测试我也踩过坑用并发压测模拟20个用户同时对话时发现偶尔有人读到了别人的历史。根因不是session_id绑定错误而是我用了一个全局字典存放会话对象没有加锁。后来全部改到Redis每个session_id作为key的维度才从根上解决。结论是会话状态不要放到内存进程内Agent服务一旦多实例部署内存方案必出问题。2.2 上下文窗口的预算与截断策略上下文窗口是会话能力里最影响体验的部分。大模型的输入长度有限不可能无限塞历史。很多人的第一反应是只保留最近K条但这样会丢掉早期关键信息。Hermes Agent采用预算分配法。我设了一个可配置的总预算比如32K token然后按以下优先级分配系统提示词固定占2K不可压缩。工具描述占2K到4K缓存计算结果每次请求不重复计算。当前用户消息占1K到2K不可截断。历史消息剩余的预算全部给历史消息。预留容差预留10%给模型输出和token计数的误差。历史消息部分不是简单地从最近往旧截断。我用的策略是滑动窗口 定期摘要压缩最近20轮完整保留20轮之前每轮消息做摘要压缩成短描述更早的摘要合并成一条浓缩要点。def build_context(session_history, budget): system_prompt load_system_prompt() tool_docs load_tool_docs() current_msg session_history[-1] history session_history[:-1] remaining_budget budget - count_tokens(system_prompt) - count_tokens(tool_docs) - count_tokens(current_msg) messages [] # 最近20轮原样保留 recent history[-20:] messages.extend(recent) remaining_budget - count_tokens(recent) # 更早的走摘要压缩 older history[:-20] if older and remaining_budget 0: messages.append(compress_to_summary(older)) return messages这里有个容易忽略的点token计算必须与模型的分词器保持一致。我之前用字符数除以3来估算结果偏差很大导致实际请求超过模型上限而报错。后来直接调用分词器API来计数速度慢一点但准确性是安全基础。2.3 会话持久化与恢复会话持久化不只是存消息文本。我在Hermes Agent中把会话存储分为两层消息流水表按session_id存储每一条原始消息包括role、内容、时间戳、工具调用记录。会话快照表存储最近一次完整上下文、token预算、压缩摘要用于快速恢复。恢复逻辑是这样的新请求进来先尝试读快照如果快照存在且浏览器/客户端传来的last_message_id一致说明没有并发漏消息直接基于快照追加新消息即可。如果不一致则回退到消息流水表重建完整历史。我在生产环境遇到过并发写导致的快照与流水不一致某用户开了两个标签页同时操作同一个session快照被旧请求覆盖而流水里出现了两条新消息。排查后发现快照更新用了直接覆盖而不是版本递增。改成乐观锁快照带version字段写入时version必须大于当前快照version否则拒绝写入并触发重建问题就消失了。做Agent的会话层务必把并发写考虑进去用户在真实环境不会按顺序点。3. 工具调用让 Agent 长出手3.1 工具注册与参数 Schema 设计工具层负责把模型输出的结构化指令变成真实动作。模型本身不执行代码它只是输出我想调用什么工具、传什么参数。所以工具层必须做到工具清单对模型可见、模型的输出能被严格校验、执行结果能被格式化为模型能理解的文本。Hermes Agent里每个工具是一个标准类dataclass class ToolSpec: name: str description: str parameters_schema: dict # JSON Schema handler: Callable timeout_seconds: int 10 auth_required: bool False注册时用一个Registry统一管理registry ToolRegistry() registry.register(ToolSpec( namesearch_docs, description按关键词在内部文档库中搜索返回相关文档列表, parameters_schema{ type: object, properties: { query: {type: string, description: 搜索关键词}, limit: {type: integer, default: 5, minimum: 1, maximum: 20} }, required: [query] }, handlersearch_docs_handler ))参数Schema是整个工具调用的核心。我一开始写得很随意description只有查询参数四个字结果模型经常猜错字段含义。后来我把每条字段的description写得极其具体比如limit字段写明最多返回多少条结果默认5最大20模型出错的概率下降了一半以上。这说明工具描述不是写给用户看的是写给LLM看的它就是你给一个语言模型写的API文档要尽量把边界、格式、默认值写清楚。3.2 工具调用生命周期与错误处理工具调用不能只写一个handler就完事。我把它拆成六个环节每一个都值得单独对待参数校验严格按JSON Schema校验类型不匹配直接拒绝而不是尝试强转。权限校验检查该用户是否有调用此工具的权限。超时控制每个工具单独设置超时时间避免一个慢接口拖死整个Agent。重试策略对临时性错误做一次重试对业务性错误直接返回给模型。结果格式化把工具返回的结果整理成简洁文本控制长度。审计日志完整记录谁在何时调用了什么工具、传了什么参数、结果如何。我记得有一次线上问题就是因为漏掉超时控制。某个外部接口异常挂起模型等着工具结果工具等待网络响应整个会话卡了整整30秒。后来我在ToolSpec里给每个工具都加上了timeout_seconds并在执行器外层设置了总超时保护超时后立刻把错误信息返回给模型让模型自行判断下一步而不是无限等待。错误处理还有一个关键点工具执行失败的信息一定要原样回传给模型。比如查询失败连接数据库超时模型可以据此回答用户系统暂时查询不了你可以稍后再试。如果你吞掉错误、返回内部错误模型就会开始一本正经的胡说八道编造一个根本不存在的结果。3.3 工具权限与安全边界工具能访问外部系统就意味着Agent拥有了手。这双手如果没边界会出大问题。我做的工具有三种安全措施工具维度白名单每个用户角色可见的工具集合不同普通用户看不到管理类工具。参数敏感校验对涉及删除、修改、转账等敏感操作的参数二次人工确认或要求模型输出一个风险提示。执行沙箱本地文件操作、命令执行类工具一律在容器或子进程中运行禁止直接访问宿主机。记忆深刻的教训是搜索文档工具因为没有做路径限制模型有一天真的去读了系统配置文件的路径。排查之后我所有文件访问类工具都加了目录白名单传入绝对路径时强制转换成以某个根目录结尾的相对路径才彻底堵住。工具层越是灵活越要靠硬约束来兜底。4. MCP 能力统一接入外部系统的标准插座4.1 为什么放弃自研工具协议而选择 MCP在引入MCP之前Hermes Agent的每个外部系统对接都是一套自定义逻辑对接内部搜索服务要写一个HTTP封装对接数据库要写一个SQL工具对接某协作平台又写一套Webhook工具。每接一个系统代码量增加错误处理还不统一。MCPModel Context Protocol解决的核心问题是标准化。它定义了一套客户端与服务器之间通信的协议Agent作为MCP Client外部系统通过MCP Server暴露能力。我接入一个系统的成本从写几十行适配代码降到配置一段JSON告诉Agent去哪找一个MCP Server然后它就能发现这个Server提供哪些工具、资源、提示词。这一点我的理解是原来Agent和外部系统的关系是人肉对接现在变成协议握手。就好比你买家电以前每个品牌插头都不一样现在统一了接口标准插上就能用。4.2 MCP 的三种原语Tool、Resource、PromptMCP协议不只是工具调用协议它一共定义了三种原语我在实际使用中全都用到了Tool可执行的动作。Agent发现工具、按Schema传参并调用和本地工具类似只是执行函数跑在MCP Server端。Resource可读取的上下文数据。比如某个数据库的Schema描述、某个目录的文档列表、某个系统的配置信息。Agent可以在需要时读取这些资源来辅助决策。Prompt预定义的提示模板。MCP Server可以暴露一系列提示模板Agent按模板格式补全参数就能直接使用省去每次临时构造提示词的工作量。举个例子我接了一个文档问答服务它通过Resource暴露当前知识库目录通过Tool暴露文档检索和文档摘要通过Prompt暴露以何种格式回答用户的技术问题。Agent启动时先读取Resource了解有哪些文档源需要查资料时调用Tool回答时套用Prompt模板。三者配合体验比单一工具化接入完整得多。4.3 从零接入一个 MCP 服务器的实操记录接入MCP Server的步骤可以分为四步。第一步是启动一个MCP Server进程或远程服务第二步是在Agent配置里声明这个Server的地址与Transport第三步是让Agent读取该Server的能力清单第四步是把Server暴露的工具当作普通工具列表传给模型。我先用一个最简单的文件系统MCP Server来跑通流程。配置文件{ mcpServers: { fs-server: { command: python, args: [-m, mcp_server_fs, --root, /tmp/agent-workspace], transport: stdio } } }transport用stdio是指MCP Server和Agent在同一台机器上通过标准输入输出通信。远程场景则用HTTP transport写法是{ mcpServers: { business-api: { url: http://127.0.0.1:8765/mcp, transport: http } } }接入后Agent侧代码做的事情是发现工具列表mcp_sessions {} for name, config in mcp_config[mcpServers].items(): client await create_mcp_session(config) tools await client.list_tools() for tool in tools: registry.register(ToolSpec( namef{name}_{tool.name}, descriptionf[via MCP {name}] {tool.description}, parameters_schematool.inputSchema, handlermake_mcp_tool_handler(client, tool.name), timeout_secondsconfig.get(timeout, 30) ))这里有一个重要的细节MCP工具名我加了Server名前缀例如fs_server_read_file避免多个Server暴露同名工具时冲突。模型看到工具名时也能自然识别这个工具来自哪个系统。实际接入最大的坑是MCP Server的工具返回值可能非常长。文档检索服务经常返回大段文本直接回填给模型会浪费token并导致模型上下文爆炸。我在tool handler外层加了一层结果裁剪超过3000字符的内容截断并自动附加一句结果过长已截断详细内容可在对话中追问。5. 记忆能力Agent 的长期硬盘5.1 记忆分层工作记忆、短期记忆、长期记忆记忆模块在Hermes Agent里分三层很多人以为记忆就是存到向量数据库这么简单实际不是。工作记忆当前会话内的上下文也就是会话模块管理的那部分消息。作用范围只在本次session内。短期记忆跨会话但仍然要求很新鲜的记忆比如用户上次聊到的主题、最近一次调整的偏好。存储时可以带时间戳召回时做时间衰减。长期记忆用户的稳定画像、长期偏好、重要事实。比如用户偏好简洁回答、用户常用某种语言、用户某个项目的背景信息。我在代码里分别用三个存储工作记忆用内存Redis简单存储。短期记忆用Redis或关系库带TTL。长期记忆用向量数据库按用户ID分桶。5.2 记忆写入什么时候写、写什么、怎么写记忆写入是记忆层最容易出问题的环节。一开始我写的是每轮对话结束后把整段对话内容都丢进记忆库结果向量库迅速膨胀召回时全是无关信息。我需要明确值得记忆的标准。Hermes Agent里设计了两个条件信息密度高包含事实性陈述用户身份、项目名、时间点、明确偏好而不是寒暄和客套。对后续对话有预测价值能预见到未来可能需要用它回答问题或执行动作。实现上我用了一个记忆抽取器基于规则加模型配合def extract_memories(conversation): candidates [] for msg in conversation: if msg.role ! user: continue # 规则1: 包含明确偏好词 if any(kw in msg.content for kw in [我喜欢, 我希望, 请以后, 不要]): candidates.append((preference, msg.content)) # 规则2: 包含具体事实词 if any(kw in msg.content for kw in [我的项目, 公司, 地址, 生日, 账号]): candidates.append((fact, msg.content)) return candidates规则命中之后再让模型把候选句精炼成一条事实/偏好描述存入向量库。比如用户说以后回复尽量短一点别啰嗦抽取结果是用户希望回复简洁避免冗长解释。如果完全依赖模型抽取成本高且不稳定完全依赖规则漏掉很多语义信息。我采用的是规则初筛 模型精炼成本和准确率的平衡在实操中性价比最高。5.3 记忆召回向量检索与相关性重排记忆召回决定了Agent能不能在合适的时机想起来的。Hermes Agent的召回流程分三步从当前对话中提取查询关键词。用embedding模型把关键词向量化在向量库中按余弦相似度召回TopK。对召回结果做时间衰减与相关性重排。时间衰减公式我使用的是def time_decay(score, timestamp, now, half_life_days7): elapsed_days (now - timestamp).days return score * (0.5 ** (elapsed_days / half_life_days))这个公式表示记忆越久远权重越低但不会直接消失。半衰期我在这里设为7天实际使用中发现7天到30天之间表现差异不大关键是别让三个月前的冷记忆和昨天的热记忆同等权重。第一次上线时召回效果很差用户问我之前让你记住的项目截止时间Agent答不出来。排查发现embedding的检索返回了TopK5但其中有三条是和项目时间完全无关的用户喜欢咖啡之类的偏好。原因是单纯向量相似度对短文本不敏感。后来我加入了实体匹配加权如果查询词和高频记忆里出现同一个实体词直接把该记忆排到最前面。召回准确率提升明显。5.4 记忆的更新、遗忘与隐私控制记忆不是写了就永远有效。我需要处理三类问题冲突、过期、删除。冲突用户说了一件事后来自己做了相反的说法。比如用户先喜欢详细报告后来要求极简回复。我的处理方式是写入新记忆时如果检测到同一主题下已有旧记忆标记旧记忆为superseded召回时优先返回最新版本避免Agent按旧偏好行动。过期很多记忆有时效比如项目截止时间过了就失去意义。我在长期记忆的每条记录里设置了expire_at、topic两个字段定时清理过期数据。删除用户有权要求删除记忆。我在记忆模块提供了delete_memories(user_id, topic)接口并且配套了用户说忘记我之前提过的X的对话处理流程让模型识别出这类请求后调用删除工具。这三个问题如果不做记忆越多越乱最后反而会降低用户体验。6. 常见问题与排查技巧实录6.1 会话串扰与上下文污染现象用户A的请求里出现了用户B的偏好或历史数据。常见原因是缓存键设计不当。我遇到过三种情况session_id混用导致历史消息串线。全局变量缓存用户会话对象多进程下互相覆盖。记忆召回未按user_id过滤导致跨用户记忆被检索出来。排查方法在日志里把request_id、session_id、user_id、memory_hits四个维度同时打出来对照一次请求的完整链路。如果发现memory_hits里出现别的用户ID就能立刻定位是记忆库过滤缺失。6.2 工具循环与死循环现象Agent不断调用同一个工具或者两个工具互相调用把请求资源耗尽。我遇到的是搜索-摘要-再搜索死循环Agent搜索A返回结果不够详细又调用搜索工具扩大query结果返回更多内容继续调用摘要工具摘要不够又去搜索……直到步数上限。排查后发现是系统提示词里没有明确工具调用步数限制。我加了两个硬限制单轮请求内最大工具调用次数默认6次。当连续3次调用同一个工具且参数相似时强制终止并让模型直接回答当前情况。同时每次工具返回后我给模型加了一句提示如果当前结果已足够回答问题请直接回复不必继续调用工具。这一句话让无效工具调用次数大幅下降。6.3 MCP 连接超时与工具不可用现象某个MCP Server挂了Agent反复尝试调用它的工具每次都等超时体验极差。我的做法是给MCP工具调用加熔断机制。统计近5分钟内的失败率超过阈值后主动把该Server的所有工具标记为unavailable不再参与模型决策。同时把错误原因写进工具描述让模型明确知道该工具不可用不要调用它避免模型固执地尝试。这个机制上线后一次内部服务抖动用户只感觉到回复变慢了一点点而不是完全卡死。6.4 记忆噪声与幻觉现象Agent记了一堆无关信息回答时反而被带偏甚至回答出用户从来没有说过的事情。我前面提到的规则初筛模型精炼已经过滤掉大量噪声但仍然存在幻觉问题模型在抽取阶段把用户随口一句假设性发言当成事实写入了记忆。解决办法是给抽取器加事实置信度判断。比如在抽取提示词中明确要求不确定是事实还是假设的语句不要抽取。同时记忆写入前做一个最终审核只保留能和用户原话有明确对应的记忆无法回溯源头的一律丢弃。现在我的Hermes Agent里有一条铁律记忆库里的每一条记录都必须带原文引用字段。模型生成回复时如果要引用记忆内容必须能翻译回用户原话否则不引用。这个约束看起来简单却让记忆的可信度上了几个台阶。最后再说一点个人体会做Agent能力这件事最大的敌人不是模型能力不够而是工程细节太松。会话层的边界、工具层的校验、MCP层的协议适配、记忆层的噪声过滤每一个单点都决定最终体验。我踩过最多次的坑是每层都觉得自己做得很完善一联调就看到缝隙。如果你也在做类似项目建议在联调阶段专门准备一套跨层测试用例把用户从提问到记忆召回全链路跑一遍很多诡异问题会自己浮出来。