SpringBoot集成Open WebUI實(shí)現(xiàn)AI流式對話
背景與架構(gòu)概覽
在企業(yè) CRM 系統(tǒng)中,我們希望為業(yè)務(wù)人員提供一個(gè)內(nèi)嵌的 AI 助手,讓用戶能直接在系統(tǒng)內(nèi)輸入問題、實(shí)時(shí)獲取 AI 回答,而無需跳轉(zhuǎn)到外部 AI 平臺(tái)。
整體方案選型:
| 組件 | 說明 |
|---|---|
| Open WebUI | 開源 LLM 前端平臺(tái),提供 OpenAI 兼容接口,支持多模型管理 |
| openai-java SDK | 官方 Java 客戶端,直接對接 OpenAI 兼容 API |
| Spring WebFlux | 響應(yīng)式編程,支持 SSE(Server-Sent Events)流式推送 |
| Redis | 緩存 Token,避免每次請求都重新登錄 Open WebUI |
整體請求鏈路:
前端輸入框 → POST /v1/chat/completions
→ LLMController(SSE 接口)
→ LLMService(獲取 Token + 構(gòu)造請求)
→ OpenAI Java SDK(流式調(diào)用 Open WebUI)
→ SSE 逐塊推送回前端
依賴與配置
Maven 依賴
<!-- OpenAI Java 官方 SDK -->
<dependency>
<groupId>com.openai</groupId>
<artifactId>openai-java</artifactId>
<version>2.5.0</version>
</dependency>
<!-- Spring WebFlux(SSE 支持) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Hutool(HTTP 工具 + JSON 解析) -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.x</version>
</dependency>
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>application.yml 配置
open-web-ui: base-url: http://your-openwebui-host/api # Open WebUI 的 OpenAI 兼容接口地址
Open WebUI 的 OpenAI 兼容接口通常為 http://host/api/v1,請根據(jù)實(shí)際部署調(diào)整。
Token 生命周期管理
設(shè)計(jì)思路
Open WebUI 使用 JWT Token 進(jìn)行鑒權(quán),Token 有有效期(默認(rèn)約數(shù)小時(shí))。若每次調(diào)用 AI 接口都重新登錄,不僅效率低下,還會(huì)對 Open WebUI 服務(wù)產(chǎn)生不必要的登錄壓力。
因此,我們設(shè)計(jì)了一套 “先查緩存 → 有效直接用 → 過期再刷新” 的 Token 自動(dòng)管理機(jī)制,并通過 Redis 實(shí)現(xiàn)跨實(shí)例共享。
TokenRepository 接口抽象
定義標(biāo)準(zhǔn)的增刪查接口,便于后續(xù)替換為其他存儲(chǔ)介質(zhì)(如內(nèi)存 Map、數(shù)據(jù)庫等)。
public interface TokenRepository {
OpenWebUIToken get(String key);
void save(String key, OpenWebUIToken value);
void delete(String key);
}
設(shè)計(jì)亮點(diǎn):通過接口隔離存儲(chǔ)實(shí)現(xiàn),TokenManager 不依賴具體存儲(chǔ)技術(shù),方便單元測試和替換。
Redis 存儲(chǔ)實(shí)現(xiàn)
public class RedisTokenRepository implements TokenRepository {
private String prefix = "openwebui:";
private final RedisTemplate<Object, Object> redisTemplate;
public RedisTokenRepository(RedisTemplate<Object, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public RedisTokenRepository(String prefix, RedisTemplate<Object, Object> redisTemplate) {
this.prefix = prefix;
this.redisTemplate = redisTemplate;
}
@Override
public OpenWebUIToken get(String key) {
return BeanUtil.copyProperties(
redisTemplate.opsForValue().get(prefix + key),
OpenWebUIToken.class
);
}
@Override
public void save(String key, OpenWebUIToken value) {
redisTemplate.opsForValue().set(prefix + key, value);
}
@Override
public void delete(String key) {
redisTemplate.delete(prefix + key);
}
}
關(guān)鍵點(diǎn)說明:
- Key 格式為
openwebui:{username},通過前綴做命名空間隔離,避免與其他 Redis Key 沖突。 - 使用
BeanUtil.copyProperties從 Redis 返回的LinkedHashMap反序列化為強(qiáng)類型對象,避免手動(dòng)類型轉(zhuǎn)換。
OpenWebUIToken 數(shù)據(jù)模型
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class OpenWebUIToken implements Serializable {
@Schema(description = "認(rèn)證token")
private String accessToken;
@Schema(description = "token過期時(shí)間(毫秒時(shí)間戳)")
private Long expireAt;
@Schema(description = "用戶名")
private String username;
@Schema(description = "密碼")
private String password;
/**
* 判斷 Token 是否仍然有效
*/
public boolean isValid() {
return StringUtils.isNotBlank(accessToken)
&& Objects.nonNull(expireAt)
&& TimeUtil.getLocalDateTime(expireAt).isAfter(LocalDateTime.now());
}
}
isValid() 方法封裝了有效性判斷,Token 有效的條件:
accessToken不為空expireAt不為空- 當(dāng)前時(shí)間在過期時(shí)間之前
OpenWebUITokenManager 核心管理器
這是整個(gè) Token 管理的核心組件,負(fù)責(zé):
- 優(yōu)先從緩存獲取有效 Token
- Token 失效時(shí),發(fā)起 HTTP 請求重新登錄 Open WebUI 并刷新緩存
- 使用
@Synchronized防止并發(fā)場景下的重復(fù)登錄(雙檢鎖模式)
@Slf4j
public class OpenWebUITokenManager {
/** Token 默認(rèn)有效期(秒),登錄接口無 expires_at 時(shí)使用 */
private static final Long TOKEN_EXPIRE_TIME = 60 * 60L;
private final String signInUrl;
private final TokenRepository tokenRepository;
public OpenWebUITokenManager(String signInUrl, TokenRepository tokenRepository) {
this.signInUrl = signInUrl;
this.tokenRepository = tokenRepository;
}
/**
* 獲取有效 Token(優(yōu)先緩存,緩存失效則重新登錄)
*/
public String getValidToken(String username, String password) {
if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) {
log.warn("用戶名或密碼為空");
return null;
}
// 1. 查詢緩存
OpenWebUIToken cachedToken = tokenRepository.get(username);
// 2. 緩存有效直接返回
if (cachedToken != null && cachedToken.isValid()) {
log.debug("使用緩存 Token: {}", username);
return cachedToken.getAccessToken();
}
// 3. 緩存失效,重新登錄
log.info("Token 過期或不存在,重新登錄: {}", username);
return refreshToken(username, password);
}
public String getValidToken(Credential credential) {
if (Objects.isNull(credential)) return null;
return getValidToken(credential.getUsername(), credential.getPassword());
}
/**
* 刷新 Token(加鎖防并發(fā),內(nèi)部二次檢查)
*/
@Synchronized
public String refreshToken(String username, String password) {
// 二次檢查:加鎖后再次確認(rèn)緩存是否已被其他線程刷新
OpenWebUIToken cachedToken = tokenRepository.get(username);
if (cachedToken != null && cachedToken.isValid()) {
return cachedToken.getAccessToken();
}
try {
// 調(diào)用 Open WebUI 登錄接口
HttpResponse response = HttpUtil.createPost(signInUrl)
.header("Content-Type", "application/json")
.body(JSONUtil.toJsonStr(Map.of("email", username, "password", password)))
.execute();
if (!response.isOk() || !StringUtils.hasText(response.body())) {
throw new RuntimeException("登錄失敗: " + response.getStatus());
}
// 解析響應(yīng)
JSONObject responseData = JSONUtil.parseObj(response.body());
String token = responseData.getStr("token");
// 計(jì)算過期時(shí)間:優(yōu)先使用接口返回值,否則使用默認(rèn)值
Long expiresTime = TimeUtil.getEpochMilli(LocalDateTime.now().plusSeconds(TOKEN_EXPIRE_TIME));
String expiresAt = responseData.get("expires_at").toString();
if (StringUtils.hasText(expiresAt)) {
expiresTime = Long.parseLong(expiresAt) * 1000; // 秒 → 毫秒
}
// 構(gòu)建并緩存 Token
OpenWebUIToken webUIToken = OpenWebUIToken.builder()
.accessToken(token)
.username(username)
.password(password)
.expireAt(expiresTime)
.build();
tokenRepository.save(username, webUIToken);
log.info("Token 刷新成功: {}, 過期時(shí)間: {}", username, expiresTime);
return token;
} catch (Exception e) {
log.error("登錄獲取 Token 失敗: {}", username, e);
throw new RuntimeException("Open WebUI 登錄失敗", e);
}
}
}
并發(fā)安全分析:
線程 A: getValidToken → 緩存失效 → refreshToken(加鎖) 線程 B: getValidToken → 緩存失效 → refreshToken(等待鎖) 線程 A: 登錄成功,緩存 Token,釋放鎖 線程 B: 獲取到鎖 → 二次檢查緩存 → 發(fā)現(xiàn) Token 有效 → 直接返回
通過二次檢查(Double-Check),避免了多個(gè)線程同時(shí)發(fā)起重復(fù)登錄請求。
憑證獲取
系統(tǒng)用戶與 Open WebUI 賬號存在映射關(guān)系。CredentialProvider 負(fù)責(zé)根據(jù)當(dāng)前登錄用戶 ID 查詢其對應(yīng)的 Open WebUI 賬號和密碼。
@Slf4j
@Component
public class CredentialProvider {
@Resource
private SysUserService sysUserService;
/**
* 根據(jù)用戶 ID 獲取 Open WebUI 登錄憑證
*/
public Credential getCredential(String userId) {
SysUser sysUser = sysUserService.getById(userId);
if (Objects.isNull(sysUser)) {
throw new BusinessException(401, "用戶不存在");
}
// 用戶的 AI 郵箱(即 Open WebUI 賬號)
String aiEmail = sysUser.getAiEmail();
// 密碼規(guī)則:郵箱前綴 + 固定后綴
String password = aiEmail.split("@")[0] + "AI++2025&";
return new Credential(aiEmail, password);
}
}
設(shè)計(jì)說明:
- 系統(tǒng)在用戶表中存儲(chǔ)
aiEmail字段,與 Open WebUI 用戶一一對應(yīng)。 - 密碼采用固定規(guī)則生成,方便批量初始化 Open WebUI 用戶,同時(shí)保持一定的隨機(jī)性。
- 這種設(shè)計(jì)使得系統(tǒng)用戶與 AI 平臺(tái)賬號解耦,無需在系統(tǒng)中明文存儲(chǔ) AI 平臺(tái)密碼。
系統(tǒng)提示詞管理
系統(tǒng)提示詞(System Prompt)決定了 AI 的角色定位和回答風(fēng)格。為了方便維護(hù)多套提示詞,我們將其以文本文件的形式存放在 classpath 中,通過枚舉統(tǒng)一管理。
提示詞枚舉
@Getter
@AllArgsConstructor
public enum SystemPromptEnum {
DEFAULT("default", "默認(rèn)");
private final String code;
private final String message;
/**
* 根據(jù) code 獲取枚舉,未找到時(shí)返回 DEFAULT
*/
public static SystemPromptEnum of(String code) {
for (SystemPromptEnum value : values()) {
if (value.getCode().equals(code)) {
return value;
}
}
return DEFAULT;
}
}
提示詞加載器
@Slf4j
public class SystemPromptLoader {
private static final String SYSTEM_PROMPT_PATH = "prompt/";
private static final String SYSTEM_PROMPT_SUFFIX = "-system-prompt.txt";
private SystemPromptLoader() {}
/**
* 從 classpath 加載指定名稱的系統(tǒng)提示詞文件
* 文件路徑:resources/prompt/{promptName}-system-prompt.txt
*/
public static String loadSystemPrompt(String promptName) {
ClassPathResource resource = new ClassPathResource(
SYSTEM_PROMPT_PATH + promptName + SYSTEM_PROMPT_SUFFIX
);
log.info("加載系統(tǒng)提示: {}", resource.getPath());
try (InputStream in = resource.getInputStream()) {
return new String(in.readAllBytes(), Charset.defaultCharset());
} catch (Exception e) {
log.error("加載系統(tǒng)提示失敗: {}", promptName, e);
return "";
}
}
public static String loadSystemPrompt(SystemPromptEnum promptEnum) {
return loadSystemPrompt(promptEnum.getCode());
}
}
文件結(jié)構(gòu)示例:
src/main/resources/
└── prompt/
└── default-system-prompt.txt ← 默認(rèn)場景提示詞default-system-prompt.txt 內(nèi)容示例:
你是一名專業(yè)的企業(yè) CRM 智能助手,請根據(jù)用戶的問題給出準(zhǔn)確、簡潔的回答。 回答時(shí)請使用中文,保持專業(yè)、友好的語氣。
擴(kuò)展新場景只需新增枚舉值和對應(yīng)文本文件,無需修改業(yè)務(wù)代碼,符合開閉原則。
Spring Bean 配置
@Configuration
public class OpenWebUIConfig {
@Bean
public OpenWebUITokenManager openWebUITokenManager(
RedisTemplate<Object, Object> redisTemplate) {
return new OpenWebUITokenManager(
OpenWebUIConstant.LOGIN_URL, // Open WebUI 登錄接口地址
new RedisTokenRepository(redisTemplate) // Redis Token 存儲(chǔ)
);
}
}
OpenWebUIConstant.LOGIN_URL 參考值:
public class OpenWebUIConstant {
public static final String LOGIN_URL = "http://your-openwebui-host/api/v1/auths/signin";
}
請求與響應(yīng)模型
請求參數(shù)ChatRequest
@Data
public class ChatRequest {
@Schema(description = "場景標(biāo)識(shí)(用于加載特定場景提示詞),默認(rèn) default")
private String code = SystemPromptEnum.DEFAULT.getCode();
@NotEmpty(message = "請輸入文字...")
@Schema(description = "用戶輸入內(nèi)容")
private String prompt;
@Schema(description = "模型名稱,默認(rèn) Qwen3-VL-8B-Instruct")
private String model = "Qwen3-VL-8B-Instruct";
}
字段說明:
| 字段 | 類型 | 必填 | 說明 |
|---|---|---|---|
code | String | 否 | 場景標(biāo)識(shí),關(guān)聯(lián)系統(tǒng)提示詞,默認(rèn) default |
prompt | String | 是 | 用戶輸入的問題 |
model | String | 否 | 指定 Open WebUI 中部署的模型名稱 |
響應(yīng)數(shù)據(jù)ChatStreamingVo
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ChatStreamingVo {
@Schema(description = "本次推送的內(nèi)容片段")
private String content;
@Schema(description = "使用的模型名稱")
private String model;
}
每個(gè) SSE 事件攜帶一個(gè)內(nèi)容片段(content),前端拼接所有片段即可得到完整回答。
LLMService 流式對話核心實(shí)現(xiàn)
這是整個(gè)功能的核心,主要完成以下步驟:
- 加載當(dāng)前場景的系統(tǒng)提示詞
- 獲取當(dāng)前用戶的 Open WebUI 憑證和有效 Token
- 使用 OpenAI Java SDK 構(gòu)建流式請求
- 通過
Flux+ SSE 將響應(yīng)片段逐塊推送
@Slf4j
@Service
public class LLMService {
@Resource
private OpenWebUITokenManager openWebUITokenManager;
@Value("${open-web-ui.base-url}")
private String baseUrl;
@Resource
private CredentialProvider credentialProvider;
public Flux<ServerSentEvent<ChatStreamingVo>> chatStream(ChatRequest chatRequest) {
// 1. 加載系統(tǒng)提示詞
String systemPrompt = SystemPromptLoader.loadSystemPrompt(
SystemPromptEnum.of(chatRequest.getCode())
);
// 2. 獲取當(dāng)前登錄用戶的 Open WebUI Token
Credential credential = credentialProvider.getCredential(
SecurityUtils.getUser().getId()
);
String validToken = openWebUITokenManager.getValidToken(credential);
// 3. 構(gòu)建 OpenAI Java 客戶端(復(fù)用 Open WebUI 兼容接口)
OpenAIClient aiClient = OpenAIOkHttpClient.builder()
.baseUrl(baseUrl)
.apiKey(validToken) // 將 Open WebUI Token 作為 API Key
.build();
// 4. 構(gòu)建對話參數(shù)
ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
.model(chatRequest.getModel())
.addSystemMessage(systemPrompt) // 系統(tǒng)提示詞
.addUserMessage(chatRequest.getPrompt()) // 用戶輸入
.build();
// 5. 流式調(diào)用 + Flux 包裝 + SSE 封裝
return Flux.using(
// 創(chuàng)建流式響應(yīng)資源
() -> aiClient.chat().completions().createStreaming(params),
// 將 Stream<ChatCompletionChunk> 轉(zhuǎn)換為 Flux<ServerSentEvent>
streamResponse -> Flux.fromStream(streamResponse.stream())
.map(chunk -> {
// 提取當(dāng)前 chunk 中的文本內(nèi)容
String content = chunk.choices().stream()
.findFirst()
.flatMap(choice -> choice.delta().content())
.orElse("");
return ServerSentEvent.<ChatStreamingVo>builder()
.data(new ChatStreamingVo(content, chatRequest.getModel()))
.build();
}),
// 流結(jié)束/出錯(cuò)/取消 時(shí)關(guān)閉資源,防止連接泄漏
StreamResponse::close
)
// 切換到彈性線程池,避免阻塞事件循環(huán)線程
.subscribeOn(Schedulers.boundedElastic())
// 全局異常處理
.onErrorResume(e -> {
log.error("LLM 流式對話異常", e);
throw new BusinessException("LLM 流式對話異常");
});
}
}
關(guān)鍵技術(shù)點(diǎn)詳解
Flux.using的資源管理模式
Flux.using 是 Reactor 提供的資源管理操作符,它的三個(gè)參數(shù)分別對應(yīng):
Flux.using(
resourceSupplier, // 創(chuàng)建資源(流式響應(yīng)對象)
sourceSupplier, // 使用資源生產(chǎn)數(shù)據(jù)
resourceCleanup // 資源清理(無論成功/失敗/取消都會(huì)執(zhí)行)
)
這里使用它來確保 StreamResponse 對象(底層是一個(gè) HTTP 長連接)無論何種情況下都能被正確關(guān)閉,防止連接資源泄漏。
subscribeOn(Schedulers.boundedElastic())
OpenAI SDK 的流式調(diào)用是阻塞的 I/O 操作,而 Spring WebFlux 的事件循環(huán)線程(Netty NIO 線程)不允許被阻塞。通過 subscribeOn 將訂閱行為切換到 boundedElastic 線程池(專為阻塞 I/O 設(shè)計(jì)),避免阻塞主事件循環(huán)。
SSE 數(shù)據(jù)格式
ServerSentEvent 對象在 Spring WebFlux 中會(huì)被序列化為標(biāo)準(zhǔn)的 SSE 格式:
data: {"content":"你好","model":"Qwen3-VL-8B-Instruct"}
data: {"content":",有什么可以","model":"Qwen3-VL-8B-Instruct"}
data: {"content":"幫助您的?","model":"Qwen3-VL-8B-Instruct"}
LLMController 接口層
@Tag(name = "LLM 對話")
@RestController
@RequestMapping("/v1/chat")
public class LLMController {
@Resource
private LLMService llmService;
@Operation(summary = "LLM 流式對話")
@PreAuthorize("@knifeSecurity.authenticated()")
@PostMapping(value = "/completions", produces = "text/event-stream")
public Flux<ServerSentEvent<ChatStreamingVo>> chatStream(
@Valid @RequestBody ChatRequest chatRequest) {
return llmService.chatStream(chatRequest);
}
}
要點(diǎn):
produces = "text/event-stream":聲明接口返回 SSE 格式,瀏覽器EventSourceAPI 及 Fetch 流均可消費(fèi)。@PreAuthorize:接口鑒權(quán),確保只有登錄用戶才能訪問。- 返回
Flux<ServerSentEvent<...>>:Spring WebFlux 會(huì)自動(dòng)將其轉(zhuǎn)換為持續(xù)推送的 SSE 響應(yīng)。
前端對接思路
前端通過 Fetch API + ReadableStream 消費(fèi) SSE,實(shí)時(shí)渲染流式內(nèi)容:
async function sendChat(prompt) {
const response = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ prompt, code: 'default', model: 'Qwen3-VL-8B-Instruct' })
});
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let answer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解析 SSE 數(shù)據(jù)行
const text = decoder.decode(value);
const lines = text.split('\n').filter(line => line.startsWith('data:'));
for (const line of lines) {
const data = line.replace('data:', '').trim();
if (!data || data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
answer += parsed.content;
// 更新 UI 文本框
document.getElementById('answer').innerText = answer;
} catch (e) {
// 忽略非 JSON 行
}
}
}
}
Vue 3 + Element Plus 示例(輸入框 + 流式渲染):
<template>
<div class="ai-chat">
<el-input
v-model="prompt"
type="textarea"
:rows="3"
placeholder="輸入你的問題..."
@keydown.ctrl.enter="sendChat"
/>
<el-button type="primary" :loading="loading" @click="sendChat">發(fā)送</el-button>
<div class="answer" v-if="answer">
<pre>{{ answer }}</pre>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useUserStore } from '@/store/user'
const prompt = ref('')
const answer = ref('')
const loading = ref(false)
const userStore = useUserStore()
async function sendChat() {
if (!prompt.value.trim()) return
loading.value = true
answer.value = ''
const response = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userStore.token}`
},
body: JSON.stringify({ prompt: prompt.value })
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const lines = decoder.decode(value).split('\n')
for (const line of lines) {
if (!line.startsWith('data:')) continue
const json = line.slice(5).trim()
if (!json || json === '[DONE]') continue
answer.value += JSON.parse(json).content
}
}
} finally {
loading.value = false
}
}
</script>整體流程圖
┌────────────────────────────────────────────────────────────────────┐
│ 前端瀏覽器 │
│ 用戶輸入 prompt → Fetch POST /v1/chat/completions │
│ ← 逐塊接收 SSE 數(shù)據(jù) → 拼接渲染到文本框 │
└────────────────────────────────┬───────────────────────────────────┘
│ HTTP SSE
┌────────────────────────────────▼───────────────────────────────────┐
│ LLMController │
│ @PostMapping(produces = "text/event-stream") │
│ → 調(diào)用 LLMService.chatStream(chatRequest) │
└────────────────────────────────┬───────────────────────────────────┘
│
┌────────────────────────────────▼───────────────────────────────────┐
│ LLMService │
│ 1. SystemPromptLoader.loadSystemPrompt(code) 加載提示詞 │
│ 2. CredentialProvider.getCredential(userId) 獲取 AI 憑證 │
│ 3. TokenManager.getValidToken(credential) 獲取有效 Token │
│ 4. OpenAIOkHttpClient.build(baseUrl, token) 構(gòu)建 SDK 客戶端 │
│ 5. Flux.using(createStreaming, mapChunks, close) 流式調(diào)用 │
└────┬───────────────────────────────────────────┬───────────────────┘
│ Token 管理 │ AI 調(diào)用
┌────▼──────────────────────────┐ ┌─────────────▼──────────────────┐
│ OpenWebUITokenManager │ │ Open WebUI │
│ ┌──────────────────────────┐ │ │ POST /api/v1/auths/signin │
│ │ Redis 緩存查詢 │ │ │ POST /api/v1/chat/completions │
│ │ → 有效:直接返回 │ │ │ (OpenAI 兼容接口) │
│ │ → 失效:登錄刷新 + 緩存 │ │ │ │
│ └──────────────────────────┘ │ │ → 流式返回 ChatCompletionChunk │
└───────────────────────────────┘ └────────────────────────────────┘
設(shè)計(jì)總結(jié)與經(jīng)驗(yàn)
亮點(diǎn)設(shè)計(jì)
| 設(shè)計(jì)點(diǎn) | 說明 |
|---|---|
| Token 雙檢鎖 | @Synchronized + 內(nèi)部二次校驗(yàn),防止高并發(fā)下重復(fù)登錄 |
| 接口隔離存儲(chǔ) | TokenRepository 接口 + RedisTokenRepository 實(shí)現(xiàn),易替換、易測試 |
| 提示詞文件化 | 提示詞以 .txt 存放 classpath,通過枚舉管理多場景,無需改代碼 |
| 資源安全釋放 | Flux.using 三段式確保流式連接必然關(guān)閉,防止資源泄漏 |
| 線程模型正確 | subscribeOn(Schedulers.boundedElastic()) 避免阻塞 WebFlux 事件線程 |
| 用戶憑證映射 | 系統(tǒng)用戶與 AI 平臺(tái)賬號分離,憑證由 CredentialProvider 統(tǒng)一管理 |
注意事項(xiàng)
Token 有效期與 Redis TTL 保持一致:建議在 save 時(shí)同步設(shè)置 Redis Key 的過期時(shí)間,避免 Redis 中存放已過期 Token 占用內(nèi)存。
// 改進(jìn)建議:設(shè)置 Redis TTL long ttlSeconds = (expiresTime - System.currentTimeMillis()) / 1000; redisTemplate.opsForValue().set(prefix + key, value, ttlSeconds, TimeUnit.SECONDS);
Open WebUI 模型名稱與實(shí)際部署保持一致:ChatRequest.model 的默認(rèn)值 Qwen3-VL-8B-Instruct 需要與 Open WebUI 中實(shí)際加載的模型 ID 完全匹配,否則會(huì)返回 404。
系統(tǒng)提示詞文件編碼:建議統(tǒng)一使用 UTF-8 編碼保存 .txt 文件,避免中文亂碼。
SSE 連接超時(shí):生產(chǎn)環(huán)境中建議配置 Nginx 的 proxy_read_timeout 和 proxy_buffering off,確保 SSE 長連接不被中斷。
location /v1/chat/ {
proxy_pass http://backend;
proxy_buffering off;
proxy_read_timeout 120s;
proxy_set_header Cache-Control no-cache;
}AI 賬號批量初始化:在用戶表中添加 ai_email 字段后,需要在 Open WebUI 中預(yù)先創(chuàng)建對應(yīng)賬號,或通過 Open WebUI 管理接口批量創(chuàng)建。
小結(jié)
本文完整介紹了在企業(yè) Spring Boot 項(xiàng)目中集成 Open WebUI 的實(shí)踐方案:
- 通過 Redis 緩存 + 雙檢鎖 實(shí)現(xiàn) Token 的自動(dòng)管理與并發(fā)安全;
- 通過 枚舉 + classpath 文本文件 實(shí)現(xiàn)多場景系統(tǒng)提示詞的靈活管理;
- 通過 OpenAI Java SDK + Spring WebFlux + SSE 實(shí)現(xiàn)流式 AI 響應(yīng);
- 通過 Fetch ReadableStream 在前端實(shí)時(shí)渲染流式輸出。
這套架構(gòu)可以方便地?cái)U(kuò)展到更多 AI 場景:代碼審查、文案生成、智能客服等,只需新增場景枚舉和對應(yīng)提示詞文件即可快速接入。
以上就是SpringBoot集成Open WebUI實(shí)現(xiàn)AI流式對話的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot AI流式對話的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
如何解決SpringBoot集成百度UEditor圖片上傳后直接訪問404
在本篇文章里小編給大家整理的是一篇關(guān)于如何解決SpringBoot集成百度UEditor圖片上傳后直接訪問404相關(guān)文章,需要的朋友們學(xué)習(xí)下。2019-11-11
搭建Java開發(fā)環(huán)境JDK、IDE的詳細(xì)圖文教程
本文詳細(xì)介紹了如何搭建Java開發(fā)環(huán)境,包括下載JDK、配置環(huán)境變量、安裝IDE以及編寫和運(yùn)行第一個(gè)Java程序,本文通過圖文并茂的形式給大家介紹的非常詳細(xì),感興趣的朋友跟隨小編一起看看吧2025-11-11

