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 变更后清空 ChatClientEmbeddingModel 缓存,下次调用时重新构建。
  • 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 校验 idbaseUrlapiKeymodel 均不能为空。
  3. Service 开启事务并获取写锁。
  4. DB 模式下先检查 Provider ID 是否已存在。
  5. baseUrlmodelapiKey 做二次非空校验。
  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 idUpdateProviderRequest
  2. Service 开启事务并获取写锁。
  3. DB 模式下根据 id 查询 Provider。
  4. Provider 不存在时抛出 BusinessException(PROVIDER_NOT_FOUND)
  5. 按字段更新配置:
    • baseUrlnull 表示不修改,空字符串非法。
    • modelnull 表示不修改,空字符串非法。
    • apiKeynull 表示不修改,空字符串非法,更新时重新加密。
    • 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。
  • 只清空内存中的 ChatClientEmbeddingModel 缓存。
  • 下次调用 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.defaultProviderproperties.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 配置和文件配置的同步机制,让配置来源更清晰、运行时状态更可控。