一、为什么"跑得久"的 Agent 一定会挂
前几篇文章构建的 Agent 都能完成任务,但都隐含一个前提:一次执行过程中,进程不重启、网络不断、模型不超时。Demo 里这个前提基本成立,生产里它脆弱得像纸糊的——长任务(批量数据处理、多轮人工审批、跨小时的分析流程)跑上几十分钟,任何一个环节抖动,整个任务就得从头再来。
真实生产里最常见的三类"打断":
- 基础设施抖动:Pod 被调度器驱逐、发布时滚动重启、OOM,进程说没就没了;
- 外部依赖超时:LLM 调用超时、下游 API 5xx,一次失败可能只是暂时的,但当前这步的状态已经丢了;
- 人工介入:你的 Agent 需要审批人确认(第 1 篇的
interrupt),审批可能等 10 分钟,也可能等 10 小时——这段时间里进程重启了怎么办?
所有这些问题指向同一个能力:断点续跑。一个能断点续跑的 Agent,至少要具备三件事——状态持久化(状态不丢)、断点恢复(从挂掉的地方接着跑,而不是从头)、幂等执行(重跑不会产生重复副作用)。这篇文章逐个拆开,最后给一个可运行的最小实现。
二、状态持久化:Agent 的"记忆"存到哪
2.1 状态里到底有什么
先想清楚要持久化什么。一个 Agent 任务的状态,通常由三部分组成:
- 对话/推理上下文:messages 历史、当前计划、中间结果;
- 执行进度:进行到第几步、哪些步骤已完成、哪些在等待人工审批;
- 副作用记录:已经调用过哪些工具、产生了哪些外部影响(工单号、订单号、发送的消息 ID)。
第一部分丢了可以"少记一点重来",第三部分丢了就是灾难——你根本不知道系统已经被改成了什么样。所以持久化的第一原则:副作用记录和推理上下文分开存,副作用记录只追加、不覆盖。
2.2 三层存储方案
| 层次 | 方案 | 适用规模 | 特点 |
|---|---|---|---|
| 内存 | dict + 进程内 | 单机、短任务 | 最快,进程死就丢 |
| 文件/嵌入式 | SQLite、JSONL | 单机、中等任务 | 零依赖,进程重启可恢复 |
| 分布式 | Redis、PostgreSQL、对象存储 | 多实例、长任务 | 支持水平扩展与故障转移 |
生产上最常见的组合是 SQLite/Redis 存进度 + 对象存储存大上下文(比如超过几 MB 的中间文件)。下面用 SQLite 做最小演示,因为它的 API 清晰、零部署,原理完全一致。
import sqlite3, json, time, uuid
class TaskStore:
"""基于 SQLite 的任务状态存储,支持断点续跑"""
def __init__(self, db_path: str = "agent_tasks.db"):
self.conn = sqlite3.connect(db_path)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
status TEXT NOT NULL, -- running / paused / done / failed
state_json TEXT NOT NULL, -- 推理上下文与进度
updated_at REAL NOT NULL
)
""")
# 副作用流水账:只追加
self.conn.execute("""
CREATE TABLE IF NOT EXISTS effects (
task_id TEXT NOT NULL,
effect_id TEXT NOT NULL, -- 幂等键
tool TEXT NOT NULL,
payload TEXT NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (task_id, effect_id)
)
""")
self.conn.commit()
def save_state(self, task_id: str, state: dict, status: str = "running"):
self.conn.execute(
"INSERT OR REPLACE INTO tasks VALUES (?, ?, ?, ?)",
(task_id, status, json.dumps(state, ensure_ascii=False), time.time()),
)
self.conn.commit()
def load_state(self, task_id: str) -> dict | None:
row = self.conn.execute(
"SELECT state_json, status FROM tasks WHERE task_id = ?", (task_id,)
).fetchone()
return None if row is None else {"state": json.loads(row[0]), "status": row[1]}
def has_effect(self, task_id: str, effect_id: str) -> bool:
return self.conn.execute(
"SELECT 1 FROM effects WHERE task_id=? AND effect_id=?", (task_id, effect_id)
).fetchone() is not None
def record_effect(self, task_id: str, effect_id: str, tool: str, payload: dict):
self.conn.execute(
"INSERT OR IGNORE INTO effects VALUES (?, ?, ?, ?, ?)",
(task_id, effect_id, tool, json.dumps(payload, ensure_ascii=False), time.time()),
)
self.conn.commit()
两个设计要点:一是 state_json 存推理上下文和进度,每次关键节点 save_state 一次;二是 effects 表只追加 + 主键去重,它既是幂等判断的依据,也是审计日志——这就是"副作用记录和推理上下文分开存"的落地。
三、断点恢复:从"挂掉的地方"接着跑
有了存储,恢复就变成了两个问题:从哪恢复和怎么恢复。
3.1 从哪恢复:任务进度即断点
不要用"第几行代码"当断点,要用业务语义当断点。一个任务被拆成步骤后,每一步有明确的输入、输出、副作用。断点 = “最后一条已确认完成的步骤”。
class StepRunner:
"""把任务拆成步骤,每步执行前先检查是否已完成(断点续跑核心)"""
def __init__(self, store: TaskStore, task_id: str, steps: list[dict]):
self.store, self.task_id, self.steps = store, task_id, steps
async def run(self, restart: bool = False):
# 支持"从头跑"和"从断点跑"两种模式
start = 0
if not restart:
saved = self.store.load_state(self.task_id)
if saved:
start = saved["state"].get("completed_steps", 0) # 断点 = 已完成步数
for i in range(start, len(self.steps)):
step = self.steps[i]
# 恢复时关键判断:这步的副作用是否已经发生过?
if self.store.has_effect(self.task_id, step["effect_id"]):
print(f"step {i} 已执行过,跳过")
continue
# 执行前先落"计划",执行后落"副作用",两者都入库
self.store.save_state(self.task_id,
{"completed_steps": i, "current": step["name"]})
try:
result = await self._execute_with_guard(step)
self.store.record_effect(self.task_id, step["effect_id"],
step["name"], {"result": result})
print(f"step {i} 完成: {step['name']}")
except Exception as e:
# 失败即落盘,下次从这一位继续
self.store.save_state(self.task_id,
{"completed_steps": i, "current": step["name"], "error": str(e)},
status="failed")
raise
self.store.save_state(self.task_id, {"completed_steps": len(self.steps)}, status="done")
注意 run(restart=False) 的恢复流程:加载状态 → 取 completed_steps 作为断点 → 对每一步先查副作用表,执行过就跳过。这样即使状态文件里的步数计数和实际副作用不一致,也能靠幂等键兜底,不会重复执行。
3.2 怎么恢复:上下文重建
推理上下文(messages)同样要从存储里恢复。上一节的 state_json 里存的是序列化后的 messages,恢复时直接 json.loads 拿回来,不需要重新让模型"回忆"——模型的记忆成本比存储贵得多,永远用存储而不是让模型重算。
def rebuild_context(saved: dict) -> list[dict]:
"""从存储重建对话上下文,而不是重新问模型"""
return saved["state"].get("messages", [])
# 进程重启后的入口
def on_worker_start(task_id: str):
saved = store.load_state(task_id)
if saved and saved["status"] == "running":
messages = rebuild_context(saved)
runner = StepRunner(store, task_id, STEPS)
asyncio.run(runner.run(restart=False)) # 从断点继续
四、幂等设计:重跑不可怕,可怕的是重跑产生新副作用
4.1 为什么幂等是断点续跑的生死线
断点续跑必然带来重试——进程恢复后,最后一步可能"执行了一半":副作用已发生,但状态没来得及落盘。如果这步是"发短信",用户会收到两条;如果是"扣款",后果更严重。所以断点续跑的铁律是:任何有副作用的操作,重跑时必须能识别"我已经做过这件事",并直接返回上次的结果。
4.2 幂等键:从哪来、怎么用
幂等键(Idempotency Key)是客户端生成、服务端识别的唯一标识。规则:
- 由任务上下文确定性生成,而不是随机生成——否则重跑时键就变了,等于没幂等;
- 每个副作用操作一个键,粒度到"这个任务的这一步";
- 下游支持
Idempotency-Key头(支付宝、Stripe、短信网关一般都有);不支持的下游,自己用effects表做去重。
import hashlib, uuid
def make_effect_id(task_id: str, step_index: int, tool: str, args: dict) -> str:
"""确定性幂等键:同一任务同一参数的同一操作,键永远相同"""
raw = json.dumps(
{"task": task_id, "step": step_index, "tool": tool, "args": args},
sort_keys=True, ensure_ascii=False,
)
return hashlib.sha256(raw.encode()).hexdigest()[:24]
async def execute_with_idempotency(store, task_id, step):
"""执行工具前先查幂等表,命中直接返回上次结果"""
eid = make_effect_id(task_id, step["index"], step["tool"], step["args"])
if store.has_effect(task_id, eid): # 副作用已发生,直接返回
row = store.get_effect(task_id, eid)
return {"replayed": True, "result": row["payload"]["result"]}
# 幂等键传给下游(支持的话),重试时下游自行去重
result = await call_tool(step["tool"], step["args"],
idempotency_key=eid)
store.record_effect(task_id, eid, step["tool"], {"result": result})
return {"replayed": False, "result": result}
配套动作:effects 表的写入必须在工具返回之后立即落盘(先记副作用,再继续下一步);而状态进度的更新可以稍后。这个顺序保证:万一进程在"副作用已发生、进度未更新"的窗口挂掉,恢复时查幂等表会发现副作用已存在,直接跳过——这是唯一不会出错的重放顺序。
4.3 重试策略与幂等的配合
有幂等键兜底,重试策略就可以大胆一些:瞬时错误(超时、连接重置)重试 3 次,指数退避;永久错误(参数非法、权限不足)不重试,直接落盘失败状态等人工处理。重试的底气来自幂等,而不是运气。
五、完整示例:一个能扛住重启的任务执行器
把上面所有部分拼起来,就是一个最小可用的"可断点续跑 Agent"——它包含一个模拟的"发短信 + 记账"两步任务,你可以直接跑,并随时 Ctrl+C 杀掉进程再重启,观察它如何从断点继续:
import asyncio, json, sqlite3, time, hashlib
# ---------- 1. 存储层(见上文 TaskStore,此处省略重复代码) ----------
# ---------- 2. 模拟有副作用的工具 ----------
sent = {} # 进程内模拟"短信已发出"的外部状态
async def send_sms(phone: str, content: str):
await asyncio.sleep(0.3) # 模拟网络耗时
key = f"{phone}:{content}"
if key in sent: # 模拟下游幂等
return {"duplicate": True}
sent[key] = time.time() # 真实副作用发生点
return {"sms_id": uuid4hex()}
async def charge(amount: float):
await asyncio.sleep(0.3)
return {"charge_id": uuid4hex(), "amount": amount}
# ---------- 3. 断点续跑主流程 ----------
async def run_task(task_id: str, restart: bool = False):
store = TaskStore("agent_tasks.db")
steps = [
{"name": "send_sms", "tool": send_sms, "args": {"phone": "13800000000", "content": "您的订单已发货"}},
{"name": "charge", "tool": charge, "args": {"amount": 99.0}},
]
for i, step in enumerate(steps):
eid = make_effect_id(task_id, i, step["name"], step["args"])
if store.has_effect(task_id, eid): # 断点恢复:已做过就跳过
print(f"[{task_id}] step {i} 已执行,跳过")
continue
store.save_state(task_id, {"completed_steps": i, "current": step["name"]})
try:
result = await step["tool"](**step["args"])
store.record_effect(task_id, eid, step["name"], {"result": result})
print(f"[{task_id}] step {i} 完成: {result}")
except Exception as e:
store.save_state(task_id, {"completed_steps": i, "error": str(e)}, status="failed")
print(f"[{task_id}] step {i} 失败: {e},已落盘,重启后从此恢复")
return
store.save_state(task_id, {"completed_steps": len(steps)}, status="done")
print(f"[{task_id}] 任务全部完成")
if __name__ == "__main__":
import sys
task_id = sys.argv[1] if len(sys.argv) > 1 else "demo-task"
restart = "--restart" in sys.argv
asyncio.run(run_task(task_id, restart))
验证方法:第一次运行到 send_sms 后杀掉进程(比如在 charge 之前 Ctrl+C),再运行 python agent.py demo-task——你会看到 send_sms 被跳过、直接从 charge 继续,而且 sent 里没有重复的短信记录。这就是断点续跑的全部意义。
六、与 LangGraph 的关系:别人已经帮你铺好了路
第 1 篇文章里我们用过 interrupt() 和 MemorySaver(),其实就是同一套思想的框架化实现:checkpointer 负责状态持久化,interrupt 负责人工介入断点,Command(resume=...) 负责从断点恢复。自己实现这套逻辑价值在于理解原理、掌控边界(自定义存储、幂等、审计),但生产上直接用框架的 checkpointer(PostgresSaver、RedisSaver)往往更稳。建议:理解原理用本文的代码,落地生产用框架 + 自定义 checkpointer。
七、总结:四条铁律
- 状态与副作用分开存,副作用只追加——状态可丢,副作用账本不能丢;
- 断点用业务语义,不用行号——"第 N 步完成"才是可靠的恢复点;
- 所有副作用操作必须幂等,幂等键确定性生成——重跑只重放结果,不重放动作;
- 先记副作用,再更新进度——窗口期挂掉,靠幂等表兜底,不会重复执行。
断点续跑不是"锦上添花",而是长任务 Agent 进入生产的入场券。它能扛住重启、扛住超时、扛住人工审批的漫长等待,并且让每一次重试都变得安全——这四项能力,恰恰是企业级与 Demo 的分界线。

437

被折叠的 条评论
为什么被折叠?



