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 这些概念,以及它们之间的规则
- 基础设施层(Infrastructure):LLM 客户端、向量库、关系库、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 的参数我调过:m=16、ef_construction=64、ef_search=40。这套参数在 50 万条记忆上,检索延迟 P99 在 45ms 左右,召回率 95% 以上。ef_search可以按需调高,但会牺牲延迟。
4. SSE 流式推送:让 Agent 的思考过程可见
4.1 为什么选 SSE 而不是 WebSocket
Agent 的响应有两个特点:一是生成时间长(可能几十秒),二是过程性信息多(思考、工具调用、中间结果)。如果等全部生成完再返回,用户体验极差。所以必须流式推送。
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 环境准备与依赖选型
我的技术栈是这样的:
| 组件 | 选型 | 版本 |
|---|---|---|
| 语言 | Java | 21 |
| 框架 | Spring Boot | 3.2 |
| LLM 客户端 | 自研 HTTP 客户端 | - |
| 关系库 | PostgreSQL | 16 |
| 向量扩展 | pgvector | 0.7 |
| 缓存 | Redis | 7.2 |
| 构建 | Maven | 3.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 { List<Memory> retrieve(String userId, String query, MemoryQueryOptions options); void save(Memory memory); void update(Memory memory); void delete(String memoryId); } public interface LlmClient { LlmResponse chat(List<Message> messages, LlmOptions options); void chatStream(List<Message> messages, LlmOptions options, StreamCallback callback); } public interface McpClient { List<ToolDefinition> listTools(); ToolResult call(String toolName, Map<String, Object> args); }这些接口定义好之后,领域逻辑就可以独立开发和测试了,不依赖任何具体实现。
6.4 记忆检索的实现细节
检索这块我展开讲,因为它是核心。检索分三步:
第一步:查询改写。用户的原始查询可能很短(“那个问题怎么样了”),直接拿去检索效果差。我用一次轻量 LLM 调用把查询改写成更完整的检索语句,同时提取出时间范围、实体等结构化信息。
第二步:多路召回。向量检索召回 top-20,关键词检索召回 top-20,合并去重。
第三步:重排序。用前面讲的打分公式对候选记忆重新排序,取 top-8。
public List<Memory> retrieve(String userId, String query, MemoryQueryOptions options) { String rewritten = queryRewriter.rewrite(query, options.getSessionContext()); List<Memory> vectorResults = vectorStore.search(userId, rewritten, 20); List<Memory> keywordResults = keywordStore.search(userId, rewritten, 20); List<Memory> 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 BlockingQueue<SseEvent> 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 Map<String, McpServerConnection> connections; @Override public ToolResult call(String toolName, Map<String, 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 |
| 记忆检索 P99 | 45ms |
| 记忆写入 P99 | 120ms |
| 1000 并发 SSE 内存 | 约 1.2GB |
| 单次对话平均 token | 3200 |
| 记忆检索准确率 | 87% |
这些数据是在 4 核 8G 的机器上测的,LLM 用的是外部 API。如果你的场景差异大,这些数字只能作为量级参考。
我在实际使用中发现,记忆型 Agent 的调优是个持续过程,没有一劳永逸的参数。上线后要持续监控记忆检索的准确率、工具调用的成功率、token 消耗趋势,根据数据不断调整。最开始不要追求完美,先把链路跑通,再逐步优化每一环。踩过几次坑之后你会发现,真正难的不是技术实现,而是想清楚“什么值得记住”这个业务问题。