基于 DeepSeek 大模型的多 Agent 多轮对话技术实现

一、需求背景

在智慧养老场景中,我们需要为老年人提供全方位的 AI 健康服务。不同于简单的单轮问答,养老场景下的 AI 助手需要:

  • 记住上下文:老人描述症状可能分多次表达,AI 必须串联前后信息
  • 个性化回应:基于老人的病历、用药、体征数据给出针对性建议
  • 多角色服务:同一套技术底座支撑 5 个功能定位不同的 Agent

本文以「智能居家养老健康守护系统」的后端实现为案例,详细介绍如何基于 DeepSeek 大模型构建支持多轮对话、流式输出、会话持久化的多 Agent 系统。

二、系统中的 5 个 AI Agent

Agent 服务对象 核心能力
情感陪伴(康心) 老人 心理慰藉、陪诊助手
健康陪诊(康伴) 老人 全科健康问答、导诊
健康干预(养怡) 老人 饮食/运动/作息个性化方案
用药安全审核 老人 药物配伍禁忌筛查
家属辅诊(家护) 家属 跨角色健康分析与照护建议

每个 Agent 拥有独立的 System Prompt 和对话上下文,但共享同一套会话管理和大模型调用基础设施。

三、多轮对话的核心挑战

3.1 上下文窗口管理

大模型的 API 是无状态的——每次请求都需要把完整的对话历史发送给模型。这带来了几个问题:

  1. 消息拼接顺序:必须严格按 system → user → assistant → user → assistant… 的顺序组织
  2. 上下文长度限制:DeepSeek 的 context window 有限,过长的对话需要做裁剪
  3. 个性化注入时机:老人的健康档案信息应在 system prompt 中注入,且不能让 AI 主动暴露"我已知道你的信息"

我们的消息构建策略:

private List<Map<String, String>> buildMessages(ChatSession session, ElderlyContext context) {
    List<Map<String, String>> messages = new ArrayList<>();
    // 1. System Prompt + 个性化健康档案
    messages.add(Map.of("role", "system", "content", buildSystemPrompt(context)));
    // 2. 历史对话按时间顺序追加
    for (ChatMessage msg : session.getMessages()) {
        messages.add(Map.of("role", msg.getRole(), "content", msg.getContent()));
    }
    return messages;
}

3.2 会话持久化

每个 Agent 的对话需要跨请求保持状态。用户第一次发消息时创建会话,后续通过 sessionId 关联。

方案演进:我们从最初的 JSON 文件存储迁移到了 PostgreSQL 关系型数据库。

文件存储的问题:

  • 按目录查找会话,目录名硬编码导致新增 Agent 时遗漏
  • 无法按用户维度筛选,不支持分页
  • 并发写入时存在竞态风险

数据库方案的优势:

  • 通过 session_id 唯一索引精确定位,不依赖目录结构
  • 支持按 elderly_idagent_type 灵活查询
  • ON DELETE CASCADE 保证删除一致性
  • 天然支持事务和并发

表结构设计:

CREATE TABLE chat_session (
    id BIGSERIAL PRIMARY KEY,
    session_id VARCHAR(64) NOT NULL UNIQUE,
    agent_type VARCHAR(32) NOT NULL,       -- 区分不同Agent
    mode VARCHAR(32),                       -- 子模式(如饮食/运动)
    title VARCHAR(200),                     -- 首条消息摘要
    elderly_id BIGINT,                      -- 关联老人
    family_id BIGINT,                       -- 关联家属
    created_at TIMESTAMP DEFAULT now(),
    updated_at TIMESTAMP DEFAULT now()
);

CREATE TABLE chat_message (
    id BIGSERIAL PRIMARY KEY,
    session_id VARCHAR(64) NOT NULL,
    role VARCHAR(16) NOT NULL,              -- user/assistant
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT now(),
    CONSTRAINT fk_msg_session FOREIGN KEY (session_id)
        REFERENCES chat_session(session_id) ON DELETE CASCADE
);

3.3 增量保存策略

对话是增长型数据——每次交互新增 2 条消息(user + assistant)。为避免全量覆写,采用增量插入:

public ChatSession saveSession(ChatSession session) {
    // 查询已持久化的消息数量
    long existingCount = chatMessageMapper.selectCount(
        new LambdaQueryWrapper<ChatMessage>()
            .eq(ChatMessage::getSessionId, session.getSessionId())
    );
    // 仅插入新增部分
    List<ChatMessage> allMessages = session.getMessages();
    if (allMessages.size() > existingCount) {
        List<ChatMessage> newMessages = allMessages.subList((int) existingCount, allMessages.size());
        for (ChatMessage msg : newMessages) {
            msg.setSessionId(session.getSessionId());
            chatMessageMapper.insert(msg);
        }
    }
    return session;
}

四、SSE 流式输出

养老场景对体验要求高——老人等待完整回复可能产生焦虑。采用 Server-Sent Events(SSE)实现逐字输出:

@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(@RequestBody @Valid HealthChatRequest request) {
    return healthCompanionService.chatStream(request);
}

Service 层通过线程池异步执行,主线程立即返回 SseEmitter

public SseEmitter chatStream(HealthChatRequest request) {
    SseEmitter emitter = new SseEmitter(120_000L);

    executor.execute(() -> {
        try {
            String fullReply = streamFromDeepSeek(session, context, emitter);
            session.addMessage(new ChatMessage("assistant", fullReply));
            chatSessionService.saveSession(session);
        } catch (Exception e) {
            emitter.send(SseEmitter.event().name("error").data("服务暂时不可用"));
            emitter.completeWithError(e);
        }
    });

    return emitter;
}

流式读取 DeepSeek 返回的 SSE 数据,逐块转发给前端:

while ((line = reader.readLine()) != null) {
    if (!line.startsWith("data: ")) continue;
    String data = line.substring(6).trim();
    if ("[DONE]".equals(data)) {
        emitter.send(SseEmitter.event().name("done").data("[DONE]"));
        break;
    }
    JsonNode delta = objectMapper.readTree(data)
        .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));
    }
}

五、System Prompt 设计要点

好的 System Prompt 直接决定了 Agent 的回答质量。总结几个关键设计原则:

  1. 明确身份和边界:给 Agent 一个名字和明确的职责范围,如"你是临床药师,不做诊断"
  2. 安全红线:遇到急症关键词必须建议拨打 120,不确定时坦诚表达
  3. 结构化输出:通过格式模板引导输出(如"【审核结果】【风险等级】【详细分析】")
  4. 个性化注入:将老人健康数据追加在 system prompt 末尾,并明确告知"不要主动提及已知信息"
  5. 温度参数区分:问答类 Agent 用 0.6,审核类用 0.3(需要更确定性的回答)

六、个性化健康上下文

每个 Agent 在对话时都会加载老人的完整健康档案,包括:

  • 基本信息(姓名、年龄、性别)
  • 疾病列表
  • 当前用药计划(药名、剂量、用法)
  • 近期健康数据(血压、血糖、心率趋势)
  • 病历摘要

这些数据由 ElderlyContextService 统一聚合,各 Agent 按需注入到 system prompt 中。

七、遇到的问题与解决方案

问题 原因 解决方案
部分 Agent 的会话删除失效 文件存储的查找函数只搜索了 2 个目录 迁移到数据库,通过 session_id 唯一索引精确定位
流式对话中断后消息丢失 异常时未保存已接收的部分回复 在 catch 中保存已累积的 fullReply
首次对话无 sessionId 前端不知道该传什么 后端约定"不传则新建",返回时携带 sessionId
对话过长导致 token 超限 历史消息无限追加 限制 max_tokens + 未来可做消息裁剪

八、总结与展望

通过统一的会话管理层 + 差异化的 System Prompt + 共享的大模型调用基础设施,我们用较少的代码量实现了 5 个功能各异的 AI Agent。核心收获:

  1. 分层抽象:会话管理、模型调用、上下文聚合各自独立,新增 Agent 只需编写 Prompt
  2. 增量持久化:避免全量写入,数据库方案天然支持并发和查询
  3. 流式体验:SSE 给老年用户带来明显的体验提升

后续可优化方向:

  • 引入消息裁剪策略,保证超长对话不超 token 限制
  • 添加 Redis 缓存热点会话,减少数据库读取
  • 接入向量数据库实现 RAG,让 Agent 能检索医学知识库
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐