持久化AI代理2026:租约执行器实战
2026年,AI代理开始做真正的工作:花二十分钟研究一个问题、等十分钟构建、请求用户审批、或数小时后等外部任务完成。如果仍然把一个代理当作长驻进程来写——一个worker接收请求、进入代理循环、调用模型和工具、等待结果、最终返回答案——资源会被白白浪费,一次部署或崩溃就会摧毁已经完成的工作。Ju Lin 在 2026年8月26日的文章《Agents Should Be Durable, Not Long-Lived》中提出了一个更优雅的模型:把「代理运行」与「执行它的进程」分离。本文用完整的代码示例讲透这套模式。
1. 为什么长驻进程模式会失败
传统实现中,一个代理等于一个长期运行的进程。状态存在内存里,worker 必须全程存活。真实工作流里代理经常要等待:20分钟的研究、10分钟的构建、人工审批、外部回调。让一个 worker 为整个运行过程保持存活,既浪费资源,又让失败代价高昂——部署、崩溃、机器重启都可能摧毁已经完成的进度。这正是 2026 年生产级代理系统转向「持久化运行」的核心原因。
// The old way: one agent = one long-lived process
// Problems: a 20-min research task holds a worker hostage,
// a deploy or crash destroys in-flight work.
class LongLivedAgent {
constructor(private request: Request) {
this.messages = []; // state lives in RAM
}
async run() {
const research = await this.research(20 * 60_000); // blocks
const build = await this.waitForCI(10 * 60_000); // blocks
const approval = await this.askHuman(); // blocks
return this.finalize(research, build, approval);
}
}2. 核心模式:持久状态 + 租约执行
正确的抽象不是短命代理,而是「带租约执行器的持久化代理」。代理运行本身是持久的:状态、消息、工具结果、预算、当前位置都存储在 worker 之外。worker 是临时的:它租约一个可运行的代理,在短时间内执行有用的工作,checkpoint 新状态,然后消失。另一个 worker 可以从 checkpoint 继续。这并不意味着每次模型调用都要新开进程——一个 worker 可以持有 30 到 60 秒的租约,在有进展时执行多个代理步骤。
// Durable run: state lives outside the worker.
// A worker "leases" a runnable agent for a short window,
// checkpoints progress, then disappears.
interface AgentState {
runId: string;
messages: Message[];
toolResults: ToolResult[];
budgetSpent: number;
position: string; // where in the plan we are
waitingOn: null | { type: "ci" | "human" | "timer"; ref: string };
}
async function executeLease(state: AgentState, leaseSeconds = 60) {
const deadline = Date.now() + leaseSeconds * 1000;
while (Date.now() < deadline) {
const step = await nextStep(state); // one agent step
state = await checkpoint(state); // persist after every step
if (step.kind === "wait") return { state, status: "waiting" };
}
return { state, status: "lease-expired" }; // another worker continues
}3. 状态必须廉价可重建
持久化模式有一个关键约束:状态必须便宜。好消息是,现代数据库让 checkpoint 变得异常简单——每次执行一个步骤就写入一次。用 libSQL/Turso 这样的边缘数据库,run_id 作为主键做 upsert 即可。坏消息是,浏览器、shell 和沙箱这类有状态的服务可能需要独立的长期服务,而流式输出也必须与当前持有运行的 worker 解耦。这些都是基础设施问题,而大规模 Web 系统早就解决了类似问题——没有人会给每个用户分配一个常驻服务器进程。
// Checkpoint storage: cheap to write, cheap to rebuild.
import { Database } from "@libsql/client";
const db = new Database({ url: process.env.TURSO_URL! });
export async function checkpoint(state: AgentState) {
await db.execute({
sql: `INSERT INTO agent_runs (run_id, state_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(run_id) DO UPDATE SET state_json = excluded.state_json`,
args: [state.runId, JSON.stringify(state), Date.now()],
});
}
export async function loadRun(runId: string): Promise<AgentState> {
const row = await db.execute({
sql: "SELECT state_json FROM agent_runs WHERE run_id = ?",
args: [runId],
});
return JSON.parse(row.rows[0].state_json as string);
}4. 等待边界是设计的核心
最重要的边界是「等待」。如果代理需要等五分钟的 CI,它不应该睡五分钟。它应该记录「正在等待 CI」然后退出;当 CI 完成——或定时器触发——运行再次变为可运行。同样的模式适用于速率限制、人工审批、定时任务、外部回调和代理之间的通信。这套「等待即退出」的语义是租约模式能扩展到百万级并发运行的关键。
// The important boundary is WAITING.
// If an agent needs 5 minutes of CI, it must not sleep 5 minutes.
// It records "waiting on ci" and exits. A timer or webhook re-activates it.
const RUNNABLE_QUEUE = "agent:runnable";
async function waitForCI(state: AgentState, buildId: string): Promise<AgentState> {
await checkpoint({ ...state, waitingOn: { type: "ci", ref: buildId } });
await redis.lpush(RUNNABLE_QUEUE, state.runId); // parked, not running
return state; // worker exits now
}
// CI webhook: when the build finishes, make the run runnable again.
app.post("/webhooks/ci/:buildId", async (req) => {
const runId = await redis.get(`ci:${req.params.buildId}`);
await redis.rpush(RUNNABLE_QUEUE, runId);
});5. 幂等与副作用安全
租约执行意味着重试是常态,所以副作用绝不能执行两次。实践中用幂等键包裹每个外部副作用:先检查 Redis 里是否已有结果,有则直接返回,没有则执行并写入。再加上调度器从可运行队列里取出 run_id、分配 worker、执行租约、释放 worker 的循环,一套支撑百万级代理运行的系统就成型了。
// One million active runs, far fewer processes.
// Most agents are waiting; only runnable ones get compute.
async function scheduler() {
while (true) {
const runId = await redis.blpop(RUNNABLE_QUEUE, 0);
const state = await loadRun(runId);
const worker = acquireWorker(); // short lease
try {
const { state: next } = await executeLease(state, 60);
await checkpoint(next);
} finally {
releaseWorker(worker);
}
}
}
// Idempotency: side effects must not run twice after a retry.
export async function withIdempotency<T>(key: string, fn: () => Promise<T>): Promise<T> {
const done = await redis.get(`effect:${key}`);
if (done) return JSON.parse(done);
const result = await fn();
await redis.set(`effect:${key}`, JSON.stringify(result), { EX: 86_400 });
return result;
}6. 何时开始迁移
如果你的代理只是秒级响应的简单查询,长驻进程完全够用。但一旦代理开始等待构建、请求审批、或跨小时运行,就该迁移到持久化模式。迁移路径:先把状态抽到数据库,再加 checkpoint,然后引入租约和等待边界。每一步都是增量收益,最后一步是让系统具备真正的横向扩展能力。
📌 常见问题 FAQ
持久化代理和长驻进程的根本区别是什么?
长驻进程把状态放在 worker 内存里,worker 必须全程存活;持久化代理把状态(消息、工具结果、预算、位置)存在外部存储,worker 只持有短租约执行有进展的步骤,然后消失。
每次模型调用都要新开进程吗?
不需要。那会造成不必要的调度和状态重建开销。一个 worker 可以持有 30-60 秒租约,在有进展时连续执行多个代理步骤。关键边界是等待:一旦需要等待,就记录状态并退出。
如何保证重试后副作用不重复执行?
用幂等键。每个外部副作用(发邮件、调用 API、写数据库)都先查 Redis 中是否已有对应 key 的结果,有则直接返回,没有则执行并写入。这样租约重试就完全安全。
流式输出怎么处理?
流式输出必须独立于当前持有运行的 worker。通常把流接到一个持久化的发布-订阅通道(如 Redis Streams),任何 worker 接管运行后都能继续向客户端推送。
这个模式适合所有代理吗?
秒级响应的简单代理用长驻进程即可。一旦代理开始等待构建、请求人工审批或跨小时运行,就值得迁移。迁移可以增量进行:先抽状态,再加 checkpoint,最后引入租约。