ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从零手搓生产级记忆型AI Agent:DDD架构与SSE流式实战

2026/9/25 20:20:57 拓冰建站 浏览量
从零手搓生产级记忆型AI Agent:DDD架构与SSE流式实战 1. 为什么我要从零手搓一个记忆型 AI Agent先说结论市面上大部分所谓“AI Agent 框架”本质上只是把大模型的 API 包了一层加了个循环调用真正落到生产环境里记忆管理、流式输出、人机协同、领域建模这几块全是坑。我自己在做一个企业级智能助手平台的时候前后换了三套方案最后决定基于 AgentScope 的思路从零构建一个带长期记忆的生产级 Agent这篇文章就是把整个过程中的技术选型、架构设计、踩坑记录全部摊开讲。如果你正在做 AI Agent 开发或者你是一个 Java 后端工程师想切入 AI 应用赛道又或者你已经在用 Spring AI、LangChain 这类工具但觉得不够可控那这篇内容应该能帮你省掉至少两周的试错时间。我会从 DDD 领域建模讲到 SSE 流式渲染从 HITL 人机协同讲到多 Agent 编排尽量把每个技术决策背后的“为什么”说清楚。先对齐一个基础认知AI Agent 不等于大模型。大模型是推理引擎Agent 是在推理引擎之上加了记忆、规划、工具调用、反馈循环的完整系统。你可以把大模型理解成一台发动机Agent 是整辆车——发动机再好没有变速箱、方向盘、刹车你也上不了路。DeepSeek、GPT 这类属于大模型层而 AgentScope 这类框架解决的是怎么把发动机装进一辆能跑的车里。我选择 AgentScope 作为参考架构而不是直接拿来用原因很简单它的设计理念清晰但 Java 生态的落地资料太少大部分教程都是 Python 版本而企业级项目绝大多数跑在 Java 技术栈上。所以我做的事情本质上是把 AgentScope 的核心设计思想用 Java 技术栈重新实现了一遍同时针对生产环境做了大量加固。2. 整体架构设计与技术选型拆解2.1 为什么用 DDD 而不是传统的三层架构大部分 AI Agent 项目的代码组织方式是 Controller → Service → DAO 三层架构初期跑得挺快但一旦 Agent 数量超过三个、记忆类型超过两种、工具调用链路变长代码就会变成一团乱麻。我试过在一个 Service 类里塞了 2000 行代码来管理对话历史、工具注册、Prompt 拼装、流式输出后来加一个“记忆摘要”功能直接改崩了。DDD 的核心价值在这里体现得非常明显限界上下文帮你把 Agent 运行时、记忆管理、工具编排、会话管理切成独立的领域每个领域有自己的聚合根和值对象。比如“会话”是一个聚合根“消息”是实体“记忆片段”是值对象。这样做的好处是当你要把短期记忆换成长期记忆或者从内存存储换成向量数据库只需要替换对应领域的仓储实现不会波及整个系统。具体分层是这样的接口层REST API SSE 端点负责接收用户请求和推送流式响应应用层编排用例比如“发起对话”这个用例会依次调用记忆检索、Prompt 组装、模型推理、记忆写入领域层Agent 聚合、Session 聚合、Memory 聚合、Tool 聚合包含所有业务规则基础设施层大模型客户端、向量数据库、缓存、消息队列的具体实现注意不要一上来就追求完美的 DDD 分层。我的建议是先把领域层划出来基础设施层用接口隔离接口层和应用层可以先用简单实现后续再重构。2.2 SSE 流式输出为什么不用 WebSocket流式输出是大模型交互的标配需求用户不可能等 30 秒才看到完整回答。可选方案有 WebSocket、SSE、轮询三种。我最终选了 SSE理由如下WebSocket 是全双工协议适合双向实时通信场景但大模型对话本质上是“客户端发一次请求服务端流式返回”的半双工模式。用 WebSocket 属于杀鸡用牛刀而且 WebSocket 的连接管理、心跳保活、断线重连在复杂网络环境下问题很多。SSE 基于 HTTP 协议天然支持断线重连浏览器会自动重连实现简单调试方便用 curl 就能直接看流。但 SSE 有一个经典坑idle timeout。当大模型推理时间较长两个 token 之间间隔超过网关或负载均衡的空闲超时时间连接就会被断开前端报错stream disconnected before completion: idle timeout waiting for SSE。我的解决方案是在服务端加心跳机制每隔 15 秒发送一个 SSE 注释行以冒号开头的行保持连接活跃。同时在前端配合 AbortController 实现用户主动中断生成的能力。2.3 记忆系统的分层设计记忆是“记忆型 Agent”的核心。我把记忆分成三层第一层工作记忆Working Memory。就是当前对话的上下文窗口直接拼在 Prompt 里。容量有限一般控制在模型上下文窗口的 60% 以内留出空间给系统提示和工具返回结果。第二层短期记忆Short-term Memory。存储最近 N 轮对话的摘要用滑动窗口 摘要压缩的方式管理。当对话轮次超过阈值把最早的几轮对话交给大模型生成摘要替换原始消息。第三层长期记忆Long-term Memory。把用户偏好、关键事实、历史决策等持久化到向量数据库每次对话开始时根据当前输入做语义检索召回 Top-K 相关记忆注入 Prompt。这三层的读写策略完全不同工作记忆是每轮读写短期记忆是异步压缩长期记忆是写入时做 embedding、读取时做相似度检索。用 DDD 的话说它们是三个独立的聚合通过领域事件解耦。3. 核心模块的详细实现与实操要点3.1 Agent 运行时ReAct 循环的 Java 实现Agent 的核心运行逻辑是 ReActReasoning Acting循环思考 → 行动 → 观察 → 再思考。用伪代码表示就是public AgentResponse run(AgentContext context) { while (!context.isFinished()) { // 1. 组装 Prompt系统提示 记忆 工具描述 对话历史 String prompt promptBuilder.build(context); // 2. 调用大模型 LlmResponse response llmClient.chat(prompt); // 3. 解析输出判断是最终回答还是工具调用 ParsedOutput parsed outputParser.parse(response); if (parsed.isFinalAnswer()) { return buildResponse(parsed); } // 4. 执行工具调用 ToolResult result toolExecutor.execute(parsed.getToolCall()); // 5. 把工具结果加入上下文继续循环 context.addObservation(result); // 6. 安全检查防止无限循环 if (context.getIterationCount() MAX_ITERATIONS) { return buildTimeoutResponse(); } } }这段代码看起来简单但生产环境要处理的边界情况非常多。比如工具调用失败怎么重试、大模型返回格式不符合预期怎么兜底、循环过程中用户主动中断怎么响应。我的做法是给每次循环加一个状态机用枚举管理THINKING、ACTING、OBSERVING、FINISHED、ABORTED五个状态每个状态转换都有对应的钩子函数方便埋点和调试。实操心得MAX_ITERATIONS 不要设太大我一般设 8。超过 8 轮还没得出结论大概率是 Prompt 有问题或者工具描述不清晰继续循环只是浪费 token。3.2 工具注册与调用让 Agent 真正“能干活”Agent 和聊天机器人的本质区别在于能不能调用外部工具。工具注册我用的是注解 反射的方案AgentTool(name queryOrder, description 根据订单号查询订单状态) public class QueryOrderTool implements Tool { Override public ToolResult execute(MapString, Object params) { String orderId (String) params.get(orderId); // 实际查询逻辑 return ToolResult.success(orderData); } }启动时扫描所有带AgentTool注解的类自动生成工具描述注入到系统 Prompt 中。这里有个关键细节工具描述的质量直接决定 Agent 的调用准确率。我踩过的坑是描述写得太简单比如只写“查询订单”结果 Agent 经常在不该调用的时候调用。后来改成“根据订单号查询订单的详细状态包括支付状态、物流状态、退款状态。仅在用户明确提供了订单号时调用”准确率从 60% 提升到 90% 以上。工具调用的参数校验也很重要。大模型生成的参数经常有类型错误或者缺少必填字段我在 ToolExecutor 里加了一层参数校验和自动修复逻辑比如字符串类型的数字自动转换、缺失参数时返回明确的错误提示让 Agent 重新生成。3.3 HITL 人机协同什么时候该让人介入HITLHuman-in-the-Loop是生产级 Agent 的必备能力。不是所有决策都能让 Agent 自己做尤其是涉及资金、权限、敏感操作的时候。我的设计是在 Agent 运行时插入“审批检查点”当 Agent 准备调用高风险工具时比如“发起退款”“修改用户权限”运行时暂停循环通过 SSE 推送一个审批请求给前端等待人工确认后再继续。这个暂停-恢复机制用 Java 的CompletableFuture实现超时时间设 5 分钟超时后自动拒绝并让 Agent 走备选路径。这里有个容易忽略的点审批请求要携带足够的上下文。不能只告诉审批人“Agent 想调用退款工具”还要展示 Agent 的推理过程、用户原始请求、涉及金额等信息否则审批人没法做判断。3.4 多 Agent 编排什么时候需要多个 Agent单个 Agent 能搞定的事情不要拆成多个。我见过一些项目为了“架构好看”把简单任务拆成 Planner Agent、Executor Agent、Reviewer Agent 三个结果延迟翻了三倍效果还不如单个 Agent。真正需要多 Agent 的场景是不同 Agent 需要不同的系统提示、不同的工具集、不同的记忆策略。比如一个客服系统里售前咨询 Agent 需要产品知识库和推荐工具售后 Agent 需要订单查询和退款工具这两个 Agent 的 Prompt 和工具集完全不同拆开更合理。多 Agent 之间的通信我用的是消息总线模式每个 Agent 有独立的收件箱通过领域事件异步通信。AgentScope 2.0 里提到的多 Agent 调用配置核心就是定义清楚 Agent 之间的通信协议和路由规则。4. 完整实操流程从零到跑通第一个对话4.1 环境准备与项目骨架搭建技术栈选型如下组件选型理由语言Java 17虚拟线程支持对 IO 密集型 Agent 场景友好框架Spring Boot 3.x生态成熟SSE 支持好大模型客户端自封装 HTTP 客户端避免框架绑定方便切换模型向量数据库内存版 可插拔接口开发期用内存生产切 Milvus缓存Caffeine本地缓存低延迟构建Maven团队熟悉度高项目骨架按 DDD 分层agent-core/ ├── domain/ # 领域层 │ ├── agent/ # Agent 聚合 │ ├── memory/ # 记忆聚合 │ ├── session/ # 会话聚合 │ └── tool/ # 工具聚合 ├── application/ # 应用层 │ ├── service/ # 用例编排 │ └── dto/ # 数据传输对象 ├── infrastructure/ # 基础设施层 │ ├── llm/ # 大模型客户端 │ ├── persistence/ # 持久化 │ └── vector/ # 向量存储 └── interfaces/ # 接口层 ├── rest/ # REST API └── sse/ # SSE 端点4.2 SSE 流式端点的完整实现服务端 SSE 端点核心代码GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(RequestParam String sessionId, RequestParam String message) { SseEmitter emitter new SseEmitter(300_000L); // 5分钟超时 // 心跳保活 ScheduledFuture? heartbeat scheduler.scheduleAtFixedRate(() - { try { emitter.send(SseEmitter.event().comment(heartbeat)); } catch (IOException e) { // 连接已断开 } }, 15, 15, TimeUnit.SECONDS); // 异步执行 Agent executor.execute(() - { try { agentRuntime.run(sessionId, message, chunk - { emitter.send(SseEmitter.event() .name(message) .data(chunk, MediaType.APPLICATION_JSON)); }); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } finally { heartbeat.cancel(true); } }); return emitter; }前端用EventSource接收配合AbortController实现中断const controller new AbortController(); const eventSource new EventSource(/chat/stream?sessionId${sid}message${msg}); eventSource.addEventListener(message, (e) { const chunk JSON.parse(e.data); appendToChat(chunk.content); }); // 用户点击停止按钮 function stopGeneration() { eventSource.close(); controller.abort(); // 通知服务端中断 Agent 循环 fetch(/chat/abort?sessionId${sid}, { method: POST }); }注意SSE 的Last-Event-ID机制可以实现断线续传但需要服务端缓存已发送的事件。生产环境建议加上用户体验会好很多。4.3 记忆写入与检索的完整链路一次完整的对话流程中记忆系统的参与节点如下对话开始根据用户输入做向量检索召回 Top-5 长期记忆注入系统 Prompt对话进行中每轮对话追加到工作记忆超过窗口限制时触发摘要压缩对话结束提取本轮对话中的关键事实用大模型做信息抽取写入长期记忆异步任务定期对长期记忆做去重和合并避免记忆膨胀信息抽取的 Prompt 我调了很多版最终稳定下来的是“从以下对话中提取用户的事实性信息包括偏好、身份、历史决策。只提取明确陈述的事实不要推断。以 JSON 数组格式返回每个元素包含type、content、confidence三个字段。”4.4 中断与恢复Abort 机制的完整实现用户中断生成后服务端需要做三件事停止 Agent 循环、保存当前状态、清理资源。我用一个AbortSignal对象在 Agent 运行时传递中断信号public class AbortSignal { private final AtomicBoolean aborted new AtomicBoolean(false); public void abort() { aborted.set(true); } public boolean isAborted() { return aborted.get(); } public void checkAbort() { if (aborted.get()) throw new AgentAbortException(); } }在 ReAct 循环的每个关键节点调用checkAbort()抛出异常后由上层捕获保存当前对话状态到会话存储返回中断响应给前端。这样用户下次进入会话时可以看到中断前的对话历史。5. 常见问题与排查技巧实录5.1 SSE 连接频繁断开这是最高频的问题。排查顺序如下现象可能原因解决方案30秒后断开网关空闲超时加心跳每15秒发注释行60秒后断开Nginx proxy_read_timeout调大超时或加心跳随机断开负载均衡会话保持开启 sticky session首字节就断开响应头不正确检查 Content-Type 和缓冲设置Nginx 配置需要加proxy_buffering off否则 SSE 数据会被缓冲前端收不到实时流。5.2 Agent 陷入无限循环典型表现是 Agent 反复调用同一个工具或者在不同工具之间来回跳。根因通常是工具描述有歧义或者 Prompt 里没有明确的终止条件。我的排查方法是打开 DEBUG 日志把每一轮的 Prompt 和模型输出都打出来人工看一遍就能定位问题。预防措施有三个设置 MAX_ITERATIONS 硬限制、在系统 Prompt 里明确“如果已经获得足够信息直接给出最终回答”、给每个工具加调用频率限制。5.3 记忆检索召回不准确向量检索的效果高度依赖 embedding 模型和分块策略。我踩过的坑是把整段对话作为一个记忆片段存储导致检索粒度太粗。后来改成按“事实”粒度存储每条记忆只包含一个独立事实召回准确率大幅提升。另一个技巧是给记忆加时间衰减因子最近的记忆权重更高。检索时用similarity * decay_factor排序decay_factor 按exp(-λ * days)计算λ 取 0.01 左右。5.4 大模型输出格式不稳定要求大模型返回 JSON 时经常出现多余的解释文字或者格式错误。我的兜底策略是三层第一层用 Prompt 约束输出格式第二层用正则提取 JSON 部分第三层用 JSON 修复库做容错解析。如果三层都失败返回一个默认结构并记录日志不要让整个流程崩掉。5.5 多 Agent 通信死锁两个 Agent 互相等待对方响应时会死锁。解决方案是给每次 Agent 间通信设置超时超时后走降级路径。同时用有向无环图约束 Agent 之间的调用关系启动时做校验发现环就报错。6. 一些实战中攒下来的经验关于模型选择我的建议是不要绑定单一模型。生产环境用两个模型做互备主模型负责复杂推理备用模型负责简单任务和降级。切换逻辑封装在 LlmClient 接口后面上层无感知。关于 Prompt 管理千万不要把 Prompt 硬编码在 Java 代码里。我用的是外部配置文件 模板引擎的方案每个 Prompt 有版本号支持热更新和 A/B 测试。这样调 Prompt 不需要重新部署效率高很多。关于测试Agent 的测试和传统软件测试完全不同。传统测试是确定性输入输出Agent 的输出是不确定的。我的做法是建一个“评估集”包含 50-100 个典型场景每次改动后跑一遍用大模型做自动评分看整体通过率有没有下降。这比写单元测试实用得多。关于成本控制Token 消耗是大头。我做了三件事一是 Prompt 压缩把冗余的系统提示精简掉二是缓存相同或相似的请求直接返回缓存结果三是分级路由简单问题走小模型复杂问题才走大模型。这三招下来成本降了大概 60%。最后说一个容易被忽略的点日志和可观测性。Agent 的运行过程是个黑盒出了问题很难排查。我在每个关键节点都埋了结构化日志包括 Prompt 内容、模型输出、工具调用参数和结果、耗时统计。配合链路追踪能快速定位是哪个环节出了问题。这部分投入在后期排障时回报巨大。