1. 为什么我要从零手搓一个记忆型 AI Agent
先说结论:市面上能跑通 Demo 的 Agent 框架一抓一大把,但真正能扛住生产流量、还能记住上下文、断线能续、工具能热插拔的,少之又少。我这次拿 AgentScope 做底座,从零搭了一个带长期记忆的生产级 AI Agent,踩的坑比想象中多,收获也比预期大。这篇文章不讲虚的,就把整个设计思路、核心实现、参数取舍、排查过程全部摊开讲,适合已经写过基础 Agent Demo、想往生产环境推一步的开发者,也适合正在选型 Agent 框架的技术负责人。
AgentScope 这个框架我关注挺久了,它的定位很清晰——面向多智能体协作的开发框架,消息传递、角色编排、工具调用这些基础能力都做得比较扎实。但"能跑"和"生产级"之间隔着一条鸿沟:记忆怎么持久化、流式输出怎么保证不中断、工具协议怎么标准化、领域模型怎么拆才不乱,这些才是真正决定一个 Agent 能不能上线的关键。我这次的项目目标很明确,就是要做一个记忆型 Agent——它能记住用户之前说过什么、做过什么决策、偏好是什么,而不是每次对话都从零开始。
为什么强调"记忆型"?因为绝大多数 Agent 的痛点就在这。你问它"上次那个方案改得怎么样了",它一脸茫然;你让它"按我之前的风格来",它根本不知道你之前的风格是什么。这种体验在 Demo 阶段无所谓,一旦放到真实业务里,用户立刻就会觉得"这玩意儿不智能"。所以记忆能力不是锦上添花,而是生产级 Agent 的入场券。
整个项目我用了几个关键技术栈:AgentScope作为 Agent 编排框架,DDD(领域驱动设计)来拆分业务边界,SSE(Server-Sent Events)做流式输出,MCP(Model Context Protocol)做工具协议标准化。这几个词看着独立,实际上是一条完整的链路——DDD 决定代码怎么组织,AgentScope 决定 Agent 怎么跑,SSE 决定前端怎么实时收到结果,MCP 决定 Agent 怎么调用外部工具。下面我逐个拆开讲,把每个环节的"为什么"和"怎么做"都说透。
2. 整体架构设计与技术选型拆解
2.1 为什么用 DDD 而不是简单的分层架构
很多人搭 Agent 项目,上来就是 controller-service-dao 三层走天下,Demo 阶段确实快,但一旦 Agent 数量超过三个、工具超过十个、记忆类型超过两种,代码就会迅速腐化。我这次坚持用 DDD,核心原因是Agent 系统的复杂度不在技术层,而在领域层。
一个记忆型 Agent 的领域概念其实很多:会话(Session)、记忆(Memory)、工具(Tool)、角色(Role)、任务(Task)、上下文窗口(Context Window)。这些概念之间有明确的边界和交互规则,如果用传统的贫血模型,所有逻辑都会堆到 Service 里,最后变成一个几千行的上帝类。DDD 的价值就是把这些概念显式建模成聚合根、实体、值对象,让业务规则内聚在领域层。
具体怎么拆?我把整个系统分成四个限界上下文:
- 会话上下文:管理 Session 生命周期、消息历史、上下文窗口裁剪
- 记忆上下文:管理长期记忆的写入、检索、衰减、合并
- 工具上下文:管理 MCP 工具的注册、发现、调用、超时
- 编排上下文:管理 Agent 的角色定义、任务分发、多 Agent 协作
每个上下文独立演进,通过领域事件通信。比如会话上下文产生一条"消息已写入"事件,记忆上下文订阅后决定是否要抽取长期记忆。这种解耦在生产环境里非常关键,因为记忆抽取是个重操作,不能阻塞主对话流程。
2.2 AgentScope 在架构中的定位
AgentScope 我把它放在编排上下文里,作为 Agent 运行时的引擎。它负责的是"给定一个角色定义和一批工具,怎么驱动大模型完成一轮推理"。我不把业务逻辑写进 AgentScope 的 Agent 里,而是让它保持纯粹——只做推理和工具调用,业务规则全部在领域层。
这样做的好处是,AgentScope 的版本升级不会影响我的业务代码。我见过太多项目把业务逻辑和框架 API 深度耦合,框架一升级整个项目就得重写。保持框架的"可替换性"是生产级系统的基本素养。
AgentScope 的几个能力我特别看重:一是消息传递机制,它支持结构化的消息对象,不是简单的字符串拼接;二是工具调用,它有一套标准的工具描述格式;三是多 Agent 协作,支持 Agent 之间的消息路由。这三点正好对应我需要的编排能力。
2.3 SSE 而不是 WebSocket 的取舍
流式输出这块,我在 SSE 和 WebSocket 之间纠结了很久。最后选 SSE,理由有三条:
第一,Agent 的输出是单向流。用户发一条消息,Agent 流式返回结果,这个场景本质上是服务器推送,不需要双向通信。WebSocket 的双向能力在这里是浪费。
第二,SSE 基于 HTTP,运维成本低。不需要额外的协议升级、不需要处理心跳、不需要考虑代理兼容性。生产环境里,任何额外的协议复杂度都是故障源。
第三,SSE 天然支持断线重连。浏览器端的 EventSource 会自动重连,配合 Last-Event-ID 还能做断点续传。这一点对长对话场景特别重要,用户网络抖动一下,不能整个对话就断了。
当然 SSE 也有坑,最大的坑就是空闲超时。我实测下来,很多反向代理默认 60 秒没数据就断连接,而 Agent 思考时间可能超过这个值。解决办法是定期发送心跳注释行(: heartbeat\n\n),保持连接活跃。这个细节后面会详细讲。
2.4 MCP 作为工具协议的价值
MCP 这个东西,简单说就是给大模型调用外部工具定了一套标准协议。在没有 MCP 之前,每个框架都有自己的工具描述格式,你写一个工具要适配 N 个框架。有了 MCP,工具实现一次,所有支持 MCP 的客户端都能用。
我这次把所有外部能力都封装成 MCP Server,包括数据库查询、文件操作、HTTP 请求。Agent 侧只需要一个 MCP Client,就能动态发现和调用这些工具。这种设计让工具的热插拔成为可能——新增一个工具,不需要改 Agent 代码,只需要注册一个新的 MCP Server。
MCP 的通信方式支持 stdio 和 SSE 两种,我选的是 SSE,因为工具服务可能部署在独立进程甚至独立机器上,stdio 只能本地通信。SSE 模式下,MCP Server 暴露一个 HTTP 端点,Client 通过 SSE 接收工具列表和调用结果。
3. 记忆系统的核心设计与实操要点
3.1 记忆分层:短期、长期、工作记忆
记忆不是一个大池子,必须分层。我设计了三层记忆结构:
短期记忆就是当前会话的消息历史,存在内存里,随会话结束而销毁。它的作用是维持对话连贯性,让 Agent 知道刚才聊了什么。短期记忆的关键参数是上下文窗口大小,我设的是最近 20 轮对话,超过就裁剪。为什么是 20 轮?因为实测下来,大部分任务的有效上下文不超过 15 轮,20 轮留了点余量,再多了纯属浪费 token。
长期记忆是跨会话持久化的,存在数据库里。它记录的是用户的偏好、重要事实、历史决策。比如"用户偏好简洁的回答风格"、"用户上次选择了方案 B"这类信息。长期记忆的写入不是每轮都做,而是通过一个记忆抽取器异步处理,判断当前对话里有没有值得长期保留的信息。
工作记忆是任务级的临时存储,比如 Agent 执行一个多步任务时,中间结果放这里。任务结束就清空。工作记忆的价值在于避免中间结果污染长期记忆,同时让 Agent 在多步推理时能引用前面的结果。
三层的生命周期和存储介质都不一样,用表格对比一下更清楚:
| 记忆类型 | 生命周期 | 存储介质 | 典型容量 | 写入时机 |
|---|---|---|---|---|
| 短期记忆 | 会话级 | 内存 | 20 轮对话 | 每轮对话 |
| 长期记忆 | 永久 | 数据库 | 无上限 | 异步抽取 |
| 工作记忆 | 任务级 | 内存/Redis | 任务相关 | 任务执行中 |
3.2 记忆抽取的触发时机与策略
记忆抽取是整个系统里最容易出问题的地方。抽取太频繁,浪费算力还容易写入噪音;抽取太少,长期记忆就形同虚设。我试过三种策略:
第一种是每轮都抽取,结果发现大量无意义记忆被写入,比如"用户说了你好"这种。而且每轮都调用一次抽取模型,成本翻倍。
第二种是定时抽取,比如每 10 轮抽一次。问题是可能错过关键信息,而且定时任务和对话流程解耦后,上下文可能已经丢失。
第三种是我最终采用的事件触发 + 阈值判断。具体来说,当一轮对话满足以下任一条件时才触发抽取:对话轮数达到 5 的倍数、用户显式表达了偏好(通过关键词识别)、Agent 完成了某个任务节点。这样既不会太频繁,也不会漏掉关键信息。
抽取本身用一个小模型来做,不需要用主模型。我用的是一个 7B 级别的模型,专门做信息抽取,把对话内容压缩成结构化的记忆条目。这样成本可控,速度也快。
3.3 记忆检索:向量检索 + 关键词混合
长期记忆检索我一开始只用向量检索,后来发现纯向量检索有两个问题:一是对精确匹配不敏感,比如用户问"我上次说的那个项目叫什么",向量检索可能召回一堆语义相近但不相关的记忆;二是冷启动问题,记忆库小的时候向量检索效果很差。
所以我改成了混合检索:向量检索负责语义召回,关键词检索(BM25)负责精确召回,两路结果用 RRF(Reciprocal Rank Fusion)融合。实测下来,混合检索的召回准确率比纯向量高了大概 30%。
检索的 top-k 我设的是 5,也就是每次最多召回 5 条记忆注入上下文。为什么是 5?因为记忆注入会占用上下文窗口,太多会挤压对话空间。5 条是个平衡点,实测覆盖了大部分场景。
注意:记忆检索一定要加时间衰减因子。三个月前的记忆和昨天的记忆,权重不应该一样。我用的是指数衰减,半衰期设 30 天。这样旧记忆会自然淡化,除非被反复召回。
3.4 记忆冲突的处理
记忆冲突是个隐蔽的坑。比如用户先说"我喜欢 Python",后来说"我现在主要用 Java",这两条记忆是冲突的。如果不处理,Agent 可能会给出矛盾的回复。
我的处理策略是新记忆覆盖旧记忆 + 保留历史版本。当抽取器发现新记忆和旧记忆冲突时,把旧记忆标记为"已过期",新记忆设为"当前有效"。检索时只召回有效记忆,但历史版本保留在库里,用于审计和回溯。
判断冲突的方法是用一个轻量的语义相似度模型,如果两条记忆的相似度超过 0.85 但内容矛盾(通过一个小的分类模型判断),就触发冲突处理。这个阈值 0.85 是调出来的,太低会误判,太高会漏判。
4. SSE 流式输出的生产级实现
4.1 SSE 连接的生命周期管理
SSE 看着简单,就是一个 HTTP 长连接,但生产环境里的坑一点不少。我先把连接的生命周期理清楚:
连接建立时,服务端要设置正确的响应头:Content-Type: text/event-stream、Cache-Control: no-cache、Connection: keep-alive。这三个头缺一不可,少一个就可能导致浏览器不识别或者代理缓存。
连接保持时,要定期发送心跳。我设的心跳间隔是 15 秒,发送一个注释行: heartbeat\n\n。为什么是 15 秒?因为大部分反向代理的空闲超时是 60 秒,15 秒的心跳留了足够的安全边际。心跳太频繁浪费带宽,太稀疏又起不到保活作用。
连接关闭时,要清理服务端的资源,比如取消正在进行的 Agent 推理、释放会话锁。这一步很容易被忽略,结果就是连接断了但后台任务还在跑,资源泄漏。
4.2 断线重连与断点续传
SSE 的断线重连是浏览器自动做的,但断点续传需要服务端配合。原理是这样的:服务端每条消息都带一个id字段,浏览器重连时会带上Last-Event-ID头,服务端根据这个 ID 找到断点,把之后的消息补发。
我的实现是给每条 SSE 消息分配一个单调递增的序号,服务端维护一个消息缓冲区(最近 100 条),重连时根据 Last-Event-ID 从缓冲区里补发。超过 100 条的情况,就返回一个"需要重新开始"的事件,让前端重新发起请求。
这里有个细节:消息缓冲区要按会话隔离,不能全局共享。否则多用户并发时会串消息。我用的是Map<sessionId, CircularBuffer>的结构,每个会话一个环形缓冲区。
4.3 空闲超时的排查与解决
stream disconnected before completion: idle timeout waiting for SSE这个报错我遇到过好几次,每次原因都不一样。整理一下排查思路:
第一次是反向代理的超时配置太短,默认 60 秒。解决办法是调大代理的proxy_read_timeout,同时加上心跳。
第二次是 Agent 推理时间太长,中间没有任何输出。解决办法是在 Agent 推理过程中插入"思考中"的事件,保持连接有数据流动。
第三次是服务端的写超时设置问题,底层 socket 写阻塞了。解决办法是给写操作加超时,超时就主动关闭连接让客户端重连。
排查这类问题的通用方法是:在链路的每一层都打日志,从客户端到代理到服务端,看数据流在哪一层断掉。我一般会先用 curl 直接连服务端,排除客户端问题;再用 curl 连代理,排除代理问题。逐层缩小范围。
4.4 前端消费 SSE 的正确姿势
前端用 EventSource 消费 SSE,有几个坑要注意:
一是EventSource 不支持自定义请求头,所以认证信息只能放在 URL 参数或者 Cookie 里。我选的是 Cookie,相对安全一些。
二是EventSource 会自动重连,但重连时不会带请求体,所以如果初始请求有参数,重连时会丢失。解决办法是把参数放在 URL 里,或者用 fetch + ReadableStream 自己实现 SSE 消费。
三是要处理error事件,不能只监听message。连接断开、服务端返回错误都会触发 error 事件,前端要据此更新 UI 状态。
我最终用的是 fetch + ReadableStream 的方案,虽然代码多一点,但可控性强,能自定义请求头、能精确控制重连逻辑。
5. MCP 工具集成与动态调用
5.1 MCP Server 的注册与发现
MCP 的核心价值是工具的动态发现。Agent 启动时,通过 MCP Client 连接各个 MCP Server,拉取工具列表,构建工具注册表。这个过程是异步的,不阻塞 Agent 启动。
工具注册表的结构大概是这样的:每个工具有一个唯一的名称、一段描述、一个参数 schema。Agent 在推理时,把这些工具描述注入到 prompt 里,模型决定调用哪个工具、传什么参数。
我踩过一个坑:工具描述的质量直接决定调用准确率。一开始我写的工具描述很简略,比如"查询数据库",结果模型经常传错参数。后来我把描述写详细,包括用途、参数含义、返回值格式、使用示例,调用准确率明显提升。工具描述本质上是在给模型写文档,文档写得好,模型才用得好。
5.2 工具调用的超时与重试
生产环境里,工具调用失败是常态。网络抖动、下游服务超时、参数错误,各种情况都可能发生。我的处理策略是:
超时控制:每个工具调用设一个超时,默认 30 秒,特殊工具可以单独配置。超时后立即返回错误,不让 Agent 无限等待。
重试策略:只对幂等操作重试,非幂等操作不重试。重试次数最多 2 次,采用指数退避。为什么是 2 次?因为 3 次以上重试的收益递减,而且会放大下游压力。
降级处理:工具调用失败后,Agent 要能优雅降级。比如数据库查询失败,Agent 可以回复"暂时无法查询,请稍后再试",而不是直接报错崩溃。
5.3 工具权限与安全边界
工具是 Agent 的手脚,权限控制必须严格。我的做法是最小权限原则 + 白名单机制。
每个 MCP Server 注册时,要声明它需要哪些权限。Agent 调用工具前,先检查当前会话是否有对应权限。比如文件操作工具,只允许访问特定目录;数据库工具,只允许执行只读查询。
白名单机制是指,只有显式注册的工具才能被调用,未注册的工具即使 MCP Server 暴露了也不可用。这样防止了工具被意外调用。
提示:工具的参数一定要做校验,不能直接透传给下游。我见过因为参数没校验导致 SQL 注入的案例,Agent 场景下这个风险更高,因为参数是模型生成的,不可控。
5.4 工具调用结果的格式化
工具返回的结果不能直接塞给模型,要做格式化。原因有两个:一是原始结果可能很长,直接塞会爆上下文;二是原始结果可能是结构化的,模型理解起来费劲。
我的做法是结果摘要 + 结构化提取。对于长结果,先用一个小模型做摘要,只保留关键信息;对于结构化结果,提取关键字段,用自然语言重新组织。这样既节省 token,又提升模型理解准确率。
6. 常见问题与排查技巧实录
6.1 记忆检索召回不准怎么办
这是最常见的问题。排查思路分三步:
第一步,检查记忆写入质量。如果写入的记忆本身就是噪音,检索再准也没用。可以抽样看几条记忆,判断抽取器是否工作正常。
第二步,检查检索参数。top-k 是不是太小?相似度阈值是不是太高?时间衰减因子是不是太激进?这些参数都要根据实际数据调。
第三步,检查 embedding 模型。不同的 embedding 模型对中文的支持差异很大,选一个适合中文的模型很关键。我试过几个模型,最后选的是一个在中文语义相似度任务上表现较好的。
6.2 SSE 消息乱序或丢失
SSE 本身是基于 TCP 的,理论上不会乱序。如果出现乱序,大概率是服务端多线程写入没有加锁。解决办法是给每个会话的 SSE 写入加一个锁,保证同一会话的消息串行发送。
消息丢失通常是缓冲区溢出导致的。如果客户端消费速度慢于服务端生产速度,缓冲区满了就会丢消息。解决办法是加背压机制,服务端发现缓冲区快满时,暂停生产或者降级。
6.3 Agent 陷入死循环
Agent 死循环通常发生在工具调用环节。比如 Agent 调用工具失败,重试,又失败,又重试,无限循环。解决办法是设置最大迭代次数,我设的是 10 次。超过就强制终止,返回一个兜底回复。
另一个原因是工具返回的结果让 Agent 误判。比如工具返回"操作成功"但实际失败了,Agent 就会继续下一步。解决办法是工具返回结果要明确,成功和失败要区分清楚。
6.4 上下文窗口爆掉
上下文窗口爆掉的表现是模型报错或者回复质量骤降。根本原因是注入的内容太多:短期记忆 + 长期记忆 + 工具描述 + 系统提示,加起来超了。
解决办法是动态裁剪。我实现了一个上下文管理器,实时计算当前上下文的 token 数,超过阈值就按优先级裁剪。优先级从高到低是:系统提示 > 当前用户消息 > 最近几轮对话 > 长期记忆 > 工具描述。裁剪时从低优先级开始。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 记忆召回不准 | 写入质量差/参数不当 | 抽样检查记忆/调参 | 优化抽取器/调 top-k |
| SSE 消息乱序 | 多线程写入无锁 | 检查写入代码 | 加会话级锁 |
| SSE 空闲超时 | 代理超时/无心跳 | 逐层排查 | 加心跳/调代理超时 |
| Agent 死循环 | 无迭代上限 | 看调用日志 | 设最大迭代次数 |
| 上下文爆掉 | 注入内容过多 | 算 token 数 | 动态裁剪 |
| 工具调用失败 | 超时/参数错 | 看工具日志 | 超时重试/参数校验 |
7. 我踩过的坑和几条实在建议
第一个坑是过早优化。我一开始就想着做完美的记忆系统,结果花了两周在记忆抽取上,主流程还没跑通。后来调整策略,先把主流程跑通,记忆系统用最简单的版本,再逐步迭代。生产级系统是演进出来的,不是设计出来的。
第二个坑是忽视可观测性。Agent 系统的不确定性很高,没有完善的日志和监控,出了问题根本不知道从哪查。我后来加了全链路追踪,每个环节都打点,排查效率提升了好几倍。建议一开始就把日志和监控做进去,别等出问题再补。
第三个坑是工具描述写得太随意。前面提过,工具描述的质量直接决定调用准确率。我现在的做法是,每个工具描述都要经过测试,用几个典型场景验证模型能不能正确调用。
几条实在建议:记忆系统的参数一定要根据实际数据调,别照搬网上的配置;SSE 的心跳间隔要小于代理超时的一半;MCP 工具一定要做权限控制,别图省事全放开;上下文管理要动态,别用固定窗口。
最后分享一个小技巧:Agent 的回复质量很大程度上取决于系统提示的质量。我花了不少时间打磨系统提示,把角色定义、行为准则、输出格式都写清楚,效果比调模型参数明显得多。系统提示是 Agent 的"人格",值得认真对待。
这个项目后续还可以扩展的方向不少,比如多 Agent 协作、记忆的主动遗忘机制、工具的自适应选择。但核心思路是不变的:领域建模要清晰,框架要保持可替换,流式输出要稳,工具协议要标准。把这几点做好,Agent 就能从 Demo 走向生产。