很多智能体跑着跑着就断了——不是因为模型不行,而是因为进程没了。部署一次、滚个更新、机器重启,正在进行到一半的任务连同上下文一起蒸发。要么重跑,要么人工补数据。
Rivet 的解法是把它当成"演员(actor)"来编排:每个智能体实例有自己一份持久化状态,动作串行执行,可以在中间睡上几小时甚至几天,醒来接着干。状态不放在内存里,而是以 SQLite 的形式落到对象存储(S3 或兼容协议),本地只留热数据。下面从零搭一套,把并发、重试、失败恢复都实测一遍。
适用场景
这套方案适合这样的任务:单个智能体实例要跑几分钟到几天(等待人工审批、轮询外部系统、抓取+分析+产出报告),中途不能被重启打断,且团队已经有 S3 或兼容对象存储可用。如果你只是做一次性的短请求,普通函数服务更省事;一旦出现"跑到 80% 挂了要重来"的痛点,actor 模型的价值就体现出来了。
环境与前置条件
- 操作系统:Linux x86_64 或 arm64,容器化部署,Docker 与 docker compose v2(版本要求以官方文档为准)
- 内存:同机跑引擎 + Postgres + 对象存储,建议 8 GB 起步;只跑引擎、数据库和存储都用外部服务时可以降到 4 GB
- 磁盘:50 GB 以上 SSD。actor 的 SQLite 快照会在本地留一份缓存,磁盘小会频繁触发回落
- 对象存储:一个桶即可,建议开版本控制;MinIO 或云厂商 S3 都行。凭证需要
GetObject / PutObject / DeleteObject / ListBucket,走分片上传还要AbortMultipartUpload - 运行时:Node.js 20 LTS 及以上(以官方文档为准),用于编写 actor 与客户端
- 网络:引擎默认监听 6420(端口以官方文档为准),客户端与 actor 进程要能访问它
分步骤部署
第 1 步:准备目录与变量
所有配置集中到 .env,不要写死在 compose 里。
```bash
mkdir -p ~/rivet-s3 && cd ~/rivet-s3
mkdir -p data/{engine,postgres,minio}
```
创建 .env:
```bash
镜像:名称与 tag 以官方文档当前版本为准
MINIO_IMAGE=minio/minio:latest
POSTGRES_IMAGE=postgres:latest
RIVET_IMAGE=rivetdev/engine:latest # 镜像名与 tag 以官方文档为准
对象存储
S3_ACCESS_KEY=agentadmin
S3_SECRET_KEY=请换成足够长的随机串
S3_BUCKET=agent-state
S3_REGION=us-east-1
引擎元数据库
PG_USER=rivet
PG_PASSWORD=请换成足够长的随机串
PG_DB=rivet
```
这一步只是把"要连什么"写清楚,还没有启动任何东西。
第 2 步:把状态桶建好
桶是这套架构的地基:actor 的状态快照、智能体抓到的大文件都往这里放。用 MinIO 客户端(mc)或 AWS CLI 都行,二者选一。
```bash
方式一:mc
mc alias set local http://127.0.0.1:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY"
mc mb local/agent-state
mc version enable local/agent-state
```
```bash
方式二:aws cli,对云上 S3 或兼容服务都可用
aws --endpoint-url http://127.0.0.1:9000 s3api create-bucket --bucket agent-state
aws --endpoint-url http://127.0.0.1:9000 s3api put-bucket-versioning \
--bucket agent-state --versioning-configuration Status=Enabled
```
成功的标志是命令无报错返回,mc ls local 能看到 agent-state。版本控制强烈建议打开——actor 状态快照出问题时,能回滚到上一个可用版本,比重跑划算得多。
第 3 步:用 compose 起引擎
```yaml
name: rivet-s3
services:
minio:
image: ${MINIO_IMAGE}
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY}
volumes:
- ./data/minio:/data
ports:
- "9000:9000"
- "9001:9001"
restart: unless-stopped
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
retries: 12
postgres:
image: ${POSTGRES_IMAGE}
environment:
POSTGRES_USER: ${PG_USER}
POSTGRES_PASSWORD: ${PG_PASSWORD}
POSTGRES_DB: ${PG_DB}
volumes:
- ./data/postgres:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DB}"]
interval: 10s
retries: 12
engine:
image: ${RIVET_IMAGE}
env_file: .env
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
ports:
- "6420:6420"
volumes:
- ./data/engine:/data
environment:
键名以官方文档中"对象存储 / SQLite 持久化"一节为准
RIVET_OBJECT_STORAGE_KIND: s3
RIVET_OBJECT_STORAGE_BUCKET: ${S3_BUCKET}
RIVET_OBJECT_STORAGE_ENDPOINT: http://minio:9000
RIVET_OBJECT_STORAGE_REGION: ${S3_REGION}
RIVET_OBJECT_STORAGE_ACCESS_KEY_ID: ${S3_ACCESS_KEY}
RIVET_OBJECT_STORAGE_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
RIVET_OBJECT_STORAGE_FORCE_PATH_STYLE: "true"
restart: unless-stopped
```
两点说明。第一,官方自托管模板里通常还会带 ClickHouse 之类的日志/指标组件,按需启用即可,不影响本文的验证路径。第二,RIVET_OBJECT_STORAGE_* 这组键名请对照你所用版本的官方配置项填写,不同版本可能改为配置文件(TOML/YAML)形式——要表达的信息只有五个:桶名、端点、区域、密钥、是否 path-style,其中 MinIO 必须打开 path-style,否则会出现"签名对但访问 404"的怪现象。
启动:
```bash
docker compose up -d
docker compose ps
```
预期输出里 minio、postgres 为 healthy,engine 为 running。接着看引擎日志确认它认到了桶:
```bash
docker compose logs --tail=80 engine
```
日志里应能看到监听地址与存储初始化信息,且没有连续的 error / retry 关键字。
第 4 步:用状态机定义一个智能体
核心思路:把智能体写成一台小状态机,状态存在 actor 里,每个动作只做一小步、可重复执行。
```bash
mkdir -p agent && cd agent
npm init -y
npm i rivetkit @aws-sdk/client-s3 # 包名以官方文档为准
```
```ts
// agent.ts
import { actor, setup } from "rivetkit";
type Phase = "queued" | "running" | "waiting_human" | "done" | "failed";
const researchAgent = actor({
state: {
phase: "queued" as Phase,
urls: [] as string[],
finished: [] as string[],
artifacts: {} as Record<string, string>, // url -> S3 key
retries: 0,
},
actions: {
// 入队:只改状态,立刻返回
enqueue: (c, urls: string[]) => {
c.state.urls.push(...urls);
c.state.phase = "queued";
return c.state.urls.length;
},
// 单步推进:每次只处理一个 URL,失败可重入
step: async (c) => {
if (c.state.phase === "waiting_human") return c.state.phase;
const url = c.state.urls.shift();
if (!url) {
c.state.phase = "done";
return c.state.phase;
}
try {
const html = await fetch(url).then((r) => r.text());
const key = artifacts/${encodeURIComponent(url)}.html;
await putToS3(key, html); // 大文件进对象存储
c.state.artifacts[url] = key;
c.state.finished.push(url);
c.state.retries = 0;
c.state.phase = "running";
} catch (err) {
c.state.retries += 1;
c.state.urls.unshift(url); // 放回队头,下次重试
if (c.state.retries >= 5) c.state.phase = "failed";
else await c.sleep(2 ** c.state.retries * 1000); // 长睡眠不占进程
}
return c.state.phase;
},
status: (c) => ({ phase: c.state.phase, pending: c.state.urls.length }),
},
});
export const registry = setup({ use: { researchAgent } });
registry.start();
```
```ts
// s3.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: process.env.S3_REGION,
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: true,
});
export async function putToS3(key: string, body: string) {
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: body,
ContentType: "text/html; charset=utf-8",
}));
return key;
}
```
这里的设计要点值得展开。状态(phase、urls、artifacts)是会被持久化的,进程重启后自动读回;大文件不进状态,只在状态里存 S3 的 key,避免状态膨胀到几十 MB 导致快照变慢;睡眠是持久化的,c.sleep 一小时不会占着线程,恢复后从睡的地方继续。三件事合起来,才有"长期运行"。
同样提醒:SDK 迭代较快,actor / state / actions / c.sleep 的具体写法以你安装版本的官方文档为准,但"状态 + 动作 + 可持久化睡眠"这个骨架是稳定的。
第 5 步:从客户端驱动它
```ts
// client.ts
import { createClient } from "rivetkit/client";
const client = createClient("http://127.0.0.1:6420");
// 用业务主键拿到同一个 actor:同一个工单永远落在同一个状态机上
const agent = client.researchAgent.getOrCreate(["task-42"]);
await agent.enqueue(["https://example.com/a", "https://example.com/b"]);
for (let i = 0; i < 5; i++) {
console.log(await agent.step());
console.log(await agent.status());
}
```
getOrCreate(["task-42"]) 这一行是整个编排的关键:键相同就是同一个 actor,键不同就是不同 actor。你的任务 ID、会话 ID、用户 ID 都是天然的键。
第 6 步:并发与重试
并发分两层看。actor 内部的动作是串行执行的,同一个键上的两个 step 不会同时改 state,天然没有竞态;跨 actor 才是并行的,可以放心把 1000 个任务分给 1000 个 actor。
真正要控制的是"同时跑多少个"。最朴素也最可控的做法是在客户端侧加一个信号量:
```ts
async function runWithLimit<T>(items: T[], limit: number, fn: (x: T) => Promise<void>) {
const queue = [...items];
const workers = Array.from({ length: limit }, async () => {
while (queue.length) await fn(queue.shift()!);
});
await Promise.all(workers);
}
await runWithLimit(taskIds, 8, async (id) => {
const a = client.researchAgent.getOrCreate([id]);
await a.enqueue(urlsOf(id));
while ((await a.status()).phase !== "done") await a.step();
});
```
重试则写在 actor 内部,三件事一起做:失败放回队头、指数退避睡眠、计数到阈值转 failed。注意"放回队头"这个动作——它保证了至少一次处理,代价是可能重复处理,所以每个 step 必须幂等:写 S3 用固定 key(同 key 覆盖),写数据库带唯一约束。
第 7 步:实测失败恢复
这是最该做的一步。投一批任务,跑到一半直接把引擎杀掉:
```bash
终端 1:投 20 个任务后不断 step
终端 2:
docker compose kill engine
docker compose up -d engine
```
重启后再调一次 status(),预期 pending 不是 20(已经完成的不会重来),phase 延续之前的状态,finished 数组还在。再观察 S3:
```bash
mc ls -r local/agent-state | wc -l
```
这个数字会在任务推进过程中增长——增长的正是 actor 的状态快照与产物文件。到这里,"状态真的落在对象存储上"就有了实证。
验证部署是否成功
按顺序跑下面几条,全部符合预期才算部署完成。
1. 组件健康:docker compose ps → minio、postgres 为 healthy,engine 为 running 且重启次数为 0。
2. 端口连通:curl -i http://127.0.0.1:6420/ → 返回任意 HTTP 响应(200/404 都说明服务在监听);健康检查的具体路径以官方文档为准。
3. 状态持久化:用第 5 步的客户端写 5 次,执行 docker compose restart engine,再读回,计数应为 5 而不是 0。
4. 队列续跑:一次性 enqueue 20 个 URL,中途 kill 引擎,重启后继续 step,最终 finished.length 应为 20,且已完成项没有重复出现在 urls 里。
5. 对象存储生效:mc ls -r local/agent-state | wc -l 随时间增长;mc ls -r local/agent-state | head 能看到 actor 状态与 artifacts/ 两类前缀。
6. 并发受控:同时投 100 个任务、限流设为 8,用 docker stats 或引擎日志观察,同时在跑的实例数不应长期高于 8。
常见报错与解决
报错:SignatureDoesNotMatch 或 AccessDenied(日志里反复出现)
原因:密钥错误、区域不匹配,或 MinIO 没开 path-style 寻址(把桶名当成了子域名)。
解决:
```bash
先用同一组凭证在容器外验证,排除引擎问题
mc alias set check http://127.0.0.1:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY"
mc ls check
确认无误后,检查引擎配置里 FORCE_PATH_STYLE 是否为 true(MinIO 必须)
docker compose exec engine env | grep -i storage
```
报错:database is locked 或 actor 频繁重启
原因:快照间隔过密、本地磁盘太慢,SQLite 写入与快照互相抢占。
解决:把快照间隔调大(以官方配置项为准),把 ./data/engine 换到 SSD;同时确认磁盘没满:
```bash
df -h ./data
docker compose restart engine
```
报错:port is already allocated
原因:6420 或 9000 被别的进程占用。
解决:
```bash
ss -lntp | grep -E '6420|9000'
改 compose 里的宿主端口映射,例如 "16420:6420",然后
docker compose up -d
```
报错:重启后状态回到初始值(计数变回 0)
原因:两次启动用的桶或前缀不一致,或者根本没开对象存储持久化——状态只写了本地临时盘,容器一重建就没了。
解决:确认引擎日志里的桶名与实际桶一致;确认 mc ls -r local/agent-state 有对象增长;确认 ./data/engine 是挂载卷而不是容器内路径。
报错:no space left on device
原因:快照缓存堆积,旧代际文件没有清理。
解决:给桶加生命周期规则清理过期对象,并清理本地缓存目录:
```bash
mc ilm rule add local/agent-state --expire-days 30
du -sh ./data/engine && docker compose restart engine
```
后续维护
备份。 桶开版本控制只是第一层,还要加跨区域复制或定期 mc mirror 到另一个桶;Postgres 里存的是引擎元数据,用 docker compose exec postgres pg_dump -U "$PG_USER" "$PG_DB" > backup.sql 做定期导出,和桶分开存放。
升级。 顺序是:停止投递新任务 → 等一轮快照落盘 → 备份桶前缀与 Postgres → 改 .env 里的镜像 tag 拉新镜像 → docker compose up -d 滚动重启 → 挑一个老 actor 读一次 status() 验证状态兼容。跨大版本升级前先在测试环境跑一遍这个流程。
日志。 给容器加日志上限,避免长期运行把磁盘写满:
```yaml
logging:
driver: json-file
options: { max-size: "50m", max-file: "5" }
```
监控。 四个指标足够覆盖大多数故障:对象存储里快照对象的增长速度(突然不动 = 持久化断了)、快照落后于当前时间多久、每个 actor 的 pending 队列深度、引擎的错误日志速率。前三项可以用定时脚本采集后打到现有监控系统里。
成本与安全。 快照频率乘以 actor 数量乘以状态大小就是存储增量,老代际用生命周期规则转低频存储;桶保持私有,凭证走密钥管理而不是明文 .env,权限按最小化授予到前缀级别。
最后提醒一句:Rivet 与 RivetKit 都在快速迭代,镜像名、配置键名、SDK 方法名请以官方文档的当前版本为准。本文给出的骨架——状态机定义 actor、状态与队列落对象存储、幂等重试、杀掉进程验恢复——是这套方案里不会变的部分,把它跑通,比记住某个具体参数更值钱。
