技术派面试拷打②:策略模式 + 工厂模式 + WebSocket 流式推送

技术派面试拷打②:策略模式 + 工厂模式 + WebSocket 流式推送

简历原文:「为解决多态大模型接入的扩展性问题,基于策略模式与工厂模式实现 LLM 调用链路的统一抽象与热插拔;后端结合 WebSocket 推送 Stream 流,实现前端毫秒级响应体验。」

面试口径:“我们项目要接入多个大模型(ChatGPT、Kimi、DeepSeek 等),如果写 if-else 判断类型再调用,新增一个模型就要改主流程代码,违反开闭原则。所以用策略模式:定义 ChatService 接口,每个模型写一个实现类;用工厂模式(ChatServiceFactory)在启动时自动注册所有策略,调用时根据枚举类型直接拿对应的实现,新增模型只要加一个实现类,不动任何旧代码(热插拔)。前端用 WebSocket(STOMP 协议)订阅后端推送,后端用 SimpMessagingTemplate 逐段推送 LLM 生成的文本,前端收到一段渲染一段,不用等全部生成完才显示,实现毫秒级响应体验。”


第一层:为什么要策略模式?(设计模式基础题)

Q1:不用策略模式,直接用 if-else 不行吗?有什么区别?

答: 用 if-else 能跑,但不好维护。策略模式是为了解决”一堆 if-else 判断类型,然后执行不同逻辑“这种场景的。

if-else 版本(反面教材):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 糟糕的写法:所有逻辑耦合在一个方法里
public ChatRecordsVo chat(AISourceEnum type, Long user, String question) {
if (type == AISourceEnum.CHAT_GPT_3_5) {
// 调用 ChatGPT 的逻辑(50 行)
return callChatGpt(user, question);
} else if (type == AISourceEnum.DEEP_SEEK) {
// 调用 DeepSeek 的逻辑(50 行)
return callDeepSeek(user, question);
} else if (type == AISourceEnum.DOUBAO) {
// 调用豆包的逻辑(50 行)
return callDoubao(user, question);
}
// 新增一个模型,就要改这个方法!违反开闭原则
}

问题:

  1. 违反开闭原则:新增一个模型,要改这个 if-else 方法
  2. 代码臃肿:所有模型的逻辑都堆在一个类里,可读性差
  3. 无法独立测试:想单元测试 DeepSeek 的调用逻辑,但这个方法依赖所有模型

策略模式版本(项目里的写法):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ✅ 优雅的写法:定义策略接口
public interface ChatService {
AISourceEnum source(); // 每个策略告诉工厂"我是哪个模型"
ChatRecordsVo chat(Long user, String question);
}

// 每个模型写一个实现类(策略)
@Service
public class ChatGptAiServiceImpl extends AbsChatService {
@Override
public AISourceEnum source() { return AISourceEnum.CHAT_GPT_3_5; }

@Override
public ChatRecordsVo chat(Long user, String question) {
// 只关心 ChatGPT 的调用逻辑(50 行)
}
}

// 调用时:根本不用 if-else,工厂直接给实现类
ChatService service = chatServiceFactory.getChatService(AISourceEnum.CHAT_GPT_3_5);
return service.chat(user, question);

面试回答:

“不用策略模式也能跑,但维护成本高。如果项目要接入 10 个大模型,if-else 版本要写 10 个 else if,新增模型要改主流程代码,违反开闭原则。策略模式把’调用哪个模型’的判断逻辑剥离到工厂里,每个模型独立成一个实现类,新增模型只要加一个类,不动任何旧代码(热插拔)。”


Q2:策略模式的核心角色有哪些?项目里分别是谁?

答: 策略模式有三个核心角色:

角色 作用 项目里的类
策略接口(Strategy) 定义所有策略的通用方法 ChatService
具体策略(ConcreteStrategy) 实现策略接口,每个策略一种算法 ChatGptAiServiceImplDeepSeekChatServiceImplDoubaoAiServiceImpl
上下文/工厂(Context/Factory) 根据条件选择并执行对应的策略 ChatServiceFactory

项目里的类结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
ChatService(策略接口)
├── source():返回 AISourceEnum(我是谁?)
├── chat():同步调用
└── asyncChat():异步调用(带 Stream 回调)

ChatGptAiServiceImpl(具体策略:ChatGPT
DeepSeekChatServiceImpl(具体策略:DeepSeek
DoubaoAiServiceImpl(具体策略:豆包)
AliAiServiceImpl(具体策略:阿里百炼)
ZhipuAiServiceImpl(具体策略:智谱 AI

ChatServiceFactory(工厂:持有一个 Mapkey=AISourceEnumvalue=ChatService 实现)
└── getChatService(AISourceEnum):根据枚举直接拿实现类

面试回答:

“策略模式有三个角色:策略接口(定义通用方法)、具体策略(每种算法的实现)、上下文/工厂(选择并执行策略)。我们项目里,ChatService 是策略接口,每个大模型是一个具体策略实现类,ChatServiceFactory 是工厂,启动时自动注册所有策略,调用时根据 AISourceEnum 直接拿实现类。”


Q3:工厂模式有哪几种?项目里用的是哪种?为什么?

答: 工厂模式有三种:简单工厂、工厂方法、抽象工厂。项目里用的是注册式工厂(属于简单工厂的变种)。

三种工厂的区别:

工厂类型 原理 优点 缺点
简单工厂 一个工厂类,根据条件 if-else 创建对象 简单 新增产品要改工厂类,违反开闭原则
工厂方法 每种产品一个工厂类,实现同一个工厂接口 符合开闭原则 类爆炸(10 个产品 = 10 个工厂类)
抽象工厂 工厂方法 + 产品族(一组相关产品) 保证产品族兼容 复杂,过度设计

项目里的”注册式工厂”(推荐!):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 工厂持有所有策略的 Map
@Component
public class ChatServiceFactory {
private final Map<AISourceEnum, ChatService> chatServiceMap;

// ✅ Spring 自动注入所有 ChatService 的实现类(神奇!)
public ChatServiceFactory(List<ChatService> chatServiceList) {
chatServiceMap = Maps.newHashMapWithExpectedSize(chatServiceList.size());
for (ChatService chatService : chatServiceList) {
// 每个策略自己告诉工厂"我是谁"(通过 source() 方法)
chatServiceMap.put(chatService.source(), chatService);
}
}

public ChatService getChatService(AISourceEnum aiSource) {
return chatServiceMap.get(aiSource);
}
}

为什么用注册式工厂?

  1. 不用写 if-else:所有策略自动注册到 Map
  2. 新增策略零修改:加一个 XxxAiServiceImpl,工厂自动把它加进 Map(Spring 自动注入 List<ChatService>
  3. 符合开闭原则:对扩展开放,对修改关闭

面试回答:

“工厂模式有三种:简单工厂(if-else 判断)、工厂方法(每种产品一个工厂类)、抽象工厂(产品族)。我们项目用的是’注册式工厂’(简单工厂的变种):工厂持有一个 Map,启动时 Spring 自动注入所有 ChatService 的实现类,调用 source() 方法把每个策略注册到 Map 里。新增一个模型时,只要加一个实现类,工厂自动注册,不用改任何旧代码。”


第二层:Spring 自动注入的魔法(Java 基础题)

Q4:ChatServiceFactory 的构造函数里,List<ChatService> chatServiceList 是什么神器?为什么 Spring 能自动注入所有实现类?

答: 这是 Spring 的泛化注入(by-type) 特性。

原理:

1
2
3
4
5
6
7
// Spring 启动时:
// 1. 扫描所有 @Service / @Component 注解的类
// 2. 发现 ChatGptAiServiceImpl 实现了 ChatService → 放进容器
// 3. 发现 DeepSeekChatServiceImpl 实现了 ChatService → 放进容器
// ...
// 4. 创建 ChatServiceFactory 时,发现构造函数需要 List<ChatService>
// → 把所有实现了 ChatService 的 Bean 打包成 List,注入进来!

如果有两个ChatService 实现类,但我想指定注入某一个怎么办?

1
2
3
4
5
6
7
8
9
// 方案 1:用 @Qualifier 指定 Bean 名字
@Autowired
@Qualifier("chatGptAiServiceImpl")
private ChatService chatService;

// 方案 2:用 @Primary 标注"首选"的实现类
@Primary
@Service
public class ChatGptAiServiceImpl implements ChatService { ... }

面试回答:

List<ChatService> 是 Spring 的泛化注入特性。Spring 启动时会把所有实现了 ChatService 接口的 Bean 都找出来,打包成 List 注入进来。这样工厂不用手动注册,新增一个实现类时 Spring 自动把它加进 List,工厂的构造函数自动把它注册到 Map 里,实现零配置扩展。”


Q5:如果 ChatService 有 10 个实现类,但我想在工厂里按”模型厂商”分组(比如 OpenAI 系、阿里系),怎么办?

答: 这是策略模式 + 工厂模式的进阶用法,可以给策略接口加一个”分组”方法。

方案 1:在 AISourceEnum 里加一个”厂商”字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public enum AISourceEnum {
CHAT_GPT_3_5("OpenAI"),
GPT_4("OpenAI"),
DEEP_SEEK("DeepSeek"),
DOUBAO("ByteDance"),
ALI("Alibaba");

private final String vendor; // 厂商
AISourceEnum(String vendor) { this.vendor = vendor; }
public String getVendor() { return vendor; }
}

// 工厂可以按厂商分组
public class ChatServiceFactory {
private Map<String, List<ChatService>> vendorGroupedMap; // 按厂商分组

public List<ChatService> getByVendor(String vendor) {
return vendorGroupedMap.get(vendor);
}
}

方案 2:用多个工厂(工厂方法模式)

1
2
3
4
5
6
7
8
9
10
11
12
// 抽象工厂接口
public interface ChatServiceFactory {
ChatService getChatService(AISourceEnum type);
}

// OpenAI 工厂:只管 ChatGPT 系列
@Service
public class OpenAIFactory implements ChatServiceFactory { ... }

// 阿里工厂:只管通义千问
@Service
public class AlibabaFactory implements ChatServiceFactory { ... }

面试回答:

“如果想按厂商分组,可以在 AISourceEnum 里加一个 vendor 字段,工厂里维护一个 Map<String, List<ChatService>> 按厂商分组。或者升级成’工厂方法模式’:定义一个抽象工厂接口,每个厂商实现一个工厂类(OpenAI 工厂、阿里工厂),调用时根据厂商选工厂,再从工厂里拿具体的模型策略。”


第三层:WebSocket 与 STOMP 协议(网络编程题)

Q6:为什么用 WebSocket 而不是 HTTP 轮询?区别是什么?

答: HTTP 是**”问一次,答一次”,WebSocket 是“建立连接后,服务端可以主动推消息”**。

HTTP 轮询的问题(反面教材):

1
2
3
4
5
6
前端(每 1 秒问一次):"AI 回复了吗?"
后端:"还没好,继续等。"
前端(1 秒后又问):"AI 回复了吗?"
后端:"还没好..."
前端(再问):"AI 回复了吗?"
后端:"好了,给你。"

问题:

  1. 浪费资源:每 1 秒发一次 HTTP 请求,服务器要处理大量无效请求
  2. 延迟高:最多有 1 秒的延迟(轮询间隔)
  3. 服务器压力大:1000 个用户 = 每秒 1000 个轮询请求

WebSocket 的优势:

1
2
3
4
5
6
前端:"我要建立 WebSocket 连接"(HTTP Upgrade 请求)
后端:"好的,连接建立了(TCP 长连接)"
...(AI 在生成文本)
后端(主动推):"AI 生成了第 1 段文本"
后端(主动推):"AI 生成了第 2 段文本"
后端(主动推):"AI 生成完了(Stream END)"

优势:

  1. 服务端可以主动推消息(HTTP 做不到)
  2. 延迟极低(生成一段推一段,不用等全部完成)
  3. 节省资源(一条 TCP 连接搞定,不用每次 HTTP 请求)

面试回答:

“HTTP 是短连接,客户端问一次服务器答一次,要实现服务端推消息只能用轮询(每隔 N 秒问一次’有新消息吗’),延迟高且浪费资源。WebSocket 是长连接,建立连接后服务端可以主动推消息,延迟低(毫秒级),适合 LLM 流式输出场景。我们项目里,AI 每生成一段文本,后端就通过 WebSocket 推一段,前端收到一段渲染一段,不用等全部生成完。”


Q7:为什么用 STOMP 协议而不是直接用原生 WebSocket?

答: 原生 WebSocket 只有最基础的消息通道(发文本/二进制),没有消息路由、订阅、ACK 确认这些高级功能。STOMP 是在 WebSocket 之上的消息协议,相当于”给 WebSocket 加上消息队列的能力”。

原生 WebSocket 的问题:

1
2
3
4
5
6
7
8
9
// 原生 WebSocket:只有 onopen / onmessage / onclose / onerror
const ws = new WebSocket("ws://localhost:8080/gpt/123/ChatGPT");

ws.onmessage = (event) => {
// 所有消息都到这里,要自己解析"这是哪类消息"
if (event.data.startsWith("NOTIFY:")) { ... }
else if (event.data.startsWith("CHAT:")) { ... }
// 很麻烦!
};

STOMP 的优势:

1
2
3
4
5
6
7
8
9
10
11
12
13
// STOMP:有"订阅"概念,不同目的地(destination)的消息分开处理
const stompClient = new StompJs.Client({ brokerURL: "ws://localhost:8080/gpt/123/ChatGPT" });

// 订阅 AI 回复频道(destination = /user/chat/rsp)
stompClient.subscribe("/user/chat/rsp", (message) => {
// 这里只收到 AI 回复消息,不用自己判断类型
appendText(message.body);
});

// 订阅通知频道(destination = /user/notify)
stompClient.subscribe("/user/notify", (message) => {
showNotification(message.body);
});

STOMP 的核心概念(类比 RabbitMQ):

STOMP 概念 类比 RabbitMQ 作用
destination routing key + queue 消息的目的地(订阅哪个频道)
subscribe consumer 订阅队列 客户端订阅某个 destination
send producer 发消息 客户端/服务端向某个 destination 发消息
ack basicAck 确认收到消息

面试回答:

“原生 WebSocket 只有基础的消息收发,没有消息路由、订阅、ACK 这些高级功能,所有消息都走同一个 onmessage 回调,要自己解析消息类型。STOMP 是在 WebSocket 之上的消息协议,支持 destination(消息路由)、subscribe(订阅)、ACK 确认等高级功能,代码更清晰。我们项目里,前端订阅 /user/chat/rsp 接收 AI 回复,后端通过 SimpMessagingTemplate.convertAndSendToUser() 推消息到这个 destination。”


Q8:项目里 WebSocket 是用哪个框架实现的?STOMP 的配置在哪里?

答: 用的是 Spring WebSocket(spring-websocket) 的 STOMP 支持,配置在 WsChatConfig.java

核心配置WsChatConfig.java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Configuration
@EnableWebSocketMessageBroker // 开启 STOMP over WebSocket
public class WsChatConfig implements WebSocketMessageBrokerConfigurer {

// 1. 配置消息代理(Broker):前端订阅的 destination 前缀
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// /chat 开头的 destination → 走内存消息代理(给 AI 回复用)
// /msg 开头的 destination → 走内存消息代理(给通知推送用)
config.enableSimpleBroker("/chat", "/msg");

// 客户端发消息到哪个 destination(比如发送聊天消息)
// 前端发消息到 /app/chat/send → 后端 @MessageMapping("/chat/send") 处理
config.setApplicationDestinationPrefixes("/app");
}

// 2. 注册 WebSocket 端点(前端连接的 URL)
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 前端连接的 URL:ws://localhost:8080/gpt/{userId}/{aiType}
registry.addEndpoint("/gpt/{userId}/{aiType}")
.setAllowedOriginPatterns("*") // 允许跨域
.setHandshakeHandler(new AuthHandshakeHandler()); // 握手时做认证
}
}

前端连接的 URL:

1
2
3
4
5
// ws://localhost:8080/gpt/123/ChatGPT
// 123 = 用户 ID,ChatGPT = AI 类型
const stompClient = new StompJs.Client({
brokerURL: "ws://localhost:8080/gpt/123/ChatGPT"
});

面试回答:

“我们项目用 Spring WebSocket 的 STOMP 支持,配置在 WsChatConfig.java。核心配置有两部分:① configureMessageBroker 设置消息代理(/chat、/msg 开头的 destination 走内存代理);② registerStompEndpoints 注册 WebSocket 端点(前端连接的 URL 是 /gpt/{userId}/{aiType})。前端通过这个 URL 建立 STOMP 连接,然后订阅 /user/chat/rsp 接收 AI 回复。”


第四层:流式推送实现细节(LLM 集成题)

Q9:后端是怎么”流式”推送 LLM 返回的文本的?SSE 和 WebSocket 有什么区别?

答: 后端用 SimpMessagingTemplate.convertAndSendToUser() 逐段推送,前端收到一段渲染一段。

流式推送的原理(以 ChatGPT 为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 前端发送问题:"你好"

2. 后端调用 ChatGPT API(stream=true

3. ChatGPT 返回:
{"choices":[{"delta":{"content":"你"}}]}
{"choices":[{"delta":{"content":"好"}}]}
{"choices":[{"delta":{"content":"!"}}]}

4. 后端每收到一个 chunk,就通过 WebSocket 推给前端
stompClient.send("/app/chat", "你"); ← 第 1 段
stompClient.send("/app/chat", "好"); ← 第 2 段
stompClient.send("/app/chat", "!"); ← 第 3 段

5. 前端收到一段渲染一段(不用等全部完成)

项目里的实际代码ChatGptAiServiceImpl.java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// AI 返回的流式监听器
AbstractStreamListener listener = new AbstractStreamListener() {
@Override
public void onMsg(String message) {
if (StringUtils.isNotBlank(message)) {
// 把 AI 返回的这段文本,追加到答案里
item.appendAnswer(message);

// ✅ 关键:通过 consumer 回调,触发 WebSocket 推送
// AiChatStatEnum.MID = "中间状态",告诉前端"还在生成中"
consumer.accept(AiChatStatEnum.MID, chatRes);
}
}
};

// 调用 ChatGPT API(stream=true)
chatGptIntegration.streamReturn(user, chatRes.getRecords(), listener);

SSE(Server-Sent Events)和 WebSocket 的区别:

SSE WebSocket
协议 HTTP(长连接) 独立的 WS 协议(HTTP Upgrade)
方向 服务端 → 客户端(单向) 双向(客户端和服务端都能发消息)
浏览器支持 原生支持(EventSource API) 需要 new WebSocket()
适用场景 服务端单向推消息(比如股价推送) 双向通信(聊天、游戏)

面试回答:

“后端流式推送的实现:调用 LLM API 时开启 stream=true,LLM 会逐段返回文本(SSE 格式),我们在回调里每收到一段就通过 SimpMessagingTemplate.convertAndSendToUser() 推给前端,前端收到一段渲染一段,不用等全部生成完。SSE 和 WebSocket 的区别是:SSE 是 HTTP 长连接,只能服务端→客户端单向推;WebSocket 是独立的协议,支持双向通信。聊天场景用 WebSocket 更合适,因为前端也要发消息给后端。”


Q10:SimpMessagingTemplate 是怎么用的?convertAndSendToUserconvertAndSend 有什么区别?

答: SimpMessagingTemplate 是 Spring 提供的消息推送工具类,类似 RabbitMQ 的 RabbitTemplate

convertAndSend vs convertAndSendToUser

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Autowired
private SimpMessagingTemplate messagingTemplate;

// 方案 1:推送给所有订阅了 /chat/rsp 的客户端(广播)
messagingTemplate.convertAndSend("/chat/rsp", message);
// 所有在线用户都能收到(比如"系统通知")

// 方案 2:推送给指定用户(点对点)
// ✅ 项目里用的是这个!
messagingTemplate.convertAndSendToUser(
"123", // 用户 ID(Principal.name)
"/chat/rsp", // destination(会自动加 /user/ 前缀)
message
);
// 只有用户 123 能收到,其他用户收不到

convertAndSendToUser 的魔法:

1
2
3
4
5
6
7
后端调用:messagingTemplate.convertAndSendToUser("123", "/chat/rsp", msg)

Spring 自动转换成 destination:/user/123/chat/rsp

只有订阅了 /user/123/chat/rsp 的会话才能收到

用户 123 的 WebSocket 连接收到了消息

项目里的实际代码WsAnswerHelper.java,推测):

1
2
3
4
5
6
7
8
// 推送 AI 回复给指定用户
public void response(Long userId, ChatRecordsVo chatRes) {
simpMessagingTemplate.convertAndSendToUser(
String.valueOf(userId),
"/chat/rsp", // 前端订阅的 destination
chatRes
);
}

面试回答:

SimpMessagingTemplate 是 Spring 的消息推送工具。convertAndSend(destination, msg) 是广播(所有订阅了这个 destination 的客户端都能收到);convertAndSendToUser(userId, destination, msg) 是点对点推送(只有指定用户能收到)。我们项目里用的是后者,因为 AI 回复只能给提问的那个人看。convertAndSendToUser 会自动把 destination 转换成 /user/{userId}/chat/rsp,只有这个用户的 WebSocket 连接能收到。”


Q11:如果 WebSocket 连接断了(比如网络不好),前端怎么重连?

答: STOMP 客户端(比如 stompjs)支持自动重连机制

stompjs 的重连配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const stompClient = new StompJs.Client({
brokerURL: "ws://localhost:8080/gpt/123/ChatGPT",

// ✅ 关键:配置重连调度器
reconnectDelay: 5000, // 断线后 5 秒自动重连
heartbeatIncoming: 4000, // 心跳:每 4 秒检查一次连接是否存活
heartbeatOutgoing: 4000,

// 连接成功回调
onConnect: (frame) => {
console.log("WebSocket 连接成功");
// 重新订阅 AI 回复频道
stompClient.subscribe("/user/chat/rsp", (msg) => { ... });
},

// 连接断开回调
onStompError: (frame) => {
console.error("WebSocket 连接断开", frame);
// stompjs 会自动重连(如果配置了 reconnectDelay)
}
});

后端也要处理连接断开:

1
2
3
4
5
6
7
// Spring WebSocket 提供了 SessionDisconnectEvent 事件
@EventListener
public void handleWebSocketDisconnect(SessionDisconnectEvent event) {
String userId = event.getUser().getName();
log.info("用户 {} 的 WebSocket 连接断开", userId);
// 可以做一些清理工作:比如标记"该用户的 AI 对话状态为中断"
}

面试回答:

“WebSocket 断线重连在前端做。我们用的 stompjs 客户端支持自动重连,配置 reconnectDelay=5000(断线后 5 秒重连)就行。同时配置 heartbeatIncoming/Outgoing(心跳检测),每 4 秒检查一次连接是否存活。后端可以监听 SessionDisconnectEvent 事件,在用户断线时做一些清理工作(比如取消正在进行的 AI 生成任务)。”


第五层:LLM 集成与流式处理(AI 集成题)

Q12:项目里是怎么调用大模型 API 的?用的是什么 HTTP 客户端?

答: 从代码看,项目里用的是 com.plexpt.chatgpt 这个第三方库(ChatGPT 的 Java SDK)。

调用 LLM API 的两种方式:

方式 1:直接用 OkHttp / HttpClient 调用 HTTP API(底层)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
OkHttpClient client = new OkHttpClient();

// 构造请求体(JSON)
String requestBody = """
{
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "你好"}],
"stream": true
}
""";

// 发送 HTTP POST 请求
Request request = new Request.Builder()
.url("https://api.openai.com/v1/chat/completions")
.post(RequestBody.create(requestBody, MediaType.parse("application/json")))
.build();

// ✅ 关键:用 EventSource(SSE 客户端)接收流式返回
EventSource eventSource = new EventSource.Builder(chatGptListener, request)
.build();
eventSource.start(); // 开始接收流式返回

方式 2:用封装好的 SDK(项目里的方式)

1
2
3
4
5
6
7
8
9
// ChatGptIntegration.java(推测)
public class ChatGptIntegration {
private final ChatGPTClient client;

public void streamReturn(Long user, List<ChatItemVo> messages, AbstractStreamListener listener) {
// 封装好的 SDK,直接调用
client.streamChat(user, messages, listener);
}
}

为什么用 SDK 而不是直接 HTTP 调用?

  1. 省代码:不用自己构造 HTTP 请求、处理 SSE 格式
  2. 错误处理更完善:SDK 帮你处理了重试、超时、错误码
  3. 维护方便:API 升级时只要升级 SDK,不用改业务代码

面试回答:

“我们项目里用的是 com.plexpt.chatgpt 这个第三方 SDK(ChatGPT 的 Java 封装),底层是 OkHttp + EventSource(SSE 客户端)。调用时开启 stream=true,SDK 会逐段回调 AbstractStreamListener.onMsg(message),我们在回调里把每段文本通过 WebSocket 推给前端。如果不用 SDK,也可以用 OkHttp 直接调 OpenAI 的 HTTP API,但要自己处理 SSE 格式解析,比较麻烦。”


Q13:如果调用 LLM API 超时了(比如网络不好),项目里怎么处理的?

答: 从代码看,AbstractStreamListeneronError 回调,专门处理 API 调用失败的场景。

项目里的错误处理ChatGptAiServiceImpl.java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
AbstractStreamListener listener = new AbstractStreamListener() {
@Override
public void onMsg(String message) {
// 成功返回文本的回调
item.appendAnswer(message);
consumer.accept(AiChatStatEnum.MID, chatRes);
}

@Override
public void onError(Throwable throwable, String response) {
// ✅ 关键:API 调用失败时,给前端返回错误信息
item.appendAnswer("Error:" + (StringUtils.isBlank(response)
? throwable.getMessage() : response))
.setAnswerType(ChatAnswerTypeEnum.STREAM_END);
consumer.accept(AiChatStatEnum.ERROR, chatRes);
}

@Override
public void onClosed(EventSource eventSource) {
// 连接关闭的回调(正常结束 or 异常结束)
if (item.getAnswerType() != ChatAnswerTypeEnum.STREAM_END) {
// 如果 AI 没返回完就断连了 → 主动结束对话
item.appendAnswer("大模型超时未返回结果,主动关闭会话;请重新提问吧\n")
.setAnswerType(ChatAnswerTypeEnum.STREAM_END);
consumer.accept(AiChatStatEnum.ERROR, chatRes);
}
}
};

API 超时重试策略:

1
2
3
4
5
6
7
8
9
10
11
12
// 方案 1:OkHttp 的自动重试(底层)
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS) // 连接超时 10 秒
.readTimeout(30, TimeUnit.SECONDS) // 读取超时 30 秒
.retryOnConnectionFailure(true) // 自动重试连接失败
.build();

// 方案 2:业务层重试(比如重试 3 次)
@Retryable(value = RuntimeException.class, maxAttempts = 3)
public ChatRecordsVo chatWithRetry(Long user, String question) {
return chatGptIntegration.streamReturn(user, question, listener);
}

面试回答:

“我们项目里,AbstractStreamListeneronError 回调,API 调用失败时会在答案后面追加错误信息(Error: xxx),然后通过 WebSocket 推给前端。onClosed 回调里会判断是不是正常结束(AI 返回完了),如果不是,会主动结束对话并提示用户’重新提问’。底层用的 OkHttp 可以配置连接超时和读取超时,也会自动重试连接失败。”


第六层:开放设计题(架构设计)

Q14:如果让你设计一个”支持 10 个大模型”的聊天系统,你会怎么设计?

答: 这是策略模式的最佳实践场景,项目里的设计已经很不错了,但可以再优化。

V1(项目当前设计):策略模式 + 注册式工厂

1
2
3
4
5
6
7
ChatService(策略接口)
├── ChatGptAiServiceImpl
├── DeepSeekChatServiceImpl
└── ...

ChatServiceFactory(注册式工厂)
└── getChatService(AISourceEnum) → 返回对应实现

优点: 新增模型零修改(只要加实现类)
缺点: 所有策略都在同一个 JVM 里,如果某个模型的 SDK 很重(比如要加载 1GB 的模型文件),会影响其他策略

V2(优化:策略模式 + 插件化)

1
2
3
4
5
6
7
8
9
10
11
12
核心服务(不依赖具体 SDK)
├── ChatService 接口
└── ChatServiceFactory

插件服务(独立 JVM 进程,按需启动)
├── chatgpt-plugin(只依赖 ChatGPT SDK)
├── deepseek-plugin(只依赖 DeepSeek SDK)
└── ...

核心服务通过 HTTP / gRPC 调用插件服务
→ 某个插件的 SDK 崩溃了,不影响核心服务
→ 可以独立扩缩容(ChatGPT 用的人多,就多部署几个 chatgpt-plugin

V3(终极:支持动态添加模型,不用重启服务)

1
2
3
4
把策略实现类做成 Groovy 脚本 / Java 动态加载
→ 新增模型时,上传一个 Groovy 脚本到服务器
→ 核心服务动态加载这个脚本,注册到工厂
→ 不用重启服务!

面试回答模板:

“如果要支持 10 个大模型,我会用策略模式 + 注册式工厂(项目里已经实现了)。进一步优化可以考虑插件化:把每个模型的 SDK 封装成独立进程(微服务),核心服务通过 gRPC 调用插件服务,这样某个模型的 SDK 崩溃了不会影响其他模型。如果要求动态添加模型(不用重启服务),可以把策略实现做成 Groovy 脚本,核心服务动态加载。”


Q15(附加):项目里 LLM 的 API Key 是怎么管理的?会不会泄露?

答: 从代码看,API Key 应该配置在 application-ai.yml 里,用 Spring 的 @Value@ConfigurationProperties 注入。

API Key 管理的三种方案:

方案 1:配置文件(简单,但不安全)

1
2
3
# application-ai.yml
chatgpt:
api-key: "sk-xxxxxxxxxxxxxxxx"

问题: 配置文件如果提交到 Git,API Key 就泄露了。

方案 2:环境变量(推荐)

1
2
# 启动项目时传入环境变量
java -jar app.jar -Dchatgpt.api-key=${CHATGPT_API_KEY}

或者:

1
2
3
# application-ai.yml
chatgpt:
api-key: ${CHATGPT_API_KEY} # 从环境变量读取

方案 3:配置中心(生产环境推荐)

1
2
3
Spring Cloud Config / Nacos / Apollo
→ API Key 存在配置中心,不落在本地配置文件
→ 可以动态刷新(不用重启服务)

面试回答:

“我们项目里 API Key 配置在 application-ai.yml 里,通过 @ConfigurationProperties 注入到 ChatGptIntegration 类。生产环境会用环境变量或配置中心(Nacos / Apollo)管理 API Key,避免提交到 Git 导致泄露。也可以在网关层统一做 API Key 的加密解密,进一步保证安全。”


总结:简历上这句话的面试回答模板

面试官:”你的简历上写了’策略模式 + 工厂模式实现 LLM 调用链路统一抽象,WebSocket 推送 Stream 流实现毫秒级响应’,你能详细讲一下吗?”

回答模板(背下来!):

“好的。我们项目要接入多个大模型(ChatGPT、DeepSeek、豆包等),如果写 if-else 判断类型再调用,新增一个模型就要改主流程代码,违反开闭原则。

策略模式:我们定义了 ChatService 接口(策略接口),每个大模型写一个实现类(具体策略),比如 ChatGptAiServiceImplDeepSeekChatServiceImpl。每个实现类通过 source() 方法告诉工厂’我是哪个模型’。

工厂模式:我们用的是’注册式工厂’(ChatServiceFactory)。Spring 启动时自动注入所有 ChatService 的实现类(通过 List<ChatService> 构造函数注入),工厂在构造函数里把每个策略注册到 Map 里(key = AISourceEnum,value = 策略实现)。调用时只要 factory.getChatService(AISourceEnum.CHAT_GPT_3_5) 就能拿到对应的实现类,不用 if-else。新增模型只要加一个实现类,不动任何旧代码(热插拔)。

WebSocket 流式推送:前端通过 STOMP over WebSocket 建立长连接(URL = /gpt/{userId}/{aiType}),订阅 /user/chat/rsp 接收 AI 回复。后端调用 LLM API 时开启 stream=true,LLM 逐段返回文本,我们在 AbstractStreamListener.onMsg() 回调里把每段文本通过 SimpMessagingTemplate.convertAndSendToUser() 推给前端。前端收到一段渲染一段,不用等全部生成完,实现毫秒级响应体验。”


下一篇预告: FastExcel + 线程池 + CountDownLatch 并发导出,百万级数据导出性能优化 🚀


技术派面试拷打②:策略模式 + 工厂模式 + WebSocket 流式推送
https://whyalwaysme.lol/2026/06/08/2026-06-08-devlink-strategy-websocket-qa/
作者
Cassiur
发布于
2026年6月8日
许可协议