Ai面试项目:llm-provider模块

interview-guide项目 llm-provider模块的设计与接口实现

Llm-provider 模块设计与实现

这篇笔记记录 interview-guide 项目中 llm-provider 模块的设计与接口实现。该模块负责统一管理大模型 Provider 配置,包括模型列表、默认模型、Embedding 能力、连通性测试,以及语音面试中 ASR/TTS 的运行时配置。

模块能力概览

  • Provider 管理:支持查询、创建、更新、删除大模型 Provider。
  • 双存储模式:兼容 DB 模式和 Legacy 配置文件模式。
  • 密钥保护:DB 模式下 API Key 使用 AES-GCM 加密存储,接口返回前统一脱敏。
  • 默认模型管理:区分默认 Chat Provider 和默认 Embedding Provider。
  • 缓存重载:Provider 变更后清空 ChatClient 和 EmbeddingModel 缓存,下次调用时重新构建。
  • Embedding 校验:创建、更新和设置默认 Embedding Provider 时校验模型类型、维度和能力开关。
  • 连通性测试:支持对 LLM Provider 发起真实 HTTP 测试请求。
  • 语音配置管理:支持读取和更新 Qwen ASR/TTS 配置,并同步重载运行时服务。

流程图

核心设计

llm-provider 模块的核心是把“模型配置读取、密钥保护、默认模型选择、运行时客户端缓存”放在同一套服务中管理。

DB 模式下,Provider 配置来自数据库。服务层读取 LlmProviderEntity 后,会先解密 API Key,再做脱敏,然后转换为 ProviderDTO 返回给前端。API Key 明文只在服务端运行时短暂出现,不会通过接口返回。

Legacy 模式下,Provider 配置来自 ConfigurationProperties。创建、更新和删除时会同步修改 YAML 配置文件和 .env 文件,并在修改完成后重载 Provider 注册表。

模块通过 rwLock 控制并发读写:查询类接口使用读锁,创建、更新、删除和默认值修改使用写锁,避免配置在读写过程中出现不一致。

Provider 列表查询

GET /api/llm-provider/list 获取全部 Provider 列表

返回:

  • Result<List<ProviderDTO>>

调用链:

providerController.listProviders();
providerService.listProviders();
globalSettingRepository.findById(1L);
providerRepository.findAll();
encryptionService.decrypt(nonce, ciphertext);

处理流程:

  1. Controller 调用 listProviders()。
  2. Service 获取 rwLock.readLock()。
  3. DB 模式下先查询全局配置,用于判断默认 Chat Provider 和默认 Embedding Provider。
  4. 查询全部 LlmProviderEntity。
  5. 遍历每个 Provider:
    • 解密 API Key。
    • 调用 maskApiKey(...) 脱敏。
    • 调用 resolveEmbeddingDimensions(...) 解析向量维度,未配置时使用全局默认值。
    • 映射为 ProviderDTO。
  6. Legacy 模式下从 properties.getProviders() 读取内存配置。
  7. 返回 Provider 列表。

关键点:

  • DB 模式读取失败时会抛出 BusinessException(PROVIDER_CONFIG_READ_FAILED)。
  • API Key 永远不会以明文返回给前端。
  • 当前存在一个问题:如果已启用 DB 存储 LLM 配置,更新配置文件和 API Key 后,即使重启项目也不会自动同步到 DB,除非关闭 DB 模式或清理数据库配置。

GET /api/llm-provider/{id} 获取单个 Provider 详情

返回:

  • Result<ProviderDTO>

处理流程:

  1. Controller 接收 Provider id。
  2. Service 获取读锁。
  3. DB 模式下查询全局配置和目标 Provider。
  4. Provider 不存在时抛出 BusinessException(PROVIDER_NOT_FOUND)。
  5. 解密 API Key 后脱敏。
  6. 解析 Embedding 维度并构建 ProviderDTO。
  7. Legacy 模式下从内存配置中按 id 获取 Provider。

Provider 创建与更新

POST /api/llm-provider 创建新 Provider

返回:

  • Result<Void>

调用链:

providerService.createProvider(request);
providerRepository.existsById(request.id());
validateEmbeddingConfig(...);
encryptionService.encrypt(apiKey);
providerRepository.save(entity);
registry.reload();

处理流程:

  1. Controller 接收 CreateProviderRequest。
  2. 通过 @Valid 校验 id、baseUrl、apiKey、model 均不能为空。
  3. Service 开启事务并获取写锁。
  4. DB 模式下先检查 Provider ID 是否已存在。
  5. 对 baseUrl、model、apiKey 做二次非空校验。
  6. 调用 validateEmbeddingConfig(...) 校验 Embedding 配置。
  7. 使用 encryptionService.encrypt(apiKey) 加密 API Key。
  8. 保存 LlmProviderEntity。
  9. 调用 registry.reload() 清空运行时缓存。

Legacy 模式处理:

  • 检查 properties.getProviders() 中是否已有相同 ID。
  • 构建 ProviderConfig 并写入内存 Map。
  • 调用 writeProviderToYaml(...) 写回 YAML。
  • 调用 writeEnvValue(...) 写入 .env。
  • 调用 registry.reload() 重载缓存。

Embedding 校验逻辑:

supportsEmbedding = true
embeddingModel == null        // 抛出错误
looksLikeChatModel(...)       // 抛出错误并给出推荐
embeddingDimensions <= 0      // 抛出错误

PUT /api/llm-provider/{id} 更新 Provider

返回:

  • Result<Void>

调用链:

providerService.updateProvider(id, request);
providerRepository.findById(id);
validateEmbeddingConfig(...);
encryptionService.encrypt(newApiKey);
providerRepository.save(entity);
registry.reload();

处理流程:

  1. Controller 接收 Provider id 和 UpdateProviderRequest。
  2. Service 开启事务并获取写锁。
  3. DB 模式下根据 id 查询 Provider。
  4. Provider 不存在时抛出 BusinessException(PROVIDER_NOT_FOUND)。
  5. 按字段更新配置:
    • baseUrl:null 表示不修改,空字符串非法。
    • model:null 表示不修改,空字符串非法。
    • apiKey:null 表示不修改,空字符串非法,更新时重新加密。
    • embeddingModel:允许传 null 清除。
    • embeddingDimensions:按请求值更新。
    • supportsEmbedding:按请求值更新。
    • temperature:按请求值更新。
  6. 调用 validateEmbeddingConfig(...) 做完整校验。
  7. 保存实体并重载缓存。

注意:

  • UpdateProviderRequest 没有 @Valid,所有字段都是可选字段。
  • null 表示不更新。
  • 空字符串视为非法输入。

Provider 删除与重载

DELETE /api/llm-provider/{id} 删除 Provider

返回:

  • Result<Void>

处理流程:

  1. Service 开启事务并获取写锁。
  2. DB 模式下读取全局设置。
  3. 判断当前 Provider 是否为默认 Chat Provider 或默认 Embedding Provider。
  4. 如果是默认 Provider,抛出 BusinessException(PROVIDER_DEFAULT_CANNOT_DELETE)。
  5. 查询目标 Provider,确认存在后删除。
  6. 调用 registry.reload() 清空运行时缓存。

Legacy 模式处理:

  • 检查是否为默认 Provider。
  • 从内存 Map 中删除配置。
  • 调用 removeProviderFromYaml(...) 删除 YAML 节点。
  • 调用 removeFromEnv(...) 删除 .env 中的 API Key。
  • 调用 registry.reload() 重载缓存。

保护机制:

  • 默认 Chat Provider 和默认 Embedding Provider 不允许直接删除。
  • 必须先切换默认值,再删除原 Provider。

POST /api/llm-provider/reload 手动重载 Provider 缓存

返回:

  • Result<Void>

处理逻辑:

registry.reload();
clientCache.clear();
embeddingModelCache.clear();

说明:

  • 该接口不加锁。
  • 不开启事务。
  • 不访问 DB。
  • 只清空内存中的 ChatClient 和 EmbeddingModel 缓存。
  • 下次调用 getChatClient() 或获取 Embedding 模型时按最新配置重新构建。

Provider 连通性测试

POST /api/llm-provider/{id}/test 测试 Provider 连接

返回:

  • Result<ProviderTestResult>

处理流程:

  1. Service 获取读锁。
  2. 根据模式读取运行时配置:
    • DB 模式下调用 getProviderRuntimeConfigOrThrow(id)。
    • Legacy 模式下调用 toRuntimeConfig(...)。
  3. 构建 RestClient:
    • connectTimeout = 5s
    • readTimeout = 10s
    • Header 中设置 Authorization: Bearer {apiKey}
  4. 构建测试请求体:
{
  "model": "xxx",
  "messages": [
    {
      "role": "user",
      "content": "Reply with OK only."
    }
  ],
  "max_tokens": 1
}
  1. 构建候选测试 URL:
    • baseUrl + "/chat/completions"
    • 如果 baseUrl 不含版本号,再尝试 baseUrl + "/v1/chat/completions"
  2. 依次向候选 URL 发送 POST 请求。
  3. 任一 URL 成功时返回连接成功。
  4. 全部失败时返回最后一次失败原因。

说明:

  • 这是 Provider 管理中唯一会直接调用外部 LLM API 的接口。
  • 测试请求会发送真实 HTTP 请求。
  • HTTP 错误会记录状态码和响应体,普通异常会记录异常类型和错误信息。

默认 Provider 管理

GET /api/llm-provider/default-provider 获取默认 Provider

返回:

  • Result<DefaultProviderDTO>

处理流程:

  1. Service 获取读锁。
  2. DB 模式下查询 globalSettingRepository.findById(1L)。
  3. 返回默认 Chat Provider ID 和默认 Embedding Provider ID。
  4. Legacy 模式下从 properties.defaultProvider 和 properties.defaultEmbeddingProvider 构建返回值。

返回结构:

{
  "defaultProvider": "dashscope",
  "defaultEmbeddingProvider": "dashscope"
}

PUT /api/llm-provider/default-provider 设置默认 Chat Provider

返回:

  • Result<Void>

处理流程:

  1. Service 开启事务并获取写锁。
  2. 读取 request.defaultProvider()。
  3. 默认 Provider 为空时抛出 BAD_REQUEST。
  4. 查询目标 Provider,确认存在。
  5. DB 模式下更新 GlobalSettingEntity.defaultChatProviderId。
  6. 保存全局设置。
  7. 调用 registry.reload()。

Legacy 模式处理:

  • 校验 Provider 存在。
  • 修改 properties.setDefaultProvider(providerId)。
  • 调用 writeDefaultProviderToYaml(providerId) 写回配置。
  • 删除旧的 module-defaults 配置。
  • 调用 registry.reload()。

PUT /api/llm-provider/default-embedding-provider 设置默认 Embedding Provider

返回:

  • Result<Void>

处理流程:

  1. Service 开启事务并获取写锁。
  2. 读取 request.defaultEmbeddingProvider()。
  3. 默认 Embedding Provider 为空时抛出 BAD_REQUEST。
  4. 查询目标 Provider,确认存在。
  5. 校验该 Provider 支持 Embedding:
    • supportsEmbedding 必须为 true。
    • embeddingModel 必须存在。
    • validateEmbeddingConfig(...) 必须通过。
  6. DB 模式下更新 GlobalSettingEntity.defaultEmbeddingProviderId。
  7. 保存全局设置。
  8. 调用 registry.reload()。

与默认 Chat Provider 的差异:

  • 设置默认 Embedding Provider 时多了 Embedding 能力校验。
  • 不支持 Embedding 的 Provider 不能被设置为默认向量服务。

ASR 配置管理

GET /api/llm-provider/voice/asr 获取 ASR 配置

返回:

  • Result<AsrConfigDTO>

处理流程:

  1. Service 获取读锁。
  2. 从 VoiceInterviewProperties 读取 voiceProperties.getQwen().getAsr()。
  3. 构建 AsrConfigDTO:
    • url
    • model
    • language
    • format
    • sampleRate
    • maskedApiKey
    • enableTurnDetection
    • turnDetectionType
    • turnDetectionThreshold
    • turnDetectionSilenceDurationMs
    • VAD 相关参数
  4. 返回脱敏后的 ASR 配置。

说明:

  • ASR 配置来源于 VoiceInterviewProperties。
  • 配置前缀是 app.voice-interview。
  • 该配置不走 DB。

PUT /api/llm-provider/voice/asr 更新 ASR 配置

返回:

  • Result<Void>

处理流程:

  1. Service 获取写锁。
  2. 读取运行时 ASR 和 TTS 配置引用。
  3. 按字段更新 ASR 配置:
    • url
    • model
    • language
    • format
    • sampleRate
    • enableTurnDetection
    • turnDetectionType
    • turnDetectionThreshold
    • turnDetectionSilenceDurationMs
  4. 如果更新了 API Key,则同步更新 ASR 和 TTS:
asr.setApiKey(apiKey);
tts.setApiKey(apiKey);
updateEnvValue("AI_BAILIAN_API_KEY", apiKey);
  1. 调用 writeAsrConfigToYaml(asr) 写回 YAML。
  2. 调用 asrService.reload(voiceProperties) 重载 ASR 服务。
  3. 如果 API Key 更新,则同步调用 ttsService.reload(voiceProperties)。

注意:

  • 该方法没有 @Transactional。
  • ASR 和 TTS 共享百炼 API Key。
  • 修改 ASR 的 API Key 会同步影响 TTS。

TTS 配置管理

GET /api/llm-provider/voice/tts 获取 TTS 配置

返回:

  • Result<TtsConfigDTO>

处理流程:

  1. Service 获取读锁。
  2. 从 VoiceInterviewProperties 读取 voiceProperties.getQwen().getTts()。
  3. 构建 TtsConfigDTO:
    • model
    • maskedApiKey
    • voice
    • format
    • sampleRate
    • mode
    • languageType
    • speechRate
    • volume
  4. 返回脱敏后的 TTS 配置。

PUT /api/llm-provider/voice/tts 更新 TTS 配置

返回:

  • Result<Void>

处理流程:

  1. Service 获取写锁。
  2. 读取运行时 ASR 和 TTS 配置引用。
  3. 按字段更新 TTS 配置:
    • model
    • voice
    • format
    • sampleRate
    • mode
    • languageType
    • speechRate
    • volume
  4. 如果更新了 API Key,则同步更新 TTS 和 ASR:
tts.setApiKey(apiKey);
asr.setApiKey(apiKey);
updateEnvValue("AI_BAILIAN_API_KEY", apiKey);
  1. 调用 writeTtsConfigToYaml(tts) 写回 YAML。
  2. 调用 ttsService.reload(voiceProperties) 重载 TTS 服务。
  3. 如果 API Key 更新,则同步调用 asrService.reload(voiceProperties)。

说明:

  • TTS 更新逻辑与 ASR 对称。
  • ASR/TTS 的 API Key 始终联动更新。

ASR 连通性测试

POST /api/llm-provider/voice/asr/test 测试 ASR 连接

返回:

  • Result<ProviderTestResult>

处理流程:

  1. Service 获取读锁。
  2. 从 voiceProperties.getQwen().getAsr() 读取 ASR 配置。
  3. 解析 WebSocket URL:
    • wss 默认端口为 443。
    • ws 默认端口为 80。
  4. 使用 TCP Socket 发起连接测试:
socket.connect(address, 5000);
socket.close();
  1. 连接成功时返回:
ProviderTestResult(success=true, "ASR WebSocket 连接成功: host")
  1. 连接失败时返回失败原因。

与 Provider 连通性测试的差异:

  • ASR 测试只做 TCP Socket 连接。
  • 不发送 WebSocket 握手。
  • 不调用真实 ASR 识别接口。
  • Provider 测试会发送真实 HTTP 请求到 LLM 服务。

缓存与运行时行为

Provider 配置变更后都会调用 registry.reload()。这个方法会清空内部缓存:

clientCache.clear();
embeddingModelCache.clear();

因此,配置变更不会立即创建新的客户端,而是在下一次业务代码调用 Provider 时按需重建。这种方式避免了更新接口直接承担模型客户端初始化成本,也能保证旧配置不会长期停留在缓存中。

需要注意的是,reload 只负责清空缓存,不负责同步配置源。如果 DB 模式已经启用,系统会优先读取数据库配置,而不是重新从 YAML 或 .env 导入配置。

当前问题与优化方向

当前模块已经支持 DB 模式和 Legacy 模式,但配置同步边界还需要进一步明确:

  • DB 模式启用后,YAML 和 .env 的修改不会自动回写数据库。
  • 重启项目只能重新加载运行时配置,不能解决 DB 配置与文件配置不一致的问题。
  • 手动 reload 只清空运行时缓存,不会重新导入配置源。
  • ASR/TTS 配置仍来自 VoiceInterviewProperties,与 Provider DB 配置不是同一套存储。
  • ASR/TTS 更新方法没有事务,写 YAML、写 .env、服务重载之间存在部分成功的可能。

后续可按以下方向优化:

  • 增加 DB 模式下的配置导入接口,用于从 YAML 和 .env 同步 Provider 到数据库。
  • 在启动阶段增加一次性迁移策略,明确 DB 优先还是配置文件优先。
  • 给 Provider 配置增加版本号或更新时间,便于排查缓存是否已刷新。
  • 将 ASR/TTS 配置纳入统一配置存储,减少双配置源带来的不一致。
  • 对 YAML 写入、.env 写入和服务重载增加失败补偿或更明确的错误提示。

小结

llm-provider 模块承担了大模型能力的统一配置入口。它不仅管理 Chat Provider,还管理 Embedding Provider、默认模型、运行时缓存和语音 ASR/TTS 配置。模块的关键价值在于:把模型配置和业务调用解耦,让知识库、RAG 聊天、语音面试等上层能力都可以通过统一 Provider 注册表获取模型能力。后续重点是进一步梳理 DB 配置和文件配置的同步机制,让配置来源更清晰、运行时状态更可控。