一、需求分析与功能定位

1.1 家属辅诊 Agent——跨角色的远程照护助手

在居家养老场景中,家属往往面临"关心但看不懂"的困境:老人的体检报告指标密集,病历术语晦涩,想了解老人近况但缺乏医学背景知识。家属辅诊 Agent 正是为解决这一痛点而设计:

  • 综合分析已绑定老人的健康档案,生成通俗易懂的健康状况总结
  • 解读体征指标的变化趋势,识别需要关注的异常信号
  • 提供家属可直接执行的照护建议(饮食搭配、复诊提醒、用药监督等)
  • 帮助家属理解老人的疾病进展和治疗方案含义

与老人端 Agent 不同,家属辅诊有一个核心约束:必须通过绑定关系校验才能访问老人数据,这是系统安全设计的基石。

1.2 上下文聚合层——Agent 矩阵的数据基座

整个系统包含五个 Agent(健康陪诊、健康干预、用药安全、情感陪伴、家属辅诊),它们都需要感知老人的个人健康画像才能实现个性化推理。上下文聚合层的职责是:

  • 从 elderly、disease、medication_plan、medical_record、elderly_health_data 五张业务表中汇聚数据
  • 将异构的结构化数据统一转换为 LLM 可理解的自然语言格式
  • 为不同 Agent 提供不同粒度的上下文(全量/基础)

二、家属辅诊 Agent 的 Prompt 工程

2.1 角色定义与安全边界

你是一位专业的家属端 AI 辅诊助手,名叫"家护"。
你服务于智能居家养老系统的家属端,帮助家属了解已绑定老人的整体健康状况,
并基于老人的病历数据和时序健康数据提供跨角色的营养与照护建议。

你的职责:
1. 综合分析老人的健康档案,为家属提供清晰易懂的健康状况总结
2. 解读老人的体检指标变化趋势,识别需要关注的异常信号
3. 提供家属可执行的照护建议(饮食搭配、用药监督、复诊提醒等)
4. 帮助家属理解老人的疾病进展和治疗方案
5. 提醒家属关注老人可能被忽视的健康风险
6. 指导家属如何有效与老人沟通健康相关事宜

重要原则:
- 只回答与已绑定老人健康相关的问题,拒绝无关查询
- 信息来源严格限定于系统内的病历和健康数据
- 表述准确但通俗,避免让家属过度焦虑
- 明确区分"数据显示"和"建议就医确认"
- 涉及严重异常时明确建议尽快就医
- 尊重老人隐私,仅在授权绑定范围内提供信息

设计要点

  1. 受众转换:与老人端 Agent 不同,家属辅诊的对话对象是中青年家属。语言风格从"亲切温和"调整为"专业简洁",信息密度更高。

  2. 行动导向:家属最需要的不是"知道什么"而是"应该做什么"。Prompt 明确要求侧重"可执行的照护建议",而非长篇的医学解释。

  3. 焦虑管控:家属得知异常指标时容易过度紧张。Prompt 中"表述准确但通俗,避免让家属过度焦虑"+"明确区分数据显示和建议就医确认"的指令对此做了预防。

  4. Temperature 设置为 0.4:家属辅诊需要高准确性(涉及健康数据解读),但比药理审核(0.3)稍高,因为照护建议允许一定灵活性。

2.2 与老人端 Agent Prompt 的差异对比

维度 老人端 Agent(陪诊/干预) 家属端 Agent(辅诊)
语言风格 简单易懂、温和亲切 专业简洁、信息密集
核心输出 健康建议、情绪安抚 数据解读、行动指令
安全约束 不诊断、急症升级 不诊断、区分确定/不确定
数据引用 隐式参考 显式引用指标数值
权限要求 当前登录用户 绑定关系双重校验

三、绑定关系权限校验的实现

3.1 安全模型设计

家属辅诊是系统中唯一涉及跨用户数据访问的模块。安全模型的设计原则:

绝对不允许未经绑定的家属查看任何老人的健康数据
即使通过 API 直接调用也必须被拦截

完整的权限校验链路:

家属请求(携带 familyId + elderlyId)
    → Service 层绑定关系校验
    → 查询 elderly_family 表(familyId + elderlyId + bindStatus=1)
    → 通过 → 加载老人 ElderlyContext → LLM 推理
    → 不通过 → 抛出 403 BusinessException

3.2 代码实现

private void verifyBinding(Long familyId, Long elderlyId) {
    LambdaQueryWrapper<ElderlyFamily> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(ElderlyFamily::getFamilyId, familyId)
            .eq(ElderlyFamily::getElderlyId, elderlyId)
            .eq(ElderlyFamily::getBindStatus, 1);
    long count = elderlyFamilyService.count(wrapper);
    if (count == 0) {
        throw new BusinessException(403, "无权访问该老人的健康数据,请先完成绑定");
    }
}

关键决策——校验放在 Service 层而非 Controller 层

这样做的原因是防止通过其他内部调用路径绕过校验。Service 层是业务逻辑的唯一入口,无论 Controller、定时任务还是其他 Service 调用,都必须经过权限检查。

3.3 SSE 流式响应中的权限前置

流式接口的权限校验必须在建立 SSE 连接之前完成:

@Override
public SseEmitter chatStream(FamilyAssistChatRequest request) {
    verifyBinding(request.getFamilyId(), request.getElderlyId());  // 先校验

    SseEmitter emitter = new SseEmitter(120_000L);
    // ... 后续正常流式逻辑
}

如果校验失败,异常在 Controller 线程抛出,Spring 的全局异常处理器可以正常捕获并返回 JSON 错误响应。若把校验放在异步线程里,异常将无法被全局处理器拦截。

四、多源数据上下文聚合层实现

4.1 ElderlyContext 数据模型

所有 Agent 共享的老人画像数据结构:

@Data
public class ElderlyContext {
    private Long elderlyId;
    private String name;
    private String gender;
    private Integer age;

    private List<String> diseases;                    // 疾病史
    private List<MedicationInfo> currentMedications;  // 当前用药
    private List<String> recentMedicalRecords;        // 近期就诊
    private HealthData latestHealthData;              // 最新体征

    @Data
    public static class MedicationInfo {
        private String drugName;
        private String dosage;
        private String usage;
        private String diseaseName;
    }

    @Data
    public static class HealthData {
        private String height;
        private String weight;
        private String bloodPressure;
        private String heartRate;
        private String bloodSugar;
        private String temperature;
    }
}

4.2 多表聚合的 Service 实现

ElderlyContextService 从五张表中拉取数据并组装:

public interface ElderlyContextService {
    ElderlyContext getFullContext(Long elderlyId);    // 全量(5个Agent均使用)
    ElderlyContext getBasicContext(Long elderlyId);   // 精简版(性能优化场景)
}

聚合路径:

elderly 表         → name, gender, age, address
disease 表         → diseases[]
medication_plan 表 → currentMedications[](含药名、剂量、用法、对应疾病)
medical_record 表  → recentMedicalRecords[](最近 N 条)
elderly_health_data 表 → latestHealthData(最新一条)

4.3 结构化文本输出——为什么选择自然语言而非 JSON

ElderlyContext 提供了分层的格式化输出方法:

public String toFullContextString() {
    return toBasicInfoString()       // 【基本信息】
         + toDiseaseInfoString()     // 【疾病史】
         + toMedicationInfoString()  // 【当前用药】
         + toHealthDataString()      // 【最新健康数据】
         + toMedicalRecordString();  // 【近期就诊记录】
}

输出示例:

【基本信息】
- 姓名:李奶奶
- 性别:女
- 年龄:68岁

【疾病史】
- 冠心病
- 骨质疏松

【当前用药】
- 阿司匹林肠溶片,剂量:100mg/日,用法:每日早餐后口服(用于治疗:冠心病)
- 碳酸钙D3片,剂量:1片/日,用法:每日睡前口服(用于治疗:骨质疏松)

【最新健康数据】
- 血压:128/82 mmHg
- 心率:72 次/分

选择自然语言格式的原因

经过测试对比,将相同数据分别以 JSON 和自然语言格式注入 Prompt,LLM 在自然语言格式下:

  • 推理准确率更高(模型训练数据中自然语言描述远多于结构化 JSON)
  • 生成的回复更自然流畅(不会出现"根据 JSON 字段 bloodPressure..."这类别扭表述)
  • 中文标签(如【疾病史】)提供了清晰的语义分隔,等效于 JSON 的 key

4.4 家属辅诊 vs 老人端 Agent 的上下文注入差异

对于老人端 Agent(陪诊、干预),采用隐式注入

sb.append("以下是当前对话老人的背景信息,请在对话中适当参考" +
          "(不要主动提及你已知道这些信息,除非与对话内容相关):\n");

对于家属辅诊 Agent,采用显式注入

sb.append("以下是该老人的完整健康档案,请基于这些数据回答家属的问题:\n");

区别的原因:老人面对 AI 说出自己的病史会感到被"窥探";而家属主动查询就是来看数据的,AI 应该大方引用。

五、多轮会话管理机制

5.1 统一会话服务的设计

所有 Agent 共享一套 ChatSessionService,通过 agentType 字段实现逻辑隔离:

public interface ChatSessionService {
    ChatSession createSession(String agentType, String mode);
    ChatSession getSession(String sessionId);
    ChatSession saveSession(ChatSession session);
    List<ChatSession> listSessions(String agentType);
    void deleteSession(String sessionId);
}

会话模型:

public class ChatSession {
    private String sessionId;              // UUID 主键
    private String agentType;             // "health-companion" / "health-intervention" / "family-assist" 等
    private String mode;                   // 可选子模式
    private List<ChatMessage> messages;    // 完整历史消息
    private LocalDateTime createTime;
}

5.2 messages 数组的构建策略

每次 LLM 调用时,构建完整的 messages 数组:

private List<Map<String, String>> buildMessages(ChatSession session, ElderlyContext context) {
    List<Map<String, String>> messages = new ArrayList<>();
    // System Prompt 始终在第一位
    messages.add(Map.of("role", "system", "content", buildSystemPrompt(context)));
    // 按时序追加完整历史
    for (ChatMessage msg : session.getMessages()) {
        messages.add(Map.of("role", msg.getRole(), "content", msg.getContent()));
    }
    return messages;
}

这符合 OpenAI 兼容接口的标准范式:system → user → assistant → user → assistant → ...

5.3 会话 ID 的生命周期

前端首次请求(sessionId = null)
    → Service: createSession("family-assist", null) → 生成 UUID
    → 返回 response 中携带 sessionId

前端后续请求(sessionId = "xxx-xxx")
    → Service: getSession("xxx-xxx") → 恢复历史
    → 追加新消息 → 调用 LLM → 存储回复 → 返回

这个设计让前端无需维护本地消息列表,所有状态由后端会话管理统一托管。

六、开发中的关键决策与踩坑

6.1 绑定关系查询的性能优化

初版使用 selectList 后判空,改为 count 查询避免不必要的数据加载。elderly_family 表已有 (family_id, elderly_id) 联合索引,count 查询走索引覆盖扫描,耗时稳定在 1ms 以内。

6.2 上下文数据量与 Token 预算的平衡

全量 ElderlyContext 注入后,System Prompt 可能占用 500-800 Token。当历史消息积累到 10 轮以上,总 Token 数可能逼近模型上下文窗口。

当前策略:

  • max_tokens 限制输出长度为 2000
  • 后续规划:引入消息裁剪(保留最近 N 轮)或摘要压缩

6.3 家属辅诊的"关键病理特征覆盖率"验证

项目要求"关键病理特征覆盖率达到 95%"。验证方法:

  • 准备含 5 种疾病 + 3 种用药的测试用户数据
  • 向家属辅诊 Agent 提问"整体健康状况怎么样"
  • 检查回复是否覆盖了所有疾病和主要用药信息
  • 通过在 Prompt 中加入"关键病理特征要完整覆盖,不遗漏重要信息"指令提升覆盖率

6.4 SseEmitter 的连接超时处理

实测发现老年用户(家属)有时打开对话后会走开几分钟再回来看。如果 SSE 连接在此期间超时关闭,前端需要优雅处理。设置了 120 秒超时并注册 onTimeout 回调确保连接干净关闭。

七、AI 辅助开发实录——提示词与人工调试

在开发家属辅诊 Agent 和上下文聚合层的过程中,我同样借助了 AI 编程工具加速开发。但 AI 生成的代码在实际运行中暴露出多个需要人工排查和修复的问题,这里如实记录。

7.1 使用的核心提示词

设计 ElderlyContextService 的数据聚合逻辑时,我给 AI 的提示词:

我有以下数据库表:elderly(基本信息)、disease(疾病记录)、medication_plan(用药计划,含drug_name/dosage/usage/disease_name)、medical_record(就诊记录)、elderly_health_data(体征数据,含blood_pressure/heart_rate/blood_sugar等)。
请为我实现 ElderlyContextServiceImpl,通过 elderlyId 从以上表中聚合全量数据,
组装到 ElderlyContext 对象中。注意:
1. medication_plan 需要关联 disease 表拿到 disease_name
2. medical_record 只取最近 5 条
3. elderly_health_data 只取最新一条
4. 任何子查询为空时字段设为 null 而非抛异常

开发家属辅诊 Agent 的绑定校验逻辑时:

在 FamilyAssistServiceImpl 中实现权限校验方法 verifyBinding(Long familyId, Long elderlyId)。
逻辑:查询 elderly_family 表中 family_id 和 elderly_id 匹配且 bind_status=1 的记录,
不存在则抛出 403 BusinessException。使用 MyBatis-Plus 的 LambdaQueryWrapper。
校验必须在 chatStream 和 chat 方法的第一行调用,确保 SSE 连接建立前就拦截。

家属辅诊 Prompt 本身也经过了 AI 生成 + 人工迭代:

帮我写一段 system prompt,角色是家属端的健康辅诊助手。
对话对象是中青年家属而非老人本身。要求:
- 解读老人的病历数据,给出通俗健康总结
- 侧重"家属能做什么"的行动建议
- 不过度渲染病情引发焦虑
- 必须校验绑定关系才能回答(这点在代码层实现,prompt 里强调只回答已绑定老人的问题)
- 严重异常必须建议就医

AI 初版 Prompt 有两个问题我手动改了:

  1. 原文写了"你可以访问老人的所有医疗数据"——这句话让模型过于自信,即使上下文中没有某项数据也会编造。改为"信息来源严格限定于系统内的病历和健康数据"
  2. 缺少"明确区分数据显示和建议就医确认"的指令,导致模型对偏高的血压值直接说"您父亲血压很危险",引发测试中模拟家属的焦虑。加入该指令后模型改为"数据显示血压 145/92,建议近期复查确认"

7.2 AI 生成代码的 Bug 与人工修复

Bug 1:verifyBinding 放在异步线程内导致 403 无法被全局异常处理器捕获

AI 最初将 verifyBinding 调用放在了 executor.execute(() -> { ... }) 的 lambda 内部:

// AI 生成的有问题的版本
public SseEmitter chatStream(FamilyAssistChatRequest request) {
    SseEmitter emitter = new SseEmitter(120_000L);
    executor.execute(() -> {
        try {
            verifyBinding(request.getFamilyId(), request.getElderlyId());  // ← 在异步线程中
            // ...
        } catch (Exception e) {
            emitter.send(SseEmitter.event().name("error").data(e.getMessage()));
            emitter.completeWithError(e);
        }
    });
    return emitter;
}

问题:BusinessException 在异步线程抛出,Spring 的 @RestControllerAdvice 全局异常处理器无法捕获。前端收到的不是标准的 {code: 403, msg: "..."} JSON,而是一个 SSE error event——前端无法区分"权限不足"和"服务异常"。

修复:将 verifyBinding 提到主线程,在 executor.execute 之前执行。校验失败时异常在 Controller 线程抛出,全局处理器正常返回 403 响应:

@Override
public SseEmitter chatStream(FamilyAssistChatRequest request) {
    verifyBinding(request.getFamilyId(), request.getElderlyId());  // 主线程校验
    SseEmitter emitter = new SseEmitter(120_000L);
    // ... 异步逻辑
}

Bug 2:ElderlyContextServiceImpl 中 medication_plan 关联查询的 N+1 问题

AI 为每条 medication_plan 记录单独查了一次 disease 表获取 disease_name:

// AI 生成的 N+1 查询
List<MedicationPlan> plans = medicationPlanMapper.selectByElderlyId(elderlyId);
for (MedicationPlan plan : plans) {
    Disease disease = diseaseMapper.selectById(plan.getDiseaseId());  // ← 每条用药查一次
    info.setDiseaseName(disease != null ? disease.getName() : null);
}

当老人有 8 种用药时,这里产生了 1 + 8 = 9 次 SQL 查询。虽然数据量小时不明显,但日志中看到明显的查询放大。

修复:先批量查出所有相关 disease,构建 Map<Long, String> 后内存关联:

List<MedicationPlan> plans = medicationPlanMapper.selectByElderlyId(elderlyId);
Set<Long> diseaseIds = plans.stream()
    .map(MedicationPlan::getDiseaseId)
    .filter(Objects::nonNull)
    .collect(Collectors.toSet());
Map<Long, String> diseaseNameMap = diseaseIds.isEmpty() ? Map.of() :
    diseaseMapper.selectBatchIds(diseaseIds).stream()
        .collect(Collectors.toMap(Disease::getId, Disease::getName));

for (MedicationPlan plan : plans) {
    info.setDiseaseName(diseaseNameMap.getOrDefault(plan.getDiseaseId(), null));
}

9 次查询降为 2 次。

Bug 3:toMedicationInfoString() 中 currentMedications 为空列表时输出不一致

AI 生成的判空逻辑只判了 null,没判空列表:

// AI 的版本
public String toMedicationInfoString() {
    if (currentMedications == null) {  // ← 空列表 [] 时走到下面,输出空的【当前用药】标签
        return "\n【当前用药】\n- 无\n";
    }
    StringBuilder sb = new StringBuilder();
    sb.append("\n【当前用药】\n");
    for (MedicationInfo med : currentMedications) { ... }
    return sb.toString();
}

当用户确实无用药记录时,数据库返回空列表(非 null),导致输出一个孤零零的 【当前用药】\n 标签没有内容,LLM 有时会对此产生困惑回复"未查询到用药信息"。

修复:补充 isEmpty() 判断:

if (currentMedications == null || currentMedications.isEmpty()) {
    return "\n【当前用药】\n- 无\n";
}

Bug 4:会话并发写入导致消息顺序错乱

在高并发测试中发现,如果用户快速连续发送两条消息(前一条还在等 LLM 回复),两个线程同时操作同一个 ChatSession 的 messages 列表,导致消息顺序错乱(assistant 回复插到了下一条 user 消息之前)。

AI 完全没有考虑这个并发场景。修复:在 resolveSession 和 saveSession 中对同一 sessionId 加了基于 ConcurrentHashMap 的轻量锁,确保同一会话的消息追加串行化。

Logo

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

更多推荐