资讯动态

构建个人AI Agent:会话、工具调用、MCP与记忆系统的工程实践

发布时间:2026/10/14 6:37:23 来源:尧图企业网站定制
做 Personal AI Agent 的同学大概率都经历过一个尴尬期模型接口调通了、聊天能跑了但一旦想让助手做点实事——查资料、写备忘录、调接口——整个流程就崩。我开发 Hermes Agent 时最重要的任务就是把会话、工具、MCP、记忆这四件事做成一套可复用、可扩展的能力底座。这篇博文不吹架构只讲清楚这几块能力背后的取舍、实现和调试过程适合正在搭 Agent 但卡在集成层的开发者参考。我会按 Hermes Agent 的实际演进顺序来写先是会话管理再是工具调用然后是 MCP 接入最后补上记忆系统并在结尾用一个真实场景把这四部分串起来。每部分都会给出可直接拿去改的代码片段和参数配置以及我在落地过程中踩过的坑。1. 整体架构Agent 不是聊天机器人而是一个控制系统很多人把 Agent 理解成“能聊很多轮的聊天机器人”这个方向一开始就走偏了。聊天机器人只需要管理好上下文而 Agent 的核心是闭环感知输入、做出决策、调用外部能力、把结果反馈给模型最后再形成新的决策。Hermes Agent 之所以把能力拆成会话、工具、MCP、记忆四块就是因为闭环中的每一环都在解决一个不同的问题。1.1 四个能力谁先谁后我的建议是先做会话再做工具然后接 MCP最后加记忆。会话管理是所有能力的地基。模型没有状态所有状态都要由外部来维护。没有一套清晰的消息结构后面接工具和记忆的时候会非常痛苦。工具调用是让 Agent 真正“动手”的那一步。模型本身不擅长精确计算也无法直接操作外部系统只有通过函数调用把“意图”翻译成“动作”Agent 才能从问答器变成执行器。MCP 是对工具能力的进一步收敛。如果没有统一协议每接一个新服务就要写一套新的适配代码这会让工具层快速腐化。MCP 能让你用同一套客户端去对接不同服务相当于给工具层加了一个标准插座。记忆系统则是最后才加的我甚至建议不要在一开始就做长期记忆。原因很现实早期会话和工具还没调稳模型很容易在不合适的时机写入一些临时信息形成记忆污染。等核心闭环跑通了再引入记忆你会更清楚哪些信息值得被记住。1.2 一条请求的完整路径看懂这四个能力如何协作最直观的方式是看一条用户请求的生命周期。用户输入 - 会话模块接收并记录消息 - 检索相关长期记忆拼入上下文 - 模型决定是直接回答还是调用工具 - 工具执行 / MCP Server 调用 - 返回结果并回填会话 - 模型生成最终答复 - 异步写入需要的新记忆这条链路最关键的点在于“回填”。工具的执行结果不是直接丢给用户而是作为一条新的系统消息喂回给模型让模型基于真实结果继续推理。没有这一步工具调用就只是一次单向请求无法形成真正的 Agent 循环。1.3 为什么这样拆把能力拆开不只是为了代码结构好看更是为了可测试和可替换。会话模块只负责消息存储、截断和摘要不感知任何具体工具。工具注册表只维护“名称到执行函数的映射”不关心模型是哪家的。MCP 客户端只负责协议通信也不参与业务逻辑。记忆系统更独立它只是给上下文提供额外的事实而不是直接参与模型决策。这样拆完之后我可以随时替换底层模型、增加新工具、接入新的 MCP Server而不会影响其他模块。比如后来我把 Hermes Agent 的会话存储从内存缓存改成关系型数据库工具层和记忆层完全没动。这就是边界清晰带来的直接收益。2. 会话管理多轮上下文的“地基”会话管理是最不性感但最容易翻车的模块。很多 Agent 项目写到后期出现问题比如上下文越来越长、模型答非所问、多个用户数据串掉根源都在最初消息结构设计得太草率。2.1 消息模型不能只存 role 和 content不少人最初会用一个简单的数组保存消息每条消息只有role和content。这在纯闲聊里够用但一旦涉及工具调用就完全不够了。工具调用会产生中间消息模型输出一个tool_call系统执行后返回tool消息。如果这些消息无法被区分上下文就乱了。所以我在 Hermes Agent 里设计了完整的消息模型。{ message_id: msg_8f2a1e, session_id: session_42, user_id: user_11, role: assistant, content: , tool_calls: [ { tool_call_id: call_01, tool_name: send_reminder, arguments: { content: 给同事发项目进度, time: 2025-07-10T15:00:0008:00 } } ], created_at: 2025-07-09T12:00:00Z }多聊几轮之后你就会发现消息类型至少包括user、assistant、tool和system四类而且这类中间消息和普通回复一样需要被截断、归档和检索。如果你的消息模型一开始只考虑role/content后面返工的成本相当高。这里还有一个很隐蔽的细节工具调用结果回填之后如果结果内容特别长比如一个接口返回了几万字符的 JSON你需要提前做截断或摘要。否则这些原始内容会全部留在会话上下文里一次请求就能占满上下文窗口。2.2 Token 预算不精打细算上下文就会爆模型上下文长度是有限的所以每次请求前都需要估算 token 预算。我用的方法很简单把系统提示词、工具定义、历史消息、当前输入分别估算然后减去总上下文上限留下余量。context_limit 12k tokens system_prompt 1.5k tokens tool_schemas 2.0k tokens history 3.5k tokens current_input output_reserved 5k tokens当历史消息超过预算时我采用“滑动窗口 全局摘要 关键片段保留”的组合策略。滑动窗口意味着只保留最近 N 条消息这部分是模型做当前决策最重要的上下文。全局摘要把更早的对话压缩成一段概述保留主要意图和结论而不是每条消息都塞进去。关键是压缩时不能只让模型“总结”我会在摘要指令中明确要求保留用户显式说过的时间、任务、限制条件因为摘要一旦丢失这些关键信息后续工具调用就可能出错。说到这我得提醒一句千万不要为了省预算而只保留“最近 N 条”早期会话里那个用户主动提到的“周五之前完成”可能远超 N 条之外。我的方案是让工具调用产生的所有关键结构都被同步写入记忆系统这样即使摘要丢失细节模型也能通过记忆检索把它找回来。2.3 会话隔离与并发多用户场景下会话隔离出问题会非常严重。我曾经遇到过一次 A 用户的上下文串进了 B 用户的对话追查了半天就是因为两个会话共用了同一个 session_id 缓存。现在的规则很简单每个用户拥有独立的 session 列表每条 session 都有一个全局唯一的session_id所有消息读写都带session_id和user_id做双层过滤。服务端在响应请求时必须先确认当前请求的 token 属于该用户再查询对应的 session。并发又是一个容易被忽略的坑。同一个 session 在同一时刻只允许有一个 Agent 循环在跑否则多轮消息会被交叉写入状态就会错乱。我的做法是为每个 session 加一把分布式锁锁的粒度就落到 session_id 上请求进来先尝试加锁拿不到锁就直接返回“该会话正在处理中”避免并发把消息顺序打坏。3. 工具能力给模型一双“可执行的手”工具调用是让 Agent 从“会说”变成“会做”的核心。但“会做”并不只是定义一个函数那么简单你需要让模型知道在什么情况下调用哪个函数、参数怎么填、失败怎么办。3.1 工具的本质是把意图翻译成动作模型擅长语言推理但不擅长执行真实操作。它不能真正发邮件、写数据库、调接口。工具层的作用就是把模型的意图输出转换成一个可执行的动作再把动作的结果变成模型能理解的文本。没有工具时用户问“帮我设置提醒”模型只能回一句“好的我会记住”。有了工具后模型可以输出一个send_reminder的函数调用系统执行后回填“提醒已创建ID 为 xxx”模型再告诉用户“提醒已设置”。这个问题看起来很简单但真正做起来就会遇到两个麻烦一是工具数量变多后模型经常选错工具二是模型给出的参数可能不完整、格式错误。这两个问题都需要通过工具注册表的设计去缓解。3.2 工具注册表与 SchemaHermes Agent 内部维护了一个工具注册表每个工具注册时需要提供四样东西名称、描述、参数 Schema、执行函数。TOOL_SCHEMA { type: function, function: { name: send_reminder, description: 创建一条定时提醒。只有当用户明确要设置时间时使用。, parameters: { type: object, properties: { content: {type: string, description: 提醒内容}, time: {type: string, format: date-time, description: 提醒时间ISO8601格式} }, required: [content, time] } } }我特别想把“描述”这个字段拿出来单独强调。描述写得好不好直接决定模型的选型准确率。比如send_reminder的描述如果只写“设置提醒”模型在用户说“明天记得提醒我带文件”时很容易漏掉时间参数。但如果你在描述里加上“用户必须给出具体时间才调用若没有时间先询问用户”模型的准确率会显著提高。另外参数的 JSON Schema 要尽量严格。时间字段明确要求 ISO 格式数字字段明确范围枚举字段给出合法值。模型虽然不是每次都能严格遵守但你的 Schema 越清晰模型的输出就越接近目标。3.3 工具循环的实现工具调用不是一次性的模型可能会连续调用多个工具比如先查询天气再决定是否提醒带伞。所以我们需要一个循环来处理。def run_agent_loop(session_id, user_input): messages session_loader.load(session_id) messages.append({role: user, content: user_input}) for _ in range(MAX_TOOL_ROUNDS): response model.chat( messagesmessages, toolsregistry.get_schemas(), tool_choiceauto ) if not response.tool_calls: session_loader.save(session_id, messages) return response.content messages.append({ role: assistant, content: response.content or , tool_calls: response.tool_calls }) for call in response.tool_calls: result registry.execute(call.name, call.arguments) messages.append({ role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse) }) return 工具调用次数超限已停止这里有几个关键点。每次循环都要把 assistant 的tool_calls完整保留因为后续模型需要看到这些历史调用。工具结果必须以tool角色回填并且要带上tool_call_id这样才能和对应的调用匹配。MAX_TOOL_ROUNDS是个保护参数我一般设为 5。如果模型陷入“调工具、失败、再调工具、再失败”的死循环这个参数可以兜底不至于无限消耗 token。工具执行时不要直接修改全局状态。每个工具执行函数只接收参数字典返回一个 JSON 字符串什么时候落库、什么时候发通知由工具自己负责。这样注册表就能保持统一。3.4 工具层的护栏比调用更重要工具层最容易出安全问题而且很多问题在测试阶段根本不会暴露。第一是参数校验。不能假设模型输出的参数一定是合法的。执行工具前必须再校验一次类型、范围、格式。比如用户让模型删文件模型把路径参数填成了根目录这种风险必须靠参数白名单去拦截。第二是执行超时。外部接口可能会卡住工具执行函数必须带超时控制。我用的是统一的with_timeout包装器超过 10 秒就返回错误结果而不是让整个请求挂在那。第三是权限控制。不是所有工具都对所有用户开放。比如管理类的工具只能管理员调用工具注册表在execute入口就要校验用户身份和权限。这个校验不能放在工具函数内部而应当在注册表统一处理。第四是审计日志。每一次工具调用都需要记录调用者、参数、返回结果、执行耗时。后期排查问题几乎全靠这些日志。我之前没有加审计出事之后完全无法还原现场。4. MCP 集成用标准协议取代“一个服务一个适配器”工具如果只有两三个用注册表直接维护就够了。但当外部服务越来越多每个服务都有自己的 SDK、接口格式、认证方式如果每个都写一遍适配代码工具层的维护成本会呈指数级上升。这也是 MCP 存在的核心价值。4.1 MCP 到底帮你省了什么MCPModel Context Protocol是一套面向模型上下文的开放协议。简单说它把外部服务的能力统一抽象成三类Tools、Resources 和 Prompts。作为 Agent 客户端你不需要了解每个服务内部的实现只需要按协议去发现、调用即可。在没有 MCP 之前接一个日程服务要读它的 API 文档、写 HTTP 客户端、处理鉴权再接一个笔记服务又要重复一遍。有了 MCP 之后只要服务端实现了协议客户端就能直接列出它暴露的工具并且用统一的callTool方式来调用。我最初对这套协议有点怀疑因为“标准协议”往往意味着过度抽象。但实际接入几次之后我发现它的抽象粒度刚好工具定义使用 JSON Schema调用参数也是 JSON这对大模型来说非常自然。它没有强迫你把所有能力强行变成自然语言接口而是保留了结构化的准确性。4.2 在 Hermes 中配置 MCP Server我的想法是让 MCP Server 像插件一样被动态加载。Hermes Agent 启动时会读取一组配置文件每个配置描述一个 MCP Server。{ servers: [ { name: demo-calendar, transport: stdio, command: [python, -m, demo_calendar_mcp], env: { LOCALE: zh-CN } }, { name: remote-notes, transport: http, url: https://mcp.internal.example.com/notes } ] }stdio 模式适合本地启动一个子进程通过标准输入输出和 MCP Server 通信。这种模式的好处是进程隔离坏处是子进程崩溃后连接会断。http 模式则适合部署在远端或容器里的服务Agent 通过 HTTP/SSE 连接。在 Hermes Agent 里我封装了一个MCPManager负责启动、保活和工具发现。初始化流程是启动子进程或建立连接完成初始化握手然后主动请求工具列表再把这些工具注册到本地工具注册表。有一个坑特别值得说stdio 模式下子进程启动是需要时间的在启动完成之前调用工具会直接失败。我的做法是给每个 MCP Server 增加一个ready状态只有完成握手并拿到工具列表之后才允许被模型调用。否则模型可能因为一次瞬时失败就认为工具不可用开始编造回退方案。4.3 把 MCP 工具“翻译”成本地工具MCP Server 返回的工具定义是标准的 JSON Schema 格式但本地工具注册表可能还需要一些额外信息比如分组、权限、是否默认启用。所以在接入时我会做一次映射。def convert_mcp_tool(tool): return { name: tool.name, description: tool.description, parameters: tool.inputSchema, handler: lambda args: mcp_client.call_tool( server_nametool.server_name, tool_nametool.name, argumentsargs ) }映射过程中有两个问题经常出现。第一个是工具名冲突。不同 MCP Server 可能都有叫create_note的工具如果直接注册到本地名称就会撞。我的方案是在转换时给工具名前加服务名前缀比如calendar_create_note并在描述里保留原始工具名让模型在调用时能理解含义。第二个是返回结果不够干净。有些 MCP Server 返回的是自然语言文本比如“成功创建日程”有些返回结构化 JSON。为了让模型更稳定地理解结果我通常会在工具 handler 里对返回做一层规范化如果是 JSON 就保留原始结构如果是纯文本就包装成{result: ...}避免模型在后续推理时解析格式错乱。4.4 接入真实服务时的心得MCP 接入并不总是“开箱即用”。我遇到过几个比较典型的问题这里一并分享。一是 MCP Server 暴露的工具过多。有些服务一次会暴露几十个工具如果全部注册进模型上下文工具 Schema 会占掉大量 token而且模型在选工具时也会犹豫。我的做法是在配置里增加enabled_tools白名单只注册真正会用到的工具子集。比如日程服务可能同时有“查询”“创建”“更新”“删除”工具但初期我只注册查询和创建有效缩小模型的选择空间。二是连接保活。HTTP 连接的 MCP Server 偶发超时需要在callTool外面包一层重试机制。stdio 子进程崩溃后父进程需要监听退出事件自动重启并重新初始化否则下一次调用就会报错。三是测试环境要模拟工具失败。LLM 在工具失败时倾向于“道歉并放弃”而不是“尝试换一种完成方式”。所以在测试 MCP 接入时我会故意让一个工具返回异常观察模型是否能基于失败信息提出替代方案。如果没有就需要在系统提示词里加上一句“工具失败时分析错误原因并尝试其他可行工具。”5. 记忆系统让 Agent 从“聊过”变成“记得住”会话是短期的工具是当下的而记忆让 Agent 有了“历史”。如果一个智能体每次启动都像失忆一样那它只是一个搜索引擎不是一个真正的助手。但记忆也不是越多越好盲目记住所有内容反而会让系统变得混乱。5.1 记忆分三层我采用三层记忆模型短期记忆、工作记忆、长期记忆。短期记忆就是当前会话的上下文它存在 session 里随会话结束而消失。工作记忆是正在执行的任务相关的信息比如用户刚才提到的会议时间、参会人在任务未完成时会持续保留。长期记忆是跨会话的信息比如用户的偏好、身份、长期项目背景它需要在不同 session 之间复用。很多人在实现记忆系统时只做“长期记忆”跳过了工作记忆结果就是用户明明刚说过“我不喝咖啡”模型在下一次工具调用时还是推荐咖啡。这其实是工作记忆没有发挥好。工作记忆不一定需要写数据库它可以是一块暂存区由记忆管理器负责在每轮对话结束后更新。5.2 写入不是所有内容都值得记住如果对话里的每句话都写入长期记忆两三天之后记忆库里就会充满无用的闲聊。我在 Hermes Agent 里设计了记忆写入的触发条件只有满足这些条件之一才写入长期记忆。用户表达明确偏好比如“我更喜欢邮件通知”。用户陈述稳定事实比如“我的项目代号是 Hermes”。用户给出任务进度更新比如“需求评审已经完成了”。用户纠正了之前的信息比如“我上次说的不是周四是周五”。触发判断可以交给一个轻量级模型也可以用规则过滤。我最初用的是规则但准确率不够后来改成一个单独的判断提示词让模型读当前消息输出一个 JSON 列表里面包含记忆类型、记忆内容和置信度。只有置信度高于阈值的内容才会被写入。写入要异步不能阻塞主链路。用户发完消息后回复已经生成这时再异步提取记忆并入库。如果写入失败也不能影响当前会话。我会在代码里把写记忆放在一个带重试的队列里极端情况就是丢几条不太重要的记忆而不会让请求崩溃。5.3 检索找到最相关的“旧事”记忆写入之后最关键的是检索。用的最多的方案是把记忆文本转成向量用向量相似度找出最相关的几条然后拼接到上下文里。def recall(query, top_k5): query_vec embed(query) candidates vector_store.search(query_vec, top_ktop_k) reranked rerank_by_recency(candidates) return [memory.text for memory in reranked if memory.score 0.35]直接向量检索有个问题它只看语义相似度不管时间。用户上周说过“我喜欢早上开会”这周又说“早上没时间”如果只按向量相似度可能会把旧的偏好捞上来导致记忆冲突。所以我加了两个辅助信号一是时间衰减越久远的记忆权重越低二是显式的“覆盖标记”当用户更新信息时旧记忆不是被删除而是被标记为superseded_by检索时默认跳过。这样既保留历史轨迹又不会让互相矛盾的信息同时进入模型上下文。检索到的记忆不能原样塞进系统提示词就完事。我通常会把它们包装成一段“相关背景”文本标注每条记忆的来源时间和可信度。例如相关背景 - 用户偏好邮件通知2025-06-20置信度0.9 - 用户提示下周五前完成项目初稿2025-07-05置信度0.8这样的好处是模型在回答时可以判断哪些背景信息是更可靠的而不是把每一条记忆都当成铁的事实。5.4 更新、遗忘与隐私记忆系统必须支持修正和删除。用户会说“我其实不用邮件还是用微信吧”这种时候如果只是往记忆库新增一条“用微信通知”同时保留一条“用邮件通知”模型就可能在不同轮次里给出矛盾答案。我的做法是“写入新记忆 标记旧记忆失效”。每一条记忆都有一个status字段active、superseded或deleted。查询时默认只返回active的记忆。这比直接删除旧记忆更稳妥因为你可以随时回放历史知道用户之前曾经说过什么。删除要提供用户可见的接口。如果用户要求“别再记住我的偏好”Agent 必须能根据用户 ID 清除对应记忆分区。这个功能不仅是产品体验问题更是信任问题。我没有把记忆库做成全局共享而是每个用户一个独立的命名空间跨用户查询一律禁止。还有一点要强调不要无限积累记忆。定期做一次归档超过一定时间且未被访问的静态记忆可以降级到冷存储避免向量索引越来越慢。否则等记忆库膨胀到几十万条之后检索的延迟和噪声会让你怀疑人生。6. 四合一实战用会话、工具、MCP、记忆完成一个提醒任务前面的内容偏模块化这一节我用一个具体场景把四块能力串起来。场景很简单用户对 Hermes Agent 说“明天上午十点提醒我带项目计划书然后帮我看看日程安排。”6.1 端到端流程拆解第一步是会话接入。Hermes Agent 把这条消息追加到当前 session 的消息列表同时抽取用户 ID 和 session ID。第二步是记忆检索。系统从长期记忆里捞到两条关键信息用户之前提过项目计划书放在某个指定目录用户每天上午会先看日程。这两条信息被拼入上下文让模型在后续决策时不至于凭空猜测。第三步是模型决策。模型发现用户提到“提醒”和“看看日程安排”于是决定调用两个工具一个是本地工具send_reminder另一个是 MCP Server 暴露的calendar_query。模型会先调用日程查询确认明天上午是否有已有会议然后决定提醒时间是否冲突。第四步是工具执行。send_reminder执行后返回提醒 IDcalendar_query通过 MCP Server 返回明天的日程列表。结果回填到会话模型继续推理。第五步是生成回复。模型告诉用户“已设置提醒明天上午十点提醒您带项目计划书另外您明天上午九点有一个内部评审会我提前半小时提醒”。这个回复完全基于工具返回的真实数据而不是模型编造。第六步是记忆写入。系统异步写入一条新记忆用户有一个项目计划书明天需要带。这样如果用户后天再问Agent 依然记得这个事实。6.2 调试期间的真实问题与修复这个场景看起来顺畅但实际调试过程中我踩过不少坑整理成表格供参考。现象根因解决方案模型提示工具不存在MCP Server 还没完成初始化工具列表为空增加ready状态只有完成握手后才把工具注册到本地多轮后上下文爆长度工具返回结果太大没有截断对工具结果做摘要或截断将完整数据写入日志而非会话用户过去的偏好干扰当前任务向量检索没有做时间衰减和失效标记检索时加入时间权重跳过superseded记忆同一 session 并发请求导致消息乱序没有加锁为每个 session 加分布式锁拒绝并发写操作工具调用陷入循环缺少轮数保护设置MAX_TOOL_ROUNDS5超限后主动结束循环模型不会在工具失败时更换方案系统提示词缺少指引在提示词中加入“失败时分析错误并尝试其他工具”其中比较难排查的是第一条。MCP Server 启动后工具列表不是立刻就存在的。如果你在启动后马上让模型调用它看到的工具注册表还是空的就会直接报“工具不存在”。后来我专门写了一个健康检查接口每 5 秒检查一次所有 MCP Server 的连接状态并把状态输出到日志这样才算解决了这个问题。6.3 关于调试工具链的心得工具调用出问题时不要直接去改 Prompt先看审计日志。我遇到过一种情况模型明明调用了calendar_query但用户说结果不对。打开日志才发现模型调用时传的时间参数是“明天”而 MCP Server 收到的参数把“明天”翻译成了当前日期的明天但用户当时已经接近凌晨跟用户理解的“明天”差了整整一天。这不是模型的问题而是时间语义没有标准化。在这之后我定了一条铁律所有工具参数里的时间字段在进入工具执行层之前必须先标准化为带时区的 ISO 字符串。“明天”“下周一”“晚上八点”这类自然语言时间必须由模型在工具参数里给出具体时间戳不能直接把模糊表达传给工具。工具层不负责理解自然语言只负责执行精确指令。另一个经验是调试 MCP 时不要只依赖日志最好能单独写一个 MCP 调试终端手动向 Server 发tools/list和tools/call确认协议层没问题后再回到 Agent 整体调试。这样可以把问题定位到协议层还是业务层能省下大量时间。现在回头看我把这四块能力打通之后Hermes Agent 才真正从一个“问答器”变成了“执行器”。如果让我重新做一遍我会坚持一个顺序先把会话和工具跑通让 Agent 能独立完成单次任务再去接 MCP把外部系统接入成本降到最低记忆最后加并且一定要带着“可修正、可删除、可隔离”的设计去做千万不要一开始就把所有对话都存下来。最后再分享一个小技巧给每个模块都留一个手动触发的测试命令比如“强制摘要当前会话”“清空某个用户的记忆”“重新加载 MCP Server 列表”这些在日常调试里会救你无数次。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑