医疗 AI 分诊助手开发:基于 Spring Boot 与 Ollama 的 SSE 流式对话系统实现
项目架构与依赖管理
在构建医疗 AI 分诊助手时,后端核心基于 Spring Boot 3.x 结合 Spring AI 框架,前端则采用 Vue 3 配合 Element Plus。后端采用多模块 Maven 结构,核心服务模块 triage-service-provider 的依赖配置如下:
<dependencies>
<!-- 标准 Web 启动器,用于 MVC 架构和 SseEmitter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI 集成 Ollama -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<!-- 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
值得注意的是,虽然 Spring AI 底层使用了 Project Reactor,但本项目并未引入 spring-boot-starter-webflux。我们选择了传统的 Servlet 栈(Spring MVC),并通过 SseEmitter 实现流式响应。这种选型能够更好地兼容现有的 JDBC/JPA 等阻塞式数据库驱动,同时降低了响应式编程带来的调试难度。由于 AI 推理延迟(秒级)远大于网络传输损耗,WebFlux 的高并发吞吐优势在单纯的对话场景中并不显著。
后端抽象层设计
为了提高系统的灵活性,我们定义了 MedAiClient 接口,屏蔽底层 AI 供应端的差异(如 Ollama 或 Mock 实现)。
public interface MedAiClient {
/**
* 同步阻塞对话
*/
String generateResponse(String prompt);
/**
* 携带系统上下文的对话
*/
String generateResponse(String context, String prompt);
/**
* SSE 流式对话
*/
void streamResponse(String prompt, Consumer<String> chunkHandler, Runnable completionHandler, Consumer<Throwable> exceptionHandler);
}
Ollama 实现类
利用 Spring AI 自动装配的 StreamingChatModel,我们可以轻松实现流式输出:
@Component
@Primary
@ConditionalOnProperty(name = "triage.ai.mode", havingValue = "ollama", matchIfMissing = true)
public class OllamaClientImpl implements MedAiClient {
private final ChatModel chatModel;
private final StreamingChatModel streamingModel;
private final String systemInstruction;
public OllamaClientImpl(ChatModel chatModel,
StreamingChatModel streamingModel,
@Qualifier("medSystemPrompt") String systemInstruction) {
this.chatModel = chatModel;
this.streamingModel = streamingModel;
this.systemInstruction = systemInstruction;
}
@Override
public void streamResponse(String prompt, Consumer<String> chunkHandler, Runnable completionHandler, Consumer<Throwable> exceptionHandler) {
Prompt messagePrompt = new Prompt(systemInstruction + "\n用户咨询: " + prompt);
streamingModel.stream(messagePrompt)
.subscribe(
resp -> {
String text = resp.getResult().getOutput().getText();
if (text != null) chunkHandler.accept(text);
},
exceptionHandler::accept,
completionHandler
);
}
// 省略其他同步方法实现...
}
SSE 流式控制器实现
在控制器层,我们使用 SseEmitter 来管理长连接。AI 推理时间较长,建议将超时时间设置为 5 分钟(300,000ms),防止前端过早断开。
@RestController
@RequestMapping("/api/v1/triage")
public class TriageController {
private final TriageService triageService;
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter handleStreamChat(@RequestBody @Valid TriageRequest req) {
SseEmitter emitter = new SseEmitter(300_000L);
triageService.executeStreamProcess(req.getUserInput(), emitter);
emitter.onTimeout(() -> emitter.complete());
emitter.onError((ex) -> emitter.completeWithError(ex));
return emitter;
}
}
在 Service 层,我们将 AI 生成的每一个字符包装成 SSE 的 data 格式推送到前端,并在生成结束后追加法律免责声明:
public void executeStreamProcess(String input, SseEmitter emitter) {
aiClient.streamResponse(input,
chunk -> {
try {
emitter.send(SseEmitter.event().data(chunk));
} catch (IOException e) {
emitter.completeWithError(e);
}
},
() -> {
try {
emitter.send(SseEmitter.event().data("\n\n---\n*提醒:AI 建议仅供参考,危急情况请立即就医。*"));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
},
emitter::completeWithError
);
}
前端流式解析处理
由于标准的 axios 对流式 ReadableStream 支持不佳,前端推荐使用原生的 fetch API。为了避免中文字符分块导致的乱码(一个汉字占 3 字节,可能被分割在两个数据块中),必须在 TextDecoder 中开启 stream: true 模式。
async function fetchAiStream(content: string, onUpdate: (val: string) => void) {
const response = await fetch('/api/v1/triage/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userInput: content })
});
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let isDone = false;
while (!isDone) {
const { value, done } = await reader.read();
isDone = done;
// 关键:开启流模式解码,防止中文乱码
const rawChunk = decoder.decode(value, { stream: !done });
// 解析 SSE 协议格式 "data:xxxx\n\n"
const lines = rawChunk.split('\n');
lines.forEach(line => {
if (line.startsWith('data:')) {
const dataText = line.replace('data:', '');
onUpdate(dataText);
}
});
}
}
技术要点总结
- 模型配置:在 Spring AI 中,Ollama 的模型名称应通过配置文件(
spring.ai.ollama.chat.model)指定,而非直接在代码中硬编码。 - Mock 测试:在没有本地 GPU 环境时,可以通过自定义
MockClient并利用Thread.sleep模拟逐字输出的效果,以便进行 UI 联调。 - 资源管理:
SseEmitter返回后,Servlet 线程会立即释放,实际的写操作在异步线程中完成。务必注册onTimeout和onError回调,确保服务端连接能正常关闭,避免内存泄露。 - 安全性提示:医疗场景下,系统提示词(System Prompt)应强制 AI 不得给出具体的药物剂量建议,并由后端代码层级强制注入免责声明,确保合规性。
