最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

SpringBoot集成Open WebUI實(shí)現(xiàn)AI流式對話

 更新時(shí)間:2026年05月15日 08:38:11   作者:北風(fēng)朝向  
本文介紹如何在 Spring Boot 項(xiàng)目中集成 Open WebUI,通過自動(dòng)管理用戶 Token、調(diào)用 OpenAI Java SDK,實(shí)現(xiàn) SSE 流式輸出,并與前端文本輸入框無縫對接,為業(yè)務(wù)系統(tǒng)注入 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 有效的條件:

  1. accessToken 不為空
  2. expireAt 不為空
  3. 當(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";
}

字段說明:

字段類型必填說明
codeString場景標(biāo)識(shí),關(guān)聯(lián)系統(tǒng)提示詞,默認(rèn) default
promptString用戶輸入的問題
modelString指定 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è)功能的核心,主要完成以下步驟:

  1. 加載當(dāng)前場景的系統(tǒng)提示詞
  2. 獲取當(dāng)前用戶的 Open WebUI 憑證和有效 Token
  3. 使用 OpenAI Java SDK 構(gòu)建流式請求
  4. 通過 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 格式,瀏覽器 EventSource API 及 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_timeoutproxy_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)文章

  • java ZXing生成二維碼及條碼實(shí)例分享

    java ZXing生成二維碼及條碼實(shí)例分享

    本文分享了java ZXing生成二維碼及條碼的實(shí)例代碼,具有很好的參考價(jià)值,需要的朋友一起來看下吧
    2016-12-12
  • 使用VisualVM分析日志

    使用VisualVM分析日志

    文章強(qiáng)調(diào)程序員需掌握多種工具(如JMeter、ELK、Prometheus等)應(yīng)對工作挑戰(zhàn),避免線上事故,重點(diǎn)介紹VisualVM作為Java故障排查工具,通過分析大對象和GC監(jiān)控,提升代碼質(zhì)量與問題定位效率
    2025-07-07
  • Java定義形式及可變參數(shù)實(shí)例解析

    Java定義形式及可變參數(shù)實(shí)例解析

    這篇文章主要介紹了Java定義形式及可變參數(shù)實(shí)例解析,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下
    2019-12-12
  • Spring是怎么擴(kuò)展解析xml接口的

    Spring是怎么擴(kuò)展解析xml接口的

    這篇文章主要介紹了Spring是怎么擴(kuò)展解析xml接口的,文章圍繞主題展開詳細(xì)的內(nèi)容介紹,具有一定的參考價(jià)值,需要的小伙伴可以參考一下
    2022-08-08
  • SpringBoot實(shí)現(xiàn)熱部署詳解

    SpringBoot實(shí)現(xiàn)熱部署詳解

    SpringBoot熱部署是一種開發(fā)時(shí)極為有用的功能,它能夠讓開發(fā)人員在代碼修改后無需手動(dòng)重啟應(yīng)用程序就能立即看到變化的效果,所以我本文就給打擊介紹一下為什么要使用熱部署以及實(shí)現(xiàn)熱部署的方式,需要的朋友可以參考下
    2023-07-07
  • Mybatis結(jié)果集映射一對多簡單入門教程

    Mybatis結(jié)果集映射一對多簡單入門教程

    本文給大家介紹Mybatis結(jié)果集映射一對多簡單入門教程,包括搭建數(shù)據(jù)庫環(huán)境的過程,idea搭建maven項(xiàng)目的代碼詳解,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),需要的朋友參考下吧
    2021-06-06
  • 如何解決SpringBoot集成百度UEditor圖片上傳后直接訪問404

    如何解決SpringBoot集成百度UEditor圖片上傳后直接訪問404

    在本篇文章里小編給大家整理的是一篇關(guān)于如何解決SpringBoot集成百度UEditor圖片上傳后直接訪問404相關(guān)文章,需要的朋友們學(xué)習(xí)下。
    2019-11-11
  • 搭建Java開發(fā)環(huán)境JDK、IDE的詳細(xì)圖文教程

    搭建Java開發(fā)環(huán)境JDK、IDE的詳細(xì)圖文教程

    本文詳細(xì)介紹了如何搭建Java開發(fā)環(huán)境,包括下載JDK、配置環(huán)境變量、安裝IDE以及編寫和運(yùn)行第一個(gè)Java程序,本文通過圖文并茂的形式給大家介紹的非常詳細(xì),感興趣的朋友跟隨小編一起看看吧
    2025-11-11
  • Java生成讀取條形碼和二維碼的簡單示例

    Java生成讀取條形碼和二維碼的簡單示例

    條形碼(barcode)是將寬度不等的多個(gè)黑條和空白,按照一定的規(guī)則排列,用來表示一組信息的圖形標(biāo)識(shí)符,而二維碼大家應(yīng)該都很熟悉了,這篇文章主要給大家介紹了關(guān)于Java生成讀取條形碼和二維碼的相關(guān)資料,需要的朋友可以參考下
    2021-07-07
  • 詳解Java中clone的寫法

    詳解Java中clone的寫法

    這篇文章主要介紹了Java中clone的寫法,非常不錯(cuò),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下
    2018-07-07

最新評論

郴州市| 阿拉尔市| 苗栗市| 西华县| 南漳县| 阳高县| 灵丘县| 大安市| 溧水县| 卢氏县| 大埔区| 衢州市| 石阡县| 衡水市| 德清县| 安图县| 余干县| 肥东县| 绥棱县| 阿城市| 柯坪县| 溧阳市| 潞城市| 若羌县| 无为县| 红桥区| 赣榆县| 昌平区| 景泰县| 涞水县| 嘉鱼县| 镇宁| 昆明市| 陵水| 麻江县| 梁河县| 黄龙县| 商城县| 龙井市| 屯门区| 公安县|