1. 为什么我要从零手搓一个记忆型 AI Agent
2025 年底到 2026 年初这段时间,我陆续接触了七八个 AI Agent 项目,有做客服的、有做代码助手的、也有做企业内部知识问答的。踩了一圈坑之后我发现一个共性痛点:大部分所谓“Agent”其实只是套了一层 Prompt 的聊天机器人,会话一断,上下文全丢,用户昨天说过什么、系统上周处理过什么,一概不记得。这种“金鱼记忆”在 Demo 阶段看着还行,一旦放到生产环境,用户体验直接崩盘。
所以我决定从零构建一个生产级记忆型 AI Agent,技术选型上锁定了 AgentScope 这套框架。原因很简单:它原生支持多智能体协作、内置记忆管理抽象、对 SSE 流式输出和 HITL(Human-in-the-Loop)有比较成熟的封装,而且 2.0 版本开始往企业级方向走,DDD 架构分层也做得比较清晰。这篇文章我会把整个项目的设计思路、核心实现、踩坑记录全部摊开讲,适合两类人看:一是想从 0 到 1 搭建 AI Agent 的开发者,二是已经在用 AgentScope 但想深入理解其内部机制的同学。
先给个全局认知:这个项目最终实现的能力包括——多轮对话记忆持久化、SSE 流式实时渲染、人工介入审批、工具调用编排、以及基于 DDD 分层的可扩展架构。技术栈是 Java 为主(AgentScope Java 2.0),前端 React + SSE,存储层用了 Redis 做短期记忆、PostgreSQL 做长期记忆。下面逐层拆解。
2. 整体架构设计与技术选型逻辑
2.1 为什么选 DDD 而不是传统三层架构
一开始我其实想用 Controller-Service-DAO 这套经典三层结构快速堆出来,但写到第三个 Agent 类型的时候发现不对劲了:对话记忆、工具调用、审批流、流式输出这几块逻辑互相纠缠,Service 层越来越臃肿,改一个记忆策略要动五六个文件。这就是典型的贫血模型问题——业务逻辑全堆在 Service 里,领域概念没有自己的行为。
转成 DDD 之后,我把核心领域拆成了几个聚合:
- Conversation 聚合:管理会话生命周期、消息序列、上下文窗口
- Memory 聚合:短期记忆(Redis)和长期记忆(向量库)的统一抽象
- Agent 聚合:智能体配置、工具注册、执行策略
- Approval 聚合:HITL 审批流的状态机
每个聚合有自己的仓储接口,应用层只负责编排,不碰业务规则。这么改完之后,新增一个记忆策略只需要实现 Memory 聚合的一个接口,其他层完全不用动。这就是 DDD 在 AI Agent 场景下的价值——领域边界清晰,扩展成本低。
2.2 SSE 还是 WebSocket,我为什么最终选了 SSE
流式输出这块我纠结了很久。WebSocket 是全双工,理论上更灵活,但实际用下来有几个问题:一是连接管理复杂,需要自己处理心跳、重连、粘包;二是企业内网环境下,部分代理对 WebSocket 支持不好;三是我们的场景本质上是服务端单向推送,用户输入走普通 HTTP POST 就够了,不需要全双工。
SSE(Server-Sent Events)正好匹配这个场景。它基于 HTTP,浏览器原生支持 EventSource,服务端用 Spring 的SseEmitter就能推。唯一要注意的是空闲超时问题——我遇到过stream disconnected before completion: idle timeout waiting for SSE这个报错,原因是 Agent 在调用工具时思考时间过长,超过了默认超时。解决办法是加心跳事件,每 15 秒推一个 comment 类型的空事件保活。
提示:SSE 的
retry字段可以控制客户端重连间隔,生产环境建议设为 3000ms,避免断线后疯狂重连打爆服务端。
2.3 记忆分层:短期用 Redis,长期用向量库
记忆这块我做了明确分层。短期记忆存最近 N 轮对话,用 Redis 的 List 结构,读写快,设置 TTL 自动过期。长期记忆存用户偏好、历史决策、重要事实,用向量库做语义检索。两层之间有个“记忆固化”过程:当短期记忆超过阈值(我设的是 20 轮),触发一次摘要压缩,把关键信息抽取出来写入长期记忆。
这个设计的核心考量是成本和效果的平衡。如果全部塞进上下文窗口,Token 成本爆炸;如果全部走向量检索,又会丢失最近对话的连贯性。分层之后,最近对话保证连贯,历史信息按需召回,实测下来 Token 消耗降低了约 60%,而用户感知的“记忆准确度”反而提升了。
3. 核心模块的实操实现细节
3.1 记忆聚合的领域模型设计
先看 Memory 聚合的核心接口设计。我定义了一个MemoryStore接口,短期和长期实现分别继承它:
public interface MemoryStore { void save(String conversationId, Message message); List<Message> recall(String conversationId, int limit); List<Message> semanticSearch(String conversationId, String query, int topK); }短期记忆实现RedisShortTermMemory,用RPUSH追加消息,LRANGE取最近 N 条。这里有个细节:消息要带时间戳和角色标记,否则多轮对话顺序会乱。我用的结构是conversationId:timestamp:role作为 key 的一部分,value 存 JSON 序列化的消息体。
长期记忆实现VectorLongTermMemory,写入时先调 Embedding 模型把文本转向量,再存进向量库。检索时用余弦相似度找 TopK。这里踩过一个坑:Embedding 模型的选择直接影响召回质量。我一开始用了某个轻量模型,结果语义检索经常召回不相关内容,换成维度更高的模型后明显改善。建议在生产环境用至少 1024 维的 Embedding 模型。
3.2 SSE 流式输出的完整实现
SSE 这块是前端体验的关键。服务端我用 Spring 的SseEmitter,核心代码如下:
@GetMapping("/agent/stream") public SseEmitter stream(@RequestParam String conversationId, @RequestParam String input) { SseEmitter emitter = new SseEmitter(300_000L); executor.execute(() -> { try { agentService.process(conversationId, input, chunk -> { emitter.send(SseEmitter.event() .name("message") .data(chunk)); }); emitter.send(SseEmitter.event().name("done").data("[DONE]")); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }前端 React 侧用 EventSource 接收:
const es = new EventSource(`/agent/stream?conversationId=${id}&input=${encodeURIComponent(text)}`); es.addEventListener('message', (e) => { setContent(prev => prev + e.data); }); es.addEventListener('done', () => es.close());这里有几个实操要点。第一,超时时间要设够,我设的是 300 秒,因为复杂 Agent 任务可能跑几分钟。第二,要处理 abort 场景,用户点“停止生成”时前端调es.close(),后端要监听连接断开事件,及时中断 Agent 执行,否则会浪费算力。第三,心跳保活必须加,我单独起了一个定时任务每 15 秒推一个空 comment。
注意:SSE 默认不支持自定义 Header,如果需要传 Token 做鉴权,要么放 URL 参数(注意脱敏),要么用 POST + fetch 的 ReadableStream 方案替代 EventSource。
3.3 HITL 人工介入审批流
HITL 是生产级 Agent 的必备能力。想象一下,Agent 要执行一个删除数据库的操作,你敢让它自动跑吗?我的设计是:高风险工具调用前挂起,推一个审批事件给前端,等人工确认后再继续。
实现上用了状态机。Approval 聚合有几个状态:PENDING、APPROVED、REJECTED、TIMEOUT。Agent 执行到需要审批的步骤时,创建一个 Approval 记录,状态设为 PENDING,然后通过 SSE 推一个approval_required事件。前端弹出审批框,用户点确认后调审批接口,状态流转到 APPROVED,Agent 继续执行。
这里的关键是超时处理。我设了 5 分钟超时,超时后自动拒绝并让 Agent 走降级路径。另外,审批记录要持久化,方便审计。这块我用 PostgreSQL 存,字段包括审批 ID、会话 ID、工具名、参数、审批人、审批时间、结果。
3.4 工具调用编排与执行隔离
Agent 的工具调用我用了一个ToolRegistry做注册中心,每个工具实现Tool接口:
public interface Tool { String name(); String description(); ToolResult execute(Map<String, Object> params); boolean requiresApproval(); }requiresApproval()返回 true 的工具会走 HITL 流程。执行时用线程池做隔离,避免某个工具卡死拖垮整个 Agent。每个工具调用设了独立超时,默认 30 秒,超时抛异常让 Agent 决定是否重试。
工具描述这块我特别想强调:description 的质量直接决定 Agent 会不会正确调用工具。我一开始写得太简略,Agent 经常选错工具。后来改成“功能 + 适用场景 + 参数说明 + 返回示例”的格式,调用准确率明显提升。这其实就是 Prompt 工程在工具层面的体现。
4. 实操过程中的典型问题与排查
4.1 SSE 连接频繁断开怎么排查
这是我最开始遇到的头号问题。现象是前端流式输出到一半就断了,控制台报stream disconnected before completion。排查思路分三步:
第一步,看服务端日志有没有异常。如果没有异常,说明是连接层的问题。第二步,检查超时配置。Spring 的SseEmitter默认超时是 30 秒,Agent 思考超过 30 秒就会断。我改成 300 秒后好了很多。第三步,如果还断,加心跳。我遇到过一次是中间有负载均衡设备,空闲 60 秒就掐连接,加心跳后解决。
下面是我整理的排查速查表:
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 30 秒左右必断 | SseEmitter 默认超时 | 调大 timeout 参数 |
| 60 秒左右必断 | 中间设备空闲超时 | 加心跳事件保活 |
| 随机断开 | 线程池耗尽 | 检查 executor 配置 |
| 断开无日志 | 客户端主动关闭 | 监听 onCompletion 回调 |
4.2 记忆召回不准确的处理经验
长期记忆召回不准,通常有三个原因。一是 Embedding 模型太弱,语义理解不到位。二是分块策略有问题,把一段完整信息切碎了。三是检索时没做重排序,TopK 里混入了噪声。
我的处理方案:换更强的 Embedding 模型,分块时按语义边界切(比如按段落而不是按固定字数),检索后加一层 Rerank 模型做精排。这三板斧下去,召回准确率从大概 60% 提到了 85% 以上。另外,给记忆加元数据标签也很重要,比如按时间、按主题打标签,检索时可以先用标签过滤再语义搜索,效果更好。
4.3 Agent 陷入死循环怎么破
Agent 调用工具失败后重试,重试又失败,无限循环——这个坑我踩过。解决办法是加最大迭代次数限制,我设的是 10 次。超过就强制终止,返回一个兜底回复。另外,每次工具调用失败后,要把失败原因写回上下文,让 Agent 知道“这条路走不通了”,而不是傻傻地重复。
还有一个隐蔽的死循环是两个 Agent 互相等待。多智能体协作时,A 等 B 的输出,B 等 A 的输出,死锁。这个要靠超时机制兜底,每个 Agent 的执行都设超时,超时后走降级逻辑。
提示:生产环境一定要给 Agent 执行加“预算”概念,包括最大迭代次数、最大 Token 消耗、最大执行时长,任一超限就终止。这是防止成本失控的关键。
4.4 并发场景下的记忆一致性问题
多个请求同时操作同一个会话的记忆,会出现覆盖问题。比如用户快速发了两条消息,两个请求同时读记忆、改记忆、写记忆,后写的覆盖先写的。解决办法是给会话加分布式锁,用 Redis 的SETNX实现,锁的粒度是 conversationId。拿到锁的请求才能操作记忆,其他请求排队等待。
锁的超时时间要设合理,太短会导致锁提前释放,太长会导致请求堆积。我设的是 10 秒,配合看门狗机制自动续期。这块用 Redisson 会比较省心,它内置了看门狗。
5. 生产部署与性能调优的实战心得
5.1 部署架构与资源配置
生产环境我用的架构是:Nginx 做负载均衡,后面挂多个 Agent 服务实例,Redis 做会话和短期记忆存储,PostgreSQL 做持久化,向量库单独部署。Agent 服务是无状态的,可以水平扩展,但要注意会话粘性——同一个会话的请求最好打到同一个实例,否则记忆读取会有延迟。我在 Nginx 层用ip_hash做了简单粘性,更精细的方案是用一致性哈希按 conversationId 路由。
资源配置上,Agent 服务是 IO 密集型,CPU 不用给太多,但内存要给够,因为要缓存模型客户端和工具实例。我每个实例配了 4 核 8G,实测能扛住约 200 并发会话。向量库比较吃内存,单独给了 16G。
5.2 性能瓶颈定位与优化
上线后我做过一轮压测,发现瓶颈主要在两个地方。一是 Embedding 调用,每次记忆写入都要调一次,延迟高。优化方案是批量写入 + 异步处理,记忆写入不阻塞主流程,丢到消息队列里异步做。二是 SSE 连接数,每个连接占一个线程,连接多了线程池不够用。优化方案是用 WebFlux 的响应式方案替代阻塞式 SseEmitter,一个线程能扛更多连接。
优化后,单实例并发能力从 200 提到了 800 左右,P99 延迟从 3 秒降到了 800 毫秒。这个提升在生产环境是很可观的。
5.3 监控与告警体系
生产级系统离不开监控。我埋了几个关键指标:Agent 执行成功率、平均执行时长、工具调用失败率、SSE 连接数、记忆召回命中率。用 Micrometer 采集,Prometheus 存储,Grafana 展示。告警规则设了三条:执行成功率低于 95% 告警、P99 延迟超过 5 秒告警、SSE 连接数超过阈值告警。
日志这块,我给每个会话打了 traceId,从请求进来到 Agent 执行完,全链路日志可以用 traceId 串起来。排查问题时特别有用,能快速定位是哪个环节出的问题。
6. 关于 AgentScope 2.0 的一些个人观察
AgentScope 2.0 往企业级方向走这个定位我觉得是对的。它把 RAG as a Service 做成了内置能力,省去了自己搭检索管道的功夫。DDD 分层也比 1.x 清晰很多,领域概念明确,扩展点设计得比较合理。Java 版本对企业开发者友好,毕竟大部分企业后端还是 Java 栈。
不过也有几个我觉得可以改进的地方。一是文档还不够完善,有些高级用法得看源码才能搞明白。二是多智能体协作的调试工具比较弱,出问题时不好定位是哪个 Agent 的锅。三是记忆管理的抽象层还可以再厚一点,现在有些场景还是得自己实现。
如果你也在用 AgentScope 做项目,我的建议是:先把单 Agent 跑通,再上多智能体。多智能体的复杂度是单 Agent 的好几倍,调试成本很高。另外,记忆策略要尽早设计,不要等业务跑起来再补,那时候改造成本会很大。
最后分享一个我踩过的坑:Agent 的 System Prompt 不要写太长。我一开始把工具说明、记忆规则、输出格式全塞进 System Prompt,结果 Token 消耗巨大,而且 Agent 经常忽略后面的指令。后来拆成多个部分,核心指令放 System Prompt,工具说明走 Function Calling 的 schema,输出格式用 Few-shot 示例,效果反而更好。Prompt 工程这件事,少即是多,把该结构化的东西结构化,别全堆在自然语言里。