Files
ZY-Agent/docs/multi-tenant-redesign/01-redesign/adr-003-llm-key-billing.zh-CN.md
T
1445043649 dce5e9598b docs(multi-tenant): add ADRs and phase-0 plan for multi-tenant redesign
Add 7 ADRs and a phase-0 plan covering the multi-tenant redesign of
DeerFlow, plus an architecture-overview snapshot of the current state.

ADRs:
- 001 data isolation: row-level tenant_id + Postgres RLS, including
  LangGraph-owned checkpoint tables (subquery RLS or column upgrade path).
- 002 sandbox isolation: K8s namespace + gVisor + NetworkPolicy default-
  deny, threat model and pod spec defaults.
- 003 LLM key & billing: hybrid platform/BYO with pessimistic reservation
  to handle the "ghost token" overflow on the last call, plus a usage
  category split for memory/title/summarization charges.
- 004 RBAC: two-level (owner/admin/member), JWT-with-role + 30s LRU
  cache for reads, strict DB lookup for sensitive writes, token_version
  bump as the single revocation path.
- 005 storage topology: Postgres (structured) + S3-compatible object
  store (large objects) + emptyDir (ephemeral); explicit treatment of
  the extensions_config.json migration's downstream effects.
- 006 runtime & channel tenancy: per-tenant MCP cache, dual-track skills
  loader, sandbox provider routing by namespace, internal LLM call
  billing, IM channel-to-tenant binding model.
- 007 routing & frontend: path-slug URL form, JWT-only API auth,
  TenantProvider, hard-reload tenant switch, Better Auth integration.

These docs are decision records; no code changes are included.
2026-05-08 23:45:33 +08:00

11 KiB
Raw Blame History

ADR-003 · LLM Key 与计费模型

项目 内容
状态 草稿(Draft
决策日期 TBD
决策者 产品 + CTO + 财务
关联 ADR ADR-001 数据隔离、ADR-005 存储拓扑、ADR-006 运行时与渠道

1. 背景

DeerFlow 当前的 LLM 配置是进程级全局config.yamlapi_key: $OPENAI_API_KEY,环境变量在容器启动时注入,所有用户共用同一个 key。models/factory.pycreate_chat_model() 时做反射 + 环境变量替换。

多租户场景下,这套模式有四个问题:

  1. 成本归因不清:所有租户的 token 消费混在同一个 key 上,月底无法精确按租户计费
  2. 滥用风险大:一个租户写循环 prompt 把 key 刷爆,所有租户一起被拉黑
  3. 客户合规问题:部分企业要求"我的数据只能走我的 LLM 账户"(数据驻留 / 审计闭环)
  4. MCP / 第三方 API key 同样问题Tavily / Firecrawl / Jina 的 key 也是全局的

需要决定:LLM Key 由谁出?怎么算账?


2. 决策

采用混合模式(Hybrid BYO

  • Free / Pro 套餐:默认走 平台 key,按月配额限制(token / 并发 / 沙箱 CPU 秒)
  • Enterprise / BYO 套餐:客户上传自己的 LLM keyOpenAI / Anthropic / Azure / Bedrock),不限平台 quota,按"管理费 + 沙箱用量"收费

理由:

  1. 降低小客户上手摩擦——他们不想注册 OpenAI 账号、不想填信用卡,平台 key 体验最顺
  2. 大客户控制权要求——他们已经有 OpenAI / Anthropic 企业合同(折扣 / 数据条款),希望复用
  3. 平台风险可控——平台 key 有 quota 兜底;BYO key 客户自己滥用自己的额度
  4. 现有 models/factory.py 改造可控——加一层 tenant 上下文 + secret vault 即可

3. 备选方案与拒绝理由

A. 纯平台 key(所有租户共用,按 token 加价转售)

拒绝。 看似简单实则雷区遍地:

  • 大客户必拒——数据合规审查过不去(数据流过你的 key 等于过你的账户)
  • 一旦 OpenAI 临时封号 / rate limit,全员宕机
  • 加价定价天花板低(客户自己注册也能买,溢价空间小)
  • 你要替每个客户做信用评估和反滥用,运维成本飙升

B. 纯 BYO key(强制客户自带)

拒绝。 与早期增长冲突:

  • Onboarding 多一步"去注册 OpenAI"——转化率显著下降
  • 试用客户体验差("还没用就要填卡")
  • 小客户会嫌烦直接放弃

C. 平台 key + 严苛 quota(不开 BYO

部分采纳,作为 Free/Pro 默认。 但不开 BYO 会卡企业客户,不能作为唯一选项。


4. 落地影响

4.1 Tenant Secrets 表(ADR-005 已含)

tenant_secrets (
  tenant_id   UUID FK,
  key         VARCHAR(64),         -- 'OPENAI_API_KEY' / 'ANTHROPIC_API_KEY' / 'TAVILY_API_KEY' / ...
  encrypted_value BYTEA,           -- KMS 加密
  rotated_at  TIMESTAMP,
  created_at  TIMESTAMP,
  PRIMARY KEY (tenant_id, key)
)

加密策略:每个租户一个 KMS data keyDEK),KEK 在 AWS KMS / 阿里云 KMS 集中管。

4.2 改造 create_chat_model()

当前签名(伪):

def create_chat_model(name: str = None, *, thinking_enabled: bool, app_config: AppConfig = None) -> BaseChatModel:
    model_config = app_config.get_model_config(name)
    api_key = resolve_env_var(model_config.api_key)   # 从 process env 取
    return reflect(model_config.use)(api_key=api_key, ...)

改造后:

async def create_chat_model(
    name: str = None,
    *,
    thinking_enabled: bool,
    tenant_id: str = AUTO,             # 从 ContextVar 取
    app_config: AppConfig = None,
) -> BaseChatModel:
    tenant = resolve_tenant_id(tenant_id, method_name="create_chat_model")
    model_config = app_config.get_model_config(name)

    # 优先级:tenant 自带 key > 平台 key(带 quota
    api_key = await secret_vault.get(tenant, model_config.api_key_secret_name)
    if api_key is None:
        api_key = await platform_keys.get(model_config.api_key_secret_name)
        # 走平台 key 的 LLM 调用必须挂 QuotaMiddleware(见 4.4

    return reflect(model_config.use)(api_key=api_key, ...)

4.3 Quota 表与 Usage 表(ADR-005 已含)

tenant_quotas (
  tenant_id  UUID,
  metric     VARCHAR(32),    -- tokens_monthly / runs_concurrent / sandbox_cpu_seconds_daily
  hard_limit BIGINT,         -- 超过即拒绝
  soft_limit BIGINT,         -- 超过即告警 / 降速
  PRIMARY KEY (tenant_id, metric)
)

tenant_usage_daily (
  tenant_id  UUID,
  date       DATE,
  metric     VARCHAR(32),
  model_name VARCHAR(64),    -- 区分 OpenAI / Anthropic / 本地 vLLM
  value      BIGINT,
  PRIMARY KEY (tenant_id, date, metric, model_name)
)

写入时机:

  • token:现有 TokenUsageMiddleware 改造,写入按 (tenant_id, model, date) 累加
  • 沙箱 CPU 秒:K8s metrics-server / Prometheus 抓取,每 5 min 聚合写入
  • 并发 runsRunManager 启动/结束时增减计数器

4.4 新增 QuotaMiddleware

放在 lead_agent 中间件链最前(在 ThreadDataMiddleware 之后、SandboxMiddleware 之前),LLM 调用前检查:

class QuotaMiddleware(AgentMiddleware):
    async def before_model(self, state, runtime):
        tenant = get_current_tenant()
        usage = await usage_repo.current_month(tenant.id, "tokens_monthly")
        quota = await quota_repo.get(tenant.id, "tokens_monthly")

        if quota.hard_limit and usage >= quota.hard_limit:
            raise QuotaExceeded(
                "Monthly token quota exhausted. Upgrade plan or wait for reset.",
                next_reset=first_day_of_next_month(),
            )
        if quota.soft_limit and usage >= quota.soft_limit:
            # 软限:仍允许调用,但触发告警 + 在 UI 显示警告
            await alert_soft_limit(tenant.id)

QuotaExceeded 通过 LLMErrorHandlingMiddleware 转成 user-facing 错误(不是 500),保持一致性。

4.4.1 悲观预扣 vs 事后结算("幽灵 token"问题)

before_model 只能看到"调用前累计用量",但 token 消耗是 after_model 才知道的——硬限到达时最后一次调用一定超额(典型可超 32k200k token,单次可达 quota 的 1-5%)。处理:

阶段 动作
before_model 悲观预扣:按 model_max_input_tokens 估上限(按 model 配置查表),累加到 "reserved" 列。reserved + actual ≥ hard_limit 时拒绝
after_model(非流式) 拿到真实 token,把对应 reservation 从 reserved 移到 actual,差额返还
after_model(流式) 流尾 usage event 到达时同上;流被 cancel 时按已收到的增量扣,剩余 reservation 释放
tenant_usage_daily (
  ...,
  metric VARCHAR(32),     -- tokens_input / tokens_output / tokens_reserved
  value BIGINT,
  ...
)

reserved 不进账单,只用于 quota gate。月底对账只看 tokens_input + tokens_output

4.4.2 流式 token 的提交时机

LangGraph SDK 的 messages-tuple 流模式按 chunk 推 delta。当前 TokenUsageMiddlewareafter_model 一次性提交——多租户后这有两个隐患:

  1. 客户端 abort 流时漏记:用户关浏览器、SSE 断开 → middleware 没收到 after_model → token 漏算
  2. provider 本身的 usage 帧晚到OpenAI/Anthropic 把 usage 放在最后一个 chunkStreamBridge 必须在 finalizing 时强制等这一帧

强约束:

  • StreamBridge 收到 abort/disconnect 信号时,仍需等 LLM provider 流自然结束 + 把 usage 提交后再断 client(限超时 5s 兜底)
  • 提交时机:after_model 终态(成功/失败/abort)三选一时立刻 commit;不允许"等会话结束再批量"
  • 流式调用的 usage_category(见 ADR-006 §2.5)按触发中间件分类
  • 测试覆盖:test_billing_stream_abort.py 模拟客户端断连,断言 token 仍被记录

4.4.3 内部 LLM 调用的归属

详见 ADR-006 §2.5。简表如下:

触发 计费归属 usage_category
主对话 tenant main
MemoryMiddleware 抽取 tenant memory
TitleMiddleware 起标题 tenant title
SummarizationMiddleware 历史压缩 tenant summarization
平台 admin 主动 LLM 工具(健康检查等) platform platform

tenant_usage_daily schema 在 §4.3 基础上补 usage_category 列。报表 UI 展示这五类分项,避免"为什么我没说话也产生 token"这类客户投诉。

4.5 计费对账

平台 key 模式下,"实际成本"和"客户账单"要分清:

维度 数据来源 用途
OpenAI 实际账单 OpenAI API usage report(每日拉) 与平台财务对账
客户应付 tenant_usage_daily(你自己记的) 月底生成账单
差额 OpenAI 实际 - sum(客户应付) 监控异常(>5% 触发审计)

差额监控很重要——如果你少记了 token(比如 streaming 异常时漏记),平台会替客户埋单。每月对账。

4.6 BYO key 验证流程

客户填 key 时:

  1. 加密前先做一次 test call(小 prompt,验证 key 有效)
  2. 通过后加密入库
  3. UI 显示"已配置"但永远不回显原始 key(防泄露)
  4. 提供轮换流程(rotate)和撤销流程(revoke

5. 套餐建议(产品决策,仅参考)

套餐 LLM Key 月度 token quota 并发 runs 沙箱 CPU 秒/月 价格
Free 平台 key 100k 1 1k $0
Pro 平台 key 5M 5 50k $X
Team 平台 key(按 token 转售) 50M 20 500k $XX
Enterprise BYO 客户自带 不限 协商 协商 $XXX 管理费 + 沙箱用量

具体数字由 PMM 和财务定,不在本 ADR 范围。


6. 风险与缓解

风险 缓解
平台 key 被某租户刷爆 QuotaMiddleware 硬限 + 异常用量告警(>3σ)+ 单 run token 上限
平台 key 被 OpenAI 临时封禁 多备份 key 轮询(多 tier API key+ 多 provider 兜底(OpenAI 挂了切 Anthropic
BYO key 在 DB 泄露 KMS 加密 + 审计每次 decrypt + 仅在沙箱 pod 启动时 inject 进环境,不出 pod
客户 BYO key 滥用导致他自己被 OpenAI 封 不归我们管(合同里写明)
计费错算(少记 token 月度对账 + 5% 阈值告警 + 流式调用结束时强制 commit
客户跨币种 / 退款 接 Stripe 完整闭环,不要自己手搓

7. 推翻条件

  • 退回纯平台 key:如果 BYO 流量 < 5%,可考虑下线 BYO 简化运维(但企业客户已签合同的不能强制迁回)
  • 强制 BYO:如果平台 key 滥用/欺诈损失年化 > $X,关掉免费档
  • per-tenant 物理 LLM 资源:如果监管客户要求"专属推理实例",需要专用 vLLM / Bedrock provisioned throughput

8. 默认假设

默认
Secret 加密 信封加密:DEKper-tenant+ KEKKMS
Quota 维度 tokens_monthly + runs_concurrent + sandbox_cpu_seconds_daily
Quota 重置 月度 token 按 UTC 月初;并发是实时;沙箱秒按 UTC 日初
软限:硬限比例 soft = 0.8 × hard
BYO 支持的 provider OpenAI / Anthropic / Azure / AWS Bedrock / vLLM-compatible
计费货币 USD(多币种由 Stripe 处理)
对账周期 每日抓取 OpenAI usage,月度核对
单 run token 上限 平台 key100k tokensBYO:不限