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.
11 KiB
ADR-003 · LLM Key 与计费模型
| 项目 | 内容 |
|---|---|
| 状态 | 草稿(Draft) |
| 决策日期 | TBD |
| 决策者 | 产品 + CTO + 财务 |
| 关联 ADR | ADR-001 数据隔离、ADR-005 存储拓扑、ADR-006 运行时与渠道 |
1. 背景
DeerFlow 当前的 LLM 配置是进程级全局:config.yaml 写 api_key: $OPENAI_API_KEY,环境变量在容器启动时注入,所有用户共用同一个 key。models/factory.py 在 create_chat_model() 时做反射 + 环境变量替换。
多租户场景下,这套模式有四个问题:
- 成本归因不清:所有租户的 token 消费混在同一个 key 上,月底无法精确按租户计费
- 滥用风险大:一个租户写循环 prompt 把 key 刷爆,所有租户一起被拉黑
- 客户合规问题:部分企业要求"我的数据只能走我的 LLM 账户"(数据驻留 / 审计闭环)
- MCP / 第三方 API key 同样问题:Tavily / Firecrawl / Jina 的 key 也是全局的
需要决定:LLM Key 由谁出?怎么算账?
2. 决策
采用混合模式(Hybrid BYO):
- Free / Pro 套餐:默认走 平台 key,按月配额限制(token / 并发 / 沙箱 CPU 秒)
- Enterprise / BYO 套餐:客户上传自己的 LLM key(OpenAI / Anthropic / Azure / Bedrock),不限平台 quota,按"管理费 + 沙箱用量"收费
理由:
- 降低小客户上手摩擦——他们不想注册 OpenAI 账号、不想填信用卡,平台 key 体验最顺
- 大客户控制权要求——他们已经有 OpenAI / Anthropic 企业合同(折扣 / 数据条款),希望复用
- 平台风险可控——平台 key 有 quota 兜底;BYO key 客户自己滥用自己的额度
- 现有
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 key(DEK),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 聚合写入
- 并发 runs:
RunManager启动/结束时增减计数器
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 才知道的——硬限到达时最后一次调用一定超额(典型可超 32k–200k 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。当前 TokenUsageMiddleware 在 after_model 一次性提交——多租户后这有两个隐患:
- 客户端 abort 流时漏记:用户关浏览器、SSE 断开 → middleware 没收到
after_model→ token 漏算 - provider 本身的 usage 帧晚到:OpenAI/Anthropic 把 usage 放在最后一个 chunk;StreamBridge 必须在 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 时:
- 加密前先做一次 test call(小 prompt,验证 key 有效)
- 通过后加密入库
- UI 显示"已配置"但永远不回显原始 key(防泄露)
- 提供轮换流程(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 加密 | 信封加密:DEK(per-tenant)+ KEK(KMS) |
| 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 上限 | 平台 key:100k tokens;BYO:不限 |