接入大模型 API 并不复杂:前端提交模型 ID,后端找到对应的接口地址和 API Key,再发起一次 HTTP 请求即可。
但当系统同时接入免费模型、会员模型和按量计费模型后,真正困难的部分就不再是“如何调用模型”,而是如何建立可靠的权限边界与成本边界:
- 普通用户能否绕过前端限制,直接调用付费模型?
- 攻击者能否伪造
userId,冒充已授权用户? - 分享标题、搜索词生成等隐式功能,会不会意外使用付费模型?
- Redis 或网络发生故障时,系统应该继续放行,还是拒绝请求?
- 请求超时后立即重试,会不会造成重复计费?
- 付费模型的 API Key,是否可能被发送到错误的服务器?
本文以一个 Spring Boot 2.7 多模型聊天系统为例,介绍如何将模型路由、身份验证、模型授权、额度控制和 HTTP 出站调用收敛到同一个安全边界中。
为了便于说明,文中使用“用户 ID 为 1 的账户可以使用 Kimi”作为示例。生产环境中,更适合使用独立的权限表、授权服务或 entitlement 机制,而不是长期依赖硬编码用户 ID。
核心原则只有一句:
前端只能表达调用意图,不能决定最终权限、接口地址、API Key 和计费策略。
一、不要让业务代码直接调用模型供应商
一个大模型系统中,真正会调用模型的功能往往不止聊天接口,还可能包括:
- 普通聊天;
- AI 率检测;
- 降 AI 率改写;
- 联网搜索词生成;
- 分享标题生成;
- 后台摘要任务;
- 内容分类与审核辅助。
如果这些功能各自读取 API Key、拼接接口地址并发送 HTTP 请求,安全逻辑就会迅速散落到不同服务中。
聊天接口可能检查了用户权限,标题生成任务却忘记检查;某个调用实现了限流,另一个内部服务却绕过了限流。更严重的是,只要任意一个业务服务可以直接读取付费 API Key,统一访问层就不再是真正的安全边界。
因此,所有离开服务器的 AI 请求都应经过统一的出站访问层:
Controller / Service
↓
AiProviderAccessService
├─ 认证主体校验
├─ 模型白名单校验
├─ 调用用途校验
├─ Provider 与 Endpoint 校验
├─ 额度与并发租约控制
└─ 请求生命周期管理
↓
ProviderEndpoint
↓
模型供应商 API
只有 AiProviderAccessService 或其底层组件可以:
- 读取供应商 API Key;
- 创建带认证头的 HTTP 请求;
- 选择模型供应商地址;
- 获取和释放计费租约;
- 控制超时、重试和重定向行为。
业务代码只负责提交“调用什么能力”的请求,不直接接触凭据和底层 HTTP 客户端。
这相当于为整个系统设置了一道“AI 出站防火墙”。
二、模型配置不能只是一个字符串
在多模型系统中,不同模型之间的差异远不止模型名称。
有些模型支持图片,有些支持工具调用;有些返回独立的推理字段,有些把推理过程混在正文中;不同供应商还可能使用不同的参数格式、Token 字段和响应结构。
因此,模型应该被视为一个由服务端管理的策略对象,而不是任意字符串。
例如:
ai:
api:
models:
- id: "kimi-k2.6"
supportsReasoning: true
supportsImage: true
supportsTools: true
reasoningOutput: "native"
- id: "openai/gpt-oss-120b"
supportsReasoning: true
supportsImage: false
supportsTools: true
reasoningOutput: "content-carried"
free: true
default-model: "openai/gpt-oss-120b"
default-free-model: "openai/gpt-oss-120b"
后端通过 @ConfigurationProperties 将配置绑定为模型对象,并统一判断模型能力:
public boolean supportsImage(String modelId) {
ModelConfig model = findModelConfig(modelId);
return model != null
&& Boolean.TRUE.equals(model.getSupportsImage());
}
public boolean isFreeModel(String modelId) {
ModelConfig model = findModelConfig(modelId);
return model != null
&& Boolean.TRUE.equals(model.getFree());
}
这样做有两个直接好处。
第一,业务代码不再通过模型名称猜测能力。
第二,模型是否免费、是否支持图片、是否允许工具调用,都由服务端配置决定,前端无法自行声明。
对于付费属性,建议默认采用保守语义:除非服务端明确标记为免费,否则不要因为字段缺失而自动将模型视为免费。
应用启动时还应校验:
- 模型 ID 是否重复;
- 默认模型是否真实存在;
- 免费默认模型是否被标记为免费;
- 模型别名是否存在冲突;
- 付费模型是否配置了对应的 Provider 和安全端点。
配置错误最好在启动阶段暴露,而不是等到第一笔付费请求到来时才发现。
三、模型权限必须由服务端重新判断
假设系统规定 Kimi 是付费模型,仅允许用户 ID 为 1 的账户使用。
最危险的实现方式,是相信请求体中的 userId:
{
"userId": 1,
"model": "kimi-k2.6",
"message": "你好"
}
攻击者只需要修改请求参数,就可能冒充已授权用户。
正确做法是从服务端认证上下文中取得当前用户,并与业务请求中的用户身份进行比对:
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
if (authentication == null
|| !(authentication.getPrincipal() instanceof User)) {
throw new BusinessException(401, "用户未登录");
}
User principal = (User) authentication.getPrincipal();
if (!Objects.equals(principal.getId(), userId)) {
throw new BusinessException(403, "AI请求用户身份不匹配");
}
这样,即使某个调用方错误地向服务层传入了 userId=1,只要当前登录用户不是 1,请求仍然会被拒绝。
更稳妥的设计,是让统一访问层直接从认证上下文取得用户 ID,尽量不让上层业务代码传递可伪造的 userId:
Long currentUserId = currentUserService.requireCurrentUserId();
完成身份绑定后,还需要执行模型级授权:
if (isKimiModel(model)
&& userId.longValue() != KIMI_ALLOWED_USER_ID) {
throw new BusinessException(403, "Kimi仅限授权用户使用");
}
生产环境中,可以将这段判断替换为独立授权服务:
if (isKimiModel(model)
&& !modelEntitlementService.canUse(userId, model)) {
throw new BusinessException(403, "当前用户无权使用该模型");
}
这里必须坚持一个原则:
管理员、会员或前端展示权限,不能隐式覆盖付费模型的专属授权规则。
“用户是会员”和“用户可以调用某个按量计费模型”是两种不同的权限。将它们混为一谈,很容易因为一次普通的会员逻辑调整,意外打开付费模型入口。
账户禁用、封禁、注销等状态,也应在模型授权之前完成检查。
四、模型白名单必须使用精确匹配
另一类常见漏洞,是通过模糊匹配判断模型类型:
modelId.contains("kimi")
攻击者可以构造类似下面的模型 ID:
proxy/kimi-k2.6-experimental
如果后端只判断字符串中是否包含 kimi,这个未知模型就可能被错误地识别为 Kimi,并被路由到付费接口。
应当使用精确模型 ID 白名单:
private static final String KIMI_MODEL_ID =
"kimi-k2.6";
private static final String LEGACY_KIMI_MODEL_ID =
"moonshotai/kimi-k2.6";
public boolean isKimiModel(String modelId) {
return KIMI_MODEL_ID.equals(modelId)
|| LEGACY_KIMI_MODEL_ID.equals(modelId);
}
历史模型 ID 可以通过明确的别名规则规范化:
public String resolveModelId(String modelId) {
return isKimiModel(modelId)
? KIMI_MODEL_ID
: modelId;
}
推荐的处理顺序是:
String resolvedModel = resolveModelId(requestedModel);
ModelConfig modelConfig = requireConfiguredModel(resolvedModel);
其中,别名映射本身也必须采用精确匹配。只有服务端明确登记过的历史 ID 才能被规范化,不能根据前缀、后缀或关键词进行猜测。
所有不在服务端模型配置中的 ID,都应直接返回 400 Bad Request,而不是尝试转发给供应商。
这就是典型的 fail-closed:
无法确认模型是否合法时,默认拒绝,而不是继续尝试。
五、付费凭据必须绑定到固定出站地址
即使用户权限检查完全正确,只要付费接口地址可以被任意修改,系统仍然可能泄露 API Key。
例如,配置被错误地修改为:
https://attacker.example/v1/chat/completions
后端随后发送:
Authorization: Bearer <付费API Key>
此时,API Key 会被直接交给攻击者控制的服务器。
因此,付费凭据不能与任意 URL 组合使用。凭据、供应商和端点应被绑定为一个不可随意拆分的 Provider 配置。
以 Kimi 为例,发送请求前至少应验证:
- Scheme 必须是
https; - Host 必须精确等于
api.moonshot.cn; - 端口只能是默认 HTTPS 端口或
443; - Path 必须精确等于
/v1/chat/completions; - 不允许包含 User Info;
- 不允许包含 Query;
- 不允许包含 Fragment;
- HTTP 客户端不得自动跟随重定向。
核心判断可以写成:
URI uri = URI.create(endpointUrl);
boolean officialEndpoint =
"https".equalsIgnoreCase(uri.getScheme())
&& "api.moonshot.cn".equalsIgnoreCase(uri.getHost())
&& (uri.getPort() == -1 || uri.getPort() == 443)
&& "/v1/chat/completions".equals(uri.getRawPath())
&& uri.getUserInfo() == null
&& uri.getRawQuery() == null
&& uri.getRawFragment() == null;
if (!officialEndpoint) {
throw new BusinessException(503, "仅允许访问官方接口");
}
这里使用 getRawPath(),可以避免编码后的路径被解码成看似合法的结果。
同时应在 HTTP 客户端中显式关闭重定向:
RequestConfig requestConfig =
RequestConfig.custom()
.setRedirectsEnabled(false)
.build();
否则,即使初始地址是官方域名,供应商响应中的 302 或 307 也可能把带有认证信息的后续请求引导到其他地址。
端点校验最好执行两次:
- 应用启动时校验配置,尽早发现错误;
- 真正发送请求前再次校验,防止运行时配置变化或绕过。
这道检查不是为了防止用户“选错模型”,而是为了防止服务器主动把付费凭据发送到错误地址。
六、计费额度为什么要使用 Redis Lua
单机环境中,可以使用内存计数器限制请求次数。
但在多实例部署中,每个实例都有自己的本地计数。如果部署三台服务器,每台每分钟允许调用 10 次,那么系统实际可能放行 30 次。
付费模型的额度控制必须使用跨实例共享的存储,并且要保证多个限制之间原子执行。
Redis Lua 适合用来完成以下操作:
- 检查当前并发租约数量;
- 检查分钟请求数;
- 检查每日请求数;
- 检查每日预估用量;
- 计算本次请求加入后的额度;
- 如果任意限制超标,则不写入并直接拒绝;
- 如果全部通过,则创建租约、增加计数并设置 TTL。
关键点不是“把多个 Redis 命令放在一起执行”,而是:
检查额度和占用额度必须是同一个原子操作。
否则,两个请求可能同时读取到“剩余一个名额”,随后都成功增加计数。
Redis Key 可以设计为:
ai:kimi:usage:{1}:minute:202607151430
ai:kimi:usage:{1}:day:20260715
ai:kimi:usage:{1}:estimated-units:20260715
ai:kimi:usage:{1}:concurrent
其中 {1} 是 Redis Cluster 的 Hash Tag。
在 Redis Cluster 中,Lua 脚本涉及的多个 Key 必须位于同一个 Hash Slot。使用相同的 {1} 可以保证这些 Key 被路由到同一个槽位,从而在集群环境中原子执行。
系统可以同时限制:
- 每分钟请求次数;
- 每日请求次数;
- 同时进行的请求数量;
- 每日预估消耗量。
并发租约最好使用唯一随机 Token,而不是只维护一个简单计数。续租和释放时,Lua 脚本必须验证 Token,避免旧请求错误地释放新请求持有的租约。
如果 Redis 不可用,付费模型不能降级为“无限放行”:
catch (Exception e) {
throw new BusinessException(
503,
"计费保护暂时不可用,请稍后重试");
}
对于普通缓存,Redis 故障时允许降级可能是合理的。
但对于计费保护,Redis 故障时继续放行,意味着系统已经失去了请求次数、并发数量和费用上限。
因此,付费请求必须 fail-closed。
需要注意,每日预估用量是一种请求前保护机制,并不等同于供应商最终账单。系统可以在响应中取得实际用量后进行对账或修正,但不能因为后续可以对账,就放弃请求前的额度控制。
七、超时不是失败证明:不确定结果不能立即释放租约
这是计费接口中最容易被忽略的问题之一。
假设后端调用模型供应商时发生读取超时:
客户端 → 本系统 → 模型供应商
↑
已收到请求并开始处理
本系统没有收到完整响应,并不代表供应商没有收到请求,也不代表供应商没有开始计费。
如果后端在超时后立即释放并发租约,客户端就可以马上重试。此时,供应商可能同时处理第一次请求和第二次请求,形成重复调用甚至重复计费。
因此,请求结束状态至少要分为两类。
1. 已收到确定的终态响应
无论供应商返回:
200;400;429;500;
只要响应已经完整接收,本系统就知道该 HTTP 请求已经得到明确结果。
对于流式响应,应在正常读取到流结束标记或完整 EOF 后,才能认定为终态,而不是只收到响应头就立即标记完成。
此时可以释放并发租约。
需要注意,释放并发租约只表示“该请求已经不再处于运行中”,并不表示这次调用一定没有产生费用。对 429、500 等响应是否重试,仍应由独立的重试策略决定。
2. 请求结果不确定
以下情况通常属于不确定状态:
- 请求写出后发生读取超时;
- 连接在响应过程中被重置;
- 本地线程被中断;
- 客户端主动取消请求;
- 流式响应未正常结束;
- 网络异常导致无法确认供应商是否已接受请求。
对于这类异常,不应立即释放租约,而应等待 Redis TTL 自动过期,暂时阻止用户立即重试。
如果系统能够明确证明请求尚未离开本机,例如连接建立前就被拒绝,可以将其视为确定的本地失败。但在无法证明时,应采用保守策略。
伪代码如下:
try (ProviderEndpoint endpoint =
authorizeRequest(...)) {
ProviderHttpResponse response =
endpoint.executeJsonPost(body);
// 必须在完整收到响应后再标记
endpoint.markTerminalResponseReceived();
return response;
}
close() 根据请求状态决定是否释放租约:
boolean releaseLease =
terminalResponseReceived
&& lease != null;
if (releaseLease) {
lease.close();
}
如果请求状态不确定,close() 只清理本地资源,不主动释放 Redis 租约。
此外,付费 POST 请求应关闭 HTTP 客户端的自动重试。重试必须由业务策略显式触发,不能因为底层网络库认为某次异常“可以重试”,就在调用方不知情的情况下再次发送请求。
对于真实计费接口,宁可让用户多等待一段时间,也不要在无法确认请求状态时制造重复调用。
八、长连接必须使用租约心跳
流式响应可能持续数分钟,复杂任务甚至可能运行更久。
如果并发租约的 TTL 固定为 15 分钟,而某次请求超过 15 分钟,租约就会在请求仍然运行时过期。此时第二个请求可以再次进入,系统的并发限制就会失效。
因此,长请求需要租约续期机制:
- 请求发送前验证租约仍然有效;
- 按固定周期续期;
- 续期时校验唯一租约 Token;
- 续期失败后终止当前 HTTP 请求;
- 请求结束后取消心跳任务。
例如:
heartbeatExecutor.scheduleAtFixedRate(
this::renewLeaseSafely,
30,
30,
TimeUnit.SECONDS);
心跳周期应明显小于租约 TTL,避免一次短暂抖动就导致租约过期。
请求对象还需要注册取消函数:
endpoint.registerAbortAction(httpPost::abort);
当 Redis 续租失败时,系统不能只记录日志后继续请求。否则,HTTP 请求仍然在消耗模型资源,但计费保护已经失效。
因此,续租失败后应立即执行取消操作:
private void renewLeaseSafely() {
try {
lease.renew();
} catch (Exception e) {
abortCurrentRequest();
}
}
需要强调的是,取消本地 HTTP 请求并不能保证供应商停止计费,它只能尽快阻止后续内容继续生成。
如果取消发生时请求结果仍然不确定,租约也不应立即释放,仍应等待 TTL 到期。
这样,“Redis fail-closed”才不仅覆盖请求开始前,也覆盖长时间运行中的请求。
九、隐式 AI 功能不能偷偷使用付费模型
用户主动选择付费模型时,费用是可预期的。
但系统中还有大量隐式 AI 调用,例如:
- 创建分享链接时生成标题;
- 根据对话生成联网搜索词;
- 自动生成摘要;
- 后台生成标签;
- 为历史记录生成简介。
用户只是点击了“分享”或“保存”,并没有明确选择使用付费模型。
如果这些功能直接沿用当前会话模型,那么一个普通操作也可能触发付费请求。
因此,模型路由不能只考虑“用户是谁”,还要考虑“本次调用用于什么目的”。
可以为调用目的建立明确类型:
public enum AiInvocationPurpose {
USER_CHAT,
SHARE_TITLE,
SEARCH_QUERY,
BACKGROUND_SUMMARY
}
只有明确的用户聊天请求,才允许根据用户选择进入付费模型路由。辅助功能则统一使用免费或低成本模型。
例如:
String model =
aiProviderAccessService
.resolveNonKimiModelForUser(userId);
当前系统可以将普通默认模型、免费默认模型和隐式辅助模型统一设置为:
openai/gpt-oss-120b
即使用户 ID 为 1,分享标题也不会隐式调用 Kimi。
辅助功能还应设置更严格的:
- 最大输入长度;
- 最大输出 Token;
- 连接超时;
- 连接池等待超时;
- 响应读取超时。
例如:
endpoint.executeJsonPost(
requestBody,
5000,
5000,
15000);
当模型异常或超过 15 秒时,系统自动使用本地标题:
对话分享20260715
分享链接仍然可以正常创建。
这体现了一个重要原则:
AI 增强功能可以失败,但核心业务不能跟着失败。
十、按用户变化的模型列表不能被公共缓存
前端模型列表通常会根据用户身份动态变化。
例如:
- 用户 1 可以看到 Kimi;
- 其他用户只能看到免费模型;
- 未登录用户只能看到匿名可用模型。
如果共享代理、CDN 或浏览器错误缓存了用户 1 的响应,其他用户就可能拿到不属于自己的模型列表。
因此,模型列表接口应返回:
Cache-Control: private, no-store, max-age=0
Pragma: no-cache
其中:
private表示响应只能存储在私有缓存中;no-store表示不应存储该响应;max-age=0表示响应立即过期。
模型列表响应中也不应包含:
- API Key;
- 供应商内部地址;
- 计费账户信息;
- 仅供后端使用的路由配置。
需要再次强调:
隐藏前端选项只是用户体验优化,不能替代服务端权限验证。
即使普通用户手动构造 Kimi 请求,统一访问层仍然必须返回 403。
十一、将安全规则写成回归测试
计费权限系统不能只依赖人工测试。
它需要通过自动化测试,将权限、路由和请求生命周期规则固化为可执行规范。
至少应覆盖以下场景:
身份与授权
用户 1 + Kimi → 允许
用户 2 + Kimi → 403
匿名用户 + Kimi → 403
内部辅助身份 + Kimi → 403
用户 2 伪造 userId=1 → 403
已禁用的用户 1 + Kimi → 403
模型与路由
未知模型 ID → 400
包含 kimi 关键词的未知模型 → 400
合法历史别名 → 规范化为正式模型 ID
Kimi 指向非官方域名 → 503
Kimi 地址包含 Query 或 User Info → 503
官方地址返回重定向 → 不自动跟随
输入与额度
输入或图片超过限制 → 413
超过分钟请求额度 → 拒绝
超过每日请求额度 → 拒绝
超过每日预估用量 → 拒绝
超过并发限制 → 拒绝
Redis 计费保护不可用 → 503
非 Kimi 请求 → 不消耗 Kimi 额度
请求生命周期
请求结果不确定 → 不立即释放租约
完整收到非 2xx 响应 → 释放并发租约
流式响应未正常结束 → 不立即释放租约
心跳续租失败 → 终止当前 HTTP 请求
旧租约 Token → 不能释放新租约
付费 POST 网络异常 → 不自动重试
隐式功能
分享标题生成 → 使用免费模型
辅助模型超时 → 使用本地标题
辅助模型失败 → 不影响分享链接创建
这些测试的价值,不只是发现代码错误。
更重要的是,它们将计费规则和安全边界固化为可执行规范。以后即使有人调整默认模型、修改模型顺序、替换 HTTP 客户端或重构调用链,只要违反这些规则,测试就会立即失败。
十二、总结
接入付费大模型以后,后端需要解决的已经不只是 API 调用问题,而是一套完整的权限、网络和成本控制问题。
一个可靠的多模型架构,至少要关闭四类边界。
1. 身份边界
- 不信任前端提交的用户 ID;
- 以服务端认证主体为准;
- 校验认证用户与业务用户是否一致;
- 将付费模型权限与普通会员权限分离。
2. 模型边界
- 模型必须来自服务端白名单;
- 使用精确 ID 匹配;
- 只允许明确登记的历史别名;
- 模型能力和计费属性由服务端配置决定;
- 未知模型默认拒绝。
3. 网络边界
- 业务代码不能直接读取付费 API Key;
- 凭据必须绑定到固定 Provider;
- 付费请求只允许发送到官方 HTTPS 地址;
- 禁止 Query、Fragment、User Info 和自动重定向;
- 禁止底层 HTTP 客户端自动重试付费 POST 请求。
4. 成本边界
- 使用 Redis Lua 原子控制并发和额度;
- Redis 异常时对付费请求 fail-closed;
- 长请求通过心跳维持租约;
- 续租失败时终止当前请求;
- 不确定结果下不立即释放租约;
- 隐式 AI 功能与付费模型隔离;
- 辅助功能失败时回退到本地逻辑。
大模型系统真正上线后,最昂贵的问题往往不是模型回答得不够好,而是某个看似普通的内部功能,在没有权限边界和成本边界的情况下不断发出请求。
安全的多模型后端,本质上不是一个根据 modelId 执行 switch 的 HTTP 代理,而是一个完整的策略执行点:
它必须同时决定谁可以调用、可以调用什么、凭据可以发往哪里、最多可以花费多少,以及网络结果不确定时应该如何处理。
把模型调用视为一种受保护的出站资源,而不是普通的 HTTP 请求,才是多模型应用真正走向生产环境的开始。

这下是真吓哭了