DeepAgent 这个项目从立项到现在差不多四个月。最初就两个目标:一是把底层各种大模型接口统一封装成一套 AI 交互逻辑,让业务方不用关心某个模型返回的是 JSON 还是 Markdown;二是在这个基础上做一个真正有记忆的 Agent,能跨会话记住用户说过的话。这周刚把 SSE 流式输出推到线上,前端终于不用再对着白屏等几十秒,模型回答可以一个 chunk 一个 chunk 地渲染出来。但实现完 SSE 再回头看长期记忆模块,它仍然是个典型半成品:数据能写进去,查询基本靠拼 SQL,向量检索和遗忘机制一个都没接。这篇文章就把这两块拆开聊,给同样在折腾 agent 服务的同学一点参考。
1. 项目背景:DeepAgent 为什么先把 SSE 推上线,而不是先补长期记忆
1.1 DeepAgent 这个项目解决什么问题
DeepAgent 不是某个特定聊天机器人,而是一个放在业务系统前面的 Agent 服务层。团队内部有工单助手、知识库问答、内部运维机器人等多个场景,每个场景都要跟大模型打交道。如果各做各的,Prompt 怎么写、上下文怎么维护、模型怎么切换、工具调用怎么收敛,全是重复劳动。DeepAgent 的定位就是把这些能力收拢成一个 HTTP 服务,业务方只需要传入会话 ID 和用户消息,剩下的模型选择、上下文组装、工具调用、结果输出,都由服务端负责。
这个定位决定了两个核心诉求:第一,接口必须足够简单,让业务方一天就能接完;第二,交互体验必须接近原生聊天,不能让人感觉到这是机器人在复制粘贴。这两点在最早的设计里其实没有考虑得太清楚,第一版就是同步接口,前端等一个完整 JSON 回来再渲染。后来业务方反馈多了,SSE 才被提到最高优先级。
1.2 SSE 上线之前,AI 交互的体验有多痛
没有 SSE 之前,DeepAgent 对外暴露的是普通 POST 接口:前端发一个请求,服务端调用大模型,等模型完整输出后把整段文本放进 JSON 返回。遇到简单问题还好,两三秒就回来了。但只要涉及工具调用、多轮推理、长文本生成,响应时间轻松超过十秒。一个完整的 Agent 调用可能要经历“大模型第一轮回答 -> 解析出工具参数 -> 执行外部 API -> 把结果拼回上下文 -> 模型第二轮总结”。整个链路走完,中间只要有一环节稍慢,用户看到的就是一个转圈的光标。
更麻烦的是网关超时。我们内部网关默认读超时 60 秒,如果模型推理加上工具执行超过这个阈值,客户端会直接收到 504,但服务端可能已经拿到了结果。结果就是用户等了一分钟,最终看到的却是“请求失败”,重新问一遍还得从零开始。这种体验放在 ToB 内部工具里还可以容忍,放在面向用户的业务系统里就是事故。
后来我们决定把响应流式化,让模型每生成一段就把增量推给前端。前端一边收一边渲染,用户能看到“正在思考”和“正在组织答案”的过程,心理等待感会大幅下降。即使最终结果一样,体验也是天壤之别。
1.3 “长期记忆还是半成品”到底缺了什么
项目立项时我写过一张记忆架构图,把 Agent 记忆分成三层:短期记忆、长期记忆、永久记忆。当时计划是短期记忆用会话上下文实现,长期记忆用向量库加 RAG 实现,永久记忆用结构化存储实现。理想很丰满,真到实现的时候发现每一层都比想象中复杂。
短期记忆相对好做,把历史消息按 token 数裁剪,塞进 Prompt 就行。长期记忆要做到“跨会话记住用户说过的事实”,比如用户上个星期提到“公司有一个订单系统,数据库是 MySQL 8”,这次会话问“订单系统的表结构在哪看”,Agent 应该能从长期记忆里找到这条背景,而不是让用户重复一遍。想做到这一点,至少要解决抽取、存储、召回、更新、遗忘五个环节。现在的 DeepAgent 只做到了“对话文本入库”,五个环节里实际上只完成了存储的骨架,召回基本靠关键词,更新和遗忘一个都没做。所以我才说,长期记忆目前就是个半成品。
2. 技术栈选型:基于 Spring WebFlux 封装 AI 交互逻辑
2.1 为什么选择 Spring Boot 3 + WebFlux + WebClient
DeepAgent 的服务端是 Java 技术栈,老项目都是 Spring Boot,团队里没人想为了一个 Agent 服务再引入 Python 或者 Node。选型的关键在于,Java 生态里做 SSE 有两种主流方式:Spring MVC 的 SseEmitter 和 Spring WebFlux 的 Flux 。
先说 SseEmitter,它实现起来非常直观,一个方法返回 SseEmitter,然后在其他线程里手动调用 send。但 SseEmitter 是 Servlet 阻塞模型,每个连接会占用一个 Tomcat 线程,大模型流式响应时长动辄几十秒,线程占用时间太长,并发一高线程池就很容易被打满。当然,如果只是给内部低并发用,SseEmitter 完全够了,但 DeepAgent 后面要接的业务系统不止一个,我一开始就按高并发场景设计。
所以我最终选择的是 Spring Boot 3.2 + WebFlux + WebClient。WebFlux 是非阻塞模型,一个连接不会死死占住一个线程,数据天然以 Flux 形式流动,非常适合做流式转发。WebClient 则是 WebFlux 生态里的 HTTP 客户端,我们可以用它去请求大模型的流式接口,然后把拿到的 Flux 原样转给客户端。整个过程没有阻塞,内存和线程的开销都小得多。
我整理了一张对比表供参考:
| 方案 | 线程模型 | 实现复杂度 | 适合场景 |
|---|---|---|---|
| Spring MVC + SseEmitter | 阻塞,每连接占线程 | 低 | 低并发、内部工具 |
| Spring WebFlux + Flux | 非阻塞,事件驱动 | 中 | 高并发、长连接推送 |
| Netty + 自研 | 完全异步 | 高 | 需要极致控制时不推荐 |
2.2 为什么用 SSE 而不是 WebSocket
很多朋友第一反应是搞 WebSocket,因为 WebSocket 的名气大,聊到推送第一时间就会想到它。但 AI 聊天这个场景,SSE 才是更合适的方案。原因很简单,聊天虽然看起来像双向交互,但本质上还是“客户端发起请求,服务端流式返回结果”的请求响应模型。客户端发消息走普通 HTTP POST,服务端返回结果走 SSE,完全够用。
SSE 有几个非常实用的优势:它基于 HTTP,不需要像 WebSocket 那样做协议升级,现有的 Nginx、网关、浏览器都天然支持;它有原生的断线重连机制,EventSource 会自动重连;服务端实现也简单,Java 里只要返回一个 Flux 就能搞定。WebSocket 是双向的,适合服务端需要主动给客户端推消息、同时客户端也要随时给服务端发指令的场景,比如文件监听里既要轮询文件状态还要下发控制命令。
DeepAgent 的对话流程里,控制命令走普通 HTTP 接口,模型输出走 SSE 流,没必要把连接升级成 WebSocket。前端也省了很多复杂的心跳处理,至少在 DeepAgent 这个场景里,SSE 是用最少的复杂度解决实际问题。
2.3 封装 AI 交互逻辑的分层设计
选完技术方案,接下来要解决“怎么封装 AI 交互逻辑”的问题。DeepAgent 并没有把大模型调用直接暴露在 Controller 里,而是做了分层,每一层只负责一件事。
- Controller 层:负责 HTTP 协议转换,接收前端请求,返回 Flux 。
- AgentOrchestrator 层:负责编排,包括 Prompt 组装、工具调用循环、记忆注入、上下文截断。
- ModelAdapter 层:负责做大模型适配,把不同厂商的接口统一成同一个 StreamResult 返回。
- MemoryService 层:负责记忆读写,包括短期会话缓存、长期事实存取。
这样分层的核心好处是,SSE 只是最外层的传输形态,它和业务逻辑解耦。未来如果要从 SSE 换成 WebSocket,或者在 SSE 之外再支持纯文本输出,只需要改 Controller 层和前端协议,AgentOrchestrator 下面的代码完全不用动。实测下来,这种设计在后期排查问题的时候特别有用,比如“是前端迟迟不收包还是服务端没吐流”,只要看 Controller 层的日志就能定位。
3. 核心实现:SSE 流式输出、Abort 取消与 React 实时渲染
3.1 后端实现:把大模型流式响应映射成 SSE 事件流
先看 Controller 这一层,核心代码并不复杂:
@PostMapping(value = "/v1/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<AiChunk>> streamChat(@RequestBody ChatRequest request) { String sessionId = request.sessionId(); return agentOrchestrator.streamChat(request) .map(chunk -> ServerSentEvent.builder(chunk) .event("message") .id(sessionId) .build()) .doOnCancel(() -> log.info("SSE stream cancelled, session={}", sessionId)) .doOnError(err -> log.error("SSE stream error, session={}", sessionId, err)); }注意produces = MediaType.TEXT_EVENT_STREAM_VALUE,这个 MediaType 是text/event-stream。Spring WebFlux 看到返回值是Flux<ServerSentEvent>,就会自动按 SSE 协议把每个元素序列化成事件流。前端只要按event-stream解析就能一行一行拿到数据。
真正的流式源头在 ModelAdapter 层。它用 WebClient 请求大模型的流式接口:
public Flux<AiChunk> stream(ModelRequest request) { return webClient.post() .uri(modelConfig.getChatCompletionsUrl()) .header("Authorization", "Bearer " + modelConfig.getApiKey()) .bodyValue(buildRequestBody(request)) .retrieve() .bodyToFlux(String.class) .map(this::parseSseData) .filter(Optional::isPresent) .map(Optional::get); }这里有一个很容易踩的坑:如果用bodyToMono(String.class),WebClient 会把整个 HTTP 响应体全部读完再返回,那就等于放弃了流式。必须要用bodyToFlux(String.class),它才会一个事件一个事件地往后吐。大模型接口返回的内容一般是data: {json}格式,所以String.class已经够用。如果你对接的供应商返回的是二进制或者自定义协议,这里可能需要换成自定义解码器。
3.2 模型适配层:解析 data 块、过滤结束标记
大模型流式接口返回的数据,本质上是多行 SSE 文本。每一行可能是注释、事件类型、或者是data:开头的有效载荷。常见的 OpenAI 兼容接口里,流式结束时会返回data: [DONE],这个标记不是有效内容,需要单独过滤。
我写了一个解析方法,把data:后面的 JSON 读出来,提取增量文本:
private Optional<AiChunk> parseSseData(String raw) { if (raw == null || raw.isEmpty()) { return Optional.empty(); } for (String line : raw.split("\n")) { if (line.startsWith("data:")) { String data = line.substring(5).trim(); if (data.isEmpty() || "[DONE]".equals(data)) { return Optional.empty(); } try { JsonNode json = objectMapper.readTree(data); String content = json.path("choices").get(0).path("delta").path("content").asText(); if (content.isEmpty()) { return Optional.empty(); } return Optional.of(new AiChunk(content)); } catch (Exception e) { log.warn("unable to parse chunk: {}", data); return Optional.empty(); } } } return Optional.empty(); }为了稳妥,我把每个data当成独立 JSON 来解析,解析失败就丢弃并记一条 WARN。实际生产环境里,某个模型偶尔会在流中夹带一段空 content 或者注释,直接跳过就好,不要因为一个脏数据把整个流搞挂。
3.3 前端 React:用 fetch + ReadableStream 做实时渲染
前端我用的是 React + fetch,没有用原生 EventSource。原因很简单,DeepAgent 的聊天接口是 POST 请求,需要往 request body 里放会话 ID 和用户消息。原生 EventSource 只支持 GET,虽然可以通过 URL 参数传值,但在复杂场景下不太优雅。所以我选择 fetch,把请求体正常发送,然后逐段读取 response body 的流。
核心代码大概长这样:
const controller = new AbortController(); async function streamChat(payload) { const response = await fetch('/api/v1/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', }, body: JSON.stringify(payload), signal: controller.signal, }); if (!response.ok || !response.body) { throw new Error('stream request failed'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let received = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // SSE 事件之间用空行分隔,先把积压的数据按空行切块 const events = buffer.split('\n\n'); buffer = events.pop() ?? ''; for (const eventText of events) { const dataLine = eventText .split('\n') .find((line) => line.startsWith('data:')); if (!dataLine) continue; const data = dataLine.slice(5).trim(); if (data === '[DONE]') continue; const chunk = JSON.parse(data); received += chunk.content ?? ''; setContent(received); } } }这里有一个关键细节:浏览器拿到的流不保证按行对齐,TCP 拆包可能把一个 SSE 事件拆成两次,也可能把两个事件粘在一起。所以不能直接value.toString().split('\n')去逐行解析,必须维护一个 buffer,先按\n\n切块,剩下的半截留到下一次循环再拼。这是我在联调时踩过的最常见的一个前端问题,不处理会导致偶尔丢字、偶尔报错。
3.4 AbortController:点击取消后,连接到底断在哪里
Agent 对话里一定会有一个“停止生成”按钮。前端用 AbortController 实现:
const controller = new AbortController(); // 用户点击停止 controller.abort();只要调用了abort(),浏览器就会断开当前的 fetch 请求。如果请求还没发出,它不会再发;如果请求已经发出,连接会直接被关闭。对服务端来说,WebFlux 的响应流会收到下游取消信号,触发我们刚才在 Controller 里注册的doOnCancel回调,同时 Flux 里的 WebClient 上游请求也会被取消,Netty 会释放连接。
但这里有一个坑要想清楚:客户端断开连接,不等于大模型服务器立刻停止生成。我在生产环境观察过,某些模型的流式接口在客户端断开后仍然会把剩余 token 计算完,只是不再推数据。这对计费是有影响的。如果你的供应商提供了“取消生成”的接口,最好在doOnCancel里主动调用一次,而不是单纯依赖 HTTP 连接断开。
另外,取消之后前端状态要复位,不能停留在“正在生成”的状态。我一般会在abort()的 catch 里判断error.name === 'AbortError',然后强制把状态切回 idle,并保留已经渲染出来的内容。
3.5 心跳与断线重连:别让 SSE 连接被静默回收
SSE 本身有断线重连机制,但那是 EventSource 自带的。我们前端用了 fetch,就得自己处理重连。还有一个更隐蔽的问题:中间任何一层代理(Nginx、网关)如果一段时间没收到数据,可能会认为连接空闲,直接把连接回收掉。
解决方案有两个。第一,服务端定时发送心跳注释行。SSE 协议里注释行以冒号开头,浏览器会自动忽略,但能起到“证明连接还活着”的作用。在 WebFlux 里可以这样处理:
Flux<Long> heartbeat = Flux.interval(Duration.ofSeconds(15)) .map(i -> ServerSentEvent.builder().comment("heartbeat").build());再把心跳流和实际消息流做 merge。第二,Nginx 的proxy_read_timeout必须调大,同时要关闭缓冲。否则即使服务端在推,Nginx 也会先把整块数据攒完再一次性发给客户端,SSE 的实时性就没了。
location /api/ { proxy_pass http://deepagent-service; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_buffering off; proxy_cache off; proxy_read_timeout 1800s; }proxy_buffering off是重中之重,忘了这一行,前端看到的不是逐字输出,而是突然一下子全部出现,等于白做流式。
4. 记忆体系:短期、长期、永久记忆的实现思路与半成品现状
4.1 先给三种记忆一个通俗定义
很多文章一上来就讲 RAG、向量库,但对 Agent 记忆先得有统一认识。我习惯把记忆分成三类:
| 记忆类型 | 生命周期 | 典型存储 | 例子 |
|---|---|---|---|
| 短期记忆 | 单次会话内 | 对话上下文列表 | 用户刚才提到的需求 |
| 长期记忆 | 跨会话,持续累积 | 向量库 + 结构化事实表 | 用户公司的主数据库类型、常用技术栈 |
| 永久记忆 | 基本不变,长期有效 | 主数据存储 | 用户账号、组织架构、核心身份标识 |
短期记忆解决的是“上下文连贯”,长期记忆解决的是“跨会话不再重复解释”,永久记忆解决的是“身份与底线信息不丢”。三者不是替代关系,而是要同时存在,并且在每次组装 Prompt 时按优先级注入。
4.2 短期记忆:DeepAgent 已经能用的部分
短期记忆在 DeepAgent 里已经跑得相对稳定。实现方式很朴素:每个会话在服务端保存一个消息列表,新消息进来时追加,然后根据估算的 token 数量动态裁剪。
public List<ChatMessage> buildContext(String sessionId, String userMessage) { List<ChatMessage> messages = new ArrayList<>(sessionMemory.get(sessionId)); messages.add(userMessage); while (estimateTokens(messages) > MAX_CONTEXT_TOKENS) { messages.remove(0); } return messages; }这个方案简单,但效果已经比“全量塞进去”好很多。需要注意:系统提示词和工具定义也要占 token,所以MAX_CONTEXT_TOKENS要留出安全余量。等到裁剪到只剩系统提示和最近几条消息时,短期记忆实际上已经失效了一半。下一步我计划引入“消息摘要”,把超出窗口的历史消息用模型压缩成一两句摘要,替代直接丢弃。
4.3 长期记忆半成品的现状:能存,但不会用
如果只看代码结构,DeepAgent 确实有长期记忆模块。每次对话结束,会把用户和助手的原始文本存进memory_record表,字段包括用户 ID、会话 ID、消息内容、时间戳。这个表每天都在涨,数据量不小,但真正用起来的时候却非常痛苦。
现在新会话开始时,系统会按用户 ID 拉出最近 20 条记录,用 SQL 里的 LIKE 模糊匹配去撞关键词。比如用户问“数据库配置”,代码就拼一个WHERE content LIKE '%数据库%' OR content LIKE '%配置%'。这种召回方式有几个致命问题:一是语义相近但用词不同的内容搜不到;二是历史消息里大部分是无关聊天,噪音远大于有效信息;三是没有时效性概念,三年前的旧消息和昨天的消息权重一样。
所以我把它叫半成品,并不是说“没有存储”,而是存储之后没有做加工。数据只是躺在表里,既不能主动形成认知,也不能高效地被利用。用户感受就是:换一个新会话之后,Agent 完全不记得自己是谁,聊过什么等于没聊。
4.4 长期记忆真正落地需要解决的五个问题
把长期记忆做好,至少要打通五个环节。下面结合我正在做的方案逐个拆。
第一个是事实抽取。把原始对话变成结构化事实。例如用户说“我这边有个订单系统,数据库是 MySQL 8”,模型应该抽成这样一个 JSON:
{ "subject": "用户的订单系统", "predicate": "数据库类型", "object": "MySQL 8", "confidence": 0.95, "observed_at": "2025-05-10T10:30:00Z" }抽取不能每轮对话都做,成本和噪音都太高。我计划在三个时机触发:用户明确说“记住”、对话结束后的异步批处理、包含明显关键信息词时。抽取出来之后,还要做去重归类。
第二个是存储。事实表和向量要分开。事实表用来精确过滤,向量用来语义召回。比如用 pgvector 存向量,同时保留一张事实表记录主体、谓词、对象、置信度和时间戳。查询时会先根据当前问题的向量召回相似片段,再根据事实表的字段做过滤和重排。
第三个是召回。召回的难点不是“查出来”,而是“查得准”。用户问“我的生产环境用的是哪个数据库”,应该优先召回和“生产环境”“数据库”相关的强置信度事实,而不是把三个月前一句闲聊里的“数据库”捞出来。我会用一个综合评分:语义相似度占大头,置信度次之,最近访问时间也加权。
第四个是更新与遗忘。这一块最容易被人忽略。用户今天说“我最近在学 Go”,下个月说“我已经切到 Rust 好几年了”,长期记忆不能简单把两条事实都保留,否则召回时可能自相矛盾。需要给事实加版本号,新事实覆盖旧事实时旧事实降权归档。遗忘也一样,长期没有被访问和确认的事实应该逐渐降低权重,甚至移到归档表。
第五个是评估。记忆系统好不好,不能靠感觉。我会准备一份固定的记忆问答集,包含用户画像、项目背景、技术选型等问题,然后让 Agent 基于长期记忆去回答,统计正确率和召回率。没有评估,就很难判断到底改对了没有。
4.5 我会怎么把长期记忆补成完整功能
目前我的改造路线已经定了,分三步走。第一步,先做事实抽取和结构化存储,让记忆从“聊天原文”变成“事实条目”。第二步,接入 pgvector,把每个事实向量化,实现真正的语义召回。第三步,做更新和遗忘机制,这是从“能查”到“能用”的关键一步。
短期我先做一个小过渡方案:把用户的高置信度属性,比如姓名、职位、常用技术栈,在每次对话时固定注入到系统提示词里。相当于一个轻量的用户画像缓存。这个方案实现成本低,见效快,即使长记忆半成品,用户也能先感知到“Agent 好像认识我”。
5. 实战复盘:SSE 上线过程中的 5 个坑和排查方法
5.1 Nginx 缓冲关闭后 SSE 才真正实时
SSE 上线后,团队测试反馈第一版“不流式”,前端等了十几秒才一下出全文本。抓包发现后端明明已经按增量在推,但 Nginx 默认开启了 proxy_buffering,会先把后端传过来的数据攒满再一次性发给客户端。对大模型流式场景来说,这个缓冲必须关掉。
解决方式就是在 Nginx 配置里加proxy_buffering off;。此外还要注意proxy_http_version 1.1,因为 HTTP/1.0 不支持分块传输,SSE 事件流可能被截断。这个两个配置配合起来才算完整。
5.2 首字延迟优化:别把 Flux 用成 Mono
另一个性能问题是首 token 延迟很高。排查半天,发现是 ModelAdapter 里有人把 WebClient 的返回值从Flux<String>改成了bodyToMono(String.class)。当时改代码的人想的是“字符串也能返回”,但 Mono 会等整个响应体接收完,流式直接退化成非流式。
这给团队的教训是:做 AI 流式服务,终端和 Service 层的返回类型必须统一盯住。只要有一个地方用 Mono 接收了流式 API 的响应,前面的所有 Stream 设置都白搭。我在 Code Review 里会专门留意bodyToMono、collectList、toStream这类会触发汇总的操作符。
5.3 前端长文本渲染卡顿,批量更新解决
SSE 推流的频率很高,尤其模型生成速度快的时候,每秒可能推送几十个 token。前端如果每个 token 都直接setContent(prev => prev + token),React 会频繁触发渲染,长文本生成过半后页面明显卡顿。
我的优化策略是先把增量累积到一个 buffer 里,每隔 100 毫秒批量刷新一次。代码层面可以用一个 ref 保存当前内容,再用requestAnimationFrame在下一帧统一提交。这样既保证了逐字输出的视觉效果,又不会把 React 主线程打满。
5.4 连接泄漏:客户端关页面,服务端连接没释放
有段时间服务端出现大量空闲连接。查日志发现,前端在用户关闭页面或离开聊天时没有调用controller.abort(),fetch 连接一直挂着。浏览器可能认为连接还能继续读,但实际上页面已经销毁。这个问题其实前端一行代码就能解决,在组件卸载时调用controller.abort(),确保基础连接被主动关闭。
服务端这边我也加了doOnCancel日志,可以看到每个 session 为什么被取消。如果发现大量取消发生在页面卸载点而不是用户点击“停止”时,基本可以定位为前端清理不彻底。
5.5 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 事件流不实时,一下全出来 | Nginx 缓冲开启 | 关闭proxy_buffering |
| 首 token 特别慢 | 服务端用了bodyToMono或同步取值 | 改为bodyToFlux流式接收 |
| 点击停止没效果 | 前端没有把 AbortSignal 传给 fetch | 传入signal: controller.signal |
| 长文本生成卡 UI | React 高频 setState | 用批量渲染合并刷新 |
| SSE 连接中断 | 网关或代理空闲超时 | 调大超时时间 + 服务端心跳 |
6. 后续路线:SSE 已经上线,长期记忆怎么补完
6.1 SSE 模块上线后的运行状态
SSE 模块上线到现在跑了两周,整体状态稳定。峰值并发大概在两百多个连接左右,单机 WebFlux 服务内存占用没超过 1GB,CPU 平稳,日志里没有出现连接池耗尽或者线程阻塞的问题。刚开始那几天收到最多的反馈还是“偶尔断流”,后来把 Nginx 的超时和心跳都调好之后,基本没有再出现。
这个结果也验证了一件事:用 Java 实现 SSE 流式输出,完全能上生产。之前团队里有人担心 WebFlux 学习成本高、排障困难,实际上只要把数据流的关键日志加到位,问题定位并不比传统接口难多少。尤其在 AI 场景下,Java 的非阻塞能力反而比传统线程模型更合适。
6.2 长期记忆模块的下一步任务清单
接下来这段时间,我会把精力全部转向长期记忆。任务已经拆成了几块:
- 事实抽取服务:基于大模型的结构化输出能力,把对话文本转成事实条目。
- 向量化入库:接入 pgvector,新增 embedding 字段,周期性地把新事实向量化。
- 召回接口:基于向量相似度和置信度做综合排序,对外提供记忆查询接口。
- 更新与遗忘机制:对冲突事实做版本控制,对长期未访问的记忆做降权归档。
- 用户隐私清理:提供按用户删除全部记忆的接口,避免数据合规出问题。
这块做完,DeepAgent 才敢说自己是“有记忆”的 Agent,而不是一个只能聊天的接口壳子。
6.3 个人体会
把 SSE 从开发到上线,我最大的感受是:流式推送让产品体验上了一个台阶,但真正让 Agent 显得聪明的其实是记忆。如果只把对话记录落库就管它叫长期记忆,上线后用户很快会发现它更像一个录音机而不是助手。我的建议是,从第一天就把记忆当做一个独立子系统来设计,别等 SSE 都跑通了再回头补救。
后面等我搞定长期记忆的第一个可测评版本,会把事实抽取、召回评分、冲突更新这几块的线上运行数据继续复盘出来。这套方案不一定适合所有团队,但能给同样在深水区折腾 Agent 记忆的同学提供一个可对照的落地方案。