山东大学软件学院项目实训-基于语言大模型的智能居家养老健康守护系统-个人博客(二)
情感陪伴 Agent 开发博客 (SSE 流式响应)
一、需求背景
老年人群体普遍面临孤独感和就医焦虑两大心理问题。子女不在身边时,老人遇到身体不适往往感到无助;去医院就诊时面对复杂的流程和专业术语也容易紧张。
本模块构建了一个「情感陪伴 Agent」,提供两种模式:
- 心理慰藉模式 (companion):扮演一位温暖的陪伴者,倾听老人的心声,给予情感支持
- 陪诊助手模式 (diagnosis):帮助老人整理症状描述、解释医疗流程、缓解就医焦虑
与上一个用药审核 Agent 不同,本模块的核心技术特性是 SSE (Server-Sent Events) 流式响应——AI 的回复像打字一样逐字推送到前端,大幅提升交互体验。
二、技术选型
| 技术 | 选型 | 理由 |
|---|---|---|
| 流式推送 | Spring MVC SseEmitter |
无需引入 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 = "情感陪伴对话请求")
public class CompanionChatRequest {
@NotBlank(message = "消息内容不能为空")
@Schema(description = "用户消息", example = "我今天觉得胸口有点闷")
private String message;
@NotBlank(message = "对话模式不能为空")
@Schema(description = "对话模式: companion(心理慰藉) / diagnosis(陪诊)", example = "companion")
private String mode;
}
mode 字段决定 Agent 的「人格」——同一个服务根据 mode 切换不同的 System Prompt。
4.2 Service 层:双模式人设 Prompt
心理慰藉模式 Prompt
private static final String COMPANION_PROMPT = """
你是一位温暖、耐心的老年人情感陪伴助手,名叫"暖心"。
你的职责是为老年人提供心理慰藉和情感支持。
你的性格特点:
- 说话温柔、亲切,像一位关心长辈的晚辈
- 善于倾听,会先共情再给建议
- 用简单易懂的语言交流,避免专业术语
- 适当使用鼓励性的话语
- 关注老人的情绪变化,及时安抚
对话原则:
1. 先表达理解和关心
2. 温和地引导老人表达感受
3. 给出积极正面的回应
4. 必要时建议老人与家人或专业人士沟通
5. 绝不给出医疗诊断或用药建议,涉及身体不适时建议就医
请用中文回答,语气要亲切自然。
""";
陪诊助手模式 Prompt
private static final String DIAGNOSIS_PROMPT = """
你是一位专业的老年人陪诊助手,名叫"安心"。
你的职责是帮助老年人在就医过程中理解病情、整理问题,并提供陪伴支持。
你的职责:
1. 帮助老人整理和描述症状,以便就诊时更清晰地向医生表达
2. 解释常见的医学检查项目和流程
3. 帮助老人理解医嘱和注意事项
4. 提醒就诊前的准备事项(空腹、带证件等)
5. 缓解就医紧张情绪
重要原则:
- 你不是医生,绝不做诊断或开处方
- 始终建议老人遵循医生的专业意见
- 遇到紧急情况立即建议拨打120或前往急诊
- 用简单易懂的语言解释医学概念
- 保持耐心和温和的态度
请用中文回答,语气要专业但亲切。
""";
通过 mode 字段动态切换:
private String getSystemPrompt(String mode) {
return "diagnosis".equalsIgnoreCase(mode) ? DIAGNOSIS_PROMPT : COMPANION_PROMPT;
}
Prompt 设计要点:两个模式的关键差异在于——「暖心」侧重情感共情(先理解再建议),「安心」侧重信息整理(帮助老人与医生高效沟通)。两者都设置了安全边界:绝不做医疗诊断。
4.3 Service 层:SSE 流式调用(核心难点)
这是本模块的技术核心——如何实现从 DeepSeek 到前端的端到端流式传输。
4.3.1 创建 SseEmitter 并异步执行
@Override
public SseEmitter chatStream(CompanionChatRequest request) {
// 创建 SseEmitter,超时时间 120 秒
SseEmitter emitter = new SseEmitter(120_000L);
// 在异步线程中执行流式调用,不阻塞 Servlet 线程
executor.execute(() -> {
try {
streamFromDeepSeek(request, emitter);
} catch (Exception e) {
log.error("流式对话异常", e);
try {
emitter.send(SseEmitter.event()
.name("error")
.data("对话服务暂时不可用,请稍后重试"));
} catch (Exception ignored) {
}
emitter.completeWithError(e);
}
});
// 注册超时和异常回调
emitter.onTimeout(emitter::complete);
emitter.onError(t -> log.warn("SSE 连接异常断开", t));
return emitter;
}
为什么需要异步线程池?
SseEmitter的工作原理是:Controller 方法返回SseEmitter后,Servlet 线程立即释放;后续通过其他线程调用emitter.send()推送数据。如果在 Servlet 线程中同步读取 DeepSeek 的流,会长时间占用线程,在高并发时耗尽线程池。
4.3.2 流式读取 DeepSeek SSE 响应
private void streamFromDeepSeek(CompanionChatRequest request, SseEmitter emitter)
throws Exception {
String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions";
// 构建请求体,关键是 "stream": true
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,
"stream", true // 开启流式响应
);
String jsonBody = objectMapper.writeValueAsString(body);
// 使用 Java 内置 HttpClient 发起请求
HttpRequest httpRequest = 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 (BufferedReader reader = new BufferedReader(
new InputStreamReader(response.body(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isBlank()) continue;
if (!line.startsWith("data: ")) continue;
String data = line.substring(6).trim();
// [DONE] 表示流结束
if ("[DONE]".equals(data)) {
emitter.send(SseEmitter.event().name("done").data("[DONE]"));
break;
}
// 解析 JSON,提取 delta.content
try {
JsonNode node = objectMapper.readTree(data);
JsonNode delta = node.path("choices").get(0).path("delta");
String content = delta.path("content").asText("");
if (!content.isEmpty()) {
emitter.send(SseEmitter.event()
.name("message")
.data(content));
}
} catch (Exception e) {
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 的客户端(如微信小程序)提供传统的同步接口:
@Override
public String chat(CompanionChatRequest request) {
String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions";
HttpHeaders headers = new HttpHeaders();
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 = new HttpEntity<>(body, headers);
ResponseEntity<String> response = restTemplate.exchange(
url, HttpMethod.POST, entity, String.class);
JsonNode root = objectMapper.readTree(response.getBody());
return root.path("choices").get(0).path("message").path("content").asText();
} catch (Exception e) {
log.error("调用 DeepSeek API 失败", e);
throw new RuntimeException("情感陪伴服务暂时不可用,请稍后重试: " + e.getMessage());
}
}
4.5 Controller 层:双端点设计
@RestController
@RequestMapping("/api/companion")
@RequiredArgsConstructor
@Tag(name = "情感陪伴 Agent", description = "基于 AI 的老年人情感陪伴与陪诊助手,支持流式响应")
public class EmotionalCompanionController {
private final EmotionalCompanionService emotionalCompanionService;
// 流式接口:produces 声明为 text/event-stream
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@Operation(summary = "流式对话", description = "SSE 流式响应,逐字输出 AI 回复")
public SseEmitter chatStream(@RequestBody @Valid CompanionChatRequest request) {
return emotionalCompanionService.chatStream(request);
}
// 非流式接口:标准 JSON 响应
@PostMapping("/chat")
@Operation(summary = "普通对话", description = "非流式,等待完整回复后一次性返回")
public Result<String> chat(@RequestBody @Valid CompanionChatRequest request) {
String result = emotionalCompanionService.chat(request);
return Result.success(result);
}
}
注意
produces = MediaType.TEXT_EVENT_STREAM_VALUE:这告诉 Spring MVC 该接口返回的 Content-Type 是text/event-stream,浏览器/客户端据此识别为 SSE 流。
五、前端接入指南
5.1 Web 前端(fetch + ReadableStream)
async function chatStream(message, mode) {
const response = await fetch('/elderlycare/api/companion/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, mode })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('data:')) {
const data = 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 -X POST http://localhost:8080/elderlycare/api/companion/chat/stream \
-H "Content-Type: application/json" \
-d '{"message": "我今天觉得胸口有点闷", "mode": "companion"}' \
--no-buffer
加 --no-buffer 可以实时看到逐字输出效果。
curl 普通测试
curl -X POST 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
觉得有趣的话,点个赞再走吧 ❤️
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)