情感陪伴 Agent 开发博客 (SSE 流式响应)
一、需求背景
老年人群体普遍面临孤独感和就医焦虑两大心理问题。子女不在身边时,老人遇到身体不适往往感到无助;去医院就诊时面对复杂的流程和专业术语也容易紧张。
本模块构建了一个「情感陪伴 Agent」,提供两种模式:
- 心理慰藉模式 (companion):扮演一位温暖的陪伴者,倾听老人的心声,给予情感支持
- 陪诊助手模式 (diagnosis):帮助老人整理症状描述、解释医疗流程、缓解就医焦虑
与上一个用药审核 Agent 不同,本模块的核心技术特性是SSE (Server-Sent Events) 流式响应——AI 的回复像打字一样逐字推送到前端,大幅提升交互体验。
二、技术选型
| 技术 | 选型 | 理由 |
|---|---|---|
| 流式推送 | Spring MVCSseEmitter | 无需引入 WebFlux 响应式框架,与现有 MVC 架构兼容 |
| 流式 HTTP 客户端 | Java 内置java.net.http.HttpClient | JDK 11+ 原生支持,零额外依赖,支持 InputStream 流式读取 |
| 异步处理 | ExecutorService线程池 | 避免流式读取阻塞 Servlet 线程 |
| 大模型 API | DeepSeek Chat API (stream=true) | OpenAI 兼容的 SSE 流式接口 |
为什么不用 WebFlux?项目已经是 Spring MVC 架构,引入 WebFlux 需要全面改造(Mono/Flux 返回类型、响应式数据库驱动等)。
SseEmitter是 Spring MVC 原生提供的 SSE 支持,可以在不改变项目架构的前提下实现流式推送。
三、流式响应架构设计
3.1 流式 vs 非流式对比
【非流式】用户发送 → 等待 5-10 秒 → 一次性收到完整回复 【流式】 用户发送 → 0.5 秒后开始逐字显示 → 持续 5-10 秒 → 显示完成流式的优势在于首字延迟极低,用户几乎立刻就能看到 AI 开始回复,心理上不会觉得「卡住了」。
3.2 端到端流式链路
┌──────────┐ POST请求 ┌───────────────┐ stream=true ┌───────────┐ │ 前端 │ ──────────→ │ Controller │ ──────────────→ │ DeepSeek │ │ │ │ (SseEmitter) │ │ API │ │ │ ←─ SSE 流 ── │ ↑ │ ←── SSE 流 ──── │ │ │ 逐字渲染 │ │ Service层 │ data: {...} │ │ │ │ │ (异步线程池) │ │ │ └──────────┘ └───────────────┘ └───────────┘关键流程:
- Controller 创建
SseEmitter并立即返回(非阻塞) - Service 在异步线程中向 DeepSeek 发起
stream=true的请求 - 通过
InputStream逐行读取 DeepSeek 返回的 SSE 流 - 解析每个
data: {...}JSON 中的delta.content字段 - 通过
emitter.send()将内容片段推送给前端 - 收到
[DONE]信号后调用emitter.complete()关闭连接
四、详细实现
4.1 DTO 层:请求参数
@Data@Schema(description="情感陪伴对话请求")publicclassCompanionChatRequest{@NotBlank(message="消息内容不能为空")@Schema(description="用户消息",example="我今天觉得胸口有点闷")privateStringmessage;@NotBlank(message="对话模式不能为空")@Schema(description="对话模式: companion(心理慰藉) / diagnosis(陪诊)",example="companion")privateStringmode;}mode字段决定 Agent 的「人格」——同一个服务根据 mode 切换不同的 System Prompt。
4.2 Service 层:双模式人设 Prompt
心理慰藉模式 Prompt
privatestaticfinalStringCOMPANION_PROMPT=""" 你是一位温暖、耐心的老年人情感陪伴助手,名叫"暖心"。 你的职责是为老年人提供心理慰藉和情感支持。 你的性格特点: - 说话温柔、亲切,像一位关心长辈的晚辈 - 善于倾听,会先共情再给建议 - 用简单易懂的语言交流,避免专业术语 - 适当使用鼓励性的话语 - 关注老人的情绪变化,及时安抚 对话原则: 1. 先表达理解和关心 2. 温和地引导老人表达感受 3. 给出积极正面的回应 4. 必要时建议老人与家人或专业人士沟通 5. 绝不给出医疗诊断或用药建议,涉及身体不适时建议就医 请用中文回答,语气要亲切自然。 """;陪诊助手模式 Prompt
privatestaticfinalStringDIAGNOSIS_PROMPT=""" 你是一位专业的老年人陪诊助手,名叫"安心"。 你的职责是帮助老年人在就医过程中理解病情、整理问题,并提供陪伴支持。 你的职责: 1. 帮助老人整理和描述症状,以便就诊时更清晰地向医生表达 2. 解释常见的医学检查项目和流程 3. 帮助老人理解医嘱和注意事项 4. 提醒就诊前的准备事项(空腹、带证件等) 5. 缓解就医紧张情绪 重要原则: - 你不是医生,绝不做诊断或开处方 - 始终建议老人遵循医生的专业意见 - 遇到紧急情况立即建议拨打120或前往急诊 - 用简单易懂的语言解释医学概念 - 保持耐心和温和的态度 请用中文回答,语气要专业但亲切。 """;通过 mode 字段动态切换:
privateStringgetSystemPrompt(Stringmode){return"diagnosis".equalsIgnoreCase(mode)?DIAGNOSIS_PROMPT:COMPANION_PROMPT;}Prompt 设计要点:两个模式的关键差异在于——「暖心」侧重情感共情(先理解再建议),「安心」侧重信息整理(帮助老人与医生高效沟通)。两者都设置了安全边界:绝不做医疗诊断。
4.3 Service 层:SSE 流式调用(核心难点)
这是本模块的技术核心——如何实现从 DeepSeek 到前端的端到端流式传输。
4.3.1 创建 SseEmitter 并异步执行
@OverridepublicSseEmitterchatStream(CompanionChatRequestrequest){// 创建 SseEmitter,超时时间 120 秒SseEmitteremitter=newSseEmitter(120_000L);// 在异步线程中执行流式调用,不阻塞 Servlet 线程executor.execute(()->{try{streamFromDeepSeek(request,emitter);}catch(Exceptione){log.error("流式对话异常",e);try{emitter.send(SseEmitter.event().name("error").data("对话服务暂时不可用,请稍后重试"));}catch(Exceptionignored){}emitter.completeWithError(e);}});// 注册超时和异常回调emitter.onTimeout(emitter::complete);emitter.onError(t->log.warn("SSE 连接异常断开",t));returnemitter;}为什么需要异步线程池?
SseEmitter的工作原理是:Controller 方法返回SseEmitter后,Servlet 线程立即释放;后续通过其他线程调用emitter.send()推送数据。如果在 Servlet 线程中同步读取 DeepSeek 的流,会长时间占用线程,在高并发时耗尽线程池。
4.3.2 流式读取 DeepSeek SSE 响应
privatevoidstreamFromDeepSeek(CompanionChatRequestrequest,SseEmitteremitter)throwsException{Stringurl=deepSeekConfig.getBaseUrl()+"/v1/chat/completions";// 构建请求体,关键是 "stream": trueMap<String,Object>body=Map.of("model",deepSeekConfig.getModel(),"messages",List.of(Map.of("role","system","content",getSystemPrompt(request.getMode())),Map.of("role","user","content",request.getMessage())),"temperature",0.8,"max_tokens",1500,"stream",true// 开启流式响应);StringjsonBody=objectMapper.writeValueAsString(body);// 使用 Java 内置 HttpClient 发起请求HttpRequesthttpRequest=HttpRequest.newBuilder().uri(URI.create(url)).header("Content-Type","application/json").header("Authorization","Bearer "+deepSeekConfig.getApiKey()).POST(HttpRequest.BodyPublishers.ofString(jsonBody)).timeout(Duration.ofSeconds(60)).build();// 关键:使用 InputStream 方式接收响应,实现流式读取HttpResponse<java.io.InputStream>response=httpClient.send(httpRequest,HttpResponse.BodyHandlers.ofInputStream());// 逐行读取 SSE 数据try(BufferedReaderreader=newBufferedReader(newInputStreamReader(response.body(),StandardCharsets.UTF_8))){Stringline;while((line=reader.readLine())!=null){if(line.isBlank())continue;if(!line.startsWith("data: "))continue;Stringdata=line.substring(6).trim();// [DONE] 表示流结束if("[DONE]".equals(data)){emitter.send(SseEmitter.event().name("done").data("[DONE]"));break;}// 解析 JSON,提取 delta.contenttry{JsonNodenode=objectMapper.readTree(data);JsonNodedelta=node.path("choices").get(0).path("delta");Stringcontent=delta.path("content").asText("");if(!content.isEmpty()){emitter.send(SseEmitter.event().name("message").data(content));}}catch(Exceptione){log.debug("跳过无法解析的 SSE 数据: {}",data);}}}emitter.complete();}DeepSeek 流式响应格式解析:
DeepSeek 开启
stream=true后,响应是标准的 SSE 格式,每行格式为:data: {"id":"...","choices":[{"delta":{"content":"你"},"index":0}]} data: {"id":"...","choices":[{"delta":{"content":"好"},"index":0}]} ... data: [DONE]非流式响应中内容在
choices[0].message.content,流式响应中每个 chunk 的增量内容在choices[0].delta.content,这是 OpenAI 格式流式与非流式的关键区别。
4.3.3 参数选择说明
| 参数 | 值 | 说明 |
|---|---|---|
temperature | 0.8 | 比用药审核 Agent 的 0.3 更高,让对话更自然、有温度感 |
max_tokens | 1500 | 陪伴对话不需要太长的回复,控制在合理范围 |
stream | true | 开启流式输出 |
| SseEmitter 超时 | 120,000ms | 2 分钟超时,足够完成一轮对话 |
| HttpClient 超时 | 60s | API 调用超时保护 |
4.4 Service 层:非流式兜底接口
为不支持 SSE 的客户端(如微信小程序)提供传统的同步接口:
@OverridepublicStringchat(CompanionChatRequestrequest){Stringurl=deepSeekConfig.getBaseUrl()+"/v1/chat/completions";HttpHeadersheaders=newHttpHeaders();headers.setContentType(MediaType.APPLICATION_JSON);headers.setBearerAuth(deepSeekConfig.getApiKey());Map<String,Object>body=Map.of("model",deepSeekConfig.getModel(),"messages",List.of(Map.of("role","system","content",getSystemPrompt(request.getMode())),Map.of("role","user","content",request.getMessage())),"temperature",0.8,"max_tokens",1500);try{HttpEntity<Map<String,Object>>entity=newHttpEntity<>(body,headers);ResponseEntity<String>response=restTemplate.exchange(url,HttpMethod.POST,entity,String.class);JsonNoderoot=objectMapper.readTree(response.getBody());returnroot.path("choices").get(0).path("message").path("content").asText();}catch(Exceptione){log.error("调用 DeepSeek API 失败",e);thrownewRuntimeException("情感陪伴服务暂时不可用,请稍后重试: "+e.getMessage());}}4.5 Controller 层:双端点设计
@RestController@RequestMapping("/api/companion")@RequiredArgsConstructor@Tag(name="情感陪伴 Agent",description="基于 AI 的老年人情感陪伴与陪诊助手,支持流式响应")publicclassEmotionalCompanionController{privatefinalEmotionalCompanionServiceemotionalCompanionService;// 流式接口:produces 声明为 text/event-stream@PostMapping(value="/chat/stream",produces=MediaType.TEXT_EVENT_STREAM_VALUE)@Operation(summary="流式对话",description="SSE 流式响应,逐字输出 AI 回复")publicSseEmitterchatStream(@RequestBody@ValidCompanionChatRequestrequest){returnemotionalCompanionService.chatStream(request);}// 非流式接口:标准 JSON 响应@PostMapping("/chat")@Operation(summary="普通对话",description="非流式,等待完整回复后一次性返回")publicResult<String>chat(@RequestBody@ValidCompanionChatRequestrequest){Stringresult=emotionalCompanionService.chat(request);returnResult.success(result);}}注意
produces = MediaType.TEXT_EVENT_STREAM_VALUE:这告诉 Spring MVC 该接口返回的 Content-Type 是text/event-stream,浏览器/客户端据此识别为 SSE 流。
五、前端接入指南
5.1 Web 前端(fetch + ReadableStream)
asyncfunctionchatStream(message,mode){constresponse=awaitfetch('/elderlycare/api/companion/chat/stream',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message,mode})});constreader=response.body.getReader();constdecoder=newTextDecoder();letbuffer='';while(true){const{done,value}=awaitreader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});constlines=buffer.split('\n');buffer=lines.pop();for(constlineoflines){if(line.startsWith('data:')){constdata=line.substring(5);if(data==='[DONE]')return;document.getElementById('chat-output').textContent+=data;}}}}5.2 微信小程序(使用非流式接口)
wx.request({url:'https://your-server/elderlycare/api/companion/chat',method:'POST',header:{'Content-Type':'application/json'},data:{message:'我今天觉得胸口有点闷',mode:'companion'},success(res){that.setData({reply:res.data.data});}});六、SSE 协议细节
6.1 事件类型定义
| 事件名 | data 内容 | 说明 |
|---|---|---|
message | AI 回复的文本片段 | 前端应拼接到对话区域 |
done | [DONE] | 流结束信号,前端应关闭连接 |
error | 错误描述文本 | 异常时发送,前端应展示错误提示 |
6.2 实际 SSE 数据示例
event:message data:您 event:message data:好 event:message data:,听到您说 event:message data:胸口有点闷 event:message data:,我很关心 event:message data:您的感受。 ... event:done data:[DONE]七、测试方法
curl 流式测试(推荐)
curl-XPOST http://localhost:8080/elderlycare/api/companion/chat/stream\-H"Content-Type: application/json"\-d'{"message": "我今天觉得胸口有点闷", "mode": "companion"}'\--no-buffer加--no-buffer可以实时看到逐字输出效果。
curl 普通测试
curl-XPOST http://localhost:8080/elderlycare/api/companion/chat\-H"Content-Type: application/json"\-d'{"message": "明天要去医院做心电图,需要注意什么?", "mode": "diagnosis"}'八、文件结构总结
src/main/java/com/xxx/elderlycare/ ├── controller/ │ └── EmotionalCompanionController.java # 双端点:/chat/stream (SSE) + /chat (普通) ├── dto/ │ └── CompanionChatRequest.java # 请求 DTO(message + mode) ├── service/ │ ├── EmotionalCompanionService.java # 接口(chatStream + chat) │ └── impl/ │ └── EmotionalCompanionServiceImpl.java # 核心实现 │ ├── COMPANION_PROMPT # 心理慰藉人设 │ ├── DIAGNOSIS_PROMPT # 陪诊助手人设 │ ├── chatStream() # SseEmitter + 异步线程 │ ├── streamFromDeepSeek() # HttpClient 流式读取 │ └── chat() # RestTemplate 同步调用九、总结与思考
技术亮点
零依赖流式方案:没有引入 WebFlux 或 OkHttp,使用 Java 内置
HttpClient+ Spring MVCSseEmitter就实现了完整的 SSE 流式传输,与现有 MVC 架构完美兼容。双模式 Prompt 切换:通过
mode字段在运行时切换 System Prompt,一个 Service 支撑两种截然不同的对话风格,避免了代码重复。异步非阻塞:
SseEmitter+ 线程池的组合让流式读取不占用 Servlet 线程,保证了服务在高并发下的响应能力。双端点兜底:同时提供流式和非流式接口,SSE 不可用时(如小程序)可无缝降级。
局限性与改进方向
- 无多轮对话上下文:当前每次请求都是独立的单轮对话。后续可引入会话 ID + 消息历史管理,实现连续对话。
- 无情绪识别:可以在 Service 层增加情绪分析前处理,根据检测到的情绪状态动态调整回复风格。
- 线程池参数:当前使用
CachedThreadPool,生产环境建议替换为固定大小的线程池并配置合理的队列长度。
作者简介:YOU优,一名专注 AI 应用开发的程序员。
项目源码:https://gitee.com/cai-xukun1111/elder-guard-ai
觉得有趣的话,点个赞再走吧 ❤️