Agent 平台

用量要算钱,那密钥就不能给客户端

通过短期 Token、设备绑定、实时复校与 fail-closed 策略,阻断客户端绕过网关直连上游的三类路径。

按用量计费的 LLM 中转服务面临一个根本矛盾:客户端是完全攻击者可控的代码,应用逻辑最终依赖模型 API,但鉴权与计费只能在服务端进行。一旦客户端能绕过网关直连上游,计费和配额就失效了——用户可以白嫖,也可以把你的中转当成代理给别人用。这不是"更好的用户体验"问题,而是商业模式破裂。

绕过的三条路径与对应防堵

路径 1:客户端持有上游密钥

当前常见的方案是:桌面端登录后拿到平台的 API 密钥,本地保存,直接用这个密钥去请求上游模型。客户端代码大致像这样:

// 不安全的旧做法
const apiKey = readLocallyStoredApiKey(); // 明文或"加密"存储
const response = await fetch('https://upstream-api.example/v1/messages', {
  headers: { 'x-api-key': apiKey },
  body: messagePayload
});

这个方案的问题在于:密钥是长期的、可移植的、不可吊销的。即使密钥被加密存储在本地,一个决心充分的用户可以解密、导出,然后用 curl 直连上游——根本不需要走你的客户端。更糟的是,这个密钥可能被多个用户共享(意外泄露或故意倒卖)。

防堵方案:不向客户端下发真实的上游密钥,只下发短期凭证。具体实现是引入一个专用的 llmToken(不同于普通的 accessToken):

  • 签发:用户请求 /api/llm/token 端点时,后端校验 accessToken 有效→用户处于激活状态→当前设备的 session 未被撤销,然后生成一个 JWT 格式的 llmToken,TTL 设为 1–2 小时(足以覆盖一次长对话,但不会无限期存活)。
  • 绑定:llmToken 包含 userIdsessionIddeviceId 三个标识。前两个用于账号级的全局控制(用户被禁用、session 被远程踢下线),最后一个用于防倒卖(即使密钥被导出,也只能在绑定的设备上用)。
  • 校验:每次 LLM 调用网关时,网关验证签名→拆出 userId/sessionId/deviceId→实时复校用户状态与 session(不依赖 token 的 TTL)。
// 防堵后的安全做法
const llmToken = await fetchLlmToken(accessToken, deviceId); // 短期 token
const response = await fetch('https://gateway.example/api/llm/v1/messages', {
  headers: { 'authorization': `Bearer ${llmToken}` },
  body: messagePayload
});

相比之下,即使用户拿到了 llmToken,这个 token 也只能在原设备上用 1–2 小时。过期后需要重新签发,而签发必须经过 accessToken 校验(意味着如果账号被禁用或 session 被撤销,签发会立即失败)。不再有永久密钥在客户端浮动。

路径 2:直接改配置指向上游

客户端代码中通常有一个 baseUrl 配置,指向网关:https://gateway.example/api/llm。攻击者可以修改客户端代码或配置文件,改成直接指向上游:https://upstream-api.example

// 攻击者修改配置后
const baseUrl = 'https://upstream-api.example'; // 绕过网关
const apiKey = storedToken; // 不管这个 token 从哪来
const response = await fetch(`${baseUrl}/v1/messages`, { ... });

防堵方案:配置不要让客户端掌握。关键的网关地址与上游地址都应该由服务端下发或在部署时写死,而不是嵌在客户端配置文件里。同时,客户端应该有一个"配置验证"环节——比如在启动时校验 baseUrl 是否和预期值匹配,或者网关做请求签名验证。

但更根本的防堵来自路径 1 的解决方案:即使改了 baseUrl,也改不了 llmToken 的签发机制。直连上游时,你没有有效的、绑定了 deviceId 的短期 token,请求会直接失败。上游服务也不认识你的 llmToken(它只认 sub2api 的密钥),所以伪造一个不可能成功。

路径 3:复用他人凭据

如果 llmToken 没有绑定,或绑定得太松散,用户 A 可以把自己的 token 分享给用户 B 用。这样用户 B 就能免费消耗用户 A 的额度,甚至整个平台的额度都被少数用户共享。

防堵方案

  1. deviceId 绑定:llmToken 中包含发起签发请求的设备 ID。网关在验证 token 时,检查当前请求的设备 ID 是否和 token 中的 deviceId 一致。设备 ID 可以基于硬件特征(MAC 地址、CPU 序列号)或操作系统本地生成的 UUID。设备难以伪造(虽然 Electron 应用中可以被修改,但需要重新编译客户端)。
  2. sessionId 绑定:llmToken 还绑定了当前登录的 session ID。如果用户 A 在设备 D1 登录,生成的 token 包含 sessionId='sess-abc'。用户 B 不可能有相同的 sessionId(除非他也登录用户 A 的账号,但那时就真的是同一个账号了)。
  3. 即时吊销:单设备互踢(用户 A 在另一台设备登录时,D1 的 session 被撤销)或远程封禁都会立即生效。网关不仅校验 token 的签名和 TTL,还会查一遍数据库确认 session 未被撤销。所以即使倒卖者拿到了别人的 token,一旦源用户被禁或 session 被踢,这个 token 就废了。

网关的计费保障:金额一致性的困境

防住绕过只是第一步,还需要保证计费的完整性:请求被处理,就必须被正确计费;计费成功了,请求才能真正完成。否则会出现两类灾难:

  1. 请求已发出但计费失败 → 白嫖了额度
  2. 计费成功但请求被中断 → 用户被重复扣费

待补:事务一致性 这涉及数据库事务、网关层幂等键设计等细节。当前素材中关于"请求处理与计费如何保证在同一事务边界内"的设计不足。

实践中采用的策略是 fail-closed

  • 余额查询走缓存(Redis 或进程内 LRU),TTL 设为 30–60 秒。命中且大于 0 就放行;命中且小于等于 0 就直接拒绝(HTTP 402);未命中则同步查一次上游服务。
  • 上游服务失败时(网络问题、超时)拒绝该请求(不放行,也不允许用户花钱)。这比"放行再补扣"更宁可一时用户不可用,也不容忍"余额未知却已消费"的场景。
  • 缓存 TTL 窗口内(30–60 秒),同一用户的余额不会从正变负而立即被拦截。这个窗口内的透支是允许的代价,换来的是减少与上游服务的往返压力。缓存失败后有限重试(通常 1 次),仍失败就拒绝。

网关的实时复校:不相信 Token 的 TTL

即使 llmToken 的 TTL 设成 2 小时,不能依赖这个 2 小时直到 token 过期。如果用户账号在 1 小时后被禁用或 session 被远程踢下线,第二小时的请求不应该还能用旧 token 成功发出。

网关的每一次请求处理链都包括:

  1. 提取请求头中的 token(可能来自 x-api-keyauthorization: Bearer
  2. 验证签名与 TTL 有效
  3. 从 token 中拆出 userId、sessionId、deviceId
  4. 实时查数据库:用户的 status 字段是否仍为 active
  5. 实时查数据库:该 sessionId 对应的 revokedAt 是否为空
  6. 余额门控:该用户的余额是否 > 0
  7. 限流:该用户的请求速率是否超过限额
  8. 通过全部检查后,用服务端保管的上游密钥代替 token,转发请求

前三步是 token 的格式与密码学验证,不需要 I/O。后四步都走数据库查询,引入延迟但保证了最新状态。这样即使 token 本身还有 1 小时有效期,如果用户状态变了,下一次请求立即失败。

限流与防刷

同时还需要防止单个用户刷爆系统。llmToken 签发端点(/api/llm/token)本身需要限流,防止用户持续刷 token。/api/llm/v1/* 代理端点需要按用户限流,比如限制每分钟最多 60 个请求。如果有用户超过限额,返回 HTTP 429 并告知。

限流计数在多实例部署时走 Redis 保证一致,单机时可用进程内 LRU 缓存(精度够用)。

错误分类与用户体验

网关需要明确地区分不同的失败原因,这样客户端才能正确响应:

错误HTTP 状态原因客户端处理
llm_token_invalid401token 签名失败、过期或格式错重新签发,签发失败则回登录页
user_disabled403账号被禁用提示用户账号已禁用,登出
session_revoked401该 session 被撤销(异地登录踢下线)提示"已在其他设备登录",回登录页
insufficient_balance402余额不足提示充值,不做重试
rate_limited429请求过于频繁指数退避重试
upstream_error502上游服务错误或超时提示"服务暂时不可用,请稍后重试"

设计权衡与代价

这套防绕过方案的代价是什么?

  1. 网关成为热路径:每次 LLM 调用都必须经过网关,包括数据库查询。高并发时网关可能成为瓶颈。缓解方法是无状态设计(水平扩展)+ 缓存(减少数据库压力)+ 上游超时控制(防止卡住)。
  2. 可用性与安全的权衡:fail-closed 策略意味着当上游服务的余额查询接口不可用时,所有用户都无法发起 LLM 调用。这是一个 all-or-nothing 的决策——宁可整个平台短时间无法用,也不允许"余额未知但已消费"的场景。代价是需要对上游服务的可用性做严格监控与告警,并预留人工切换开关(紧急放行)。
  3. 长会话的 Token 重取:1–2 小时的 TTL 意味着长对话可能中途需要重取 token。由于 Electron 应用运行中无法热更环境变量,需要在应用层实现"token 即将过期时自动重取"的逻辑。如果 token 过期后仍在续取,accessToken 本身可能也已过期,这时需要用 refreshToken 刷新。处理不当会导致长对话中途断连。
  4. 设备 ID 的可信度:设备 ID 基于硬件或本地 UUID,Electron 应用中理论上可以被修改(重新编译、patch 二进制)。这个防护针对的是"非专业用户倒卖 token"的场景,不能防住"决心充分的开发者自己修改客户端"。但那类用户通常会干脆把客户端改成绕过整个 login 流程,直接注入自己的上游 key,不会去倒卖 token。

这些代价都是可接受的,因为目标不是"让绕过物理上不可能"(这在客户端代码的场景下确实不可能),而是"让绕过没有动机":白嫖你中转的人通常不是付费客户,如果他们能自己持有密钥,还会用你的中转吗?关键是挡住"账号体系坍塌、流量无法计费"的最坏情形。

小结

LLM 网关的账号鉴权不只是验证身份,而是在客户端完全可控的前提下,通过短期凭证 + 多维绑定 + 实时复校 + fail-closed 缓存这四层防线,让绕过失去意义。每一层都有明确的威胁模型:路径 1 针对"密钥倒卖",路径 2 针对"配置改写",路径 3 针对"凭据复用",底层的计费保障针对"透支风险"。没有一层是万能的,但叠加起来足以在商业上可接受的代价范围内保护好平台的计费完整性。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。