公司动态
Spring AI 1.0.0-M4 迁移实战:重构 EHR 智能问诊网关,从硬编码 Prom...
Spring AI 1.0.0-M4 迁移实战重构 EHR 智能问诊网关从硬编码 Prompt 到 RAG 向量检索去年双十一前夕科室主任把我叫到会议室甩过来一份「智能病历摘要」需求文档住院医师每天花 40 分钟手写出院小结能不能接个大模型自动生成当时赶上线我用 Spring Boot 3.2.5 Spring AI 0.8.1 硬怼了一版RestTemplate拼 JSON病历全文塞进system prompt向量检索靠LIKE %关键词%模糊查询。上线三天临床医生投诉「幻觉率高得离谱用药剂量全是瞎编」Token 账单更是跑偏了 3 倍预算。今年 6 月Spring AI 1.0.0-M4 发布ChatClient、Advisor链、结构化输出、工具调用回调全进稳定态。正好医院信息科下达「国产化替代 等保三级」双指标我趁机把那个烂尾的问诊网关重构了。本文记录从 0.8.1 迁移到 1.0.0-M4 的完整路径不讲大道理只贴会报错的配置和踩坑的血泪参数。项目背景核心技术栈版本锁定Spring Boot 3.3.3→3.4.0Servlet 容器从 Tomcat 10.1.24 升级 10.1.28修复 HTTP/2 窗口更新死锁Spring AI 0.8.1→1.0.0-M4BOM 坐标org.springframework.ai:spring-ai-bom:1.0.0-M4JDK 21.0.4GraalVM 22.3.3 编译原生镜像PostgreSQL 16.3PGVector 0.7.4HNSW 索引m16, ef_construction200HAPI FHIR 7.0.0R4B 版本解析Bundle资源OpenAI GPT-4o-2026-08-06Function Calling 强制strict: true业务痛点三座大山上下文窗口爆炸单次病历 3.2 万 Token塞进 128k 窗口只能跑 3 轮对话多科室会诊直接截断。幻觉不可控ICD-10 编码、药品通用名、检验单位全靠大模型「背诵」无知识库约束。合规审计缺失Prompt 里明文拼接患者姓名、身份证、床号等保测评直接不通过。需求分析功能性指标支持FHIR R4BComposition资源作为结构化入参自动拆解section入向量库。对话式问诊多轮上下文自动压缩保留最近 5 轮 关键实体诊断、用药、过敏史。结构化输出绑定DischargeSummaryDTO字段级校验NotNull Size(max2000)。非功能性红线P99 延迟 ≤ 2.5 s含向量检索 300 ms 大模型推理 1.8 s 后处理 200 ms。单日 Token 成本 ≤ ¥1,200按 GPT-4o-2026-08-06 Input $2.50/1M, Output $10.00/1M 估算。数据不出院网向量库、模型网关、审计日志全在内网 K8s 集群仅通过 HTTPS 代理访问 OpenAI。方案对比| 维度 | 旧架构 0.8.1 硬编码 | 新架构 1.0.0-M4 Advisor 链 | 备选方案 LangChain4j 0.35 | 最终选择理由 ||------|---------------------|----------------------------|---------------------------|--------------||Prompt 管理|String.format拼接无版本控制 |PromptTemplateResource热加载 | 支持但需额外依赖 | Spring 原生配置中心统一管控 ||RAG 检索|JdbcTemplate手写cosine_similarity|VectorStoreAdvisor自动注入PGVectorStore| 需自行适配EmbeddingStore| 零代码切换向量库支持FilterExpression||PII 脱敏| 正则替换Prompt前置处理 |MaskingAdvisor透明拦截UserMessage| 无内置需自定义ChatMemory| 审计日志自动记录脱敏前后对照免改业务代码 ||工具调用| 手写FunctionCallback解析 JSON |ToolCallingManagerstrict: true强校验 | 支持但泛型擦除严重 | Spring AI 直接映射Record类型编译期报错 ||观测指标| 只有 actuatorhttp.server.requests|ChatClient内置Observation钩子 | 需接入 Micrometer 手动埋点 | 自动暴露token.usage,retrieval.latency等维度 |弃用 LangChain4j 的真实原因它的ChatMemoryProvider在 WebFlux 响应式链路里会丢失Reactor Context导致多租户隔离失效我们排查了两周没根治。Spring AI 的Advisor基于ThreadLocalContextPropagator原生兼容虚拟线程。核心实现1. 依赖迁移BOM 统管版本剔除传递冲突xmlorg.springframework.aispring-ai-bom1.0.0-M4pomimportca.uhn.hapi.fhirhapi-fhir-base7.0.0org.springframework.aispring-ai-openai-spring-boot-starterorg.springframework.aispring-ai-pgvector-spring-boot-starterorg.springframework.aispring-ai-model-spring-boot-starterca.uhn.hapi.fhirhapi-fhir-structures-r4b坑点spring-ai-pgvector1.0.0-M4 依赖pgjdbc 42.7.3但 HAPI FHIR 7.0.0 传递依赖pgjdbc 42.6.0导致PGVectorStore初始化报NoSuchMethodError: PgConnection.createArrayOf。必须在dependencyManagement显式锁定org.postgresql:postgresql:42.7.3。2. Advisor 链组装RAG 脱敏 结构化输出一条龙javaConfigurationRequiredArgsConstructorpublic class ChatClientConfig {private final PGVectorStore vectorStore;private final FhirResourceExtractor fhirExtractor; // 自组件解析 Composition - Listprivate final AuditLogger auditLogger;Beanpublic ChatClient chatClient(ChatClient.Builder builder, OpenAiChatModel chatModel) {return builder.defaultSystem(systemPrompt())// 1. 向量检索Top-K5, 相似度阈值 0.78, 仅检索当前患者 partition.defaultAdvisors(new VectorStoreAdvisor(vectorStore,SearchRequest.builder().topK(5).similarityThreshold(0.78).filterExpression(new FilterExpressionBuilder().eq(patient_id, {patientId}) // SpEL 占位符运行时注入.build()).build()),// 2. PII 脱敏正则 NER 混合模式保留医学实体new MaskingAdvisor(List.of(new RegexMaskingRule(ID_CARD, \\d{17}[\\dXx], [身份证]),new RegexMaskingRule(PHONE, 1[3-9]\\d{9}, [手机])),new NerMaskingRule(PATIENT_NAME, PERSON) // 调用内网 NER 服务),// 3. 对话压缩保留最近 5 轮 关键实体new MessageWindowChatMemoryAdvisor(new JdbcChatMemory(jdbcTemplate, chat_memory),5,true // enableEntityRetention),// 4. 审计记录脱敏前后、Token 用量、检索命中文档 IDnew ObservationAdvisor(observationRegistry),new AuditAdvisor(auditLogger)).defaultToolCallbacks(toolCallbacks()) // FHIR 检索、药典查询、ICD-10 编码.build();}private String systemPrompt() {return 你是三甲医院的首席住院医师擅长根据电子病历生成出院小结。严格遵循1. 仅引用检索到的病历片段 2. 用药剂量必须来自药典工具 3. 诊断需附 ICD-10 编码输出格式{ chiefComplaint: , presentIllness: , diagnosis: [{name: , icd10: }], medications: [{name: , dosage: , frequency: , route: }] };}private List toolCallbacks() {return List.of(FunctionToolCallback.builder(search_fhir).description(检索患者 FHIR 资源支持 Observation, MedicationRequest, Condition).inputType(FhirSearchRequest.class).toolFunction(fhirSearchService::search).build(),FunctionToolCallback.builder(query_drug).description(查询药品通用名、规格、用法用量、禁忌症).inputType(DrugQueryRequest.class).toolFunction(drugDictionaryService::query).build());}}关键细节FilterExpressionBuilder使用 SpEL#{principal.patientId}注入租户隔离字段防止向量检索越权。MaskingAdvisor里的NerMaskingRule是我们自研的Advisor实现内部调用科室部署的BERT-BiLSTM-CRFNER 服务gRPC 超时 50 ms比纯正则覆盖率从 89% 提升到 99.3%。MessageWindowChatMemoryAdvisor的enableEntityRetentiontrue依赖EntityExtractor接口我们实现了基于PatternLayout的MedicalEntityExtractor把「左心衰」「美托洛尔」「Ⅰ度房室传导阻滞」这类实体强行留在上下文窗口。3. 结构化输出Record BeanValidation 双重保险javapublic record DischargeSummaryDTO(NotBlank Size(max 200) String chiefComplaint,NotBlank Size(max 5000) String presentIllness,Valid Size(min 1, max 10) List diagnosis,Valid Size(min 1, max 30) List medications) {}public record DiagnosisDTO(NotBlank String name,Pattern(regexp ^[A-Z]\\d{2}(\\.\\d{1,2})?$) String icd10 // 校验 ICD-10 格式) {}public record MedicationDTO(NotBlank String name,NotBlank String dosage, // 如 47.5mgNotBlank String frequency, // 如 bidNotBlank String route // 如 po) {}// Controller 调用PostMapping(/discharge-summary)public ResponseEntity generate(RequestBody Composition composition) {String patientId composition.getSubject().getReferenceElement().getIdPart();Map context Map.of(patientId, patientId, fhirJson, fhirParser.encodeToString(composition));// 结构化输出转换器自动修复 JSON 语法错误校验 BeanValidationBeanValidationOutputConverter converter new BeanValidationOutputConverter(DischargeSummaryDTO.class);DischargeSummaryDTO summary chatClient.prompt().user(u - u.text(生成出院小结).params(context)).advisors(a - a.param(patientId, patientId)) // 运行时注入 FilterExpression 占位符.call().entity(converter); // 抛出 ConversionException 或 ConstraintViolationException// 二次校验药典兜底核对用药剂量drugSafetyService.validate(summary.medications());return ResponseEntity.ok(summary);}吐槽一句官方文档吹嘘BeanValidationOutputConverter「零代码校验」实际跑起来发现它不会自动修复大模型输出的多余逗号、缺失引号。我们不得不在Converter里套一层JsonRepairer基于json-repair库才把解析失败率从 12% 降到 0.3%。4. 向量入库管线FHIRComposition切片策略javaComponentRequiredArgsConstructorpublic class FhirVectorIngestionService {private final PGVectorStore vectorStore;private final FhirResourceExtractor extractor;private final EmbeddingModel embeddingModel; // text-embedding-3-large, 3072 维Transactionalpublic void ingest(Composition composition) {String patientId composition.getSubject().getReferenceElement().getIdPart();List chunks extractor.extract(composition); // 按 section 切片保留 section.code 语义// 批量计算 Embedding避免单条调用 OpenAI Embedding API 触发 RPM 限流List embeddings embeddingModel.embed(chunks.stream().map(Document::getText).toList());List toStore IntStream.range(0, chunks.size()).mapToObj(i - Document.builder().text(chunks.get(i).getText()).metadata(Map.of(patient_id, patientId,section_code, chunks.get(i).getMetadata().get(sectionCode), // LOINC 代码document_date, composition.getDate(),source_resource, Composition/ composition.getIdElement().getIdPart())).vector(embeddings.get(i)).build()).toList();vectorStore.add(toStore);log.info(患者 {} 入库 {} 个向量切片, patientId, toStore.size());}}切片策略对比实测| 策略 | 平均切片数/病历 | 检索命中率 | Token 消耗 | 备注 ||------|----------------|------------|------------|------|| 固定 512 Token 重叠 50 | 62 | 0.61 | 高 | 破坏「用药清单」完整性 ||按 FHIR Section 语义切片|18|0.89|低| 保留section.code10164-2(History of present illness) 等语义边界 || 整文档单切片 | 1 | 0.34 | 极低 | 超过 Embedding 模型 8191 Token 限制直接报错 |最终选语义切片配合section_code作为元数据过滤条件检索时只召回History of present illnessMedicationsAllergies三个 Section噪声大幅下降。效果复盘上线两周生产环境实测数据日均 1,200 次调用| 指标 | 旧版 0.8.1 | 新版 1.0.0-M4 | 变化幅度 ||------|------------|---------------|----------||P99 延迟| 4.8 s |2.1 s|-56%||幻觉投诉率| 23% |0.8%|-96%||日 Token 成本| ¥3,450 |¥980|-72%||等保审计通过| ❌ 明文泄露 | ✅ 全链路脱敏 | 合规达标 ||向量检索 QPS| 45 (DB 全表扫) |1,200(HNSW) |25 倍|成本拆解text-embedding-3-large入库一次性成本 ¥0.13/千 Token病历总量 420 万 Token一次性 ¥546。推理端GPT-4o-2026-08-06平均 Input 1,800 Token / Output 650 Token单次 ¥0.0082日均 ¥984。省下的钱够买两张 PGVector 只读副本的 ECS 费用。一个反直觉的发现官方推荐的VectorStoreAdvisor默认把检索到的Document全部拼接进UserMessage导致 Prompt 膨胀到 8k Token。我们改写AroundAdvisor在before阶段把Document只保留text前 300 字符 metadata检索上下文从 8k 压到 2.1k Token推理延迟再降 300 ms且回答质量没跌。这属于「官方最佳实践在特定业务下反而更糟」的典型案例。迁移检查清单给下一个接手的人依赖冲突mvn dependency:tree | grep pgjdbc确保仅剩 42.7.3。Advisor 顺序MaskingAdvisor必须在VectorStoreAdvisor之前否则向量检索用的是明文患者 ID违规。ToolCallback 严格模式strict: true要求 JSON SchemaadditionalProperties: falseRecord类不能有额外字段否则 OpenAI 直接返回 400。原生镜像编译spring-ai-openai依赖netty-native需在native-image.properties添加Args -H:UnlockExperimentalVMOptions --enable-url-protocolshttp,https否则 GraalVM 编译报Unsupported URL protocol。观测指标命名spring.ai.chat.client.observation.nameehr.chat别用默认chat.clientPrometheus 里混着别的微服务查不出来。这版重构把「大模型套壳」改成了「RAG 工具链 合规护栏」的工程化形态。Spring AI 1.0.0-M4 的Advisor机制确实解决了 0.8.1 时代「拦截器地狱」的问题但BeanValidationOutputConverter的 JSON 容错、VectorStoreAdvisor的上下文膨胀依然得靠业务方自己兜底。下一步打算接入Spring AI 1.0.0-RC1的StructuredOutputAdvisor把BeanValidationOutputConverter彻底扔进垃圾桶。#后端 #Java #SpringBoot #SpringAI #RAG #FHIR #PGVector #架构重构你在实际项目中有遇到类似问题吗欢迎在评论区分享你的经验和解决方案。