java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot AI流式对话

SpringBoot集成Open WebUI实现AI流式对话

作者:北风朝向

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

背景与架构概览

在企业 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 依赖

<!-- 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,请根据实际部署调整。

Token 生命周期管理

设计思路

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

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

TokenRepository 接口抽象

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

public interface TokenRepository {
    OpenWebUIToken get(String key);
    void save(String key, OpenWebUIToken value);
    void delete(String key);
}

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

Redis 存储实现

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);
    }
}

关键点说明:

OpenWebUIToken 数据模型

@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 管理的核心组件,负责:

@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 账号和密码。

@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);
    }
}

设计说明:

系统提示词管理

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

提示词枚举

@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;
    }
}

提示词加载器

@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 配置

@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 参考值:

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

请求与响应模型

请求参数ChatRequest

@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";
}

字段说明:

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

响应数据ChatStreamingVo

@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 将响应片段逐块推送
@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 接口层

@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);
    }
}

要点:

前端对接思路

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

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 示例(输入框 + 流式渲染):

<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 统一管理

注意事项

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

// 改进建议:设置 Redis TTL
long ttlSeconds = (expiresTime - System.currentTimeMillis()) / 1000;
redisTemplate.opsForValue().set(prefix + key, value, ttlSeconds, TimeUnit.SECONDS);

Open WebUI 模型名称与实际部署保持一致ChatRequest.model 的默认值 Qwen3-VL-8B-Instruct 需要与 Open WebUI 中实际加载的模型 ID 完全匹配,否则会返回 404。

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

SSE 连接超时:生产环境中建议配置 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 中预先创建对应账号,或通过 Open WebUI 管理接口批量创建。

小结

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

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

以上就是SpringBoot集成Open WebUI实现AI流式对话的详细内容,更多关于SpringBoot AI流式对话的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文