智能体框架四基石:会话、工具、MCP与记忆的设计与实践
做智能体框架最绕不开的四个能力就是会话、工具、MCP、记忆。我最近把Hermes Agent内部的这套逻辑重新梳理了一遍把四条主线从代码层面拆开又合上过程中踩了挺多坑也理清了它们各自的边界和配合方式。这篇就围绕这四个能力逐一拆解讲清楚背后为什么这么设计、实际怎么落地、哪些地方容易翻车适合正在做Agent项目、或者准备从对话玩具升级到工具型Agent的开发者参考。先说个总体感受很多人以为Agent就是“大模型 一个可以调用函数的循环”真做起来才发现没那么简单。会话管不好模型一会儿就“失忆”工具管不严模型会乱调MCP不接每对接一个外部系统就要写一套定制代码记忆不搞用户第二次来的时候跟陌生人一样。这四个能力没有一个能省但如果一起上又容易把自己搞崩。所以下面按顺序拆开讲最后再聊我自己的组合策略。1. 整体设计四个能力为什么非拆不可1.1 智能体的最小闭环你观察一个跑得起来的Agent哪怕是最简单的“帮我查天气然后约个会议”这种任务它背后也在执行一个闭环理解用户意图组织多轮上下文决定要不要调用外部工具或数据源拿到结果后再组织回复整个过程还要保留相关的状态和事实信息。这个闭环里的每一环都指向一个基础能力。理解意图和组织上下文靠会话能力决定调用外部能力并执行动作靠工具能力连接外部数据、服务的标准化通道靠MCP保留跨轮、跨会话的有效信息靠记忆。如果只搭一套大模型调用接口顶多算个聊天脚本。真正让Agent从“会聊”变成“能干活”的是把这四个底座打扎实。而且这四个能力不是各自独立的孤岛它们之间是互相依赖的比如工具调用的结果要回填到会话上下文里记忆检索到的内容也要注入到当前会话的提示词里。所以拆开来设计本质是为了让各层逻辑内聚同时明确接口边界后面才好分别迭代不会牵一发动全身。1.2 四者的边界、关系与组合策略我把它们按数据流方向排了一下用户的请求进入会话层会话层负责组装上下文上下文里既包含多轮对话历史也包含工具调用的结果还可能包含从记忆层检索出来的长期信息。组装好之后发给模型模型决定是直接回答还是要发起工具调用这时候工具层和MCP层负责执行具体的动作执行结果再回到会话层形成新的上下文继续下一轮。边界上要注意的是会话层不应该直接碰业务逻辑它只负责管理对话轮次、上下文窗口和状态。工具层只解决“能不能调、怎么调、结果怎么回填”不回答“该不该调”这个问题该不该调由模型在会话上下文中决定。MCP解决的是“接入方式标准化”它跟具体工具一样最终也是以工具的形式暴露给会话层的。记忆层比较特殊它不是单独一环而是横切进来的在每轮组建上下文之前都可能插入检索结果。组合策略上我的建议是分阶段。第一版先只跑通会话和原生工具让闭环转起来第二版再做MCP标准化记忆作为最后一个增强项。一次全上出问题的时候很难排查是哪一环的锅。我自己就是这么干的事实证明先窄后宽比一开始铺大摊子要省心得多。2. 会话能力把“聊天”做成可靠的多轮上下文2.1 上下文管理滑动窗口、摘要压缩与提炼会话层最容易摆烂的地方就是直接把所有历史一股脑塞给模型。上下文窗口是有限的哪怕号称百万上下文塞久了也有信息冗余、指令冲突、性能下降的问题。我在Hermes Agent里用的是三层结构滑动窗口、摘要压缩、关键信息提炼。窗口层保留最近几轮原始对话一般按轮数或字符数控制比如最近二十轮以内保留原样剩下更早的内容走摘要。摘要不是简单让模型“总结一下刚才聊了什么”而是按结构性模板抽取比如“用户身份信息”“用户偏好”“历史决策结论”“待确认事项”“未完成意图”。这样摘要既能压缩体积又保留了后续任务真正需要的信息。关键信息提炼这一层其实是把抽取出来的一些高价值临时事实单独存下来比如用户临时提到的一个截止日期、某个任务ID、一个临时修改的规则。这类信息放摘要里容易被稀释但要回答当前问题时又很关键。我会在每轮对话结束时抽取一批新的关键信息与旧的去重合并然后在下文注入会话提示词的固定区域。做上下文管理的时候最怕的是“该丢的没丢、该留的没留”。我个人的经验是原始对话宁可多留几轮也不要过早压缩因为压缩有损但一旦压缩了就一定要把关键决策点保留出来。另一个经验是摘要不要每次做全量重写太费token也容易把之前的摘要内容又“总结一遍”信息越来越糊。我现在的做法是增量摘要只让模型对比新旧片段之间的差异输出合并后的版本实测效果好很多。2.2 会话状态的生命周期与持久化会话不是只存在于一次HTTP请求里的。用户断线、刷新、隔天回来这些场景都要求会话状态能恢复。我把会话生命周期分成四个阶段创建、活跃、归档、过期。创建时生成会话ID同时记录起始时间、来源渠道、初始元数据。活跃阶段的会话要保持上下文数据可读可写一般缓存在内存或本地键值库里。归档发生在会话结束或进入空闲一段时间后把上下文摘要、关键信息、工具调用记录统一落盘。过期则是超过保留时限后清理掉。持久化需要注意的一个细节是不要把原始上下文全部落盘否则历史数据会膨胀得很快。更合理的做法是落盘三样东西当前轮次的原始消息、最新版摘要、关键信息列表。这样恢复会话时用窗口内的原始消息加窗口外的摘要基本能无缝接上。另一个我踩过的坑是并发写。同一个会话ID如果用户开了两个标签页或者请求重试容易出现上下文互相覆盖。解决方式很简单给会话状态加一个版本号写入时校验版本冲突就拒绝覆盖返回提示让客户端刷新或者合并最近几轮再写入。2.3 流式输出、中断与并发隔离经验大模型接口默认是全量返回但真实交互场景下用户等不了那么久所以会话层一定要支持流式输出。流式不只是把token一段段从后端传到前端更重要的是在工具调用的场景下流式里可能夹杂着“工具调用事件”“状态变化事件”“最终文本事件”会话层要根据事件类型分别处理不能一股脑全部拼成文本丢出去。我这边的事件类型大概有text_delta文本增量、tool_call_start工具发起、tool_call_result工具结果、status_change状态变化。前端拿到这些事件后分别渲染文本、显示工具状态卡片、更新进度。如果没有做事件分发用户会在对话里看到类似“我帮你查一下…[object Object]”这种诡异输出本质就是事件类型没区分。中断控制也值得一说。用户随时可能打断模型的输出比如发一条新消息或手动点击停止。实现时至少要支持两种中断一种只是让界面停止渲染但后台继续走完这对工具类任务不友好另一种是真中断往调度器里发一个取消信号让当前模型请求和工具调用一起终止。我强烈建议做真中断否则用户以为停了结果后台还在调外部服务等会儿状态又跳出来体验非常奇怪。并发隔离说的是不同会话、不同用户之间的运行时状态要完全隔离不能因为一个会话里积累了大量上下文把另一个会话的内存拖垮。我采用的办法是为每个会话维护独立的内存区限制单个会话的上下文上限超出就归档到磁盘或向量库让活跃会话的内存占用始终可控。3. 工具能力让模型真正“动手干活”3.1 工具注册与调用描述设计Model本身不会调工具它是“阅读工具说明书”然后输出一个调用意图真正执行要由Agent侧完成。所以工具的注册和描述设计决定了模型能不能在正确的时机调用正确的工具。我把每个工具看作一个独立的函数模块注册时至少要包含四样东西名称、用途说明、参数Schema、执行入口。这里面最容易出问题的是用途说明和参数Schema。用途说明的问题在于很多人写得过于简单比如“查询天气”模型不知道什么时候该用。更合理的写法是“当用户询问当地当前天气、温度、降水概率或天气趋势时使用当用户询问昨天天气历史时请使用历史天气工具”。这种带触发条件和边界说明的描述能显著减少模型错误调用。参数Schema我统一用JSON Schema来表达因为主流模型基本都支持这种格式。需要特别注意的是参数描述要写全尤其是枚举值、默认值、参数间依赖关系。我见过不少工具因为参数描述不全模型把日期格式从YYYY-MM-DD传成了“明天”或者漏传必填字段。所以我在Schema里会把类型、格式、示例值、必填与否全部写清楚。{ name: create_meeting, description: 当用户需要安排或预约会议时调用。支持传入参会者列表、会议主题、开始与结束时间。若用户未明确时间但提到“尽快”默认取当前时间两小时后。, parameters: { type: object, properties: { subject: { type: string, description: 会议主题例如项目周会 }, start_time: { type: string, format: date-time, description: 会议开始时间ISO8601格式示例2025-03-18T14:00:00 }, end_time: { type: string, format: date-time, description: 会议结束时间ISO8601格式不得早于start_time }, attendees: { type: array, items: { type: string }, description: 参会者邮箱列表 } }, required: [subject, start_time, end_time] } }3.2 工具调用的循环控制推理、执行、观察工具不是调一次就完事。真实任务里Agent常常需要先调一个工具查点东西根据结果决定下一个动作再调另一个工具最后才组织回答。这就是经典的ReAct循环推理、执行、观察。我在会话层里专门维护了一个工具调用循环伪代码如下for step in range(max_steps): response llm.complete(messages, toolstool_specs) if response.has_tool_call: tool_result execute_tool(response.tool_call) messages.append(tool_result) continue return response.text这看起来简单但有几个细节必须做好。第一是循环上限我默认设五步防止模型陷入无限调用。第二是每一步的结果都要明确回填为一条工具消息并且带上tool_call_id把调用和结果一一对应。第三是判定结束的条件模型可能在有工具调用后还要再输出一段文本这时候要保留这段文本作为最终回答的一部分。还有一个容易忽略的细节当模型连续调用多个工具时部分模型支持并行返回多个tool_call。我在执行时会把互不依赖的工具调用并行执行减少等待时间但串行依赖的情况一定要等前一个结果出来后再回填。粗暴地全部并行可能导致参数依赖拿不到。3.3 工具权限、超时与错误回填工具层最怕的不是模型不会调而是模型乱调、重复调、调用停不下来。权限控制上我给每个会话绑定工具白名单没有注册的工具一律不暴露给模型。对于高风险操作比如删除数据、发消息、操作外部系统还要加二次确认机制模型先发出一个待确认调用Agent把确认请求发给用户用户同意后才真正执行。超时也是必须考虑的。外部工具可能很慢或者直接挂掉。我在执行入口外面统一包了一层带超时的执行器默认十秒超时超时后返回一条明确的错误消息给模型比如“工具执行超时请告知用户稍后重试或改用其他方式”。这比让模型空等或者前端转圈半天强太多。错误回填的方式也很讲究。我建议把错误信息放在工具消息里返回同时附上“建议行动方向”。比如某个数据库工具因为权限不足失败了返回里除了报错原因还要提示模型“不要重复尝试该操作建议告诉用户联系管理员”。这样模型看到错误后知道收敛而不是换个参数再试三次白白浪费token和用户耐心。4. MCP把工具接入做成标准插槽4.1 MCP解决的核心问题协议统一在MCP出现之前Agent对接外部系统几乎是一对一定制。接一个数据库写一套连接逻辑接一个云端服务再写一套API封装每换一个模型框架这些封装又要重来一遍。MCP做的事情是把“Agent接入外部能力”这件事抽象成统一的协议所有的外部数据源、工具、服务都通过标准接口暴露Agent只需要实现一个客户端就能接入任意符合协议的服务器。MCP的架构上主要分三层Host承载Agent的环境、Client与MCP Server建立连接的客户端、Server提供工具和数据的服务端。通信方式常见两种本地子进程走标准输入输出远端服务走HTTP流。MCP Server通过协议暴露三类核心原语资源可读取的数据、提示词可复用的提示模板、工具可执行的操作。对于Agent场景来说最重要的就是把远端能力暴露成工具然后让会话层的工具机制统一调度。我自己接入MCP之后最大的感受是新增一个数据源从原来的“写代码、调接口、改Agent逻辑”变成了“配置一个Server地址声明工具清单Agent侧基本不动”。这个省下来的不是一行两行代码是整个接入流程的复杂度。4.2 一个最小可跑的MCP接入配置示例我以一个内部数据查询服务为例展示接入MCP后的最小配置模样。Client侧连接Server的配置大致如下{ mcpServers: { internal-data-service: { type: http, url: http://127.0.0.1:8317/mcp, headers: { Authorization: Bearer sk-xxxx }, tools: { query_report: { description: 按条件查询内部运营报表数据 } } }, local-helper: { type: stdio, command: python, args: [/opt/mcp-servers/local_helper_server.py] } } }配置完成后Agent启动时会自动连接这些Server拉取工具清单再把工具注册进会话层的工具调度里。此时用户对Agent说一句“帮我查一下上周的运营报表”模型就会在MCP工具列表中发现query_report这个工具发起调用请求Agent执行后把结果回填过程跟调用本地工具几乎没有区别。实际操作中有个细节要注意MCP Server的工具清单是动态拉取的Server侧可能会升级新增工具。所以我设置了一个同步间隔启动时拉一次每隔一段时间重新拉一次保证Agent侧的工具列表和Server侧一致。如果忽略同步Server新增了工具Agent却一直不知道就会出现模型“想用某功能但没工具可用”的尴尬。4.3 MCP与原生工具的取舍、同场协作引入MCP不意味着所有工具都要通过它来接入。像那些执行路径短、只在内存里算一下就返回的纯函数工具比如字符串处理、时间格式化、简单计算直接用原生工具注册反而更快少走一层协议通信。我的分类标准是纯逻辑工具走原生注册外部数据源、外部服务、需要鉴权的系统访问走MCP。MCP工具的优势是解耦和复用原生工具的优势是低延迟和零额外依赖两者可以共存。在会话层的工具列表里我同时挂两类工具用前缀区分命名空间比如本地工具叫local_前缀MCP工具叫mcp_前缀这样排查日志时一眼能看出调用来源。同场协作时还要注意工具冲突。假设本地有一个“获取用户信息”的工具MCP Server也暴露了一个同名工具模型可能会混淆。我通常在注册MCP工具时自动加Server前缀比如internal-data-service_query_report这样保证全局唯一也让模型更容易理解工具来自哪个系统。5. 记忆能力短期长线两条腿走路5.1 先分清楚三类记忆记忆如果不多想会觉得就是把历史消息存下来检索。真真做起来才发现要分清楚至少三类会话记忆、事实记忆、偏好记忆。会话记忆就是会话过程中的短期上下文上一节讲的窗口和摘要都属于这类它跟着会话走会话结束就归档不属于持久化的重点。事实记忆是跨会话的长期信息比如用户的姓名、账号ID、项目代号、角色权限这些不随对话结束而消失下次用户再来时应该能直接想起来。偏好记忆更像是用户画像的一部分比如“用户习惯使用简洁回答”“用户偏好中文报告格式”“用户通常周三下午有空开会”。这三类记忆的存储方式也不一样。会话记忆用普通内存或键值库事实记忆用结构化存储偏好记忆建议半结构化存储再加上向量检索支持语义召回。如果你只用一种向量库存所有东西检索时大概率会混入大量低价值的对话碎片反而干扰回答。5.2 记忆的写入时机与更新策略记忆写入最核心的问题是什么时候写、什么时候更新。如果每轮对话结束都写一次记忆库会迅速膨胀大量临时讨论也会变成长期记忆最后记忆里全是噪声。我采用的时机策略是定期加事件触发。定期是指每隔几轮对话做一次全局提炼事件触发则是在出现某些关键节点时立刻写入比如用户明确修改了某条个人信息、用户下达了一个跨会话的目标、用户确认了一个偏好设置。更新策略上事实型记忆和偏好型记忆有不同的规则。事实记忆按唯一键做覆盖同一个用户ID的账号信息只维护一条记录新值覆盖旧值同时记录更新时间。偏好记忆要做加权累加比如用户多次提到“回复要简短”那就把“简短回复”这个偏好的权重加高在生成回答时更倾向遵循高权重偏好而不是被单次提到的偏好带跑。另一个容易被忽略的细节是删除和遗忘。记忆不是越多越好用户改掉了某个偏好或者某个事实已经过期都要及时更新或删除。我在每类记忆记录上都设置了过期时间偏好类最长临时意图类最短。过期记忆不参与检索从源头上减少幻觉风险。5.3 记忆检索匹配度阈值与上下文拼装检索记忆这个动作不是简单查一下向量相似度然后塞进提示词。检索链路里有两个关键点一是匹配度阈值二是上下文拼装。匹配度阈值要设得太低会召回一堆不相关的内容稀释提示词设得高真正有用的历史信息又召不回来。我建议分档处理高置信度结果直接作为事实引入中置信度结果作为“候选信息”提示模型“用户可能有过这样说过”低置信度就丢弃。我自己用的经验阈值大概在0.72以上直接引入0.5到0.72之间作为弱提示低于0.5丢弃。不同embedding模型产生的相似度分布不一样这个值需要自己测一轮调整。上下文拼装也有讲究。单纯把检索结果拼在历史前面不行那样模型分不清哪些是刚聊的哪些是旧记忆。我在拼装时会把记忆包装成带时间戳的来源标签例如“根据用户之前的偏好记录3周前用户习惯……”然后再放到上下文的一个独立区域中和当前会话历史隔开。这样模型既能利用历史信息又不会把旧记忆误当成当前会话的一部分。5.4 防止记忆污染命名空间、过期和幻觉防护记忆污染是最隐蔽的问题一旦发生Agent会拿着错误的旧信息一本正经地胡说八道。我遇到过最典型的场景是用户几个月前说“月底去出差”系统把“出差”记住了结果用户这次说“帮我给好友推荐本周末活动”模型却引入了“用户正在出差”的旧记忆推荐结果完全跑偏。命名空间隔离是防污染的第一步。不同项目、不同用户、不同用途的记忆严格分开检索时只查当前上下文命中的命名空间。其次是过期机制我已经说过不再重复但强调一点过期不是简单删除而是从检索候选集里剔除保留审计日志方便回溯。幻觉防护上我加了一道生成前的“记忆校验”环节让模型在引用记忆时输出来源IDAgent侧再校验这个ID是否在当前会话允许引用的记忆集合里。如果模型引用了一个不存在的ID就驳回并重新生成。这个方法实测下来能有效挡住一部分“模型基于记忆胡编”的情况代价是多了一次校验调用对性能敏感的可以考虑用规则过滤替代。6. 常见问题排查与避坑清单6.1 高频问题速查表我把这几个能力上线后遇到的高频问题整理成一张表方便对照排查。现象可能原因排查思路模型忘记几轮前说过的话上下文窗口被截断或摘要丢失关键信息检查会话摘要生成模板确认关键决策点是否被保留模型重复调用同一个工具工具结果回填后没有让模型“观察”到有效信息检查工具消息是否携带足够结论必要时附加“无需再次调用”的提示工具参数传错格式工具Schema描述不全缺少示例或格式说明完善参数描述中的format、示例、枚举值MCP工具列表没有刷新Server新增工具后未同步检查同步间隔手动触发一次工具清单拉取记忆检索返回大量无关内容匹配度阈值过低或命名空间未隔离调高阈值按会话命名空间限制检索范围模型引用过期记忆过期记录未排除出检索候选集检查过期时间是否生效增加来源ID校验流式输出出现乱码或混杂状态信息事件类型未做区分全部拼成文本按text_delta、tool_call等事件分支处理6.2 我踩过的几个具体坑第一个坑是摘要压缩过早。当时为了省token连续几轮就把历史全部压成摘要结果用户突然问“你刚才说的第三个选项是什么”模型已经答不出来了因为第三个选项正好被压掉。后来我把“待办事项”“候选方案列表”“数字编号信息”加进了摘要的关键信息模板里才算解决。第二个坑是MCP工具和原生工具同时暴露同名工具。模型选工具时经常选错日志里看到它在一个纯计算场景调了远程服务白白多出几秒延迟。后来启用前缀命名空间才好转但这个教训告诉我工具命名的全局唯一性真的不能偷懒。第三个坑是记忆写入太频繁导致向量库里整个会话的碎片消息都变成了长期记忆检索时噪声特别高。我花了不少时间清洗数据还加了一个“记忆质量打分”的规则只有包含明确主语、谓语、宾语的结构化事实或者有约束力的偏好才进入长期记忆模糊的闲聊语句一律不写。这条规则让记忆检索的准确率上了一个台阶。第四个坑跟超时有关当时工具层没加超时控制某个外部服务偶发卡住模型也跟着一起卡住用户等了几分钟没有任何反馈。后来统一包了超时执行器并且超时后主动给模型一条引导消息让它去回复用户“该服务暂时不可用”整体体验才稳定下来。我个人在实际操作中最深的体会是这四个能力不是各自越强越好而是配合得当才有效。会话管好了工具调用才能有足够的上下文依据MCP接好了工具接入的边际成本才会降低记忆做扎实了会话和工具的效果才能跨时间累积。建议动手做Agent的朋友先画一张自己业务的数据流图标清楚哪些信息从哪来、到哪去、怎么持久化再决定每个能力的分工会少走很多弯路。最后再分享一个小技巧为每个能力单独记录运行指标会话层关注上下文压缩频率和摘要token占比工具层关注工具调用成功率、平均调用步数和超时率MCP关注Server连接稳定性和工具同步延迟记忆层关注检索命中率、过期记录清除率和记忆写入质量。把这些指标固化到监控看板里之后优化才有方向而不是凭感觉对某个模块瞎调。这个内容后面还可以扩展成一份能力评估清单把每个底层能力从“能用”到“好用”的标准量化出来我打算下一阶段就朝这个方向做。