项目里要接 AI 大模型了?我把多厂商网关、场景配额、调用日志一次性做成了脚手架
导读:业务方提需求"给客服加个 AI 助手"“给简历做个 OCR 识别”,一个接一个。最早是各业务自己 new 一个 SDK 调,密钥散在 yml 里,出了事还没法查是谁调的。后来我把 AI 调用收敛成了一个独立模块:多厂商 SPI、场景配置、配额限流、调用日志全在里面。
先交代一下当时的状态。项目里要接大模型的地方越来越多:智能客服、简历 OCR、内容润色、知识库问答。一开始图省事,每个功能直接引 SDK,DeepSeek 的、通义的、OpenAI 兼容的一起上。半年后发现三个问题:密钥管理混乱(有人把 key 写进 yml 提交了)、没法审计(谁在什么时候调了多少 token 完全不知道)、换厂商要改业务代码。
于是我在脚手架里加了qkl-ai模块,核心思路就一句话:业务方不关心用的是哪家大模型,只认场景编码。
SPI 抽象:所有厂商长一个样
先把各家 SDK 的差异吃掉。定义一个AiModelClient接口,流式、非流式、对话、Embedding 都收敛进去:
publicinterfaceAiModelClient{/** 厂商编码,如 DASHSCOPE / DOUBAO / OPENAI */StringproviderCode();/** 非流式对话 */ChatResultchat(ChatRequestrequest);/** 流式对话(SSE) */voidchatStream(ChatRequestrequest,StreamCallbackcallback);/** 文本向量化,RAG 用 */Listembed(Listtexts);/** 连通性测试,后台"测试连接"按钮用 */ConnectivityTestResulttestConnectivity();}实现类各自继承一个AbstractOpenAiCompatibleClient——现在主流厂商都兼容 OpenAI 协议,改个 baseUrl 和鉴权头就行,真正要手写协议适配的很少。我用的是 DashScope、Doubao、OpenAI 三家,全部走这个抽象:
@ComponentpublicclassDoubaoCompatibleClientextendsAbstractOpenAiCompatibleClient{@OverridepublicStringproviderCode(){return"DOUBAO";}@OverrideprotectedStringresolveBaseUrl(AiProviderprovider){return"https://ark.cn-beijing.volces.com/api/v3";}@OverrideprotectedMapresolveHeaders(AiProviderprovider){returnMap.of("Authorization","Bearer "+provider.getApiKey());}}客户端注册进AiModelClientRegistry,按providerCode取,业务层永远不知道具体实现是谁。
场景配置:业务方只传场景编码
接口暴露给业务方的就一个方法:chat(scene, messages)。场景对应哪家厂商、哪个模型、什么系统提示词、温度多少,全在qkl_ai_scene表里配:
// 场景实体关键字段privateStringscene;// 场景编码:CUSTOMER_SERVICE / RESUME_OCR / CONTENT_POLISHprivateStringproviderCode;// 走哪家厂商privateStringmodelCode;// 模型编码privateStringsystemPrompt;// 系统提示词privateIntegertemperature;// 采样温度privateIntegerstatus;// 1 启用 0 停用加一个新场景,运营在后台插一条记录,开发一行代码都不用改。之前那种"新场景要改代码发版"的日子算是过去了。
配额 + 限流:防的是失控不是防人
AI 调用是要花钱的,必须有两道闸。第一道是租户维度日 token 配额,第二道是Redis 窗口限流:
publicvoidconsume(StringtenantId,inttokens){AiQuotaquota=getOrCreate(tenantId);resetIfNewDay(quota);// 跨天自动归零longused=quota.getDailyTokenUsed()==null?0L:quota.getDailyTokenUsed();quota.setDailyTokenUsed(used+tokens);quotaMapper.updateById(quota);}publicvoidassertAllowed(){Stringidentity=currentUser();Stringkey=RedisKeys.rateApi("ai.chat","user",identity);Longcurrent=redisTemplate.opsForValue().increment(key);if(current!=null&¤t==1){redisTemplate.expire(key,Duration.ofSeconds(Math.max(1,aiProperties.getRateLimitPeriod())));}if(current!=null&¤t>aiProperties.getRateLimitCount()){thrownewQklBizException(ErrorCode.RATE_LIMITED,"操作太频繁,请稍后再试");}}配额超了就抛"当日额度已用完",前端友好提示;限流超了就 429 类错误,防止脚本刷。
密钥加密 + 调用日志:出事能追责
密钥不落 yml,存库的时候加密,用AiSecretCipher包了一层,解密只在真正发起调用时进行:
privateStringdecryptSecret(AiProviderprovider){returnsecretCipher.decrypt(provider.getApiKeyEncrypted());}每次调用无论成败都落qkl_ai_call_log:场景、模型、输入输出 token 数、耗时、结果状态、失败原因。日志保留 90 天,出了事故翻日志就能还原"谁、什么时候、调了什么、花了多少 token"。
踩坑记录:总开关一关,整个模块静默消失
问题现象:本地没配 AI 相关配置,启动项目后调用/admin/ai/scene接口,返回70001,页面上一脸懵。
排查过程:翻代码发现qkl.ai.enable默认是false,AiAutoConfiguration用@ConditionalOnProperty控制,开关没开时整个模块的 Bean 都不注册,管理端接口统一返回业务码 70001"模块未启用"。
定位思路:这个设计本身没问题——AI 密钥是敏感配置,不该让没开通的租户看到界面。但报错信息太含糊,前端看不出是"没启用"还是"接口坏了"。
最终解决:管理端接口把 70001 的 message 改成"AI 模块未启用,请在配置中设置 qkl.ai.enable=true",前端根据错误码弹出明确提示。另外约定:生产环境密钥必须走配置中心/环境变量注入 cryptoSecret,空 salt 回退逻辑只允许开发环境用,防止线上密钥可逆。
可直接复用的清单
- 厂商差异用SPI 抽象 + 注册中心吃掉,业务层只认场景编码;
- 场景表配"厂商+模型+提示词",新场景后台配置即上线;
- 配额按租户按天,跨天自动归零;限流用 Redis increment + expire 窗口;
- 密钥加密存库、调用时解密,绝不进 yml 明文;
- 每次调用落日志,保留 90 天,审计追责全靠它;
- 模块总开关用
@ConditionalOnProperty控制,未启用时错误提示要友好。
这套 AI 网关是 qkl-boot 脚手架里的标准模块。项目源码:https://gitee.com/gzqkl/qkl-boot