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.
This commit is contained in:
1445043649
2026-05-08 23:45:33 +08:00
parent 2b1fcb3e43
commit dce5e9598b
10 changed files with 2767 additions and 0 deletions
+3
View File
@@ -60,3 +60,6 @@ config.yaml.bak
/frontend/playwright-report/
.gstack/
.worktrees
skills/gstack
skills/superpowers
CLAUDE.md
@@ -0,0 +1,188 @@
# DeerFlow 整体架构鸟瞰
按"自外向内、自顶向下"分层讲,并指出每一层对应的代码位置,方便后续深入。
## 一、进程与部署拓扑
DeerFlow 表面是 4 个端口,本质是 **3 个进程 + 1 个反向代理**
```
┌──────────────────────┐
浏览器 / IM ─────────▶│ nginx :2026 │ 统一入口
│ (含 CORS、SSE 透传) │
└──────────┬───────────┘
┌──────────────────┴──────────────────┐
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────────────┐
│ Frontend (Next.js) │ │ Gateway (uvicorn) │
│ :3000 │ │ :8001 │
│ pnpm dev / preview │ │ ┌──────────────────────┐ │
└────────────────────┘ │ │ FastAPI 路由层 │ │
│ │ /api/models, /skills │ │
│ │ /threads, /runs ... │ │
│ ├──────────────────────┤ │
│ │ LangGraph Runtime │ │
│ │ (RunManager, │ │
│ │ StreamBridge, │ │
│ │ Checkpointer) │ │
│ ├──────────────────────┤ │
│ │ lead_agent 图 │ │
│ │ + 18 个中间件 │ │
│ │ + Sandbox / Tools │ │
│ └──────────────────────┘ │
└────────────┬─────────────┘
┌──────────────────────────┼─────────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Sandbox │ │ MCP Servers │ │ LLM 提供商 │
│ Local / AIO Docker│ │ (stdio/sse/http) │ │ OpenAI/Anthropic │
│ 提供 bash/fs │ │ │ │ /vLLM/Codex CLI │
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
关键事实(容易踩坑):
- **LangGraph 运行时不是独立进程**,而是嵌在 Gateway 这同一个 uvicorn 进程里。`scripts/serve.sh:225` 只起了一个后端进程:`uvicorn app.gateway.app:app`
- nginx 把 `/api/langgraph/*` 重写成 `/api/*` 再代理到 Gateway`docker/nginx/nginx.local.conf:49-73`),所以前端用的"标准 LangGraph SDK 协议"和 DeerFlow 自己的 REST 是同一个 8001 端口。
- 路由协议在 nginx 里特意为 SSE 关闭了缓冲(`proxy_buffering off; X-Accel-Buffering no`),否则流式响应会被吃掉。
## 二、后端代码二分:Harness vs App
后端最重要的边界,**决定新写代码该放哪里**。
```
backend/
├── packages/harness/deerflow/ ← 可发布的智能体框架(import: deerflow.*
│ ├── agents/ lead_agent + memory + middlewares + ThreadState
│ ├── runtime/ checkpointer / runs / stream_bridge / events / store
│ ├── sandbox/ Sandbox 抽象 + local 实现 + 文件/bash 工具
│ ├── subagents/ 子代理注册表 + 后台执行池
│ ├── tools/ 内建工具(present_files / ask_clarification / view_image
│ ├── mcp/ MultiServerMCPClient + 缓存 + OAuth
│ ├── skills/ SKILL.md 加载、工具白名单
│ ├── models/ 模型工厂、vLLM/Codex/Claude 自定义 provider
│ ├── community/ tavily / jina / firecrawl / aio_sandbox(可选实现)
│ ├── memory/ 长期记忆(事实抽取、debounce 队列)
│ ├── persistence/ SQLAlchemy 模型(用户、运行、事件、反馈)
│ ├── guardrails/ 工具调用前置鉴权(可插拔 provider)
│ ├── tracing/ LangSmith / Langfuse callback
│ ├── reflection/ "module:variable" 字符串 → 实例(配置驱动的关键)
│ ├── uploads/ 上传文件转换 markdown
│ └── client.py DeerFlowClient(嵌入式 Python 客户端)
└── app/ ← 应用代码(import: app.*
├── gateway/
│ ├── app.py FastAPI 入口 + lifespan
│ ├── auth_middleware.py 会话/Token 鉴权
│ ├── csrf_middleware.py 双重 cookie CSRF
│ ├── langgraph_auth.py 注入到 langgraph.json 的鉴权钩子
│ └── routers/ ↓ 下表
└── channels/ IMSlack/Telegram/Feishu/DingTalk/微信/企微)
```
**铁律:app 可以 import deerflowdeerflow 不能 import app**CI 用 `tests/test_harness_boundary.py` 强制)。这意味着 harness 必须自给自足——任何"agent 运行时需要的能力"都要在 harness 里完成抽象,app 层只做 HTTP/IM 适配。
## 三、Gateway 路由总览
`backend/app/gateway/routers/` 14 个路由文件,分三类职责:
| 类别 | 路由 | 干什么 |
|---|---|---|
| **配置/资源管理** | `models` `skills` `mcp` `memory` `agents` | 列出/启停 LLM 模型、技能、MCP、自定义 agent |
| **会话/数据** | `threads` `uploads` `artifacts` `suggestions` | 管理线程、上传文件、产物下载、追问建议 |
| **运行(核心)** | `thread_runs` `runs` `feedback` `assistants_compat` | 创建运行、SSE 流、消息分页、反馈打分、LangGraph 兼容协议 |
| **横切** | `auth` `channels` | 用户登录注册、IM 渠道状态 |
`assistants_compat.py` 是关键:它把前端用的 LangGraph SDK 协议(`POST /threads/{id}/runs/stream``messages-tuple` 流模式等)翻译成 DeerFlow 内部的 `RunManager` 调用——这就是 nginx 那条 `/api/langgraph/*` 重写规则的接收端。
## 四、一次对话的完整生命周期
把上面所有零件串起来——用户在前端输入一句话,会发生这些事:
```
1. 前端 useThreadStream hook
└─▶ LangGraph SDK 调用 POST /api/langgraph/threads/{id}/runs/stream
(stream_mode=["values","messages-tuple","custom"])
2. nginx 重写 → /api/threads/{id}/runs/stream → Gateway
3. Gateway thread_runs 路由
├─▶ AuthMiddleware 解析 session → user_id 注入到 user_contextcontextvar
├─▶ CSRFMiddleware 校验
└─▶ runtime.RunManager 创建 Run → 落库(runs / run_events 表)
4. RunManager 调用 lead_agent 图(langgraph.json: deerflow.agents:make_lead_agent
├─▶ 解析 configurable: model_name / thinking_enabled / is_plan_mode / subagent_enabled
├─▶ create_chat_model() 实例化 LLMreflection 从 "module:Class" 字符串实例化)
└─▶ create_agent(model, tools, middlewares, state_schema=ThreadState)
5. 18 个中间件按顺序拦截每一轮 model→tool→model
ThreadDataMiddleware 创建 .deer-flow/users/{uid}/threads/{tid}/...
UploadsMiddleware 注入新上传文件
SandboxMiddleware acquire 沙箱,state.sandbox_id 写入
DanglingToolCall 修复中断的 tool_call 序列
LLMErrorHandling LLM 报错降级
Guardrail 工具调用前鉴权(可选)
SandboxAudit 记录 bash/fs 操作
ToolErrorHandling tool 异常 → ToolMessage 不中断
Summarization token 接近上限时压缩历史
TodoList plan_mode 才挂
TokenUsage 累计 token
Title 首轮后自动起标题
Memory 队列异步抽取记忆
ViewImage 视觉模型注入 base64
DeferredToolFilter 需要时才暴露 tool schema
SubagentLimit 限制 task 并发到 3
LoopDetection 检测重复工具循环
Clarification ask_clarification 触发 interrupt(END)
6. Tools 由 get_available_tools() 拼装:
├─ Sandbox 工具:bash / ls / read_file / write_file / str_replace
├─ 内建工具:present_files / ask_clarification / view_image / setup_agent
├─ MCP 工具:从 extensions_config.json 启用的 server 拉取
├─ Community 工具:tavily / jina / firecrawl / image_search(按 config.yaml
└─ task 工具(可选):派遣 subagent
7. StreamBridge 把图执行的事件流转换成 SSE:
- "values" 完整状态快照
- "messages-tuple" 增量 token / 工具调用 / 工具返回
- "custom" StreamWriter 自定义事件
- "end" 收尾,附 token usage
8. 前端 LangGraph SDK 接 SSE,按 message id 累加 delta,更新 UI
9. 运行结束后,MemoryMiddleware 后台 30s debounce 抽取记忆事实写入
.deer-flow/users/{uid}/memory.json
```
## 五、状态与持久化的几条线
DeerFlow 的状态被有意拆成"快/慢/历史"三层,因为它要同时支持长会话、跨进程恢复、文件级产物:
| 状态 | 位置 | 谁写 |
|---|---|---|
| **会话状态(messages, todos, artifacts** | LangGraph checkpointer(内置 SQLite/PG,路径在 `runtime/checkpointer/async_provider.py` | 每个 step 自动 |
| **运行元数据/事件流** | `persistence/` 下 SQLAlchemy 模型(`runs``run_events``feedback``threads_meta` | RunManager + StreamBridge |
| **每用户每线程文件** | `.deer-flow/users/{uid}/threads/{tid}/user-data/{workspace,uploads,outputs}` | ThreadDataMiddleware + 沙箱工具 |
| **长期记忆** | `.deer-flow/users/{uid}/memory.json`(可叠加 per-agent | MemoryMiddleware(异步) |
| **配置** | `config.yaml`(模型、工具、沙箱、记忆…) + `extensions_config.json`MCP、技能开关) | `make setup` 或 Gateway PUT |
agent 看到的永远是 **虚拟路径** `/mnt/user-data/...``/mnt/skills/...`,由 `sandbox/tools.py``replace_virtual_path()` 翻译成上面物理路径。这层抽象让"本地沙箱"和"Docker 沙箱"对 agent 完全透明。
## 六、前端架构(一行总结)
`frontend/src/core/threads/hooks.ts` 里的 `useThreadStream` / `useSubmitThread` / `useThreads` 是整个前端的"主动脉"——它们包了 LangGraph SDK 单例(`core/api/`),所有 UI 组件订阅 thread 状态做渲染。Server Components 默认,需要交互的才 `"use client"``core/` 下其它子目录(artifacts/skills/mcp/memory/settings)都是为这条主动脉提供周边能力。
## 七、一图记住"它在做什么"
DeerFlow 本质上是一个 **"LangGraph 智能体 + 18 段切面 + 沙箱 + 记忆"** 的组合:
- **LangGraph** 提供图执行、checkpoint、stream 协议
- **18 个中间件** 是 DeerFlow 自己加的"切面层",每个解决一个具体的健壮性/能力问题(错误恢复、上下文压缩、记忆、子代理限流……)
- **沙箱+技能+MCP+工具** 是 agent 的"手脚"
- **Gateway + IM Channels + 嵌入式 Client** 是同一个 agent 的三种暴露方式(HTTP/聊天/Python 直调)
> 想继续往里钻的话,建议下一步选三个之一:(a)走读 lead_agent + 中间件链,理解 agent 一轮 think/act 的完整代码路径;(b)走读 sandbox + tools,理解虚拟路径和工具拼装;(c)走读 runtime + StreamBridge,理解 SSE 协议怎么映射回 LangGraph SDK。
@@ -0,0 +1,244 @@
# ADR-001 · 数据隔离模型
| 项目 | 内容 |
|---|---|
| 状态 | 草稿(Draft |
| 决策日期 | TBD |
| 决策者 | CTO + 架构 + 后端 lead |
| 关联 ADR | ADR-004 租户层级、ADR-005 存储拓扑、ADR-006 运行时与渠道 |
---
## 1. 背景
DeerFlow 当前是 **"多用户单租户"** 模型:所有用户的会话、运行、记忆、产物都共用同一套表,仓储层用 `user_id` WHERE 过滤做个人空间隔离(`runtime/user_context.py:138-167`)。这套机制设计良好——repository 用 ContextVar + `AUTO` 哨兵自动注入当前用户,下层不依赖上层(harness 不能 import app)。
多租户化需要在 `user_id` 之上再加一层 `tenant_id`。问题是:**用什么物理隔离强度?**
三种主流方案:
| 维度 | 行级(tenant_id WHERE | per-tenant schema | per-tenant DB |
|---|---|---|---|
| 实现成本 | 低 | 中 | 高 |
| 跨租户 bug 爆炸半径 | 高 | 中 | 极低 |
| 备份/恢复粒度 | 全量 | 按 schema | 按 DB |
| 合规友好度(SOC2/HIPAA | 一般 | 好 | 最好 |
| 跨租户分析查询 | 容易 | 中 | 难 |
| 升级 schema | 一次完成 | 要遍历所有 schema | 要遍历所有 DB |
| 适用客户规模 | <10k 租户 | 10k100 大客户 | <100 大客户 |
| 运维复杂度 | 低 | 中 | 高 |
---
## 2. 决策
**采用 行级 `tenant_id` + Postgres Row-Level SecurityRLS** 作为双保险。
理由:
1. **DeerFlow 仓储层现状几乎平行扩展**——已经有 `resolve_user_id()` 哨兵模式,把 `tenant_id` 按同样模式补一遍,改造面集中、风险可控。
2. **Postgres RLS 是 DB 层兜底**——即使应用层有 bug 漏写 `WHERE tenant_id = ...`DB 也会强制过滤,第二道防线。
3. **覆盖目标客户规模**:B2B 中小客户为主、租户数 1k–10k,行级方案足够。
4. **不放弃跨租户分析能力**:平台需要做用量统计、监控、健康检查,单库行级最方便。
---
## 3. 备选方案与拒绝理由
### A. per-tenant schema(同库不同 schema
**拒绝。** 看似比行级更隔离,实际坑很多:
- **schema 数量爆炸**1000 个租户 = 1000 个 schema × 每张表,pg_class 体积膨胀,连接池里 search_path 切换有性能抖动
- **schema migration 痛苦**:每发布一次 schema 改动要遍历所有 schema 跑 migration,失败回滚极复杂
- **跨租户查询难**:要写 `UNION ALL` 跨所有 schema,运营仪表盘几乎无法实现
- **依然需要应用层过滤**:连接进哪个 schema 仍由应用层决定,没真正消除"应用层 bug 跨租户"
### B. per-tenant database(独立物理库)
**拒绝(默认场景)。** 隔离最强但成本极高:
- **运维负担**1000 个 DB = 1000 套备份/恢复/监控/连接池
- **冷启动延迟**:每个租户新建 DB 时间从秒级飙到分钟级
- **跨租户操作不可能**:平台级查询、聚合、迁移全部失效
- **连接池复杂度爆炸**:每租户独立连接池或者共用动态切库,都是噩梦
**仅在两种情况切换到此方案:** ① 拿到强合规客户(金融/医疗/政府),合同里写明物理数据隔离;② 客户付费足够覆盖每租户独立 DB 的运维成本(典型企业级订阅)。
---
## 4. 落地影响
### 4.1 表结构改造
所有业务表加 `tenant_id` 列 + 复合索引(`tenant_id` 作为前导列):
```sql
ALTER TABLE threads_meta ADD COLUMN tenant_id UUID NOT NULL;
CREATE INDEX idx_threads_meta_tenant_user ON threads_meta (tenant_id, user_id, updated_at DESC);
ALTER TABLE runs ADD COLUMN tenant_id UUID NOT NULL;
CREATE INDEX idx_runs_tenant_created ON runs (tenant_id, created_at DESC);
ALTER TABLE run_events ADD COLUMN tenant_id UUID NOT NULL;
CREATE INDEX idx_run_events_tenant_run ON run_events (tenant_id, run_id, seq);
ALTER TABLE feedback ADD COLUMN tenant_id UUID NOT NULL;
CREATE INDEX idx_feedback_tenant_run ON feedback (tenant_id, run_id);
-- ADR-005 引入的新表也要带 tenant_id(建表时就有)
-- agent_configs, memory_facts, memory_context,
-- tenant_skill_state, tenant_mcp_configs, tenant_secrets, tenant_quotas
```
**关键索引原则**:每个 `tenant_id` 都必须是复合索引的**第一列**——RLS policy 走的就是这条路径,前导列错了 RLS 会全表扫。
#### 4.1.1 LangGraph 自有表(checkpoints / checkpoint_writes / checkpoint_blobs
`runtime/checkpointer/async_provider.py` 用的是 LangGraph 内置 `AsyncPostgresSaver`**表结构不在 DeerFlow 控制下**——直接 ALTER 添加 `tenant_id` 列在 LangGraph 升级时会被它自己的 migration 覆盖。处理路径分两档(详见 ADR-006 §2.1):
**默认(subquery 形式 RLS,零侵入)**
```sql
-- LangGraph 自动建表后,DeerFlow 的 init migration 跑:
ALTER TABLE checkpoints ENABLE ROW LEVEL SECURITY;
ALTER TABLE checkpoints FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON checkpoints
USING (
thread_id IN (
SELECT thread_id::text FROM threads_meta
WHERE tenant_id = current_setting('app.tenant_id', true)::uuid
)
);
-- checkpoint_writes / checkpoint_blobs 同形态,按 thread_id 反查
```
**升级路径(侵入式列)**:当 subquery RLS 在大表上 EXPLAIN 出现问题时,改用:
```sql
ALTER TABLE checkpoints ADD COLUMN tenant_id UUID;
CREATE INDEX idx_checkpoints_tenant_thread ON checkpoints (tenant_id, thread_id);
-- trigger 在 INSERT 时从 threads_meta 反查 tenant_id 写入
-- 升级 LangGraph 前必须验证它的 migration 不会丢这列
```
#### 4.1.2 第一道防线:thread_id ↔ tenant_id 校验
不论用哪种 RLS 形态,`AssistantsCompat` 路由(`app/gateway/routers/assistants_compat.py`)在调用 LangGraph 之前**必须先用 `threads_meta` 校验 `(tenant_id, thread_id)` 归属**。RLS 是兜底,应用层校验是第一道防线——RLS 只能"过滤掉看不到的",不能阻止"创建到错误租户名下"。
### 4.2 RLS policy 模板
所有带 `tenant_id` 的表都加同样形态的 policy
```sql
ALTER TABLE threads_meta ENABLE ROW LEVEL SECURITY;
ALTER TABLE threads_meta FORCE ROW LEVEL SECURITY; -- 即使表所有者也走 policy
CREATE POLICY tenant_isolation ON threads_meta
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
```
应用层在每次拿到连接时,先 `SET LOCAL app.tenant_id = '<uuid>'`
```python
# packages/harness/deerflow/persistence/engine.py
async def _set_session_tenant(session: AsyncSession, tenant_id: str) -> None:
"""Bind session to tenant; RLS policy enforces filter."""
await session.execute(text("SET LOCAL app.tenant_id = :tid"), {"tid": tenant_id})
```
每个仓储方法的开头自动调用,从 ContextVar 取 tenant_id(仿照现有 `resolve_user_id` 模式)。
**LangGraph 自有连接池的注入**DeerFlow 仓储和 LangGraph checkpointer 是**两套连接池**——前者是 SQLAlchemy `AsyncSession`,后者是 LangGraph 自己持有的 asyncpg 池。后者的注入路径不能复用上面的 helper:
```python
# 1. 优选:自定义 connection factory 注入到 saver 构造(langgraph-checkpoint-postgres>=2.0
async def tenant_aware_acquire(pool):
async with pool.acquire() as conn:
tid = get_current_tenant_id()
await conn.execute("SET LOCAL app.tenant_id = $1", tid)
yield conn
saver = AsyncPostgresSaver(connection_factory=tenant_aware_acquire)
# 2. 兜底:在 RunManager 进入 thread 前主动 issue 一条 SET(要求 LangGraph 复用同 conn
```
具体路径选择与失败模式见 ADR-006 §2.1。
**关键约束**:所有"会查 LangGraph 表"的代码路径——`AssistantsCompat` 路由、`RunManager`、checkpointer 直读——都必须保证调用栈上已注入 `app.tenant_id`,否则 RLS 会把整个会话过滤成空集。CI 加冒烟测试确认这点。
### 4.3 ContextVar 扩展
`runtime/user_context.py` 旁边加 `tenant_context.py`
```python
_current_tenant: Final[ContextVar[CurrentTenant | None]] = ContextVar("deerflow_current_tenant", default=None)
class _AutoSentinel: ... # 同 user_id 模式
AUTO: Final[_AutoSentinel] = _AutoSentinel()
def resolve_tenant_id(value, *, method_name) -> str:
"""与 resolve_user_id 同款三态:AUTO / 显式 str / 显式 None。
SaaS 模式下 None 是 forbidden(除非显式 admin override)。"""
```
`AuthMiddleware` 在解析完 JWT 后两个 ContextVar 同时注入。
### 4.4 SQLite → Postgres 迁移
**SQLite 不支持 RLS**,多租户上线必须切 Postgres。迁移路径:
1. 第 0 阶段:在 dev 环境同时跑 SQLite 和 Postgres,用 `aiosqlite` / `asyncpg` 双驱动
2. 第 1 阶段:生产切 Postgres,老数据用 `pg_loader` 导入;DeerFlow 现有的 `Base.metadata.create_all()` 直接接 Postgres
3. 老用户的 `user_id` 在没有 tenant_id 时归到一个"legacy_tenant",迁移脚本同步把 `tenant_id` 回填
### 4.5 跨租户操作(平台后台)
平台 admin / 运维需要跨租户查询时,不能简单"绕过 RLS"——而是用一个**专用 role** 配 `BYPASSRLS`,只给受限运维账号使用,操作审计入库:
```sql
CREATE ROLE deerflow_admin BYPASSRLS;
-- 应用层 admin 路由用这个 role 的连接池,并强制审计日志
```
**绝不允许应用主连接池有 `BYPASSRLS`。**
---
## 5. 风险与缓解
| 风险 | 缓解 |
|---|---|
| 应用层漏写 tenant_id WHERE | RLS 是兜底;CI 加静态检查(detect SQL 不带 tenant_id |
| 索引前导列错了走全表扫 | DBA 评审所有 EXPLAIN;上线前压测 |
| `current_setting('app.tenant_id')` 没设导致 RLS 全过滤掉 | 应用层 fail-closed;监控空集查询率 |
| 跨租户分析需求多 | 提供受控的 admin role + 审计日志 |
| SQLite 开发 vs Postgres 生产差异 | 测试集成层用 testcontainers 跑 Postgres;不允许用 SQLite 跑 RLS 相关测试 |
| 单 DB 容量上限(>1TB 后维护困难) | 监控 DB 体积;超过阈值切 per-tenant DB(推翻方案) |
---
## 6. 推翻条件
切换到 **per-tenant DB** 当且仅当:
1. 拿到强合规客户(金融/医疗/政府),合同要求物理数据隔离
2. 单 DB 容量 / 写 TPS 触顶,垂直扩展不经济
3. 出现一次跨租户数据泄露事故,董事会要求最强隔离
---
## 7. 默认假设
| 项 | 默认 |
|---|---|
| 数据库 | PostgreSQL 16+ |
| RLS 启用 | 所有带 `tenant_id` 的业务表 |
| 主键 | UUID v7(时间排序) |
| 索引前导列 | `tenant_id` |
| Connection pool | 每应用进程 2050 connsPgBouncer transaction mode |
| 备份 | 每日全量 + WAL streaming,保留 30 天 |
| 跨租户查询 | 仅通过 `deerflow_admin` role + 审计 |
@@ -0,0 +1,363 @@
# ADR-002 · 沙箱隔离模型
| 项目 | 内容 |
|---|---|
| 状态 | 草稿(Draft |
| 决策日期 | TBD |
| 决策者 | 安全 + 架构 + SRE |
| 关联 ADR | ADR-001 数据隔离、ADR-005 存储拓扑 |
---
## 0. 概念前提
本 ADR 反复出现 **K8s Namespace + gVisor/Kata 运行时 + NetworkPolicy** 三件套——它们在不同层把租户的代码运行环境关起来,缺一不可。先用一段话讲清楚是什么、防什么、不防什么,再读后面的决策细节会顺很多。
### 0.1 K8s Namespace —— 资源/视图隔离
Kubernetes 的逻辑分区。一个集群里跑多租户,每租户分一个 namespace(如 `tenant-acme``tenant-bigco`),其中的 Pod / Service / Secret / ConfigMap 互相看不见。配套:
- **ResourceQuota**:限制 namespace 总用量(CPU、内存、Pod 数、存储)
- **LimitRange**:单 Pod 兜底(默认 request/limit、单 Pod 上限)
- **RBAC**:把租户管理员权限只绑到自己的 namespace
⚠️ **不是安全边界**。namespace 只让你"看不到",不是"碰不到"。两租户的 Pod 若都跑在默认 `runc` 上、共享同一个 Linux 内核,**任何一个内核 0day 都能让 A 容器逃逸到宿主,进而看到 B 容器**。所以需要下一层。
> 类比:办公楼里不同公司的门禁卡。同事进不了别人的工位,但墙不会自己变厚。
### 0.2 gVisor / Kata —— 内核级隔离
把容器从宿主内核上"再隔一层"。DeerFlow 沙箱要跑租户上传的任意 Python 代码,必须比 runc 更硬。
**gVisorGoogle**
- 用户态实现一个沙箱内核(Sentry),拦截容器所有 syscall 自己模拟,再用极少几个 syscall 跟真内核打交道
- 攻击面:先攻破 Sentry,再攻破真内核——多一道
- 代价:每个 syscall 中转,IO 密集型 workload 慢 ~10-30%
- 集成:Pod spec 写 `runtimeClassName: gvisor`
**Kata Containers**
- 给每个 Pod 起一个轻量虚拟机(QEMU 或 Firecracker 后端),容器跑在 VM 内独立内核里
- 隔离强度 ≈ 真 VM,启动几百毫秒
- 代价:每 Pod 多占 ~50-150 MB 内存、冷启动比 gVisor 慢一点
- 集成:`runtimeClassName: kata-qemu` / `kata-fc`
> 本 ADR 取舍:默认 **gVisor**(性价比平衡),监管/付费档位切 **Kata-Firecracker**(接近 VM 强度,单独定价)。配套 Cosign 镜像签名 + 只读根文件系统 + drop ALL caps 是纵深防御。
### 0.3 NetworkPolicy —— 出/入流量白名单
K8s 原生防火墙,按 namespace / Pod label 控制谁能跟谁通信。**默认拒绝 + 显式放行**是标准姿势:
```yaml
spec:
podSelector: {} # 命中 namespace 内所有 Pod
policyTypes: [Egress]
egress: [] # 空白名单 = 全部禁止
```
为什么对 DeerFlow 是关键——租户代码可能尝试访问:
| 目标 | 风险 |
|---|---|
| `169.254.169.254`(云元数据) | 偷 IAM 凭据、节点 token,直接拿下集群 |
| 平台内网 DB / Redis | 横向打到其他租户的数据 |
| 其他租户的 namespace IP | 跨租户监听/嗅探 |
| 互联网 C2 服务器 | 数据外泄、挖矿、僵尸网络 |
默认全部拒绝后,仅放行:DNS(CoreDNS)+ 出口走 **Egress Gateway**Envoy/Squid 做域名白名单,允许 `api.openai.com``pypi.org` 等,拒绝其余)。
> 执行靠 CNI 插件(Calico / Cilium)。Cilium 还支持 L7 策略(如"允许 GET /v1/chat/completions、禁 POST /admin"),是更强的备选。
### 0.4 三件套合起来看
```
租户的 Python 代码
┌────────────────────────────────────────┐
│ Pod (tenant-acme namespace) │ ← K8s Namespace:逻辑隔离 + 配额
│ ├─ runtimeClassName: gvisor │ ← gVisor:内核级攻击面隔离
│ ├─ readOnlyRootFilesystem │
│ └─ capabilities.drop: ["ALL"] │
└────────────────────────────────────────┘
│ egress
┌────────────────────────────────────────┐
│ NetworkPolicy: default-deny │ ← NetworkPolicy:网络层白名单
│ → 仅允许 Egress Gateway / DNS │
└────────────────────────────────────────┘
Egress Gateway(域名白名单)
```
- 没有 namespace:租户互相能看见对方的资源对象
- 没有 gVisor:一个内核 0day 全集群陪葬
- 没有 NetworkPolicy:租户代码 `curl 169.254.169.254` 就能拿走节点凭据
三层都套上,才是本 ADR 想要的"对抗任意租户代码"的最低防御姿势。
---
## 1. 背景
沙箱是多租户里**爆炸半径最大**的组件:客户的 agent 可以跑任意 bash 命令、读写文件、调用 MCP 工具。如果隔离不够强,一个客户能:
- **读到其他客户的数据**(容器逃逸 / 共享卷误用)
- **薅云元数据**`curl http://169.254.169.254/...` 偷 IAM 凭证)
- **横向移动**(同 namespace 的其他容器、同节点的 hostNetwork
- **耗尽资源**(fork bomb、无限循环、磁盘填满)
DeerFlow 现状有两个 sandbox provider
| Provider | 强度 | 多租户可用 |
|---|---|---|
| `LocalSandboxProvider` | bash/fs **直接落主机**,零隔离 | ❌ 绝对不能用 |
| `AioSandboxProvider` | Docker 容器(社区实现,`packages/harness/deerflow/community/aio_sandbox/` | ⚠️ 当前配置不够 |
`AioSandboxProvider` 起点不错(每 thread 一个容器、虚拟路径翻译已有),但默认配置缺少多租户必需的几条隔离:默认出网未禁、CPU/内存 limits 未强制、根文件系统未只读、镜像未签名校验。
---
## 2. 决策
**采用 K8s + 强隔离运行时(gVisor 或 Kata Containers+ NetworkPolicy 默认禁出网 + per-tenant Namespace。**
| 层 | 作用 |
|---|---|
| **K8s Namespace per tenant** | 资源逻辑隔离;NetworkPolicy 起效边界 |
| **gVisor (runsc) 运行时** | 用户态系统调用拦截,容器逃逸到宿主难度大幅提升;性能损失 ~5-15%(多数 agent 任务可接受) |
| **NetworkPolicy 默认 DENY** | 出网白名单:只允许到 LLM endpoint、配置的 MCP servers、搜索 API |
| **ResourceQuota + LimitRange** | per-namespace CPU/内存上限;单 pod CPU/内存上限 |
| **Pod Security Standard: restricted** | 禁 root、禁 privileged、只读根文件系统、drop ALL caps |
| **emptyDir 临时卷** | 数据靠 ADR-005 同步对象存储,pod 销毁即清 |
| **镜像签名校验**Cosign | 启动 pod 前验证 sandbox 镜像签名,防供应链攻击 |
**Premium 客户档位**:在此之上再加 per-tenant **物理节点池** + **Firecracker microVM**kata-fc),把"租户 X 的沙箱永远不和别人共享物理节点"做到 SLA 里。
---
## 3. 威胁模型
| 攻击场景 | 共享 Docker(现状) | per-tenant K8s NS + gVisor(决策) | per-tenant Firecracker |
|---|---|---|---|
| 容器逃逸到宿主 | 全员沦陷 | 单租户沦陷(gVisor 大幅降低成功率) | 单租户沦陷(VM 边界,逃逸难度极高) |
| 容器间横向移动(同节点) | 可行 | NetworkPolicy 禁止 + namespace 隔离 | 不可能(独立 VM |
| 出网到云元数据 169.254.169.254 | 默认可行 | NetworkPolicy 禁 + IMDSv2 强制 token | 同左 |
| 出网到内部服务(DB / 内网) | 可行 | NetworkPolicy 禁 + egress gateway 白名单 | 同左 |
| 侧信道(CPU 缓存 / Spectre | 可行 | 减弱(gVisor 隔离系统调用,但 CPU 共享仍有风险) | 显著减弱(独立 VM、独立 vCPU) |
| 资源耗尽(fork bomb / OOM | 影响同节点全部容器 | LimitRange 强制 cgroup;超限 OOMKill | VM 内独立调度 |
| 持久化攻击(写定时任务) | 可写主机 cron | 只读根文件系统 + ephemeral pod 重建即清 | 同左 |
| 提权 | 看 Docker 配置(默认 root | restricted PSS 禁 root、禁 capabilities | 同左 |
| 镜像被替换(供应链) | 不校验 | Cosign 验证签名 + admission controller 拦截 | 同左 |
---
## 4. 备选方案与拒绝理由
### A. 共享 Docker(保留 AioSandboxProvider 当前模式)
**拒绝。** 即使加固到极致,根本问题仍在:
- 所有租户共享 dockerd / containerd,一次容器逃逸 = 全员沦陷
- Docker 的 NetworkPolicy 等价物(user-defined network)粒度粗
- 资源限制依赖 cgroup v1/v2 配置一致,运维难统一
### B. per-tenant Firecracker microVM(默认)
**拒绝(作为默认)。** 隔离最强但成本最高:
- 冷启动比 K8s pod 慢 2-5×(500ms vs 几秒)
- 运维需要专门团队(kata-fc / cloud-hypervisor 都不是开箱即用)
- AWS / 阿里云的部分托管 K8s 不直接支持 Firecracker,需要自建 nodepool
**保留作为 premium 档位**:把 Firecracker 当付费 SLA 卖给监管类客户。
### C. 共享 K8s Namespace + 仅靠 NetworkPolicy + cgroup
**拒绝。** namespace 不分隔意味着:
- pod-to-pod 通信默认开放(NetworkPolicy 是白名单制,漏一条就全开)
- ServiceAccount 共享,越权读 secret 风险大
- ResourceQuota 是 namespace 级别,没法精细分配到租户
---
## 5. 落地影响
### 5.1 新建 `K8sSandboxProvider`
```python
# packages/harness/deerflow/sandbox/k8s/provider.py
class K8sSandboxProvider(SandboxProvider):
"""每 thread 一个 Pod,按 tenant 落到对应 Namespace。
生命周期:
acquire(thread_id) → 创建 PodgVisor runtime, restricted PSS, NetworkPolicy 已挂)
get(sandbox_id) → 返回与运行中 Pod 通信的客户端(kubectl exec / WebSocket
release(sandbox_id) → delete PodemptyDir 自动回收)
"""
```
替换 `LocalSandboxProvider`(开发/测试用)和 `AioSandboxProvider`(保留作为单机部署 fallback)。
### 5.2 K8s 资源(每租户 Namespace 一份)
```yaml
# tenant onboarding 时自动渲染、apply
apiVersion: v1
kind: Namespace
metadata:
name: tenant-{tenant_id}
labels:
pod-security.kubernetes.io/enforce: restricted
deerflow.io/tenant-id: {tenant_id}
---
apiVersion: v1
kind: ResourceQuota
metadata:
namespace: tenant-{tenant_id}
spec:
hard:
cpu: "16"
memory: 32Gi
pods: "20"
requests.storage: 100Gi
---
apiVersion: v1
kind: LimitRange
metadata:
namespace: tenant-{tenant_id}
spec:
limits:
- type: Container
default: { cpu: "500m", memory: 1Gi }
defaultRequest: { cpu: "100m", memory: 256Mi }
max: { cpu: "2", memory: 4Gi }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all-egress
namespace: tenant-{tenant_id}
spec:
podSelector: {}
policyTypes: [Egress]
egress: # 仅允许下面这些
- to:
- namespaceSelector: { matchLabels: { name: kube-system } }
podSelector: { matchLabels: { k8s-app: kube-dns } }
ports: [{ protocol: UDP, port: 53 }]
- to: # 通过 egress gateway 出外网
- podSelector: { matchLabels: { app: deerflow-egress-gateway } }
```
### 5.3 Egress gateway
放一个集中的出口代理(envoy / squid)做:
- LLM endpoint 白名单(OpenAI / Anthropic / vLLM 内网)
- MCP server 白名单(per-tenant 启用列表)
- 搜索 API 白名单(Tavily / Jina / Brave / DuckDuckGo
- **黑名单**169.254.169.254cloud metadata)、10.0.0.0/8 / 172.16.0.0/12 / 192.168.0.0/16(内部网络,除非白名单)
- 全量审计(按租户记录每次出网)
### 5.4 Pod Spec 关键字段
```yaml
spec:
runtimeClassName: gvisor # 或 kata-fcpremium
automountServiceAccountToken: false # 沙箱不该有 SA token
securityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: sandbox
image: registry.deerflow.io/sandbox:v2.3.4@sha256:... # 强制 digest pin
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
volumeMounts:
- { name: workspace, mountPath: /mnt/user-data }
- { name: skills, mountPath: /mnt/skills, readOnly: true }
volumes:
- { name: workspace, emptyDir: { sizeLimit: 10Gi } }
- { name: skills, emptyDir: { sizeLimit: 5Gi } } # 启动时从 S3 拉
```
### 5.5 镜像签名
- CI 用 cosign sign 给 sandbox 镜像签名
- K8s admission controllerpolicy-controller / Kyverno)启动 pod 前验证 cosign signature
- 拒绝任何未签名 / 签名不匹配的镜像
### 5.6 SandboxAuditMiddleware 增强
现有 `SandboxAuditMiddleware` 记录了工具调用,多租户必须:
- 每条审计带 `(tenant_id, user_id, thread_id, tool_name, args_hash)`
- 异步推到独立审计存储(不与业务库共用)
- 保留至少 90 天(合规要求常见值)
---
## 6. 性能影响
| 指标 | 估计 |
|---|---|
| Pod 冷启动(gVisor + 镜像 pre-warm | 1.5-3s |
| Pod 冷启动(Firecracker | 3-8s |
| Bash 命令延迟(vs 宿主) | gVisor +5-15%Firecracker +10-30% |
| 文件 I/O(小文件) | gVisor 显著慢(系统调用拦截);用 emptyDir tmpfs 缓解 |
| 网络(egress gateway | +1-3ms 单跳 |
**冷启动是最敏感的指标**——靠两条路径优化:
1. **Pod prewarm**:每个 namespace 维护 N 个空闲 pod 池(`PodReadinessProbe` 通过即可服用,按需绑定 thread)
2. **镜像层缓存**:每节点预拉镜像(DaemonSet image-puller
---
## 7. 风险与缓解
| 风险 | 缓解 |
|---|---|
| gVisor 与某些 syscall 不兼容(agent bash 跑不动某些工具) | sandbox 镜像里预装常用工具;CI 跑兼容性测试集 |
| 出网白名单维护负担 | Per-tenant MCP/搜索配置自动生成 NetworkPolicy;运维 admin UI 一键加 |
| Firecracker 运维复杂 | 仅作为 premium 档位,不强制全量上 |
| 节点 noisy neighborCPU 共享导致侧信道) | 高敏租户走专属 nodepooltaints/tolerations |
| Pod prewarm 池资源浪费 | 按租户活跃度动态调节池大小;闲置超过阈值缩到 0 |
| 镜像供应链攻击 | Cosign 强制 + SBOM + 漏洞扫描 |
---
## 8. 推翻条件
切换到 **per-tenant Firecracker(默认)** 当且仅当:
1. 实测 gVisor 在某条关键 syscall 上有不可绕过的兼容性问题(且 sandbox 镜像无法预装替代品)
2. 拿到合同要求"强物理隔离"的监管客户,付费档位要求覆盖运维成本
3. 出现一次容器逃逸 PoC 影响多租户
切换到 **共享 Docker(极端简化)** 当且仅当:
- 公司决定退回单租户产品形态——多租户上线后基本不应回退
---
## 9. 默认假设
| 项 | 默认 |
|---|---|
| 集群 | EKS / ACK / GKEK8s 1.29+ |
| 沙箱 runtime | gVisor (runsc) |
| Premium runtime | Kata Containers + Firecracker |
| Pod 隔离粒度 | per-thread(不复用) |
| 冷启动 SLO | P50 < 2s, P99 < 5s |
| Pod CPU/Mem 上限 | 2 CPU / 4 GiB(单 pod |
| Namespace 配额 | 16 CPU / 32 GiB / 20 pods(按 plan 调) |
| Egress 白名单数量 | <30 个域名 / 租户 |
| 审计保留期 | 90 天热 + 1 年冷 |
| 镜像签名 | cosign + Kyverno 强制 |
@@ -0,0 +1,285 @@
# 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()` 时做反射 + 环境变量替换。
多租户场景下,这套模式有四个问题:
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 已含)
```sql
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()`
当前签名(伪):
```python
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, ...)
```
改造后:
```python
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 已含)
```sql
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 调用前检查:
```python
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 释放 |
```sql
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` 一次性提交——多租户后这有两个隐患:
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:不限 |
@@ -0,0 +1,372 @@
# ADR-004 · 租户内层级与 RBAC
| 项目 | 内容 |
|---|---|
| 状态 | 草稿(Draft |
| 决策日期 | TBD |
| 决策者 | 产品 + 后端 lead |
| 关联 ADR | ADR-001 数据隔离、ADR-003 LLM Key 与计费 |
---
## 1. 背景
DeerFlow 当前的角色模型在 `users.system_role` 字段(`backend/packages/harness/deerflow/persistence/user/model.py:33`),只有两级:
- `admin`:首启账户、平台运维
- `user`:普通注册用户
并且 admin 是**全平台级别**的——它能管所有用户。多租户化后,"管理员"概念要拆成两层:
- **平台 admin**:你(运营 DeerFlow SaaS 的人)的运维账号
- **租户内角色**:客户公司内部的角色(owner / admin / member
需要决定:**租户内要不要分角色?分多细?**
---
## 2. 决策
**采用二级 RBAC**:每个租户内有 `owner` / `admin` / `member` 三种角色。最小化但够用。
| 角色 | 数量 | 核心权限 |
|---|---|---|
| **owner** | 1 个/租户(可转让) | 删除租户、修改计费、转让所有权、管理 admin |
| **admin** | 多个 | 邀请/移除 member、安装/启用 skill、配置 MCP、查看用量 |
| **member** | 多个 | 跑 agent、上传文件、看自己的 thread、看本租户共享 skill |
---
## 3. 备选方案与拒绝理由
### A. 扁平(租户内不分角色)
**拒绝。** 简单但企业客户会立刻不满:
- 没法满足"我让员工用,但不让他们改 skill 配置"的需求
- 没法做 SSO / SCIM 集成(IT 管理员要能批量管成员)
- 客户拉新时(员工增多)会出现"谁负责清理"的问题
### B. 三级以上 RBAC(自定义角色 / 项目级权限)
**拒绝(起步阶段)。** 复杂度爆炸:
- 自定义角色 = 完整的 RBAC engine(角色继承、权限矩阵、UI 编辑器)
- 项目/工作区级权限 = 还要再加一层组织
- 这是 Enterprise 后期需求,第一年用不到
**保留作为后续扩展点**:等"自定义角色"成为销售卡点再做。
### C. 单一 adminowner = admin = 同一个)
**拒绝。** owner 和 admin 必须分开:
- owner 是"账户所有权"——负责计费和租户存亡
- admin 是"日常管理"——可以授权多个
- 不分开会导致"所有 admin 都能删租户",运营失控
---
## 4. 权限矩阵
按"资源 × 动作"展开,下表是 MVP 矩阵(可扩展):
| 资源 | 动作 | owner | admin | member |
|---|---|---|---|---|
| **tenant** | view | ✓ | ✓ | ✓ |
| | update_settings | ✓ | ✓ | |
| | delete | ✓ | | |
| | transfer_ownership | ✓ | | |
| **billing** | view | ✓ | ✓ | |
| | update_payment | ✓ | | |
| | manage_byo_key | ✓ | ✓ | |
| **members** | invite | ✓ | ✓ | |
| | remove | ✓ | ✓ | |
| | change_role | ✓ | | |
| **threads** | create | ✓ | ✓ | ✓ |
| | read_own | ✓ | ✓ | ✓ |
| | read_others | ✓ | ✓ | |
| | delete_own | ✓ | ✓ | ✓ |
| | delete_others | ✓ | ✓ | |
| **skills** | install | ✓ | ✓ | |
| | enable_disable | ✓ | ✓ | |
| | use | ✓ | ✓ | ✓ |
| **mcp_servers** | configure | ✓ | ✓ | |
| | use | ✓ | ✓ | ✓ |
| **custom_agents** | create_for_self | ✓ | ✓ | ✓ |
| | create_shared | ✓ | ✓ | |
| | edit_others | ✓ | ✓ | |
| **usage_reports** | view | ✓ | ✓ | |
| **audit_log** | view | ✓ | ✓ | |
注意几个设计选择:
- **`threads:read_others`**:默认 admin 能看(合规审计需要),但建议加配置项让租户 owner 可关掉
- **`custom_agents:create_for_self` 给 member**:每个成员可以建私人 agent,但要 admin 才能"共享给整租户"
- **`skills:use` 给 member**:使用 admin 启用的 skill;不能自己装 / 关
---
## 5. 落地影响
### 5.1 数据模型(ADR-005 phase 0 plan 已含)
```sql
-- 1 个用户可属于多个租户
tenant_memberships (
tenant_id UUID FK,
user_id UUID FK,
role VARCHAR(16), -- 'owner' | 'admin' | 'member'
invited_by UUID,
joined_at TIMESTAMP,
PRIMARY KEY (tenant_id, user_id)
)
-- 1 个租户必须有且仅有 1 个 owner
CREATE UNIQUE INDEX idx_one_owner_per_tenant
ON tenant_memberships (tenant_id) WHERE role = 'owner';
-- 当前用户的"默认租户"(用户登录时落进哪个 tenant context
ALTER TABLE users ADD COLUMN default_tenant_id UUID;
```
### 5.2 JWT Payload 改造
当前 JWT 只有 `sub: user_id`。改造后:
```json
{
"sub": "<user_id>",
"tid": "<tenant_id>", // 当前激活的租户
"role": "admin", // 当前租户内的角色
"tv": 5, // token_version(保留现有撤销机制)
"iat": ...,
"exp": ...
}
```
**为什么把 role 放 JWT**:避免每次请求都查 `tenant_memberships` 表;权限变化时通过 `token_version++` 强制踢人重登。
**租户切换**:用户在 UI 切换租户时,调 `POST /api/v1/auth/switch-tenant` → 服务端校验 membership → 重发 JWT(新 tid + role+ 新 cookie。
#### 5.2.1 token_version 的存储与失效路径
`token_version` 不是 JWT 自带字段——需要在 `users` 表加一列:
```sql
ALTER TABLE users ADD COLUMN token_version INT NOT NULL DEFAULT 0;
```
签发 JWT 时把当前 `token_version` 写进 `tv` claim;验证 JWT 时**只读一次**(不每请求查 DB):
- 命中**短 cache**30s LRUkey=user_id)→ 比对 cache 里的 token_version
- cache miss → 查 DB 一次,写入 cache
- DB 里的 `token_version` 与 JWT 内 `tv` 不一致 → 401,cookie 强制失效,重登
谁会 bump `token_version`
| 触发 | 谁写 |
|---|---|
| Membership 撤销 | `DELETE /tenants/{tid}/members/{uid}``users.token_version += 1` |
| 角色变更(admin → member 等) | 同上 |
| 主动注销所有设备("踢出所有会话") | `POST /auth/sign-out-all` |
| 密码修改 | `POST /auth/change-password` |
| owner 转让冷静期满 | 双方都 bump |
#### 5.2.2 JWT 内 role 与每请求查 DB 的取舍
这两条**只能选一条**,本 ADR 选 **AJWT 内 role + 30s cache + 敏感操作必查 DB**
- **常规请求**read thread、list skills 等):信 JWT,30s 内可能拿到过期权限——可接受
- **写敏感资源**(删除 thread、修改 billing、邀请成员、安装 skill):装饰器 `@require_permission(strict=True)` 强制查 `tenant_memberships`,不走 cache
- **撤销/降权后**bump `token_version` 让 cache miss → 下一次请求 401 → 用户重登拿新 JWT
这等于"读路径乐观、写路径悲观",与 ADR-001 RLS 是双层兜底(JWT role 错了 RLS 还在 tenant 维度过滤,跨租户绝不会泄露)。
#### 5.2.3 cache 实现要点
```python
# packages/harness/deerflow/persistence/membership/cache.py
class MembershipCache:
"""Per-process LRU, 30s TTL, key = (user_id,) → token_version."""
_cache: TTLCache = TTLCache(maxsize=10_000, ttl=30)
async def get_token_version(self, user_id: str) -> int:
cached = self._cache.get(user_id)
if cached is not None:
return cached
version = await user_repo.get_token_version(user_id)
self._cache[user_id] = version
return version
def invalidate(self, user_id: str) -> None:
"""主动失效——bump token_version 时调用。"""
self._cache.pop(user_id, None)
```
进程间不共享(K8s 多 pod 时 30s 内可能不一致——可接受,超过 30s 全部 pod 自动同步)。
**禁忌**:把 membership cache 做到 Redis 之类共享层。理由:失效广播复杂、租户隔离混乱、收益小。30s 不一致窗口比这个复杂度划算。
### 5.3 AuthMiddleware 注入两层 ContextVar
```python
async def dispatch(request, call_next):
if _is_public(request.url.path):
return await call_next(request)
user = await get_current_user_from_request(request)
jwt_payload = await verify_jwt(request)
tenant_id = jwt_payload["tid"]
role = jwt_payload["role"]
jwt_tv = jwt_payload["tv"]
# 走 cache 比对 token_version:通常不查 DB
current_tv = await membership_cache.get_token_version(user.id)
if jwt_tv != current_tv:
raise HTTPException(401, "Token revoked, please sign in again")
# ContextVar + request.state 注入
set_current_user(user)
set_current_tenant(tenant_id, role)
request.state.user = user
request.state.tenant_id = tenant_id
request.state.role = role
return await call_next(request)
```
**这里**只比对 `token_version`30s cache),**不**每请求查 `tenant_memberships`。membership 状态变化通过 §5.2.1 的 bump 机制传导。
敏感写操作另走一层(`@require_permission(strict=True)`)—— 在路由 handler 里查 DB,详见 §5.2.2。
### 5.4 权限装饰器升级
现有 `@require_permission("threads", "read", owner_check=True)` 沿用,但语义升级:
```python
@router.get("/{thread_id}")
@require_auth
@require_permission("threads", "read", owner_check="self_or_admin")
# self_or_admin: 自己拥有的 thread 自由读;他人的需要 admin/owner
async def get_thread(thread_id: str, request: Request):
...
@router.delete("/tenants/{tid}/members/{uid}")
@require_auth
@require_permission("members", "remove", owner_check="admin_only", strict=True)
# strict=True: 不走 cache,强制查最新 membership
async def remove_member(...):
...
```
`owner_check` 的几种模式:
- `"self"`:必须是该资源的 user_id 创建者
- `"self_or_admin"`:自己 / 当前租户的 admin/owner 都行
- `"admin_only"`:仅 admin/owner
- `"owner_only"`:仅 owner
`strict=True` 何时必加(不走 30s cache,强制查 DB):
- 任何修改 RBAC 的操作(邀请/移除/改角色/转让)
- 任何修改计费 / billing 的操作
- 删除 thread / 删除 skill / 删除 MCP server
- owner 才能做的危险操作(删租户)
读操作和普通写操作(创建 thread、改自己的 memory)不需要 strict——cache 不一致最多窗口 30s,足够。
### 5.5 邀请流程
```
1. admin 在 UI 输入 email + role → POST /api/v1/tenants/{tid}/invitations
2. 后端写 invitations 表 → 发邮件(链接含 invitation_token
3. 受邀用户点击:
a. 已注册 → 直接 attach membership
b. 未注册 → 跳注册流程,注册成功后 attach
4. 邀请 7 天过期,admin 可重发或撤销
```
`invitations` 表已在 phase 0 plan 设计中。
### 5.6 Owner 转让
owner 是单一的,转让流程要谨慎:
```
1. owner 在 UI 选定新 owner(必须是当前 admin
2. 系统给原 owner 发确认邮件 + 二次密码确认
3. 24h 冷静期内可撤销
4. 冷静期满 → membership 表事务交换:
原 owner.role := 'admin'
新 owner.role := 'owner'
(单事务 + 唯一索引保证不会出现 2 个 owner)
```
### 5.7 SSO / SCIM 预留
**SSOSAML / OIDC**
- 复用现有 `oauth_provider` / `oauth_id` 字段(`UserRow`
- 新增 `tenant_sso_configs(tenant_id, provider, idp_url, cert, ...)`
- 用户首次 SSO 登录时,自动 attach 到 IdP 配置的默认 tenant + 默认角色(通常 member
**SCIM**(自动用户配置 / 撤销):
- 实现 `/api/scim/v2/Users` + `/api/scim/v2/Groups` 标准接口
- IdPOkta / Azure ADpush 用户增删 → 自动同步 `tenant_memberships`
- **第一年可不实现**——SSO 已能覆盖大多数企业需求
---
## 6. 与现有 system_role 的关系
| 字段 | 含义 | 是否保留 |
|---|---|---|
| `users.system_role` | **平台级**角色:`platform_admin` / `user` | 保留 |
| `tenant_memberships.role` | **租户内**角色:`owner` / `admin` / `member` | 新增 |
`platform_admin`(DeerFlow 运营人员)可以跨租户操作(结合 ADR-001 的 `BYPASSRLS` role),但操作必须审计。普通用户的 `system_role = 'user'`
注意 **首次启动**逻辑改动:
- 旧:`/setup` 创建第一个 admin 用户
- 新:`/setup` 创建第一个 `platform_admin` + 同时建一个名为 `default` 的租户,把这个用户设为 owner(兼容老部署)
---
## 7. 风险与缓解
| 风险 | 缓解 |
|---|---|
| 最后一个 owner 离职导致租户失控 | 不允许 owner 直接退出,必须先转让;提供平台 admin 强制转让的运维接口(带审计) |
| 权限矩阵越改越复杂 | MVP 矩阵冻结半年;新需求先走"是否扁平角色能解决"评审 |
| JWT 里 role 缓存与 DB 不一致 | membership 撤销时 bump `token_version`cookie 失效 |
| 跨租户用户切换场景容易误操作 | UI 显著标识当前激活租户(顶栏色块 + 租户名);危险操作再校验 |
| SSO 接入工作量被低估 | SSO 列入 v2 路线图,明确不阻塞 v1 上线 |
---
## 8. 推翻条件
- **走向自定义角色**:销售反馈明确"我们大客户必须自定义 admin/operator/auditor"——升级到完整 RBAC engine
- **走向项目/工作区**:客户内部需要"多个团队互相不可见"——加 workspace 层(tenant > workspace > user
- **退回扁平**:极简产品形态变化,弃用企业销售路线(不太可能)
---
## 9. 默认假设
| 项 | 默认 |
|---|---|
| 角色数 | 3 (owner/admin/member) |
| Owner 数 | 严格 1 个/租户 |
| Admin 数 | 不限(按 plan 可设上限) |
| 跨租户成员 | 同一 user 可属于多个租户 |
| JWT role claim | 缓存到 token 里,membership 变更走 token_version 失效 |
| 默认新成员角色 | `member` |
| Invitation TTL | 7 天 |
| Owner 转让冷静期 | 24h |
| SSO | v2 路线图(非 v1 阻塞) |
| SCIM | 暂不实现 |
| 自定义角色 | 暂不实现 |
@@ -0,0 +1,402 @@
# ADR-005 · 存储拓扑与持久化策略
| 项目 | 内容 |
|---|---|
| 状态 | 草稿(Draft |
| 决策日期 | TBD |
| 决策者 | 架构 + 后端 lead + SRE |
| 关联 ADR | ADR-001 数据隔离、ADR-002 沙箱隔离、ADR-006 运行时与渠道 |
---
## 1. 背景
DeerFlow 当前所有持久化数据都在容器/主机的本地文件系统下(`{base_dir}/...`,默认 `./.deer-flow`,可用 `DEER_FLOW_HOME` 覆盖)。这套布局是为"单机/单副本本地开发"设计的——多租户上线后会出现三类问题:
1. **多副本不一致**K8s 横向扩展后,副本 A 写的 `memory.json` 副本 B 看不到。
2. **容器重建丢数据**:沙箱 pod 销毁、Gateway pod 重启即丢失自定义 skill / agent / memory / 上传 / 产物。
3. **没有备份/灾备**:磁盘损坏即客户数据全失。
现状盘点(详见 `paths.py``skills/storage/`):
| 数据 | 现位置 | 容器重建会丢 |
|---|---|---|
| 公共 skills | repo 内 `skills/public/`(镜像里) | 否 |
| 自定义 skills(用户安装) | `skills/custom/`**全局共享**,非 per-user | **是** |
| 自定义 agentSOUL.md + config.yaml | `{base_dir}/users/{uid}/agents/{name}/` | **是** |
| 长期记忆 | `{base_dir}/users/{uid}/memory.json` | **是** |
| 用户上传 | `{base_dir}/users/{uid}/threads/{tid}/user-data/uploads/` | **是** |
| Agent 工作区(草稿) | `.../user-data/workspace/` | 是(可接受) |
| Agent 产物 | `.../user-data/outputs/` | **是** |
| 配置开关 | `extensions_config.json`(仓库根,全局) | **是** |
| 会话/运行/反馈 | SQLAlchemy 表(DB | 否 |
| 用户/认证 | `users` 表(DB | 否 |
---
## 2. 决策
**采用三层存储拓扑**:结构化数据进 Postgres,二进制大对象进对象存储,沙箱本地只保留运行期临时区。**沙箱 pod 因此是真正无状态的**——可被任意调度、滚动升级、销毁重建。
```
┌─────────────────────────────────────────────────────────────┐
│ Postgres (RLS) │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ users │ │ agent_configs │ ← SOUL.md 入库 │
│ │ tenants │ │ memory_facts │ ← memory.json 入库│
│ │ memberships │ │ memory_context │ │
│ │ threads_meta │ │ tenant_secrets │ ← API key 加密入库│
│ │ runs │ │ tenant_skill_state│ │
│ │ run_events │ │ tenant_mcp_configs│ │
│ │ feedback │ │ tenant_quotas │ │
│ └────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 对象存储(S3 / OSS / MinIO
│ deerflow-data/{tenant_id}/... │
│ uploads/{thread_id}/... ← 客户上传 │
│ threads/{thread_id}/outputs/...← agent 产物 │
│ deerflow-skills/{tenant_id}/ │
│ {skill_name}-{version}.skill ← 技能包源 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 沙箱 Pod 本地(容器 emptyDir / 临时卷) │
│ /mnt/user-data/workspace/ ← 中间草稿,run 结束清掉 │
│ /mnt/user-data/uploads/ ← 启动时从 S3 拉,按需 │
│ /mnt/user-data/outputs/ ← 写完后同步到 S3 │
│ /mnt/skills/ ← 启动时按租户 enabled 列表拉│
└─────────────────────────────────────────────────────────────┘
```
### 2.1 各类数据的归属
**A. 进 Postgres(结构化、要查询、要并发更新、不大)**
| 数据 | 表 | 备注 |
|---|---|---|
| 自定义 agent 的 `SOUL.md` + `config.yaml` | `agent_configs(tenant_id, user_id, agent_name, soul_md TEXT, config_yaml TEXT, version, updated_at)` | 文本不大;要并发改、版本回滚 |
| memory.json 中的事实 | `memory_facts(tenant_id, user_id, fact_id, content, category, confidence, created_at, source)` | 要按 confidence/category 查询、去重、流式追加 |
| memory.json 中的上下文 | `memory_context(tenant_id, user_id, work_context, personal_context, top_of_mind, ...)` | 1 行 / (tenant, user) |
| skill 启用状态 | `tenant_skill_state(tenant_id, skill_name, enabled, source)` | 替代全局 `extensions_config.json` |
| MCP 配置 | `tenant_mcp_configs(tenant_id, server_name, transport, url, encrypted_config)` | 替代全局 `extensions_config.json` |
| Tenant secretsLLM key 等) | `tenant_secrets(tenant_id, key, encrypted_value)` | KMS 加密 |
**B. 进对象存储(大、二进制、写一次读多次、版本化天然)**
| 数据 | Object key | 备注 |
|---|---|---|
| 用户上传文件 | `tenants/{tid}/uploads/{thread_id}/{filename}` | presigned PUT 直传,沙箱按需拉 |
| Agent 产物 | `tenants/{tid}/threads/{thread_id}/outputs/{path}` | `present_files` 触发上传 |
| 技能包 `.skill` | `tenants/{tid}/skills/{skill_name}/{version}.skill` | SHA256 校验,做版本控制 |
| 公共技能包(平台) | `platform/skills/{skill_name}/{version}.skill` | 跨租户共享 |
**C. 不持久化(彻底丢弃)**
| 数据 | 为什么 |
|---|---|
| 沙箱 `workspace/` 中间产物 | agent 的"草稿纸"——半成品脚本、临时调试输出。强行同步浪费带宽 + 增加爆炸半径。让 agent 主动 `present_files` 到 outputs 才同步 |
| 解压后的 skill 目录 | 当本地缓存(LRU)。启动时从 S3 拉 .skill 包解压;缓存命中则跳过 |
| Gateway 进程的本地缓存(mtime 失效那些) | 进程级,不需要持久化 |
---
## 3. 备选方案
### A. 全部进 PVC(共享文件存储 RWX,例如 EFS / NFS
**拒绝。** 看似最小改动,但:
- 读写竞态依然存在(DeerFlow 的 atomic rename 在 NFS 上行为不一致)
- 大量小文件读写性能差(memory.json 每次写需要 fsync
- 备份策略复杂(NFS 快照 vs 增量备份)
- 退订清理很慢(递归删大量小文件)
### B. 全部进对象存储(连 metadata 都用 S3
**拒绝。** 用对象存储模拟文件系统:
- 强一致性差(多数对象存储是 read-after-write,没有 CAS
- 对小文件高频写延迟太高(每次写 50–200ms)
- 没有事务,跨对象一致性靠应用层补
- 列表操作慢(list-objects 是分页拉取)
### C. 分层:DB(结构化)+ S3(二进制)+ 临时区(中间产物)
**采纳。** 各取所长,与 ADR-001(行级隔离 + RLS)天然契合,并让沙箱 pod 真正无状态。
---
## 4. ObjectStorage 接口草稿
抽象层放在 `backend/packages/harness/deerflow/storage/`。所有调用方写抽象接口,**不直接 import boto3**。
```python
# packages/harness/deerflow/storage/protocol.py
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Protocol, runtime_checkable
from pydantic import BaseModel
class ObjectMetadata(BaseModel):
key: str
size: int
etag: str
content_type: str
last_modified: datetime
metadata: dict[str, str] = {} # 自定义元数据(x-amz-meta-*
class ObjectNotFound(Exception):
"""对象不存在(幂等删除/读取时由实现转换为此异常或返回 None)。"""
@runtime_checkable
class ObjectStorage(Protocol):
"""租户感知的对象存储抽象。
实现:
- LocalObjectStorage 开发/单元测试用,落本地目录
- S3ObjectStorage AWS S3 / 兼容接口(OSS / OBS / R2
- MinIOObjectStorage 自部署 MinIOS3 协议)
Key 命名规范(强约束,便于按 prefix 退订清理):
tenants/{tenant_id}/...
platform/... ← 跨租户共享资源(公共技能包)
"""
# ── 同步 / 异步统一为 async(实现里用 aioboto3 / 自托管 thread pool ──
async def put(
self,
key: str,
data: bytes,
*,
content_type: str = "application/octet-stream",
metadata: dict[str, str] | None = None,
) -> ObjectMetadata: ...
async def put_stream(
self,
key: str,
stream: AsyncIterator[bytes],
*,
content_type: str = "application/octet-stream",
content_length: int | None = None,
metadata: dict[str, str] | None = None,
) -> ObjectMetadata: ...
async def get(self, key: str) -> bytes: ...
async def get_stream(self, key: str) -> AsyncIterator[bytes]: ...
async def stat(self, key: str) -> ObjectMetadata | None:
"""读取元数据;不存在返回 None(不抛异常)。"""
async def exists(self, key: str) -> bool: ...
async def delete(self, key: str) -> None:
"""幂等删除——不存在视为成功。"""
async def delete_prefix(self, prefix: str) -> int:
"""按 prefix 批量删除,返回删除数量。
重要:仅供 tenant 退订 / GC 任务使用。生产实现应:
- 校验 prefix 必须以 ``tenants/{tenant_id}/`` 开头
- 限制最大并发与最大删除数(避免一次误删全库)
- 异步分批,超时可继续
"""
async def list(
self,
prefix: str,
*,
limit: int = 1000,
continuation_token: str | None = None,
) -> tuple[list[ObjectMetadata], str | None]:
"""分页列出。第二个返回值是下一页 token,None 表示结束。"""
async def copy(self, src_key: str, dst_key: str) -> ObjectMetadata: ...
async def presigned_put_url(
self,
key: str,
*,
expires_in: int = 3600,
content_type: str = "application/octet-stream",
max_size_bytes: int | None = None,
) -> str:
"""给客户端生成预签名上传 URL。max_size_bytes 用于 S3 POST policy。"""
async def presigned_get_url(
self,
key: str,
*,
expires_in: int = 3600,
content_disposition: str | None = None,
) -> str:
"""给客户端生成预签名下载 URL。
重要:对 HTML/SVG 等活动内容,调用方必须传入
content_disposition='attachment; filename="..."'
以保留当前 Gateway artifacts 路由的 XSS 防护策略。
"""
```
### 4.1 调用边界
```python
# 应用层只 import 抽象,不知道底下是 S3 还是 local
from deerflow.storage import ObjectStorage, get_storage
storage: ObjectStorage = get_storage() # 单例工厂,按 config.yaml 选实现
# 上传:从 sandbox outputs 同步到 S3
await storage.put_stream(
f"tenants/{tid}/threads/{thread_id}/outputs/{filename}",
stream=open_async(local_path),
content_type=guessed_mime,
)
# 退订:清理整个租户(GC 任务)
await storage.delete_prefix(f"tenants/{tid}/")
# 给前端 presigned upload
url = await storage.presigned_put_url(
f"tenants/{tid}/uploads/{thread_id}/{filename}",
expires_in=900,
content_type=mime,
max_size_bytes=100 * 1024 * 1024,
)
```
### 4.2 配置
```yaml
# config.yaml
storage:
use: deerflow.storage.s3:S3ObjectStorage # 反射加载,沿用现有模式
bucket: deerflow-data
region: ap-northeast-1
endpoint_url: null # MinIO 时填自部署地址
access_key_id: $S3_ACCESS_KEY_ID
secret_access_key: $S3_SECRET_ACCESS_KEY
encryption: SSE-KMS # SSE-S3 / SSE-KMS / null
kms_key_id: $S3_KMS_KEY_ID # 用 KMS 时填
```
---
## 5. 落地改造点(实施清单)
按优先级排序,每条对应第 1 阶段或第 2 阶段的一个 PR:
### 第 1 阶段(必须)
1. **抽象 `ObjectStorage` 接口 + `LocalObjectStorage` 实现** — 走通端到端,开发/测试用本地目录跑,不阻塞迁移
2. **memory.json 迁库**
- 新增 `memory_facts` / `memory_context`
- 改写 `agents/memory/storage.py` 用 repository
- 移除 30s debounce 队列的"合并写"逻辑(DB 层不需要了,保留外部 LLM 抽取的 debounce
- 数据迁移脚本:扫现有 `memory.json` 文件 → 入库 → 校验 → 删源文件
3. **agent SOUL/config 迁库**
- 新增 `agent_configs` + `agent_config_history`
- 改写 `setup_agent` / `update_agent` 工具用 repository
- 数据迁移脚本:扫 `users/{uid}/agents/` → 入库
4. **`extensions_config.json` 拆库**——这条比看起来大,连锁影响展开如下:
**a. 新表与仓储**
- `tenant_skill_state(tenant_id, skill_name, enabled, source, version, updated_at)`
- `tenant_mcp_configs(tenant_id, server_name, transport, url, encrypted_config, updated_at)`
- 各自配 repository(仿 `persistence/user/` 的 ContextVar AUTO 模式)
**b. 替换文件 mtime 热重载机制**
- 现状:`get_app_config()` + 多处 `_is_cache_stale()`(如 `mcp/cache.py:31`)靠文件 mtime 判失效
- 改造:
- `extensions_config.py` 保留为"平台默认开关"的载体(公司 ship 的默认值)
- 运行时配置全部走 DB;mtime 失效信号改为 `tenant_*_configs.updated_at`DB 单调递增)
- 缓存层从模块级单例 → per-tenant LRU(详见 ADR-006 §2.2 / §2.3
**c. Gateway 路由改写**
- `app/gateway/routers/skills.py``PUT /skills/{name}/enable``POST /skills/install` → 全部改为按当前 tenant_id 写 `tenant_skill_state` / 上传 S3
- `app/gateway/routers/mcp.py``PUT /mcp/servers/{name}` → 改为按当前 tenant_id 写 `tenant_mcp_configs`,写完调 `TenantMCPCache.invalidate(tenant_id)`
- 鉴权:admin/owner 才能改(参照 ADR-004 §5.4 `strict=True`
**d. AppConfig 反射链不动**
- `config.yaml` 里的 model / sandbox / channels 等仍是平台级配置,不动
- 只有"per-tenant 可定制的开关"挪到 DBskill enabled、MCP server、tenant secrets
- 旧的 `extensions_config.json` 在迁移完成后**删文件 + 删读取代码**,CI 加禁字典阻止再被引用
**e. 数据迁移**
- 写一次性脚本:扫现有 `extensions_config.json` → 写入 "legacy_tenant" 的 `tenant_skill_state` / `tenant_mcp_configs`
- 校验所有租户 enabled list 与现状一致后,删除文件
- 自部署单机用户:first-run upgrade 时自动跑此脚本
**f. 老用户/单机模式兼容**
- 单机部署 = 1 租户("default");体验上不应感知"DB 化"——配置改完仍立即生效
- 解决路径:tenant_*_configs 写入后,立即 invalidate 进程内 cache(同一个进程,没问题)
- 多 pod 部署:靠 `updated_at` 版本号在请求路径上自动同步,无需广播
**估工**:原 ADR-005 列了 1 条 bullet 偏乐观;这块包含 c/d/e/f 四子项,**整体约 M 偏 L**(一周量级),不是 S。
### 第 1 阶段 / 第 2 阶段交界
5. **`S3ObjectStorage` 实现** — 用 aioboto3,覆盖 protocol 全部方法
6. **上传文件改 presigned 直传**
- 前端拿 presigned PUT URL → 直传 S3
- 后端收 "upload complete" 通知 → 异步触发 markitdown 转换 worker
- 沙箱启动时按需 lazy 拉取(不预拉所有上传)
7. **agent 产物 outputs 同步**
- `present_files` 工具:上传 outputs 到 S3 + 返回 presigned GET URL(带 `Content-Disposition: attachment` 给 HTML/SVG
- sandbox 销毁时本地清理(emptyDir 自然回收)
8. **技能包二进制迁 S3**
- `POST /api/skills/install`:把 .skill 上传到 S3(带 SHA256 metadata),不再解压到本地全局目录
- 启动时按 tenant skill list 从 S3 拉 + 校验 + 解压到 LRU 缓存
### 第 2 阶段(建议)
9. **退订 GC**tenant 标记 deleted 后,30 天定时任务跑 `delete_prefix(f"tenants/{tid}/")` + DB cascade delete
10. **跨区域复制 / CDN**:按客户分布加 region replica 或 CloudFront / OSS 加速域名
11. **审计接入**:每次 storage 操作(put/delete/presigned)记审计日志,带 (tenant_id, user_id, key, op, request_id)
---
## 6. 一致性与失败模式
| 场景 | 行为 | 备注 |
|---|---|---|
| Run 中途崩溃,outputs 没上传完 | DB 里 run 标记为 failed;下次重试 / 用户重新发起 | outputs 是"声明产物",丢失中间状态可接受 |
| S3 上传成功但 DB 写失败 | 后台 GC 扫"无 DB 引用的 S3 对象"清理 | 标准的 sweep 模式 |
| DB 写成功但 S3 上传失败 | 应用层重试;持续失败则把 run 标 failed 并告警 | 不允许 DB 引用一个不存在的 S3 对象 |
| 客户上传到 presigned URL 但没通知后端 | 后台 sweeper 扫"上传超时未关联 thread 的对象"清理 | TTL 1h |
| Memory 抽取异步任务卡住 | DB 层不会有半成品(事务 commit 才落库) | 保留之前的可观测性 |
---
## 7. 风险与推翻条件
**风险:**
- S3 调用延迟(上传/下载几十 MB 文件耗时,影响 sandbox 冷启动)→ 用 region 同区 + 内网 endpoint 缓解;首启延迟拿 LRU 命中率监控
- presigned URL 泄露(被截获后任何人可上传/下载)→ TTL 短(≤1h)+ `max_size_bytes` 限制 + content-type 限制
- 退订时 `delete_prefix` 误伤 → 强制 prefix 校验 + dry-run 模式 + 二次确认
**推翻条件:**
1. 实测 sandbox 冷启动 P99 > 30s 且无法靠 LRU 缓存解决 → 改用 PVC 存技能包 + 共享只读卷
2. 客户合规要求物理隔离(监管类客户) → 切 ADR-001 的 per-tenant DB 方案,对象存储也切 per-tenant bucketvs prefix
3. 团队规模不足以维护 S3 + Postgres 两套基础设施 → 退回 PVC + 单一存储(接受单副本部署)
---
## 8. 默认假设
如无相反证据,按此推进:
| 决策项 | 默认值 |
|---|---|
| 对象存储实现 | S3 兼容接口(生产用 AWS S3 / 阿里 OSS,自部署用 MinIO |
| 加密 | SSE-KMS,每个租户一个 KMS key alias(可选,起步用 SSE-S3 |
| Bucket 布局 | 单 bucket + tenant prefix`tenants/{tid}/...` |
| Presigned URL TTL | 上传 15 min / 下载 1 h |
| LRU 缓存大小 | 沙箱 pod 本地 5 GB(按 image size 调整) |
| 退订保留期 | 软删除 30 天后 GC |
| 技能包大小上限 | 50 MB |
| 单上传文件上限 | 100 MBpresigned policy 强制) |
@@ -0,0 +1,294 @@
# ADR-006 · 运行时与渠道层的租户化
| 项目 | 内容 |
|---|---|
| 状态 | 草稿(Draft |
| 决策日期 | TBD |
| 决策者 | 后端 lead + 架构 + 渠道 owner |
| 关联 ADR | ADR-001 数据隔离、ADR-003 LLM Key 与计费、ADR-005 存储拓扑 |
---
## 1. 背景
ADR-001 ~ 005 解决了"数据/沙箱/Key/RBAC/存储"五个面,但 DeerFlow 还有几块**进程级、单例化、跨请求共享**的运行时组件,它们既不在仓储层也不在沙箱层,是 ADR-001 ~ 005 的"夹层",独立讲一遍才不漏:
| 组件 | 现状 | 现在的 key | 多租户后的问题 |
|---|---|---|---|
| LangGraph Checkpointer | 内置 `AsyncSqliteSaver` / `AsyncPostgresSaver``runtime/checkpointer/async_provider.py`),表结构由 LangGraph 自己定 | `thread_id` | 表里只有 `thread_id`,没有 `tenant_id`ADR-001 的 RLS policy 怎么挂、`SET LOCAL` 怎么注入 |
| MCP 工具缓存 | `mcp/cache.py:11` 一个进程级 `_mcp_tools_cache: list[BaseTool]`,按 `extensions_config.json` 的 mtime 失效 | 无(全局) | 每个租户启用的 MCP server 不同;当前缓存命中第一个加载的租户配置 |
| Skills loader | `skills/loader.py``skills/public/` + `skills/custom/`,结果走 LRU;MCP 工具拼装在内 | 文件系统路径 | 多租户后 `skills/custom/` 不再是全局共享,要按租户维度拉/解压 |
| Sandbox provider 单例 | `LocalSandboxProvider` / `AioSandboxProvider` 在 lifespan 创建一次,所有 thread 共享 | thread_id | ADR-002 切到 K8s 后是 per-tenant Namespaceprovider 必须知道当前租户 |
| Memory 抽取 LLM 调用 | `MemoryMiddleware` 30s debounce 后发 LLM 抽取事实 | 当前 thread 的 user_id | 这次调用的 token 算平台还是租户?BYO 时用谁的 key? |
| Title / Summarization 内部 LLM 调用 | `TitleMiddleware` / `SummarizationMiddleware` 在 thread 上下文里复用主对话 LLM | 同上 | 同上 |
| IM 渠道 ↔ 用户绑定 | `app/channels/store.py` 把 IM 用户映射到平台 user_id;落 `~/.deer-flow/channels.yaml` | platform user_id | Slack workspace / 飞书租户 / 企微 corp 怎么映射到平台 tenantwebhook 来流量时怎么决定 tenant 上下文? |
这一组不解决,ADR-001 的 RLS、ADR-003 的计费都是空中楼阁。
---
## 2. 决策(按组件分别给)
### 2.1 LangGraph Checkpointer
**决策**:保留 LangGraph 原生 checkpointer 表结构(不 fork),但要做四件事:
1. **逻辑租户隔离靠 thread_id 命名空间**:每个 `thread_id` 在 ADR-001 的 `threads_meta(tenant_id, thread_id)` 表里有租户归属。`AssistantsCompat` 路由收到 thread 操作时,**先用 `threads_meta` 校验 thread_id ↔ tenant_id**,再放行 LangGraph 调用。这是第一道防线。
2. **物理隔离靠 RLS + 注入**:在 LangGraph 自己的 `checkpoints` / `checkpoint_writes` / `checkpoint_blobs` 三张表上加 RLS policypolicy 通过 `app.tenant_id` session var 过滤。需要写一段 SQL 脚本在 init migration 里跑:
```sql
-- LangGraph 自己创建表后跑(不动它的 schema)
ALTER TABLE checkpoints ENABLE ROW LEVEL SECURITY;
ALTER TABLE checkpoints FORCE ROW LEVEL SECURITY;
-- 通过 thread_id 反查 tenant_idsubquery 形式,性能要测)
CREATE POLICY tenant_isolation ON checkpoints
USING (
thread_id IN (
SELECT thread_id::text FROM threads_meta
WHERE tenant_id = current_setting('app.tenant_id', true)::uuid
)
);
-- checkpoint_writes / checkpoint_blobs 同样形态
```
缺点:subquery 形式 RLS 在大表(>千万行)上有性能风险。**备选**:在 LangGraph 表上**直接加一列 `tenant_id`**(用 ALTER TABLE,不 fork),靠每次 `put` 之前 trigger 从 thread_id 反查并写入。这是侵入更深但性能更好的路。
3. **`SET LOCAL` 注入路径**LangGraph 的 `AsyncPostgresSaver` 自己持有连接池,DeerFlow 不能直接控制每次取连接。两条路:
- **改造点 A**:包装一个 `TenantAwareConnectionFactory`,注入到 saver 构造参数;每次 acquire 时 `SET LOCAL app.tenant_id = '<uuid>'`。需要看 LangGraph 是否支持自定义 connection factory`langgraph-checkpoint-postgres>=2.0` 支持)。
- **改造点 B**:在 `RunManager` 创建/恢复 thread 前,**主动 issue 一条 `SET app.tenant_id`** 到 LangGraph 用的连接池上。需要 LangGraph 复用同一个连接(pool size = 1 模式不可行,必须能 binding)。
- **首选 A**;如果 LangGraph 版本不支持,用 B 作为兜底,并在升级版本后切回 A。
4. **测试**`backend/tests/test_harness_boundary.py` 之外,新增 `test_checkpointer_tenant_isolation.py` —— 建两个租户、各创建一个 thread,相互查询必须查不到。这是 RLS 是否生效的活体检测。
**推翻条件**:LangGraph 升级后表结构变化导致 RLS 不可挂 → 切换到 fork checkpointer 实现,自己控制表结构。
### 2.2 MCP 工具缓存
**决策**:模块级单例改为 **per-tenant 多级缓存**。
```python
# packages/harness/deerflow/mcp/cache.py
class TenantMCPCache:
"""Per-tenant MCP 工具缓存。
第一级 key: tenant_id
第二级 key: tenant_mcp_configs.updated_atDB 版本,替代 mtime
"""
_caches: dict[str, _CachedTools] = {}
_lock: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
async def get(self, tenant_id: str) -> list[BaseTool]:
async with self._lock[tenant_id]:
cached = self._caches.get(tenant_id)
current_version = await mcp_config_repo.version(tenant_id)
if cached and cached.version == current_version:
return cached.tools
# 失效或未初始化:拉 tenant_mcp_configs → 启 MultiServerMCPClient
client = await build_mcp_client_for_tenant(tenant_id)
tools = await client.get_tools()
self._caches[tenant_id] = _CachedTools(tools=tools, version=current_version)
return tools
async def invalidate(self, tenant_id: str) -> None:
async with self._lock[tenant_id]:
self._caches.pop(tenant_id, None)
```
要点:
- 现在 `mcp/cache.py:31` 靠 `extensions_config.json` 的 mtime 判 staleDB 化后用 `tenant_mcp_configs.updated_at`(或 `version` 列单调递增),失效信号走仓储层而不是文件系统。
- `MultiServerMCPClient` 实例**也要 per-tenant 持有**,因为它内部缓存了到各 MCP server 的连接 + OAuth token。租户切换不能复用别的租户的连接。
- **OAuth token 存储**:当前 `mcp/oauth.py` 的 token 落本地文件,多租户后必须挪到 `tenant_secrets``(tenant_id, key='mcp_oauth:<server_name>')`),KMS 加密。
- 进程内存上限:`TenantMCPCache` 加 LRU 上限(默认 1000 租户),超过淘汰最久未访问的;淘汰时关闭它的 MCP client 释放连接。
- Gateway PUT mcp 路由(`app/gateway/routers/mcp.py`)改完写 DB 后,调 `TenantMCPCache.invalidate(tenant_id)` 主动失效。
### 2.3 Skills loader
**决策**:拆成 **平台 skills(共享只读)+ 租户 skills(隔离可写)** 两套。
```
本地缓存目录 (LRU, 5GB):
${DEER_FLOW_SKILLS_CACHE}/platform/{skill_name}-{version}/
${DEER_FLOW_SKILLS_CACHE}/tenants/{tenant_id}/{skill_name}-{version}/
```
启动/调用流程:
1. Sandbox pod 启动时,从 ADR-005 的对象存储拉两类技能包:
- 平台启用列表:`platform/skills/{name}/{version}.skill`(公司维护,所有租户可见)
- 租户启用列表:`tenants/{tid}/skills/{name}/{version}.skill`(租户私有)
2. 解压到本地 LRU 缓存目录,挂到沙箱内的 `/mnt/skills/`(虚拟路径不变,对 agent 透明)
3. 工具拼装时 `get_available_tools()` 按 `tenant_skill_state(tenant_id, skill_name, enabled)` 过滤
4. 卸载/禁用:直接刷 `tenant_skill_state.enabled = false`,下一次 thread 启动时不挂载(在线 thread 不强制热卸,避免 in-flight 调用失败)
**LRU 淘汰策略**:按 `(tenant_id, skill_name)` 键淘汰;缓存满时优先淘汰非活跃租户的私有 skill,**永不淘汰 platform skills**(频繁命中)。
### 2.4 Sandbox provider 单例
**决策**`SandboxProvider` 实例本身保持全局单例(每进程一个 K8s client),但 `acquire(thread_id)` 内部按 `tenant_id` 路由到对应 namespace
```python
class K8sSandboxProvider:
async def acquire(self, thread_id: str) -> SandboxHandle:
tenant_id = resolve_tenant_id(AUTO, method_name="acquire")
namespace = f"tenant-{tenant_id}"
# 从 per-tenant prewarm 池借 pod,没有则按 tenant 配额创建
pod = await self._pool.borrow(namespace, thread_id)
return K8sSandboxHandle(pod=pod, tenant_id=tenant_id, thread_id=thread_id)
```
prewarm 池策略:
| 租户活跃度 | 池大小 |
|---|---|
| 7 天内有 thread 创建 | 按 plan 维度(free=0, pro=1, team=3, enterprise=可配)|
| 闲置 > 24h | 缩到 0;下次冷启动接受 P95 < 5s |
| 7 天内无活动 | 删除 namespace 的 prewarm 资源(保留 NS |
`SandboxAuditMiddleware` 已经在 ADR-002 §5.6 加了 tenant_id;这里强调一句:**audit 写入不能复用业务 DB session**,要走独立 audit DB(避免业务回滚把审计也滚掉)。
### 2.5 Memory / Title / Summarization 的内部 LLM 计费
ADR-003 只覆盖了"主对话"的 LLM 调用计费,但 DeerFlow 的中间件链里有 3 处会**额外**触发 LLM:
| 中间件 | 触发时机 | 现状 token 归属 | 决策 |
|---|---|---|---|
| `MemoryMiddleware` | thread 闲置 30s 后异步抽取 | 主 LLM 配置 | **算入 tenant 用量**:用 tenant 当前 thread 的 model + key |
| `TitleMiddleware` | 首轮回复后给 thread 起标题 | 主 LLM 配置 | **算入 tenant 用量**:成本可见,不要藏 |
| `SummarizationMiddleware` | token 接近上限时压缩历史 | 主 LLM 配置 | **算入 tenant 用量**:和主对话不可分割 |
**统一原则**:凡是 tenant 触发的 thread 内部产生的 LLM 调用,**都计入该 tenant 的 quota 和账单**——理由:
1. 透明:客户能在 usage 报表里看到"主对话 vs 内部任务"分项,不奇怪
2. 安全:不会有"租户用 quota 跑完后还能让平台贴钱抽 memory"的 bug
3. BYO 一致:BYO 租户用自己的 key,平台不替他付任何 token
实现侧改造:
- 这 3 个中间件目前都通过 `create_chat_model()` 拿 LLMADR-003 §4.2 已经把这个函数改成 tenant-aware,自动会带上 tenant key
- `TokenUsageMiddleware` 现在按 message id 累加 token;多加一个 `usage_category` 字段(`main` / `memory` / `title` / `summarization`),写入 `tenant_usage_daily.usage_category`
- usage 报表 UI 区分这四类,让客户对账
**例外**:平台主动触发的 LLM 调用(比如平台 admin 跑健康检查时调用 LLM)算平台账,不算租户。
### 2.6 IM 渠道 ↔ 租户映射
这是产品形态决定的,方案分两档:
#### 形态 A:单租户独占一个 IM 集成(小客户/SaaS)
每个 Slack workspace / 飞书企业 / 钉钉 corp 绑到**一个** tenant。
现有 `app/channels/store.py` 的"channel binding"扩成:
```sql
channel_bindings (
id UUID PK,
tenant_id UUID FK, -- 新增:每个绑定属于哪个租户
platform VARCHAR(32), -- slack / feishu / dingtalk / wecom / telegram / discord / wechat
external_workspace_id VARCHAR(128), -- Slack team_id / 飞书 tenant_key / 钉钉 corpId
config_encrypted BYTEA, -- bot token / app secret 等
status VARCHAR(16),
created_by UUID,
created_at, updated_at,
UNIQUE (platform, external_workspace_id) -- 一个外部 workspace 只能绑一个 tenant
)
```
Webhook 路由(`app/channels/manager.py:740` 附近):
```python
async def on_webhook(platform: str, payload: dict):
external_id = extract_workspace_id(platform, payload)
binding = await binding_repo.get(platform, external_id)
if binding is None:
return reject_unbound()
# 把 tenant 上下文塞进去,再走 lead_agent
set_current_tenant(binding.tenant_id, role=None) # IM 渠道无 RBAC,按 tenant 默认权限
user = await resolve_or_create_im_user(binding.tenant_id, payload.user)
set_current_user(user)
...
```
**关键**IM 来的请求**不走 JWT**,所以 ContextVar 注入要靠 webhook handler 自己写,不能漏。`app/channels/auth_filter.py` 加一道中间层强制每次 webhook 必经 `set_current_tenant`。
#### 形态 B:多租户共享一个 IM 集成(企业平台)
平台只装一个 Slack App,多个客户公司装在自己 workspace;通过 Slack `team_id` 自动路由到对应 tenant。逻辑同 A,但 onboarding 流程不一样:客户跳 Slack OAuth → 回调时根据登录态的 `tenant_id` 写 binding。
**取舍**:起步先做 A(每租户独立 IM 集成)。形态 B 是 enterprise marketplace 上架后才需要,不在 v1 范围。
#### IM user ↔ platform user 的映射
```sql
channel_user_links (
tenant_id UUID FK,
platform VARCHAR(32),
external_user_id VARCHAR(128),
user_id UUID FK, -- platform user
linked_at,
PRIMARY KEY (tenant_id, platform, external_user_id)
)
```
未链接的 IM 用户 → 自动建 ghost user`tenant_memberships.role = 'member'`),首次发消息时让用户在 IM 里点链接确认 → 落库。
---
## 3. 落地改造清单(与 ADR-001/005 不重叠)
| 模块 | 改动 | 估工 |
|---|---|---|
| `runtime/checkpointer/async_provider.py` | 接 LangGraph PG saver 的 connection factory,注入 `SET LOCAL` | M |
| 新建 `tests/test_checkpointer_tenant_isolation.py` | RLS 活体测试 | S |
| `mcp/cache.py` | 模块级 → `TenantMCPCache` 类 | M |
| `mcp/oauth.py` | OAuth token 存储从文件系统 → `tenant_secrets` | M |
| `app/gateway/routers/mcp.py` | PUT 接口改写 DB + 调 invalidate | S |
| `skills/loader.py` | 拆 platform / tenant 两路加载 | M |
| `app/gateway/routers/skills.py` | install 路径改为按 tenant 写 DB + S3 | M |
| `sandbox/k8s/provider.py`ADR-002 新建) | `acquire` 注入 namespace = tenant | 已计入 ADR-002 |
| Sandbox prewarm pool | 新增 controller,按 tenant 维度管理 | L |
| `agents/memory/middleware.py` | usage_category=memory | S |
| `agents/title.py` / `agents/summarization.py` | usage_category=title/summarization | S |
| `agents/middleware/token_usage.py` | 增加 usage_category 维度 | S |
| `app/channels/store.py` | binding 表加 tenant_id | M |
| `app/channels/manager.py` | webhook handler 注入 tenant ContextVar | M |
| 新建 `app/channels/auth_filter.py` | 强制 tenant 上下文 | S |
| 新建 `channel_user_links` 仓储 | IM user ↔ platform user | M |
合计:约 12 人周(2 人 6 周 / 3 人 4 周),不含 ADR-001 ~ 005 各自的改造。
---
## 4. 风险与缓解
| 风险 | 缓解 |
|---|---|
| RLS subquery 反查 thread_id → tenant_id 全表扫 | 测试 EXPLAIN,必要时 LangGraph 表加 `tenant_id` 列(侵入但可控) |
| LangGraph 升级换 schema | CI 锁版本;升级前先跑 tenant isolation 测试集 |
| `TenantMCPCache` 内存爆 | LRU 上限 + 闲置淘汰;监控租户数 / 进程 |
| MCP server 自身泄露租户上下文 | 出网走 ADR-002 egress gateway 白名单;MCP server 在沙箱内 stdio 启动时 env 不带平台 secret |
| Memory 抽取算入租户用量被客户抗议"我没让它跑" | UI 显式开关 + 用量分项展示;默认开启可关 |
| IM webhook 没注入 tenant 上下文 → 调用 RLS 全过滤掉空集 | 中间层 fail-closed;监控空集查询率 |
| 多 IM 平台 token 在 `channel_bindings.config_encrypted` 泄露 | KMS 加密 + 审计每次 decrypt(参照 ADR-003 §4.6 |
| ghost IM user 没绑回 platform user → 数据归到 ghost | 强制首次 IM 交互弹链接卡片 + N 天未确认自动停 |
---
## 5. 推翻条件
- LangGraph 官方推出原生多租户 checkpointer → 切换并废弃本 ADR §2.1 的注入方案
- 平台决定走"完全集中式 IM"(所有租户共用一个 bot account) → §2.6 切到形态 B 为唯一形态
- MCP server 全部跑在 K8s sidecar 而非进程内 → §2.2 缓存策略需要重写,client 实例从进程内挪到 service mesh
---
## 6. 默认假设
| 项 | 默认 |
|---|---|
| Checkpointer | LangGraph PG saver + RLSsubquery 反查 thread_id |
| MCP cache | per-tenant LRU,上限 1000 租户/进程 |
| Skills | platform 公共只读 + tenant 私有;本地 LRU 5GB |
| Sandbox prewarm | per-tenant 池,按 plan 配置大小 |
| 内部 LLM 调用计费 | 全部记入 tenant,分 4 类 usage_category |
| IM 集成形态 | 形态 A:每租户独立 bindingUNIQUE(platform, external_workspace_id) |
| IM ghost user TTL | 7 天未链接自动停 |
| 审计 DB | 与业务 DB 物理分离(独立连接池或独立实例) |
@@ -0,0 +1,354 @@
# ADR-007 · URL 路由与前端租户化
| 项目 | 内容 |
|---|---|
| 状态 | 草稿(Draft |
| 决策日期 | TBD |
| 决策者 | 前端 lead + 后端 lead + 产品 |
| 关联 ADR | ADR-001 数据隔离、ADR-004 RBAC、ADR-006 运行时与渠道 |
---
## 1. 背景
ADR-001 ~ 006 锁定了数据/沙箱/Key/RBAC/存储/运行时——但客户最先看到的是**浏览器地址栏长什么样**。多租户产品形态决定了 URL 形态、cookie scope、登录跳转、Better Auth 接入方式。
当前 DeerFlow`frontend/src/`):
- nginx 把 `/api/*` → Gateway 8001、`/api/langgraph/*` → 同 Gateway(重写)
- 前端用 Better Auth 走 cookie session(路径作用域 `/`
- 没有租户概念,单 host 单工作区
- LangGraph SDK client 在 `core/api/` 单例,所有 thread 操作共用一个 SDK 实例
多租户后必须回答:
1. URL 怎么标记 tenant
2. 多 tenant 切换时 SDK 单例怎么处理?
3. cookie 怎么 scope(避免跨 tenant session 串)?
4. Better Auth 怎么知道当前 tenant
---
## 2. 决策
**采用 path-based slug + 顶层 `TenantProvider` + 切换时强制刷新**
| 维度 | 决策 |
|---|---|
| URL 形态 | `/{tenant_slug}/...`(如 `/acme/threads/abc-123` |
| 子域名(如 `acme.deerflow.app` | 推迟到 v2,通过 `tenants.custom_domain` 列预留 |
| Tenant 解析点 | nginx 不解析;后端 `AuthMiddleware` 从 path + JWT 双向交叉校验 |
| Cookie scope | `Path=/`、不绑 tenant;通过 JWT 内 `tid` 区分 |
| SDK 单例 | 全局单例,但切租户时 `await invalidate()` + 强制 reload |
| Better Auth | 单一 `auth.deerflow.app` 登录域,登录后跳到 `/{slug}/`;多租户用户走 tenant picker |
---
## 3. 备选方案与拒绝理由
### A. 子域名(`acme.deerflow.app`)作为默认
**拒绝(默认)。** 几个硬伤:
- **本地开发劝退**:每个开发者要起 `*.localtest.me` 之类的通配 DNSDocker compose 的 nginx 也要改
- **TLS 证书**:通配证书或 ACME 动态签证;自部署客户卡这一步
- **Better Auth cookie 跨子域**:要走 `Domain=.deerflow.app`scope 太宽,租户隔离反而变弱
- **CSRF 双重 cookie**:当前 csrf_middleware 假定同源;跨子域要改写
**保留**作为 enterprise plan 的"自定义域名"功能(vanity domain),通过 `tenants.custom_domain` 解析回平台 tenant,但不作为默认。
### B. Header `X-Tenant-Id`(无 URL 标记)
**拒绝。** 浏览器分享一个 thread URL 别人打不开(缺 header),UX 灾难;爬虫/SEO 也无法索引租户公开内容。
### C. URL 不带 tenant,全靠 session
**拒绝。** 用户多 tenant 切换后,浏览器 history 不可区分;同一个 URL 在不同会话里显示不同内容,BUG 报告噩梦。
---
## 4. URL 形态规范
```
公开(不带租户):
/ → 营销页
/login → Better Auth 登录页
/signup
/accept-invite/{token}
/pricing
租户内:
/{slug}/ → 租户首页(threads 列表)
/{slug}/threads/{tid}
/{slug}/skills
/{slug}/mcp
/{slug}/memory
/{slug}/settings → 租户设置(owner/admin
/{slug}/settings/billing
平台 adminsystem_role=platform_admin):
/admin/tenants
/admin/usage
/admin/audit
```
**slug 约束**
- `^[a-z0-9](-?[a-z0-9])*$`3-32 字符
- 保留 slug 黑名单:`admin``api``auth``login``signup``pricing``docs``status``accept-invite``platform`
- 大小写归一化:DB 存小写
- 切换 slug`tenants``slug_history` 表,30 天内老 slug 重定向到新 slug,过后 410
API 路径**不带 slug**
```
/api/... # 业务 APItenant 由 JWT 决定)
/api/langgraph/threads/{tid}/runs/stream # LangGraph 兼容
```
理由:API 是 SDK 调用的,不需要人类可读 URL;slug 只在浏览器导航/分享时有意义。
---
## 5. 后端:Path slug 与 JWT 的交叉校验
`AuthMiddleware` 当前从 cookie 读 JWT,注入 `user_id` 到 ContextVar。多租户后改:
```python
async def dispatch(request, call_next):
if _is_public(request.url.path):
return await call_next(request)
payload = await verify_jwt_from_cookie(request)
jwt_tid: str = payload["tid"]
role: str = payload["role"]
# 1. API 调用:tenant 完全靠 JWT
if request.url.path.startswith("/api/"):
active_tid = jwt_tid
# 2. 页面导航:从 path 解析 slug → 反查 tenant_id
else:
slug = _extract_slug(request.url.path)
if slug is None:
active_tid = jwt_tid
else:
tenant = await tenant_repo.get_by_slug(slug)
if tenant is None:
raise HTTPException(404, "Tenant not found")
# JWT tid 与 URL slug 不一致 → 强制重定向到正确 slug 或拒绝
if tenant.id != jwt_tid:
# 校验 user 是否是该 tenant 的成员
membership = await membership_repo.get(tenant.id, payload["sub"])
if membership is None:
raise HTTPException(403, "Not a member of this tenant")
# 是成员但 JWT 没切过来 → 重定向到 /switch-tenant
return RedirectResponse(f"/auth/switch-tenant?to={slug}&next={request.url.path}")
active_tid = tenant.id
set_current_user(...)
set_current_tenant(active_tid, role)
return await call_next(request)
```
**关键**page 路由用 path slug 校验,API 路由用 JWT tid——两条路只在登录时由 `/auth/switch-tenant` 触发同步。
---
## 6. 前端:TenantProvider + SDK 重建
### 6.1 顶层 Provider
```tsx
// frontend/src/core/tenant/provider.tsx
"use client";
export function TenantProvider({ children, tenantId, slug, role }: Props) {
const value = useMemo(() => ({ tenantId, slug, role }), [tenantId, slug, role]);
return <TenantContext.Provider value={value}>{children}</TenantContext.Provider>;
}
export function useTenant() {
const ctx = useContext(TenantContext);
if (!ctx) throw new Error("useTenant() outside TenantProvider");
return ctx;
}
```
挂载点:`app/(tenant)/[slug]/layout.tsx`
```tsx
export default async function TenantLayout({ params, children }) {
const { slug } = await params;
const session = await getSession();
const tenant = await fetchTenantBySlug(slug);
if (!tenant) notFound();
if (session.tid !== tenant.id) {
// 同上:要么是路径错了,要么是切租户没同步
redirect(`/auth/switch-tenant?to=${slug}`);
}
return (
<TenantProvider tenantId={tenant.id} slug={slug} role={session.role}>
{children}
</TenantProvider>
);
}
```
### 6.2 LangGraph SDK 实例与 tenant 绑定
当前 `core/api/langgraph-client.ts` 是模块级单例。改造:**SDK 实例**保持单例(HTTP client 不需要重建),但**所有调用 wrapper**强制读 `useTenant()`,把 `tenantId` 作为对话 metadata 传递(实际 tenant 鉴权在后端 JWT,前端传只是为了请求溯源):
```ts
// useThreadStream.ts
export function useThreadStream(threadId: string) {
const { tenantId } = useTenant();
return useStream<ThreadState>(threadId, {
apiUrl: "/api/langgraph",
metadata: { tenant_id: tenantId }, // 仅用于日志/追踪,不替代鉴权
});
}
```
**租户切换时的清理**
- 切换前用 `cancelAllStreams()` 关掉所有打开的 SSE
- 调用 `POST /api/auth/switch-tenant`(后端重发 JWT 新 cookie
- 拿到 200 后 `window.location.assign(/{newSlug}/)` 强制硬刷新
**为什么硬刷新**
- React state 里有大量缓存的 thread / skill / mcp 配置,按租户全洗一遍代码量大
- LangGraph SDK 内部维护 EventSource 连接池,强制重建最干净
- 一次 nav 1-2 秒可接受,远比 in-memory 切租户的边界 bug 划算
### 6.3 租户切换 UI
顶栏组件 `<TenantSwitcher>`
- 列出 user 的所有 membership(来自 `/api/auth/me` 返回的 `tenants[]`
- 当前激活租户高亮 + 显著色块(避免误操作)
- 点击切换 → 上面 6.2 的硬刷新流程
### 6.4 路由组与 Server Components
```
frontend/src/app/
├── (marketing)/ # 公开:/, /pricing
├── (auth)/ # 登录注册:/login, /signup, /accept-invite
├── (admin)/ # 平台 admin/admin/...
└── (tenant)/[slug]/ # 租户内:/{slug}/...
├── layout.tsx # TenantProvider 注入
├── page.tsx # threads 列表
├── threads/[tid]/page.tsx
├── skills/page.tsx
├── settings/page.tsx
└── ...
```
`layout.tsx` 内的 `fetchTenantBySlug` 走 Server Component → 直连后端,缓存 60s(用 React `cache()`)。
---
## 7. Cookie 与会话
| 维度 | 决策 |
|---|---|
| Session cookie name | `deerflow_session`(不变) |
| Path scope | `/`(不绑 tenant slug |
| Domain | 平台主域(不跨子域) |
| SameSite | `Lax`(默认) |
| HttpOnly | 是 |
| Secure | 是(生产) |
| 切换 tenant | 后端**重签**新 JWT,覆写同一 cookie;不清旧 cookie |
| 跨设备登录 | session 多设备 OK;切 tenant 不强制其他设备退出 |
**为什么 cookie 不绑 slug**:用户从 `/acme/...` 切到 `/bigco/...` 时如果 cookie path 不同,会出现"两个 cookie 同时存在浏览器但前端选错一个"的边界 case。统一 path=/ + JWT 内 `tid` 单一来源最干净。
**CSRF**:现有双重 cookie CSRF`csrf_middleware.py`)保持,CSRF token 不需要按 tenant 区分。
---
## 8. Better Auth 接入
Better Auth 当前配置(`frontend/src/server/auth/`)走单一 user 池。多租户化改:
1. **登录后落到 picker**:用户登录成功 → 检查 `tenant_memberships` 数量
- 0 个:跳到 `/onboarding/create-tenant`(新用户首次登录)
- 1 个:直接跳到 `/{slug}/`JWT 带该 tenant
- 多个:跳到 `/select-tenant`,让用户选;选后落 `users.default_tenant_id`
2. **JWT 签发**Better Auth 的默认 session token 不够——需要在 `session.fresh()` 后注入 `{ tid, role, tv }` claim。建议自定义 session cookie 或在 Better Auth 之上叠一层 `deerflow_session`(与 ADR-004 §5.2 一致)
3. **SSOv2**Better Auth 的 SAML/OIDC provider 已经支持组织化(`organization` plugin),后续接入时把 organization 等价映射到 tenant
4. **Invitation 流程**`/accept-invite/{token}` 路径下点击 → 校验 invitation → 自动 attach membership → 跳到 `/{new_slug}/`
---
## 9. 自定义域名(v2 预留)
`tenants` 表加 `custom_domain VARCHAR(253) UNIQUE NULL`。客户配置 CNAME 后:
1. 客户在 settings 里填域名
2. 平台调 ACME 签证(per-domain+ Caddy/nginx 动态 vhost
3. 请求来时 nginx 看 Host 头:
- 是平台主域 → 走 path slug 解析
- 是 custom_domain → 反查 tenant_id 直接注入
不在 v1 范围。
---
## 10. 落地改造清单
| 模块 | 改动 | 估工 |
|---|---|---|
| `tenants.slug` + `slug_history` 表 | DB 迁移 | S |
| `AuthMiddleware` path slug 解析 + 交叉校验 | 后端 | M |
| `/api/auth/switch-tenant` 路由 | 后端 | S |
| `/api/auth/me` 返回 tenant 列表 | 后端 | S |
| Better Auth session 改造,注入 `tid/role/tv` | 后端 + 前端 | M |
| 前端 `app/(tenant)/[slug]/layout.tsx` + Provider | 前端 | M |
| 前端路由全部按 `(tenant)/[slug]/` 重组 | 前端 | L |
| `useTenant()` hook + 所有 API 调用接入 | 前端 | M |
| `<TenantSwitcher>` 组件 | 前端 | S |
| 租户首登 onboarding `/onboarding/create-tenant` | 前端 + 后端 | M |
| Tenant picker 页面 `/select-tenant` | 前端 | S |
| 平台 admin `/admin/...` 路由 + 鉴权 | 前端 + 后端 | M |
| 硬刷新切换流 + cancelAllStreams | 前端 | S |
合计:约 8 人周(前端 5 + 后端 3)。
---
## 11. 风险与缓解
| 风险 | 缓解 |
|---|---|
| slug 冲突(保留字 / 已注册) | 注册流程强制校验黑名单;冲突时返显建议 slug |
| 浏览器分享 URL 给非成员看 | 后端 403,前端展示"申请加入"按钮 |
| 切换 tenant 时 streams 没断干净导致看到上租户的 events | hard reload 兜底;E2E 测试 stream cancellation |
| Better Auth 升级破坏 session 字段 | 锁版本;session 改造前先 fork 一份测 |
| 自定义域名灰区(DNS / TLS) | v2 才做,v1 不实现 |
| `default_tenant_id` 被删除(成员被踢) | 登录时 fallback 到 memberships 第一个;都没了引导建租户 |
| SEO 收录租户页 | 默认 `noindex`,租户开关启用公开页 |
---
## 12. 推翻条件
- v2 决定走子域名优先 → §2 决策切到子域名 + 兼容老 path 形态 6 个月
- 单页应用改成多页 / SSR 完整迁移 → 前端层重写,路由组结构会变
- Better Auth 弃用 → 切换到自有 auth;JWT 部分不变
---
## 13. 默认假设
| 项 | 默认 |
|---|---|
| URL 形态 | `/{slug}/...`slug 3-32 字符小写 |
| 自定义域名 | v2 才支持,v1 不开 |
| Cookie path | `/` |
| Cookie domain | 平台主域,不跨子域 |
| 切换 tenant | 硬刷新(`window.location.assign` |
| Tenant picker | 1 个 membership 时跳过 |
| API 路径 | 不带 slugtenant 由 JWT 决定) |
| SEO | 默认 `noindex`,可按租户开 |
@@ -0,0 +1,262 @@
# 多租户改造 · 第 0 阶段计划
> 目标:在写第一行代码前,对齐设计、产出可评审的文档。这一阶段不动代码,时间盒 **两周封顶**。
---
## 产出物清单(8 份文档 + 1 份代码盘点)
| 产出物 | 形式 | 谁批 | 作用 |
|---|---|---|---|
| [ADR-001 数据隔离模型](./adr-001-data-isolation.zh-CN.md) | ADR(决策记录) | CTO / 架构 | 锁定行级 / schema / 库级;含 LangGraph checkpoint 表的注入路径 |
| [ADR-002 沙箱隔离模型](./adr-002-sandbox-isolation.zh-CN.md) | ADR + 威胁模型 | 安全 + 架构 | 锁定 K8s / Firecracker / VM |
| [ADR-003 LLM Key 与计费模型](./adr-003-llm-key-billing.zh-CN.md) | ADR | 产品 + CTO | 锁定 BYO / 平台付费 / 混合;含悲观预扣 + 内部 LLM 计费 |
| [ADR-004 租户 ↔ 用户层级](./adr-004-tenant-rbac.zh-CN.md) | ADR | 产品 | 锁定二级 RBAC + JWT/cache 一致性策略 |
| [ADR-005 存储拓扑](./adr-005-storage-topology.zh-CN.md) | ADR + ObjectStorage 接口签名 | 架构 + SRE | 锁定 DB / 对象存储 / 临时区分层 |
| [ADR-006 运行时与渠道租户化](./adr-006-runtime-channel-tenancy.zh-CN.md) | ADR | 后端 lead + 渠道 owner | 锁定 checkpointer / MCP cache / skills loader / 内部 LLM 计费 / IM 渠道 ↔ 租户 |
| [ADR-007 路由与前端租户化](./adr-007-routing-frontend.zh-CN.md) | ADR | 前端 lead + 后端 lead | 锁定 URL 形态 / cookie / Better Auth / SDK 切换 |
| Tenant 数据模型设计 | DB schema 草稿 + ER 图 | 后端 lead | 第 1 阶段直接落地用 |
| 多租户改造代码盘点 | 表格 / spreadsheet | 后端 lead | 估工 + 拆 PR 用 |
每份 ADR 用统一结构:**目标客户画像 → 评估维度 → 选项对比 → 选 X 的理由 → 推翻条件**。
---
## 1. 四个决策怎么定(决策框架)
> 下面只是决策框架的 1 页概览。每份 ADR 的完整正文(背景 / 备选方案 / 落地影响 / 风险 / 推翻条件 / 默认假设)已分别成独立文档:
> - [ADR-001 数据隔离模型](./adr-001-data-isolation.zh-CN.md)
> - [ADR-002 沙箱隔离模型](./adr-002-sandbox-isolation.zh-CN.md)
> - [ADR-003 LLM Key 与计费模型](./adr-003-llm-key-billing.zh-CN.md)
> - [ADR-004 租户 ↔ 用户层级](./adr-004-tenant-rbac.zh-CN.md)
> - [ADR-005 存储拓扑与持久化策略](./adr-005-storage-topology.zh-CN.md)
> - [ADR-006 运行时与渠道租户化](./adr-006-runtime-channel-tenancy.zh-CN.md)
> - [ADR-007 路由与前端租户化](./adr-007-routing-frontend.zh-CN.md)
### ADR-001 数据隔离
| 维度 | 行级 (tenant_id WHERE) | per-tenant schema | per-tenant DB |
|---|---|---|---|
| 实现成本 | 低 | 中 | 高 |
| 跨租户 bug 爆炸半径 | 高 | 中 | 极低 |
| 备份/恢复粒度 | 全量 | 按 schema | 按 DB |
| 合规友好度(SOC2/HIPAA | 一般 | 好 | 最好 |
| 跨租户分析查询 | 容易 | 中 | 难 |
| 适用客户规模 | <10k 租户 | 10k100 大客户 | <100 大客户 |
**90% 团队选 行级 + Postgres RLS(双保险)**。理由:DeerFlow 现在的仓储层已经是 `user_id` 过滤模式,RLS 加上去几乎是平行扩展。
**推翻条件**:拿到金融/医疗类客户、客户合同里写明"物理数据隔离"——直接跳到 per-tenant DB。
### ADR-002 沙箱隔离
威胁模型表(每行一个攻击场景):
| 攻击 | 共享 Docker | per-tenant Namespace | per-tenant VM |
|---|---|---|---|
| 容器逃逸 | 全员沦陷 | 单租户沦陷 | 单租户沦陷 |
| 侧信道(CPU 缓存等) | 可行 | 可行 | 难 |
| 出网到云 metadata | 可行(必须默认禁) | 可行(必须默认禁) | 可行(必须默认禁) |
| 资源耗尽(fork bomb) | 影响他人 | 仅影响自己 | 仅影响自己 |
| 提权 | 看 K8s 配置 | 看 K8s 配置 | VM 边界更强 |
**推荐:K8s namespace + gVisor/Kata runtime + NetworkPolicy 默认禁出网**。Firecracker 是更强的方案但运维成本翻倍,留给"premium 租户专属"档位。
### ADR-003 Key & 计费
三种模式选一种主线:
- **BYO key**:客户自己带 OpenAI/Anthropic key。优点:你不背模型成本、不背滥用;缺点:客户体验差,需要 secret vault。
- **平台付费**:你统一付,按 token 加价转售给客户。优点:体验顺;缺点:你要做精细的 quota+成本归账,否则会被刷爆。
- **混合**(推荐起步):默认平台 key + 限额;高级套餐切 BYO key 不限额。
**写 ADR 时要把 "成本归因路径" 画清楚**:哪个表记 `tenant_id × model × token`,谁算月度账单,怎么和 Stripe 对账。
### ADR-004 租户内层级
最常见的两种:
- **扁平**tenant 直接装 user,所有 user 等价。简单,适合自助型 SaaS。
- **二级 RBAC**tenant 有 owner/admin/memberadmin 能管 member 的 skill 安装权限和 quota 分配。适合企业销售。
如果要 SSOSAML/OIDC),那默认要二级 RBAC——因为客户 IT 部门要能管理"哪些员工进哪些 workspace"。
### ADR-005 存储拓扑
详见独立文档:[ADR-005 · 存储拓扑与持久化策略](./adr-005-storage-topology.zh-CN.md)。
### ADR-006 运行时与渠道租户化
详见独立文档:[ADR-006 · 运行时与渠道层的租户化](./adr-006-runtime-channel-tenancy.zh-CN.md)。
要点:
- **LangGraph checkpointer**:保留原表结构,靠 `thread_id ∈ threads_meta(tenant_id=...)` 子查询 RLS 兜底;连接注入 `SET LOCAL app.tenant_id` 走自定义 connection factory。
- **MCP 工具缓存**:模块级单例 → per-tenant LRUOAuth token 从文件挪到 `tenant_secrets`
- **Skills loader**:拆 platform 共享只读 + tenant 私有;按 `tenant_skill_state.enabled` 过滤工具。
- **Sandbox provider**:实例单例,`acquire(thread_id)` 内按 tenant 路由到 K8s namespaceprewarm 池按 plan 大小。
- **内部 LLM 计费**Memory / Title / Summarization 调用都算 tenant 用量,分 `usage_category` 报表展示。
- **IM 渠道**:每个 binding 加 `tenant_id`webhook handler 强制注入 tenant ContextVarghost user 7 天未链接自动停。
### ADR-007 路由与前端租户化
详见独立文档:[ADR-007 · URL 路由与前端租户化](./adr-007-routing-frontend.zh-CN.md)。
要点:
- **URL 形态**`/{slug}/...`,子域名留给 v2 自定义域名。
- **Cookie**`Path=/` + JWT 内 `tid`;切换 tenant 重签 JWT + 硬刷新。
- **前端**`app/(tenant)/[slug]/layout.tsx` 注入 `TenantProvider`SDK 实例单例但调用读 `useTenant()`;切换时 `cancelAllStreams + window.location.assign`
- **Better Auth**:登录后跳 picker / 直进 / onboardingsession 注入 `{tid, role, tv}`
要点:
- **现状问题**:自定义 skill / agent / memory / 上传 / 产物全部在容器本地文件系统。多副本不一致、容器重建丢数据、无备份。
- **决策**:三层拓扑——**结构化进 Postgres**agent SOUL/config、memory facts、skill enabled、tenant secrets+ **大对象进对象存储**(上传、产物、技能包)+ **临时区**(沙箱 workspace,不持久化)。
- **接口**`ObjectStorage` Protocol,实现 `LocalObjectStorage`dev/ `S3ObjectStorage`prod/ `MinIOObjectStorage`(自部署)。
- **关键约束**:单 bucket + `tenants/{tid}/` prefixpresigned URL 短 TTLHTML/SVG 强制 `Content-Disposition: attachment`(保留当前 XSS 防护)。
- **拒绝方案**:① 全部进 PVC(小文件读写差、备份难);② 全部进 S3(强一致差、列表慢、无事务)。
---
## 2. Tenant 数据模型草稿
第 0 阶段就把这张表画出来,第 1 阶段直接落地:
```sql
-- 新表
tenants (
id UUID PK,
slug VARCHAR(64) UNIQUE, -- URL 用,/t/{slug}/...
display_name VARCHAR(128),
plan VARCHAR(32), -- free / pro / enterprise
status VARCHAR(16), -- active / suspended / deleted
created_at, updated_at
)
tenant_memberships (
tenant_id UUID FK,
user_id UUID FK,
role VARCHAR(16), -- owner / admin / member
invited_by UUID,
joined_at,
PRIMARY KEY (tenant_id, user_id)
)
tenant_secrets (
tenant_id UUID FK,
key VARCHAR(64), -- e.g. OPENAI_API_KEY
encrypted_value BYTEA, -- KMS 加密
rotated_at,
PRIMARY KEY (tenant_id, key)
)
tenant_quotas (
tenant_id UUID FK,
metric VARCHAR(32), -- tokens_monthly / runs_concurrent / sandbox_cpu_seconds
hard_limit BIGINT,
soft_limit BIGINT,
PRIMARY KEY (tenant_id, metric)
)
tenant_usage_daily (
tenant_id UUID,
date DATE,
metric VARCHAR(32),
value BIGINT,
PRIMARY KEY (tenant_id, date, metric)
)
invitations (
id UUID PK,
tenant_id UUID FK,
email VARCHAR(320),
role VARCHAR(16),
token VARCHAR(64) UNIQUE,
expires_at,
used_at NULL
)
-- 已有表加列
users + default_tenant_id UUID
threads_meta + tenant_id UUID + INDEX (tenant_id, user_id, updated_at)
runs + tenant_id UUID + INDEX (tenant_id, created_at)
run_events + tenant_id UUID
feedback + tenant_id UUID
-- 未来 Tier 2 还要加:
skills_state (tenant_id, skill_name, enabled, source)
agent_configs (tenant_id, user_id, agent_name, soul_md, config_yaml)
mcp_configs (tenant_id, server_name, transport, url, encrypted_config)
```
画完后让 DBA / 后端 lead 评审两件事:**索引覆盖**(每个查询 path 是否走索引)和 **RLS policy 草稿**(每张带 tenant_id 的表写一条 policy)。
---
## 3. 多租户改造代码盘点
第 0 阶段最容易被忽略的是**先量一下工作量**。在 spreadsheet 里把"目前涉及 user_id / 全局状态"的代码点全部列出来:
```bash
grep -rn "user_id\|get_effective_user_id\|DEFAULT_USER_ID" backend/packages/harness/deerflow/ backend/app/
grep -rn "users/\|/.deer-flow/" backend/ scripts/
grep -rn "extensions_config\|skills/public\|skills/custom" backend/
```
把命中点分成五类:
| 类别 | 改造动作 | 估计点位 |
|---|---|---|
| **DB 仓储**`persistence/*/sql.py` | 增加 tenant_id 解析与 WHERE | ~1015 处 |
| **文件系统路径**`ThreadDataMiddleware`、memory storage、agents 存储) | 路径加 tenant 维度 | ~58 处 |
| **配置/Secret 读取**`models/factory.py`、MCP client、community tools | 改成 tenant 上下文取 key | ~812 处 |
| **路由 handler**`app/gateway/routers/*.py` | 加 `@require_permission` + tenant 上下文 | ~14 个 router 文件 |
| **全局单例**(沙箱 provider、MCP cache、skills loader | 缓存 key 加 tenant 维度 | ~5 处 |
每条点位估一个 S/M/L 工作量。这张表是后面拆 PR、估工期、估钱的依据。
---
## 4. 第 0 阶段的"完成定义" (DoD)
走完这阶段,团队应该能回答:
- [ ] 数据存哪、用什么数据库、怎么隔离 → ADR-001 给出
- [ ] 客户的 bash/工具跑在哪、能访问什么、爆炸半径多大 → ADR-002 给出
- [ ] 客户的 LLM 调用钱谁出、怎么算 → ADR-003 给出
- [ ] 客户内部能不能自己加员工、怎么加 → ADR-004 给出
- [ ] Skill / agent / 上传 / 产物 / memory 各自存哪、丢失怎么办 → ADR-005 给出
- [ ] LangGraph / MCP / 内部 LLM / IM 渠道这些"夹层"怎么按租户隔离 → ADR-006 给出
- [ ] 浏览器地址栏长什么样、Cookie 怎么 scope、租户切换怎么走 → ADR-007 给出
- [ ] 第一阶段 PR 怎么拆、估几人周 → 代码盘点给出
- [ ] 第一个内测客户长什么样、什么时候能上 → 项目经理排期
---
## 5. 时间盒与节奏
第 0 阶段 **两周封顶**,再长就是过度设计。
- **第 1 周**:写 ADR-001/002/003/004 草稿,团队读、challenge、收敛
- **第 2 周**:定 schema、做代码盘点、估工、定第一阶段范围与 design partner 客户
如果两周后还有 ADR 定不下来,**绝大多数情况是因为缺一个真实客户做参照**——这时候应该先去签一个 design partner(哪怕免费),用他们的合同和合规要求来反推决策。
---
## 6. 默认假设(如无特殊情况按此推进)
为避免决策瘫痪,先写下一个"默认值",所有 ADR 在没有相反证据前按这个走:
| 决策 | 默认值 | 选它的理由 |
|---|---|---|
| 数据隔离 | 行级 + Postgres RLS(含 LangGraph 表 subquery RLS | 改造成本低,DeerFlow 现状几乎平行扩展 |
| 沙箱隔离 | K8s namespace + gVisor + NetworkPolicy 默认禁出网 | 强度足够 + 运维可控 |
| LLM Key | 混合:默认平台 key + 限额,premium 切 BYO;悲观预扣防超额 | 体验与成本兼顾 |
| 租户层级 | 二级 RBACowner/admin/member+ JWT/cache 双层 | 为 SSO 和企业销售留口 |
| 存储拓扑 | Postgres(结构化)+ S3 兼容对象存储(大对象)+ emptyDir(临时) | 沙箱 pod 真正无状态;备份/灾备/横向扩展直接通 |
| 运行时夹层 | per-tenant MCP cache + skills 拆双路 + 内部 LLM 计费分类 + IM binding 加 tenant | 关上"非仓储非沙箱"那一组进程级单例的隔离漏洞 |
| 前端路由 | `/{slug}/...` 路径 + JWT 内 tid + 硬刷新切换 | UX 简单,与 ADR-001/004 cookie 模型契合 |
> 这是"中等强度方案",覆盖 90% B2B SaaS。如果客户画像偏极端(大企业 / 强合规 / 自助小客户),再调整。