AI Agent 响应加速与记忆体系:SSE 流式落地及长期记忆改造实践
这周我把 DeepAgent 的会话链路从同步 HTTP 请求切到了 SSE 流式输出上线的第一刻体感变化非常明显——之前前端一个 loading 转圈能等十几秒现在模型第一个 token 蹦出来就开始逐字渲染用户在心理上立刻觉得“这系统活了”。但说实话这次同步上线的长期记忆虽然已经打通了一条完整 Pipeline我依然愿意管它叫半成品。如果你也在做 Agent 应用正被“响应太慢”和“记忆不靠谱”两头夹击这篇文章把技术选型、SSE 落地、AbortController 配合和记忆体系现状一次性捋清楚应该能少踩几个坑。1. 为什么从同步 HTTP 切到 SSE等待 20 秒的教训1.1 最初的实现一次 POST 等完整回答DeepAgent 最开始的实现非常简单浏览器发一个 POST 请求后端调各家大模型完整推理完再一次性返回 JSON。单看架构没有任何问题但实际用起来就会发现大模型的响应速度根本撑不起这种交互。一个稍微复杂一点的 Agent 任务涉及检索、工具调用、多轮内部思考用户可能要在 loading 状态里等 15 到 30 秒。这个过程中前端什么都干不了用户要么以为系统挂了要么疯狂刷新按钮把同样的请求重复提交好几遍。更麻烦的是超时问题。网关层、Nginx、反向代理都有默认超时时间很多环境里proxy_read_timeout默认只有 60 秒一旦模型被内部工具调用拖慢HTTP 连接很容易被中间层掐断。前端收到一个连接重置错误后端其实还在继续生成回答——两边状态彻底错位。1.2 SSE、WebSocket、轮询到底差在哪一张决策表当时我列了一张对比表把三种常见方案摆在一起对比决策过程就清晰多了。方案数据方向连接维护断线重连实现成本大模型场景适配度同步 HTTP客户端发起服务端一次性返回无无最低差等待时间长短轮询客户端反复拉取无天然支持低能跳过但请求量大、延迟高SSE服务端单向推送HTTP 长连接内置事件重连字段低非常适合单向流式输出WebSocket双向实时通信全双工长连接需要自己实现较高适合聊天室、协作编辑等场景SSE 的全称是 Server-Sent Events本质上是一条只读的 HTTP 长连接服务端可以不断往客户端推事件。它 2011 年就被纳入 HTML5 标准并不是什么新技术。它最大的优势是“简单”——不需要像 WebSocket 那样处理握手协议、心跳帧、连接状态机只要后端按照text/event-stream的格式往响应流里写数据前端用原生 API 就能解析。1.3 做 AI Agent 为什么首选 SSE对于 DeepAgent 这个场景交互模式很清晰用户发起一个问题服务端持续生成回答中间基本没有客户端往服务端推消息的需求。即使 Agent 流程里需要用户确认某个工具调用也可以用事件回调重新发起一次独立请求而不是依赖一条全双工通道。所以 SSE 几乎是这个场景的最优解。它的成本低到只要后端加一个接口、前端加一小段解析逻辑就能跑通同时又能解决“首字延迟”这个体验关键点。实测切换当天前端从发起到第一个字渲染从原来的 12 秒左右降到了 1.5 秒以内用户体感是完全不一样的。2. 技术栈选型怎么封装 AI 交互逻辑才不至于被模型厂商绑死2.1 项目背景Java 17 Spring Boot 3 下的多模型接入DeepAgent 的定位不是一个具体 AI 应用而是把“问模型”“调工具”“存取记忆”这些动作封装成一个可复用的 Agent 交互层。后端技术栈压在了 Java 17 Spring Boot 3之所以没选 Python是因为团队整体栈偏 Java且后面要接消息队列、对象存储、内部鉴权体系用 Spring 生态顺手得多。项目一开始就面临一个现实问题不能绑定单一模型厂商。客户可能今天用深度求索明天换成通义后天私有化部署一个 Llama 微调版本。如果每个模型接一套回调Agent 核心逻辑里全是 if-else 分支后面维护会非常痛苦。所以我在最底层抽了一个 Provider 接口。2.2 为什么选 SseEmitter 而不是响应式 WebFluxSpring 生态里做 SSE其实有两条路Spring MVC 自带的SseEmitter或者直接上响应式栈 WebFlux。我选了前者。理由很务实。SseEmitter 本质上就是往 Servlet 异步响应里写数据流完全可以沿用现有的 Filter、拦截器、鉴权逻辑项目从普通 Controller 切到 SSE 只需要新增接口不需要动基础设施。WebFlux 确实有更好的背压处理和更高的并发上限但 DeepAgent 当前的场景是面向内部用户和少量外部客户SSE 并发量几百路的水平用Async线程池完全扛得住。在这个量级用响应式编程徒增心智负担收益却很小。我宁可在真正需要高吞吐时再重构成响应式也不在设计初期就引入不必要的复杂度。2.3 统一抽象ChatProvider、AgentRequest、AgentChunk我设计了三层结构ChatProvider统一封装各家大模型 SDK只暴露streamChat()和chat()两个方法。AgentRequest请求统一对象包含sessionId、userId、query、上下文列表和取消令牌。AgentChunk流式返回的统一切片包含delta增量文本、metaEOF、工具调用开始等状态、done标志。对外暴露的接口不关心模型厂商是谁上层只依赖这三个抽象。某家模型从 OpenAI 协议切成原生协议改动只发生在新的 Provider 实现里Agent 编排、工具调用、记忆写入这些核心逻辑完全不用动。2.4 取消机制用 CancellationToken 给各家 SDK 兜底各家模型 SDK 对“取消生成”的支持程度差别很大。OpenAI 系的 Java SDK 提供了cancel()有些国产模型 SDK 直接没暴露取消方法还有一些是流式游标自己维护连接强行中断可能导致下游异常。所以我在封装层设计了一个CancellationToken内部用volatile标志位 CompletableFuture模拟。Agent 引擎在流式回调里不断检查这个令牌发现取消请求就把循环体抛异常退出同时主动关闭底层 HTTP 连接。这个令牌不仅用于前端点了“停止生成”也用于后端内部流程——比如工具调用超时、或者用户主动发起了新一句提问旧生成任务必须立刻终止否则会白白烧 token。3. 后端实现的三件大事SseEmitter、心跳与断连清理3.1 SseEmitter 的基本用法与线程池隔离Spring MVC 里写 SSE核心就是SseEmitter。Controller 方法直接返回它Spring 就会把响应切换成异步模式不会占用 Servlet 容器线程去等模型输出。GetMapping(value /api/agent/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(RequestParam String sessionId, RequestParam String query) { SseEmitter emitter new SseEmitter(0L); // 0 表示不自动超时 CancellationToken token CancellationToken.create(); // 客户端断连时取消下游模型的生成 emitter.onCompletion(token::cancel); emitter.onTimeout(token::cancel); agentEngine.streamChat( AgentRequest.of(sessionId, query), new AgentStreamCallback() { Override public void onChunk(AgentChunk chunk) { try { emitter.send(SseEmitter.event() .name(message) .data(chunk.getDelta())); } catch (IOException e) { token.cancel(); } } Override public void onDone() throws IOException { emitter.send(SseEmitter.event() .name(done) .data(Map.of(status, ok))); emitter.complete(); } Override public void onError(Throwable t) throws IOException { emitter.send(SseEmitter.event() .name(error) .data(Map.of(message, t.getMessage()))); emitter.complete(); } }, token ); return emitter; }这里最关键的一个细节模型厂商 SDK 的流式回调线程不能与全局业务线程池共用。大模型推一个 token 调一次回调如果回调线程被其他 CPU 密集型任务占了SSE 数据就会卡住前端表现为“打字机突然断气”。我专门给流式输出分配了一个独立的线程池线程名前缀deepagent-sse-方便出问题时用 jstack 排查。3.2 心跳与超时别死在网关那一层实际跑起来第一周就有人反映长回答经常中途断掉查日志发现是 Nginx 的proxy_read_timeout到了 60 秒默认值。但模型还在推理客户端却收到连接中断。这是 SSE 落地最常见的坑。解决办法分两层。第一层在应用响应头上加X-Accel-Buffering: no告诉 Nginx 别对这条流做缓冲同时把proxy_buffering off在对应 location 上关掉。否则 Nginx 会把小数据包攒到一定大小才转发前端看到的不是逐字渲染而是一段一段地蹦。第二层是 SSE 自保熟手都会做的心跳包。学着用注释事件维持连接活性比如每 15 秒发一条注释行。原因是模型如果只是在做工具调用或者检索可能超过 30 秒不产生任何文本增量这时中间网关很容易认定连接空转。Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() - { try { emitter.send(SseEmitter.event().comment(keep-alive)); } catch (IOException e) { token.cancel(); } }, 15, 15, TimeUnit.SECONDS);这个心跳是只发注释不触发前端数据解析所以对渲染逻辑完全无侵入。3.3 断连清理用户关页面前要做的收尾SSE 最容易被忽略的是断连后的资源清理。用户点了停止或者直接关了浏览器客户端会断开连接但后端的模型生成任务不会自动停止它还会继续从供应商那边拉数据一直跑到完这部分的费用和时间全浪费了。所以在SseEmitter上必须注册onCompletion回调在里面取消掉CancellationToken。回调线程发现令牌被置位会终止当前生成流程同时断开底层 HTTP 连接。另一个细节是活跃发射器要进出注册表管理定时扫描清理已经完成的 emitter不然容器里会累积一批无用的 SseEmitter 引用变成了事实上的内存泄漏。3.4 实测踩坑清单现象根本原因解决方案前端一次性收到一大段文字Nginx 或 Spring 压缩层缓冲关闭 proxy_buffering设置 X-Accel-Buffering: no60 秒左右连接必断网关默认 read timeout调大 proxy_read_timeout加 15 秒心跳回调线程阻塞导致流中断模型回调与业务线程池共用独立线程池给 SSE 输出关闭浏览器后模型还在跑未调用 emitter.onCompletion 清理注册回调取消 CancellationToken返回 JSON 而不是流produces 未设置 TEXT_EVENT_STREAM显式配置 MediaType.TEXT_EVENT_STREAM_VALUE这套链路跑稳定之后SSE 本身已经没有太多悬念。真正让我头疼的是记忆体系那头。4. 前端渲染为什么我用 fetch 而不用 EventSourceAbortController 怎么配合4.1 EventSource 的两个致命缺陷浏览器原生EventSource用起来极其简单自动重连、事件分发都内置好了。但有两点对 Agent 应用不友好第一它不支持自定义请求头。这意味着鉴权 token 只能放在 URL query 里既容易被网关日志打出来又受 URL 长度限制第二EventSource 只支持 GET 请求。Agent 接口如果入参包含长 contextGET 很容易触及 URL 上限。DeepAgent 因为要在请求里携带历史记忆摘要、工具执行结果、版本号等大量上下文POST 几乎是必需的。所以最终方案是用fetch加ReadableStream手动解析 SSE虽然代码量多一些但请求头和请求方式完全可控取消逻辑也能直接用标准AbortController。4.2 用 fetch 手写 SSE 解析核心代码SSE 的线上格式很简单事件之间用空行分隔每行以field: value形式存在核心字段就是data。我用 fetch 拿到响应体之后把字节流按\n\n切分提取data:开头的行再 JSON.parse 成对象。const controller new AbortController(); const { signal } controller; fetch(/api/agent/stream?sessionId${sessionId}query${encodeURIComponent(query)}, { signal }) .then(res { if (!res.ok || !res.body) throw new Error(stream not supported); const reader res.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; return (async function process() { while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const events buffer.split(\n\n); buffer events.pop(); for (const raw of events) { const data raw.split(\n) .filter(line line.startsWith(data:)) .map(line line.slice(5).trim()) .join(\n); if (!data || data [DONE]) continue; const chunk JSON.parse(data); if (chunk.event done) return; if (chunk.delta) { onToken(chunk.delta); } } } })(); }) .catch(err { if (err.name AbortError) { console.log(用户主动中断); } else { scheduleReconnect(); // 断线重连走指数退避 } });TextDecoder的{ stream: true }参数必须加上否则多字节中文可能在跨包时被解析成乱码。4.3 AbortController 的完整配合链路停止生成这个动作通过 AbortController 是分两层的。第一层controller.abort()会让 fetch 内部主动断开连接后端 SseEmitter 感知到断连触发onCompletion进而取消 CancellationToken模型生成被终止。第二层为了让后端立即知道“用户不想继续了”而不是等网络层发现也可以单独发一个 POST/api/agent/cancel接口携带sessionId后端主动查表找到对应 emitter 并 complete 掉。两层方案我测下来主动 POST 的实时性更好。因为前面那层依赖 TCP 连接状态检测有时要等几十秒才被系统判定断连。生产环境我建议两层都做前端先发取消 POST再调用abort()断开残余连接后端两个入口都能触发取消保证最终一致性。4.4 React 状态更新不要在流式输出时频繁 setState流式接口每秒会推很多个 token如果每收到一个 chunk 就setState一次React 会对列表做大量 diff 计算很容易掉帧。实测下来我采用了两级缓冲在useRef里持续累积完整回答文本然后用requestAnimationFrame节流每帧最多触发一次 setState。const contentRef useRef(); const [display, setDisplay] useState(); const onToken (delta) { contentRef.current delta; if (!rafRef.current) { rafRef.current requestAnimationFrame(() { setDisplay(contentRef.current); rafRef.current null; }); } };这样一个高频流也能保持界面流畅而且停止生成后手动把contentRef.current置空就行渲染状态干净回收。5. 记忆体系现状短期、长期、永久记忆到底做到哪一层了5.1 记忆分层的定义与边界Agent 记忆体系这个概念很多文章会把它讲得很玄其实落到工程实现就三件事短期记忆是当前会话上下文长期记忆是跨会话的可检索事实永久记忆是用户稳定不变的画像级信息。这个分类不是为了学术严谨而是因为三者对应完全不同的存储与处理方式。5.2 短期记忆纯工程问题问题在上下文窗口短期记忆在 DeepAgent 里就是每次请求携带的对话上下文列表。这个基本是纯工程问题只要管好 token 预算和窗口截断逻辑就行。我当前的做法是每轮对话结束把最新的“用户问题 Agent 回答”追加进上下文数组下一次请求组装 prompt 时估算 token 数超出上下文窗口就从最老的对话开始裁。这里有个细节截断不要简单按消息条数一刀切最好优先保留系统指令、最近两轮对话和涉及工具结果的片段中间部分可以压缩成摘要。因为对于 Agent 场景工具调用结果往往直接影响后续回答全部被截掉的话模型可能重复调用已执行过的工具。短期记忆的难点不在存储而在“剪掉哪些信息不会影响当前任务”。这需要根据 Agent 内部状态动态决定而不是机械截断。5.3 长期记忆一条摘要型 RAG 流水线长期记忆的设计本质上是 RAG。query 是用户当前的问题doc 是历史对话产生的持久化摘要。当前已经跑通了一条最小可用链路写入侧对话结束后异步调用一个专门的摘要模型把本次会话的核心事实压缩成 500 字以内的摘要然后调用 embedding 模型向量化连同用户 ID、会话时间、原始会话 ID 一起存入向量库。读取侧用户发起新对话时先用当前问题向量召回 top-K 历史摘要再拼进 system prompt让模型参考历史记忆作答。这个设计方向是对的但质量确实带毛病的——具体问题我在下一节细讲。总体而言长期记忆在当前项目里能用了但是可用度让我心虚。5.4 永久记忆现在只是个占位符永久记忆我目前只做了最简单的用户自定义 Key-Value 存到配置表里比如“用户的称呼”“偏好的语言风格”“工作领域”这类。它是靠用户手动填写而不是系统自动从对话里提炼。这样设计的原因很简单自动提炼的用户画像如果出错会导致系统用错误的假设去回答效果比没有记忆更糟。所以我宁可从最小闭环开始先把用户主动声明的事实稳定下来再逐步推进自动画像更新。这个模块离真正可用还有距离严格说是一个标注清晰的占位符。6. 长期记忆半成品的五个坑和下一步改造计划6.1 摘要即损失细节漏掉之后找不回来第一版长期记忆是“会话摘要派”每次把整段对话塞给摘要模型让它浓缩。但实测发现摘要环节会丢失大量关键细节。比如用户说“我周三下午三点和甲方有个评审会”摘要模型可能会浓缩成“用户近期有项目活动”具体的日期、参会方、主题全部丢了后续检索时完全召回不出来。摘要模型的信息压缩本身就是有损的这是硬伤。你不能指望一次摘要就保留所有关键事实尤其是长对话。后续设计里必须并行做结构化抽取把关键事件、实体、时间点拆成字段单独存摘要只作为补充描述。6.2 召回精度不足top-K 的语义噪声另一个问题出现在召回侧。现在只有 embedding 向量检索没有重排阶段召回结果里经常混入语义相近但实际无关的内容。比如用户之前聊过“如何搭建推荐系统”新对话问的却是“推荐算法的评估指标”两次内容相关但不完全相同向量召回能把旧摘要捞出来却可能让模型跑偏去讲旧案例。解决办法是加一层 Reranker。先向量召回 50 条候选再用交叉编码器精排取前 5 条注入 prompt。交叉编码器对 query 和 doc 的交互建模比纯向量相似度精细得多能显著过滤语义噪声。没有重排阶段的 RAG 在敏感场景下基本不可信。6.3 记忆污染临时信息被当成了长期事实这是目前最难受的问题。用户随口说“今天天气真热”系统可能在格式化摘要时记成“用户喜欢炎热天气”用户说“这个项目下周截止”被记成长期任务。临时状态和稳定偏好之间没有过滤层。解决思路是给记忆写入加一道分类开关抽取出来的事实先判断是临时性事件还是稳定偏好稳定偏好才写入长期记忆临时事件单独存一份带时间戳的短期事件表过了有效期自动清理。这个分类本身可以交给大模型做但提示词要写得很清楚并给出正反例。6.4 矛盾信息旧记忆不会更新用户今天说“我最常用 Java”三个月后说“我现在已经切到 Go 了”。长期记忆里那条“Java”偏好不会自动失效新对话召回时可能把旧偏好一起注入模型就糊涂了。矛盾处理的常规做法是版本增量更新给记忆条目加置信度字段和最后确认时间新写入的信息如果与旧记忆冲突不是简单覆盖而是把两条都标出来必要时在下次注入时让模型自行判断哪个更可信。这个机制我还没完全落地但已经在设计表结构。6.5 过期清理记忆会发霉长期记忆要求稳定但那些“稳定但无用”的信息积累太多反而会稀释 prompt 的有效信息量。当前系统对所有记忆基本是按照固定 top-K 召回没有对时间衰减建模。一条半年多前的闲聊对话可能因为在语义上跟当前问题重合就挤掉了一条更近更重要的工作信息。后续准备给记忆条目打上时间衰减因子召回分数等于向量相似度乘以时间权重让新近信息在同等相似度下优先挤出。同时配合上面提到的临时事件表给每条记忆设置合理的 TTL。6.6 下一步从摘要记忆转向事件记忆复盘下来我把改造路线定为“从摘要记忆转向事件记忆”。摘要仍然保留但不是主角。主角是一系列结构化事件包含主体、动作、对象、时间、置信度、来源会话。图结构存进去检索时按关系路径扩展而不是只做文本向量匹配。可以预料完全落地还得两三个迭代。工程上我可以先把事件抽取的提示词调稳再把存储结构迁移到支持关系查询的 schema最后再接 Reranker。每个阶段上线前都要用固定的用户模拟对话集回归不能拍脑袋说“感觉变聪明了”。这轮项目做下来我最大的感受是SSE 这类传输优化是“立即满足”的工程问题你花一天改完用户立刻就能感知到长期记忆这类能力是“滞后正确”的产品问题你花一个月做完用户可能还是觉得它在胡说八道。两种工作完全不同并不适合用同一套标准来推进。如果你也在做自己的 Agent 框架有个建议倒是很实用——先把 SSE、断连、流式渲染这批体验底座做扎实再用之后几周甚至几个月的精力去攻坚长期记忆。半成品不可怕可怕的是不敢承认自己的记忆系统是半成品然后带着一个根本不靠谱的记忆系统去应付所有用户。