课程0基础Agent开发课 / Agent基础 / Agent长期运行-状态持久化与任务断点续跑
— 24 min read

Agent长期运行-状态持久化与任务断点续跑

大多数 Agent 教程演示的是一次性任务:输入一个问题,Agent 思考几秒钟,返回答案,结束。这类任务的生命周期只有几秒到几分钟。

Agent 长期运行:状态持久化与任务断点续跑

大多数 Agent 教程演示的是一次性任务:输入一个问题,Agent 思考几秒钟,返回答案,结束。这类任务的生命周期只有几秒到几分钟。

但真实业务里有另一类任务:分析一份 500 页的年度报告(需要几十分钟);爬取并整理竞争对手的所有产品文档(可能需要几小时);持续监控某个数据源并在异常时触发操作(7×24 小时)。

这类长期运行的 Agent 面临的挑战,和前几篇讨论的短时 Agent 完全不同。


1.1 长期运行 Agent 的三大挑战

Agent状态持久化架构图
Agent 状态持久化架构——检查点保存运行状态,故障后从最近检查点断点续跑

挑战一:进程崩溃和服务重启

服务器不是永远稳定的。K8s(Kubernetes,一种用于自动化部署和管理容器化应用的开源平台)扩缩容、内存不足被 OOM Kill(Out of Memory Kill,系统内存耗尽时强制终止进程)、代码部署重启——这些都会让正在运行的进程瞬间消失。

一个分析了 2 小时的 Agent 进程被 kill 掉,如果没有任何持久化机制,只能从头开始。2 小时的工作白费,API 调用费用也白花了。

挑战二:用户关闭浏览器后任务继续运行

用户提交了一个"分析这 100 份合同,找出所有风险条款"的任务,然后关掉了浏览器去开会。

任务应该在后台继续跑,用户回来时能看到结果。但如果 Agent 的状态只存在于内存里,用户一走、连接断开,状态就消失了。

挑战三:需要人工审核时暂停等待

第 17 篇讲的 Human-in-the-Loop 依赖 Checkpointer(检查点保存器)。Agent 暂停时,把当前状态存下来,等人工确认后再恢复。如果没有持久化,等待期间服务一重启,Agent 的位置就丢失了,也无法恢复。

这三个挑战指向同一个解决方案:状态持久化——把 Agent 在运行过程中的中间状态保存到持久存储中,使得任何时刻的中断都可以从上次停止的地方继续。


1.2 Checkpoint 机制:游戏存档的思路

**Checkpoint(检查点)**是状态持久化的核心概念。

类比:玩 RPG 游戏时,每隔一段时间游戏会自动存档。如果角色在 Boss 战中阵亡(进程崩溃),不需要从第一章重新开始,从最近的存档点继续就好。

Agent 的 Checkpoint 机制完全相同:每个节点执行完毕后,把当前的完整状态自动保存到存储中。下次启动时,先检查是否有未完成的任务状态,如果有,从上次存储的节点继续执行。

LangGraph(LangChain 旗下的 Agent 工作流框架,原生支持状态持久化和断点续跑)提供了三种 Checkpointer(检查点保存器),对应不同场景:

Checkpointer 存储位置 重启后是否保留 适用场景
MemorySaver 内存 否(重启即失) 本地开发、调试、演示
SqliteSaver 本地 SQLite 文件 是(文件持久) 单机部署、个人工具
PostgresSaver PostgreSQL 数据库 是(数据库持久) 生产环境、多实例部署

MemorySaver 最简单,用于前面文章的示例代码。PostgresSaver 是生产环境的标准选择,支持多个服务实例同时访问同一个状态存储。


1.3 关键概念:thread_id

LangGraph 用 thread_id(线程ID)来标识"一次任务"或"一次对话"。

可以把 thread_id 理解为一本书的书名——同一本书的所有内容(所有 Checkpoint)都归在这个书名下,通过书名就能找到这本书最新的进度。

python
# 每次任务使用唯一的 thread_id
config = {"configurable": {"thread_id": "task-20241215-report-analysis"}}

# 第一次运行:Agent 开始执行
graph.invoke({"task": "分析Q3财务报告"}, config=config)

# 服务重启后,使用相同的 thread_id 恢复
graph.invoke(None, config=config)  # 从上次停止的地方继续

同一个 thread_id 的所有状态快照形成一条时间线,可以随时回放历史状态,也可以从任意一个历史状态点分叉出新的执行路径。


1.4 断点续跑的完整流程

完成节点1

进程崩溃 / 服务重启

所有节点完成

用户提交长期任务
(附带 thread_id)

Agent 开始执行
节点1:数据获取

执行是否完成?

Checkpointer 保存状态
(存储到数据库)

Agent 继续执行
节点2:数据分析

状态已保存在数据库
数据不丢失

服务恢复启动

用相同 thread_id 请求恢复

Checkpointer 加载最新状态
(找到上次保存的节点)

从断点继续执行
(跳过已完成的节点)

任务结束
返回最终结果


1.5 代码示例:支持断点续跑的长文档分析 Agent

以下为代码示例,非程序员可跳过代码,重点看文字说明。

这个示例实现了一个分析长文档的 Agent,整个分析流程分为四个阶段:文档切分 → 逐段分析 → 整合摘要 → 生成报告。每个阶段完成后自动保存状态。

python
from typing import TypedDict, Annotated
import operator
import time
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
# 生产环境替换为:
# from langgraph.checkpoint.postgres import PostgresSaver

# ---- 定义 Agent 状态 ----

class DocumentAnalysisState(TypedDict):
    """
    长文档分析 Agent 的状态。
    每个字段在不同阶段被填充,全部完成时任务结束。
    """
    document_text: str                              # 原始文档内容
    document_chunks: list[str]                      # 切分后的文档片段
    chunk_analyses: Annotated[list, operator.add]   # 每个片段的分析结果(累加)
    summary: str                                    # 整合摘要
    final_report: str                               # 最终报告
    current_phase: str                              # 当前所在阶段(用于进度展示)
    progress: str                                   # 进度说明(面向用户)


llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# ---- 阶段1:文档切分 ----

def chunk_document(state: DocumentAnalysisState) -> DocumentAnalysisState:
    """
    把长文档切分成小片段。
    为什么要切分?因为单个 LLM 调用的上下文窗口有限,
    一次只能处理一部分文档。
    """
    print("[阶段1] 开始切分文档...")

    text = state["document_text"]
    # 简单的切分策略:每 1000 字为一段(实际生产中应按段落切分)
    chunk_size = 1000
    chunks = [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]

    print(f"[阶段1] 文档切分完成,共 {len(chunks)} 个片段")
    return {
        "document_chunks": chunks,
        "chunk_analyses": [],  # 初始化为空列表
        "current_phase": "chunked",
        "progress": f"文档切分完成,共 {len(chunks)} 个片段,开始逐段分析",
    }


# ---- 阶段2:逐段分析 ----

def analyze_chunks(state: DocumentAnalysisState) -> DocumentAnalysisState:
    """
    逐段分析文档内容。
    这是最耗时的阶段——如果有 50 个片段,需要调用 LLM 50 次。
    每次调用完成后,Checkpointer 会保存当前状态。
    如果这个阶段中途被中断,下次从已分析到的片段继续,不需要重新分析已完成的片段。
    """
    chunks = state["document_chunks"]
    already_analyzed = len(state.get("chunk_analyses", []))

    print(f"[阶段2] 开始逐段分析,共 {len(chunks)} 段,已完成 {already_analyzed} 段")

    # 注意:这里只分析尚未处理的片段
    # 如果之前中断过,chunk_analyses 里已有部分结果,直接从断点继续
    new_analyses = []
    for i, chunk in enumerate(chunks[already_analyzed:], start=already_analyzed):
        print(f"  分析第 {i+1}/{len(chunks)} 段...")

        response = llm.invoke([
            SystemMessage(content=(
                "你是一名文档分析专家。请提取以下文档片段的关键信息:\n"
                "1. 主要观点(1-2句)\n"
                "2. 重要数据或事实(如有)\n"
                "3. 潜在风险或问题(如有)\n"
                "输出格式:JSON,字段为 main_point、key_facts、risks"
            )),
            HumanMessage(content=f"文档片段 {i+1}:\n{chunk}"),
        ])

        new_analyses.append({
            "chunk_index": i,
            "analysis": response.content,
        })

        # 模拟耗时操作(实际场景中这里就是 LLM 调用的延迟)
        # 每分析完一段,LangGraph 会在下个节点执行前保存状态
        # 如果此时服务重启,chunk_analyses 里的已有内容会被保留

    total_analyses = already_analyzed + len(new_analyses)
    print(f"[阶段2] 逐段分析完成,共分析 {total_analyses} 段")
    return {
        "chunk_analyses": new_analyses,  # Annotated[list, operator.add] 会自动追加
        "current_phase": "analyzed",
        "progress": f"逐段分析完成,共分析 {total_analyses} 个片段",
    }


# ---- 阶段3:整合摘要 ----

def synthesize_summary(state: DocumentAnalysisState) -> DocumentAnalysisState:
    """
    把所有片段的分析结果整合成一份连贯的摘要。
    """
    print("[阶段3] 开始整合摘要...")

    # 把所有片段分析结果拼接成输入
    all_analyses = "\n\n".join([
        f"片段{item['chunk_index']+1}分析:{item['analysis']}"
        for item in state["chunk_analyses"]
    ])

    response = llm.invoke([
        SystemMessage(content=(
            "基于以下各片段的分析结果,生成一份整合摘要。\n"
            "要求:综合所有主要观点、汇总重要数据、列出全部风险点。\n"
            "字数:300-500字。"
        )),
        HumanMessage(content=all_analyses),
    ])

    print("[阶段3] 摘要整合完成")
    return {
        "summary": response.content,
        "current_phase": "summarized",
        "progress": "摘要整合完成,准备生成最终报告",
    }


# ---- 阶段4:生成最终报告 ----

def generate_report(state: DocumentAnalysisState) -> DocumentAnalysisState:
    """
    基于摘要生成结构化的最终报告。
    """
    print("[阶段4] 开始生成最终报告...")

    response = llm.invoke([
        SystemMessage(content=(
            "基于文档摘要,生成一份专业的分析报告。\n"
            "报告结构:执行摘要、主要发现、风险评估、建议措施。\n"
            "格式:Markdown,使用标题和列表。"
        )),
        HumanMessage(content=f"文档摘要:\n{state['summary']}"),
    ])

    print("[阶段4] 最终报告生成完成")
    return {
        "final_report": response.content,
        "current_phase": "completed",
        "progress": "任务完成",
    }


# ---- 构建支持断点续跑的图 ----

def build_resumable_graph(checkpointer):
    """
    构建带 Checkpointer 的分析图。
    checkpointer 参数决定状态存储在哪里:
      - MemorySaver():存内存(开发用)
      - SqliteSaver.from_conn_string("./agent_state.db"):存本地文件
      - PostgresSaver(...):存数据库(生产用)
    """
    builder = StateGraph(DocumentAnalysisState)

    builder.add_node("chunk_document", chunk_document)
    builder.add_node("analyze_chunks", analyze_chunks)
    builder.add_node("synthesize_summary", synthesize_summary)
    builder.add_node("generate_report", generate_report)

    # 串行管道:四个阶段依次执行
    builder.add_edge(START, "chunk_document")
    builder.add_edge("chunk_document", "analyze_chunks")
    builder.add_edge("analyze_chunks", "synthesize_summary")
    builder.add_edge("synthesize_summary", "generate_report")
    builder.add_edge("generate_report", END)

    # 关键:传入 checkpointer,启用状态持久化
    return builder.compile(checkpointer=checkpointer)


# ---- 演示:启动任务、模拟中断、断点续跑 ----

def demo_resumable_task():
    """
    完整演示:
    1. 启动一个文档分析任务
    2. 用相同 thread_id 查询进度(模拟中断后恢复)
    """
    checkpointer = MemorySaver()  # 生产环境换成 PostgresSaver
    graph = build_resumable_graph(checkpointer)

    # 每次任务使用固定且唯一的 thread_id
    # 实际场景中可以是 "用户ID-任务ID" 的组合
    thread_id = "user-001-q3-report-analysis"
    config = {"configurable": {"thread_id": thread_id}}

    sample_document = """
    第一章:市场概述
    本季度市场总规模达到 500 亿元,同比增长 23%。
    主要增长驱动力来自 AI 相关产品和服务,占新增市场的 67%。
    ...(模拟长文档内容)...
    第二章:竞争格局
    头部三家企业占据市场份额的 78%,格局相对集中。
    新进入者增加,竞争加剧,价格战风险上升。
    """

    print("=== 启动文档分析任务 ===")
    final_state = graph.invoke(
        {
            "document_text": sample_document,
            "document_chunks": [],
            "chunk_analyses": [],
            "summary": "",
            "final_report": "",
            "current_phase": "started",
            "progress": "任务已创建",
        },
        config=config,
    )
    print(f"\n任务完成!最终阶段:{final_state['current_phase']}")

    # 用相同的 thread_id 查询历史状态
    history = list(graph.get_state_history(config))
    print(f"\n共保存了 {len(history)} 个检查点")
    for snapshot in history[:3]:  # 只展示最新3个
        phase = snapshot.values.get("current_phase", "unknown")
        print(f"  - 阶段:{phase},已分析片段数:{len(snapshot.values.get('chunk_analyses', []))}")

    return final_state["final_report"]


demo_resumable_task()

1.6 跨会话状态管理:用户明天继续今天的对话

断点续跑解决了"进程崩溃后恢复"的问题。还有一个更常见的场景:用户今天开始了一个任务,明天回来继续。

这需要状态能跨越进程的生命周期保存,也就是写入磁盘或数据库。

以下为代码示例,非程序员可跳过代码,重点看文字说明。

python
# 生产环境配置:使用 PostgreSQL 存储状态
# 需要安装:pip install langgraph-checkpoint-postgres

from langgraph.checkpoint.postgres import PostgresSaver
import psycopg

# 连接 PostgreSQL(实际使用时替换为真实连接信息)
DB_URL = "postgresql://user:password@localhost:5432/agent_db"

def get_production_checkpointer():
    """
    生产环境 Checkpointer 工厂函数。
    PostgresSaver 会自动创建所需的数据库表。
    """
    conn = psycopg.connect(DB_URL, autocommit=True)
    checkpointer = PostgresSaver(conn)
    checkpointer.setup()  # 创建所需的数据库表(首次使用时执行)
    return checkpointer


# 用户今天的会话
def user_session_day1(thread_id: str):
    checkpointer = get_production_checkpointer()
    graph = build_resumable_graph(checkpointer)
    config = {"configurable": {"thread_id": thread_id}}

    print("用户第一天:开始分析任务...")
    # 假设这里任务执行了一半,用户关闭了浏览器
    # 状态已自动保存到 PostgreSQL


# 用户明天的会话(服务早已重启过多次)
def user_session_day2(thread_id: str):
    checkpointer = get_production_checkpointer()
    graph = build_resumable_graph(checkpointer)
    config = {"configurable": {"thread_id": thread_id}}

    # 查询当前任务状态
    current_state = graph.get_state(config)
    if current_state.values:
        phase = current_state.values.get("current_phase")
        progress = current_state.values.get("progress")
        print(f"找到未完成的任务,当前阶段:{phase}")
        print(f"进度:{progress}")
        print("从断点继续执行...")

        # 传入 None 作为输入,表示"继续已有任务"而非"开始新任务"
        result = graph.invoke(None, config=config)
        return result
    else:
        print("没有找到未完成的任务")
        return None

1.7 生产环境的状态存储策略

不是所有数据都需要永久保存。错误的策略会导致数据库无限增长,查询越来越慢。

什么状态需要持久化:

  • 用户输入的原始内容(文档、问题)——丢失了无法重建
  • 耗时工具调用的结果(LLM 分析、网络爬取的数据)——重做代价高
  • 关键的中间决策(Supervisor 的路由决定)——影响后续步骤
  • 人工审批的结论——需要审计追溯

什么状态可以丢弃:

  • 临时的格式转换结果(把 JSON 转成字符串)——重做代价低
  • 纯调试信息——不影响业务
  • 已经被整合进最终结果的中间数据——最终结果存着就够了

状态数据的清理策略:

任务完成后,中间的 Checkpoint 历史会占用大量存储空间。需要设置清理策略,避免数据库无限增长。

以下为代码示例,非程序员可跳过代码,重点看文字说明。

python
import datetime

def cleanup_old_checkpoints(conn, retention_days: int = 30):
    """
    清理超过 retention_days 天的已完成任务的中间检查点。
    保留策略:
    - 未完成的任务:保留全部(可能还需要恢复)
    - 已完成的任务:只保留最终状态快照,删除中间步骤
    - 超过 retention_days 的已完成任务:全部删除
    """
    cutoff_date = datetime.datetime.now() - datetime.timedelta(days=retention_days)

    with conn.cursor() as cur:
        # 删除超过保留期的已完成任务的检查点
        # 注意:这里假设"completed"阶段代表任务完成
        # 实际实现需要根据具体的表结构调整 SQL
        cur.execute("""
            DELETE FROM checkpoints
            WHERE created_at < %s
            AND thread_id IN (
                SELECT DISTINCT thread_id FROM checkpoints
                WHERE checkpoint_data->>'current_phase' = 'completed'
            )
        """, (cutoff_date,))

        deleted_count = cur.rowcount
        conn.commit()

    print(f"清理完成:删除了 {deleted_count} 条旧检查点记录")
    return deleted_count


def keep_only_final_checkpoint(conn, thread_id: str):
    """
    任务完成后,删除该任务的中间检查点,只保留最终状态。
    节省存储空间,同时保留完成记录。
    """
    with conn.cursor() as cur:
        # 找到最新(最终)的检查点 ID
        cur.execute("""
            SELECT checkpoint_id FROM checkpoints
            WHERE thread_id = %s
            ORDER BY created_at DESC
            LIMIT 1
        """, (thread_id,))
        latest = cur.fetchone()

        if latest:
            # 删除除最新检查点外的所有中间检查点
            cur.execute("""
                DELETE FROM checkpoints
                WHERE thread_id = %s AND checkpoint_id != %s
            """, (thread_id, latest[0]))
            conn.commit()
            print(f"任务 {thread_id}:中间检查点已清理,保留最终状态")

1.8 任务进度追踪:让用户看到 Agent 在做什么

长时间运行的任务,用户需要知道"现在跑到哪里了"。仅仅返回最终结果是不够的,中途的进度反馈同样重要。

LangGraph 支持流式事件(Streaming Events),每次节点执行时可以向前端推送进度更新:

以下为代码示例,非程序员可跳过代码,重点看文字说明。

python
import asyncio

async def run_with_progress(graph, state, config, progress_callback):
    """
    运行 Agent 并在每个节点完成时回调进度。
    progress_callback:一个函数,接收进度信息并展示给用户(如推送给前端)
    """
    async for event in graph.astream_events(state, config=config, version="v2"):
        event_type = event["event"]
        node_name = event.get("name", "")

        if event_type == "on_chain_start" and node_name in [
            "chunk_document", "analyze_chunks", "synthesize_summary", "generate_report"
        ]:
            # 节点开始时推送进度
            phase_names = {
                "chunk_document": "正在切分文档...",
                "analyze_chunks": "正在逐段分析(耗时较长,请耐心等待)...",
                "synthesize_summary": "正在整合摘要...",
                "generate_report": "正在生成最终报告...",
            }
            await progress_callback({
                "status": "running",
                "phase": node_name,
                "message": phase_names.get(node_name, f"正在执行:{node_name}"),
            })

        elif event_type == "on_chain_end" and node_name == "generate_report":
            # 最终节点完成时推送完成通知
            await progress_callback({
                "status": "completed",
                "phase": "done",
                "message": "分析报告生成完毕",
            })


# 配合 FastAPI + WebSocket 使用示例(伪代码)
# @app.websocket("/ws/task/{thread_id}")
# async def task_websocket(websocket: WebSocket, thread_id: str):
#     await websocket.accept()
#
#     async def push_progress(progress_data):
#         await websocket.send_json(progress_data)
#
#     config = {"configurable": {"thread_id": thread_id}}
#     await run_with_progress(graph, None, config, push_progress)

1.9 小结

Agent 长期运行的核心是状态持久化,而状态持久化的核心是两件事:

选对 Checkpointer:开发用 MemorySaver,单机部署用 SqliteSaver,生产环境用 PostgresSaver。不要把 MemorySaver 带进生产——进程一重启,所有状态消失。

管好 thread_id:它是每次任务的唯一标识,相当于任务的"身份证"。同一个任务的所有 Checkpoint 都挂在同一个 thread_id 下,恢复时必须使用完全相同的 thread_id

状态清理同样重要。Checkpoint 数据会随时间累积,需要制定保留策略:完成的任务只留最终快照,超期的数据定期清理,否则数据库会变成存储黑洞。

没有状态持久化,Agent 只能做一次性任务。有了它,才能跑长程任务、从断点恢复,才算真正能用在生产环境里。

本页目录