跳到主内容
快讯直播
AI智模界
教程

上手 Rivet:在 S3 上编排长期运行的智能体

很多智能体跑着跑着就断了——不是因为模型不行,而是因为进程没了。部署一次、滚个更新、机器重启,正在进行到一半的任务连同上下文一起蒸发。要么重跑,要么人工补数据。

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、状态与队列落对象存储、幂等重试、杀掉进程验恢复——是这套方案里不会变的部分,把它跑通,比记住某个具体参数更值钱。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。