
1. 为什么我要从零手搓一个记忆型 AI Agent先说结论市面上能跑通 Demo 的 Agent 框架一抓一大把但真到了生产环境能扛住多轮对话、能记住上下文、能在关键节点让人介入、还能把流式输出稳稳推到前端的少之又少。我这次拿 AgentScope 做底座从零搭了一个带长期记忆的生产级 AI Agent踩了不少坑也攒了一堆可以直接抄作业的经验这篇就把整个思路、架构、实操和排查过程完整摊开讲。如果你正在做 AI Agent 开发尤其是用 Java 技术栈、想搞明白 DDD 架构怎么落到 Agent 项目里、SSE 流式输出怎么配合中断控制、HITLHuman-in-the-Loop怎么设计才不别扭那这篇内容基本能覆盖你 80% 的疑问。我不会只讲概念每个关键决策我都会说清楚为什么这么选不这么选会怎样参数怎么算、代码怎么写、坑在哪里全部给到位。先对齐一下认知记忆型 AI Agent不是简单地把历史对话塞进 prompt。它要解决的是跨会话记住用户偏好在多轮任务中保持状态一致在长对话里做上下文压缩和检索这三件事。而AgentScope提供的是 Agent 编排、消息流转、工具调用这套骨架记忆层需要我们自己设计。生产级还意味着另外三件事流式输出不能断、人机协同要能插得进去、架构要能横向扩展。这三点决定了整个项目的技术选型。我选的技术栈是AgentScope 做 Agent 核心编排DDD 做分层架构SSE 做前端实时渲染通道HITL 做关键节点人工确认。下面逐层拆。2. 整体架构设计与技术选型拆解2.1 为什么用 DDD 而不是传统三层架构很多人做 Agent 项目习惯用 Controller-Service-DAO 三层前期快但一旦 Agent 数量变多、记忆策略要换、工具要动态注册Service 层会迅速膨胀成上帝类。我用 DDD 的核心原因是Agent 的领域边界天然清晰。在这个项目里我把领域划分为四块对话域Conversation Domain负责一轮对话的生命周期包括消息接收、意图识别、响应生成。记忆域Memory Domain负责短期记忆会话内和长期记忆跨会话的存取、压缩、检索。工具域Tool Domain负责工具注册、参数校验、执行调度。协同域Collaboration Domain负责 HITL 的人工介入、审批流、中断恢复。每个域有自己的实体、值对象、领域服务和仓储接口。这样做的好处是当我要把短期记忆从内存 Map换成Redis 向量库时只需要改记忆域的仓储实现对话域完全无感。这就是 DDD 在 Agent 项目里最实际的价值——隔离变化。注意DDD 不是让你一上来就画一堆聚合根和领域事件。Agent 项目里我建议先识别哪些东西会独立变化把这些划成域其余的先用简单结构别过度设计。2.2 AgentScope 在架构中的定位AgentScope 官方定位是多智能体编排框架它提供了 Agent 抽象、消息总线、工具调用协议这几样核心能力。我把它放在基础设施层之上、领域层之下的位置具体来说领域层定义我要一个能记住用户偏好的对话 Agent这个业务意图。基础设施层用 AgentScope 的 Agent 类去实现这个意图。中间通过适配器Adapter做转换领域层不直接依赖 AgentScope 的 API。这么做的原因很直接AgentScope 2.0 的 API 还在演进如果领域层直接 import 它的类将来升级会牵一发动全身。加一层适配器升级时只改适配器业务代码不动。这是我在实际项目里被版本升级坑过之后养成的习惯。2.3 SSE 而不是 WebSocket 的取舍流式输出这块WebSocket 和 SSE 都能做。我最终选 SSE理由有三条第一Agent 的响应是单向流。用户发一条消息Agent 流式吐回结果这个方向是服务器到客户端的单向推送SSE 天然契合。WebSocket 的双向能力在这里是浪费。第二SSE 基于 HTTP穿透性和兼容性更好。企业内网、反向代理、网关这些环节SSE 基本不用额外配置WebSocket 经常要处理升级握手和代理兼容问题。第三SSE 配合 AbortController 做中断非常干净。前端一个abort()就能断掉流后端通过连接关闭事件感知触发 Agent 的中断逻辑。这个在 HITL 场景里太重要了——用户看到 Agent 跑偏了要能立刻喊停。代价是 SSE 不支持二进制、连接数有限制。但 Agent 场景下这两点都不构成问题。2.4 HITL 的设计原则HITL 不是加个确认弹窗这么简单。我的设计原则是只在不可逆操作和高风险决策点插入人工确认。比如 Agent 要调用一个会写数据库的工具、要发送一封邮件、要执行一段代码这些节点必须停下来等人确认。而查询类、只读类操作直接放行。实现上我用了一个中断-恢复模型Agent 执行到敏感工具前抛出一个HumanApprovalRequired事件SSE 把这个事件推给前端前端弹出确认框用户点确认后通过另一个接口把审批结果回传Agent 从断点继续执行。这里的关键是状态要能序列化否则中断后恢复不了。3. 记忆层的核心设计与实操要点3.1 短期记忆与长期记忆的分工记忆层是整个项目的灵魂我把它拆成两层短期记忆存当前会话的完整消息列表用 Redis 的 List 结构key 是session:{sessionId}:messages。每条消息带时间戳和角色标记。为什么用 List 不用 String因为 Agent 需要按顺序读取最近 N 轮对话List 的LRANGE天然支持而且可以LTRIM控制长度。长期记忆存跨会话的用户偏好、事实性知识、历史决策用向量库我用的 Milvus你也可以换 pgvector。每条记忆是一个向量 元数据元数据里带userId、type、timestamp、confidence。两层之间的桥梁是记忆压缩当短期记忆超过阈值我设的是 20 轮触发一次压缩把旧对话用 LLM 总结成一段摘要存入长期记忆同时从短期记忆里删掉原始消息。这样既控制了 token 消耗又保留了关键信息。3.2 记忆检索的触发时机不是每轮对话都要检索长期记忆那样又慢又费 token。我的策略是按需检索用户消息里出现上次之前我记得这类指代词时触发检索。Agent 在规划阶段判断当前任务需要历史信息时主动调用检索工具。每隔 N 轮我设的 5 轮做一次被动检索补充可能遗漏的上下文。检索时用向量相似度 时间衰减加权。时间衰减公式我用的是score similarity * exp(-λ * days_since_creation)λ 取 0.01意味着 70 天前的记忆权重衰减到约 0.5。这个参数不是拍脑袋定的是根据用户行为数据调的——大部分用户对两周内的记忆敏感度最高超过两个月的基本可以忽略。3.3 记忆写入的去重与冲突处理长期记忆写入时最容易出的问题是重复和冲突。用户今天说我喜欢喝美式明天说我改喝拿铁了如果两条都存进去检索时会打架。我的处理方式是写入前先做一次相似度检索如果找到相似度超过 0.9 的旧记忆就走更新而不是新增。更新的策略是保留新值但把旧值记到history字段里方便追溯。如果相似度在 0.7 到 0.9 之间标记为潜在冲突在下次检索时把两条都返回让 LLM 自己判断。实操心得记忆去重的阈值不要设太高。我一开始设 0.95结果喜欢美式和爱喝美式咖啡被判成两条检索时重复返回。降到 0.9 之后明显改善。这个值建议根据你的 embedding 模型实测调整。3.4 记忆层的代码骨架public interface MemoryRepository { void saveShortTerm(String sessionId, Message message); ListMessage getRecentMessages(String sessionId, int limit); void saveLongTerm(String userId, MemoryItem item); ListMemoryItem retrieve(String userId, String query, int topK); void compress(String sessionId, String summary); }领域层只依赖这个接口具体实现放在基础设施层。Redis 实现和向量库实现各写一个类通过配置切换。这样测试时可以用内存实现生产用 Redis Milvus互不干扰。4. SSE 流式输出的完整实现与中断控制4.1 后端 SSE 接口的设计后端我用 Spring 的SseEmitter做流式输出。核心接口长这样GetMapping(value /agent/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(RequestParam String sessionId, RequestParam String message) { SseEmitter emitter new SseEmitter(0L); // 0 表示不超时 agentService.executeStream(sessionId, message, emitter); return emitter; }这里有个关键点超时时间设 0。默认的 SseEmitter 有超时Agent 思考时间长了连接就断了前端会报stream disconnected before completion: idle timeout waiting for SSE。我一开始就踩了这个坑排查了半天才发现是超时配置问题。但设 0 也有风险——连接可能永远不关。所以我在业务层加了心跳每 15 秒发一个注释事件:heartbeat\n\n既保活又让前端知道连接还在。4.2 事件类型的设计SSE 的event字段我定义了五种类型前端按类型分别处理事件类型含义前端处理message正常的文本增量追加到当前气泡tool_callAgent 要调用工具显示正在调用 XX 工具approval需要人工确认弹出确认框done本轮结束关闭连接启用输入框error出错显示错误提示这样设计的好处是前端逻辑清晰每种事件对应一个处理分支不会把工具调用和文本输出混在一起。4.3 前端实时渲染的实现前端用 React核心是EventSource加状态管理。但EventSource有个坑它不支持自定义请求头也没法主动中断。所以我改用fetchReadableStream手动解析 SSE。const controller new AbortController(); const response await fetch(/agent/stream?..., { signal: controller.signal }); const reader response.body.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value); // 解析 SSE 格式按 event 类型分发 }中断就是controller.abort()。用户点停止按钮时调用后端会收到连接关闭事件触发 Agent 的中断逻辑。4.4 中断后的状态恢复中断不是简单地把连接断掉就完事。Agent 可能正在调用工具、正在生成文本、正在等待审批每种状态的中断处理都不一样。我的做法是Agent 执行时维护一个执行上下文记录当前步骤、已完成步骤、待执行步骤。中断时把这个上下文序列化存到 Rediskey 是interrupt:{sessionId}。用户下次发消息时先检查有没有未完成的中断上下文有的话询问是否继续上次的任务。注意工具调用中断要特别小心。如果工具已经执行了一半比如写数据库写了一半中断后不能简单重试要做幂等或者补偿。我在工具层加了idempotentKey同一个 key 的调用只执行一次。4.5 流式输出的性能优化流式输出看着简单但生产环境有几个性能点要注意第一不要每个 token 都发一次 SSE。LLM 吐 token 的速度可能很快每个都发会导致大量小包网络开销大。我的做法是攒够 5 个字符或者间隔 50ms 发一次实测下来前端渲染更平滑后端压力也小。第二背压处理。如果前端消费慢后端还在猛推内存会涨。SseEmitter的send是异步的我用一个信号量控制未确认的消息数超过阈值就暂停推送。第三连接池管理。每个 SSE 连接占一个线程并发高了会耗尽线程池。我用了异步 Servlet 加自定义线程池把 SSE 连接和业务线程解耦。5. HITL 人机协同的落地细节5.1 哪些节点需要人工介入我把需要 HITL 的操作分成三类不可逆操作写数据库、发邮件、调外部 API 产生副作用。高风险决策涉及金额、权限、敏感数据的判断。低置信度输出Agent 对自己的回答置信度低于阈值我设的 0.7时请求人工确认。这三类之外的操作全部自动执行。这个划分不是绝对的你可以根据业务调整。核心原则是人工介入的成本要低于错误执行的成本否则 HITL 就是负担。5.2 审批流的实现审批流的实现分四步Agent 执行到敏感节点构造一个ApprovalRequest包含操作描述、参数、预期影响。通过 SSE 的approval事件推给前端。前端弹出确认框用户点同意或拒绝调用/agent/approve接口。后端收到审批结果从断点恢复 Agent 执行。这里的关键是审批请求要带足够的上下文让用户能做出判断。我见过很多实现只弹一个是否继续用户根本不知道要继续什么。我的ApprovalRequest里带了操作类型、目标对象、参数摘要、风险等级用户一眼能看懂。5.3 超时与默认行为审批不能无限等。我设了 5 分钟超时超时后默认拒绝。为什么默认拒绝而不是同意因为安全原则是不确定就不执行。如果业务上希望超时默认同意那说明这个操作本身就不该走 HITL。超时后 Agent 收到拒绝信号走异常分支给用户一个操作已取消的提示并把这次拒绝记入长期记忆下次遇到类似操作时 Agent 会更谨慎。5.4 HITL 与记忆的联动HITL 的审批结果是有价值的记忆。用户拒绝了某个操作说明他不喜欢这类操作用户同意了并修改了参数说明他有偏好。我把这些审批记录也存入长期记忆Agent 在后续决策时会参考。比如用户连续三次拒绝了自动发送邮件的操作Agent 就会学到这个用户不喜欢自动发邮件下次直接走审批而不是先尝试自动执行。这就是记忆型 Agent 相比普通 Agent 的进化能力。6. 常见问题与排查技巧实录6.1 SSE 连接频繁断开这是最高频的问题。排查顺序现象可能原因排查方法30 秒左右断开网关超时检查 Nginx/网关的proxy_read_timeout随机断开心跳缺失加 15 秒心跳事件大响应后断开缓冲区溢出检查proxy_buffering配置特定网络断开代理兼容确认X-Accel-Buffering: no响应头我遇到最多的是网关超时。Nginx 默认proxy_read_timeout是 60 秒Agent 思考超过 60 秒就断。解决办法是在 Nginx 配置里把这个值调大同时后端加心跳。6.2 记忆检索返回不相关结果这个问题通常是 embedding 模型和检索策略不匹配。排查步骤先看 embedding 模型是否适合中文。很多开源模型英文强中文弱中文场景下相似度计算不准。我换过三个模型才找到合适的。再看 topK 设得是否合理。topK 太大噪声多太小漏掉关键记忆。我实测 topK5 是个平衡点。最后看是否做了时间衰减。没有衰减的话很久以前的记忆会干扰当前判断。6.3 Agent 中断后无法恢复这个问题的根因通常是状态没序列化完整。Agent 的执行上下文里可能有不可序列化的对象比如数据库连接、文件句柄。我的做法是执行上下文只存数据不存资源。资源在恢复时重新获取。具体来说上下文里存的是当前在第 3 步前 2 步的结果是 X 和 Y第 3 步要调用工具 Z参数是 P。恢复时根据这些信息重新构造执行环境而不是试图恢复原来的对象。6.4 长期记忆膨胀跑一段时间后长期记忆会积累大量条目检索变慢。我的处理是定期归档超过 90 天且从未被检索到的记忆移到冷存储被检索过的记忆更新last_accessed时间保留在热存储。另外记忆压缩时要做合并。多条相似记忆合并成一条减少总量。我设的合并阈值是相似度 0.85低于这个值的不合并避免丢失细节。6.5 工具调用参数错误Agent 调用工具时参数格式错误是常见问题。我的解决方案是在工具定义里加 JSON Schema 校验Agent 生成的参数先过校验不通过就返回错误让 Agent 重新生成。这样比让工具自己报错要好因为 Agent 能根据校验错误信息自我修正。实操心得工具的参数描述要写得非常明确包括类型、格式、示例。我一开始描述写得太简略Agent 经常传错类型。把描述写详细后错误率下降了一大半。这个投入非常值得。7. 部署与扩展的实战建议7.1 部署架构生产部署我用了三节点一个网关节点做 SSE 连接管理和负载均衡两个业务节点跑 Agent 逻辑。网关节点用 Nginx配置了proxy_buffering off和长超时。业务节点无状态会话状态全在 Redis这样可以随时扩容。为什么网关和业务分开因为 SSE 连接是长连接占用的资源特性和普通 HTTP 请求不同。分开部署可以针对性地调优网关节点优化连接管理业务节点优化计算。7.2 监控指标生产环境必须监控这几个指标SSE 连接数反映并发压力超过阈值要扩容。首字节时间TTFB反映 Agent 响应速度变慢说明 LLM 或检索有问题。中断率用户主动中断的比例过高说明 Agent 输出质量有问题。审批通过率HITL 审批的通过比例过低说明 Agent 决策太激进。记忆检索命中率检索到的记忆被实际使用的比例过低说明检索策略有问题。这些指标我都接了 Prometheus Grafana设了告警阈值。比如中断率超过 20% 就告警说明需要检查 Agent 的输出质量。7.3 扩展方向这个项目后续可以往几个方向扩展多 Agent 协作。当前是单 Agent可以拆成规划 Agent、执行 Agent、审核 Agent各司其职。AgentScope 本身支持多 Agent 编排扩展起来不难。记忆的分层。当前是短期 长期两层可以加一层工作记忆专门存当前任务的中间状态任务结束就清空。工具生态。当前工具是硬编码注册的可以做成插件化支持动态加载。这样不同业务线可以各自开发工具互不影响。RAG 增强。长期记忆目前是对话摘要可以接入企业知识库让 Agent 在回答时检索文档。AgentScope 2.0 提到的 RAG as Service 就是这个思路。我在实际跑这套架构的过程中最大的体会是Agent 的难点不在 LLM而在工程。LLM 能力再强如果记忆管理混乱、流式输出不稳、人机协同别扭用户体验就是灾难。反过来把这些工程问题解决好即使 LLM 能力一般整体体验也能做到可用甚至好用。所以如果你也在做 Agent 项目建议把 60% 的精力放在工程架构上40% 放在 prompt 和模型调优上这个配比是我踩了无数坑之后总结出来的。