情感陪伴 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: {...}    │           │
│          │              │  (异步线程池)  │                  │           │
└──────────┘              └───────────────┘                  └───────────┘

关键流程:

  1. Controller 创建 SseEmitter 并立即返回(非阻塞)
  2. Service 在异步线程中向 DeepSeek 发起 stream=true 的请求
  3. 通过 InputStream 逐行读取 DeepSeek 返回的 SSE 流
  4. 解析每个 data: {...} JSON 中的 delta.content 字段
  5. 通过 emitter.send() 将内容片段推送给前端
  6. 收到 [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 同步调用

九、总结与思考

技术亮点

  1. 零依赖流式方案:没有引入 WebFlux 或 OkHttp,使用 Java 内置 HttpClient + Spring MVC SseEmitter 就实现了完整的 SSE 流式传输,与现有 MVC 架构完美兼容。

  2. 双模式 Prompt 切换:通过 mode 字段在运行时切换 System Prompt,一个 Service 支撑两种截然不同的对话风格,避免了代码重复。

  3. 异步非阻塞SseEmitter + 线程池的组合让流式读取不占用 Servlet 线程,保证了服务在高并发下的响应能力。

  4. 双端点兜底:同时提供流式和非流式接口,SSE 不可用时(如小程序)可无缝降级。

局限性与改进方向

  • 无多轮对话上下文:当前每次请求都是独立的单轮对话。后续可引入会话 ID + 消息历史管理,实现连续对话。
  • 无情绪识别:可以在 Service 层增加情绪分析前处理,根据检测到的情绪状态动态调整回复风格。
  • 线程池参数:当前使用 CachedThreadPool,生产环境建议替换为固定大小的线程池并配置合理的队列长度。

作者简介:YOU优,一名专注 AI 应用开发的程序员。
项目源码:https://gitee.com/cai-xukun1111/elder-guard-ai
觉得有趣的话,点个赞再走吧 ❤️

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐