DeepAgent实战:SSE流式输出与长期记忆体系架构解析
DeepAgent 上线 SSE 那天我在群里发了句“流式输出终于能看了”马上有人问长期记忆呢我回了一句半成品。这不是自谦。跑了一个多月SSE 这条链路从后端推到前端渲染我满打满算用了两天就全部打通。但长期记忆我从立项第一天就在设计直到现在它依然处于一个“写进去没问题读出来经常用不上”的尴尬状态。这篇文章就把 DeepAgent 这两块的实战过程完整捋一遍技术栈怎么选、SSE 怎么落地、abort 怎么配合、记忆四层怎么划分以及长期记忆到底“半”在哪、下一步打算怎么补。先把这个项目的情况说清楚。DeepAgent 是一个跑在业务系统里的 Agent 服务层核心工作是把大模型交互逻辑封装成一套后端可维护、前端可实时渲染的完整链路。它不是一个聊天 Demo而是要接进真实业务、支持多用户并发、能跨会话记住用户信息的工程化项目。这套东西拆开看就是两个硬骨头一个是流式输出的实时渲染一个是记忆体系的落地。前者已经能用了后者还在补课。1. DeepAgent 的整体设计与方案选型1.1 我们要解决的到底是什么问题很多人一上来就把 Agent 项目做成“调大模型 API 的封装”这是最大的误区。DeepAgent 立项时我们明确了两件事第一底层大模型是可替换的今天接 A 厂商明天可能接 B 厂商业务层不能跟着绑死第二AI 交互逻辑不是一次 request/response 那么简单它牵扯到流式输出、上下文管理、停止生成、记忆注入、结果落库这一整条链路。所以 DeepAgent 实际做的事是把“用户发一句话 → 系统组织上下文 → 调用大模型 → 流式返回给前端”这个全过程封装成标准服务。业务方不需要关心底层模型是什么不需要自己拼 Prompt不需要处理 SSE 数据帧只需要调一个接口拿一个流把字渲染出来就行。这样设计的好处非常直接前端逻辑统一了后端模型切换成本低了记忆系统的接入位置也明确了。记忆模块是在 Service 层做编排的——在拼 Prompt 之前先查记忆、注入上下文在模型返回之后再异步抽取新记忆写回去。这个位置选得很关键它同时服务了“读记忆”和“写记忆”两个动作而不是把记忆做成一个孤立的模块。1.2 为什么选了 SSE 而不是 WebSocket这是项目里被问得最多的问题。先给结论大模型生成内容这个场景本质上是单工的客户端发一次请求服务端持续返回一段文本SSE 是比 WebSocket 更合适的方案。我整理了一个对比方便你看维度SSEWebSocket通信方向单向服务端 → 客户端双向底层协议普通 HTTPHTTP Upgrade 协议切换自动重连内置断线自动恢复需要自己实现传输内容文本为主文本 二进制代理/网关兼容性需要调 Nginx 缓冲也需要调代理和连接超时服务端开销轻普通 HTTP 连接需要维护长连接状态适用场景通知推送、流式文本实时互动、游戏、协同编辑SSE 有三个优势是 WebSocket 替代不了的第一它跑在普通 HTTP 上鉴权中间件、负载均衡、日志系统都不用额外适配第二EventSource 内置自动重连断网恢复了能自己续上第三服务端实现简单Spring 的 SseEmitter 开箱即用。反过来看 WebSocket双向通信这个能力在 Agent 场景里基本用不上。有人会说“停止生成”不是也需要客户端通知服务端吗这里其实有一个误解——停止生成完全可以通过断开连接来触发不需要单独发消息。abort 请求本身就会让服务端感知到连接关闭我们在 onCompletion 回调里做取消生成的处理就够了这反而比 WebSocket 发一条“stop”指令更省事。当然 SSE 也有局限最明显的两个一是 HTTP/1.1 下浏览器对同一域名并发连接数限制在 6 个左右但对聊天页面来说完全够用二是它只支持文本传不了二进制对 AI 对话这种场景根本不是问题。1.3 技术栈封装Java 后端 React 前端的 AI 交互层DeepAgent 的技术栈是 Java 后端 React 前端。后端用 Spring Boot前端用 React 全家桶。这个组合在 AI 项目里不如 Python Node 那么“网红”但对我们团队来说是最稳的选择现有基础设施都是 Java 的运维、监控、日志体系全部复用没必要为了追 AI 生态换技术栈。真正的重点在封装方式。我把 AI 交互逻辑分成了三层Controller 层只做 HTTP 协议适配接收请求、返回 SseEmitter不掺业务逻辑。Service 层编排完整对话流程顺序是“查记忆 → 拼 Prompt → 调模型 → 写回记忆 → 返回流”。适配器层统一封装不同大模型 SDK 的调用差异对外暴露一个 streamChat 方法。接口设计上统一返回text/event-stream的流式响应消息格式定义成 JSON前端不管底层接的是哪家模型拿到的数据格式都一样。这个封装很关键——后续换模型供应商的时候动适配器层就行Controller 和前端一行代码都不用改。2. SSE 流式输出与实时渲染的完整落地2.1 Java 后端用 SseEmitter 实现流式接口Spring Boot 里做 SSE 首选就是 SseEmitter。核心思路是Controller 方法返回一个 SseEmitter 对象Spring 会把这个 HTTP 连接挂住然后你在自己的线程里往 emitter 里写入数据前端就能实时收到。先看 Controller 层代码RestController RequestMapping(/api/agent) public class AgentChatController { private final AgentService agentService; public AgentChatController(AgentService agentService) { this.agentService agentService; } GetMapping(value /chat, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chat(RequestParam String userId, RequestParam String message) { // 超时时间设 180 秒模型生成超时不会导致连接提前断开 SseEmitter emitter new SseEmitter(180_000L); agentService.streamChat(userId, message, emitter); return emitter; } }这里有两个细节必须注意。第一produces一定要写成TEXT_EVENT_STREAM_VALUE这样响应头的 Content-Type 才是text/event-stream前端才能正确解析。第二SseEmitter 默认超时时间是 30 秒大模型生成慢的话很容易超时我直接拉到 180 秒宁可连接挂久一点也不能让用户看到“连接已断开”。Service 层的实现需要配合线程池因为模型调用是阻塞式的不能占用 Tomcat 的请求线程Service public class AgentService { private final ExecutorService streamExecutor Executors.newFixedThreadPool(16); private final MapString, Future? tasks new ConcurrentHashMap(); public void streamChat(String userId, String message, SseEmitter emitter) { Future? future streamExecutor.submit(() - { try { // 1. 加载记忆拼装 Prompt String prompt memoryService.buildPrompt(userId, message); // 2. 调用模型拿到流式响应逐段发送 modelAdapter.streamChat(prompt, delta - { try { MapString, String event Map.of(delta, delta); emitter.send(SseEmitter.event() .name(message) .data(JSON.toJsonString(event))); } catch (IOException e) { throw new RuntimeException(e); } }); // 3. 生成完成后异步更新记忆 memoryService.updateMemory(userId, message, finalAnswer); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } finally { tasks.remove(userId); } }); tasks.put(userId, future); // 客户端断开时取消生成任务 emitter.onCompletion(() - tasks.remove(userId)); emitter.onTimeout(() - { future.cancel(true); emitter.complete(); }); } public void cancelTask(String userId) { Future? future tasks.get(userId); if (future ! null) { future.cancel(true); } } }这里把 Future 存在一个 ConcurrentHashMap 里key 是 userId目的是为了支持“用户点击停止”时能直接取消对应的生成任务。后面讲 abort 的时候你还会看到这个 map 的用处。2.2 React 端不用 EventSource用 fetch 流式读取前端很多人第一反应是用 EventSource但这个方案在我们项目里直接毙掉了。原因很简单EventSource 不支持自定义请求头。DeepAgent 的接口需要带 Authorization tokenEventSource 只能通过 URL query 传参数token 暴露在 URL 里既难看又不安全。所以我的做法是用 fetch ReadableStream 手动解析 SSE 数据。const controller new AbortController(); async function streamChat(message: string) { const response await fetch( /api/agent/chat?userId${userId}message${encodeURIComponent(message)}, { headers: { Authorization: Bearer ${token}, Accept: text/event-stream, }, signal: controller.signal, } ); if (!response.ok) { throw new Error(HTTP ${response.status}); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; let answer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // SSE 事件之间用空行分隔按 \n\n 拆帧 const events buffer.split(\n\n); buffer events.pop() ?? ; // 最后一段可能不完整留到下一轮 for (const event of events) { const dataLine event.split(\n).find(line line.startsWith(data: )); if (!dataLine) continue; const rawData dataLine.slice(6); try { const payload JSON.parse(rawData); if (payload.delta) { answer payload.delta; setAnswer(answer); // React 里用 state 更新配合打字机效果 } } catch { // 解析失败就跳过一般是被代理插入的空行干扰 } } } } function stopGenerating() { controller.abort(); }这里有三个工程细节值得展开。第一是拆帧逻辑。SSE 的协议帧格式是data: xxx\n\n多个字段用单换行事件之间用空行。网络传输过程中一个帧可能会被拆成多个 chunk 到达所以必须维护一个 buffer把不完整的尾段留到下一轮继续拼接否则会出现 JSON 解析失败。第二是 TextDecoder 的{ stream: true }参数。这个参数专门处理多字节字符被截断的问题。比如一个中文字符的 UTF-8 编码可能横跨两个 chunk如果不用 stream 模式每一段单独 decode 就会在边界处出现乱码。我踩过这个坑修完之后中文渲染就再没出过问题。第三是“打字机效果”。流式渲染最直观的体验提升就是文字一个一个字蹦出来。SSE 天然适合这种场景前端只需要把 delta 追加到当前答案里就行不需要等完整回复。2.3 abort点击“停止生成”的正确姿势abort 是 SSE 链路里最容易草草了事的环节。很多人以为前端调用controller.abort()就完事儿了实际上如果后端不做配合模型还会继续生成token 费用继续烧。前端这一侧很简单就是调用 AbortController 的 abort 方法fetch 会立即抛出一个 AbortErrorReadableStream 读取循环随之终止。这个动作本身会让 HTTP 连接断开。后端这一侧才是关键。客户端断开之后SseEmitter 会触发 onCompletion 回调我们要在这里取消未完成的生成任务。前面代码里 tasks map 就派上用场了emitter.onCompletion(() - { Future? future tasks.remove(userId); if (future ! null) { future.cancel(true); } });我做过一个测试用户点击停止后如果不取消后端任务模型还会继续生成约 10 到 20 秒的内容然后一次性丢进“已断开”的管道里白白浪费 token。加上取消逻辑之后模型调用立刻中断成本直接省下一截。另外注意onTimeout 回调也要处理。SseEmitter 超时是默认行为超时后连接会被服务端关闭前端就会收到一个 EOF体验上表现为“字突然不出了”。所以超时时间给长一点180 秒同时配合前端的心跳机制后面会讲到。2.4 连接稳定性Nginx 关缓冲、心跳包保活SSE 这块最大的坑不在应用代码而在 Nginx。默认配置下 Nginx 会缓冲后端响应导致前端拿不到流式数据表现为“请求发出去好久没反应最后一下子全部返回”。这是因为 proxy_buffering 默认是开的。解决方法是加两个配置location /api/agent/ { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_set_header Connection ; add_header X-Accel-Buffering no; }proxy_buffering off让 Nginx 不缓冲数据到了就往前端传。proxy_read_timeout 300s保证长连接不被掐断。add_header X-Accel-Buffering no是给 Nginx 发指令让它对这条响应也关闭缓冲。还有一个容易忽略的点有些代理服务器会在空闲一段时间后自动断开连接。大模型有时候生成一段长文字前会先思考几秒这段时间连接上没有数据流动就可能被代理误判为超时。解决思路是发心跳注释。SSE 协议里以冒号开头的行是注释客户端会忽略但连接上有字节流动代理就不会断开。我封装了一个简单的心跳线程ScheduledExecutorService heartBeat Executors.newSingleThreadScheduledExecutor(); heartBeat.scheduleAtFixedRate(() - { try { emitter.send(SseEmitter.event().comment(ping)); } catch (IOException e) { // 连接已断开停止心跳 heartBeat.shutdownNow(); } }, 15, 15, TimeUnit.SECONDS);2.5 SSE 与轮询的对比为什么不用“轮询文件变化”有人提过一个替代方案用 React 轮询文件变化来模拟流式输出。这个思路在早期对接一些没有流式接口的模型时确实可行——后端把生成内容写入文件前端每隔 500ms 拉一次文件内容有新内容就追加渲染。但我在 DeepAgent 里没有采用这个方案原因有三个。第一是轮询延迟不管轮询间隔多短都比真正的流式推送慢一拍打字机效果会一顿一顿第二是文件 IO 对磁盘有持续压力多用户并发场景下会有一堆临时文件要清理第三是状态管理复杂前端需要维护“上次读到哪里”的游标不如 SSE 直接把增量 push 过来干净。所以这个方案只适合“没有流式能力”的临时过渡不是工程正道。3. Agent 记忆体系四层划分与实现困境3.1 记忆分层的设计逻辑做 Agent 记忆最容易犯的错是把所有历史消息一股脑塞给模型。模型上下文窗口有限塞太多会稀释注意力、拉长响应时间、提高成本。DeepAgent 把记忆分成四层核心思路就是按“时效性和重要程度”分级处理。记忆层级定义存储介质生命周期短期记忆当前会话的对话上下文Redis List会话结束即过期中期记忆近几轮对话的摘要Redis 向量库数小时到数天长期记忆跨会话提取的用户偏好、事实向量库 KV 存储数周到数月永久记忆用户身份、稳定画像关系数据库长期不失效分层的价值在于每次请求时系统按需取各层记忆。短期记忆全量取中期和长期记忆按相关性取 topK永久记忆只取核心档案。这样既保证上下文新鲜又不会让 Prompt 爆炸。3.2 短期记忆滑动窗口的工程细节短期记忆是最基础的实现上我用 Redis List 存每个会话的消息记录Key 设计成session:{sessionId}:messages。每条消息是一个 JSON 对象包含 roleuser/assistant和 content。构建 Prompt 时不是把全部历史都取出来而是按 token 上限做滑动窗口裁剪窗口策略 1. 固定保留 system prompt 和永久记忆块 2. 从最近的对话消息开始向前取 3. 累加消息的预估 token 数超过上限则停止 4. 如果单条消息超过上限从前半部分截断Token 估算我用的粗粒度公式中文字符数 × 1.2 英文字符数 ÷ 4。这个估算不需要精确只要保证不超模型上下文就行。窗口上限我设的是 4000 token给模型输出留足空间。这里有一个容易踩的坑不要用“消息条数”做窗口标准因为同样是 10 条消息用户贴一大段代码和回复一个“嗯”消耗的 token 差了上百倍。必须用 token 数做裁剪依据。3.3 中期记忆摘要不是简单截断中期记忆解决的问题是当对话超过窗口容量后早期对话信息就丢了。比如用户在第 5 轮说过“我喜欢技术文档风格的回答”窗口裁剪后这条信息可能被挤出上下文。我的方案是做递归摘要当会话累计到 N 轮我设的是 12 轮时触发一次摘要任务。摘要不是简单截断旧消息而是用模型把过去 N 轮中“对后续对话仍然重要的信息”压缩成结构化文本然后以“摘要”身份注入上下文。摘要 Prompt简化版 请阅读以下对话历史提取对后续对话有帮助的关键信息 - 用户明确表达过的偏好 - 用户提到过的事实 - 正在进行的任务进度 输出格式简洁的要点列表保留具体名词摘要本身也会增长所以我设了两级摘要12 轮的小摘要存 Redis累计 4 个小摘要后再生成一个“摘要的摘要”旧的摘要从上下文中退役。这是逻辑上防止摘要无限膨胀的兜底策略。3.4 长期记忆为什么说它还是个半成品这是 DeepAgent 目前最不硬气的一块我把它当前的状态如实摆出来读写链路通了但读出来的东西经常用不上。先说信息抽取。长期记忆的素材来源于用户历史对话但“应该记住什么”这件事没有标准答案。我维护了三个抽取维度信息类型例子存储方式用户偏好“我喜欢简洁的回答”向量库 KV用户事实“我在北京工作”KV 存储事件经历“上周项目上线了”向量库抽取用 LLM 做每轮对话结束后异步把 messages 丢给抽取模型返回结构化结果。这里的第一层问题就出现了抽取模型的 Prompt 稍微写得不够细就会抽出一堆“用户说了谢谢”这种垃圾信息。我调了三版 Prompt才把“值得长期记忆”这个标准压到一个勉强可用的水平。再说检索。每次新对话开始时系统会把用户输入向量化然后从向量库召回 topK 条长期记忆。这个方案理论上很顺实际效果却不稳定。核心原因在于长期记忆是“用户偏好”而用户当前输入往往是“具体任务”。比如用户说过“我喜欢简洁回答”当前输入是“帮我写一封邮件”。这两句话的语义向量相似度很低向量检索根本召不回那条偏好记忆。所以纯向量召回在记忆场景是跛脚的。目前我在补救的方案是混合检索向量召回一条线同时用规则命中的方式把用户 ID 维度下的关键偏好 KV 做精确匹配两条线合并去重后再注入 Prompt。跑下来后偏好类记忆的命中率有明显提升但仍然谈不上稳。最让我头疼的是记忆的更新和冲突。用户这周说“我喜欢详细报告”下周说“算了还是简洁点吧”系统里就同时存在两条互相矛盾的记忆。目前的实现是写入时不做冲突检测两条都留着靠注入重复后让模型自己判断这显然是很粗糙的做法。更合理的做法是给每条记忆加时间戳检索时按时间衰减加权但这一块还没排进开发计划。最后是评估。一个记忆系统好不好我目前没有量化指标。我给自己定了两个指标用户复访时能不能命中他之前提过的偏好用户会不会重复问同样的问题这两条指标甚至不需要自动化跑分直接人工回归测试就能看出深浅。但正因为一直没有把评估机制搭起来记忆系统的改进方向也一直是“拍到哪算哪”这就是“半成品”的根源。3.5 永久记忆目前只做了个壳永久记忆的设计目标是保存用户的稳定档案用户 ID、称呼、地域、行业、偏好设置这类几乎不变的信息。我单独建了一张用户画像表字段不多核心是稳定属性和更新时间。永久记忆的注入方式是每次对话的 system prompt 里自动带上一个“用户档案”块。这里有个权衡档案字段越多Prompt 越长模型越容易被固化信息影响判断。所以我对永久记忆做了一个精简只注入置信度高、且当前对话确实用得上的字段。置信度怎么来目前靠的是“信息被用户确认过的次数”——用户明确说过“对”或者“就这样”置信度1达到阈值才会写进永久层。老实说永久记忆这一层在 DeepAgent 里还没有遇到真正的技术挑战更像是一个预留了结构但没填充血肉的骨架。当用户量上来之后怎么高效更新画像、怎么做字段的演化才是真正的战场。4. 实战中的问题排查与避坑记录4.1 SSE 不出字先查 Nginx 缓冲现象前端请求发出去状态一直 pending过了几十秒一次性返回完整内容完全没有流式效果。排查思路第一步看网络面板确认响应头 Content-Type 是不是text/event-stream第二步看响应是不是 chunked 传输第三步查 Nginx 配置。这个问题的根因 90% 是 Nginx 缓冲。默认情况下 Nginx 会等后端数据攒够一定量再一次性转发把流式接口变成了“半批式”。解决方案就是我上面写的在 location 里加proxy_buffering off和add_header X-Accel-Buffering no。改完配置记得nginx -s reload不需要重启服务。4.2 中文乱码charset 和 TextDecoder 必须双端对齐现象接口通了但前端收到的是乱码或者最后一个字偶尔乱一下。排查思路看响应头的 Content-Type 是否包含charsetutf-8。Spring 的produces MediaType.TEXT_EVENT_STREAM_VALUE只指定了 MIME 类型没指定字符集角落场景下默认可能走 ISO-8859-1。我的做法是在 produces 里显式写完整produces text/event-stream;charsetutf-8前端则统一用new TextDecoder(utf-8)并且 decode 时传{ stream: true }处理跨 chunk 的字符边界。这两端对齐之后中文渲染再没出过问题。4.3 abort 之后服务端还在继续生成现象用户点了停止页面停止渲染但后端日志显示模型调用还在跑直到完整生成结束才停。前面说过根因是 abort 只断了浏览器端的连接服务端如果不订阅连接断开事件模型调用就不会终止。我的解决方案是维护任务注册表ConcurrentHashMap在 onCompletion 里取消对应的 Future。还有一个隐含问题Future.cancel(true) 并不保证能中断正在执行的线程它只是给线程发中断信号。如果模型 SDK 内部的网络调用不响应中断取消会失效。所以更保险的做法是在 modelAdapter 层提供显式的 cancel 方法SDK 内部关闭 HTTP 连接。这块因为我用了适配器模式所以只需要在适配器里实现对应方法业务层代码不用变。4.4 长期记忆检索不到相关内容现象用户上次说过某条偏好这次重新打开对话系统好像完全不记得。排查思路分三步第一先确认记忆有没有写入——查向量库和 KV 存储看有没有对应的记录第二确认检索链路有没有问题——把检索用的 query 向量和记忆文本向量打印出来算一下相似度第三分析是不是召回策略的问题。实际跑下来八成都是第三条。向量检索对“任务型问题”和“偏好型记忆”的语义匹配天然弱这个不是调参能解决的。我的应对方案是混合检索在向量召回之外增加规则召回。用户 ID 维度下的偏好 KV 做精确匹配和向量结果合并、去重、按置信度排序后注入。这样至少把偏好类记忆的命中率从“基本靠运气”拉到了“可预期”。4.5 常见问题速查表现象可能原因快速处理前端无流式效果一次性返回Nginx 缓冲proxy_buffering off中文乱码响应头缺 charsetproduces 显式指定 utf-8连接 30 秒断开SseEmitter 默认超时构造时给 180 秒点停止后还在生成未取消后端任务onCompletion 里 cancel长内容生成一半断连代理空闲断开心跳注释每 15 秒一次记忆命中率低纯向量召回局限混合检索 规则匹配前端拿不到 AuthorizationEventSource 限制改用 fetch ReadableStream5. 最后分享一点实际体会项目上线两周用户最直观的反馈是“打字机效果比转圈好太多了”。这个体验提升是 SSE 带来的链路本身不复杂但它把“大模型在思考”这件事变成了用户看得到的进度对产品气质的改变是立竿见影的。至于长期记忆目前还没有用户主动说“这个 Agent 居然记得我”这本身就说明它还没做到位。根据我现在的实操经验长期记忆这块下一阶段的优先级是先补评估指标偏好命中率、重复提问率再修已知的几个硬伤冲突检测、遗忘机制、偏好类记忆的召回。等这轮补完我会再来写一篇那时候的标题应该是“长期记忆不再是半成品”了。