> For the complete documentation index, see [llms.txt](https://tinyhumans.gitbook.io/openhuman/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://tinyhumans.gitbook.io/openhuman/zh/kai-fa/saas-profiles.md).

# SaaS 配置文件

将 OpenHuman 作为多用户服务运行：每个用户一个用户档案，置于可信网关之后，可在单节点或带有租用用户档案的集群上运行。

OpenHuman 核心可以通过一个进程服务多个用户。每个用户都是一个 **profile**: 他们自己的配置、凭据、工作区、沙盒和线程，布局与桌面应用的 `users/<id>/` 目录。位于核心前方的可信网关对用户进行身份验证，并代表他们调用核心。核心从不看到用户的密码或登录信息；它信任网关的服务令牌以及网关所指定的用户 ID。

本页介绍如何部署该服务。对于希望在没有网关的情况下获得同样隔离效果的 Rust 服务器，其进程内变体是 [`ProfileRuntime`](/openhuman/zh/kai-fa/embed/concepts/profiles-saas.md)。内部实现在 [`profiles/README.md`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-core/src/profiles/README.md).

## 用户可获得的内容

* **每个用户一个配置文件。** 它会在用户第一次请求时打开，在使用期间保持打开，并在以下情况下关闭： `idle_evict_secs` 处于空闲状态后（或者当 `max_profiles_open` 达到上限且它是最近最少使用的空闲项时）。
* **每个会话、每个界面各一个线程。** Web 聊天线程由用户命名（`t1`, `work-notes`）；网关从托管平台（Telegram、iMessage、Discord）转发的消息会落到该用户的 `channel:<platform>/<sender>/<chat>` 线程中。线程 ID 在每个配置文件内都是唯一的，因此两个用户的 `t1` 永远不会相遇。
* **经过审核的接口面。** 用户的请求只会到达用户方法：他们的线程、Web 聊天（启动、取消、队列控制）、频道转发，以及对自己记忆的读写。其他一切都会以未知方法作答。
* **他们自己的事件。** `GET /events?client_id=` 只会流式返回该用户的聊天进度、最终回复以及转发的 `channel_outbound` 回复。
* **关闭的宿主工具。** 独立版 SaaS CLI 不会暴露任何文件或 shell 工具，即使当 `host_files` 或 `host_shell` 位于 `tool_allowlist`中也是如此。嵌入式宿主可以提供配置文件工具适配器；允许列表限制这些适配器可以暴露哪些宿主工具组。

## 运维配置

核心在 SaaS 模式下从运维自己的 TOML 文件运行，绝不会从用户的 `config.toml`:

```bash
openhuman-core run --mode saas --saas-config /etc/openhuman/saas.toml
```

```toml
root = "/srv/openhuman"              # 绝对路径、已存在、不可被所有人写入
# service_token_file = "/srv/openhuman/service.token"   # 默认值
max_profiles_open = 256              # 本节点同时打开的配置文件数
idle_evict_secs = 1800               # 在空闲这么久后关闭配置文件
profile_ids = "raw"                  # 或 "hashed"
require_user_signature = true        # 每个用户请求都需要 X-OpenHuman-User-Sig
tool_allowlist = []                  # 仅用于准入；CLI 没有宿主工具适配器
shared_backend_api_key = true        # 让每个用户共用 OPENHUMAN_BACKEND_API_KEY
custom_definitions = false

# 集群设置（见下文）
# storage_url = "mongodb://db.internal/openhuman"   # OPENHUMAN_STORAGE_URL 优先
# node_id = "node-a"                                # 否则用 OPENHUMAN_NODE_ID，再否则随机
# advertise_url = "http://10.0.0.11:7788"           # 将该节点标记为集群节点
# lease_ttl_secs = 30
# operator_dir = "/var/lib/openhuman/node-a"        # 默认 <root>/operator

[sandbox]                            # 由嵌入式宿主提供的 host_shell 策略
image = "alpine:3.20"
network = "none"
memory_limit_mb = 512
cpu_limit = 1.0
```

`tool_allowlist` 是准入策略，不是工具安装器。它识别的组有 `host_files` 和 `host_shell`。要暴露这些工具，请使用带有配置文件工具适配器和权限回调的嵌入式宿主。将任一组添加到独立 CLI 配置并不会使这些工具可用。 `host_shell` 当该组被加入允许列表时，Docker 启动检查仍然适用。

未知键会被拒绝。启动守卫在根目录为相对路径、缺失、可被所有人写入，或位于 `~/.openhuman`；当服务令牌缺失、可被他人读取或短于 32 字节时；当设置了单用户变量（`OPENHUMAN_WORKSPACE`, `OPENHUMAN_CORE_TOKEN`, `OPENHUMAN_DEV_CONNECT`, `OPENHUMAN_BACKEND_SESSION_TOKEN`）或者某个保护被关闭（`OPENHUMAN_APPROVAL_GATE`, `OPENHUMAN_SANDBOX`）；当 `host_shell` 在没有可用 Docker 的情况下被列入允许列表；以及当集群节点没有能够保持租约独占的存储后端时。

只写入一次服务令牌，仅所有者可读写：

```bash
openssl rand -hex 32 > /srv/openhuman/service.token
chmod 600 /srv/openhuman/service.token
```

## 网关契约

每个请求都携带服务令牌。针对用户的请求还会指定该用户并对该名称签名：

```
Authorization: Bearer <service token>
X-OpenHuman-User: <your user id>
X-OpenHuman-User-Sig: t=<unix secs>,v1=<hex hmac-sha256(service token, "<t>.<user id>")>
```

* 没有 `X-OpenHuman-User` 时，请求会运行在 **运维平面**: `profiles.provision`, `取消配置`, `list`, `status`, `set_credential`, `clear_credential` 和 `release` (`openhuman.profiles_*` ，通过 JSON-RPC）。
* 有了它，核心会按顺序检查：持有者令牌（`401`），重复或无法读取的用户头（`400`），签名必须与核心时钟相差不超过 60 秒（`401`），以及用户的配置文件，且该配置文件必须已预配置（`403`）。节点已满或存储故障会返回 `503`.
* 用户的 TinyHumans 凭据（会话 JWT 或 API 密钥）通过 `profiles.set_credential`只需传入一次；核心会将其用于该用户的推理和后端调用，且绝不会验证或回显它。

最小签名器：

```python
import hashlib, hmac, time

def sign(service_token: str, user_id: str) -> str:
    t = int(time.time())
    tag = hmac.new(service_token.encode(), f"{t}.{user_id}".encode(), hashlib.sha256)
    return f"t={t},v1={tag.hexdigest()}"
```

### 配置文件 ID

使用 `profile_ids = "raw"`，匹配的用户 ID `^[a-z0-9][a-z0-9_-]{0,63}$` 会原样作为配置文件 ID（`本地`, `运维` 和 `h-*` 是保留项）；任何其他 ID 都会变成 `h-<32 hex of sha256(user id)>`. `"hashed"` "hashed" 会对每个 ID 进行哈希，这样可以让你的用户 ID 不出现在路径和日志中。更改模式会把用户映射到新的空配置文件。

### 转发托管聊天平台

网关拥有各个平台的 webhook 以及用户的账号关联。对于每条入站消息，它都会以该用户身份调用：

```json
{"method": "openhuman.channel_relay_inbound",
 "params": {"channel": "telegram", "chat_id": "777", "sender_id": "555",
            "sender_name": "Alice", "message_id": "tg-1", "text": "hello",
            "client_id": "channel-relay"}}
```

答案会立即返回（`已接受`, `thread_id`, `request_id`）；一个 `message_id` 已记录的消息 ID 会返回 `duplicate: true` ，并且不会执行任何操作，因此重试是安全的。回复会以事件流的形式到达用户的 `/events?client_id=channel-relay` 流，作为一个 `channel_outbound` 携带 `full_response` 以及 `channel` 和 `chat_id` 以将其送达。

## 单节点

在单节点部署中，保持 `storage_url` 和 `advertise_url` 不设置。配置文件注册表是 `profile.toml` 位于以下路径下的文件： `<root>/users/`，且每个配置文件的租约都是对以下文件的文件锁： `<root>/users/<id>/.lease`，这也会阻止同一根目录上的第二个进程打开同一个配置文件。

## 集群

多个节点可以服务同一用户群。每个配置文件在任一时刻都只由一个节点托管，这由 **租约** 在共享存储后端中强制保证；任何节点都可以托管任何配置文件。

1. **一个存储后端。** 将 `storage_url` （或 `OPENHUMAN_STORAGE_URL`）设置为一个在进程间具有原子比较并交换的驱动：MongoDB（`mongodb://…/<db>`， `storage-mongodb` 构建特性）用于分布在多台主机上的节点，或者 SQLite（`sqlite:<path>`, `storage-sqlite`）用于同一主机上的多个进程。不要把 SQLite 放在网络文件系统上。它保存着配置文件注册表、租约、会话存储，以及每个配置文件的记录（密钥、认证配置文件、审批、cron、流程），位于作用域 `profile:<id>`.
2. **一个共享卷，用于 `root`.** 线程文件、成本和运行账本、后台完成项以及每个用户的 `sandbox/` 仍然是以下路径下的文件： `<root>/users/<id>/`。将 `root` 挂载到每个节点上的读写多节点卷（NFS、EFS、CephFS）上。把内存放在远程引擎中：配置文件使用托管的 TinyHumans 内存引擎，它不需要卷。
3. **每个节点一个唯一且稳定的 `node_id` 节点 ID，** 以及一个 `advertise_url` 网关可以通过它访问的 `advertise_url` 端点。设置该项会将节点标记为集群节点；启动守卫随后会要求使用跨进程后端。稳定的节点 ID 让重启后的节点能够立即取回自己的配置文件。
4. **自己的 `operator_dir` 节点 ID，** ，这样共享同一根目录的节点就不会共享运维工作区和密钥环。

### 路由与 409

对用户进行粘性路由：将用户 ID 哈希到某个节点上（使用一致性哈希可在节点增减时让大多数用户保持不变）。粘性只为速度；正确性由租约保障。当请求到达某个节点，而另一个节点持有该用户的配置文件时，核心会返回：

```
HTTP/1.1 409 Conflict
X-OpenHuman-Profile-Owner: node-b

{"error": "profile_held", "owner": "node-b",
 "endpoint": "http://10.0.0.12:7788", "retry_after_ms": 21500}
```

网关应将请求转发到 `端点` （并更新该用户的路由），或者当 `端点` 为空或不可达时，在以下时间后重试： `retry_after_ms`。 `409` 是在持有者令牌检查之后才返回的，因此未认证的调用方无法获知哪些用户存在。

若要有意迁移某个用户（清空节点、重新均衡），请调用 `profiles.release {"profile_id": ...}` 由持有该配置文件的节点执行。该操作会在配置文件使用中时拒绝；稍后重试。

### 故障切换与恢复

* 持有者会每隔租约时长的三分之一续租一次 `lease_ttl_secs`。崩溃或失去后端的节点会停止续租，其配置文件在租约到期后会对其他节点可用（最多 `lease_ttl_secs`，默认 30 秒）。
* 接管从未释放过的租约的节点，会在提供服务前恢复该配置文件：正在进行中的轮次会标记为中断，孤立的运行账本行会被结清。干净的交接（空闲驱逐、释放、关闭）不会恢复任何内容。
* 如果某个节点发现自己的租约在其之下发生变化（另一个节点接管，或运维人员释放了它），它会对该配置文件实施隔离：停止该配置文件正在运行的轮次，并且不再启动新的轮次。
* 在正常关闭时，节点会立即释放其空闲配置文件的租约；仍在忙碌的配置文件会保留租约，因此其下一个持有者会恢复其轮次。

已知限制：存储写入不携带代际隔离，因此一个与后端分区但仍在运行的节点，可能会在租约过期后短暂写入。请将 `lease_ttl_secs` 设置得远高于网络抖动，并将每轮前的隔离检查以及失去租约时的轮次取消视为缓解措施。

## 检查清单

* `root` 绝对路径，且不可被所有人写入，位于共享 RWX 卷上（集群）。
* `service.token` mode `0600`，至少 32 字节，仅与网关共享。
* 网关签名 `X-OpenHuman-User` 并保持时钟同步（±60 秒）。
* `storage_url` 使用 MongoDB（或同一主机上进程使用 SQLite），并启用相应的构建特性（集群）。
* `node_id`, `advertise_url` 和 `operator_dir` 每个节点唯一（集群）。
* 负载均衡器对用户进行粘性哈希，并遵守 `409` 重定向。
* 为用户预配置（`profiles.provision`）并安装其凭据（`profiles.set_credential`）后，再让其发起首次请求。
