一、业务目标
杏林堂中医药服务平台集成了四项 AI 能力:
| 能力 | 输入 | 输出与展示 |
|---|---|---|
| 药性图谱分析 | 单味药材资料 | 性味、归经、功效及关系图 |
| 智能推荐 | 症状、体质等信息 | 平台在售药材范围内的辅助建议 |
| 药方检测 | 药材、剂量、特殊人群 | 风险等级、依据、调整建议 |
| 智能问答 | 自由问题 | 科普型自然语言回答 |
工程上的难点不是“调用一次 API”,而是让模型输出能够被程序稳定解析、阻止模型引用平台不存在的药材、保存调用记录,并在模型或网络异常时给出可控反馈。
医疗健康相关系统还必须明确边界:AI 输出只能作为科普与辅助参考,不能替代医师诊断和处方;出现急重症、过敏或不良反应时,应引导用户及时就医。
二、为什么选择 Java 17 HttpClient
项目没有引入大模型 SDK,而是直接使用 JDK 自带的java.net.http.HttpClient。优点是依赖少、请求结构透明、便于根据 API 文档调整参数。
客户端初始化如下:
privatestaticfinalStringAI_URL="https://api.deepseek.com/chat/completions";privatefinalObjectMapperobjectMapper=newObjectMapper();privatefinalHttpClienthttpClient=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();API Key 不应写死在源码中。项目将ai_enabled和deepseek_api_key保存在系统配置表,由超级管理员在后台维护:
publicvoidensureAiEnabled(){if(!"1".equals(getConfigValue("ai_enabled"))){thrownewBusinessException("AI功能已关闭");}}publicStringrequireApiKey(){StringapiKey=getConfigValue("deepseek_api_key");if(!StringUtils.hasText(apiKey)){thrownewBusinessException("DeepSeek API Key 未配置");}returnapiKey;}正式环境中还应对 Key 加密存储或通过密钥管理服务注入,并确保接口、日志和异常信息都不会输出完整 Key。
三、构造 JSON Mode 请求
AI 结果需要直接映射为 Java 对象,因此请求中启用 JSON 输出模式:
ObjectNoderoot=objectMapper.createObjectNode();root.put("model","deepseek-chat");root.put("stream",false);ObjectNoderesponseFormat=objectMapper.createObjectNode();responseFormat.put("type","json_object");root.set("response_format",responseFormat);ArrayNodemessages=objectMapper.createArrayNode();ObjectNodesystemMsg=objectMapper.createObjectNode();systemMsg.put("role","system");systemMsg.put("content",systemPrompt);ObjectNodeuserMsg=objectMapper.createObjectNode();userMsg.put("role","user");userMsg.put("content",userPrompt);messages.add(systemMsg);messages.add(userMsg);root.set("messages",messages);然后发送 HTTP 请求:
HttpRequestrequest=HttpRequest.newBuilder().uri(URI.create(AI_URL)).timeout(Duration.ofSeconds(90)).header("Authorization","Bearer "+apiKey).header("Content-Type","application/json").POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(root))).build();HttpResponse<String>response=httpClient.send(request,HttpResponse.BodyHandlers.ofString());除了设置response_format,提示词中仍需明确:“只输出一个合法 JSON 对象,不要输出解释文字,不要使用 Markdown 代码块”,并给出字段名称、类型和示例。JSON Mode 可以提高稳定性,但不能代替服务端校验。
四、解析失败自动重试与 JSON 清洗
模型偶尔仍可能返回 Markdown 围栏,或第一次输出字段不完整。项目封装统一入口,首次调用或解析失败后自动重试一次:
publicAiJsonResultcallJson(StringapiKey,StringsystemPrompt,StringuserPrompt){try{returndoCall(apiKey,systemPrompt,userPrompt);}catch(Exceptionfirst){try{returndoCall(apiKey,systemPrompt,userPrompt);}catch(Exceptionsecond){thrownewBusinessException("AI服务暂时不可用,请稍后重试");}}}解析前对常见围栏进行清理:
privateStringcleanJson(Stringraw){if(raw==null){return"{}";}Stringtext=raw.trim();if(text.startsWith("```")){text=text.replaceFirst("^```json\\s*","").replaceFirst("^```\\s*","").replaceFirst("\\s*```$","");}returntext.trim();}重试次数不能无限增加。模型输出结构错误时,盲目重试会放大费用和接口延迟。更合理的做法是有限重试、记录错误摘要,并向前端返回可理解的降级提示。
五、智能推荐中的“药材白名单”
大模型可能生成平台数据库中不存在或已经下架的药材。若前端直接根据模型返回的medicineId跳转,会出现空页面,甚至把不受平台管理的内容包装成可购买商品。
项目采用“两层约束”:
第一层:将数据库药材目录注入 Prompt
publicStringbuildCatalogText(){List<MedicineInfo>list=medicineInfoMapper.selectList(newLambdaQueryWrapper<MedicineInfo>().eq(MedicineInfo::getStatus,1));returnlist.stream().map(m->String.join("|",String.valueOf(m.getId()),nullToEmpty(m.getMedicineName()),nullToEmpty(m.getNature()),nullToEmpty(m.getMeridian()),nullToEmpty(m.getEffect()))).collect(Collectors.joining("\n"));}紧凑的id|名称|性味|归经|功效格式比完整 JSON 更节省 Token。提示词要求模型只能从目录中选择medicineId。
第二层:后端二次过滤
publicSet<Long>validMedicineIds(){returnmedicineInfoMapper.selectList(newLambdaQueryWrapper<MedicineInfo>().eq(MedicineInfo::getStatus,1)).stream().map(MedicineInfo::getId).collect(Collectors.toSet());}解析模型结果后,将返回 ID 与合法集合求交集,不在白名单中的 ID 一律剔除。这里体现了一条重要原则:Prompt 约束属于“软约束”,服务端校验才是“硬约束”。
当在售药材很多时,不宜把整个目录都塞进 Prompt。可以先使用关键词、向量检索或数据库规则召回候选药材,再让模型只在候选集内分析。
六、四项能力如何分别落地
1. 药性图谱分析
后端把药材名称、性味、归经、功效、禁忌等字段传给模型,要求返回节点与关系。结果写入ai_medicine_analysis,一味药材对应一条缓存记录。前端使用 EChartsgraph力导向图,将药材放在中心,把性味、归经、功效和配伍关系作为周边节点。
缓存可以减少相同药材的重复调用。管理员更新药材资料后,可通过“强制重新生成”刷新分析结果。
2. 智能推荐
用户输入症状和体质,系统拼接在售药材目录,请模型返回建议药材、推荐理由和注意事项。返回结果先经过 ID 白名单过滤,再写入ai_recommend。
这里应避免使用“自动开方”“确诊”等表述。更稳妥的产品定位是健康知识推荐,并持续显示“请咨询专业医师”的提示。
3. 药方检测
用户添加药材和剂量,并选择是否为儿童或妊娠人群。模型检查十八反、十九畏、剂量异常和特殊人群风险,返回safe、warn或danger,同时给出风险条目、依据和调整建议,记录写入ai_prescription_check。
高风险规则不能只依赖大模型。正式医疗系统应把明确、稳定的禁忌和剂量规则沉淀为可审计的规则库,模型负责解释和补充,最终仍由药师或医师审核。
4. 智能问答
智能问答允许匿名访问,前端左侧展示分组常见问题,右侧进行对话。问答记录写入ai_chat,可统计热门关键词和调用成本。匿名访问也应设置频率限制、敏感内容过滤和单次输入长度限制,避免接口被滥用。
七、结果落库的价值
AI 接口返回后立即丢弃结果,看似简单,实际上不利于维护。将结果落库可以获得:
- 相同药材分析直接命中缓存,减少费用和等待时间;
- 追踪模型、Token 数与生成时间;
- 后台审计风险内容;
- 统计用户关注的症状与药材;
- Prompt 或模型升级后对比新旧结果;
- 出现争议时保留必要的调用记录。
数据库中不应保存不必要的敏感健康信息。需要明确数据保留周期、访问权限、脱敏方式和删除机制。
八、前端悬浮 AI 面板设计
四项能力统一放入右下角悬浮入口,并复用固定尺寸的面板壳。业务页面通过 Pinia 保存待打开的面板类型和预填参数。例如用户在药材详情页点击“加入药方检测”,系统先把药材写入暂存篮,再打开检测面板。
这种pending-key模式避免了页面组件直接操作远处的弹窗实例:
药材详情页 -> 写入 Pinia 待办状态 -> 全局 Dock 监听 -> 打开指定面板并预填所有面板共享标题栏、遮罩、关闭行为和尺寸规范,业务组件只负责输入表单与结果展示,能够显著减少重复代码。
九、生产化还需要补充什么
课程设计中的实现已经形成完整链路,但若进入真实生产环境,还应补充:
- 对 API Key 加密存储并定期轮换;
- 增加限流、超时、熔断、监控和调用费用告警;
- 使用 JSON Schema 或 DTO 校验每个返回字段;
- 对危险医学内容设置规则库与人工审核;
- 对 Prompt 版本化,保存模型名和提示词版本;
- 对个人健康信息进行最小化收集、脱敏和权限隔离;
- 对药材目录使用检索召回,避免 Prompt 随数据量无限增长;
- 设计清晰的免责声明、急症提示和转人工入口。
十、总结
大模型接入业务系统不能停留在“请求 API,然后把文本显示出来”。一个可维护的 AI 功能至少包含配置开关、密钥管理、结构化输出、有限重试、服务端校验、白名单约束、结果缓存、审计记录、前端状态编排和安全提示。尤其在中医药与健康场景中,应让规则和专业审核掌握最终决定权,把模型定位为解释、整理和辅助工具。