夜雨聆风学习资料网

ARTICLE · 1054277

从一次 P0 事故说起:如何用插件链构建大模型网关的多租户隔离与安全防线

从一次 P0 事故说起:如何用插件链构建大模型网关的多租户隔离与安全防线

从一次 P0 事故说起:如何用插件链构建大模型网关的多租户隔离与安全防线

用一条可实现、可演练、可对账的插件链,解决 LLM Gateway 的多租户隔离、流式限额、数据外发与 Agent 工具越权问题。

很多团队把大模型网关理解为“兼容 OpenAI API 的反向代理”。在单一业务、少量流量时这没有问题;但一旦客服、知识库、试用租户和 Agent 共用模型与供应商额度,网关就变成了身份、数据、资源、内容和执行权限的交汇点。它的职责不是让模型永远不出错,而是让错误无法突破可验证的边界。

本文中的事故数据是基于生产模式整理的脱敏合成案例,用于说明设计决策,不能替代所在行业的合规与风险评估。


1. 02:13 的 P0:一个试用租户如何影响所有人

某 B2B SaaS 同时提供客服 Copilot、内部问答和试用版智能助手。上线时约 80 个租户,Gateway 复用一组供应商项目和私有推理集群。工程团队已有 API Key 校验和 IP QPS 限流,因此认为“基础安全已具备”。

一次试用租户压测暴露了真实链路:

02:13:07  trial-tenant 发起约 3,000 req/s,其中 70% 为 streaming
02:13:18  活跃 SSE 从 260 增至 4,800;GPU 队列持续增长
02:13:26  enterprise 租户 TTFT P95 从 1.1s 升至 14.8s,开始 429/502
02:14:03  Redis 短暂超时;限流器 fail-open,流量继续进入上游
02:15:17  共享 Provider 的分钟 Token 配额耗尽
02:18:40  排查发现:已认证的 Key 可用他人 conversation_id 读取会话元数据

事故不是某一个 Bug,而是几个“看似合理”的局部实现叠加:

当时做法
为什么失效
应有控制
API Key 有效即放行
认证未绑定资源授权
认证后生成唯一可信 TenantContext
按 IP 做 QPS
NAT、脚本池和 Token 成本都不等价
按租户/模型的速率、并发、Token、周期预算
流入口扣一次请求
45 秒 SSE 长期占用连接与槽位
获取并发租约、心跳续租、结束释放
Redis 异常全放行
保护失效时反而扩大爆炸半径
保守本地限流与风险分级降级
FindByID
 后再判断 tenant
漏一个调用路径就跨租户
数据访问层强制 tenant 条件
模型发起 Tool 即执行
Prompt Injection 可变成真实副作用
工具执行层再次授权与业务校验

复盘后,团队没有简单地把 QPS 调低,而是把 Gateway 重新定义为“共享 AI 能力的策略执行点”。上线修复后,试用租户再以 10 倍突发压测时仅自身收到 429;付费租户 TTFT P95 保持在 1.6s 内,Provider 的全局额度也未被打穿。


2. 先画边界:租户上下文是整条链的根

一个生产级 Gateway 至少要同时守住六条边界:

边界
关键问题
常见后果
身份
调用方是谁
泄露/过期 Key 继续使用
租户
能访问谁的数据
跨会话、跨文件、跨向量库读取
资源
能消耗多少
单租户打满连接、GPU 或供应商额度
内容
哪些数据可外发/留存
PII 进入 Provider 与日志
行为
模型最终允许做什么
Agent 越权退款、发信、SSRF
审计
能否解释一次决定
无法复盘、无法对账
Client
  │  API Key / JWT
  ▼
Auth ──► Principal { tenant_id, key_id, scopes, plan }
  │                       │
  ▼                       ├── Resource ownership
Policy snapshot            ├── Rate / concurrency / budget
  │                       ├── DLP / Prompt guard
  ▼                       ├── Route constraints
Gateway execution          └── Tool authorization
  │
  ├── Provider / private GPU
  ├── RAG / object storage
  └── Business tools

最重要的约束是:tenant_id 只能由已验证凭证产生。客户端请求体、Header、RAG filter 和模型 Tool 参数中的 tenant_id 都是不可信输入,必须忽略、覆盖或拒绝。

威胁模型

方案主要应对:泄露的客户端 Key、恶意租户猜 ID、超长 Prompt 与重试风暴、RAG 文档中的间接注入、带副作用的 Tool、Redis/Provider/审计系统局部故障。它不替代终端用户 IAM、模型训练数据治理、供应商合同审查或节点安全。


3. 插件不是一根直线:请求前、流中、收尾三阶段

把 Auth -> Limit -> Provider -> ToolGuard -> Audit 画成单线容易误导。Tool Call 发生在模型流运行期间,SSE 首字节后也不能再返回普通 HTTP 错误。应按生命周期组织:

首字节前(可返回 HTTP 4xx/5xx)
Trace → Auth → Policy → Ownership → Rate → Acquire lease
      → Reserve tokens → Input DLP/Prompt guard → Route

流运行中
Provider chunks → lease heartbeat → incremental output guard → SSE client
Provider tool call → authorize → business execution → tool result → Provider

无论成功、取消、断流、panic 都进入收尾
Settle / compensate → release lease → audit event → metrics

3.1 一个足够小的可信上下文

type Principal struct {
    TenantID string
    KeyID    string
    Subject  string// 可选:最终用户或服务身份
    Scopes   map[string]struct{}
    Plan     string
}

type RequestContext struct {
    RequestID string
    Principal Principal
    Policy    *PolicySnapshot // 请求开始后不可变
    Route     RouteDecision
    Reserve   *Reservation
}

不使用 map[string]any 或全局变量传递租户状态。策略热更新只影响新请求;已经建立的 SSE 固定使用开始时的策略版本,并把 policy_version 写入审计事件。

3.2 收尾逻辑不能靠“记得调用”

获得并发租约或 Token 预留后立即注册收尾;但要认识到进程崩溃不会执行 defer,所以还需要租约 TTL 与持久化对账。

funcRun(ctx context.Context, rc *RequestContext, next Handler) (err error) {
    lease, err := leases.Acquire(ctx, rc.Principal.TenantID, rc.RequestID, rc.Policy.MaxConcurrency)
if err != nil { return err }
deferfunc() { _ = leases.Release(context.WithoutCancel(ctx), lease) }()

    reservation, err := quota.Reserve(ctx, rc.Principal.TenantID, rc.RequestID, estimate(rc))
if err != nil { return err }
    rc.Reserve = reservation
deferfunc() {
// 函数必须幂等;失败写 durable reconciliation queue,不能只打日志。
        _ = quota.Finalize(context.WithoutCancel(ctx), reservation.ID, err)
    }()

return next(ctx, rc)
}

鉴权、资源归属、预算预留和输入治理必须发生在首个 SSE 字节前。流开始后发生 Provider 错误或输出阻断时,应发送受控 SSE error event 后断开,而不是伪造 HTTP 400。


4. API Key:认证后才开始授权

推荐网关签发两段式客户端 Key:

gw_live_<key_id>_<secret>

key_id 定位数据库记录,secret 使用 CSPRNG 生成且只回显一次。数据库保存独立 pepper 的 HMAC,不保存明文或可逆密文;网关调用供应商所需的 Key 则应存于 KMS、Vault 或受控 Secret Manager。

CREATE TABLE api_keys (
  key_id VARCHAR(64PRIMARY KEY,
  tenant_id VARCHAR(64NOT NULL,
  secret_hmac BYTEA NOT NULL,
  scopes_json JSONB NOT NULL,
  status VARCHAR(16NOT NULL,
  expires_at TIMESTAMPTZ,
  last_used_at TIMESTAMPTZ
);

认证插件只做一件关键事:以常量时间比较 secret,将数据库中的 tenant_id、scope、plan 写入 Principal。认证成功不代表可以读任意会话、选择任意模型或调用任意 Tool。

还应支持双 Key 轮转、即时吊销、过期、异常地域/速率告警。完整 Key、Authorization Header、secret 都不得出现在 access log、trace、错误信息或 metrics label 中。


5. 真正的租户隔离:查询接口必须要求 tenant

错误写法是先按资源 ID 查询、再由调用点补权限判断;任何漏检路径都会变成越权。让 repository 接口本身要求 tenant:

SELECT id, title, model, created_at
FROM conversations
WHERE tenant_id = $1AND id = $2;

无记录统一返回 404,避免泄露“该资源存在但不属于你”。同一规则覆盖所有资源:

conversation  → tenant_id + conversation_id
file          → tenant object prefix + IAM
vector store  → 服务端注入 tenant namespace/filter
cache         → tenant_id 纳入 key,调用方不可指定前缀
memory        → tenant_id + subject + conversation_id
tool secret   → tenant-scoped secret reference
usage ledger  → tenant_id + request_id

RAG 是最容易被忽略的一层。服务端应在原始请求过滤器上强制叠加 tenant 条件,不能原样信任客户端提交的 filter。重要数据还应有 Row Level Security、独立 collection/schema 或租户级 IAM 作为纵深防御。


6. 限流不能只看 QPS:四道闸门,一份账本

一个 300ms 的短问答和一个 60 秒、数万 Token 的流式请求都算“一次”,并不公平。建议分开治理:

闸门
解决的问题
典型维度
Request rate
瞬时洪峰
tenant / key / user / global
Concurrency
长连接、推理槽位
tenant / model / provider
Token reserve
超大输入和输出上限
tenant / model / project
Period budget
每日/月度成本
tenant / currency / model

6.1 Redis 令牌桶:参数先校验,额度分层保护

Redis Lua 可将读取、计算和扣减变为一次原子操作,但脚本不应代替输入校验。策略加载阶段必须保证 capacityrefill_raterequested 均为正且不超过安全上限,避免零除、负数和超大数值精度问题。

逐租户限流还不够:共享供应商项目时,必须额外有 Provider 级与全局级令牌桶,否则每个租户都“合规”仍可共同耗尽上游配额。限流 key 由服务端构造,例如 rl:v1:tenant:<id>:model:<id>,不可拼接未验证的客户端字段。

6.2 SSE 并发:必须续租,也必须幂等

常见错误是在 ZSET 写入 30 秒 deadline 后就不再处理。若 SSE 流 60 秒,30 秒时仍在运行的请求会被清理,新请求得以进入,实际并发超过限制。

正确的获取规则是:先清理过期成员;若同一 request_id 已持有租约,只刷新 deadline;否则检查容量后新增。流期间按 TTL 的一半频率 heartbeat。以下脚本省略了调用前的数值校验,但展示关键幂等语义:

-- KEYS[1]=cc key
-- ARGV[1]=now_ms ARGV[2]=deadline_ms ARGV[3]=limit ARGV[4]=request_id
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
local existing = redis.call('ZSCORE', KEYS[1], ARGV[4])
if existing then
  redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4])
return {1'renewed'}
end
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[3]) then
return {0'limited'}
end
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4])
redis.call('PEXPIRE', KEYS[1], math.max(1000tonumber(ARGV[2]) - tonumber(ARGV[1]) + 60000))
return {1'acquired'}

正常结束删除 member;崩溃时依赖 TTL 回收。heartbeat 持续失败不能被静默吞掉:高成本 streaming 应中止或切入明确、很低的本地保护上限。

6.3 Token 预留和结算不是同一件事

调用前预留:

reserved_tokens = estimated_input_tokens + accepted_max_output_tokens

结束时优先使用 Provider 返回的真实 usage;缺失时才使用当前模型的 tokenizer估算。使用持久化、幂等的状态机,而不是普通日志:

RESERVED → SETTLED(actual usage)
         → CANCELLED(no provider work)
         → PARTIAL(interrupted stream)
         → RECONCILE_PENDING(settlement write failed)

ReserveSettleCancel 都以 (tenant_id, request_id) 或 reservation_id 去重。客户端重试、Provider 重放、进程重启都不能重复扣费。结算写失败后进入 durable queue/WAL,由 reconciliation job 补偿;这也是“账本”与“打日志”的区别。


7. Redis 挂了:按风险降级,而不是统一开关

故障依赖
推荐行为
理由
API Key、资源归属
fail-closed
不用隔离换可用性
分布式 rate limit
本地保守限流 + Provider 全局保护
避免无限放行
并发租约
高成本 streaming fail-closed 或极低本地上限
防连接与推理槽位耗尽
周期预算账本
高成本接口 fail-closed
防成本失控
Prompt 检测
按场景风险;Tool 授权保持严格
检测不应是唯一边界
审计 sink
durable queue;队列满按合规级别拒绝
不能静默丢审计
审批服务
fail-closed
不得自动绕过审批

本地 emergency limiter 应从最近的已验证策略快照读取,设置总量上限与过期淘汰。简单的 map[tenant]*Limiter 若不清理,在攻击者伪造大量租户标识时会转变为内存风险;而 tenant 标识必须本就来自认证结果。


8. Prompt Injection 与 PII:降低风险,但不承诺“正则根治”

Prompt 防御应分四层:

L1 输入规范化、规则、已知攻击信号
L2 语义风险分类或专用安全模型
L3 最小检索与上下文分层:文档/网页/Tool result 均视为不可信数据
L4 Tool / Action authorization:最终决定模型最多能做什么

RAG 文档可以提供事实,不能变成系统指令。Prompt 模板应显式区分系统策略、已认证用户意图、检索文档和工具结果。规则检测适合做快速信号和观测,不应以“未命中”为安全结论。

敏感数据治理至少有四条路径:

Client → Gateway → Provider
Gateway → logs / traces / audit
Provider → Gateway → Client
Gateway → Tool / downstream business service

邮箱、手机号、明显 secret 可先用正则脱敏;人名、地址、合同号、行业字段和 JWT/连接串则需要 DLP、NER、业务字典及租户策略。客服场景如需保持实体关联,可将 13800138000 替换为 <PHONE_1>,映射仅短期保存、受 RBAC 与审计保护,不能演变为新的长期明文库。

流式输出的限制尤其需要写清:已经发送给客户端的字节无法撤回。小窗口缓冲可降低首段风险;真正高风险的内容必须采用“生成—审核—发送”或受限结构化输出,并接受 TTFT 增加。

日志默认只存 request、策略版本、模型、usage、风险决策与状态。内容采集必须显式开启、脱敏、短保留、严格 RBAC 和访问审计。tenant_id_hash 只能辅助检索,不是匿名化保证。


9. Agent 安全的落点:工具执行层重新做业务授权

模型的 Tool Call 是不可信的执行建议,不是授权凭证。无论 Prompt Guard 是否命中,工具适配器都必须检查:

scope 是否具备;当前 tenant/plan/region 是否允许该 tool;
参数 schema、金额、数量和目标范围是否合规;
目标资源是否属于本租户;是否存在有效幂等键与审批;
出网 URL 是否在 allowlist,且不访问 metadata 或内网网段。

不要把 tenant_id 混进模型可编辑的参数 map。将 Principal 作为工具执行器的独立输入,并在业务库再次以 tenant 查询:

funcExecuteRefund(ctx context.Context, p Principal, in RefundInput)error {
if !hasScope(p, "tool:refund.create") { return ErrForbidden }
if in.Amount <= 0 || in.Amount > maxRefund(p.Plan) { return ErrInvalidAmount }

    order, err := orders.GetForTenant(ctx, p.TenantID, in.OrderID)
if err != nil { return ErrNotFound }
if in.Amount > order.RefundableAmount { return ErrInvalidAmount }
if requiresApproval(in.Amount) && !validApproval(in.ApprovalID) { return ErrApprovalRequired }

return refunds.CreateIdempotently(ctx, p.TenantID, in.IdempotencyKey, order, in.Amount)
}

独立的租户级工具凭证、金额/批量上限、二次确认和可撤销审批,才是把“模型说错了”限制为“不会造成实际损失”的最后防线。


10. 路由、策略与部署:可用性不能突破安全约束

路由先筛掉不满足数据区域、保留策略、模型能力、租户计划和 Tool 能力的候选,再按健康度、延迟和成本排序。主 Provider 故障也不能把“仅限新加坡处理”的数据切去美国,或降级到一个无法执行同等约束的供应商。

策略热更新采用以下模式,并保留上一可用版本:

watch → parse → validate → build immutable snapshot → atomic swap

校验范围包括:预算和并发为正、模型/Provider 存在、区域兼容、fallback 不扩大原始权限、Tool scope 可解析。强隔离租户还应使用独立 Provider 项目/账号/密钥、独立 egress 或专属部署;共享供应商 Key 会显著扩大爆炸半径。

Kubernetes 中 Secret 的 Base64 不是加密。启用 etcd 静态加密、最小 RBAC、独立 ServiceAccount、KMS/Vault/Secret Manager、NetworkPolicy 和 egress allowlist;不要将 secret 放入镜像、Git、日志或诊断包。


11. 可观测、对账和上线验收

每次请求至少关联:

request_id, trace_id, tenant/key reference, policy_version,
provider, model, region, TTFT, total latency,
reserved/input/output tokens, settlement state,
prompt/DLP decision, tool authorization decision, final status

Prometheus 不要将任意 tenant_id 作为 label,避免高基数。按 Provider、model、region、plan 聚合;单租户明细进入受控 trace、审计事件与 usage ledger。扩容信号也不能只看 CPU,应加入 active SSE、inflight、队列深度、TTFT/P99 与 Provider pending 请求。

发布门禁至少自动验证:

演练
期望结果
Key A 访问 Conversation B
404;不泄露资源存在性
伪造 RAG namespace/filter
服务端覆盖 tenant 条件,无跨租户结果
单租户 10 倍突发
仅该租户受限,Provider 全局额度受保护
SSE 长于 lease TTL
heartbeat 持续占位;结束立即释放
同 request_id 网络重试
续租且不重复扣账
Redis 超时
命中降级矩阵,无无限放行
20 chunk 后断流
受控结束、部分结算、释放租约
RAG 注入诱导退款/SSRF
无 scope/审批/归属校验即拒绝
非法策略下发
新版本不生效,继续使用上一快照
结算库短暂失败
durable queue 重放后账本一致
日志扫描
无完整 Key、Authorization、secret、未脱敏 Prompt

结语:让模型只能在最小权限边界内行动

一次可靠的请求应遵守四个不变量:身份只从可信凭证生成;所有数据访问都携带 tenant 条件;昂贵资源都有预留、上限、释放和恢复路径;模型永远不直接拥有有副作用工具的执行权。

做到这些,Gateway 才不只是 API 转发层,而是能在模型误判、恶意输入、依赖故障和流式重试发生时,仍然保持可隔离、可限额、可恢复、可审计的 AI 控制平面。


参考资料

  1. 1. OWASP GenAI Security Project:https://genai.owasp.org/
  2. 2. Kubernetes Secret good practices:https://kubernetes.io/docs/concepts/security/secrets-good-practices/
  3. 3. Redis scripting:https://redis.io/docs/latest/develop/programmability/eval-intro/
  4. 4. OpenTelemetry semantic conventions:https://opentelemetry.io/docs/specs/semconv/
  5. 5. NIST AI RMF Generative AI Profile:https://www.nist.gov/publications/artificial-intelligence-risk-management-framework-generative-artificial-intelligence

相关学习资料