
1. 为什么记忆才是生产级 Agent 的分水岭先把结论摆在前面市面上能跑通 Demo 的 AI Agent 一抓一大把但真正能上生产的卡点几乎都不在能不能调通大模型而在它记不记得住、记不记得对、记不记得起。我前后参与过几个企业内部的智能体项目最深的体会就是——没有记忆系统的 Agent本质上只是一个包装得更花哨的问答接口。所谓记忆型 Agent拆开来看是三层能力第一层是会话内的短期记忆也就是多轮对话里它得知道上一句说了什么第二层是跨会话的长期记忆用户上周提过的偏好、上次没解决的任务这次得能接上第三层是结构化的事实记忆把散落在对话里的关键信息抽取成可检索、可更新的知识条目。这三层缺一层Agent 的智能就会露馅。AgentScope 这个项目之所以值得拿出来系统讲是因为它把记忆当成一等公民来设计而不是事后打补丁。它的整体思路是用DDD领域驱动设计把 Agent 的领域模型切干净用SSEServer-Sent Events把流式输出做实用HITLHuman-in-the-Loop把人的干预点留出来。这三个关键词不是随便凑的它们分别对应了记忆怎么组织记忆怎么实时吐出来记忆怎么被人纠正。这篇文章适合谁看如果你已经能写一个简单的对话接口但一上生产就发现上下文爆炸、状态丢失、多人并发串味那这篇就是给你写的。如果你还在纠结选哪个大模型那可以先放一放模型是变量架构才是常量。我会从领域建模讲到流式渲染再讲到记忆的持久化和人的介入尽量把每一步的为什么讲透而不是甩一堆代码让你抄。需要提前说明的是下面涉及的具体实现细节有一部分是基于 AgentScope 公开的设计理念和我在类似项目中的常见实践做的合理补全不是对官方源码的逐行复刻。你把它当成一份如果我来搭这套东西会怎么做的施工图来看会更合适。2. 用 DDD 把 Agent 的领域模型切干净2.1 为什么 Agent 项目特别容易写成一坨我见过太多 Agent 项目的代码结构是这样的一个AgentService类里面塞了 prompt 拼接、模型调用、工具执行、记忆读写、日志埋点两千行起步。刚开始能跑加第二个工具的时候开始乱加第三个记忆策略的时候直接不敢动。这不是能力问题是没有边界的问题。Agent 这个领域有个天然的特点它的行为高度依赖状态而状态又分散在对话历史、工具调用结果、外部知识库、用户画像好几个地方。如果不做领域划分这些状态就会像藤蔓一样缠在一起。DDD 在这里的价值不是让你画一堆漂亮的架构图而是逼你回答一个问题哪些概念是核心领域哪些只是支撑设施。2.2 核心域、支撑域、通用域怎么分在 AgentScope 这类项目的语境下我习惯这样切分层包含的概念职责变化频率核心域Agent、Session、Memory、Turn定义智能体的行为与记忆规则中支撑域Tool、Planner、Retriever为 Agent 提供能力扩展高通用域日志、鉴权、配置、序列化与业务无关的基础设施低核心域里最关键的是Agent 和 Session 的关系。很多人会把它们合并成一个对象觉得一个 Agent 就是一个会话。这在单用户场景下没问题但一旦要支持同一个 Agent 服务多个用户同一个用户开多个会话合并就会出大问题。正确的做法是Agent 是无状态的行为定义Session 是有状态的一次交互上下文Memory 挂在 Session 上而不是挂在 Agent 上。这个区分带来的直接好处是Agent 可以被缓存、被复用、被水平扩展而 Session 可以独立地做持久化和过期清理。你想想如果 Agent 里存着对话历史那这个对象就没法在多实例之间共享了负载均衡一开就串味。2.3 聚合根与不变量的设计Session 作为聚合根它要维护的不变量是什么我总结了几条一个 Session 内的 Turn 必须有序且序号连续Memory 的写入必须发生在 Turn 完成之后不能写一半工具调用的结果必须归属于触发它的那个 Turn不能跨 Turn 挂载。这几条听起来像废话但真到并发场景下就是救命稻草。比如用户快速连发两条消息如果 Turn 的序号生成没有做原子性保证就会出现两个 Turn 抢同一个序号记忆写入的时候互相覆盖。我的做法是在 Session 聚合根内部用一个版本号做乐观锁写入时校验版本冲突就重试。public class Session { private final String sessionId; private final ListTurn turns; private long version; public Turn appendTurn(UserInput input) { // 乐观锁版本不匹配说明有并发写入 long current this.version; Turn turn Turn.create(turns.size(), input); this.turns.add(turn); this.version current 1; return turn; } }注意聚合根内部的状态变更一定要走领域方法不要暴露 setter。我踩过的坑就是有人图省事直接session.getTurns().add(...)结果绕过了版本号校验并发问题排查了整整两天。2.4 领域事件让记忆的写入解耦记忆的写入不应该阻塞主对话流程。用户发一句话Agent 回复完记忆的抽取和落库完全可以异步做。这时候领域事件就派上用场了Turn 完成时发布一个TurnCompletedEvent记忆模块订阅这个事件异步做抽取、向量化、入库。这样做的好处是主链路的响应时间不被记忆处理拖累而且记忆模块可以独立演进——今天用规则抽取明天换成小模型抽取主流程一行不用改。事件总线的选型上单机可以用 Spring 的ApplicationEventPublisher分布式就上消息队列。别一上来就上 Kafka除非你的量真的到了那个级别否则运维成本远大于收益。3. SSE 流式输出实时渲染背后的取舍3.1 为什么是 SSE 而不是 WebSocket这个问题我被问过无数次。先给结论Agent 的对话场景绝大多数情况下 SSE 比 WebSocket 更合适。原因有三第一Agent 的输出是单向的——服务端往客户端推 token客户端基本不需要在这条通道上回传数据用户的新输入走普通 HTTP 请求就行。WebSocket 的全双工能力在这里是浪费。第二SSE 基于普通 HTTP天然兼容现有的网关、鉴权、负载均衡体系。WebSocket 要额外处理升级握手、心跳保活、代理穿透运维复杂度高一个量级。第三SSE 的自动重连是浏览器原生支持的配合Last-Event-ID还能做断点续传。WebSocket 的重连得自己写。当然SSE 也有硬伤它是纯文本协议传二进制要 base64有 33% 的体积膨胀HTTP/1.1 下单个域名并发连接数有限制。但在 Agent 对话这个场景里这两点基本不构成问题。3.2 流式输出的完整链路拆解一条 token 从大模型到用户屏幕中间要过好几道手每一道都可能出问题模型侧返回 chunk通常是 JSON 行或 SSE 格式服务端解析 chunk提取 delta 内容服务端把 delta 包装成自己的 SSE 事件推给客户端客户端 EventSource 接收触发onmessage前端把 delta 追加到当前消息的气泡里触发重渲染。第 3 步是最容易出问题的地方。很多实现直接把模型的原始 chunk 透传结果前端拿到的事件格式五花八门一会儿是data: {choices:[...]}一会儿是data: [DONE]。正确做法是定义自己的事件协议把模型差异屏蔽在服务端。// 服务端定义的事件类型 public enum SseEventType { MESSAGE_START, // 消息开始携带 messageId CONTENT_DELTA, // 内容增量 TOOL_CALL, // 工具调用通知 MESSAGE_END, // 消息结束携带完整内容 ERROR // 错误 }前端只需要认这几种事件模型换成哪家都不用改前端代码。这个抽象层的价值在你换第二个模型的时候就会体现出来。3.3 前端渲染的性能陷阱流式渲染最容易踩的坑是每个 token 都触发一次完整重渲染。React 里如果直接把 delta 拼到 state 上一秒几十次的 setState 会让页面卡成幻灯片。我的做法是用一个ref累积 delta不触发渲染用requestAnimationFrame做节流每帧最多更新一次 DOM消息气泡用memo包裹避免兄弟节点跟着重渲染。const bufferRef useRef(); const rafRef useRef(null); function onDelta(delta) { bufferRef.current delta; if (rafRef.current) return; rafRef.current requestAnimationFrame(() { setContent(bufferRef.current); rafRef.current null; }); }实测下来这套节流能把长回答的渲染帧率从个位数拉到 50。别小看这个优化用户对打字机效果的流畅度是极其敏感的卡顿一次就会觉得这 AI 好笨。3.4 中断与 Abort用户点了停止之后发生了什么用户点停止生成这个动作背后要做的事情比想象中多客户端要关闭 EventSource 连接服务端要感知到连接断开取消正在进行的模型调用已经生成的部分内容要落库不能丢记忆模块要记录这次未完成的 Turn。服务端感知断连在 Spring 里可以通过SseEmitter的onCompletion和onTimeout回调或者用AsyncContext监听。关键是要把这个信号传递到模型调用层让 HTTP 请求真正被 cancel否则模型还在那边烧 token钱照扣。emitter.onCompletion(() - { // 客户端断开取消模型调用 modelCall.cancel(); // 保存已生成的部分 memoryService.savePartial(turnId, buffer.toString()); });提示部分内容落库时一定要标记状态为INTERRUPTED不要当成正常完成。否则下次记忆检索时会把半截话当成完整事实污染上下文。4. 记忆系统的分层设计与持久化4.1 短期记忆上下文窗口的精细化管理短期记忆就是喂给模型的那段上下文。很多人以为把历史全塞进去就行结果要么超 token 限制要么成本爆炸。我的策略是滑动窗口 摘要压缩最近 N 轮对话保留原文保证细节不丢更早的对话做摘要压缩成一段话系统提示词和关键事实永远置顶不参与滑动。N 取多少我的经验值是 6 到 10 轮。太少会显得健忘太多则边际收益递减。摘要的触发时机也有讲究不要每轮都摘要那样成本高且容易累积误差建议在 token 用量达到窗口的 70% 时触发一次批量摘要。4.2 长期记忆向量检索不是万能药长期记忆的标配是向量数据库但我要泼盆冷水纯向量检索在 Agent 场景下经常不靠谱。原因是向量相似度衡量的是语义接近而 Agent 需要的是事实准确。用户说我住在杭州向量检索可能给你召回杭州天气杭州美食这些语义相近但完全无关的内容。我的做法是混合检索向量召回 关键词召回 结构化过滤三路结果做融合排序。结构化过滤尤其重要比如按用户 ID、按时间范围、按记忆类型偏好/事实/任务过滤能砍掉大量噪声。检索方式优势劣势适用场景向量检索语义泛化强精确性差模糊意图匹配关键词检索精确无法处理同义专有名词、ID结构化过滤精准依赖元数据质量用户隔离、时间范围4.3 记忆的抽取什么时候写、写什么记忆写入的时机我倾向于异步 批量。每轮对话结束触发一次轻量抽取把候选记忆暂存积累到一定数量或会话结束时做一次批量精炼去重、合并、更新。抽取什么内容我一般分四类事实类用户的客观信息如用户是后端工程师偏好类用户的喜好如用户偏好简洁的回答任务类待办和进行中的事项如用户在做 AgentScope 项目关系类实体之间的关联如用户的公司使用 Java 技术栈。这四类的更新策略不同事实类可以覆盖偏好类要累积任务类要能标记完成关系类要做图结构维护。用一张表统一存后期检索会很痛苦。4.4 记忆的遗忘机制有记忆就得有遗忘否则数据库会无限膨胀检索质量也会被陈旧信息拖垮。遗忘策略我通常设三层时间衰减越久远的记忆权重越低检索时降权访问频率长期不被召回的记忆降级到冷存储显式删除用户明确说忘掉这个立即物理删除。时间衰减的权重函数我用的是指数衰减weight exp(-λ * days)λ 取 0.01 左右大概 70 天衰减到一半。这个参数没有标准答案得根据你的业务节奏调。5. HITL把人的判断力接进自动化流程5.1 为什么全自动的 Agent 在生产环境是危险的我见过一个案例某团队的 Agent 被授权自动执行数据库操作结果它把一条DELETE语句理解错了范围删了一大片数据。这不是模型笨是架构上就不该让它全自动。生产环境的 Agent必须有人在关键节点上把关。HITL 的核心思想是在 Agent 的决策链路上设置检查点高风险动作必须经过人确认才能执行。哪些算高风险我的判断标准是不可逆的操作、涉及资金的操作、影响范围超出当前会话的操作。5.2 检查点的三种形态形态交互方式适用场景实现复杂度事前审批Agent 暂停等人确认高风险工具调用中事中干预流式输出中插入人工修正内容生成高事后复核执行完等人审核批量任务低事前审批是最常用的。实现上Agent 在执行工具前先发一个APPROVAL_REQUIRED事件前端弹出确认框用户点同意后通过一个独立的接口把审批结果回传Agent 继续执行。这里的关键是状态要能挂起和恢复不能把整个线程阻塞住。5.3 挂起与恢复的状态管理Agent 挂起时它的完整状态当前 Turn、待执行的工具、上下文要序列化存起来。恢复时反序列化从断点继续。这要求你的 Agent 状态是可序列化的不能有不可序列化的引用比如数据库连接、线程锁。public class PendingApproval { private String sessionId; private String turnId; private ToolCall pendingTool; private MapString, Object contextSnapshot; private long expireAt; // 审批超时时间 }审批超时怎么办我的做法是超时后自动拒绝并把这次 Turn 标记为REJECTED让 Agent 走降级路径比如告诉用户这个操作需要你手动处理。千万别超时后默认执行那是灾难。5.4 人工反馈如何反哺记忆HITL 不只是拦一下它产生的反馈是最宝贵的训练信号。用户拒绝了某个工具调用说明 Agent 的判断有问题用户修改了生成的内容说明输出质量不达标。这些反馈应该被记录、被分析、被用来优化 prompt 和记忆策略。我的做法是给每次人工干预打标签定期做归因分析如果某类工具调用被拒率特别高就说明 Agent 对这类工具的触发条件理解有偏差需要调整 prompt 或加约束规则。这个闭环跑起来Agent 的可靠性会肉眼可见地提升。6. 从 Demo 到生产那些文档不会告诉你的坑6.1 并发下的会话串味这是最隐蔽也最致命的坑。表现是A 用户的对话里突然出现了 B 用户的内容。根因通常是用了共享的可变状态比如把当前 Session 存在一个静态变量里或者用了线程不安全的缓存。排查这类问题的思路先确认 Session 的获取是不是每次都从存储里按 ID 取而不是从某个当前上下文里拿。我强烈建议显式传递 sessionId不要依赖 ThreadLocal 这类隐式上下文异步场景下 ThreadLocal 会失效。6.2 流式输出的超时与断连SSE 连接有个容易被忽略的问题中间代理会主动断开空闲连接。表现是长回答生成到一半突然断了前端报idle timeout。解决办法是定期发送心跳注释行// 每 15 秒发一次心跳保持连接活跃 scheduler.scheduleAtFixedRate(() - { emitter.send(SseEmitter.event().comment(keepalive)); }, 15, 15, TimeUnit.SECONDS);心跳间隔要小于代理的超时时间常见的代理超时是 30 秒或 60 秒取 15 秒比较稳妥。6.3 记忆检索的延迟拖垮首字响应首字响应时间TTFT是用户体验的生命线。如果每次对话前都要做一次向量检索而检索又要几百毫秒TTFT 就废了。我的优化手段是检索和模型调用并行发起谁先回来用谁对高频用户做记忆预加载会话开始时就把常用记忆拉到本地缓存检索结果做 LRU 缓存相同 query 短时间内不重复检索。6.4 成本失控的隐形杀手Agent 的成本不只是模型调用。记忆抽取要调模型、摘要压缩要调模型、向量化要调模型这些后台调用加起来可能比主对话还贵。我的做法是给后台任务设预算上限超了就降级到规则方法别让它们无限制地烧钱。另外上下文长度是成本的直接乘数。同样一个问题塞 10 轮历史和塞 3 轮历史成本差好几倍。定期审计你的上下文构成把不必要的内容砍掉比换便宜模型有效得多。7. 技术选型与学习路径的几点个人建议关于技术栈Java 生态做 Agent 其实比很多人想象的顺手。Spring 的依赖注入、事件机制、异步支持天然适合搭这种多组件协作的系统。SSE 用 Spring MVC 的SseEmitter就够不一定非要上 WebFlux除非你的并发量真的很大。学习路径上我的建议是先跑通最小闭环再逐层加能力。最小闭环是一个 Session 一次模型调用 SSE 输出。跑通之后加记忆加工具加 HITL每加一层都确保前面的没坏。别一上来就照着完整架构图搭那样你会在还没看到效果的时候就迷失在细节里。关于 AgentScope 本身它的设计理念值得反复琢磨尤其是它对记忆和人机协作的处理方式。但工具终究是工具真正决定项目成败的是你对业务场景的理解——用户到底需要 Agent 记住什么、在什么节点需要人介入、什么样的响应速度是可接受的。这些问题想清楚了技术选型反而是水到渠成的事。我在实际项目里最大的体会是Agent 的智能感八成来自记忆的准确性两成来自模型的强大。一个记得住、记得准、该忘就忘的 Agent哪怕用中等模型体验也远好过一个健忘的顶级模型。把精力花在记忆系统的打磨上回报率是最高的。