
1. 为什么我要从零手搓一个记忆型 AI Agent市面上开箱即用的 Agent 框架已经多到挑花眼但我还是决定从零构建一个生产级记忆型 AI Agent。原因很直接大部分教程和脚手架只教你跑通一个“能聊天”的 Demo一旦涉及多轮记忆持久化、流式输出实时渲染、人工介入审批HITL、领域模型分层这些真正上生产才会遇到的问题文档就开始语焉不详。我踩过的坑包括但不限于对话历史越滚越长导致上下文爆炸、SSE 连接在代理层被静默掐断、Agent 自作主张执行了不该执行的操作。这个项目要解决的核心问题有三个。第一记忆——Agent 不能是金鱼脑它需要短期会话记忆加长期事实记忆并且要能按需检索而不是全量塞进 prompt。第二可控——生产环境里 Agent 的每一个关键动作都应该有人工确认的入口这就是 HITLHuman-in-the-Loop的价值。第三可观测——流式输出让用户看到“正在思考”的过程而不是盯着转圈等待。适合谁来参考如果你已经写过简单的 LLM 调用想往企业级 Agent 方向走或者你是一名 Java/Spring 技术栈的开发者想搞清楚 AgentScope 这类框架背后的设计哲学那这篇内容会对你有用。我会用 DDD 的分层思路来组织代码结构用 SSE 做流式传输把记忆、工具调用、人工审批这几块拼成一个完整的闭环。全文基于我自己的实操记录参数和步骤都可以直接抄作业。2. 整体架构设计与技术选型思路2.1 用 DDD 分层拆解 Agent 的领域模型很多人写 Agent 就是一个大 Service 类从头写到尾工具调用、记忆管理、prompt 拼装全揉在一起。这种写法在 Demo 阶段没问题但一旦要加新工具、换记忆策略、接入审批流代码就会变成一团乱麻。我选择用 DDD领域驱动设计的思路来分层核心是把“Agent 是什么”和“Agent 怎么运行”分开。我的分层是这样的领域层放核心概念包括Agent、Memory、Tool、Session这几个聚合根它们只表达业务规则不关心存储和传输。应用层负责编排比如AgentOrchestrator协调一次对话的完整流程取记忆、拼 prompt、调模型、执行工具、写回记忆。基础设施层处理具体实现比如用 Redis 存短期记忆、用向量库存长期记忆、用 SSE 推流。接口层就是 Controller接收请求、返回流。这样分的好处是当我想把记忆从内存换成 Redis 时只需要动基础设施层的实现领域层和应用层的代码一行不用改。这就是 DDD 在 Agent 场景下的实际价值——让变化点隔离。Agent 这个领域变化极快今天用这个模型明天换那个今天加这个工具明天加那个没有清晰的分层根本扛不住。2.2 为什么选 SSE 而不是 WebSocket 做流式输出流式输出是 Agent 体验的关键。用户问一个问题模型可能要思考好几秒如果等全部生成完再返回用户会以为卡死了。流式输出让 token 一个个蹦出来体验完全不同。传输层我选了 SSEServer-Sent Events而不是 WebSocket。原因有三点。第一Agent 的交互模式是单向推送为主——服务端把模型生成的 token 推给客户端客户端不需要频繁往服务端发消息SSE 天然就是单向的比 WebSocket 的双工更简单。第二SSE 基于 HTTP能直接复用现有的鉴权、网关、负载均衡设施WebSocket 需要额外的协议升级处理很多企业网关对 WebSocket 支持并不好。第三SSE 自带断线重连语义浏览器端的EventSource会自动重连虽然生产环境我建议自己控制重连逻辑但这个默认行为在调试时很省心。当然 SSE 也有坑。最典型的就是stream disconnected before completion: idle timeout waiting for SSE这个报错我在 Nginx 和某些云负载均衡后面都遇到过。根因是中间层有个空闲超时如果两个 token 之间间隔超过阈值连接就被掐了。解决办法后面会详细讲。2.3 记忆系统的双层设计短期会话与长期事实记忆是“记忆型 Agent”的灵魂。我把记忆分成两层。短期记忆是当前会话的对话历史按 session 隔离存最近 N 轮对话超出部分做摘要压缩。长期记忆是跨会话的事实性知识比如“用户偏好用中文回答”“用户所在团队用的是 Spring 技术栈”这些事实抽取出来后存进向量库下次对话时按语义相似度检索。为什么不把所有历史都塞进 prompt因为上下文窗口是有限且昂贵的。假设每轮对话 500 token50 轮就是 25000 token还没算工具调用的返回结果。全量塞进去不仅贵还会稀释模型的注意力导致它忽略真正重要的指令。双层设计让短期记忆保证连贯性长期记忆保证个性化各司其职。短期记忆的摘要压缩我用的是“滑动窗口 定期摘要”策略保留最近 10 轮原文更早的对话每积累 5 轮就调一次模型做摘要把摘要作为一条特殊的系统消息插在历史前面。这样既控制了 token 量又不丢失关键信息。2.4 HITL 人工介入机制的设计取舍HITL 是生产级 Agent 和玩具 Agent 的分水岭。玩具 Agent 拿到工具就直接执行生产级 Agent 在执行敏感操作前必须让人确认。比如 Agent 要调用“删除文件”或“发送邮件”这类工具应该暂停下来把即将执行的动作展示给用户等用户点确认再继续。我的设计是基于中断的审批流。Agent 在执行工具前先判断这个工具是否标记为requiresApproval。如果是就生成一个审批请求通过 SSE 推给前端然后 Agent 的执行线程挂起等待审批结果。审批结果通过另一个 HTTP 接口回传唤醒挂起的线程继续执行或取消。这里有个技术难点SSE 连接是长连接但审批可能等很久不能让 SSE 连接一直占着。我的做法是把“等待审批”和“SSE 推送”解耦——SSE 只负责推送审批请求事件然后正常关闭这一轮流用户审批后发起新的请求Agent 从持久化的执行状态恢复。这样即使审批等了一小时也不会有连接超时问题。3. 核心模块的细节拆解与实操要点3.1 Agent 执行循环的状态机设计Agent 的核心是一个执行循环但很多人把它写成while(true)加一堆 if-else状态散落在各处。我用显式状态机来管理状态包括IDLE、THINKING、TOOL_CALLING、WAITING_APPROVAL、RESPONDING、DONE、ERROR。每次状态转移都有明确的触发条件和副作用。比如从THINKING到TOOL_CALLING触发条件是模型返回了工具调用请求副作用是记录工具调用日志。从TOOL_CALLING到WAITING_APPROVAL触发条件是工具需要审批副作用是持久化当前执行上下文以便审批后恢复。用状态机的好处是可恢复性。生产环境里 Agent 执行可能因为各种原因中断——审批等待、服务重启、超时。如果状态是隐式散落的恢复时根本不知道执行到哪了。显式状态机加上持久化的执行上下文让 Agent 可以从任意状态恢复。这是我踩过最大的坑之后才加上的设计早期版本服务重启后正在等待审批的 Agent 全部丢失用户审批完发现 Agent 已经“失忆”了。3.2 工具调用的注册、发现与安全边界工具是 Agent 的手脚。我的工具注册用注解加反射的方式定义一个AgentTool注解标注工具名称、描述、参数 schema、是否需要审批。启动时扫描所有标注了注解的方法注册到工具注册表。AgentTool( name queryOrder, description 根据订单号查询订单详情, requiresApproval false ) public OrderResult queryOrder(ToolParam(orderId) String orderId) { return orderService.getById(orderId); }参数 schema 我用 JSON Schema 描述这样能直接喂给模型做 function calling。这里有个细节工具描述的质量直接决定模型会不会正确调用。我见过太多人把描述写成“查询订单”模型根本不知道什么时候该用。好的描述应该包含使用场景和参数说明比如“当用户询问订单状态、物流信息时使用此工具orderId 是订单的唯一标识格式为 18 位数字”。安全边界方面我给每个工具打了风险等级标签READ_ONLY、WRITE、DESTRUCTIVE。READ_ONLY直接执行WRITE记录审计日志DESTRUCTIVE强制走审批。这个分级不是拍脑袋定的而是根据“操作可逆性”来判断——查询可逆写入大部分可逆删除不可逆。3.3 记忆的写入、检索与压缩策略记忆写入分两个时机。实时写入每轮对话结束后把用户消息和 Agent 回复写入短期记忆。异步抽取后台任务定期扫描短期记忆用模型抽取事实性知识写入长期记忆。抽取的 prompt 我调了很多版最终稳定在“从以下对话中提取关于用户的持久性事实忽略一次性的问题和寒暄输出 JSON 数组”。记忆检索用向量相似度。长期记忆存进向量库时除了事实文本本身还存了元数据来源会话 ID、抽取时间、置信度。检索时按相似度排序取 top-k再按置信度过滤。这里有个经验相似度阈值比 top-k 更重要。早期我只取 top-3结果经常检索出不相关的事实污染 prompt。后来加了 0.75 的相似度阈值低于阈值的一条都不要效果明显变好。记忆压缩针对短期记忆。当短期记忆的 token 数超过阈值我设的是 4000触发压缩保留最近 10 轮更早的调模型做摘要。摘要 prompt 要求“保留关键决策、用户偏好、未完成的任务忽略寒暄和重复内容”。压缩后的摘要作为一条system角色的消息插入历史标记为[历史摘要]。3.4 SSE 流式输出的实现细节与背压处理SSE 服务端实现用 Spring 的SseEmitter或者 WebFlux 的FluxServerSentEvent。我用的是 WebFlux因为背压处理更自然。模型返回的 token 流是一个FluxString直接 map 成 SSE 事件推给客户端。GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString chatStream(RequestParam String sessionId, RequestParam String message) { return agentService.chat(sessionId, message) .map(token - ServerSentEvent.builder(token).event(token).build()) .concatWith(Flux.just(ServerSentEvent.builder([DONE]).event(done).build())); }背压是个容易被忽略的问题。如果模型生成速度远快于网络传输速度token 会在内存里堆积。WebFlux 的Flux天然支持背压但前提是下游要正确消费。我在客户端用EventSource时发现它不支持背压所以生产环境我建议用fetch加ReadableStream手动处理这样可以控制消费速率。心跳机制必须加。SSE 连接如果长时间没有数据中间层会掐断。我每 15 秒发一个注释行:heartbeat作为心跳这个不会触发客户端的onmessage但能保持连接活跃。注意心跳间隔要小于中间层的空闲超时我遇到过某网关默认 30 秒超时心跳设 15 秒刚好安全。4. 完整实操流程与关键环节实现4.1 环境准备与项目骨架搭建技术栈我选的是 Spring Boot 3.2 WebFlux Spring AI Redis PostgreSQL带 pgvector 扩展。选 Spring AI 是因为它提供了模型调用的统一抽象换模型不用改业务代码。选 pgvector 而不是专门的向量库是因为生产环境少维护一个组件就少一份运维负担pgvector 的性能对中小规模记忆完全够用。项目骨架按 DDD 分层建包com.example.agent ├── domain # 领域层Agent, Memory, Tool, Session ├── application # 应用层Orchestrator, CommandService ├── infrastructure # 基础设施RedisMemoryRepo, VectorMemoryRepo, LlmClient └── interfaces # 接口层ChatController, ApprovalController依赖方面核心的几个spring-boot-starter-webflux、spring-ai-openai-spring-boot-starter、spring-boot-starter-data-redis-reactive、postgresql加pgvector的 JDBC 驱动。版本上 Spring AI 我用的是 1.0.0-M4注意 milestone 版本 API 可能变动锁定版本很重要。4.2 领域模型与聚合根的代码落地领域层的Agent聚合根我设计成不可变对象每次状态变化返回新实例。这样并发安全也方便做事件溯源。public record Agent(AgentId id, SessionId sessionId, AgentState state, ListMessage shortTermMemory, ListToolCall pendingTools) { public Agent transitionTo(AgentState newState) { return new Agent(id, sessionId, newState, shortTermMemory, pendingTools); } }Memory聚合根管理短期和长期记忆的读写。短期记忆用DequeMessage保证顺序长期记忆的检索接口定义在领域层实现放在基础设施层。Tool聚合根包含工具元数据和执行逻辑的引用执行逻辑本身通过依赖倒置注入领域层不依赖具体实现。这里有个 DDD 的实践要点聚合根之间通过 ID 引用不直接持有对象。Agent持有SessionId而不是Session对象避免加载一个 Agent 时把整个 Session 都拉出来。这在记忆量大时对性能影响很大。4.3 记忆持久化与向量检索的接入短期记忆存 Rediskey 是session:{sessionId}:messages用 List 结构LPUSH写、LRANGE读。设置 TTL 为 7 天过期自动清理。长期记忆存 PostgreSQL表结构CREATE TABLE long_term_memory ( id BIGSERIAL PRIMARY KEY, session_id VARCHAR(64) NOT NULL, content TEXT NOT NULL, embedding vector(1536) NOT NULL, confidence FLOAT DEFAULT 1.0, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX ON long_term_memory USING ivfflat (embedding vector_cosine_ops);embedding 维度 1536 对应的是常见的 embedding 模型输出维度换模型时要同步改。ivfflat 索引的lists参数我设的是 100经验值是数据量的平方根数据量大了要重建索引。检索时先算 query 的 embedding然后SELECT content, 1 - (embedding :queryEmbedding) AS similarity FROM long_term_memory WHERE session_id :sessionId AND 1 - (embedding :queryEmbedding) 0.75 ORDER BY embedding :queryEmbedding LIMIT 5;注意是余弦距离1 - 距离才是相似度。这个 SQL 我调了好几版早期忘了加 session 过滤导致跨会话记忆串了用户 A 的偏好影响到了用户 B这是严重的安全问题。4.4 流式对话与审批中断的联调实录完整流程走一遍。用户发消息ChatController收到请求调AgentOrchestrator.chat()。Orchestrator 先从 Redis 取短期记忆从向量库检索长期记忆拼成 prompt 调模型。模型返回流式 tokenOrchestrator 一边推 SSE 一边累积完整回复。如果模型返回了工具调用请求Orchestrator 检查工具的风险等级。READ_ONLY直接执行结果作为新消息继续调模型。DESTRUCTIVE则进入审批流程持久化当前 Agent 状态到 Redis通过 SSE 推送一个approval_required事件包含工具名、参数、审批 ID然后结束这一轮流。前端收到approval_required后弹出确认框。用户点确认前端调POST /approval/{approvalId}/approve。ApprovalController收到后从 Redis 恢复 Agent 状态继续执行工具调用然后发起新的一轮 SSE 流把后续结果推给前端。联调时踩的坑审批后恢复执行时短期记忆里缺少了“工具调用请求”这条消息导致模型不知道自己在干什么。修复方法是在持久化 Agent 状态时把待审批的工具调用也写进短期记忆恢复时一起加载。5. 常见问题与排查技巧实录5.1 SSE 连接中断与超时问题速查stream disconnected before completion: idle timeout waiting for SSE这个报错我遇到太多次了整理成排查表现象可能原因排查方法解决固定 30/60 秒断开中间层空闲超时看 Nginx/网关配置加心跳间隔小于超时随机断开网络抖动抓包看 TCP 状态客户端加重连大响应时断开缓冲区溢出看代理 buffer 配置关闭代理缓冲首字节就断鉴权失败看响应头检查 token 传递Nginx 的配置要改三处proxy_buffering off关闭缓冲proxy_read_timeout 300s延长读超时proxy_set_header Connection 清空 Connection 头。云负载均衡一般有独立的空闲超时设置要单独调。客户端重连我建议自己实现不要依赖EventSource的自动重连。因为自动重连会从头开始丢失上下文。我的做法是记录最后收到的事件 ID重连时带上Last-Event-ID头服务端从该 ID 之后继续推。这要求服务端把事件 ID 和内容持久化我用 Redis 存最近 100 个事件。5.2 记忆膨胀与上下文超限的应对上下文超限的报错通常是maximum context length exceeded。根因是短期记忆加长期记忆加工具返回结果的总 token 超了模型窗口。排查步骤先打印每次请求的 token 数定位是哪部分膨胀。我的应对策略是三级防线。第一级短期记忆超过 4000 token 触发压缩。第二级长期记忆检索限制 top-5 且相似度阈值 0.75。第三级工具返回结果超过 2000 token 时截断只保留前 1000 和后 1000中间用...[截断]...标记。三级防线下来基本不会超限。有个隐蔽的坑工具返回的 JSON 里可能有大量无用字段。比如查订单返回了 50 个字段模型只需要 5 个。我在工具实现里做了字段裁剪只返回模型需要的字段token 量直接降了 80%。5.3 工具调用失败的分类与重试策略工具调用失败分三类。参数错误模型生成的参数不符合 schema这类不重试把错误信息返回给模型让它重新生成。临时故障网络超时、下游服务 503这类重试 3 次指数退避。业务错误比如订单不存在这类不重试把业务错误返回给模型让它向用户解释。重试要幂等。READ_ONLY工具天然幂等随便重试。WRITE工具要加幂等键我用的是sessionId toolName 参数hash下游服务根据幂等键去重。DESTRUCTIVE工具不自动重试失败了让用户决定。5.4 生产环境部署的注意事项部署上Agent 服务要无状态所有状态存 Redis 和 PostgreSQL这样才能水平扩展。但有个例外正在等待审批的 Agent 状态如果只存内存服务重启就丢了。所以审批状态必须持久化我用 Redis 的 Hash 结构存key 是approval:{approvalId}TTL 设 24 小时。监控要覆盖几个关键指标SSE 连接数、平均对话轮次、工具调用成功率、审批通过率、记忆检索命中率。我用 Micrometer 打点Prometheus 采集Grafana 展示。其中审批通过率特别值得关注如果某类工具的审批通过率极低说明 Agent 调用这个工具的时机不对需要优化工具描述或 prompt。日志要记录完整的对话链路但要注意脱敏。用户消息里可能有敏感信息我做了正则过滤手机号、身份证号、邮箱都替换成占位符。日志保留 30 天方便排查问题。最后分享一个我在实际使用中的体会Agent 的 prompt 不是写一次就完事的它需要持续迭代。我建了一个“失败案例库”每次 Agent 表现不好就记下来定期分析是 prompt 问题、工具描述问题还是记忆检索问题。这个习惯让我的 Agent 在两个月内把任务完成率从 60% 提到了 85%。记忆型 Agent 的“记忆”不只是给用户的也是给开发者自己的——记住每一次失败才能让它越来越聪明。