构建可断点续跑的 Agent:长任务执行中的状态持久化、恢复与幂等设计

一、为什么"跑得久"的 Agent 一定会挂

前几篇文章构建的 Agent 都能完成任务,但都隐含一个前提:一次执行过程中,进程不重启、网络不断、模型不超时。Demo 里这个前提基本成立,生产里它脆弱得像纸糊的——长任务(批量数据处理、多轮人工审批、跨小时的分析流程)跑上几十分钟,任何一个环节抖动,整个任务就得从头再来。

真实生产里最常见的三类"打断":

  1. 基础设施抖动:Pod 被调度器驱逐、发布时滚动重启、OOM,进程说没就没了;
  2. 外部依赖超时:LLM 调用超时、下游 API 5xx,一次失败可能只是暂时的,但当前这步的状态已经丢了;
  3. 人工介入:你的 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)是客户端生成、服务端识别的唯一标识。规则:

  1. 由任务上下文确定性生成,而不是随机生成——否则重跑时键就变了,等于没幂等;
  2. 每个副作用操作一个键,粒度到"这个任务的这一步";
  3. 下游支持 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

七、总结:四条铁律

  1. 状态与副作用分开存,副作用只追加——状态可丢,副作用账本不能丢;
  2. 断点用业务语义,不用行号——"第 N 步完成"才是可靠的恢复点;
  3. 所有副作用操作必须幂等,幂等键确定性生成——重跑只重放结果,不重放动作;
  4. 先记副作用,再更新进度——窗口期挂掉,靠幂等表兜底,不会重复执行。

断点续跑不是"锦上添花",而是长任务 Agent 进入生产的入场券。它能扛住重启、扛住超时、扛住人工审批的漫长等待,并且让每一次重试都变得安全——这四项能力,恰恰是企业级与 Demo 的分界线。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值