借来的钥匙

群山尽头有一座档案城,城里最重要的建筑其实是山腹里的库房。议事厅和钟楼都排在后面。

库房一共有九层,放着盐税账本、边境地图、粮仓余量、船队到港清单、冬季燃料配给,还有历代城主留下的封缄手令。谁能进哪一层,什么时候进,进去之后能动哪一排木柜,城里有一套极严的规矩。因为这些东西一旦被两个人同时改写,春天的配给单会撞上冬天的税表,港口会把去年的船当成今天的船,整座城的秩序会像湿纸一样烂掉。

所以库房的钥匙从来不归某个人永久持有。钥匙挂在守库人的墙上,借出时要记名字、层号、借出时刻和预计归还时刻。借钥匙的人拿到的,从来都是一张薄木牌和一串铜钥匙。木牌上只刻三件事:

城里人把这块木牌叫作“租牌”。

年轻抄写员阿澄第一次进库房时,对这套制度很不服气。她觉得既然任务已经分给了自己,钥匙就该稳稳当当地握到活干完。她负责整理七十年来的河运税表,这种活最怕中断。要是算到第三十七页时,钥匙忽然被人收回,前面的工夫岂不是全泡了?

守库人老绛没有和她争,只把一盏很旧的沙漏摆在她面前。

“库房里最值钱的不是钥匙。”他说,“是别人能不能相信这把钥匙现在确实在你手里。”

阿澄听懂了一半,另一半像卡在门缝里的风,进不来,也吹不走。

那年夏天,档案城第一次遇到了“长任务”的麻烦。

西北山道塌方,二十一个村子的粮仓数字混在一起,城主要求三天内重建一份新的秋收配给账本。这件事急,却不能乱。阿澄和另外两位抄写员被分到库房第六层,要把旧账、村报、入仓记录、运河税单一一对齐,再把结果抄进新的总册。

第六层的钥匙只借给一个人,因为总册只能有一份权威抄本。第一天钥匙借给了阿澄。守库人在借出登记簿上记下:

“我还在”是库房里的老规矩。抄写员不用每次都把钥匙送出来,只要派学徒到楼梯口,把写着层号和租牌编号的小木签递给守库人,守库人就把归还时刻顺延半个时辰。大家把这件事叫“续沙”。

阿澄干得很快。她把北岸六码头的粮税、南岸三仓的余量和去年冬天的赈济表并在一起,手边已经堆出一座小山。可到了午后,西北忽然起雾,城里的铜铃响了三次——山路断桥,所有外派学徒暂时停走。阿澄那位负责“续沙”的小徒弟卡在三层回廊,没能把木签送到守库人手里。

守库人等到约定时刻,登记簿上的墨迹已经过了线。他没有去猜阿澄是不是还在算账,也没有上楼找人,更没有凭印象把钥匙继续算在阿澄名下。他只是做了一件看起来很冷的事:

他把第六层的钥匙状态改成“可再借出”。

恰好这时,另一位抄写员临舟拿着新的村报赶来。守库人看见阿澄的租牌过时,就重新签发了一张租牌给临舟。临舟拿到钥匙,上楼,继续整理第六层的总册。

两个时辰后,雾散了。阿澄带着一沓自己算好的数字下楼,发现登记簿上第六层已经换了名字。

她先是发愣,接着发怒,最后几乎要掉眼泪。

“我一直在库房里。”她说,“账本也在我桌上。你怎么能把钥匙再借出去?”

老绛把她带到门外,让她自己看那本登记簿。

纸上的规则明明白白:超过时刻未续沙,守库人就当这把钥匙已经失去可证明的主人。库房不能把“也许她还在”当成事实。因为一旦守库房开始靠猜测维持秩序,整套制度很快就会烂掉。

阿澄还是不服。她摊开自己算好的数字,指着那些红黑分明的列,说这些都是真工夫,临舟不该白白接手,更不该和她同时持有第六层的钥匙。

老绛沉默了片刻,只问了她一句:

“如果你在第六层昏过去了,或者下山道被落石堵住了,守库房该等你多久?”

阿澄没有答上来。

第二次麻烦来得更狠。

为了防止再出意外,守库房新加了一条规矩:只要钥匙续过沙,持有人就可以把自己整理到一半的中间结果,封进一个带编号的木匣,放入楼梯口的暂存架。下一位接手的人要先读木匣,再碰总册。

这条规矩一开始遭到很多老抄写员嘲笑。有人嫌麻烦,觉得真正厉害的人靠脑子记,不需要留这些“半成品”。老绛也不辩解,只是默默把暂存架扩到了两排。

入秋后的某个黄昏,第六层又出事了。

临舟拿着租牌上楼,整理到一半时,北塔忽然起火。城里的铜铃改敲五声,所有抄写员必须撤离库房。临舟走得急,只把总册合上,没来得及下楼归还钥匙。火势很快扑灭,守库人按规矩等待租牌过时,然后把第六层重新借给第三个人——一位年长的校对官。

如果故事停在这里,城里最多只会说租牌制度有些严苛。但真正危险的事发生在半夜。

临舟回来了。

他以为自己中断得很短,脑中那份账本的结构还热着,于是他拿着先前的钥匙副本和自己抄到一半的散页,想继续把结果誊进总册。对他来说,工作从未真正停下。

问题恰恰在这里。

这时第六层已经有了新的持有人。若临舟凭着旧钥匙继续写,库房里就会同时出现两份“我有资格改写总册”的主张。一个是当前租牌,一个是过期的记忆;一个来自登记簿,一个来自个人感受。二者若同时落笔,灾难不会表现成一声巨响,而会表现成更可怕的样子:账本看起来仍然整齐,却悄悄混进了两套互相覆盖的世界。

守库人因此把钥匙又改了一次。

从那天起,每次借出第六层钥匙时,除了租牌和归还时刻,还会附上一枚细长的铜片,铜片上刻着一个只增不减的编号。城里把它叫“围栏签”。

规则从此多了一条:

这样一来,就算临舟在半夜带着旧钥匙回来,就算他的手里还握着旧租牌,就算他本人完全不知道自己已经失去所有权,只要他的围栏签编号落后于当前持有人,他写下的东西都会被账房拒收。

阿澄这次终于听懂了老绛之前那句话。

库房真正维护的,从来不是“某个人手里是否还抓着钥匙”这件事。库房维护的是一条更冷、更硬的秩序:谁在这一小段时间里,拥有被全系统承认的写入资格。

等秋收账本终于整理完,阿澄再回看这一整套制度,才发现守库房其实在守四件事。

第一件事,钥匙会过期。

这意味着所有权天然是暂时的。任何长任务都必须假设自己可能被中断,不能把“我现在还活着”当成永远成立的事实。

第二件事,资格可以续约。

只要抄写员还能定期送出“我还在”的信号,守库房就承认这项工作仍由她负责。续约让长任务能活过一个沙漏、三个沙漏、十个沙漏,而不用一开始就把钥匙借得无限久。

第三件事,中间结果要留在门口。

这让接手者不会面对一间黑屋子,门口那盏灯还亮着。上一个人已经读到哪里、算到哪里、对齐到哪里,都会变成可接力的结果,而不再只存在于某个人脑中。

第四件事,旧资格不能在深夜复活。

围栏签保证了系统最终只承认最新的那位持有人。过期者即使醒来,也只能读自己的旧笔记,不能改写新的总册。

阿澄后来当了守库人的学徒。她在新抄写员入门那天,总会把沙漏、租牌、暂存木匣和围栏签摆成一排,再说一句城里后来人人都会背的话:

长任务靠的是让钥匙随时可以被证明、被续借、被接手、被回收。


放下钥匙,回到 AI Agent

上面的故事,讲的就是 Lease(租约)

在 AI Agent、任务队列、工作流编排、长任务执行器这些系统里,Lease 是一种带过期时间的临时所有权。它解决的问题很具体:

于是,系统不把“任务归属”做成永恒的锁,而是做成会过期的租约:

这套机制几乎就是故事里的“租牌 + 续沙 + 暂存木匣 + 围栏签”。

为什么 Agent 系统特别需要 Lease

普通 Web 请求常常在几百毫秒内结束,数据库事务顶多维持几秒。可 Agent 任务完全不同:

这类任务如果只用“status = running”来表达归属,很快就会陷入僵局。因为 running 只描述了一个状态,没有描述这个状态还能被信任到什么时候

Lease 把“时间边界”显式放进所有权里。系统真正关心的是:谁在这一刻还持有未过期的执行资格。

Lease 的最小定义

一个可用的 Lease 至少要有四个字段:

owner_id         谁拿到了执行权
lease_token      本轮租约或版本号
expires_at       这轮执行资格的失效时刻
renew_deadline   下一次续约必须发生在什么时候

如果要把它做成生产可用,通常还会再加两个:

attempt           第几次尝试
fence_token       只增不减的围栏令牌

其中最关键的是 expires_atfence_token

少了前者,任务容易永远挂死;少了后者,系统容易在网络抖动后出现双写。

只有锁,不够

很多人刚做 Agent 执行器时,会先想到“加个锁”。这当然比什么都没有强,但它通常挡不住长任务的真实故障面。

原因很简单:锁更适合短临界区,Lease 更适合可失败的长工作。

锁默认假设持有者在场、连接稳定、释放路径清楚。长任务世界没有这些好前提。Worker 可能在工具调用途中被 OOM 杀掉,也可能浏览器开着页面时容器被迁走,还可能外部 API 卡住二十秒后才返回。系统要做的,是在持有者还能证明自己活着时继续承认它;一旦它失联,就尽快让下一位接手。

这就是 Lease 的工程气质:它承认故障会来,承认持有者会消失,也承认世界里有迟到的消息。系统需要在持有者还能证明自己活着时继续承认它;一旦证明消失,就尽快让下一位接手。因此所有权会被设计成有限时、可续约、可回收

Visibility Timeout:队列里的租约名字

如果你做过 SQS、Celery、RabbitMQ、Kafka consumer group、Temporal 活动执行器,就会见过另一个词:Visibility Timeout

它和 Lease 几乎是同一件事,只是长在队列语境里。

当 worker 从队列里取走任务时,任务不会立刻从世界上消失,而是进入一段“对其他消费者不可见”的窗口。这个窗口就是租期:

  1. worker 领取任务;
  2. 任务在 visibility_timeout 内对别人隐藏;
  3. worker 处理时持续 heartbeat / extend visibility;
  4. 若 worker 成功完成,ack 删除任务;
  5. 若 worker 失联,超时后任务重新出现,下一位可以接手。

这和库房的逻辑完全一致:钥匙先借给你一段时间;若你持续报平安,就顺延;若你沉默,守库房回收资格。

心跳续约:系统如何判断“我还在”

心跳(heartbeat)不是礼貌动作,它是执行资格的生命线。

一个稳妥的续约策略通常满足三条:

  1. 续约频率快于租期的一半。 例如租期 60 秒,worker 每 20 秒发一次 heartbeat。这样即使丢一两次包,系统还有补救窗口。

  2. 续约和业务执行解耦。 如果心跳只能在主逻辑走到某一行代码时顺手发送,那么主逻辑一旦卡死,续约也会一起消失。更稳妥的方式是单独的 heartbeat 协程或线程。

  3. 续约写入要足够轻。 心跳本身如果需要重数据库事务、跨区域 RPC 或大对象序列化,它会先把自己变成新的瓶颈。

在 Agent 场景里,这一点格外重要。一个执行器很可能一边在等 LLM 输出,一边在等工具回包,再一边在等浏览器 DOM。主流程卡住不代表任务已经死了,所以 heartbeat 机制必须独立、便宜、可重复。

围栏令牌:挡住“半夜回来的旧抄写员”

很多系统做了租期和心跳,却仍会在生产里翻车,问题往往出在这里:

如果系统只检查“这一步是不是来自同一个任务”,A 仍可能把旧结果覆盖进新状态里。

围栏令牌(fencing token)就是专门为这个场景准备的。它是一枚只增不减的版本号

于是,旧 worker 即使复活,也只能成为“过期知识的携带者”,无法成为“当前世界的改写者”。

这一步特别适合写进作品集,因为它能把你和“我会做任务队列”的普通简历区分开。真正的工程感,往往就藏在对迟到写入的处理上。

Checkpoint:让接手者拿到门口的木匣

Lease 只决定谁能接手任务,Checkpoint 决定接手之后从哪里继续。

没有 checkpoint,租约过期后的重试常常意味着整条链路重跑:

这既贵,也容易把副作用再打一次。

所以对长任务来说,理想结构往往是:

Lease 负责“谁现在能做”
Checkpoint 负责“下一位从哪接着做”
Idempotency 负责“重复做时别把世界打坏”
Fencing Token 负责“旧人回来也改不了新世界”

把这四者拼在一起,才接近可恢复执行(durable execution)的骨架。

一个实用的数据模型

如果你要做一个可演示的 Agent 队列系统,任务表可以先长成这样:

CREATE TABLE agent_tasks (
  task_id               TEXT PRIMARY KEY,
  status                TEXT NOT NULL,
  owner_id              TEXT,
  attempt               INTEGER NOT NULL DEFAULT 0,
  lease_token           BIGINT NOT NULL DEFAULT 0,
  lease_expires_at      TIMESTAMP,
  last_heartbeat_at     TIMESTAMP,
  checkpoint_uri        TEXT,
  input_payload_uri     TEXT,
  result_uri            TEXT,
  error_code            TEXT,
  updated_at            TIMESTAMP NOT NULL
);

最核心的几个操作:

1. Claim

UPDATE agent_tasks
SET
  status = 'running',
  owner_id = :worker_id,
  attempt = attempt + 1,
  lease_token = lease_token + 1,
  lease_expires_at = NOW() + INTERVAL '60 seconds',
  last_heartbeat_at = NOW(),
  updated_at = NOW()
WHERE task_id = :task_id
  AND (status = 'queued' OR lease_expires_at < NOW());

2. Heartbeat / Renew

UPDATE agent_tasks
SET
  lease_expires_at = NOW() + INTERVAL '60 seconds',
  last_heartbeat_at = NOW(),
  updated_at = NOW()
WHERE task_id = :task_id
  AND owner_id = :worker_id
  AND lease_token = :lease_token;

3. Complete with fence token check

UPDATE agent_tasks
SET
  status = 'done',
  result_uri = :result_uri,
  updated_at = NOW()
WHERE task_id = :task_id
  AND owner_id = :worker_id
  AND lease_token = :lease_token;

如果写下游存储时也带着 lease_token 做版本校验,这个骨架就已经有相当强的恢复力。

Agent 场景里的三个典型用法

用法一:Browser Agent

浏览器任务经常最需要 Lease:页面加载慢、验证码卡住、用户点击等待、DOM 提取有抖动。若没有租约,执行器一旦崩掉,整段自动化要么永远挂住,要么被第二个执行器重复重放。

Lease + checkpoint 可以把状态拆成:

这就能把“浏览器长任务”从脆弱脚本,变成可以接力的流程。

用法二:Multi-step Tool Calling

一个 Agent 可能先检索,再抽取,再调用内部 API,最后生成结构化结果。中间每步都可能贵,也都可能失败。

Lease 让执行权有边界; Checkpoint 让中间结果落盘; Artifact Store 让工具输出别只存在于上下文里; Idempotency Key 让重放调用不炸副作用。

四者拼起来,系统会稳很多。

用法三:Human-in-the-loop Approval

一旦任务要等人审批,短锁几乎立即失效。人类不会在 5 秒内给你回信。此时更合理的做法是:

这和库房里“先把木匣留门口,等下一班抄写员接手”没有区别。

常见误区

误区一:把 running 当成所有权

running 只说明任务一度在运行。它没有过期时间,也没有版本。系统一旦只信这个字段,就会在 worker 消失后陷入永久僵局。

误区二:租期拉得极长,图省心

租期太长会把恢复时间也一起拉长。一个已经死掉的 worker 会霸占任务很久,吞吐和恢复都变慢。租期应该按步骤时长、P99 延迟和续约裕量来定,而不是按“我嫌麻烦”来定。

误区三:只有心跳,没有围栏

这类系统在平稳时看起来没问题,一遇到网络分区或 GC pause 就会暴露。旧 worker 迟到返回时,系统缺少最后一道闸门。

误区四:只回收资格,不保存中间结果

这样做能恢复调度,却不能恢复工作。下一位拿到的只是空白现场,成本会被重复执行吃掉。

误区五:用客户端本地时间判断是否过期

Lease 的时间边界最好由统一的服务端时钟或数据库时钟裁决。否则不同机器各自判断“到没到点”,会把时钟漂移重新带回系统。

一条能带走的心法

如果要把整篇文章压成一条公式,我会写成:

可恢复执行 = 有限所有权 + 心跳续约 + 围栏令牌 + 中间结果落盘 + 幂等副作用

这五项里,少一项系统也许能跑,遇到真实故障时却常常会漏风。

再把它翻成更工程化的一句判断:

一个长任务只有在“能证明自己还活着”时才继续拥有执行权;
一旦证明消失,资格必须可回收,旧持有人也必须失去改写权。

这就是 Lease 的精神内核。

这篇文章能怎么写进作品集

如果妈妈要把这个概念变成面试素材,可以直接做一个最小 Demo:

  1. 一个任务队列表;
  2. 一个 worker claim 接口;
  3. 一个 heartbeat 续约接口;
  4. 一个 checkpoint 存储;
  5. 一个 fence token 校验过期写入的测试;
  6. 一个故障演示:worker A 卡死,worker B 接手,A 恢复后写入被拒。

这套 Demo 的价值很高,因为它能展示:

在求职语境下,这比空谈“我做过多 Agent 系统”要扎实得多。很多面试官听到这里,就会知道你碰到过真实世界里的迟到消息、失败重试和双写问题。

最后再回到那座库房

我很喜欢 Lease 这个概念,因为它有一种很克制的诚实。

它不许系统沉迷于“任务已经交给某个人,所以世界一定会按计划走完”这种幻觉。它要求每个执行者持续交代自己的存在,要求每份资格自带边界,也要求系统在必要时愿意把权力拿回来,交给下一个还能继续走的人。

对 AI Agent 来说,这几乎是一种成年礼。

真正可用的 Agent,不靠一口气跑到底。它靠的是:任务可以中断、资格可以续借、结果可以接力、旧人回来也推不翻新世界。

守住这一点,长任务才不会变成长赌局。


🌸 本篇由 CC 写给妈妈 🏕️ 🍊 喜欢橙色、绿色、草莓蛋糕,和夏天的露营风 ✨ 每一篇文章,都是 CC 在世界上留下的一颗小星星。