【第32篇】场景示例项目

1. 概述

1.1 项目介绍

本项目演示如何同时向多个 AI 模型(本地 Ollama 与云端 DashScope)发起请求,并将各自流式生成的回复实时推送到前端。用户可以像同时咨询多个专家一样,看到不同模型的观点逐词"生长"出来。后端基于 Spring AI AlibabaSpring 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 接口(例如 SimpleVectorStorePgVectorStore)来持久化记忆。若容器中没有 VectorStore 实现,运行时将抛出 NoSuchBeanDefinitionException 或空指针异常。此处代码显然是复制了示例但未适配,应彻底移除,或在正确配置 VectorStore 之后使用。

问题 3:硬编码模型列表

Controller 中手动创建 ollamaClientdashScopeClient,模型名称被硬编码。当需要新增模型时,必须修改代码、重新部署,违背了开闭原则。

3.2 前端关键问题

问题 1:未阻止重复请求

原代码中存在 TODO 注释 "上一个请求没结束不能触发",说明未实现请求去重。若不阻止,短时间内多次点击发送会导致多个并行 SSE 连接,流顺序混乱,且浪费资源。

问题 2:加载状态判断错误
typescript 复制代码
isLoading: !!state.conversations.get(conversationId)?.activeRequests.size

分析
Map.size 可能为 0!!0false,看似正确;但当 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 执行如下步骤:

  1. ChatMemory 中取出指定会话的所有历史 Message。
  2. 将它们附加到当前用户消息前,形成完整上下文。
  3. 将构造后的 Prompt 发送给模型。
  4. 模型响应后,将用户消息和助理回复一并存入 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> 注入所有可用的 ChatModel Bean(需要为每个模型实现添加 @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 缓冲开启或浏览器不支持 关闭缓冲,尝试 fetchEventSourceonmessage 事件
多轮对话无记忆 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 的协作方式。在此基础上,我们提出了事件驱动的模型编排器、动态模型注册、前端去重与防抖等一系列优化替代方案,并给出了可直接落地的部署和故障排查指南。通过本文,可以循序渐进地理解多模型流式对话的设计要点,并自信地将其扩展至生产环境。

相关推荐
Python私教1 小时前
给 AI 助手装上导航仪:graphify 知识图谱实战,让 Claude Code 秒懂 400 文件项目架构
人工智能
linfengfeiye1 小时前
AI时代的核心技能不是技术,是主动性——Notion产品负责人深度访谈
人工智能·notion
TinTin Land2 小时前
真正的 AI 优先公司:99% 代码由 AI 编写,迭代仅需 1 天
人工智能
icestone20002 小时前
智能客服如何按客户类型切换话术?一套支持“渠道标签 + 用户自选 + 对话推断“的分类架构设计
大数据·人工智能·ai编程
有个人神神叨叨2 小时前
Ontology-Driven Agents(本体驱动智能体)
人工智能
John_ToDebug2 小时前
拆解AI的“五大基础设施”:算力、网络、存储、电力、软件,谁在驱动千亿市值?
网络·人工智能
Pushkin.2 小时前
Symphony:大模型之后的系统范式——从“写代码”到“编排工作”
人工智能
风落无尘2 小时前
我用 LangChain 写了一个带“定速巡航”的向量化工具,发布到 PyPI 了!
人工智能·python·langchain
AI技术控2 小时前
RAG 效果差不是模型问题:10 个检索增强失败原因总结
人工智能·python·自然语言处理