Spring AI 三层架构详解:ChatModel → ChatClient → Controller,同步调用 vs 流式调用(SSE)

Spring AI 三层架构详解:ChatModel → ChatClient → Controller,同步调用 vs 流式调用(SSE)

随着大模型应用从"调 API"逐步走向工程化,Spring AI 提供了一套统一的编程模型,让 Java 开发者可以用一套 API 对接多家 AI 大模型(OpenAI、Ollama、Azure OpenAI 等)。其中,ChatModel → ChatClient → Controller​ 三层架构是最基础的代码组织方式,而同步调用与流式调用(SSE)则是交互层最重要的两个分支。

本文将从分层结构、代码示例、调用机制、适用场景四个角度,把这三层与两种调用方式讲清楚。


一、三层架构:职责分明,逐层封装

Spring AI 在设计上借鉴了 Spring Data 的分层思想,从上到下分为:

1. Controller 层(展示层 / 接口层)

职责:负责 HTTP 接口暴露、参数校验、结果格式化。

Controller 不直接操作模型,而是通过 ChatClient(或更低层的 ChatModel)完成业务逻辑。这样在切换模型或者调整提示词模板时,接口签名可以保持不变。

2. ChatClient 层(服务层 / 高级客户端)

职责:提供面向业务开发者友好的链式 API,封装了 Prompt 构造、消息历史、模型配置等细节。

ChatClient 类似于 Spring JDBC 中的 JdbcTemplate,让开发者专注于"用户说了什么"和"模型应该回复什么",而不是底层 API 参数。

3. ChatModel 层(技术层 / 低层适配器)

职责:直接与具体 AI 模型通信,负责发送 Prompt、接收 ChatResponse、处理模型供应商的差异。

它是整个架构的"地基",OpenAiChatModelOllamaChatModel等都是它的实现。多数业务代码无需直接触碰 ChatModel,但在需要深度定制时,它可以提供最大的灵活性。


二、依赖与基础配置

以 OpenAI 为例,在 pom.xml中加入:

xml 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
    <version>1.0.0</version>
</dependency>

然后配置 API Key:

yaml 复制代码
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      base-url: https://api.openai.com
      chat:
        options:
          model: gpt-4o-mini
          temperature: 0.7

Spring Boot 容器会自动创建一个 ChatModelBean,我们可以直接用 ChatClient封装它。


三、同步调用:一步一出,结果完整

1. 使用 ChatModel 直接调用

最底层的方式:

kotlin 复制代码
@RestController
public class ChatController {

    private final ChatModel chatModel;

    public ChatController(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    @GetMapping("/sync/chat-model")
    public String chatModelSync(@RequestParam String message) {
        return chatModel.call(message);
    }
}

chatModel.call(message)方法会阻塞等待模型生成完整回复,然后一次性返回整个字符串。这种方式实现简单,但用户体验较差------如果生成时间较长,客户端会一直等待。

2. 使用 ChatClient 优雅调用

更推荐业务代码使用 ChatClient:

kotlin 复制代码
@RestController
public class ChatController {

    private final ChatClient chatClient;

    public ChatController(ChatModel chatModel) {
        this.chatClient = ChatClient.builder(chatModel).build();
    }

    @GetMapping("/sync/chat-client")
    public String chatClientSync(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .call()
                .content();
    }
}

这里 call()后返回 CallResponseSpec.content()直接获取文本内容。如果想返回结构化对象,可以使用 .entity(MyBean.class),ChatClient 会自动将 JSON 映射为 Java 对象。

同步调用的特征

  • 阻塞式:调用后线程阻塞,直到模型完整回复为止。
  • 响应完整:最终拿到的是全部文本,无需处理中间状态。
  • 适合短问答:生成时间可控的场景,如知识库问答、意图分类等。
  • 异常处理简单:直接 try-catch 即可。

四、流式调用:边说边发,用 SSE 提升体验

大模型的回复往往很长,如果全部生成完再返回,用户可能需要等待 5~10 秒。流式调用可以让模型每生成一个 token 或一段文本,就立即推送给客户端,实现"打字机"效果。

1. 底层 ChatModel 的流式 API

ChatModel提供了 stream(Prompt prompt)方法,返回 Flux<ChatResponse>

less 复制代码
@GetMapping(value = "/stream/chat-model", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatModelStream(@RequestParam String message) {
    return chatModel.stream(message);
}

默认情况下,ChatModel.stream(message)返回 Flux<String>,每个元素是增量文本片段。Spring WebFlux 会自动将其包装为 text/event-stream响应,浏览器或前端可以用 EventSource接收。

2. 使用 ChatClient 进行流式调用

ChatClient 提供了 .stream()方法,语义更丰富:

kotlin 复制代码
@RestController
public class ChatController {

    private final ChatClient chatClient;

    public ChatController(ChatModel chatModel) {
        this.chatClient = ChatClient.builder(chatModel).build();
    }

    @GetMapping(value = "/stream/chat-client", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chatClientStream(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .stream()
                .content();
    }
}

.stream().content()返回 Flux<String>,每次发射一段新生成的文本。

如果需要更高级的控制,比如返回元数据或自定义事件,可以这样写:

less 复制代码
@GetMapping(value = "/stream/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamWithEvents(@RequestParam String message) {
    return chatClient.prompt()
            .user(message)
            .stream()
            .chatResponse()
            .map(response -> ServerSentEvent.builder(response.getResult().getOutput().getText())
                    .event("message")
                    .build());
}

3. SSE 协议核心要点

SSE(Server-Sent Events)是一种基于 HTTP 的单向服务器推送协议。响应头必须包含:

yaml 复制代码
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

服务端推送的数据格式为:

kotlin 复制代码
data: 这是一段文本

data: 这是第二段文本

每个 data字段代表一条消息,以空行分割。Spring WebFlux 返回的 Flux会自动完成这些格式转换。

4. 传统 Servlet 栈中的 SSE 实现

如果你的项目是 Spring MVC(非 WebFlux),可以用 SseEmitter包装流式响应:

scss 复制代码
@RestController
public class SseController {

    private final ChatClient chatClient;

    public SseController(ChatModel chatModel) {
        this.chatClient = ChatClient.builder(chatModel).build();
    }

    @GetMapping("/stream/sse-emitter")
    public SseEmitter streamSse(@RequestParam String message) {
        SseEmitter emitter = new SseEmitter(60_000L);
        chatClient.prompt()
                .user(message)
                .stream()
                .content()
                .doOnNext(content -> {
                    try {
                        emitter.send(SseEmitter.event().name("message").data(content));
                    } catch (IOException e) {
                        emitter.completeWithError(e);
                    }
                })
                .doOnComplete(emitter::complete)
                .doOnError(emitter::completeWithError)
                .subscribe();
        return emitter;
    }
}

这样即使同步阻塞的 Spring MVC 容器,也能以异步方式处理流式响应。


五、同步 vs 流式:多维对比

维度 同步调用 流式调用(SSE)
响应时间 首包时间长,需等全部生成完成 首包时间短,通常几百毫秒即可开始输出
用户体验 等待期空白,用户易认为网站卡顿 实时打字机效果,感知速度更快
资源占用 阻塞线程,高并发下需更多线程池资源 非阻塞,适合处理长时间生成任务
错误处理 一次 try-catch 即可捕获 需要处理流中段、取消、超时等场景
实现复杂度 中等,需要理解 Reactor 或 SseEmitter
数据完整性 一次性获得完整 result,便于日志记录 需要自行拼接流片段,才能获得全文
适用场景 短文本、内部系统、批量处理 对话机器人、文章生成、代码补全等交互式场景

六、工程实践建议

1. 根据接口语义选择

  • 对外 API 若面向终端用户,强烈建议使用流式 SSE,并以流式为主、同步为辅。
  • 内部服务间调用,如果只是生成摘要、分类等短文本,同步足够。
  • 如果 API 返回的是结构化 JSON 数据,流式并没有明显优势,同步更简单。

2. 设置超时与取消机制

流式接口需要特别关注连接时长。前端关闭页面时,后端的 Flux应自动取消订阅。可以结合 WebFlux 的 onCancel回调清理资源:

scss 复制代码
return chatClient.prompt()
        .user(message)
        .stream()
        .content()
        .doOnCancel(() -> log.info("客户端取消连接"));

3. 文本拼接与去重

流式模型返回的片段可能是不连续的 token,如果希望记录完整回答,需要自行拼接:

go 复制代码
StringBuilder fullContent = new StringBuilder();
flux.subscribe(
    content -> fullContent.append(content),
    error -> log.error("流式生成失败", error),
    () -> log.info("生成完成,总长度:{}", fullContent.length())
);

4. 监控制造商限流

流式请求会占用模型提供商更多的资源,有些情况下会遇到速率限制。建议为不同模型配置不同的限流策略,并在响应中传递 usage信息(如 token 数)便于计费审计。

5. 结合提示词模板与记忆

不要将用户消息直接拼接,尽量使用 ChatClient.prompt().system().user()方法,或者引入 PromptTemplate来维护提示词:

ini 复制代码
String template = """
        你是{role}。请用{style}回答:
        {question}
        """;
PromptTemplate promptTemplate = new PromptTemplate(template);
Message message = promptTemplate.createMessage(Map.of(
        "role", "Java技术专家",
        "style", "简洁犀利",
        "question", question
));

七、总结

Spring AI 的三层架构清晰地区分了接口、业务和底层模型适配的关系:

  • ChatModel 是引擎,决定了怎么跟模型说话;
  • ChatClient 是驾驶舱,提供了舒适的操控体验;
  • Controller 是方向盘,让外界按指定方式访问系统。

同步调用是基础,流式调用(SSE)是增强。两者并非互斥,一个成熟的应用完全可以同时暴露两种接口:即时消息走 SSE,数据量小的请求走同步接口。

更重要的是,Spring AI 将这两者统一在同一个 ChatClientAPI 之下,切换成本极低------大部分业务代码只需要从 .call()换成 .stream(),就能让响应体验从"等待"变为"实时"。这正是 Spring AI 在 Java 生态中快速流行的核心原因之一。

相关推荐
小傅哥1 小时前
Java + DDD,1:1 复刻 Deepseek Harness 项目
前端·后端·ai编程
Lambert2811 小时前
AgentScope Java 从零(03):Agent 的记性默认全开,我在第 50 轮翻了车
后端·aigc
积硅步致千里1 小时前
Word 里的 .wmf 其实是 EMF:一次 metafile 转 PNG 排障
前端·后端
爱读源码的大都督2 小时前
DeepSeek面试官问:生产RAG系统回答不准确,该如何定位和优化?这样回答,能让面试官当场给你Offer!
java·后端·python
杨运交2 小时前
[069][公共模块]Spring Boot 全局异常处理与参数校验实战(下):校验异常精细化处理与 WebFlux 适配
java·spring boot·后端
Andya_net2 小时前
Spring Boot | 条件注解完全指南:从 @Conditional 到 @ConditionalOnExpression 的原理、实践与避坑
spring boot·后端·python
ly76893 小时前
Spring 中的 @Configuration 与 @Component 差异:为何代理时机决定 Bean 生命周期行为
java·后端·spring·注解·代理·bean生命周期
明月_清风3 小时前
字符串匹配四大经典算法:BF、RK、BM、KMP 到底有什么区别?
后端·算法
卷无止境3 小时前
测试全绿,功能能跑,代码却烂到没法上线:AI编程助手留下的十个坑
后端·python