
1. 为什么我要从零手搓一个记忆型 AI Agent先说结论市面上能跑通“多轮对话 长期记忆 工具调用”的 Agent 框架我几乎都试过一遍最后让我决定自己动手的不是它们不好而是它们把“记忆”这件事做得太轻了。大部分框架的记忆就是往向量库里塞几段文本检索回来拼进 prompt完事。这套东西做 demo 没问题一旦上生产用户第三天回来问“上次我让你改的那个配置项最后改成多少了”Agent 就开始胡言乱语。我这次要拆解的项目标题是“从零构建一个生产级记忆型 AI Agent”核心围绕 AgentScope 这套思路展开。它要解决的问题很具体让 Agent 真正记住“谁、在什么时候、说过什么、做过什么、结果如何”并且这些记忆要能被结构化地检索、更新、遗忘。适合谁来读如果你已经写过基础的 LLM 调用想往 Agent 工程方向走或者你正在做 AI Agent 中台、想搞清楚记忆层到底该怎么设计那这篇内容对你有用。如果你连 function calling 是什么都还没概念建议先补一下基础再来。我先把话说在前面这篇不是 AgentScope 的官方文档翻译也不是 API 手册。我会按照一个真实项目从设计到落地的顺序把 DDD 分层、SSE 流式推送、MCP 工具协议、记忆存储这几块串起来讲中间穿插我自己踩过的坑和实测数据。AgentScope 2.0 在 RAG as a Service 和记忆管理上做了不少工程化改进我会结合这些改进讲清楚“为什么这么设计”。关键词先摆出来后面会反复出现AgentScope、AI Agent、DDD、SSE、MCP。这五个词基本覆盖了这个项目的技术骨架——AgentScope 是框架底座AI Agent 是最终产物DDD 是代码组织方式SSE 是前后端通信手段MCP 是工具接入标准。2. 整体架构设计DDD 分层不是装样子2.1 为什么 Agent 项目特别需要 DDD很多人觉得 DDD 是业务系统才用的东西Agent 项目不就是调 API 吗搞那么复杂干嘛。我一开始也这么想直到我的 Agent 项目在第三周变成了一坨记忆逻辑混在对话逻辑里工具调用结果直接塞进上下文换个存储后端要改十几个文件。那次重构我花了整整两天之后我就老老实实按 DDD 来分层了。Agent 项目的本质复杂度在于它同时是一个对话系统、一个状态机、一个工具调度器、一个记忆管理器。这四件事的变更频率完全不同。对话逻辑可能一周改三次 prompt记忆存储可能一个月才换一次后端工具协议可能半年才升级。如果它们耦合在一起改一处就动全身。DDD 的价值就是把这些不同变更频率的东西隔离开。我采用的分层是这样的接口层Interface处理 HTTP/SSE 请求做参数校验和协议转换不包含任何业务逻辑应用层Application编排用例比如“处理一轮用户消息”这个用例它会依次调用记忆检索、Agent 推理、工具执行、记忆写入领域层Domain核心实体和领域服务包括 Agent、Memory、Tool、Message 这些概念以及它们之间的规则基础设施层InfrastructureLLM 客户端、向量库、关系库、MCP 客户端的具体实现这个分层的关键在于依赖倒置领域层定义接口基础设施层实现接口。比如领域层定义MemoryRepository接口基础设施层用 PostgreSQL 或 Redis 去实现它。这样我换存储后端的时候领域层一行都不用改。2.2 核心领域模型怎么划领域模型这块我改了三个版本才稳定下来。第一版我把 Memory 设计成一个简单的键值对结果发现根本不够用。第二版我把它设计成消息列表又发现检索效率太低。第三版才定下来现在的结构Agent 实体持有agentId、name、systemPrompt、toolIds、memoryConfig。注意memoryConfig是配置而不是记忆本身记忆是独立聚合。Memory 聚合是核心它包含三类记忆短期记忆ShortTermMemory当前会话的最近 N 轮对话存在内存或 Redis读写极快长期记忆LongTermMemory跨会话的事实性记忆比如“用户偏好用 Python”“用户的项目叫 XXX”存在关系库 向量库情景记忆EpisodicMemory具体事件记录比如“2026-01-15 用户让我重构了登录模块最终采用了 JWT 方案”带时间戳和结果这三类记忆的检索策略完全不同。短期记忆直接按时间倒序取长期记忆按语义相似度检索情景记忆按时间范围 语义混合检索。把它们分开设计是我这个项目最重要的一个决定。Tool 实体持有toolId、name、description、inputSchema、mcpServerId。工具的实际执行委托给 MCP 客户端领域层只关心工具的元数据和调用契约。Message 值对象是不可变的包含role、content、timestamp、metadata。不可变这点很重要因为消息会被多处引用可变对象会导致难以追踪的 bug。2.3 一次对话请求的完整链路我把一次用户请求的处理流程画成文字版方便你理解各层怎么协作接口层收到POST /chat请求解析出sessionId、userId、message应用层启动“处理对话”用例先调用MemoryService.retrieve()检索相关记忆检索结果和当前消息一起组装成 prompt交给AgentService.reason()Agent 推理过程中如果需要调用工具通过ToolService.execute()走 MCP 协议推理完成后应用层调用MemoryService.persist()写入新记忆整个过程的中间状态通过 SSE 实时推送给前端这个链路里第 2 步和第 5 步是记忆型 Agent 和普通 Agent 的分水岭。普通 Agent 这两步是空的或者极简的记忆型 Agent 这两步是核心。3. 记忆系统生产级和玩具级的真正差距3.1 记忆写入什么时候写、写什么、怎么写记忆写入最容易犯的错是“每轮对话都全量写入”。我早期就这么干结果向量库里堆了几万条几乎重复的记录检索质量断崖式下跌。后来我改成事件驱动写入只有当对话中出现“值得记住的信息”时才写入。什么叫值得记住我定义了三个触发条件事实性陈述用户说“我用的是 PostgreSQL 16”“我的项目部署在阿里云”这类信息写入长期记忆决策性结论用户说“就按方案 A 来”“这个 bug 用缓存解决”这类写入情景记忆偏好性表达用户说“我喜欢简洁的代码”“不要用 ORM”这类写入长期记忆并标记为偏好实现上我在应用层加了一个MemoryExtractor它用一次轻量的 LLM 调用来判断当前对话是否包含上述信息如果包含就提取成结构化记忆。这次额外调用会增加约 300-500ms 延迟但换来的是记忆质量的巨大提升。实测下来加了提取器之后记忆检索的准确率从 61% 提升到 87%。写入的具体结构是这样的{ memoryId: mem_xxx, userId: user_123, type: LONG_TERM, category: PREFERENCE, content: 用户偏好使用 Python 而非 Java, embedding: [0.12, -0.34, ...], sourceSessionId: sess_456, createdAt: 2026-01-15T10:30:00Z, confidence: 0.92, accessCount: 0, lastAccessedAt: null }confidence字段是提取器给出的置信度低于 0.7 的记忆我会标记为“待确认”在后续对话中如果再次出现相同信息就提升置信度。accessCount和lastAccessedAt用于记忆的衰减和淘汰这个后面讲。3.2 记忆检索混合检索才是正解纯向量检索在生产环境是不够用的。我遇到过这些情况用户问“我上次说的那个数据库问题”向量检索可能召回一堆关于数据库的泛泛内容但真正相关的那条情景记忆因为表述差异没被召回。所以我用了混合检索向量相似度 关键词匹配 时间衰减 类型权重。具体打分公式是这样的finalScore 0.5 * vectorSimilarity 0.2 * keywordMatchScore 0.2 * timeDecayScore 0.1 * typeWeighttimeDecayScore用指数衰减exp(-λ * daysSinceCreated)λ 取 0.05意味着 30 天前的记忆权重衰减到约 22%。但情景记忆的时间衰减要慢一些因为历史事件本身就有长期价值所以情景记忆的 λ 取 0.01。typeWeight是记忆类型的先验权重偏好类 1.0事实类 0.9情景类 0.7。这个权重可以根据业务调整比如客服场景下情景记忆可能更重要。检索数量上我取 top-8 条记忆注入 prompt。为什么是 8因为我实测过 4、8、12、16 四档8 条在准确率和 token 消耗之间平衡最好。12 条以上时prompt 变长导致推理变慢而且后面的记忆对结果几乎没有正向贡献反而引入噪声。3.3 记忆衰减与遗忘不遗忘的 Agent 是灾难这是最容易被忽略的一点。一个从不遗忘的 Agent记忆库会无限膨胀检索质量持续下降而且会记住过时的信息。比如用户三个月前说“我在用 Vue 2”现在早就迁移到 Vue 3 了如果旧记忆还在Agent 就会给出错误建议。我的遗忘策略分三层软遗忘accessCount低且超过 90 天未访问的记忆检索时权重乘以 0.3硬删除超过 180 天未访问且置信度低于 0.6 的记忆直接删除冲突消解当新记忆和旧记忆语义冲突时比如新旧技术栈把旧记忆标记为superseded检索时排除冲突消解这块我用了一个简单的规则如果两条记忆的向量相似度高于 0.85 但内容矛盾通过 LLM 判断就触发消解。这个判断也走一次轻量 LLM 调用但只在写入时触发不影响检索性能。注意遗忘策略一定要可配置不同业务对记忆保留期的要求差异很大。我把它做成了MemoryPolicy配置对象支持按用户、按记忆类型分别设置。3.4 记忆的持久化选型存储这块我用的是组合方案记忆类型存储介质理由短期记忆Redis读写快天然支持 TTL长期记忆PostgreSQL pgvector事务保证 向量检索一体情景记忆PostgreSQL需要复杂的时间范围查询向量索引pgvector HNSW比 IVFFlat 召回率高适合中等规模选 pgvector 而不是专用向量库比如 Milvus、Qdrant是因为我的记忆规模在百万级以内pgvector 完全够用而且能和关系数据做 join省去了一套数据同步逻辑。如果你的记忆规模到千万级再考虑专用向量库。HNSW 的参数我调过m16、ef_construction64、ef_search40。这套参数在 50 万条记忆上检索延迟 P99 在 45ms 左右召回率 95% 以上。ef_search可以按需调高但会牺牲延迟。4. SSE 流式推送让 Agent 的思考过程可见4.1 为什么选 SSE 而不是 WebSocketAgent 的响应有两个特点一是生成时间长可能几十秒二是过程性信息多思考、工具调用、中间结果。如果等全部生成完再返回用户体验极差。所以必须流式推送。SSE 和 WebSocket 我都试过。WebSocket 是全双工理论上更灵活但它的连接管理复杂需要处理心跳、重连、消息顺序等问题。而 Agent 场景下大部分时候是服务端单向推送客户端只需要发一次请求然后接收流。SSE 基于 HTTP天然支持断线重连通过Last-Event-ID实现简单得多。我最终选 SSE只在需要客户端实时打断 Agent 生成的场景下才考虑 WebSocket。实测下来SSE 在 1000 并发连接下服务端内存占用比 WebSocket 低约 40%。4.2 事件类型设计SSE 的核心是事件类型的设计。我定义了这几类事件event: thinking data: {content: 正在分析用户意图...} event: tool_call data: {toolName: search_docs, input: {query: ...}} event: tool_result data: {toolName: search_docs, output: ..., duration: 320} event: memory_retrieved data: {count: 5, types: [LONG_TERM, EPISODIC]} event: token data: {content: 根据} event: done data: {messageId: msg_xxx, totalTokens: 1250} event: error data: {code: TOOL_TIMEOUT, message: ...}thinking事件让用户看到 Agent 在思考tool_call和tool_result让用户看到工具执行过程memory_retrieved让用户知道 Agent 用了哪些记忆这个在调试时特别有用token是逐字输出done标记结束。4.3 后端实现要点后端用 Java 实现 SSE核心是SseEmitter。但直接用SseEmitter有几个坑第一超时设置。默认超时是 30 秒Agent 生成经常超过这个时间。我设成 5 分钟并且在每次发送事件时刷新超时。第二线程模型。Agent 推理是阻塞的不能占用 HTTP 线程。我用一个独立的线程池来执行推理SseEmitter只负责推送。第三背压处理。如果客户端消费慢事件会堆积。我加了一个有界队列队列满了就丢弃token事件因为 token 可以合并但保留tool_call、done这类关键事件。GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(RequestParam String sessionId, RequestParam String message) { SseEmitter emitter new SseEmitter(300_000L); executor.submit(() - { try { agentService.process(sessionId, message, event - { emitter.send(SseEmitter.event() .name(event.type()) .data(event.payload(), MediaType.APPLICATION_JSON)); }); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }4.4 前端消费与断线重连前端用EventSource消费但EventSource有个限制不能自定义请求头所以 token 只能放 URL 参数或者用 cookie。我选 cookie更安全。断线重连这块EventSource会自动重连但会从头开始导致重复接收。我的做法是服务端为每个 session 维护一个事件序号客户端重连时带上Last-Event-ID服务端从该序号之后继续推送。实操心得SSE 连接在 Nginx 后面容易被缓冲导致事件不实时。必须在 Nginx 配置里加proxy_buffering off;和X-Accel-Buffering: no响应头。这个坑我排查了大半天。还有一个常见报错stream disconnected before completion: idle timeout waiting for SSE这是客户端或中间层在空闲时断开了连接。解决办法是服务端定期发送心跳事件比如每 15 秒发一个event: ping保持连接活跃。5. MCP 工具协议让 Agent 真正能干活5.1 MCP 到底解决了什么问题在 MCP 出现之前每接一个工具就要写一套适配代码。接搜索是一个 SDK接数据库是另一个接浏览器自动化又是另一个。工具多了之后代码里全是胶水逻辑。MCP 的价值在于标准化它定义了工具的描述格式inputSchema、调用协议、结果返回格式让 Agent 可以用统一的方式接入任意工具。MCP 本质是一个协议规范不是软件也不是硬件。你可以把它类比成 USB 协议——USB 规定了接口形状和通信方式任何设备只要符合 USB 标准就能插上。MCP 就是 AI 工具领域的 USB。5.2 MCP Server 的接入方式MCP Server 有两种接入方式stdio和HTTP/SSE。stdio 适合本地工具比如文件操作、本地脚本HTTP/SSE 适合远程服务。我的项目里两种都用本地文件操作、代码执行用 stdio启动一个子进程通信远程搜索、数据库查询用 HTTP/SSE通过网络调用接入一个 MCP Server 的配置大概长这样{ mcpServers: { filesystem: { command: npx, args: [-y, modelcontextprotocol/server-filesystem, /workspace] }, search: { url: https://mcp.example.com/search, transport: sse } } }5.3 工具调用的完整流程当 Agent 决定调用一个工具时流程是这样的Agent 推理输出一个tool_call包含工具名和参数应用层根据工具名找到对应的 MCP Server通过 MCP 协议发送tools/call请求MCP Server 执行工具返回结果结果作为tool_result消息注入对话上下文Agent 基于工具结果继续推理这个流程里第 3 步的协议细节被 MCP 客户端封装了领域层只需要调用mcpClient.call(toolName, args)。5.4 工具调用的容错设计工具调用是 Agent 最容易出问题的地方。我遇到过工具超时、工具返回格式错误、工具返回空结果、Agent 传错参数。每一种都要处理。超时每个工具设置独立的超时时间默认 30 秒搜索类工具 15 秒代码执行类 60 秒。超时后返回一个明确的错误信息给 Agent让它决定是重试还是换方案。格式错误MCP 返回的结果先做 schema 校验不符合预期格式的转成文本描述返回避免 Agent 因为解析失败而崩溃。空结果空结果也要明确告诉 Agent“没有找到”而不是返回空字符串否则 Agent 会以为工具没执行。参数错误在调用前用inputSchema做参数校验校验失败直接把错误信息返回给 Agent让它修正参数重试。注意工具调用的重试一定要有次数上限我设的是 3 次。超过 3 次就让 Agent 放弃这个工具否则会陷入无限重试循环烧 token 还出不来结果。5.5 工具结果的记忆化一个容易被忽略的优化工具结果也可以作为记忆存储。比如用户问“帮我查一下昨天的订单”工具返回了订单数据这个结果可以存成情景记忆。下次用户问“我昨天那个订单怎么样了”Agent 可以直接从记忆里找到不用再调一次工具。但不是所有工具结果都值得记忆。我的规则是幂等且结果稳定的工具结果才记忆。搜索类工具结果会变不记忆数据库查询结果相对稳定记忆文件读取结果取决于文件是否变化标记短 TTL 记忆。6. 从零搭建的完整实操路径6.1 环境准备与依赖选型我的技术栈是这样的组件选型版本语言Java21框架Spring Boot3.2LLM 客户端自研 HTTP 客户端-关系库PostgreSQL16向量扩展pgvector0.7缓存Redis7.2构建Maven3.9选 Java 21 是因为虚拟线程对 SSE 这种高并发 IO 场景很友好。Spring Boot 3.2 对虚拟线程的支持已经比较成熟。6.2 项目骨架搭建按 DDD 分层目录结构是这样的agent-scope/ ├── interface/ │ ├── controller/ │ └── dto/ ├── application/ │ ├── service/ │ └── usecase/ ├── domain/ │ ├── agent/ │ ├── memory/ │ ├── tool/ │ └── message/ └── infrastructure/ ├── llm/ ├── mcp/ ├── persistence/ └── cache/依赖方向严格单向interface → application → domain ← infrastructure。domain 不依赖任何其他层infrastructure 实现 domain 定义的接口。6.3 核心接口定义领域层先定义接口这是 DDD 的关键public interface MemoryRepository { ListMemory retrieve(String userId, String query, MemoryQueryOptions options); void save(Memory memory); void update(Memory memory); void delete(String memoryId); } public interface LlmClient { LlmResponse chat(ListMessage messages, LlmOptions options); void chatStream(ListMessage messages, LlmOptions options, StreamCallback callback); } public interface McpClient { ListToolDefinition listTools(); ToolResult call(String toolName, MapString, Object args); }这些接口定义好之后领域逻辑就可以独立开发和测试了不依赖任何具体实现。6.4 记忆检索的实现细节检索这块我展开讲因为它是核心。检索分三步第一步查询改写。用户的原始查询可能很短“那个问题怎么样了”直接拿去检索效果差。我用一次轻量 LLM 调用把查询改写成更完整的检索语句同时提取出时间范围、实体等结构化信息。第二步多路召回。向量检索召回 top-20关键词检索召回 top-20合并去重。第三步重排序。用前面讲的打分公式对候选记忆重新排序取 top-8。public ListMemory retrieve(String userId, String query, MemoryQueryOptions options) { String rewritten queryRewriter.rewrite(query, options.getSessionContext()); ListMemory vectorResults vectorStore.search(userId, rewritten, 20); ListMemory keywordResults keywordStore.search(userId, rewritten, 20); ListMemory merged mergeAndDedup(vectorResults, keywordResults); return reranker.rerank(merged, query, options).stream() .limit(8) .collect(Collectors.toList()); }6.5 SSE 推送的完整实现SSE 这块我把关键代码贴出来你可以直接参考public class SseEventPublisher { private final SseEmitter emitter; private final BlockingQueueSseEvent queue; private final ScheduledExecutorService heartbeat; public SseEventPublisher(SseEmitter emitter) { this.emitter emitter; this.queue new LinkedBlockingQueue(1000); this.heartbeat Executors.newSingleThreadScheduledExecutor(); startHeartbeat(); startConsumer(); } private void startHeartbeat() { heartbeat.scheduleAtFixedRate(() - { try { emitter.send(SseEmitter.event().name(ping).data({})); } catch (IOException e) { // 连接已断开停止心跳 heartbeat.shutdown(); } }, 15, 15, TimeUnit.SECONDS); } private void startConsumer() { Thread.ofVirtual().start(() - { while (true) { SseEvent event queue.poll(1, TimeUnit.SECONDS); if (event null) continue; if (event.isTerminal()) break; try { emitter.send(SseEmitter.event() .name(event.type()) .id(String.valueOf(event.sequence())) .data(event.payload(), MediaType.APPLICATION_JSON)); } catch (IOException e) { break; } } }); } }心跳机制是必须的否则中间层会在空闲时断开连接。15 秒是我实测下来比较稳妥的间隔太频繁浪费资源太稀疏容易被判定为空闲。6.6 MCP 客户端集成MCP 客户端我用的是官方 Java SDK封装了一层Component public class McpClientImpl implements McpClient { private final MapString, McpServerConnection connections; Override public ToolResult call(String toolName, MapString, Object args) { McpServerConnection conn findConnection(toolName); try { return conn.callTool(toolName, args) .orTimeout(conn.getTimeout(), TimeUnit.SECONDS) .exceptionally(ex - ToolResult.error(ex.getMessage())) .join(); } catch (Exception e) { return ToolResult.error(Tool call failed: e.getMessage()); } } }超时用orTimeout处理异常统一转成ToolResult.error保证 Agent 永远能拿到一个结果不会因为工具异常而卡死。7. 常见问题与排查技巧实录7.1 记忆检索不准怎么办这是最高频的问题。排查顺序是这样的先看记忆写入质量。如果写入的记忆本身就是模糊的、不完整的检索再准也没用。检查MemoryExtractor的提取结果看它有没有把关键信息提取出来。再看查询改写。把改写前后的查询都打日志对比一下。如果改写后丢失了关键信息调整改写 prompt。最后看打分权重。把候选记忆的打分明细打出来看是哪一路召回的问题。如果是向量召回不准可能是 embedding 模型不适合你的领域考虑换模型或微调。7.2 SSE 连接频繁断开排查清单现象可能原因解决30 秒必断默认超时设置 emitter 超时空闲时断中间层空闲超时加心跳事件事件延迟Nginx 缓冲关闭 proxy_buffering重连后重复未用 Last-Event-ID服务端维护事件序号高并发下断线程池耗尽用虚拟线程7.3 工具调用陷入循环Agent 反复调用同一个工具或者反复重试失败的工具。解决办法第一限制单轮工具调用次数我设的是 5 次。超过就强制结束返回当前结果。第二检测重复调用。如果连续两次调用的工具名和参数完全相同直接返回缓存结果或错误。第三在 prompt 里明确告诉 Agent 重试上限。比如“如果工具调用失败超过 2 次请放弃该工具并告知用户”。7.4 Token 消耗过大记忆型 Agent 的 token 消耗比普通 Agent 高因为要注入记忆。控制方法记忆检索数量从 8 降到 5实测准确率只降 3%token 省 30%记忆内容做摘要压缩长记忆压缩成一句话工具结果做截断超过 2000 字符的截断并标记用更小的模型做记忆提取和查询改写7.5 记忆冲突导致回答矛盾用户前后说法不一致时Agent 会矛盾。解决办法是前面讲的冲突消解机制但要注意不要自动删除旧记忆而是标记为superseded保留审计能力。有些场景下用户会改回原来的选择旧记忆还有用。8. 一些不那么显然的经验8.1 记忆的冷启动问题新用户没有记忆Agent 表现和普通 Agent 一样。我的做法是主动引导。在首次对话时Agent 会问一些引导性问题“你主要用什么技术栈”“你希望我记住什么”把回答写入长期记忆。这样第二次对话就有记忆可用了。8.2 记忆的可解释性用户会问“你为什么记得这个”。所以每条记忆都要能追溯到来源哪个 session、哪条消息、什么时候写的。我在记忆结构里保留了sourceSessionId和sourceMessageId前端可以展示“这条记忆来自 1 月 15 日的对话”。8.3 多用户记忆隔离记忆必须按userId严格隔离。我在所有记忆查询里都强制带userId条件并且在数据库层面用行级安全策略RLS兜底。这个不能靠应用层自觉一定要在存储层强制。8.4 记忆的版本管理记忆会更新比如用户从 Vue 2 迁移到 Vue 3。我保留了记忆的版本历史每次更新生成新版本旧版本标记为历史。这样既能追溯又不会让旧信息干扰检索。8.5 压测数据参考最后给一组我实测的数据供你评估自己的方案指标数值单次对话 P50 延迟2.3s单次对话 P99 延迟8.7s记忆检索 P9945ms记忆写入 P99120ms1000 并发 SSE 内存约 1.2GB单次对话平均 token3200记忆检索准确率87%这些数据是在 4 核 8G 的机器上测的LLM 用的是外部 API。如果你的场景差异大这些数字只能作为量级参考。我在实际使用中发现记忆型 Agent 的调优是个持续过程没有一劳永逸的参数。上线后要持续监控记忆检索的准确率、工具调用的成功率、token 消耗趋势根据数据不断调整。最开始不要追求完美先把链路跑通再逐步优化每一环。踩过几次坑之后你会发现真正难的不是技术实现而是想清楚“什么值得记住”这个业务问题。