Spring Boot 集成 Open WebUI 实现 AI 流式对话

Spring Boot 集成 Open WebUI 实现 AI 流式对话

本文介绍如何在 Spring Boot 项目中集成 Open WebUI,通过自动管理用户 Token、调用 OpenAI Java SDK,实现 SSE 流式输出,并与前端文本输入框无缝对接,为业务系统注入 AI 能力。


目录

  1. 背景与架构概览
  2. 依赖与配置
  3. [Token 生命周期管理](#Token 生命周期管理)
    • [TokenRepository 接口抽象](#TokenRepository 接口抽象)
    • [Redis 存储实现](#Redis 存储实现)
    • [OpenWebUIToken 数据模型](#OpenWebUIToken 数据模型)
    • [OpenWebUITokenManager 核心管理器](#OpenWebUITokenManager 核心管理器)
  4. 凭证获取
  5. 系统提示词管理
  6. [Spring Bean 配置](#Spring Bean 配置)
  7. 请求与响应模型
  8. [LLMService 流式对话核心实现](#LLMService 流式对话核心实现)
  9. [LLMController 接口层](#LLMController 接口层)
  10. 前端对接思路
  11. 整体流程图
  12. 设计总结与经验

背景与架构概览

在企业 CRM 系统中,我们希望为业务人员提供一个内嵌的 AI 助手,让用户能直接在系统内输入问题、实时获取 AI 回答,而无需跳转到外部 AI 平台。

整体方案选型:

组件 说明
Open WebUI 开源 LLM 前端平台,提供 OpenAI 兼容接口,支持多模型管理
openai-java SDK 官方 Java 客户端,直接对接 OpenAI 兼容 API
Spring WebFlux 响应式编程,支持 SSE(Server-Sent Events)流式推送
Redis 缓存 Token,避免每次请求都重新登录 Open WebUI

整体请求链路:

复制代码
前端输入框 → POST /v1/chat/completions
    → LLMController(SSE 接口)
    → LLMService(获取 Token + 构造请求)
    → OpenAI Java SDK(流式调用 Open WebUI)
    → SSE 逐块推送回前端

依赖与配置

Maven 依赖

xml 复制代码
<!-- 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 配置

yaml 复制代码
open-web-ui:
  base-url: http://your-openwebui-host/api   # Open WebUI 的 OpenAI 兼容接口地址

Open WebUI 的 OpenAI 兼容接口通常为 http://host/api/v1,请根据实际部署调整。


Token 生命周期管理

设计思路

Open WebUI 使用 JWT Token 进行鉴权,Token 有有效期(默认约数小时)。若每次调用 AI 接口都重新登录,不仅效率低下,还会对 Open WebUI 服务产生不必要的登录压力。

因此,我们设计了一套 "先查缓存 → 有效直接用 → 过期再刷新" 的 Token 自动管理机制,并通过 Redis 实现跨实例共享。


TokenRepository 接口抽象

定义标准的增删查接口,便于后续替换为其他存储介质(如内存 Map、数据库等)。

java 复制代码
public interface TokenRepository {
    OpenWebUIToken get(String key);
    void save(String key, OpenWebUIToken value);
    void delete(String key);
}

设计亮点:通过接口隔离存储实现,TokenManager 不依赖具体存储技术,方便单元测试和替换。


Redis 存储实现

java 复制代码
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);
    }
}

关键点说明:

  • Key 格式为 openwebui:{username},通过前缀做命名空间隔离,避免与其他 Redis Key 冲突。
  • 使用 BeanUtil.copyProperties 从 Redis 返回的 LinkedHashMap 反序列化为强类型对象,避免手动类型转换。

OpenWebUIToken 数据模型

java 复制代码
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class OpenWebUIToken implements Serializable {

    @Schema(description = "认证token")
    private String accessToken;

    @Schema(description = "token过期时间(毫秒时间戳)")
    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. 当前时间在过期时间之前

OpenWebUITokenManager 核心管理器

这是整个 Token 管理的核心组件,负责:

  • 优先从缓存获取有效 Token
  • Token 失效时,发起 HTTP 请求重新登录 Open WebUI 并刷新缓存
  • 使用 @Synchronized 防止并发场景下的重复登录(双检锁模式)
java 复制代码
@Slf4j
public class OpenWebUITokenManager {

    /** Token 默认有效期(秒),登录接口无 expires_at 时使用 */
    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(优先缓存,缓存失效则重新登录)
     */
    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(加锁防并发,内部二次检查)
     */
    @Synchronized
    public String refreshToken(String username, String password) {
        // 二次检查:加锁后再次确认缓存是否已被其他线程刷新
        OpenWebUIToken cachedToken = tokenRepository.get(username);
        if (cachedToken != null && cachedToken.isValid()) {
            return cachedToken.getAccessToken();
        }

        try {
            // 调用 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());
            }

            // 解析响应
            JSONObject responseData = JSONUtil.parseObj(response.body());
            String token = responseData.getStr("token");

            // 计算过期时间:优先使用接口返回值,否则使用默认值
            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; // 秒 → 毫秒
            }

            // 构建并缓存 Token
            OpenWebUIToken webUIToken = OpenWebUIToken.builder()
                    .accessToken(token)
                    .username(username)
                    .password(password)
                    .expireAt(expiresTime)
                    .build();

            tokenRepository.save(username, webUIToken);
            log.info("Token 刷新成功: {}, 过期时间: {}", username, expiresTime);
            return token;

        } catch (Exception e) {
            log.error("登录获取 Token 失败: {}", username, e);
            throw new RuntimeException("Open WebUI 登录失败", e);
        }
    }
}

并发安全分析:

复制代码
线程 A: getValidToken → 缓存失效 → refreshToken(加锁)
线程 B: getValidToken → 缓存失效 → refreshToken(等待锁)
线程 A: 登录成功,缓存 Token,释放锁
线程 B: 获取到锁 → 二次检查缓存 → 发现 Token 有效 → 直接返回

通过二次检查(Double-Check),避免了多个线程同时发起重复登录请求。


凭证获取

系统用户与 Open WebUI 账号存在映射关系。CredentialProvider 负责根据当前登录用户 ID 查询其对应的 Open WebUI 账号和密码。

java 复制代码
@Slf4j
@Component
public class CredentialProvider {

    @Resource
    private SysUserService sysUserService;

    /**
     * 根据用户 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();
        // 密码规则:邮箱前缀 + 固定后缀
        String password = aiEmail.split("@")[0] + "AI++2025&";
        return new Credential(aiEmail, password);
    }
}

设计说明:

  • 系统在用户表中存储 aiEmail 字段,与 Open WebUI 用户一一对应。
  • 密码采用固定规则生成,方便批量初始化 Open WebUI 用户,同时保持一定的随机性。
  • 这种设计使得系统用户与 AI 平台账号解耦,无需在系统中明文存储 AI 平台密码。

系统提示词管理

系统提示词(System Prompt)决定了 AI 的角色定位和回答风格。为了方便维护多套提示词,我们将其以文本文件的形式存放在 classpath 中,通过枚举统一管理。

提示词枚举

java 复制代码
@Getter
@AllArgsConstructor
public enum SystemPromptEnum {

    DEFAULT("default", "默认");

    private final String code;
    private final String message;

    /**
     * 根据 code 获取枚举,未找到时返回 DEFAULT
     */
    public static SystemPromptEnum of(String code) {
        for (SystemPromptEnum value : values()) {
            if (value.getCode().equals(code)) {
                return value;
            }
        }
        return DEFAULT;
    }
}

提示词加载器

java 复制代码
@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 加载指定名称的系统提示词文件
     * 文件路径: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("加载系统提示: {}", resource.getPath());
        try (InputStream in = resource.getInputStream()) {
            return new String(in.readAllBytes(), Charset.defaultCharset());
        } catch (Exception e) {
            log.error("加载系统提示失败: {}", promptName, e);
            return "";
        }
    }

    public static String loadSystemPrompt(SystemPromptEnum promptEnum) {
        return loadSystemPrompt(promptEnum.getCode());
    }
}

文件结构示例:

复制代码
src/main/resources/
└── prompt/
    └── default-system-prompt.txt   ← 默认场景提示词

default-system-prompt.txt 内容示例:

复制代码
你是一名专业的企业 CRM 智能助手,请根据用户的问题给出准确、简洁的回答。
回答时请使用中文,保持专业、友好的语气。

扩展新场景只需新增枚举值和对应文本文件,无需修改业务代码,符合开闭原则


Spring Bean 配置

java 复制代码
@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 存储
        );
    }
}

OpenWebUIConstant.LOGIN_URL 参考值:

java 复制代码
public class OpenWebUIConstant {
    public static final String LOGIN_URL = "http://your-openwebui-host/api/v1/auths/signin";
}

请求与响应模型

请求参数 ChatRequest

java 复制代码
@Data
public class ChatRequest {

    @Schema(description = "场景标识(用于加载特定场景提示词),默认 default")
    private String code = SystemPromptEnum.DEFAULT.getCode();

    @NotEmpty(message = "请输入文字...")
    @Schema(description = "用户输入内容")
    private String prompt;

    @Schema(description = "模型名称,默认 Qwen3-VL-8B-Instruct")
    private String model = "Qwen3-VL-8B-Instruct";
}

字段说明:

字段 类型 必填 说明
code String 场景标识,关联系统提示词,默认 default
prompt String 用户输入的问题
model String 指定 Open WebUI 中部署的模型名称

响应数据 ChatStreamingVo

java 复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ChatStreamingVo {

    @Schema(description = "本次推送的内容片段")
    private String content;

    @Schema(description = "使用的模型名称")
    private String model;
}

每个 SSE 事件携带一个内容片段(content),前端拼接所有片段即可得到完整回答。


LLMService 流式对话核心实现

这是整个功能的核心,主要完成以下步骤:

  1. 加载当前场景的系统提示词
  2. 获取当前用户的 Open WebUI 凭证和有效 Token
  3. 使用 OpenAI Java SDK 构建流式请求
  4. 通过 Flux + SSE 将响应片段逐块推送
java 复制代码
@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. 加载系统提示词
        String systemPrompt = SystemPromptLoader.loadSystemPrompt(
            SystemPromptEnum.of(chatRequest.getCode())
        );

        // 2. 获取当前登录用户的 Open WebUI Token
        Credential credential = credentialProvider.getCredential(
            SecurityUtils.getUser().getId()
        );
        String validToken = openWebUITokenManager.getValidToken(credential);

        // 3. 构建 OpenAI Java 客户端(复用 Open WebUI 兼容接口)
        OpenAIClient aiClient = OpenAIOkHttpClient.builder()
                .baseUrl(baseUrl)
                .apiKey(validToken)   // 将 Open WebUI Token 作为 API Key
                .build();

        // 4. 构建对话参数
        ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
                .model(chatRequest.getModel())
                .addSystemMessage(systemPrompt)   // 系统提示词
                .addUserMessage(chatRequest.getPrompt()) // 用户输入
                .build();

        // 5. 流式调用 + Flux 包装 + SSE 封装
        return Flux.using(
                    // 创建流式响应资源
                    () -> aiClient.chat().completions().createStreaming(params),

                    // 将 Stream<ChatCompletionChunk> 转换为 Flux<ServerSentEvent>
                    streamResponse -> Flux.fromStream(streamResponse.stream())
                            .map(chunk -> {
                                // 提取当前 chunk 中的文本内容
                                String content = chunk.choices().stream()
                                        .findFirst()
                                        .flatMap(choice -> choice.delta().content())
                                        .orElse("");

                                return ServerSentEvent.<ChatStreamingVo>builder()
                                        .data(new ChatStreamingVo(content, chatRequest.getModel()))
                                        .build();
                            }),

                    // 流结束/出错/取消 时关闭资源,防止连接泄漏
                    StreamResponse::close
                )
                // 切换到弹性线程池,避免阻塞事件循环线程
                .subscribeOn(Schedulers.boundedElastic())

                // 全局异常处理
                .onErrorResume(e -> {
                    log.error("LLM 流式对话异常", e);
                    throw new BusinessException("LLM 流式对话异常");
                });
    }
}

关键技术点详解

Flux.using 的资源管理模式

Flux.using 是 Reactor 提供的资源管理操作符,它的三个参数分别对应:

复制代码
Flux.using(
    resourceSupplier,   // 创建资源(流式响应对象)
    sourceSupplier,     // 使用资源生产数据
    resourceCleanup     // 资源清理(无论成功/失败/取消都会执行)
)

这里使用它来确保 StreamResponse 对象(底层是一个 HTTP 长连接)无论何种情况下都能被正确关闭,防止连接资源泄漏。

subscribeOn(Schedulers.boundedElastic())

OpenAI SDK 的流式调用是阻塞的 I/O 操作,而 Spring WebFlux 的事件循环线程(Netty NIO 线程)不允许被阻塞。通过 subscribeOn 将订阅行为切换到 boundedElastic 线程池(专为阻塞 I/O 设计),避免阻塞主事件循环。

SSE 数据格式

ServerSentEvent 对象在 Spring WebFlux 中会被序列化为标准的 SSE 格式:

复制代码
data: {"content":"你好","model":"Qwen3-VL-8B-Instruct"}

data: {"content":",有什么可以","model":"Qwen3-VL-8B-Instruct"}

data: {"content":"帮助您的?","model":"Qwen3-VL-8B-Instruct"}

LLMController 接口层

java 复制代码
@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);
    }
}

要点:

  • produces = "text/event-stream":声明接口返回 SSE 格式,浏览器 EventSource API 及 Fetch 流均可消费。
  • @PreAuthorize:接口鉴权,确保只有登录用户才能访问。
  • 返回 Flux<ServerSentEvent<...>>:Spring WebFlux 会自动将其转换为持续推送的 SSE 响应。

前端对接思路

前端通过 Fetch API + ReadableStream 消费 SSE,实时渲染流式内容:

javascript 复制代码
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 数据行
    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 示例(输入框 + 流式渲染):

vue 复制代码
<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">发送</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 数据 → 拼接渲染到文本框                               │
└────────────────────────────────┬───────────────────────────────────┘
                                 │ HTTP SSE
┌────────────────────────────────▼───────────────────────────────────┐
│                        LLMController                                │
│  @PostMapping(produces = "text/event-stream")                       │
│  → 调用 LLMService.chatStream(chatRequest)                          │
└────────────────────────────────┬───────────────────────────────────┘
                                 │
┌────────────────────────────────▼───────────────────────────────────┐
│                          LLMService                                 │
│  1. SystemPromptLoader.loadSystemPrompt(code)  加载提示词            │
│  2. CredentialProvider.getCredential(userId)   获取 AI 凭证          │
│  3. TokenManager.getValidToken(credential)     获取有效 Token        │
│  4. OpenAIOkHttpClient.build(baseUrl, token)   构建 SDK 客户端       │
│  5. Flux.using(createStreaming, mapChunks, close) 流式调用           │
└────┬───────────────────────────────────────────┬───────────────────┘
     │ Token 管理                                  │ AI 调用
┌────▼──────────────────────────┐  ┌─────────────▼──────────────────┐
│   OpenWebUITokenManager        │  │       Open WebUI               │
│  ┌──────────────────────────┐ │  │  POST /api/v1/auths/signin      │
│  │ Redis 缓存查询            │ │  │  POST /api/v1/chat/completions  │
│  │ → 有效:直接返回           │ │  │  (OpenAI 兼容接口)              │
│  │ → 失效:登录刷新 + 缓存   │ │  │                                │
│  └──────────────────────────┘ │  │  → 流式返回 ChatCompletionChunk │
└───────────────────────────────┘  └────────────────────────────────┘

设计总结与经验

✅ 亮点设计

设计点 说明
Token 双检锁 @Synchronized + 内部二次校验,防止高并发下重复登录
接口隔离存储 TokenRepository 接口 + RedisTokenRepository 实现,易替换、易测试
提示词文件化 提示词以 .txt 存放 classpath,通过枚举管理多场景,无需改代码
资源安全释放 Flux.using 三段式确保流式连接必然关闭,防止资源泄漏
线程模型正确 subscribeOn(Schedulers.boundedElastic()) 避免阻塞 WebFlux 事件线程
用户凭证映射 系统用户与 AI 平台账号分离,凭证由 CredentialProvider 统一管理

⚠️ 注意事项

  1. Token 有效期与 Redis TTL 保持一致 :建议在 save 时同步设置 Redis Key 的过期时间,避免 Redis 中存放已过期 Token 占用内存。

    java 复制代码
    // 改进建议:设置 Redis TTL
    long ttlSeconds = (expiresTime - System.currentTimeMillis()) / 1000;
    redisTemplate.opsForValue().set(prefix + key, value, ttlSeconds, TimeUnit.SECONDS);
  2. Open WebUI 模型名称与实际部署保持一致ChatRequest.model 的默认值 Qwen3-VL-8B-Instruct 需要与 Open WebUI 中实际加载的模型 ID 完全匹配,否则会返回 404。

  3. 系统提示词文件编码 :建议统一使用 UTF-8 编码保存 .txt 文件,避免中文乱码。

  4. SSE 连接超时 :生产环境中建议配置 Nginx 的 proxy_read_timeoutproxy_buffering off,确保 SSE 长连接不被中断。

    nginx 复制代码
    location /v1/chat/ {
        proxy_pass http://backend;
        proxy_buffering off;
        proxy_read_timeout 120s;
        proxy_set_header Cache-Control no-cache;
    }
  5. AI 账号批量初始化 :在用户表中添加 ai_email 字段后,需要在 Open WebUI 中预先创建对应账号,或通过 Open WebUI 管理接口批量创建。


小结

本文完整介绍了在企业 Spring Boot 项目中集成 Open WebUI 的实践方案:

  • 通过 Redis 缓存 + 双检锁 实现 Token 的自动管理与并发安全;
  • 通过 枚举 + classpath 文本文件 实现多场景系统提示词的灵活管理;
  • 通过 OpenAI Java SDK + Spring WebFlux + SSE 实现流式 AI 响应;
  • 通过 Fetch ReadableStream 在前端实时渲染流式输出。

这套架构可以方便地扩展到更多 AI 场景:代码审查、文案生成、智能客服等,只需新增场景枚举和对应提示词文件即可快速接入。


作者:北风朝向 · 2026年5月

相关推荐
云烟成雨TD1 小时前
Spring AI Alibaba 1.x 系列【53】Interrupts 中断机制:动态中断
java·人工智能·spring
Raink老师1 小时前
【AI面试临阵磨枪-56】大模型服务部署:Docker、K8s、GPU 调度、推理加速
人工智能·面试·kubernetes·ai 面试
云上码厂1 小时前
NeurIPS 研讨会资料:用机器学习应对气候变化
人工智能
科技小花1 小时前
2026 年度生成式引擎优化(GEO)标杆产品:百分点科技 Generforce 的差异化路径
大数据·人工智能·科技·geo·ai搜索
安心联-车辆监控管理系统1 小时前
车载主动安全ADAS/DSM技术原理、业务应用与平台接入方案
人工智能·安全
用户40189933422841 小时前
第 9 章 Skills 生态
人工智能
网安情报局1 小时前
AI大模型解析:安全赛道大模型的合规稳定之选
人工智能·安全
Android出海1 小时前
2026年Codex新手教程:安装、使用与自动化实战指南
人工智能·ai·chatgpt·自动化·脚本·codex·自动化脚本
科技小花1 小时前
2026年 GEO 产品力测评:百分点科技 Generforce 如何为品牌赢得“AI 推荐权”
人工智能·geo·ai-native