Agent长期运行-状态持久化与任务断点续跑
大多数 Agent 教程演示的是一次性任务:输入一个问题,Agent 思考几秒钟,返回答案,结束。这类任务的生命周期只有几秒到几分钟。
Agent 长期运行:状态持久化与任务断点续跑
大多数 Agent 教程演示的是一次性任务:输入一个问题,Agent 思考几秒钟,返回答案,结束。这类任务的生命周期只有几秒到几分钟。
但真实业务里有另一类任务:分析一份 500 页的年度报告(需要几十分钟);爬取并整理竞争对手的所有产品文档(可能需要几小时);持续监控某个数据源并在异常时触发操作(7×24 小时)。
这类长期运行的 Agent 面临的挑战,和前几篇讨论的短时 Agent 完全不同。
1.1 长期运行 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)都归在这个书名下,通过书名就能找到这本书最新的进度。
# 每次任务使用唯一的 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.5 代码示例:支持断点续跑的长文档分析 Agent
以下为代码示例,非程序员可跳过代码,重点看文字说明。
这个示例实现了一个分析长文档的 Agent,整个分析流程分为四个阶段:文档切分 → 逐段分析 → 整合摘要 → 生成报告。每个阶段完成后自动保存状态。
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 跨会话状态管理:用户明天继续今天的对话
断点续跑解决了"进程崩溃后恢复"的问题。还有一个更常见的场景:用户今天开始了一个任务,明天回来继续。
这需要状态能跨越进程的生命周期保存,也就是写入磁盘或数据库。
以下为代码示例,非程序员可跳过代码,重点看文字说明。
# 生产环境配置:使用 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 历史会占用大量存储空间。需要设置清理策略,避免数据库无限增长。
以下为代码示例,非程序员可跳过代码,重点看文字说明。
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),每次节点执行时可以向前端推送进度更新:
以下为代码示例,非程序员可跳过代码,重点看文字说明。
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 只能做一次性任务。有了它,才能跑长程任务、从断点恢复,才算真正能用在生产环境里。