ARTICLE · 1054277
从一次 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,而是几个“看似合理”的局部实现叠加:
TenantContext | ||
FindByID | ||
复盘后,团队没有简单地把 QPS 调低,而是把 Gateway 重新定义为“共享 AI 能力的策略执行点”。上线修复后,试用租户再以 10 倍突发压测时仅自身收到 429;付费租户 TTFT P95 保持在 1.6s 内,Provider 的全局额度也未被打穿。
2. 先画边界:租户上下文是整条链的根
一个生产级 Gateway 至少要同时守住六条边界:
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 → metrics3.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(64) PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
secret_hmac BYTEA NOT NULL,
scopes_json JSONB NOT NULL,
status VARCHAR(16) NOT 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_idRAG 是最容易被忽略的一层。服务端应在原始请求过滤器上强制叠加 tenant 条件,不能原样信任客户端提交的 filter。重要数据还应有 Row Level Security、独立 collection/schema 或租户级 IAM 作为纵深防御。
6. 限流不能只看 QPS:四道闸门,一份账本
一个 300ms 的短问答和一个 60 秒、数万 Token 的流式请求都算“一次”,并不公平。建议分开治理:
6.1 Redis 令牌桶:参数先校验,额度分层保护
Redis Lua 可将读取、计算和扣减变为一次原子操作,但脚本不应代替输入校验。策略加载阶段必须保证 capacity、refill_rate、requested 均为正且不超过安全上限,避免零除、负数和超大数值精度问题。
逐租户限流还不够:共享供应商项目时,必须额外有 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(1000, tonumber(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)Reserve、Settle、Cancel 都以 (tenant_id, request_id) 或 reservation_id 去重。客户端重试、Provider 重放、进程重启都不能重复扣费。结算写失败后进入 durable queue/WAL,由 reconciliation job 补偿;这也是“账本”与“打日志”的区别。
7. Redis 挂了:按风险降级,而不是统一开关
本地 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 statusPrometheus 不要将任意 tenant_id 作为 label,避免高基数。按 Provider、model、region、plan 聚合;单租户明细进入受控 trace、审计事件与 usage ledger。扩容信号也不能只看 CPU,应加入 active SSE、inflight、队列深度、TTFT/P99 与 Provider pending 请求。
发布门禁至少自动验证:
结语:让模型只能在最小权限边界内行动
一次可靠的请求应遵守四个不变量:身份只从可信凭证生成;所有数据访问都携带 tenant 条件;昂贵资源都有预留、上限、释放和恢复路径;模型永远不直接拥有有副作用工具的执行权。
做到这些,Gateway 才不只是 API 转发层,而是能在模型误判、恶意输入、依赖故障和流式重试发生时,仍然保持可隔离、可限额、可恢复、可审计的 AI 控制平面。
参考资料
1. OWASP GenAI Security Project:https://genai.owasp.org/ 2. Kubernetes Secret good practices:https://kubernetes.io/docs/concepts/security/secrets-good-practices/ 3. Redis scripting:https://redis.io/docs/latest/develop/programmability/eval-intro/ 4. OpenTelemetry semantic conventions:https://opentelemetry.io/docs/specs/semconv/ 5. NIST AI RMF Generative AI Profile:https://www.nist.gov/publications/artificial-intelligence-risk-management-framework-generative-artificial-intelligence