山东大学软件学院项目实训-基于语言大模型的智能居家养老健康守护系统-个人博客(三)
一、功能开发配合
会话存储的基础设施——ChatMessage、ChatSession 数据模型以及基于 JSON 文件的 ChatSessionService由队友开发实现
本篇聚焦于如何将这套存储层集成到两个 Agent 的业务逻辑中,实现:
- 两个 Agent 支持多轮对话上下文(历史消息随请求一起发给 DeepSeek)
- 流式接口的会话持久化(边流式推送边收集完整回复,结束后保存)
- 会话管理 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(); // 返回完整回复用于持久化
}
流式持久化的时序:
- 流开始前:追加
user消息到 session- 流进行中:逐字推送给前端 +
StringBuilder收集- 流结束后:追加
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 切换) |
关键设计决策
-
sessionId 通过 request 对象回传:避免修改 Service 接口签名,保持向后兼容。虽然"修改入参"不是最纯粹的做法,但在 Spring MVC 场景下非常实用。
-
流式接口不保存中间状态:只有流正常结束后才保存完整回复。如果中途断开,本轮对话不会写入文件,避免脏数据污染后续的多轮上下文。
-
两个 Agent 共享 ChatSessionService:存储层是通用的,通过
agentType区分。如果后续新增更多 Agent,只需要在findSessionFile中添加对应的子目录名即可。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)