1. 概述
1.1 项目介绍
本项目演示如何同时向多个 AI 模型(本地 Ollama 与云端 DashScope)发起请求,并将各自流式生成的回复实时推送到前端。用户可以像同时咨询多个专家一样,看到不同模型的观点逐词"生长"出来。后端基于 Spring AI Alibaba 和 Spring WebFlux ,前端使用 Next.js + Ant Design X,通过 Server-Sent Events (SSE) 实现单向实时推送。
核心功能
- 单次提问,多模型并行回答
- 流式响应,逐字显示
- 对话记忆(ChatMemory)支撑上下文连续对话
- 前端状态机管理异步请求,防止竞态
1.2 项目结构
spring-ai-alibaba-scene-example/
├── frontend/ # Next.js 前端
│ ├── src/
│ │ ├── app/
│ │ │ ├── hooks/ # 自定义 Hooks(SSE 连接、流管理)
│ │ │ ├── services/ # API 封装
│ │ │ ├── components/ # 对话卡片、输入框等
│ │ │ └── page.tsx # 主页面
│ │ ├── types/ # 类型定义
│ │ └── stores/ # Jotai 原子状态
│ ├── next.config.ts
│ └── package.json
└── multi-model-chat/ # Spring Boot 后端
└── src/
├── main/java/com/alibaba/example/multimodelchat/
│ ├── MultiModelChatApplication.java
│ └── controller/
│ └── MultiModelChatController.java
└── resources/
└── application.yml
1.3 技术栈
| 层 | 技术 |
|---|---|
| 后端 | Spring Boot 3.x, Spring AI Alibaba, Spring AI Ollama, Spring WebFlux, Reactor |
| 通信 | SSE (text/event-stream) |
| 前端 | Next.js 15, React 19, TypeScript, Ant Design X, @microsoft/fetch-event-source, Jotai |
2. 架构设计分析
2.1 整体交互流程
DashScope (云端) Ollama (本地) Spring Boot 后端 Next.js 前端 DashScope (云端) Ollama (本地) Spring Boot 后端 Next.js 前端 创建两个 ChatClient 分别绑定 Ollama/DashScope par [并行] 用户 输入问题,点击发送 POST /stream/chat (JSON) 流式请求 (prompt) SSE tokens 流式请求 (prompt) SSE tokens event:ollama / event:dashscope 分别渲染两个模型回复 用户
2.2 后端架构原理
后端核心是一个 @RestController,它通过 Flux.merge() 将两个独立的响应式 Flux<ServerSentEvent<String>> 合并成一个 SSE 流。每个模型的 token 到达时,都会被包装为带有 event 字段的 SSE 消息,例如 event: ollama。
渲染错误: Mermaid 渲染失败: Parse error on line 5: ...ux (ollama)] C -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'
关键设计决策
- 每个请求创建新的
ChatClient:尽管原文档建议将ChatMemory定义为 Bean,但ChatClient本身是轻量级对象,完全可以在方法内创建。不过,为了避免重复配置,可将ChatMemory和公共 advisors 注入,然后通过ChatClient.builder(chatModel).advisors(...).build()快速构建。 - 多路复用的 SSE 流 :利用
Flux.merge将多个流"交织"发射,前端根据event字段分流。
2.3 前端架构原理
前端使用 @microsoft/fetch-event-source 库发起 POST 请求,并持续解析事件流。状态管理采用 useReducer 构建状态机,每个对话会话独立维护一个 activeRequests 集合,用于记录进行中的请求 ID,以此实现请求去重 和加载状态。
用户点击发送
所有 token 接收完毕 / 错误
用户点击取消
流关闭
Idle
Loading
Cancelling
3. 代码分析与问题识别
3.1 后端关键问题
问题 1:MessageWindowChatMemory 每次请求都新建实例
原代码片段
java
.advisors(MessageChatMemoryAdvisor.builder(
MessageWindowChatMemory.builder().build()
).build())
分析
MessageWindowChatMemory 维护一个 List<Message> 来存储对话历史。每次请求新建实例意味着记忆永远为空,多轮对话能力丢失 。MessageChatMemoryAdvisor 会在每次提示前将历史消息注入 prompt,并在收到回复后自动更新记忆。要使记忆持续存在,必须将其声明为共享的 Spring Bean,并正确注入到 ChatClient 中。
问题 2:误用 VectorStoreChatMemoryAdvisor 参数
java
// 原代码中设置了 TOP_K 参数,但没有提供 VectorStore Bean
.advisors(memoryAdvisor -> memoryAdvisor
.param(CONVERSATION_ID, conversationId)
.param(VectorStoreChatMemoryAdvisor.TOP_K, 100))
分析
VectorStoreChatMemoryAdvisor 依赖 VectorStore 接口(例如 SimpleVectorStore 或 PgVectorStore)来持久化记忆。若容器中没有 VectorStore 实现,运行时将抛出 NoSuchBeanDefinitionException 或空指针异常。此处代码显然是复制了示例但未适配,应彻底移除,或在正确配置 VectorStore 之后使用。
问题 3:硬编码模型列表
Controller 中手动创建 ollamaClient 和 dashScopeClient,模型名称被硬编码。当需要新增模型时,必须修改代码、重新部署,违背了开闭原则。
3.2 前端关键问题
问题 1:未阻止重复请求
原代码中存在 TODO 注释 "上一个请求没结束不能触发",说明未实现请求去重。若不阻止,短时间内多次点击发送会导致多个并行 SSE 连接,流顺序混乱,且浪费资源。
问题 2:加载状态判断错误
typescript
isLoading: !!state.conversations.get(conversationId)?.activeRequests.size
分析
Map.size 可能为 0,!!0 为 false,看似正确;但当 conversation 不存在时,?.activeRequests 返回 undefined,!!undefined 也是 false。但若 activeRequests 本身为 undefined(例如从未初始化),size 取值将抛出 TypeError。更严重的是,语义上无法区分"请求数为 0"和"对话不存在"的情况。应改为明确的数值比较。
4. 原理解释
为了使修正与优化方案更有说服力,此处深入剖析核心组件的运作机制。
4.1 ChatClient 与 Advisor 链
Spring AI 的 ChatClient 是一个门面,内部封装了 ChatModel(实际调用 LLM)以及一系列 RequestResponseAdvisor。Advisor 链形成拦截器模式,可以在 prompt 发送前、回复返回后执行额外逻辑。
Prompt
更新记忆
简单日志 Advisor
ChatModel.call
解析结果
MessageChatMemoryAdvisor 执行如下步骤:
- 从
ChatMemory中取出指定会话的所有历史 Message。 - 将它们附加到当前用户消息前,形成完整上下文。
- 将构造后的 Prompt 发送给模型。
- 模型响应后,将用户消息和助理回复一并存入
ChatMemory。
如果 ChatMemory 是按请求新建的,那么步骤 1 永远拿到空列表,步骤 4 的写入也会随请求结束被垃圾回收,无法持久化。因此必须将 ChatMemory 声明为 Spring Bean,其生命周期与容器一致。
4.2 Server-Sent Events (SSE) 协议细节
SSE 是一种 W3C 标准的服务器推送技术,相比于 WebSocket 更轻量,适合单向数据流。
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
event: ollama
data: {"content": "你好"}
event: ollama
data: {"content": ",我"}
event: dashscope
data: {"content": "您好"}
event: 事件类型,前端通过addEventListener('ollama', fn)或onmessage区分。data: 负载,可以是纯文本或 JSON。- 每个事件块以空行结束。
Spring WebFlux 使用 ServerSentEvent 构建消息,Flux 的异步特性使得多个下游数据源可同时写入同一个输出流,天然支持 SSE。
4.3 Reactor Flux 合并与背压
Flux.merge 并不保证顺序,而是将上游元素"先到先发"。这在多模型场景下意味着哪个模型先产生 token,前端就先渲染它的回复 。如果希望严格交替或分组,需要使用 Flux.zip 或自定义合并策略。
java
Flux<ServerSentEvent<String>> merged = Flux.merge(
ollamaStream.map(this::buildOllamaEvent),
dashScopeStream.map(this::buildDashScopeEvent)
)
.doOnCancel(() -> log.warn("Client disconnected"))
.timeout(Duration.ofSeconds(60));
doOnCancel:监听客户端断连,释放下游资源。timeout:防止某个模型长时间不响应导致连接挂死。
5. 错误之处与修订
基于第 3 节的分析,本节给出修正后的完整实现。
5.1 修正后的后端 Controller
java
@RestController
@CrossOrigin
public class MultiModelChatController {
private final Map<String, ChatClient> chatClients = new ConcurrentHashMap<>();
private final MessageWindowChatMemory chatMemory;
public MultiModelChatController(
List<ChatModel> chatModels, // 注入所有 ChatModel 实现
MessageWindowChatMemory chatMemory) {
this.chatMemory = chatMemory;
for (ChatModel model : chatModels) {
String modelName = resolveModelName(model); // 自定义逻辑
ChatClient client = ChatClient.builder(model)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build()
)
.build();
chatClients.put(modelName, client);
}
}
@Bean
public static MessageWindowChatMemory messageWindowChatMemory() {
return MessageWindowChatMemory.builder()
.maxMessages(50)
.build();
}
@PostMapping(value = "/stream/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamChat(
@RequestBody ChatRequest chatRequest) {
String userPrompt = chatRequest.prompt();
String conversationId = chatRequest.conversationId();
List<String> models = chatRequest.models(); // 前端指定要使用的模型
List<Flux<ServerSentEvent<String>>> streams = models.stream()
.filter(chatClients::containsKey)
.map(name -> {
ChatClient client = chatClients.get(name);
return client.prompt()
.user(userPrompt)
.advisors(a -> a.param("CHAT_MEMORY_CONVERSATION_ID", conversationId))
.stream()
.content()
.map(token -> ServerSentEvent.builder(token)
.event(name)
.build());
})
.toList();
return Flux.merge(streams)
.onErrorResume(e -> Flux.just(
ServerSentEvent.builder("Error: " + e.getMessage())
.event("error").build()))
.timeout(Duration.ofSeconds(60));
}
@GetMapping("/api/models")
public List<String> listModels() {
return new ArrayList<>(chatClients.keySet());
}
}
修正要点说明
ChatMemory通过静态 Bean 方法注册,确保单例。- 移除了无效的
VectorStoreChatMemoryAdvisor,若无VectorStore配置,不使用该 advisor。 - 动态模型列表通过
ChatRequest.models传入,后端无需硬编码。 - 通过
List<ChatModel>注入所有可用的ChatModelBean(需要为每个模型实现添加@Bean和@Qualifier,或使用@Component并自动识别)。 conversationId作为参数传递给 memory advisor,确保不同对话的记忆隔离。
5.2 修正后的前端状态判断
typescript
const conversation = state.conversations.get(conversationId);
const requestCount = conversation?.activeRequests?.size ?? 0;
isLoading: requestCount > 0
并且增加请求去重逻辑:
typescript
const activeRequestsRef = useRef<Set<string>>(new Set());
const chatStream = useCallback((params: StreamParams) => {
const conversation = state.conversations.get(params.conversationId);
if (!conversation) return;
if (conversation.activeRequests.size > 0) {
console.warn("上一轮请求尚未结束,请稍后重试");
return () => {};
}
// 标记请求中...
const requestId = uuid();
dispatch({ type: "REQUEST_START", requestId, conversationId });
// ... 建立 SSE 连接,完成后 dispatch REQUEST_END
}, []);
通过 AbortController 支持取消:
typescript
const controller = new AbortController();
fetchEventSource(url, {
signal: controller.signal,
onmessage() { /*...*/ },
onclose() { dispatch({ type: "REQUEST_END", requestId }); },
onerror() { dispatch({ type: "ERROR", requestId }); }
});
// 取消:controller.abort()
6. 替代方案与优化
这一节将原本一笔带过的替代方案进行深度展开,并给出设计图与代码片段。
6.1 事件驱动的模型编排器
将多模型调用的编排逻辑抽取为可复用的 ModelOrchestrator 服务,便于后续添加负载均衡、熔断、结果聚合等高级功能。
产生
ModelOrchestrator
-Map<String,ChatClient> chatClients
+queryMultipleModels(prompt, conversationId, modelNames) : Flux<ModelResponseEvent>
+registerModel(name, client) : void
+removeModel(name) : void
ModelResponseEvent
+String modelName
+String token
+long timestamp
核心方法示意
java
public Flux<ModelResponseEvent> queryMultipleModels(
String prompt, String conversationId, List<String> modelNames) {
List<Flux<ModelResponseEvent>> fluxes = modelNames.stream()
.filter(chatClients::containsKey)
.map(name -> chatClients.get(name).prompt()
.user(prompt)
.advisors(a -> a.param("CHAT_MEMORY_CONVERSATION_ID", conversationId))
.stream().content()
.map(token -> new ModelResponseEvent(name, token, System.currentTimeMillis()))
)
.toList();
return Flux.merge(fluxes)
.onErrorContinue((e, obj) -> log.error("Model {} failed", obj, e))
.timeout(Duration.ofSeconds(60));
}
优点:
- 业务逻辑与 Controller 解耦,可单元测试。
- 未来可无缝引入 Spring Cloud 微服务调用、Resilience4j 熔断。
- 支持在运行时动态注册模型(例如热加载新部署的本地模型)。
6.2 动态模型注册与发现
利用 Spring 的依赖注入机制,将所有的 ChatModel 实现自动收集,并对外暴露 /api/models 端点。这要求每个模型实现都提供一个可以标识的名称。
方法 1:使用自定义注解 + BeanPostProcessor
java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface ChatModelProvider {
String name();
}
然后将 @ChatModelProvider(name = "ollama") 标注在 Ollama ChatModel 的配置类上,通过 BeanPostProcessor 收集。
方法 2:显式注册 Map Bean
java
@Configuration
public class ChatClientConfig {
@Bean
public Map<String, ChatModel> chatModelMap(OllamaChatModel ollama, DashScopeChatModel dashscope) {
return Map.of("ollama", ollama, "dashscope", dashscope);
}
@Bean
public ModelOrchestrator modelOrchestrator(Map<String, ChatModel> models, MessageWindowChatMemory memory) {
ModelOrchestrator orchestrator = new ModelOrchestrator();
models.forEach((name, model) -> {
ChatClient client = ChatClient.builder(model)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
.build();
orchestrator.registerModel(name, client);
});
return orchestrator;
}
}
前端可以动态获取模型列表,构建多选界面,真正实现"用户选择专家团"。
6.3 前端深度优化
6.3.1 防抖与节流处理
对于快速连续点击,可用 debounce 限制触发频率:
typescript
import { useMemo } from 'react';
import { debounce } from 'lodash';
const debouncedSend = useMemo(
() => debounce((params: StreamParams) => chatStream(params), 300),
[chatStream]
);
6.3.2 消息更新的不可变优化
由于 React 使用不可变数据,每条 token 都需要更新消息列表。如果消息较长,可使用 useRef + 队列让渲染更新合并,或利用 startTransition 降低优先级。
6.3.3 重连与错误恢复
typescript
fetchEventSource(url, {
onerror(err) {
if (err.status === 429) {
// 限流时指数退避重试
return 5000;
}
throw err; // 停止重连
}
});
6.4 架构扩展性考量
未来可演进的方向:
- 结果聚合 :在
ModelOrchestrator中增加一个聚合 stage,等所有模型完成后生成综合回复。 - 链式调用:一个模型的输出作为另一个模型的输入,构建流水线。
- 可观测性:为每个模型调用添加 Micrometer metrics,记录耗时、令牌数。
7. 部署实操指南
7.1 环境准备
- JDK 17 或以上
- Node.js 18 且包含 pnpm(
npm i -g pnpm) - Maven 3.8+(项目内置 Maven Wrapper,无需全局安装)
- Ollama 本地运行
7.2 安装与启动 Ollama
bash
# 安装
curl -fsSL https://ollama.com/install.sh | sh
# 启动服务(推荐使用 systemd 或静默后台)
ollama serve > /tmp/ollama.log 2>&1 &
# 下载所需模型
ollama pull qwen2.5:7b
7.3 配置 DashScope API Key
bash
export AI_DASHSCOPE_API_KEY="sk-xxxxxx"
# 或创建 .env 文件,Spring 可通过 @PropertySource 或 .env 支持被自动读取
7.4 本地开发启动
后端
bash
cd multi-model-chat
./mvnw spring-boot:run -Dspring-boot.run.jvmArguments="-Dspring.ai.dashscope.api-key=$AI_DASHSCOPE_API_KEY"
前端
bash
cd frontend
pnpm install
pnpm dev
7.5 Docker 部署
编写 docker-compose.yml
yaml
version: '3.8'
services:
ollama:
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
- ollama-data:/root/.ollama
command: ["serve"]
backend:
build: ./multi-model-chat
ports:
- "8848:8848"
environment:
- SPRING_AI_OLLAMA_BASE_URL=http://ollama:11434
- AI_DASHSCOPE_API_KEY=${AI_DASHSCOPE_API_KEY}
depends_on:
- ollama
frontend:
build: ./frontend
ports:
- "8080:3000"
environment:
- BACKEND_URL=http://backend:8848
volumes:
ollama-data:
启动后仍需在 Ollama 容器内拉取模型:
bash
docker exec -it ollama ollama pull qwen2.5:7b
7.6 生产环境注意事项
-
如果使用 Nginx 反代,务必关闭缓冲,保证 SSE 实时推送:
location /stream/ {
proxy_pass http://backend:8848;
proxy_buffering off;
proxy_read_timeout 90s;
chunked_transfer_encoding on;
}
8. 故障排除
| 现象 | 可能原因 | 解决步骤 |
|---|---|---|
| 后端启动报 Ollama 连接拒绝 | Ollama 服务未运行 | ollama serve 或检查 Docker 容器状态 |
| DashScope 返回 401 | API Key 错误或过期 | 确认环境变量 AI_DASHSCOPE_API_KEY,测试 curl |
| 前端看不到流式效果 | Nginx 缓冲开启或浏览器不支持 | 关闭缓冲,尝试 fetchEventSource 的 onmessage 事件 |
| 多轮对话无记忆 | ChatMemory 未作为 Bean |
按照第 5 节修正,确保单例 Bean 注入 |
| 对话记忆错乱 | conversationId 未正确传递 |
检查 advisor 参数 CHAT_MEMORY_CONVERSATION_ID 是否一致 |
| 重复请求导致界面错乱 | 未实现请求去重 | 引入 activeRequests 状态 + AbortController |
| Ollama 模型加载慢,超时 | 超时时间过短 | 增加 timeout 到 120 秒,或调整模型缓存策略 |
9. 快速参考
访问地址
| 服务 | 本地地址 | 说明 |
|---|---|---|
| 前端 | http://localhost:8080 | Next.js |
| 后端 API | http://localhost:8848 | Spring Boot |
| Ollama | http://localhost:11434 | 本地大模型 |
关键配置片段
application.yml 关键项
yaml
server:
port: 8848
spring:
ai:
ollama:
chat:
model: qwen2.5
options:
temperature: 0.7
dashscope:
api-key: ${AI_DASHSCOPE_API_KEY}
next.config.ts 代理
typescript
async rewrites() {
return [
{
source: '/api/stream/:path*',
destination: 'http://localhost:8848/stream/:path*'
}
]
}
总结
纠正了原示例中关于 ChatMemory 生命周期、无效 advisor 配置以及前端状态判断的错误,还从原理层面详述了 ChatClient、SSE、Reactor Flux 的协作方式。在此基础上,我们提出了事件驱动的模型编排器、动态模型注册、前端去重与防抖等一系列优化替代方案,并给出了可直接落地的部署和故障排查指南。通过本文,可以循序渐进地理解多模型流式对话的设计要点,并自信地将其扩展至生产环境。