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);
处理流程:
- Controller 调用
listProviders()。 - Service 获取
rwLock.readLock()。 - DB 模式下先查询全局配置,用于判断默认 Chat Provider 和默认 Embedding Provider。
- 查询全部
LlmProviderEntity。 - 遍历每个 Provider:
- 解密 API Key。
- 调用
maskApiKey(...)脱敏。 - 调用
resolveEmbeddingDimensions(...)解析向量维度,未配置时使用全局默认值。 - 映射为
ProviderDTO。
- Legacy 模式下从
properties.getProviders()读取内存配置。 - 返回 Provider 列表。
关键点:
- DB 模式读取失败时会抛出
BusinessException(PROVIDER_CONFIG_READ_FAILED)。 - API Key 永远不会以明文返回给前端。
- 当前存在一个问题:如果已启用 DB 存储 LLM 配置,更新配置文件和 API Key 后,即使重启项目也不会自动同步到 DB,除非关闭 DB 模式或清理数据库配置。
GET /api/llm-provider/{id} 获取单个 Provider 详情
返回:
Result<ProviderDTO>
处理流程:
- Controller 接收 Provider
id。 - Service 获取读锁。
- DB 模式下查询全局配置和目标 Provider。
- Provider 不存在时抛出
BusinessException(PROVIDER_NOT_FOUND)。 - 解密 API Key 后脱敏。
- 解析 Embedding 维度并构建
ProviderDTO。 - 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();
处理流程:
- Controller 接收
CreateProviderRequest。 - 通过
@Valid校验id、baseUrl、apiKey、model均不能为空。 - Service 开启事务并获取写锁。
- DB 模式下先检查 Provider ID 是否已存在。
- 对
baseUrl、model、apiKey做二次非空校验。 - 调用
validateEmbeddingConfig(...)校验 Embedding 配置。 - 使用
encryptionService.encrypt(apiKey)加密 API Key。 - 保存
LlmProviderEntity。 - 调用
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();
处理流程:
- Controller 接收 Provider
id和UpdateProviderRequest。 - Service 开启事务并获取写锁。
- DB 模式下根据
id查询 Provider。 - Provider 不存在时抛出
BusinessException(PROVIDER_NOT_FOUND)。 - 按字段更新配置:
baseUrl:null表示不修改,空字符串非法。model:null表示不修改,空字符串非法。apiKey:null表示不修改,空字符串非法,更新时重新加密。embeddingModel:允许传null清除。embeddingDimensions:按请求值更新。supportsEmbedding:按请求值更新。temperature:按请求值更新。
- 调用
validateEmbeddingConfig(...)做完整校验。 - 保存实体并重载缓存。
注意:
UpdateProviderRequest没有@Valid,所有字段都是可选字段。null表示不更新。- 空字符串视为非法输入。
Provider 删除与重载
DELETE /api/llm-provider/{id} 删除 Provider
返回:
Result<Void>
处理流程:
- Service 开启事务并获取写锁。
- DB 模式下读取全局设置。
- 判断当前 Provider 是否为默认 Chat Provider 或默认 Embedding Provider。
- 如果是默认 Provider,抛出
BusinessException(PROVIDER_DEFAULT_CANNOT_DELETE)。 - 查询目标 Provider,确认存在后删除。
- 调用
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>
处理流程:
- Service 获取读锁。
- 根据模式读取运行时配置:
- DB 模式下调用
getProviderRuntimeConfigOrThrow(id)。 - Legacy 模式下调用
toRuntimeConfig(...)。
- DB 模式下调用
- 构建
RestClient:connectTimeout = 5sreadTimeout = 10s- Header 中设置
Authorization: Bearer {apiKey}
- 构建测试请求体:
{
"model": "xxx",
"messages": [
{
"role": "user",
"content": "Reply with OK only."
}
],
"max_tokens": 1
}
- 构建候选测试 URL:
baseUrl + "/chat/completions"- 如果
baseUrl不含版本号,再尝试baseUrl + "/v1/chat/completions"
- 依次向候选 URL 发送 POST 请求。
- 任一 URL 成功时返回连接成功。
- 全部失败时返回最后一次失败原因。
说明:
- 这是 Provider 管理中唯一会直接调用外部 LLM API 的接口。
- 测试请求会发送真实 HTTP 请求。
- HTTP 错误会记录状态码和响应体,普通异常会记录异常类型和错误信息。
默认 Provider 管理
GET /api/llm-provider/default-provider 获取默认 Provider
返回:
Result<DefaultProviderDTO>
处理流程:
- Service 获取读锁。
- DB 模式下查询
globalSettingRepository.findById(1L)。 - 返回默认 Chat Provider ID 和默认 Embedding Provider ID。
- Legacy 模式下从
properties.defaultProvider和properties.defaultEmbeddingProvider构建返回值。
返回结构:
{
"defaultProvider": "dashscope",
"defaultEmbeddingProvider": "dashscope"
}
PUT /api/llm-provider/default-provider 设置默认 Chat Provider
返回:
Result<Void>
处理流程:
- Service 开启事务并获取写锁。
- 读取
request.defaultProvider()。 - 默认 Provider 为空时抛出
BAD_REQUEST。 - 查询目标 Provider,确认存在。
- DB 模式下更新
GlobalSettingEntity.defaultChatProviderId。 - 保存全局设置。
- 调用
registry.reload()。
Legacy 模式处理:
- 校验 Provider 存在。
- 修改
properties.setDefaultProvider(providerId)。 - 调用
writeDefaultProviderToYaml(providerId)写回配置。 - 删除旧的
module-defaults配置。 - 调用
registry.reload()。
PUT /api/llm-provider/default-embedding-provider 设置默认 Embedding Provider
返回:
Result<Void>
处理流程:
- Service 开启事务并获取写锁。
- 读取
request.defaultEmbeddingProvider()。 - 默认 Embedding Provider 为空时抛出
BAD_REQUEST。 - 查询目标 Provider,确认存在。
- 校验该 Provider 支持 Embedding:
supportsEmbedding必须为true。embeddingModel必须存在。validateEmbeddingConfig(...)必须通过。
- DB 模式下更新
GlobalSettingEntity.defaultEmbeddingProviderId。 - 保存全局设置。
- 调用
registry.reload()。
与默认 Chat Provider 的差异:
- 设置默认 Embedding Provider 时多了 Embedding 能力校验。
- 不支持 Embedding 的 Provider 不能被设置为默认向量服务。
ASR 配置管理
GET /api/llm-provider/voice/asr 获取 ASR 配置
返回:
Result<AsrConfigDTO>
处理流程:
- Service 获取读锁。
- 从
VoiceInterviewProperties读取voiceProperties.getQwen().getAsr()。 - 构建
AsrConfigDTO:urlmodellanguageformatsampleRatemaskedApiKeyenableTurnDetectionturnDetectionTypeturnDetectionThresholdturnDetectionSilenceDurationMs- VAD 相关参数
- 返回脱敏后的 ASR 配置。
说明:
- ASR 配置来源于
VoiceInterviewProperties。 - 配置前缀是
app.voice-interview。 - 该配置不走 DB。
PUT /api/llm-provider/voice/asr 更新 ASR 配置
返回:
Result<Void>
处理流程:
- Service 获取写锁。
- 读取运行时 ASR 和 TTS 配置引用。
- 按字段更新 ASR 配置:
urlmodellanguageformatsampleRateenableTurnDetectionturnDetectionTypeturnDetectionThresholdturnDetectionSilenceDurationMs
- 如果更新了 API Key,则同步更新 ASR 和 TTS:
asr.setApiKey(apiKey);
tts.setApiKey(apiKey);
updateEnvValue("AI_BAILIAN_API_KEY", apiKey);
- 调用
writeAsrConfigToYaml(asr)写回 YAML。 - 调用
asrService.reload(voiceProperties)重载 ASR 服务。 - 如果 API Key 更新,则同步调用
ttsService.reload(voiceProperties)。
注意:
- 该方法没有
@Transactional。 - ASR 和 TTS 共享百炼 API Key。
- 修改 ASR 的 API Key 会同步影响 TTS。
TTS 配置管理
GET /api/llm-provider/voice/tts 获取 TTS 配置
返回:
Result<TtsConfigDTO>
处理流程:
- Service 获取读锁。
- 从
VoiceInterviewProperties读取voiceProperties.getQwen().getTts()。 - 构建
TtsConfigDTO:modelmaskedApiKeyvoiceformatsampleRatemodelanguageTypespeechRatevolume
- 返回脱敏后的 TTS 配置。
PUT /api/llm-provider/voice/tts 更新 TTS 配置
返回:
Result<Void>
处理流程:
- Service 获取写锁。
- 读取运行时 ASR 和 TTS 配置引用。
- 按字段更新 TTS 配置:
modelvoiceformatsampleRatemodelanguageTypespeechRatevolume
- 如果更新了 API Key,则同步更新 TTS 和 ASR:
tts.setApiKey(apiKey);
asr.setApiKey(apiKey);
updateEnvValue("AI_BAILIAN_API_KEY", apiKey);
- 调用
writeTtsConfigToYaml(tts)写回 YAML。 - 调用
ttsService.reload(voiceProperties)重载 TTS 服务。 - 如果 API Key 更新,则同步调用
asrService.reload(voiceProperties)。
说明:
- TTS 更新逻辑与 ASR 对称。
- ASR/TTS 的 API Key 始终联动更新。
ASR 连通性测试
POST /api/llm-provider/voice/asr/test 测试 ASR 连接
返回:
Result<ProviderTestResult>
处理流程:
- Service 获取读锁。
- 从
voiceProperties.getQwen().getAsr()读取 ASR 配置。 - 解析 WebSocket URL:
wss默认端口为443。ws默认端口为80。
- 使用 TCP Socket 发起连接测试:
socket.connect(address, 5000);
socket.close();
- 连接成功时返回:
ProviderTestResult(success=true, "ASR WebSocket 连接成功: host")
- 连接失败时返回失败原因。
与 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 配置和文件配置的同步机制,让配置来源更清晰、运行时状态更可控。