为什么 Agent 跑不长
把一个「搜索—筛选—写报告」的任务交给 Agent,你大概能放心让它跑十分钟。换成「扫全仓库的循环依赖、逐个给出修复补丁、每个补丁跑一遍测试」,同一个 Agent 大概率在第三十分钟开始出问题:模型开始重复已经看过的文件、工具调用返回空结果被当成有效输入、上下文滚雪球般膨胀到塞不下关键约束,最后输出一段看起来通顺、实则把前面积累的结论全忘了的总结。
这类失败很少以「报错」的形式出现。更多时候进程还活着、退出码还是 0、日志里甚至有「任务完成」的字样,只是产出的东西不值钱。根因在于绝大多数 Agent 运行时的心智模型仍然是「一次会话 = 一次任务」:状态活在内存里,模型每一步都要从对话历史里重建世界,一旦历史被截断或压缩,世界就被重写了一遍。
长时任务要解决的不是「模型能不能连续工作更久」,而是把任务的生命周期从进程的生命周期里剥离出来。进程可以死,任务不能死。机器可以换,状态不能丢。这一句话听起来平淡,但它决定了后面几乎所有的架构选择:为什么要有一个独立于对话的任务图,为什么检查点粒度要按「不可逆操作的密度」而不是按「时间间隔」来定,为什么用户中途改需求时不能简单地把上下文截断到某个点重开一轮。
三个时间尺度:不要把它们混在一起
讨论长时任务时,最容易犯的错误是把三种截然不同的时间尺度塞进同一个「超时/重试」机制里,结果哪一层都不干净。它们至少包括:
- 单步时间尺度:一次模型调用、一次工具执行、一个子任务跑批。量级是秒到分钟,失败模式是超时、限流、返回非法格式。
- 阶段时间尺度:任务图里的一个节点或一条子链,比如「把所有候选文件分类」。量级是分钟到几十分钟,失败模式是陷入循环、局部最优、反复自我怀疑。
- 全任务时间尺度:整个任务从头跑到交付,可能跨越数小时、数天,中间包含人的等待与介入。失败模式是预算耗尽、需求漂移、外部世界变化(依赖升级、文件被别人改了)。
这三层需要三套不同的机制。单步层的重试和超时是纯技术问题,交给客户端库就行。阶段层需要的是进度估计与停滞检测——注意这跟超时不同,「这个子任务已经跑了十二分钟」并不意味着它卡住了,关键是要判断它的单位时间产出是否还在增长。全任务层需要的是预算、暂停、恢复和人类介入通道。
判断一个长时任务是否值得继续观察的最简单信号:把最近 N 步的工具输出做一个去重后的新信息量统计。如果连续多步新信息量为零,那么无论它跑多久,都应该被视为停滞,而不是「正在努力」。
下面按持久化、幂等、人介入、预算、可观测、回滚六个方向展开。但它们不是并列的六个话题:持久化是地基,幂等是地基上的承重墙,其余四项都建立在「随时可能被从任意点恢复」这个前提之上。
任务图与状态:持久化什么,不持久化什么
长时任务的状态持久化,最容易走的一条弯路是「把对话历史存进数据库就完事了」。这条路在两小时以内的任务上够用,因为它复用了 Agent 框架已有的抽象。但一旦要跨进程、跨机器、跨天,它会遇到三个具体问题:
- 对话历史是叙述,不是状态。历史是「按时间顺序排列的一句话」,它不告诉你哪些步骤已经完成、哪些副作用已经落地、哪些结论已经被后续证据推翻。从历史里重新推断状态,等于让一个可能出错的模型去执行本该由确定性代码完成的簿记工作。
- 历史会膨胀。几小时的工具调用可以轻易堆出几十万 token。存下来不难,但每次恢复都全量加载既慢又贵,而且把大量低价值内容(重试的报错、重复的目录列表)混进了模型视野。
- 历史无法表达并行。三个子任务同时跑、其中一个失败了,历史是一个线性序列,你没法从中看出「A 和 C 已经完成,B 还在跑」这个结构化事实。
所以正确的做法是引入一个独立的任务图结构:节点是任务单元,边是依赖与数据流,节点内部记录状态、输入摘要、产出引用、以及幂等相关的效果日志。对话历史降级为其中一类「节点内部的产物」,也就是某个具体步骤执行时的提示词与返回,但不再是恢复状态的唯一依据。
一个够用的任务图表示
下面是刻意做过裁剪的版本。真实系统里节点会携带更多字段,但下面这几个是恢复所必需的最小集:
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class NodeStatus(str, Enum):
"""节点状态机。恢复逻辑只允许从这里规定的几个终态往外走。"""
PENDING = "pending" # 未开始
RUNNING = "running" # 已认领,进程可能已经死了
BLOCKED = "blocked" # 依赖未满足或等待人工决策
SUCCEEDED = "succeeded" # 完成且副作用已确认落地
FAILED = "failed" # 失败,是否可续跑由 retryable 决定
ABANDONED = "abandoned" # 被外部判定放弃,保留用于审计
@dataclass
class Node:
"""任务图中的一个节点。
key 必须是稳定且可重算的,不能用随机 UUID——
否则重放同一步骤会生成一个新节点,幂等就无从谈起。
"""
key: str
kind: str # 节点类型:llm_call / tool / human / subgraph
status: NodeStatus = NodeStatus.PENDING
deps: list[str] = field(default_factory=list)
#产出与输入分离:产出存引用(比如对象存储路径),输入存摘要
input_digest: str | None = None # 输入指纹,用于检测「依赖变了但我没重跑」
output_ref: str | None = None # 产出落盘位置
#幂等三件套
effect_key: str | None = None # 本节点所有外部副作用的唯一标识
effect_log: list[str] = field(default_factory=list) # 已确认落地的副作用 id
compensations: list[str] = field(default_factory=list)# 补偿动作 id,按逆序执行
attempt: int = 0
retryable: bool = False # 失败后能否原地重试
resume_from: str | None = None # 断点续跑时指向的上一个成功节点 key
cost_used: float = 0.0 # 已消耗预算(token 折算)
wall_used: float = 0.0 # 已消耗墙钟时间(秒)
meta: dict[str, Any] = field(default_factory=dict)
@dataclass
class TaskGraph:
nodes: dict[str, Node] = field(default_factory=dict)
root: str | None = None
def add(self, node: Node) -> Node:
self.nodes[node.key] = node
return node
def ready(self) -> list[Node]:
"""返回所有依赖已满足、可立即执行的节点。
注意:RUNNING 状态不算 ready。进程重启后残留的 RUNNING
必须先被显式回收成 PENDING,否则既不会被执行也不会被监控。
"""
out = []
for n in self.nodes.values():
if n.status != NodeStatus.PENDING:
continue
if all(self.nodes[d].status == NodeStatus.SUCCEEDED for d in n.deps):
out.append(n)
return out
def reclaim_orphans(self, live_run_ids: set[str]) -> list[str]:
"""回收孤儿节点:状态是 RUNNING 但持有它的进程已经不在了。
这是「进程重启后状态还在,但活儿没人干」这一类问题的唯一解法。
返回被回收的节点 key 列表。
"""
reclaimed = []
for n in self.nodes.values():
if n.status != NodeStatus.RUNNING:
continue
if str(n.meta.get("run_id")) in live_run_ids:
continue
n.status = NodeStatus.PENDING
n.meta["reclaimed_from_run"] = n.meta.get("run_id")
n.meta["reclaim_count"] = n.meta.get("reclaim_count", 0) + 1
reclaimed.append(n.key)
return reclaimed
上面这段里有两个点值得单独强调,它们是很多实现会漏掉的。
第一,node key 必须可重算。很多团队用 UUID 做节点 id,于是「重放同一步骤」在数据层面就变成了「新增一个节点」,幂等无从谈起,状态机也会分裂成两条平行分支。正确的做法是用输入内容派生 key,比如 hash(节点类型 + 输入摘要 + 依赖输出摘要)。这样重放会命中同一个 key,恢复逻辑才敢做「跳过已完成的直接复用产出」。
第二,RUNNING 不是一个安全状态。它只意味着「某个进程在某个时刻声称自己在干这个活」。进程被 kill -9 之后,这个状态会永远卡在那里,成为一个既不失败也不完成的僵尸。恢复流程的第一步必须是按租约回收:每个 RUNNING 节点带一个 run_id 和心跳时间戳,新进程启动时把「心跳超时的 RUNNING」批量回收成 PENDING(或直接判定 FAILED,取决于该节点是否可重入)。
检查点粒度:按不可逆操作的密度来切
检查点粒度是这一节真正需要决策的地方。太粗,失败一次就白干几小时;太细,簿记开销和状态体积爆炸,还会把「可观测的进度」变成一堆噪音。常见的三种切法:
- 按对话轮次切:每 N 次模型调用存一次。实现最简单,代价是一次检查点之间的副作用全在内存里,进程死了就全部重来。
- 按步骤切:每个工具调用前后各存一次。恢复粒度最细,但状态写入量等于工具调用量,且很多步骤本身极廉价(比如读一个文件),为它们单独做检查点并不划算。
- 按副作用边界切:把连续的只读步骤合并成一段,只在「有不可逆外部写入」之前和之后各设一个检查点。
第三种是我在真实长时任务里唯一推荐的默认切法,理由是:只读步骤重放的代价接近零(多读一次文件、多调一次搜索,最多浪费一点 token),而有外部写入的步骤重放的代价可能不可估量。把检查点对齐到副作用边界,意味着恢复时只需要在「两个副作用之间」重放只读工作,代价可控;而一旦把检查点设在两个副作用中间,你面对的就是「我可能已经写了,但不知道写了没有」这个最难的问题。
具体落地时,可以给每个节点打一个风险标签:
| 节点类型 | 外部副作用 | 重放代价 | 建议检查点 |
|---|---|---|---|
| 读文件 / 列目录 / 查索引 | 无 | 仅 token | 可合并,段末存一次 |
| 调用只读 API(查询类) | 无 | token + 限流配额 | 可合并,注意限流 |
| 写本地文件(草稿、可覆盖) | 本地,可幂等覆盖 | 低 | 写后立即存 |
| 提交 git / 建 PR / 发消息 | 远端、可见、难撤回 | 高,且会打扰人 | 写前存意图,写后存确认 |
| 下单 / 付费 / 删除资源 | 不可逆 | 极高 | 强制人工确认节点 |
注意最后两行。对「提交 PR、发消息」这类动作,仅仅在写后存一次检查点是不够的——因为如果写之后、存之前进程死了,你恢复时会重放这个动作,然后仓库里就多了一个重复 PR。这类动作必须采用「先写意图,再执行,再确认」的三段式:
async def run_node_with_intent(graph: TaskGraph, node: Node, store) -> None:
"""带意图日志的副作用执行。
核心不变式:任何远端可见的副作用,在写入外部系统之前,
必须先在本地持久化一条「我打算做这件事」的记录。
"""
if node.effect_key and node.effect_key in node.effect_log:
#已经确认落地过,直接复用,不重放
node.output_ref = node.meta.get("output_ref_from_first_run")
node.status = NodeStatus.SUCCEEDED
await store.save(graph)
return
#第一段:写意图。必须在调用外部系统之前落盘。
intent = {
"effect_key": node.effect_key,
"node_key": node.key,
"payload_digest": node.input_digest,
"ts": now(),
}
await store.append_intent(node.key, intent)
#第二段:执行。副作用 ID 用 effect_key 派生,保证幂等键稳定。
ext_id = await external_api.create(
idempotency_key=node.effect_key, # 外部系统支持的话,这是最省事的一层
**node.meta["request"],
)
#第三段:写确认。哪怕这一步之前进程死了,
#恢复时也能靠 idempotency_key 或外部查询把这次操作认领回来。
node.effect_log.append(node.effect_key)
node.output_ref = ext_id
node.status = NodeStatus.SUCCEEDED
await store.save(graph)
这里的 effect_key 应该怎么构造,值得单独说。它必须满足两个条件:同一逻辑副作用在任何重试里都得到同一个 key,以及不同副作用绝不碰撞。实践中最稳的做法是把它做成「目标位置 + 动作类型 + 载荷内容摘要」的组合,例如 pr:repo-a/branch-x:create:<payload_hash>。如果同一个 PR 因为需求变更需要改两次内容,那它们的 key 天然不同,两次都会执行——这正是我们想要的行为。
断点恢复的幂等前提:重放一步到底会重复做什么
「让操作幂等」这句话在文档里随处可见,在代码里却很少能落到实处,因为大家默认了「重放一步就是再执行一遍同样的操作」——而真正的风险在于,重放的那一步往往不是一个原子操作,它由若干个子动作组成,其中一部分重放代价很低,另一部分重放代价极高。
更麻烦的是,重放的语义会随时间漂移。「重放『修复循环依赖』这一步」在两小时后可能已经不合理了:文件被别人改了、依赖版本升级了、上一步的结论建立在过期的目录快照上。所以断点恢复的正确姿势不是无脑重放,而是先判定上一个成功节点的产出是否仍然有效,再决定重放还是跳过。
三类幂等:不只是「重复执行同一个函数」
把一个节点的动作拆开看,通常会包含三类完全不同的副作用,处理方式也完全不同:
- 读类:天然幂等。重放的唯一代价是 token 和限流配额。可以放心重放,但要注意读到的内容可能已经变了——所以读的结果必须带着「读的时间戳」进入状态,否则恢复后你无法判断手里的数据有多旧。
- 本地写类:只要是「覆盖写同一路径」,就基本幂等。最危险的是「追加写」——同一个日志文件被恢复流程重放两次追加,就会出现重复行。这类操作要么改成覆盖写,要么在文件内容里带上 effect_key 并在读取时按 key 去重。
- 远端写类:发消息、建 PR、调用支付、触发 CI。这是真正需要外部系统配合的部分。
对第三类,幂等有三种实现强度,从弱到强:
- 依赖外部系统的幂等键。很多现代 API 支持传入 idempotency key,服务端保证相同 key 的重复请求只生效一次。这是最省心的做法,优先找这种。
- 事前查询去重。执行前先查一次外部系统,看这个副作用是否已经存在(按某个唯一标记,比如 PR 的标题加分支、消息的内容哈希)。脆弱但在没有幂等键支持时唯一可用的办法。
- 事后对账修复。允许重复执行,但配一个周期性对账任务,发现重复了就清理或标记。这个方案不能单独使用,它只能作为前两者的兜底——因为清理动作本身也有窗口期。
一个务实的判断:如果你的动作无法用任何形式表达「我打算做这件事」并且事后可查,那它就不该被自动化执行,而应该降级为人工确认节点。长时任务系统里,人工节点不是失败,混进不可观测的自动化才是。
这里有个容易被忽略的幂等陷阱:LLM 节点本身几乎不可能幂等。同一个节点重放两次,模型给出的文本可能不同(哪怕 temperature 为 0,模型服务端的实现也不保证位级一致)。所以 LLM 节点的幂等不靠「重放不产生不同结果」,而靠「只重放一次,然后把结果冻结」:一旦节点产出成功落盘,恢复时直接读盘,绝不重新调用模型。这一条如果没做对,一个恢复三次的任务会产生三份措辞不同、结论可能互相矛盾的报告,人根本没法判断该信哪份。
与之配套的还有「输入指纹」机制。给每个节点算一个 input_digest,由它的直接依赖的产出摘要共同派生。恢复时如果发现某个已 SUCCEEDED 的节点,其依赖的产出摘要和当初记录的不一致,说明上游内容变了——这时候你不能简单复用它的产出,而要标记为「失效」,并按失效传播规则把下游一并作废。这条链路是「用户中途改需求」能被正确处理的技术前提。
人在环路的中断与恢复:用户改需求后如何从中间继续
前面几节处理的是「机器意外死了」。这一节要处理的是「人主动叫停,然后提了一个不一样的需求」。这是长时任务系统里最容易做出坏体验的地方,因为很多实现把它简化成了「保存上下文 → 用户回来继续对话」,而这恰恰是错的。
先说为什么错。用户离开两小时后回来,说了一句「先别管那 20 个旧模块了,只处理我们上周新加的那批」。这句话改变了任务的目标集合,而不是改变了任务的措辞。如果把它当作一条新的 user message 追加到历史里,模型面对的是一个自相矛盾的上下文:前面几十步的推理都建立在「处理全部模块」这个前提上,而最新一条消息否定了它。模型通常不会报错,它会尝试调和,然后给出一个混合了两套假设的结果。
正确做法是把「需求变更」建模成任务图上的一个失效事件,让传播规则去决定哪些已完成的工作作废、哪些仍然有效。这需要你先做一件事:把节点按它所依赖的「任务前提」分组。
前提指纹:让作废范围可计算
每个节点在执行时,除了 input_digest,还应该记录一组前提标签(precondition tags)——它依赖哪些任务层面的假设。例如「筛选待处理模块」这个节点的前提标签可能是 {"scope": "all_modules"};一旦用户把 scope 改成 {"scope": "new_modules"},你不需要理解任何业务语义,只需要做一次标签集合的差集运算,就能算出哪些节点的产出失效了。
def invalidate_by_precondition(graph: TaskGraph, changed: dict[str, str]) -> list[str]:
"""前提变化后,算出必须作废的节点集合。
changed 形如 {"scope": "new_modules"},表示某个维度的
任务前提被改成了新值。只影响依赖该维度的节点。
"""
stale = []
for node in graph.nodes.values():
pre = node.meta.get("preconditions", {})
if any(pre.get(k) != v for k, v in changed.items()):
stale.append(node.key)
#连带:任何依赖了 stale 节点的下游,其产出也不可信了
for other in graph.nodes.values():
if node.key in other.deps and other.key not in stale:
stale.append(other.key)
return stale
这个函数的实际收益不在代码量,而在于它把「从中间继续」从一个模糊的产品问题变成了一个确定的数据问题。运行一次,你就知道:需要重跑 14 个节点、复用 9 个、其中 3 个必须先重新向用户确认参数。没有这个能力,产品侧只能给出「抱歉,需求变化太大,建议重新开始」这种答案——而这恰恰是用户最不能接受的。
暂停的语义要分清
很多系统只有一个「暂停」状态,结果无法表达不同意图。实际至少有四种,而且它们的恢复路径完全不同:
- 挂起(park):任务本身没问题,只是等一个外部信号,比如等 CI 结果、等用户回复。可以原地恢复,但等待期间不应继续消耗预算,也不该被停滞检测判定为卡死。
- 收缩(narrow):范围变小,比如只做新增模块。前面已完成的 9 个节点仍然有效,失效的只是被排除掉的部分。
- 回退一步(rewind):某个已完成节点被证明算错了,需要重算它和它的下游。这是恢复逻辑最常被触发的路径,也是最难写对的。
- 终止(abort):任务不再有意义。保留状态用于审计,但不再产生任何动作。
把这四种混成一个 paused 布尔值,短期看少写几个枚举,长期会付出很大代价:最难的情形恰恰是「收缩」,因为它需要保留已完成的产出并精确地作废一部分,而另三种都不需要这种精细操作。分类清晰之后,代码量其实是下降的。
判断暂停设计是否合理的经验法则:挂起状态下,超时监控和停滞检测必须同时被停用。否则你的系统会在用户午休的两小时里反复给一个正在等待的节点发告警,最后告警疲劳让真正的卡死变得无人注意。
重新确认的粒度
用户回来之后,Agent 应该主动复述「哪些保留、哪些作废、接下来跑什么」,并只就不可推断的分歧提问。判断标准很简单:如果新需求能由旧需求加上少量约束推导出来,就不要问;如果不能,就问,而且要一次问完。这条对体验的影响远大于模型能力的差异——用户抱怨的通常不是「结果错了」,而是「它明明可以直接问我,却自己猜了一个方向跑了二十分钟」。
成本封顶与时间预算:预算必须是强制的,不是提示
长时任务的成本有两个来源,性质完全不同,需要分开治理:Token 成本(随步数增长)和注意力成本(用户被迫等待和复核的时间)。后者往往比前者更痛,但它完全没有被度量过,于是很难进入优化视野。
关于预算,有一条经验值得单独强调:把预算写成 prompt 里的一句「请注意控制成本」几乎没有任何效果。原因也不难理解——模型是在事后看到自己已经花掉多少,而 prompt 里的约束是事前的意图,两者之间没有强制的连接。真正有效的是在调度层强制执行:每次要派发下一个节点之前,先问一句「再派发这个节点,预算够吗」。
三级预算闸门
实用的做法是设置三个闸门,对应三种不同的降级动作:
| 闸门 | 触发条件 | 降级动作 | 为什么有效 |
|---|---|---|---|
| 软阈值 | 已用 70% 预算或剩余时间低于总时长的 70% | 压缩上下文、裁剪历史、关闭低价值工具 | 省下的是「单价」,不影响已规划的工作量 |
| 硬阈值 | 已用 100% 预算 | 只允许执行已在白名单内的收尾节点 | 保证一定有个可交付的产物,而不是半途而废 |
| 紧急停止 | 预算或时间任一超限 100% | 立即冻结任务,输出当前最佳结果 + 未完成清单 | 避免「跑到一半被 kill」导致产出全丢 |
硬阈值里那个「收尾节点白名单」是让长时任务体验可控的关键。任务跑到 95% 被停掉,产出一份写了一半的文档,等于零。但如果在调度层就把「生成报告」「生成未完成清单」「通知用户」这几个节点预先标记为无论预算如何都允许执行,那么预算耗尽时你仍然能交付一份「已完成 18/24 项,以下 6 项因预算上限未执行,建议下一步」的成果。这是长时任务里性价比最高的一个设计细节。
时间预算比 Token 预算更需要主动管理
Token 消耗基本与步数成正比,是可以预测的;而墙钟时间经常被一些不可控因素拉长:限流退避、网络抖动、子任务意外地长尾。如果不区分这两者,一个「预算还剩 40% 但只剩 8 分钟」的任务就会在最后时刻仓促收尾,质量断崖式下跌。
class BudgetGovernor:
"""在派发节点前做准入判断,而不是在超限后才报错。"""
def __init__(self, token_cap: float, wall_cap: float, soft_ratio: float = 0.7):
self.token_cap = token_cap
self.wall_cap = wall_cap
self.soft_ratio = soft_ratio
def admit(self, node: Node, used_token: float, elapsed: float) -> str:
#白名单节点永远放行:收尾产出比预算纪律更重要
if node.meta.get("always_admit"):
return "admit"
token_left = self.token_cap - used_token
time_left = self.wall_cap - elapsed
#估算这一步的开销,留出 20% 余量给不确定性
need = max(node.meta.get("est_token", 0) * 1.2, 1)
if token_left < need:
return "deny_hard"
#剩余时间已经不够再跑一个常规节点
if time_left < node.meta.get("est_wall", 30) * 1.5:
return "deny_hard"
if token_left < self.token_cap * (1 - self.soft_ratio):
return "admit_degraded" # 允许,但要求压缩上下文
return "admit"
注意 est_token 和 est_wall 这两个估算值。它们不需要多精确——实践中用节点类型的历史均值就够了,甚至用同 kind 节点的中位数也能跑。关键是要有这个字段:一个没有预估的调度器只能在钱烧完之后才发现问题,而一个带粗略预估的调度器可以提前把剩余预算分配给最重要的几个节点。这种「预算感知的任务排序」本身就是一种质量提升,它让系统在资源紧张时优先做有价值的工作,而不是随机撞上顺序。
顺带一提,admit_degraded 这个返回值值得展开。压缩上下文不是简单截断历史——那恰恰是最容易丢掉关键约束的做法。正确的压缩是保留:任务的核心约束、前几步的关键结论、当前所处的阶段、以及尚未解决的问题,把中间的搜索噪声丢掉。判断某段历史是否是「噪声」的标准是它是否还会影响后续决策,而不是「它是否很长」。
长时任务的进度可观测性:让人愿意一直挂着它
一个跑四小时的任务,如果中间四小时完全黑箱,没人会真的信任它。这不是心理问题而是工程问题:用户需要在任意时刻用一句话回答「它在干什么、还剩多少、值不值得继续等」,做不到这一点的系统,用户唯一的理性选择就是每隔五分钟刷新一次,然后放弃。
心跳要携带信息,不能只有时间戳
最朴素的做法是每个节点更新一个心跳时间戳,这只能回答「活着吗」,回答不了「在干什么」。有用的心跳至少要包含三样东西:当前正在执行的节点 key、这一节点内已完成的工作单元数与总数、以及本次运行的累计成本。这三样数据合起来,才能支撑一个简单的剩余量估算。
估算剩余时间时有个关键细节:不要用平均值,要用分位数。四小时的任务里,前几个节点通常很快(读几个文件就完了),后面节点越来越慢(要跑测试、要等 CI)。用平均耗时线性外推会系统性低估剩余时间,而这会直接导致预算调度在最后阶段仓促收尾。用 P80 或 P90 估算,虽然偏保守,但用户看到的是一个略微悲观的数字,通常比乐观后被打脸要好得多。
更重要的是要诚实表达不确定性。「预计还需 40 分钟,置信度低」比一个精确到分钟的假数字更有用。这个领域有个反复出现的教训:给人一个假的精确数字,人会按那个数字来安排等待;等他发现不准,对整个系统的信任就一起赔进去了。
停滞检测:新信息量是比耗时更好的信号
超时能发现「卡住」,发现不了「空转」。一个 Agent 反复用同样的参数调用同样的搜索接口、反复打开同一个文件、反复输出几乎一样的思考,每次调用都在两秒内成功返回——从超时角度看一切正常,但它实际上已经浪费了二十分钟。
有效的检测方式是统计新信息量:把最近若干步的工具输出做归一化和去重,看新增的独特内容有多少。这个指标下降到接近零,就说明 Agent 正在原地打转。此时不该只是杀掉了事,而应该做一件更有用的事:把最近的重复内容和当前目标重新塞回上下文,明确告诉它「你已经尝试过这些方法且没有进展,请换一条路径」。相当一部分看似卡死的任务,其实是模型缺少「我试过了什么」的意识。
让状态本身成为用户界面
长时任务的可观测性有个很好的性质:它的状态是结构化且持久化的,所以可以直接渲染成用户界面,不需要额外埋点。这和「跑一个黑箱进程」有本质区别——你可以在任意时刻生成一份快照:已完成哪些、正在做哪些、哪些作废了、哪些在等人工、预算消耗如何、关键结论有哪些。
这个快照同时也是恢复机制本身。如果用户能在界面上点一下某个已完成节点说「这个结论不对」,系统就知道该从哪里回退、从哪里续跑,而不需要用户手写「回到第 47 步重做」。可观测性和可恢复性在这里不是两个功能,而是同一个状态结构的两个读取方向——这是设计长时任务运行时最值得记住的一个结构性结论。
轨迹要按可读性组织,而不是按完整性
最后一层是可追溯性。长时任务的日志如果只是原始事件流,人是读不动的;但如果只保留摘要,就会丢掉排查问题所需的细节。实用的折中是分两层:决策层记录「为什么选了这个动作、放弃了哪些备选、依据是什么」,这一层是给人看的,需要用自然语言组织;执行层记录原始的请求与响应、完整的工具入参出参、精确的时间戳,这一层是给机器和事后排障用的,可以任意冗长。
决策层是长时任务特有的一层,短任务不需要——因为短任务里开发者可以直接读全部过程,而四小时任务的全部过程没人读得完。这一层的价值在事后复盘时最明显:当 Agent 给出了错误结论,你能区分它是「当时依据不足」还是「依据充分但推理错了」。前者要改进信息获取,后者要改进推理,这两种改进方向完全不同,混在一起就无从下手。
回滚还是续跑:给失败做一个诚实的分类
最后一个问题,也是最难做决定的一个:任务失败之后,到底应该从头重跑,还是从失败点续跑?现实中很多系统的答案是「看情况」,而代码里往往是「统一从最后一个检查点重跑」,这个默认在长时任务里通常既不安全也不经济。
按「哪些东西还能相信」来分类
分类的依据不该是失败的类型(超时、报错、断言失败),而该是失效的范围:
- 纯计算失败,且无外部写入:直接重试。代价只有 token,甚至可以提高温度增加多样性。
- 外部写入已发生,但写入本身是幂等的:直接重试。重复执行不影响结果,只是浪费一点时间。
- 外部写入已发生,且不可撤回:不要重试。需要人工判断——检查实际发生了什么,再决定补偿还是接受。这正是前面
effect_log和意图日志存在的理由,没有它们你连「到底写没写」都回答不了。 - 结论性失败:Agent 产出的中间结论被证明是错的(测试挂了、后续步骤发现前提不成立)。这不是重试能解决的,重试只会用同样的思路再错一遍。需要的是回退到分歧点重新分叉,并且在上下文中明确标注「上一条路径已被否决,失败原因是 X」。
- 规格失败:任务本身定义有问题,或者用户中途改了需求。这类失败的重试是纯粹的资源浪费,正确动作是回到人。
第五类最容易被误判成第一类。一个跑了两小时最后交付了错误格式文档的任务,系统给出的诊断往往是「输出解析失败,重试中」——于是一个根本不会因为重试而变好的任务又烧了两小时。重试的正确前提是「失败原因具有随机性,且重试能改变结果的分布」;如果失败是确定性的,重试的期望收益是零,期望代价是全额预算。
补偿事务:回滚在 Agent 场景为什么不简单
数据库里有成熟的事务模型可以借鉴,但 Agent 场景的外部世界没有事务。你的 Agent 提交了三个 PR、发了两条消息、写了一个配置文件,这些动作散落在你控制不了的地方。
可行的替代方案是补偿而非回滚:为每个不可逆动作预先登记一个补偿动作(关掉 PR、撤回消息、删除分支),并按逆序执行。这有一个残酷的前提:你必须在一开始就想到要补偿什么。如果任务跑了两小时才决定放弃,而前面某个动作压根没登记补偿,那就只能人工收拾了。
推论很直接:不可逆动作的密度决定了长时任务的风险上限。一个跑四小时、全程只读、最后产出一份文档的任务,恢复和回滚都很容易;一个跑四小时、中途提交了二十个变更的任务,你在第四小时就其实已经失去了完整的回滚能力,只能往前走。这就是为什么把「生成 PR」「发消息」这类动作放到任务末尾集中执行,本身就是一种风险控制,而不只是流程上的方便。
续跑的前置检查清单
如果一个任务要支持续跑,下面这几条必须在设计时就确定,事后补是补不上的:
- 每个节点的 key 可由输入重算,而不是随机生成。
- 每个有外部副作用的节点,都有稳定且可查的幂等标识。
- LLM 节点的产出成功后立即冻结,恢复时读盘而不重算。
- RUNNING 状态带租约与心跳,进程死亡后能被自动回收。
- 依赖产出带摘要,需求变化时能算出失效范围。
- 暂停、收缩、回退、终止四种意图分开建模。
- 预算在调度层强制执行,且有收尾节点白名单。
- 不可逆动作的顺序被刻意安排在任务末尾,且登记了补偿动作。
这八条里没有一条是关于模型的。它们全部是关于状态与生命周期的,而这恰好说明了一件事:让 Agent 跑长时任务,瓶颈从来不在模型能思考多久,而在于系统有没有能力把「正在思考」这件事安全地存放在时间之外。
并发分支与限流:调度器才是真正的瓶颈
任务图一旦支持并行,一个常被低估的问题就会浮出水面:整个任务的吞吐往往不由模型决定,而由外部服务的限流窗口决定。一个看起来只有二十四步的任务,如果其中十八步都要打同一个代码搜索接口,而那个接口的配额是每分钟十次,那么无论你的调度器多聪明,这个任务的下限就是两分钟。
这带来的判断很直接:在设计任务图时就应该识别出共享的外部配额,把配额当成一等公民的资源来调度,而不是让各个分支各自去撞墙然后各自退避重试。各自退避的坏处在于每个分支都在重复消耗自己的等待时间,而且它们很可能会同步重试,从而把本来平滑的流量变成尖峰,进一步加剧限流。
关于「什么时候该并行、什么时候该串行」,有一个务实的判断标准:看分支之间是否存在写冲突。纯读的分支几乎总是应该并行,它们之间没有依赖关系,唯一的约束是外部配额。而只要涉及写,就必须先回答一个问题:两个分支是否可能改到同一个东西。如果可能,就不能并行;如果不可能,就放心并行,并把这个判断的结论显式记录在任务图里,而不是留给运行时去动态检测。
显式记录这一点很重要,它同时也是恢复的前提。假设两个分支因为资源不足被串行化了,那么恢复后如果并发条件变了(比如限流窗口放宽了),你是希望保留原来的串行决定,还是重新评估?保留原决定更安全也更可预测;重新评估更激进但可能改变产出顺序,进而改变结果。默认应当是保留原决定,并且在状态里标注「此串行由配额策略决定」,这样用户看到进度时可以理解为什么慢,而不是以为系统出了 bug。
另外还有一类分支冲突不来自外部配额,而来自共享的模型上下文。很多长时任务的设计是「多个专家 Agent 依次对同一份材料发表看法,最后汇总」。这类任务看起来天然可以并行,但存在一个隐藏代价:并行的分支看不到彼此的结论,可能同时做出互相矛盾的分析,最后的汇总阶段要花大量成本去调和冲突。如果这些结论之间存在收敛性质——也就是后面的分析理应修正前面的判断——那么串行反而更好。把这类任务误判为「无依赖、可并行」,是长时任务里一种很隐蔽的浪费。
重试退避的确定性
最后说一个容易被忽略的细节:退避策略必须是确定的。很多实现用了带随机抖动的退避,这在单请求场景是对的,但在长时任务里会破坏可恢复性——因为「重试时间点」本身变成了状态的一部分,而这个状态在进程重启后丢失了。结果就是恢复后的重试时间与原本的节奏对不上,可能在刚退避完的瞬间又发起请求,把刚刚缓解的限流重新打回去。正确的做法是把「下一次可重试时间」作为一个显式字段持久化,重启后照此执行。
这也解释了为什么长时任务不能用「无状态重试」那套简单模型:一旦引入跨进程的等待,等待的意图本身就需要被保存下来,否则新进程根本不知道有一个退避窗口正在生效。
状态存储本身也会成为瓶颈
把所有状态放进一个状态机设计里之后,很容易忽略一个工程现实:存状态的代价最终会超过执行状态的代价。一个有几百个节点的任务,每执行一步都要写一次完整快照,随着状态增大,单次写入可能变得相当可观;如果再叠加检查点粒度很细的情况,写放大足以让存储层成为瓶颈。
三种常见做法各有取舍。全量快照实现最简单,恢复时一次读取即可,但状态大之后写放大严重。事件溯源只记录状态变化的增量,写入便宜,且天然带有完整的操作历史,但恢复时需要重放整个事件流,状态很大时恢复本身就会变慢。周期快照 + 增量日志是前两者的折中,恢复时只需读取最近一次快照加上之后的增量,兼顾了两边的优点,代价是实现复杂度上升,也需要考虑快照本身的版本兼容。
如果只给一个建议:把「恢复所需的最小状态」和「审计所需的历史记录」分开存放。前者要小、要能快速整体读取,做成快照;后者可以大、可以追加,写成事件流。这两者混在一起是最常见的设计错误,因为审计信息通常比恢复信息大一两个数量级,把它们捆在一起会让恢复读取一直很慢。
Schema 演进:状态机活得更久
长时任务的另一个特点是长寿。一个任务挂起三天之后再恢复,期间你的代码可能已经发了好几个版本。如果状态结构没有版本标记,恢复时遇到一个不认识的字段组合,轻则报错,重则用错误的方式解释旧状态,然后悄悄产出错误结果。
最低限度的做法有三个:状态对象带 schema_version;读取时经过一个显式的迁移函数,把旧版本抬升到当前版本;迁移失败时拒绝恢复并告警,而不是带着未知的字段继续跑。第三点尤其重要——「诚实失败」远好于「带着歧义继续执行」,因为后者产生的问题在事后几乎无法定位。
这也解释了一个设计取向:状态里的字段应该是语义稳定的,而不是实现耦合的。比如把「检查点」记录成「已完成到第 N 个节点」,就比记录成「已保存了工作区快照 #7,包含以下 12 个文件的哈希」要稳定得多——后者每换一个压缩策略就全部作废。
上下文压缩:长任务里最贵的不是推理,是遗忘
短任务里上下文管理不是问题,因为整个会话从头到尾都装得下。长时任务里它是第一等问题。四小时的任务可以产生几十万 token 的工具输出,其中绝大部分是用过就不再需要的:读过的文件全文、试过又失败的查询、已经被后续结论推翻的中间假设。真正需要在第三个小时仍然可见的东西,可能只有几十行。
压缩(compaction)就是这件事的名字,但很多实现把它做成了一个纯粹的技术动作:超限了就按某种规则截断或摘要。这在两小时的会话里够用,在跨天的任务里会制造一类非常难查的故障——任务没有报错,只是慢慢变成了另一个任务。
压缩的单位应该是工作单元,不是 token
按 token 阈值触发的压缩有一个隐藏缺陷:它切在了步骤的中间。一次工具调用可能返回了三万 token 的搜索结果,压缩恰好在读完这一半时发生,于是模型看到的是一段没有结论的输出。更糟的是,如果这次调用还没被消费就被压掉了,重放时它可能返回不同的内容,于是整个下游推理建立在一个已经变化的证据上。
正确的切法是切在语义边界上:一个工作单元(读了哪些文件、得出了哪个结论、哪些被排除)是压缩的最小粒度。只有当一个单元已经完整结束、结论已经写入状态之后,才允许把它的过程细节丢掉。这也是为什么压缩应该由任务图的状态来触发,而不是由上下文长度来触发。
压缩之后必须保留的东西只有四类:任务的核心约束、前几步的关键结论、当前所处的阶段、以及尚未解决的问题。中间过程是可以丢的,但有一个例外值得强调——
失败的尝试是压缩时最容易被丢掉、也最不该丢掉的东西。「我试过按文件名匹配,没找到」这条信息在几小时之后依然有价值,丢掉它,Agent 会用同样的方式再试一遍。
约束的静默丢失才是真正的代价
压缩最隐蔽的伤害是约束的丢失,而不是事实的丢失。用户开头说的「只改测试文件,不要动生产代码」这句话,可能在第一次压缩时被摘要成「用户要求修改若干模块」,第二次压缩后变成「用户已确认范围」,第三次之后彻底消失。此后 Agent 每一步都在正确地做事,只是做的事情已经不完整符合需求了,而且没有任何信号表明这一点。
防护办法是把约束提升为独立于对话的持久对象:它在任务开始时被抽取成结构化条目(范围、禁止事项、验收标准、必须保持的接口),单独存储、单独展示、每次压缩后原样回填,而不是指望摘要能保住它。这条做法的成本很低,收益是整个任务的需求一致性从「大概还在」变成「确定还在」。
记忆外化:让任务的记忆落盘,而不是塞满窗口
上下文压缩解决的是「装不下」,记忆外化(externalized memory)解决的是「装下了但找不到」。两者的失败模式完全不同:前者会让任务逐渐遗忘,后者会让任务在几十万个 token 里反复找不到两小时前找到过的那条线索,于是重新去搜一遍,然后忘掉,又搜一遍。
长时任务的记忆不应该被当成「一段需要被记住的文本」,而应该被当成一个可以查询的数据集。判据很简单:如果你的记忆需要靠模型「记得住」来生效,那它在四小时的任务里一定不成立。
三层记忆与各自的写入门槛
实践中把记忆分成三层,每层的写入门槛和生命周期都不同,这个分层是外化设计里最值得先想清楚的部分:
- 工作记忆:当前正在处理的上下文,随压缩丢弃。对应「我正在看这个文件」。写入门槛为零,因为它是默认路径。
- 情节记忆:做过的动作和它们的结论——读过什么、发现了什么、试过什么、为什么放弃。这层必须有写入门槛:只有当某个动作产生了可复用的结论时才写入,否则一个跑四小时的任务会写入上万条「我读了 file_00123.cs 的第 40 行」这种毫无检索价值的内容,把真正重要的结论淹没掉。
- 语义记忆:跨任务复用的知识,比如「这个仓库的测试必须先跑 build」。这层需要更高的门槛——它要经过验证,并且在任务结束时由人确认。未经确认就写入语义记忆,是错误被固化的最短路径。
把这三层混在一起是常见错误。它们的失效后果完全不同:工作记忆失效任务会变笨,情节记忆失效任务会重复劳动,语义记忆失效会让下一个任务从错误的地方开始。最后一层的污染是不可逆的,因为任务本身已经结束,没有人会去检查它。
写入门槛:不是每条信息都值得成为记忆
给情节记忆设门槛时,一个能直接用的判据是可复用性测试:假设这个动作明天不会再被重做,如果新的任务开始时知道这条信息,它会做出不同的选择吗?如果会,就写;如果不会(比如「读了某个文件,发现它是空的」),就不要写,因为这类记录几乎总是纯噪声。
反过来,还有一类信息必须无条件写入,不管它看起来多琐碎:失败的尝试及其原因。这是外化记忆里价值最高也最容易被丢掉的一类。Agent 在一个目录里翻了四十分钟才确认「那里没有东西」,这条信息如果存在,下一次就不用再翻四十分钟。
记忆的检索比记忆的写入更容易被忽略
很多系统认真设计了写入,却在检索上留了短板:把所有记忆都塞回上下文,然后指望模型自己注意到相关的那几条。这是把检索问题退化成注意力问题,在记忆量超过几千条之后必然失效。
可行的做法是把检索做成由任务状态驱动而不是由模型发起的:调度器知道下一个节点是什么,就知道它大概需要哪一类记忆,于是在派发前把相关的几条检索出来附在节点输入里。这个方向反转很重要——不是「Agent 想起来去查」,而是「系统知道它该知道什么」。后者在长时任务里可靠得多。
记忆还需要带时效。一个小时前从配置文件读到的值,半小时后可能已经失效,尤其是当任务期间有别人也在改这个仓库时。给每条记忆打上产生时间和来源(哪个文件、哪次命令的输出),检索时才能判断该不该采信。这一条看起来琐碎,但它决定了长时任务的结论是「基于当前世界」还是「基于一小时前的世界快照」。
任务分解与重规划:计划是估计值,不是承诺
任务分解在短任务里是一次性的决策,在长时任务里它是一份需要被反复修订的估计。差别在于:短任务的分解通常在三十分钟内被证明对或错,长任务的分解会被使用几十次,并在使用过程中不断被环境反驳。
把它当成承诺来做的系统,会在第几十分钟遇到「计划已经和现实不符,但系统还在努力完成计划」的困境。这是最典型的长时任务失败形态之一:Agent 忙着完成一个已经不成立的计划,所有努力都用在了一个错误的目标上。
分解的粒度:按可验证性切,不按重要性切
常见的分解启发式是按重要性切——先把最关键的部分做掉。这是短任务的正确策略,因为短任务几乎总能做完。但对长时任务,按重要性切会犯一个错:最重要的工作往往最不确定,也最可能失败,于是它被放在最前面,被最充分地踩坑,而后面那些确定性高的工作一点都没做,最终交付为零。
长时任务更合适的切法是按可验证性切:让每个节点都有明确的、通过某种手段可以判定「做到了」的信号。读代码、跑测试、调用接口得到结构化返回,这些都有明确信号;而「分析架构」「评估合理性」这类没有判定标准的节点,应该被放在最后,或者被拆到能被验证的粒度。
理由是资源分配:长时任务一定会被打断(预算耗尽、用户暂停、环境出错),所以「被打断时留下什么」是核心设计问题。按可验证性切的分解保证任何一个前缀都是有价值的交付物——完成了 12 个节点,那 12 个节点的结论就是成立的,不依赖剩下 12 个。
重规划:触发条件比算法重要
重规划(replanning)有两个容易做反的地方。一是把它当成一个高频动作:每几步重新规划一次。结果是计划不断震荡,前几步的结论还没被验证就被推翻,任务变成原地摇摆。
二是让它只在失败后触发。而长时任务里最需要重规划的时刻往往没有失败——只是发现原计划里某个假设已经不成立。比如「修复 20 个模块的循环依赖」这个任务,做到第 15 个时发现剩下的 5 个其实是同一个根因引起的,那么继续一个一个修就是纯粹的浪费。
实用的触发条件有三类,建议显式定义而不是靠模型临场判断:
- 证据冲突:新观察与已有结论矛盾。注意这需要在任务状态里显式记录「已有结论」,否则无法检测冲突。
- 模式识别:连续几个节点出现同构的结果(比如同一类错误、同一个根因的多种表现)。这通常意味着分解方式错了,而不是执行错了。
- 边际收益下降:最近 N 个节点的新增价值低于某个阈值,说明任务已经进入收益递减区,应该重新定义「做到什么程度算完成」。
重规划时最容易犯的错误是把「重新想一遍要做什么」当成重规划。实际上重规划至少要保留三样东西:已完成且仍然有效的结论、被证伪的假设(必须在上下文里明确标记为已否决),以及仍然成立的核心约束。丢掉前两样,就是从零开始,浪费掉前面几小时。
计划版本化:让重规划本身可回溯
如果重规划只是覆盖掉旧计划,你就无法回答一个长时任务里迟早会被问到的问题——「为什么第二小时的做法和第五小时不一样」。而这个问题在任务失败复盘时价值极高:它能区分「系统随机地换了做法」和「系统因为新证据而合理地换了做法」。
做法很轻:每次重规划产生一个新的计划版本,记录它相对上一版改了什么(新增节点、删除节点、顺序调整、目标收窄),而不是只存新计划本身。成本几乎为零,收益是让整个任务变成一条可以审计的决策链。这和前面说的「决策层与执行层分离」是同一个思路在规划层的应用。
最后一个容易被忽略的细节:已经发出去的子任务不会因为计划改变而自动撤销。所以重规划必须显式处理在途节点——是等它们跑完、还是取消、还是让它们跑完但丢弃结果。没有这个处理,计划版本更新和实际执行之间会出现一道缝,而缝里的产物往往是错的。
跑偏的两种形态:目标漂移与自我循环
长时任务里 Agent 走偏的方式很有限,主要是两种,而且两者的检测信号、修复手段完全不同。混为一谈是常见的误诊来源——很多团队看到「任务做歪了」就归因于模型不行,但实际上一半的案例是循环,一半是漂移。
目标漂移:每一步都合理,合起来不是用户要的
漂移(drift)的特征是单步看都正确。它不是从某一步开始出错,而是决策路径本身逐渐偏离了原始目标。最常见的机制有三个:
- 约束逐帧腐蚀:上下文被压缩时约束被摘要掉,前面说过。每一次压缩都损失一点精度,积累到第几十次之后,任务已经不在原来的需求里了,但没有任何一步看起来是错的。
- 就近锚定:模型倾向于把新决策挂在最近看到的对象上,而不是挂在原始目标上。用户说「找出性能问题」,前几步在看某个接口,看到一个明显的慢查询,于是后续所有工作都围绕这一个查询展开——而用户真正关心的是整体性能画像。
- 子目标蠕变:中间过程产生的小目标(「把这个函数拆开」)逐渐获得了和主目标同等的地位,甚至取代了主目标。
检测漂移的关键在于不要检测行为,要检测目标。行为永远看起来是合理的——在读书、在搜索、在改文件。有效的方法是周期性重新评估:把当前计划、已完成节点列表、以及原始需求放在一起,让模型(或人)回答一句「按现在的计划跑完,能满足最初的需求吗」。
这个检查的间隔要刻意设得稀疏,比如每完成十几个节点做一次。理由有两个:一是它本身要消耗一次模型调用,密集执行不划算;二是漂移是累积的,密集检查只能发现刚刚发生的漂移,而稀疏检查有机会发现「已经在错误方向上走了很远」这种只有对照原始需求才能发现的情况。
一个很实用的判定:如果任务连续完成了很多节点,但没有任何一个节点可以被描述为「这直接对应用户需求里的某一条」,那基本可以确认漂移了。高效不等于对齐。
自我循环:重复消费同一个失败
循环(loop)比漂移容易被发现,因为它有明显的重复特征:同样的工具、同样的参数、同样的错误信息反复出现。它通常不是随机发生的,而是有明确的成因。
最有欺骗性的一种是通过变化伪装成推进的循环:每次换一个搜索关键词、换一个文件路径、换一种表述方式问同一个问题。表面看每一步都不一样,实质上什么都没学到。这类的检测只靠比较调用参数是不够的,必须比较语义。
有效的判据是前面提到过的新信息量:把工具输出做归一化和去重后统计新增内容。如果连续多步的新信息量接近零,那么无论参数怎么变化,它都在消费同一个失败。
修复手段值得强调,因为大多数人第一反应是「增加最大步数」,这几乎总是错的。增加步数只是让同一个失败被消费更多次。真正有效的修复按优先级是:
- 告知已试过的路径:把重复的内容和当前目标重新塞回上下文,明确说明这些方法已尝试且无进展。这能解决相当一部分「模型不知道自己试过什么」导致的循环。
- 切换工具或切换信息源:不是换个查询词,而是换一类信息源——从读代码换成跑测试,从搜索换成看运行时数据。
- 升级为人工节点:如果同一个失败已经消耗了显著预算且没有进展,正确动作是停下来问人,而不是继续消耗。
- 放弃当前路径并分叉:把这条路径标记为已否决,在上下文中保留否决原因,然后换一条路。
这四种手段的共同点是:都改变了下一步会做什么,而不是只增加可以做的步数。这个区分是循环处理里最有价值的一个判断。
两者的共同前置:失败路径必须被记住
漂移和循环之所以在长时任务里特别顽固,根本原因在前面反复出现过的那个点上:否决过的路径没有留下痕迹。压缩时被丢掉的是搜索过程,保留下来的通常是成功结论,于是模型在两小时后完全不知道有一条路已经走过并且走不通了。
所以无论你做了多少循环检测和漂移检查,如果「已否决的路径及其原因」没有作为一等状态持久化,这些检测最终都会失效——它们只是在推迟失败,而不是避免失败。这条结论适用于任何跑了几小时的 Agent,不需要额外论证。
断点续跑的真实语义:你以为在恢复,其实可能在重放
前面几节讲了检查点粒度、幂等键、失效传播,这一节要处理一个更根本的问题:恢复动作本身到底做了什么。很多系统把「从检查点恢复」实现成「重新执行剩下的步骤」,而这两句话在长时任务里差别巨大。
重新执行剩下的步骤,隐含了一个假设:已经完成的步骤可以被安全跳过。这个假设成立的前提是「世界没变」。而在几小时尺度上,世界变才是常态。
恢复时先做核对,而不是直接继续
正确的恢复流程,第一步不是执行任何节点,而是验证已完成节点的产出是否仍然成立。这个核对分三层,成本递增,可以按需做到第几层:
- 存在性核对:产出还在不在(文件没被删、远端对象还在)。最便宜,但基本只能排除最粗的问题。
- 指纹核对:产出的内容摘要和当初记录的一致吗?它依赖的输入呢?这一层能发现「我基于某个文件做的结论,那个文件已经变了」。
- 语义核对:外部世界的前提是否还成立。比如「CI 是绿的」这个结论,需要重新查一次 CI 状态,因为几小时前的绿灯现在可能已经红了。
核对的结论必须驱动失效传播,而不是仅仅触发一次重跑。这就是前面 input_digest 机制在恢复场景下的真正作用——它把「要不要重做」从一个判断题变成了一个可计算的问题。
恢复的三种模式与它们各自的代价
工程上需要区分三种恢复模式,它们的实现复杂度和正确性要求差别很大:
| 恢复模式 | 做什么 | 适用条件 | 主要风险 |
|---|---|---|---|
| 信任恢复 | 直接跳过所有已完成节点 | 任务纯只读,且耗时很短 | 世界已变时产出全部过时,且无迹可查 |
| 指纹恢复 | 核对摘要,失效的下游重跑 | 默认选择,多数长时任务适用 | 摘要是语法层的,看不出语义层的失效 |
| 语义恢复 | 重新验证外部前提,必要时重新分叉 | 依赖 CI、部署状态、协作方产出 | 成本高,且需要一个「谁来验证」的答案 |
大多数团队直接从第一档跳到第三档,或者反过来——长期停在第一档,出问题时才临时加核对。后者更危险,因为核对逻辑没有在正常路径上被测试过。
「Exactly-Once」是幻觉,At-Least-Once 加幂等才是现实
长时任务的执行语义本质上只能做到至少一次执行(at-least-once execution):一个节点可能被执行多次,而且你无法确定到底执行了几次。分布式系统的经验在这里完全适用——不要试图追求恰好一次的执行,而是让重复执行变得无害。
具体到实现上,这意味着三件事,缺一不可:
- 每个节点有稳定可重算的 key,重放不会产生新节点。
- 每个有外部副作用的节点有幂等标识,且这个标识在副作用发生之前就已经写下。
- 恢复时先认领已有的副作用记录,再决定是否执行。
这三条里最容易漏掉的是第二条的时序。「执行成功之后再写幂等记录」看起来是自然的顺序,但它留下了一个窗口:执行成功、记录未写、进程死掉,恢复时无据可查,于是重复执行。凡是有人问「这个窗口能不能靠日志解决」,答案都是不能——因为缺失的正是那条日志。
恢复风暴:一次重启引发多次重放
最后一个容易被忽略的风险:恢复动作本身可以被放大。如果一个进程启动时把所有 RUNNING 节点回收成 PENDING,而调度器同时拉起了多个执行者,那么多个执行者会同时认领同一批节点。在没有租约机制的系统里,这会导致同一节点被执行多次,每次都可能产生外部副作用。
防护手段是标准的租约(lease):节点在被认领时附带一个带过期时间的租约,执行者定期续租,执行前检查租约是否仍然有效。恢复时只回收租约已过期的节点,而不是回收全部 RUNNING 节点。
这个细节看起来像基础设施层面的琐事,但它决定了「断点续跑」这个特性在多执行者环境下是否安全。而多执行者几乎是从单机走向生产环境时必然发生的事——它带来的弹性、长时任务的必要性和成本控制直接相关。
成本控制:把账单变成调度信号
前面讲过三级预算闸门,那是防守。这一节讲进攻——如何让每一块钱产出更多价值。防守型预算控制的本质是「快要超了就停下来」,而长任务真正需要的是「在预算内把最重要的事做完」。
给每个节点算成本密度
成本密度的定义很简单:这个节点消耗的预算,除以它推进的任务进度。进度可以用已完成节点数、已覆盖的工作单元数、或者任务图里剩余节点的估计值来近似。
有了这个指标,调度就可以做一件短任务里没必要做的事:在还有预算的时候主动做取舍。具体来说,当发现某个分支的成本密度持续偏高时,早停它并把预算分配给其他分支,通常比让它跑完更划算。
def should_continue(node: Node, used_token: float,
progress_delta: float, density_limit: float) -> str:
"""成本密度闸门:在节点执行中途中决定是否继续。
progress_delta 是这个节点在一段时间内推进的进度估计。
密度持续偏高意味着这个节点正在吞噬预算却没在交付。
"""
if progress_delta <= 0:
#零推进:不一定是失败(可能正在跑一个长耗时工具),
#但绝不能在这种状态下继续派发新节点
return "hold"
density = node.cost_used / progress_delta
if density > density_limit * 3:
#严重偏高:立即中止,保留已获得的产出
return "abort"
if density > density_limit:
#偏高:允许完成当前节点,但不再派发它的后继
return "finish_no_successor"
return "continue"
这段逻辑的关键在于把「中止」和「不派发后继」分成两个动作。很多实现在成本超限时直接把整个分支杀掉,结果连已经跑完的那部分产出都标成失败,那些产出其实是有价值的。保留产出、放弃后续,是更符合成本逻辑的处理。
成本不是只有 token
长时任务里有一类成本经常被完全忽略:等待成本。一个跑四小时的任务,用户在这四小时里不能做别的事,即使任务本身只需要十分钟的实际计算。这个成本不进账单,但它是长时任务最主要的用户成本。
这个观察有一个直接的工程含义:把不确定性从等待时间里挪出去是比压缩 token 更有效的成本优化。具体做法包括:能提前并行的一律提前并行(只读的分支)、能在任务开始时一次性收集齐的信息不要在中途多次问、把需要人确认的节点尽可能排在前面而不是后面(早失败早暴露,而不是跑了三小时才来问)。
最后这一条经常被反过来做。很多系统的流程是「先跑完能跑的,最后统一找用户确认」,理由是「问太多次会打扰用户」。但这在长时任务里是危险的:如果需要用户确认的参数在第三小时才被问到,而用户给出一个不同的答案,前两小时的工作大半作废。把确认点提前,代价是多打扰用户几次,收益是避免整段工作白做,这笔账在长时任务里通常划算。
缓存与复用:长任务里的成本大头
长时任务有一个短任务没有的成本结构特征:大量重复的上下文。任务约束、代码库的基本结构、已经验证过的中间结论,在几十次调用里被反复重新发送。这类重复在按 token 计费时是实打实的成本。
缓解手段的层次很分明:提示词缓存解决的是「同样的前缀被反复计费」,前提是你把稳定的部分放在前面、不稳定的部分放在后面,并且不在中途插入变化的内容;减少重复内容解决的是「本来就传了太多」,做法是把长引用替换成引用标识,需要时再取;任务级复用解决的是「同一个节点在多次运行里做了同样的事」,做法是把确定性部分从模型调用里拿出来变成代码。
第三种收益最大也最常被忽略。长时任务里有相当一部分工作其实是确定性的——按规则匹配、格式转换、计算指标。把这部分从模型手里拿走,既省钱,又消除了这类步骤的失败模式。判断标准很简单:如果你能写出一个函数来完成它,那它就不该占用模型调用。
人类介入的检查点设计:什么时候该停,停多久,问什么
前面把暂停意图分成了挂起、收缩、回退、终止四种,那是语义层。这一节讲一个更实际的问题:人工检查点应该放在哪里,以及每个检查点应该向人要什么。
一个常见的设计错误是把人工检查点当成「错误处理的兜底」——只在任务失败时才升级给人。这个设计在短时任务里勉强可行,在长时任务里是反的:长时任务失败的代价极高,而等到失败才发现问题,往往意味着几小时已经白跑。人工介入必须是主动设计的一等机制,不是异常路径。
四类检查点及其不同的价值
按「介入的收益」排序,人工检查点有四类,它们的实现难度递增:
- 确认型:执行一个不可逆动作前问一句。最简单,价值明确,代价是打断。适合金额大、影响面广的动作。
- 方向型:在投入大量成本之前确认整体方向。比如「我打算从这五个模块入手,你觉得该先修哪两个」。这一类的性价比最高,因为它用一次打断换掉了几小时的不确定性。
- 例外型:系统遇到了它无权自行决定的情况。难点在于「无权」的判定标准,如果判定太宽松,人会被淹没;太严格,系统会在歧义上硬猜。
- 验收型:全部做完之后交付前确认。看起来最必要,实际上价值最低——因为这时候再发现方向不对,几小时的投入已经无法挽回了。
这个排序的实践含义很直接:把人工检查点尽量往前挪。一个跑四小时的任务,如果只在最后验收,那么这次人工介入能挽回的工作量是零。真正划算的是在第十分钟问一次。
一次问完,比分五次问更省时间
人工介入的成本不只是打断本身,还有用户的认知重建成本——他需要重新回忆任务背景、重新阅读已有产出、重新建立判断。用户离开两小时后回来,第一件事是理解「现在到底什么状况」,这个过程本身就要花掉十几分钟。
所以好的检查点设计有一条明确要求:在一次交互里问完所有需要问的问题。这意味着系统在触发检查点时,应该先收集齐全部待决问题,一次性呈现,而不是问一个等回答再问下一个。
呈现的内容也有讲究。一个合格的检查点应该包含四样东西,缺一样就会导致用户无法快速决策:当前完成了什么(简明列表,不是流水账)、现在卡在哪(具体到一个决策点)、有哪些选项及其代价、以及你的推荐和理由。第四样最常被省略,但它的影响最大——没有推荐,用户实际上是在替系统做规划工作。
挂起等待的正确实现
前面说过挂起状态必须停用超时监控和停滞检测。这里补一个实现层面的要点:挂起要有过期策略。
一个挂起的任务如果永远等下去,环境会变、人会忘、依赖会升级。合理的做法是给挂起节点一个明确的重新评估条件,而不是一个纯时间条件——比如「当 X 仓库有新的提交时重新唤醒」、「当 CI 跑完时重新唤醒」、「48 小时无人处理则标记为需要重新确认」。
人工检查点最容易被忽略的指标是「被打断次数」。它和系统质量负相关:一个需要打扰用户五次才能完成的任务,通常意味着它没有把不确定性收窄到足够的程度。
判断一个检查点设计得好不好,有一个简单的检验:把这次人工介入拿掉,任务会不会明显更糟。如果答案是「不会,只是多一点返工」,那这个检查点就不必要,它只是让你感觉更可控;如果答案是「会,且代价高昂」,那它是必要的。把这条当作新增检查点的准入门槛,比任何启发式都可靠。
2026 年的落地现状:哪些已经收敛,哪些还在赌
这一节不预测,而是把前面几节的机制按「当前实现的可靠程度」分层。分层的意义在于:上层的东西可以直接用,下层的东西要在设计时留出退路。具体的实现细节各家差异很大,但状态的稳定性是可以通过代码结构本身判断的。
已经基本收敛的部分
任务状态与执行进程的分离几乎不再是一个需要论证的设计选择了。把状态放进外部存储、让执行进程成为可替换的短命组件,已经成为长时任务系统的事实标准做法。相关的配套——检查点、租约、幂等键——也基本定型,差异主要在封装方式而不在语义。
上下文压缩作为必备机制同样已经普及。真正被广泛采用的形态是「按语义边界压缩 + 失败尝试单独保留」,而不是简单的按 token 截断。前面提到的约束外化做法还不是标配,但它属于成本极低、收益明确的改进,通常只需要在现有架构上加一个独立的约束存储。
预算在调度层强制也是共识。把预算写进 prompt 而不在调度层强制的做法,已经很难在严肃的长时任务里见到。这个转变背后是成本治理的成熟——当一次任务的成本可能达到几十上百美元时,「事后发现超支」是不可接受的。
正在收敛但仍有分歧的部分
记忆的分层结构方向已经明确(工作、情节、语义三层),但每层的具体边界和写入门槛还没有共识。最有争议的是语义记忆的准入:一个判断究竟是应该沉淀为跨任务知识,还是只留在本次任务的结论里。这直接关系到错误被固化的风险,各家的策略差异很大。
任务分解的粒度与重规划时机同样没有统一答案。倾向于早分解、频繁重规划的方案对环境变化反应快,但计划震荡的代价高;倾向于粗分解、少重规划的方案稳定,但跑偏时损失大。实际系统往往按任务类型选,而不只是按配置选。
人工检查点的位置在产品层面的差异比技术层面大。技术实现(暂停、恢复、失效传播)已经成熟,但产品上「多久打扰用户一次」这个问题没有技术答案,只能靠对用户类型的假设。这一点值得在做技术选型时问清楚,因为它会反过来决定架构要灵活到什么程度。
仍然在赌的部分
长任务的质量评估是当前最薄弱的一环。判断一个任务「做得好不好」,在短任务里可以直接看产出;在长任务里,产出往往是几十个分散的结论加一份报告,很难用一个指标来衡量。结果是这类系统的改进缺少可靠反馈,只能靠人工复盘。
跨任务知识的可信度管理同样没有好答案。一个任务里验证过的结论,是否可以直接用于下一个任务?验证到什么程度才算数?前面说过「写入语义记忆需要人确认」,但这在任务数量大的时候不可能对每条都确认,于是就变成了抽样,而抽样的标准怎么定,目前基本靠各自的判断。
成本与质量的权衡曲线在长时任务上的形状还不清楚。在短任务上这是个清晰的帕累托前沿:多花预算能换来更高质量,直到饱和。长任务上这个曲线更复杂——多花预算可能买到更多覆盖率,也可能买到大量重复劳动。
怎么判断一个方案是否可靠
如果要给自己一个评估标准,可以问四个问题,它们都只能通过检查代码结构回答,而不能通过阅读文档回答:
- 进程死掉时,任务状态是否完整可读?如果恢复需要从日志里重建状态,那它还不是一个真正的持久化任务。
- 同一个节点被执行两次时,外部系统里会不会多出一个东西?如果会,那副作用路径上没有幂等。
- 用户改需求时,系统能否算出哪些工作已经作废?如果不能,那需求变更只能靠重来。
- 任务跑偏时,多久会被发现?如果答案是「跑完之后」,那么中间产出的价值接近于零。
这四个问题里,只要有一个答不上来,那个系统在几小时尺度上就是不可靠的,不管它的模型能力多强、demo 效果多好。这也是本文开头那句话的实际含义:长时任务的瓶颈不在模型,而在状态与生命周期管理。
写在最后
回到最开始那个问题:跑几小时的 Agent 怎么活过进程重启。答案可以浓缩成一句话——把所有可变的、可能丢失的东西,从进程内存搬到持久化的任务图上,然后让进程变成一个随时可以死掉、死后可以被替换的执行器。
这个转变听起来只是工程细节,但它改变了问题的性质。进程内的 Agent 是「一个正在思考的实体」,你只能等它想完或者杀掉它;持久化的 Agent 是「一个有一半进度存放在外部的状态机」,你可以随时暂停、替换执行者、修改它的目标,然后让它从中断处继续。后者才真正具备处理长时任务的能力——不是因为他更聪明,而是因为他不再需要连续地活着。
最后提醒一个容易走偏的地方:这套架构里最重要的部分——幂等键、输入指纹、失效传播、预算闸门——都不是能让 demo 看起来更炫的特性,它们只会让系统在出错时表现得更体面。但在长时任务这个领域,体面恰恰就是全部:一次跑四小时的任务,用户不会因为它完成了而夸奖它,只会在它半路崩溃、产出半成品、或者悄悄重复做了两小时无用功的时候,彻底放弃这个工具。