Files
ZY-Agent/docs/multi-tenant-redesign/01-redesign/adr-003-llm-key-billing.zh-CN.md
T
1445043649 89fe54cc07 docs(multi-tenant): 加入汇总索引、跨文档一致性修订、Postgres 切换前移到 Stage 0
* 新增 README.zh-CN.md 汇总索引:ADR 状态表 + Stage 0-4 业务目标 / 技术路径 /
  验证方式 + Stage↔ADR 对照矩阵 + 不可逆决策一览 + 用语映射 + FAQ + 阅读路径
* 新增 workspace-schema-design.zh-CN.md(Stage 0 schema 锁定版)一并入库
* 7 份 ADR 顶部加"代码命名"映射行(tenant_id ↔ workspace_id)
* ADR-002 §1 加分期落地提示,明确 K8s 推迟到 Stage 3
* ADR-005 §5"第 1/2 阶段"补出与 rollout Stage 2/3 的映射
* ADR-007 §4 加 /api/v1/ 反向链接;§8 加 tid → wid 字段名映射
* headless-api §0/§7 把"SaaS + on-prem 双主线"改为"SaaS 主线、schema 兼容 on-prem"
* phased-rollout 去除重复的"Go/No-Go 进入 Stage 2"段

Postgres 切换从 Stage 1 提前到 Stage 0:Stage 0 已要 ALTER 4 张表加
workspace_id,先 SQLite 再 PG 是纯返工;Stage 0 没有生产数据,迁移阻力最小。
同步调整 phased-rollout / headless-api / phase-0-plan / workspace-schema-design /
README 中的时间盒(Stage 0: 3-4→4-5 周;Stage 1: 10-15→8-13 周)、不可逆决策
清单、PR 顺序、轨道前置依赖。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:39:58 +08:00

12 KiB
Raw Blame History

ADR-003 · LLM Key 与计费模型

项目 内容
状态 草稿(Draft · 2026-05-09 据审计修订 §4.2 / §4.3 / §4.4.2create_chat_model 是 sync、TokenUsageMiddleware 不持久化)
决策日期 TBD
决策者 产品 + CTO + 财务
关联 ADR ADR-001 数据隔离、ADR-005 存储拓扑、ADR-006 运行时与渠道
关联审计 adr-vs-code-audit
代码命名 本 ADR 写 tenant_* 表 / tenant_id 列,落代码统一读作 workspace_* / workspace_id(详 workspace-schema-design §1

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, ...)

sync → async 的连带影响:当前 create_chat_model 是同步函数(models/factory.py:50)。改成 async 后所有调用点(lead_agent factory、MemoryMiddleware / TitleMiddleware / SummarizationMiddleware 等)都要同步改 await——这是一次跨多个文件的改动,不是单点 patch。phase-1 实现时按"factory 改 async + 一次性扫所有调用点 await"作为单个 PR 落地,不要分批,避免中间态不可运行。

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 只 log 不持久化agents/middlewares/token_usage_middleware.py:268-275);本 ADR 要求新增持久化路径,按 (tenant_id, model, date) 累加,并配合 usage_category 区分主对话 / 内部任务(参 ADR-006 §2.5
  • 沙箱 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 一次性 log(不持久化)——多租户加上持久化后,这有两个隐患:

  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:不限