一、功能开发配合

会话存储的基础设施——ChatMessageChatSession 数据模型以及基于 JSON 文件的 ChatSessionService由队友开发实现

本篇聚焦于如何将这套存储层集成到两个 Agent 的业务逻辑中,实现:

  1. 两个 Agent 支持多轮对话上下文(历史消息随请求一起发给 DeepSeek)
  2. 流式接口的会话持久化(边流式推送边收集完整回复,结束后保存)
  3. 会话管理 REST API(前端可获取历史列表、查看聊天记录、删除会话)

二、DTO 层改造:新增 sessionId

两个请求 DTO 各新增了一个可选的 sessionId 字段:

用药审核请求

@Data
@Schema(description = "用药安全审核请求")
public class MedicationCheckRequest {

    @Schema(description = "会话ID,为空则创建新会话")
    private String sessionId;

    @NotEmpty(message = "当前用药列表不能为空")
    @Schema(description = "当前正在服用的药物列表", example = "[\"降压药A\", \"活血药B\"]")
    private List<String> currentMedications;

    @NotBlank(message = "拟服用药物不能为空")
    @Schema(description = "准备新增服用的药物", example = "阿司匹林")
    private String newMedication;
}

情感陪伴请求

@Data
@Schema(description = "情感陪伴对话请求")
public class CompanionChatRequest {

    @Schema(description = "会话ID,为空则创建新会话")
    private String sessionId;

    @NotBlank(message = "消息内容不能为空")
    @Schema(description = "用户消息", example = "我今天觉得胸口有点闷")
    private String message;

    @NotBlank(message = "对话模式不能为空")
    @Schema(description = "对话模式: companion(心理慰藉) / diagnosis(陪诊)", example = "companion")
    private String mode;
}

设计思路sessionId 没有加 @NotBlank 校验——它是可选的。首次对话不传,Service 层自动创建新会话;后续对话传入上一次返回的 sessionId,即可接续上下文。向前兼容:不传 sessionId 的旧版前端仍能正常工作,只是每次都是新会话。

统一响应 DTO

新增 ChatResponse,让前端能拿到 sessionId

@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "对话响应(含会话ID)")
public class ChatResponse {

    @Schema(description = "会话ID,前端后续请求需携带")
    private String sessionId;

    @Schema(description = "AI 回复内容")
    private String reply;
}

这样前端第一次对话收到 sessionId 后缓存起来,后续请求携带即可实现多轮对话。

三、用药审核 Agent:多轮对话集成

3.1 改造前后对比

【改造前】
前端传入药物列表 → 组装为单条消息 → 发给 DeepSeek → 返回

【改造后】
前端传入药物列表 + sessionId
  → 加载历史会话(或创建新会话)
  → 追加本次用户消息
  → 将 system prompt + 完整历史消息 发给 DeepSeek
  → 追加 AI 回复
  → 保存会话到磁盘
  → 返回回复 + sessionId

3.2 核心代码

@Slf4j
@Service
@RequiredArgsConstructor
public class MedicationSafetyServiceImpl implements MedicationSafetyService {

    private final DeepSeekConfig deepSeekConfig;
    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;
    private final ChatSessionService chatSessionService;

    // ... SYSTEM_PROMPT 省略(与之前相同)

    @Override
    public String checkMedicationSafety(MedicationCheckRequest request) {
        String currentDrugs = request.getCurrentMedications().stream()
                .collect(Collectors.joining("、"));
        String userMessage = String.format(
                "患者(老年人)目前正在服用以下药物:【%s】,现准备加服【%s】。" +
                "请运用医学与药学专业知识,判断是否存在药物配伍禁忌、" +
                "药食冲突或其他用药安全风险,并给出详细分析和建议。",
                currentDrugs, request.getNewMedication());

        // 1. 解析或创建会话
        ChatSession session;
        if (request.getSessionId() != null && !request.getSessionId().isBlank()) {
            session = chatSessionService.getSession(request.getSessionId());
        } else {
            session = chatSessionService.createSession("medication-safety", null);
        }

        // 2. 追加用户消息
        session.addMessage(new ChatMessage("user", userMessage));

        // 3. 调用 DeepSeek(携带完整上下文)
        String reply = callDeepSeekApi(session);

        // 4. 追加 AI 回复并持久化
        session.addMessage(new ChatMessage("assistant", reply));
        chatSessionService.saveSession(session);

        // 5. 回写 sessionId 供 Controller 返回
        request.setSessionId(session.getSessionId());
        return reply;
    }

request.setSessionId() 是一个巧妙的设计:Service 通过修改 request 对象把 sessionId 传回 Controller,避免了改变 Service 接口的返回类型(保持 String 返回值不变)。Controller 直接从 request.getSessionId() 读取即可。

3.3 构建多轮消息数组

这是实现多轮对话的关键——把会话中的所有历史消息都拼进 DeepSeek 的 messages 数组:

private List<Map<String, String>> buildMessages(ChatSession session) {
    List<Map<String, String>> messages = new ArrayList<>();
    // System Prompt 始终在最前面
    messages.add(Map.of("role", "system", "content", SYSTEM_PROMPT));
    // 按时间顺序追加所有历史消息
    for (ChatMessage msg : session.getMessages()) {
        messages.add(Map.of("role", msg.getRole(), "content", msg.getContent()));
    }
    return messages;
}

发给 DeepSeek 的消息结构如下(以第二轮对话为例):

{
  "messages": [
    {"role": "system",    "content": "你是一位临床药师..."},
    {"role": "user",      "content": "患者正在服用硝苯地平、华法林,准备加服阿司匹林..."},
    {"role": "assistant", "content": "【审核结果】:存在风险...华法林+阿司匹林出血风险..."},
    {"role": "user",      "content": "那换成氯吡格雷呢?"}
  ]
}

这样 DeepSeek 看到了完整的对话历史,能够理解"换成"指的是"把阿司匹林换成氯吡格雷",从而给出准确的回答。

3.4 API 调用(传入完整上下文)

private String callDeepSeekApi(ChatSession session) {
    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", buildMessages(session),  // 完整上下文
            "temperature", 0.3,
            "max_tokens", 2000
    );

    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());
    }
}

四、情感陪伴 Agent:流式接口的会话持久化

情感陪伴 Agent 的集成比用药审核更复杂,因为它有流式接口——AI 回复是逐字推送的,完整内容要等流结束后才能拿到。

4.1 会话解析抽取

两个接口(流式/非流式)共享同一套会话解析逻辑,抽取为 resolveSession 方法:

private ChatSession resolveSession(CompanionChatRequest request) {
    ChatSession session;
    if (request.getSessionId() != null && !request.getSessionId().isBlank()) {
        session = chatSessionService.getSession(request.getSessionId());
    } else {
        session = chatSessionService.createSession("companion", request.getMode());
    }
    request.setSessionId(session.getSessionId());
    return session;
}

private List<Map<String, String>> buildMessages(ChatSession session, String mode) {
    List<Map<String, String>> messages = new ArrayList<>();
    messages.add(Map.of("role", "system", "content", getSystemPrompt(mode)));
    for (ChatMessage msg : session.getMessages()) {
        messages.add(Map.of("role", msg.getRole(), "content", msg.getContent()));
    }
    return messages;
}

4.2 非流式接口的会话集成

与用药审核 Agent 逻辑类似,比较直观:

@Override
public String chat(CompanionChatRequest request) {
    ChatSession session = resolveSession(request);
    session.addMessage(new ChatMessage("user", request.getMessage()));

    // ... 构建请求并调用 DeepSeek API ...

    String reply = root.path("choices").get(0).path("message").path("content").asText();

    // 追加 AI 回复并持久化
    session.addMessage(new ChatMessage("assistant", reply));
    chatSessionService.saveSession(session);

    return reply;
}

4.3 流式接口的会话持久化(核心难点)

流式接口的挑战在于:AI 回复是一个字一个字推送的,完整内容只有在流结束后才知道。所以需要一边推送给前端,一边用 StringBuilder 收集完整回复。

@Override
public SseEmitter chatStream(CompanionChatRequest request) {
    SseEmitter emitter = new SseEmitter(120_000L);

    // 在流开始前就解析会话并追加用户消息
    ChatSession session = resolveSession(request);
    session.addMessage(new ChatMessage("user", request.getMessage()));

    executor.execute(() -> {
        try {
            // streamFromDeepSeek 返回收集到的完整回复
            String fullReply = streamFromDeepSeek(session, request.getMode(), emitter);

            // 流结束后,追加完整回复并保存
            session.addMessage(new ChatMessage("assistant", fullReply));
            chatSessionService.saveSession(session);
        } 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;
}

streamFromDeepSeek 方法的关键改造——用 StringBuilder 收集完整回复:

private String streamFromDeepSeek(ChatSession session, String mode,
        SseEmitter emitter) throws Exception {
    String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions";

    Map<String, Object> body = Map.of(
            "model", deepSeekConfig.getModel(),
            "messages", buildMessages(session, mode),  // 携带完整上下文
            "temperature", 0.8,
            "max_tokens", 1500,
            "stream", true
    );

    String jsonBody = objectMapper.writeValueAsString(body);

    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();

    HttpResponse<java.io.InputStream> response = httpClient.send(
            httpRequest, HttpResponse.BodyHandlers.ofInputStream());

    StringBuilder fullReply = new StringBuilder();  // 收集完整回复

    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();
            if ("[DONE]".equals(data)) {
                emitter.send(SseEmitter.event().name("done").data("[DONE]"));
                break;
            }

            try {
                JsonNode node = objectMapper.readTree(data);
                JsonNode delta = node.path("choices").get(0).path("delta");
                String content = delta.path("content").asText("");
                if (!content.isEmpty()) {
                    fullReply.append(content);    // 收集每个片段
                    emitter.send(SseEmitter.event()
                            .name("message")
                            .data(content));       // 同时推送给前端
                }
            } catch (Exception e) {
                log.debug("跳过无法解析的 SSE 数据: {}", data);
            }
        }
    }

    emitter.complete();
    return fullReply.toString();  // 返回完整回复用于持久化
}

流式持久化的时序

  1. 流开始前:追加 user 消息到 session
  2. 流进行中:逐字推送给前端 + StringBuilder 收集
  3. 流结束后:追加 assistant 完整回复到 session → 保存文件

如果流中途异常断开,catch 块会捕获异常,但不保存不完整的回复,避免脏数据。

五、Controller 层:会话管理 API

两个 Agent 的 Controller 都新增了三个会话管理端点,模式一致,以情感陪伴 Controller 为例:

@RestController
@RequestMapping("/api/companion")
@RequiredArgsConstructor
@Tag(name = "情感陪伴 Agent", description = "基于 AI 的老年人情感陪伴与陪诊助手,支持流式响应")
public class EmotionalCompanionController {

    private final EmotionalCompanionService emotionalCompanionService;
    private final ChatSessionService chatSessionService;

    @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    @Operation(summary = "流式对话")
    public SseEmitter chatStream(@RequestBody @Valid CompanionChatRequest request) {
        return emotionalCompanionService.chatStream(request);
    }

    // 改造:返回 ChatResponse(含 sessionId)
    @PostMapping("/chat")
    @Operation(summary = "普通对话")
    public Result<ChatResponse> chat(@RequestBody @Valid CompanionChatRequest request) {
        String reply = emotionalCompanionService.chat(request);
        return Result.success(new ChatResponse(request.getSessionId(), reply));
    }

    // ===== 以下为新增的会话管理接口 =====

    @GetMapping("/sessions")
    @Operation(summary = "获取会话列表", description = "返回所有历史会话(不含消息详情)")
    public Result<List<ChatSession>> listSessions() {
        return Result.success(chatSessionService.listSessions("companion"));
    }

    @GetMapping("/sessions/{sessionId}")
    @Operation(summary = "获取会话详情", description = "返回指定会话的完整聊天记录")
    public Result<ChatSession> getSession(@PathVariable String sessionId) {
        return Result.success(chatSessionService.getSession(sessionId));
    }

    @DeleteMapping("/sessions/{sessionId}")
    @Operation(summary = "删除会话")
    public Result<Void> deleteSession(@PathVariable String sessionId) {
        chatSessionService.deleteSession(sessionId);
        return Result.success();
    }
}

用药审核 Controller 的结构完全一致,仅 agentType 参数不同(传 "medication-safety")。

六、完整 API 一览

对话接口

方法 路径 说明
POST /api/medication-safety/check 用药审核(多轮)
POST /api/companion/chat 情感陪伴 - 普通对话
POST /api/companion/chat/stream 情感陪伴 - 流式对话

会话管理接口

方法 路径 说明
GET /api/{agent}/sessions 获取会话列表(不含消息)
GET /api/{agent}/sessions/{sessionId} 获取完整聊天记录
DELETE /api/{agent}/sessions/{sessionId} 删除会话

前端交互时序

1. 用户打开页面
   → GET /api/companion/sessions
   ← 返回会话列表 [{sessionId, title, updatedAt}, ...]

2. 用户点击某个历史会话
   → GET /api/companion/sessions/abc123
   ← 返回完整聊天记录 {messages: [{role, content, timestamp}, ...]}

3. 用户在该会话中发送新消息
   → POST /api/companion/chat  {sessionId: "abc123", message: "...", mode: "companion"}
   ← {sessionId: "abc123", reply: "..."}

4. 用户开启新对话(不传 sessionId)
   → POST /api/companion/chat  {message: "...", mode: "companion"}
   ← {sessionId: "def456", reply: "..."}   // 返回新的 sessionId

七、测试验证

多轮对话测试

# 第一轮:不传 sessionId,创建新会话
curl -X POST http://localhost:8080/elderlycare/api/companion/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "我今天觉得胸口有点闷", "mode": "companion"}'
# 返回: {"code":200, "data":{"sessionId":"a1b2c3...", "reply":"您好,听到您说..."}}

# 第二轮:传入 sessionId,接续上下文
curl -X POST http://localhost:8080/elderlycare/api/companion/chat \
  -H "Content-Type: application/json" \
  -d '{"sessionId": "a1b2c3...", "message": "已经闷了两天了", "mode": "companion"}'
# AI 能理解"闷了两天"是指胸口闷,因为有上下文

会话管理测试

# 列出所有会话
curl http://localhost:8080/elderlycare/api/companion/sessions

# 查看某个会话的完整聊天记录
curl http://localhost:8080/elderlycare/api/companion/sessions/a1b2c3...

# 删除会话
curl -X DELETE http://localhost:8080/elderlycare/api/companion/sessions/a1b2c3...

八、文件变更总结

新增:
  entity/ChatMessage.java            # 消息模型
  entity/ChatSession.java            # 会话模型
  dto/ChatResponse.java              # 响应 DTO(sessionId + reply)
  service/ChatSessionService.java    # 会话服务接口
  service/impl/ChatSessionServiceImpl.java  # 文件存储实现

修改:
  dto/MedicationCheckRequest.java    # +sessionId 字段
  dto/CompanionChatRequest.java      # +sessionId 字段
  service/impl/MedicationSafetyServiceImpl.java    # 集成会话上下文
  service/impl/EmotionalCompanionServiceImpl.java  # 集成会话上下文 + 流式收集
  controller/MedicationSafetyController.java       # +会话管理 API
  controller/EmotionalCompanionController.java     # +会话管理 API
  application.yml                    # +chat.session.storage-dir
  .gitignore                         # +chat-sessions/

九、总结与思考

两个 Agent 集成方式的对比

维度 用药审核 Agent 情感陪伴 Agent
接口类型 仅非流式 流式 + 非流式
会话保存时机 API 返回后同步保存 非流式:同上;流式:流结束后异步保存
回复收集方式 直接取 message.content 非流式:同上;流式:StringBuilder 拼接
mode 字段 无(固定人设) 有(companion/diagnosis 切换)

关键设计决策

  1. sessionId 通过 request 对象回传:避免修改 Service 接口签名,保持向后兼容。虽然"修改入参"不是最纯粹的做法,但在 Spring MVC 场景下非常实用。

  2. 流式接口不保存中间状态:只有流正常结束后才保存完整回复。如果中途断开,本轮对话不会写入文件,避免脏数据污染后续的多轮上下文。

  3. 两个 Agent 共享 ChatSessionService:存储层是通用的,通过 agentType 区分。如果后续新增更多 Agent,只需要在 findSessionFile 中添加对应的子目录名即可。

Logo

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

更多推荐