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.0(Servlet 容器从 Tomcat 10.1.24 升级 10.1.28,修复 HTTP/2 窗口更新死锁)
- Spring AI 0.8.1→1.0.0-M4(BOM 坐标
org.springframework.ai:spring-ai-bom:1.0.0-M4) - JDK 21.0.4(GraalVM 22.3.3 编译原生镜像)
- PostgreSQL 16.3+PGVector 0.7.4(HNSW 索引
m=16, ef_construction=200) - HAPI FHIR 7.0.0(R4B 版本,解析
Bundle资源) - OpenAI GPT-4o-2026-08-06(Function Calling 强制
strict: true)
业务痛点三座大山:
- 上下文窗口爆炸:单次病历 3.2 万 Token,塞进 128k 窗口只能跑 3 轮对话,多科室会诊直接截断。
- 幻觉不可控:ICD-10 编码、药品通用名、检验单位全靠大模型「背诵」,无知识库约束。
- 合规审计缺失:Prompt 里明文拼接患者姓名、身份证、床号,等保测评直接不通过。
需求分析
功能性指标:
- 支持FHIR R4B
Composition资源作为结构化入参,自动拆解section入向量库。 - 对话式问诊:多轮上下文自动压缩,保留最近 5 轮 + 关键实体(诊断、用药、过敏史)。
- 结构化输出绑定
DischargeSummaryDTO,字段级校验@NotNull @Size(max=2000)。
非功能性红线:
- 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拼接,无版本控制 |PromptTemplate+Resource热加载 | 支持但需额外依赖 | Spring 原生,配置中心统一管控 |
|RAG 检索|JdbcTemplate手写cosine_similarity|VectorStoreAdvisor自动注入PGVectorStore| 需自行适配EmbeddingStore| 零代码切换向量库,支持FilterExpression|
|PII 脱敏| 正则替换Prompt前置处理 |MaskingAdvisor透明拦截UserMessage| 无内置,需自定义ChatMemory| 审计日志自动记录脱敏前后对照,免改业务代码 |
|工具调用| 手写FunctionCallback解析 JSON |ToolCallingManager+strict: true强校验 | 支持但泛型擦除严重 | Spring AI 直接映射Record类型,编译期报错 |
|观测指标| 只有 actuatorhttp.server.requests|ChatClient内置Observation钩子 | 需接入 Micrometer 手动埋点 | 自动暴露token.usage,retrieval.latency等维度 |
弃用 LangChain4j 的真实原因:它的ChatMemoryProvider在 WebFlux 响应式链路里会丢失Reactor Context,导致多租户隔离失效,我们排查了两周没根治。Spring AI 的Advisor基于ThreadLocal+ContextPropagator,原生兼容虚拟线程。
核心实现
1. 依赖迁移:BOM 统管版本,剔除传递冲突
```xml
org.springframework.ai
spring-ai-bom
1.0.0-M4
pom
import
ca.uhn.hapi.fhir
hapi-fhir-base
7.0.0
org.springframework.ai
spring-ai-openai-spring-boot-starter
org.springframework.ai
spring-ai-pgvector-spring-boot-starter
org.springframework.ai
spring-ai-model-spring-boot-starter
ca.uhn.hapi.fhir
hapi-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 + 脱敏 + 结构化输出一条龙
```java
@Configuration
@RequiredArgsConstructor
public class ChatClientConfig {
private final PGVectorStore vectorStore;
private final FhirResourceExtractor fhirExtractor; // 自组件,解析 Composition -> List
private final AuditLogger auditLogger;
@Bean
public ChatClient chatClient(ChatClient.Builder builder, OpenAiChatModel chatModel) {
return builder
.defaultSystem(systemPrompt())
// 1. 向量检索:Top-K=5, 相似度阈值 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 用量、检索命中文档 ID
new 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的enableEntityRetention=true依赖EntityExtractor接口,我们实现了基于PatternLayout的MedicalEntityExtractor,把「左心衰」「美托洛尔」「Ⅰ度房室传导阻滞」这类实体强行留在上下文窗口。
3. 结构化输出:Record + BeanValidation 双重保险
```java
public 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.5mg"
@NotBlank String frequency, // 如 "bid"
@NotBlank 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 语法错误,校验 BeanValidation
BeanValidationOutputConverter 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切片策略
```java
@Component
@RequiredArgsConstructor
public class FhirVectorIngestionService {
private final PGVectorStore vectorStore;
private final FhirResourceExtractor extractor;
private final EmbeddingModel embeddingModel; // text-embedding-3-large, 3072 维
@Transactional
public 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.code=10164-2(History of present illness) 等语义边界 |
| 整文档单切片 | 1 | 0.34 | 极低 | 超过 Embedding 模型 8191 Token 限制直接报错 |
最终选语义切片,配合section_code作为元数据过滤条件,检索时只召回History of present illness+Medications+Allergies三个 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: false,Record类不能有额外字段,否则 OpenAI 直接返回 400。 - 原生镜像编译:
spring-ai-openai依赖netty-native,需在native-image.properties添加Args = -H:+UnlockExperimentalVMOptions --enable-url-protocols=http,https,否则 GraalVM 编译报Unsupported URL protocol。 - 观测指标命名:
spring.ai.chat.client.observation.name=ehr.chat,别用默认chat.client,Prometheus 里混着别的微服务查不出来。
这版重构把「大模型套壳」改成了「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 #架构重构
你在实际项目中有遇到类似问题吗?欢迎在评论区分享你的经验和解决方案。