一、引言:Agent 的“自主性”,正在变成事故现场
过去一年,几乎所有的 LLM 应用都在往 Agent 方向演进。
ReAct、Plan-and-Execute、Reflexion 这些范式让模型不再只是“一问一答”,而是能自己拆解目标、调用工具、观察结果、修正计划。听起来很美,但真正把它放到生产环境里跑长链路任务时,工程师们很快就会发现三个绕不开的问题:
第一,任务漂移。用户让它“调研三家竞品并输出对比报告”,跑到第 12 步时,Agent 突然开始纠结某家公司的融资历史,然后又自作主张地去查了行业政策,最后生成的报告里竞品对比只占了两段。这不是模型“笨”,而是每一轮决策它都在重新理解“我现在该干什么”,而上下文的噪声会不断稀释最初的目标。更麻烦的是,这种漂移往往不是“突然发生”,而是“逐渐累积”:每一轮 LLM 都会基于前一轮的输出再生成下一轮的推理,误差像滚雪球一样被放大。到了第 12 步,模型眼里唯一的上下文可能就是最近几条工具返回的融资新闻,最初那个“对比三家竞品功能与价格”的目标早被挤出了有效注意力窗口。
第二,失败即归零。一个 20 步的任务,在第 18 步调用外部 API 超时了。因为没有中间状态持久化,唯一的选择就是从头再来——重新花钱、重新耗时,而且第 1 到 17 步里那些已经产生副作用的操作(发了邮件、创建了工单、扣了库存)会被重复执行一遍。在真实业务里,重复发一封客户通知邮件可能只是尴尬,重复创建一张工单可能只是脏数据,但重复扣一次库存或者重复发起一笔支付,那就是实打实的资金事故了。
第三,不可观测、不可干预。你没法知道 Agent 现在处于哪个阶段,也没法在它即将“干坏事”之前拦一下。调试基本靠打印日志 + 猜。当产品经理问你“这个任务现在卡在哪了”,你只能把长长的 message 列表贴给他看;当合规要求“涉及对外发送的动作必须人工确认”,你只能在 prompt 里写一句“请谨慎操作”——然后祈祷模型听话。
这些问题的根因其实是同一个:我们把控制流交给了概率模型。什么时候该继续、什么时候该结束、失败了怎么办,全由 LLM 每轮临场发挥。而工程系统需要的恰恰是确定性。
一个很朴素的类比:LLM 就像一位能力很强但注意力容易分散的顾问,你让他“看着办”,他就会自由发挥;但如果你给他一张流程图,告诉他“你现在只在‘收集资料’这一格,做完这一步就交给下一格的人”,他就会老老实实聚焦在眼前这一格里。流程的确定性由代码保证,语义的灵活性由模型提供——这才是把 Agent 推向生产的关键分界线。
这篇文章讲一个已经被验证过的解法:用有限状态机(FSM)定义确定性骨架,用任务检查点(Checkpoint)实现可恢复执行。LLM 依然是核心,但它只负责“语义判断”这一件事,不再负责流程编排。
二、核心原理:让确定性归确定性,让概率归概率
2.1 状态机:把“流程”从模型脑子里搬到代码里
一个有限状态机由四要素构成:
- States(状态):Agent 当前处于哪个阶段,比如
PLANNING、SEARCHING、DRAFTING、REVIEWING、DONE、FAILED。 - Events(事件):驱动状态转移的信号,来自 LLM 决策、工具返回、人工输入或定时器。
- Transitions(转移):
(src, event) -> dst的有向边。 - Guards / Actions(守卫与动作):守卫决定这条边能不能走(比如“草稿字数 ≥ 500 才允许进入评审”),动作是进入/离开状态时要执行的副作用。
关键在于职责切分:状态机负责“能不能走、往哪走”,LLM 负责“在这条边上做语义决策”。比如在SEARCHING状态,LLM 的任务是“根据当前查询决定下一个搜索关键词”,而不是“决定接下来该不该搜索”。后者是状态机的事。
这个思路在 LangGraph 里体现为StateGraph,在 Temporal 里体现为 Workflow Definition,在 OpenAI 的 Assistants 里体现为 Run Steps——本质都是一样的:先声明图,再让模型在节点里干活。
这里有一个容易被忽视但极其重要的性质:状态机是可穷举、可测试的。因为状态和事件都是有限集合,你完全可以在单元测试里枚举所有(状态, 事件)组合,验证“非法转移一定会被拒绝”“守卫条件一定生效”。这是概率模型永远做不到的——你没法给 LLM 写一个断言“它一定不会在第 5 步发邮件”。把流程写进状态机,本质上就是把原来“无法测试”的行为,转化成了“可以像普通业务逻辑一样测试”的行为。
再补一个实践中的判断标准:如果一个决策的答案只有有限几种,就该放进状态机;如果答案是一个开放式内容,才交给 LLM。“下一步该搜索还是该起草”只有两种答案,属于状态机;“下一个搜索词是什么”是开放式的,属于 LLM。很多团队把 Agent 做崩,就是因为把前者也交给了 LLM,于是模型每天都能发明出新的“下一步”。
2.2 检查点:可恢复的前提是“状态可序列化”
检查点不是什么新概念,数据库的 WAL、Kafka 的 offset、Flink 的 snapshot 都是它。放到 Agent 场景,一个检查点至少要包含四类信息:
- 执行位置:当前状态 + 步骤序号 + 下一步待处理的事件队列。
- 业务上下文:任务目标、已产出的中间结果、当前对话摘要(注意不是全量 messages)。
- 副作用账本:已经执行过的工具调用记录(带幂等键),用于恢复时去重。
- 元信息:版本号、创建时间、重试次数、trace_id。
有了它,“恢复”就退化成一次普通的load -> 继续跑。这里有一个必须想清楚的问题:恢复语义到底是 at-least-once 还是 exactly-once?
答案是:分布式系统里真正的 exactly-once 不存在,我们能做的是effectively-once——即“至少执行一次工具调用,但通过幂等键让副作用只生效一次”。
关于第 2 点里的“对话摘要”还有一个细节值得展开:不要把全量 messages 塞进检查点。一是体积会随步数线性膨胀,二是全量历史会稀释模型注意力,反而加剧任务漂移。更好的做法是让检查点里保存一份**“滚动摘要”**——每完成一个状态,就由 LLM 把该状态产生的关键信息压缩成一两句话追加上去,同时丢掉原始消息。这样检查点体积可控,恢复后模型看到的也是“精华版上下文”,决策质量更稳。
举一个具体的幂等键设计例子:假设工具是“给客户发通知邮件”,那么幂等键可以设计成hash(run_id + customer_id + notification_type),而不是hash(run_id + step)。区别在于,前者保证“同一个任务对同一个客户同类型通知只发一次”,即使中途步骤号变了也不会重复发;后者只要步骤漂移就会重发。幂等键要绑定业务语义,而不是绑定执行时序,这是很多团队恢复时踩坑最多的地方。
2.3 两者结合:确定性骨架 + 概率性肌肉
用户目标 │ ▼ ┌─────────────┐ event: need_research ┌─────────────┐ │ PLANNING │ ──────────────────────▶ │ SEARCHING │ └─────────────┘ └─────────────┘ ▲ │ event: enough_info │ event: plan_invalid ▼ │ ┌─────────────┐ └────────────────────────────────│ DRAFTING │ └─────────────┘ │ event: draft_ready ┌────────────────────┼────────────────────┐ ▼ ▼ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ REVIEWING │ │ NEED_HUMAN │ │ RETRY │ └─────────────┘ └─────────────┘ └─────────────┘ │ event: approved ▼ ┌─────────────┐ │ DONE │ └─────────────┘每一根箭头都是可审计、可测试、可中断的。LLM 的自由度被限制在每个节点内部,而节点之间的跃迁是确定的。这种“骨架 + 肌肉”的分层带来三个直接收益:可观测(任何时刻都能从检查点里读出当前状态)、可干预(在任何状态后插入人工确认点,或强行改写状态)、可恢复(任何一步崩溃都能从最近的检查点续跑)。
三、实战案例:一个可恢复的竞品调研 Agent
假设我们要做一个“竞品调研并生成报告”的 Agent。目标:调研 3 家竞品,每个竞品需要搜索资料 → 提取要点 → 汇总成报告草稿 → 人工审核 → 输出终稿。
3.1 状态机内核 + 检查点存储
先实现一个极简但完整的状态机内核。
# fsm_core.pyimportjson,time,osfromdataclassesimportdataclass,field,asdictfromtypingimportCallable@dataclassclassCheckpoint:run_id:strstate:strstep:intcontext:dict# 业务上下文(目标、中间产物、摘要)effects:list[str]=field(default_factory=list)# 已执行副作用的幂等键version:str="v1"created_at:float=field(default_factory=time.time)classCheckpointStore:"""生产环境请换成 PostgreSQL / Redis + S3,这里用文件演示"""def__init__(self,path="./ckpt"):self.path=path os.makedirs(path,exist_ok=True)defsave(self,ckpt:Checkpoint)->None:tmp=f"{self.path}/{ckpt.run_id}.tmp"withopen(tmp,"w")asf:json.dump(asdict(ckpt),f,ensure_ascii=False)os.replace(tmp,f"{self.path}/{ckpt.run_id}.json")# 原子写defload(self,run_id:str)->Checkpoint|None:try:withopen(f"{self.path}/{run_id}.json")asf:returnCheckpoint(**json.load(f))exceptFileNotFoundError:returnNone@dataclassclassTransition:src:strevent:strdst:strguard:Callable[[dict],bool]=lambdactx:Trueaction:Callable[[dict],dict]=lambdactx:ctx# 返回新的 contextclassWorkflow:def__init__(self,name:str,initial:str,transitions:list[Transition],store:CheckpointStore):self.name,self.initial,self.store=name,initial,store self.transitions={(t.src,t.event):tfortintransitions}defstart(self,run_id:str,ctx:dict)->Checkpoint:ckpt=Checkpoint(run_id=run_id,state=self.initial,step=0,context=ctx)self.store.save(ckpt)returnckptdeffire(self,ckpt:Checkpoint,event:str)->Checkpoint:t=self.transitions.get((ckpt.state,event))iftisNone:raiseValueError(f"非法转移:{ckpt.state}--{event}--> ?")ifnott.guard(ckpt.context):raiseValueError(f"守卫拒绝:{ckpt.state}--{event}-->{t.dst}")ckpt.context=t.action(ckpt.context)ckpt.state,ckpt.step=t.dst,ckpt.step+1self.store.save(ckpt)# 每次转移后落盘returnckpt这里有两个工程细节值得强调:os.replace保证检查点写入的原子性,避免进程崩溃时留下半个 JSON;fire里先判守卫再执行 action,确保被拒绝的转移不会污染上下文。
还有一个容易被忽略但很关键的顺序问题:落盘必须发生在 action 之后、且发生在“产生外部副作用”之前之后要仔细权衡。对于纯粹的上下文更新(比如把摘要写进 context),顺序无所谓;但对于会调用外部系统的 action,正确顺序应该是“先执行副作用并记录幂等键,再落盘”。因为如果先落盘再执行副作用,中间崩溃就会导致“以为做过了、其实没做”;而先执行副作用再落盘,最坏情况是恢复时重放一次,正好被幂等键挡住。永远选择“可能重复但绝不丢失”的一侧,这是恢复语义设计的黄金法则。
3.2 用 LLM 做语义决策 + 幂等工具调用
状态机定义好了,接下来在每个状态里塞进 LLM 决策。注意这里的关键约束:LLM 的输出必须是结构化 JSON,且只能输出枚举值。
# agents.pyimporthashlib,jsonfromopenaiimportOpenAIfromfsm_coreimportCheckpoint client=OpenAI()# 用 JSON Schema 强约束模型输出,杜绝"自由发挥"DECISION_SCHEMA={"type":"json_schema","json_schema":{"name":"search_decision","strict":True,"schema":{"type":"object","properties":{"next_query":{"type":"string"},"is_sufficient":{"type":"boolean"},"reason":{"type":"string"}},"required":["next_query","is_sufficient","reason"],"additionalProperties":False}}}defdecide_next_search(ckpt:Checkpoint)->tuple[str,bool]:"""在 SEARCHING 状态下,由 LLM 决定下一个搜索词,以及信息是否足够"""prompt=(f"调研目标:{ckpt.context['goal']}\n"f"当前竞品:{ckpt.context['current_competitor']}\n"f"已收集资料摘要:{ckpt.context.get('notes','')[:2000]}\n""请给出下一个搜索关键词,并判断现有信息是否足以撰写该竞品的分析段落。")resp=client.chat.completions.create(model="gpt-4o",temperature=0,messages=[{"role":"user","content":prompt}],response_format=DECISION_SCHEMA,)data=json.loads(resp.choices[0].message.content)returndata["next_query"],data["is_sufficient"]defcall_tool_idempotent(ckpt:Checkpoint,tool:str,args:dict):"""带幂等键的工具调用:恢复重放时不会重复产生副作用"""raw=f"{ckpt.run_id}:{ckpt.step}:{tool}:{json.dumps(args,sort_keys=True)}"key=hashlib.sha256(raw.encode()).hexdigest()[:16]ifkeyinckpt.effects:# 说明这次调用在崩溃前已经执行过,直接跳过,避免重复副作用return{"status":"skipped","reason":"already_executed","key":key}result=TOOLS[tool](**args)# 真正的外部调用ckpt.effects.append(key)# 记录副作用幂等键,随检查点一起落盘return{"status":"ok","key":key,"result":result}# 简单工具注册表,生产环境可换成插件式注册 + 权限校验TOOLS={"web_search":lambdaquery,k=5:search_api(query,top_k=k),"save_section":lambdacompetitor,text:doc_store.upsert(competitor,text),}注意call_tool_idempotent里key的计算把step也带进去了,这在“同一状态内多次调用同一工具”时是必要的;但如果工具本身具备全局唯一语义(如发通知),则应改成绑定业务主键。幂等键的设计没有万能公式,核心是想清楚“什么叫做重复”。
3.3 主循环:从检查点恢复执行
真正体现价值的是主循环——它既是正常执行路径,也是恢复路径。
# runner.pyfromfsm_coreimportWorkflow,Transition,CheckpointStorefromagentsimportdecide_next_search,call_tool_idempotentdefbuild_workflow():"""声明整张图:状态、事件、守卫、动作一目了然"""returnWorkflow(name="competitor_research",initial="PLANNING",transitions=[Transition("PLANNING","need_research","SEARCHING",action=lambdac:{**c,"notes":""}),Transition("SEARCHING","enough_info","DRAFTING"),# 守卫:草稿必须有实质内容才允许进入评审Transition("DRAFTING","draft_ready","REVIEWING",guard=lambdac:len(c.get("draft",""))>=200),Transition("REVIEWING","approved","DONE"),Transition("REVIEWING","rejected","DRAFTING"),],store=CheckpointStore(),)defrun_or_resume(run_id:str,goal:str,competitors:list[str]):wf=build_workflow()ckpt=wf.store.load(run_id)ifckptisNone:# 首次启动:初始化上下文ckpt=wf.start(run_id,{"goal":goal,"competitors":competitors,"current_index":0,"notes":"","draft":"",})print(f"[{run_id}] 新建任务,从{ckpt.state}开始")else:# 恢复:直接续跑,前面已执行过的副作用会被幂等键拦截print(f"[{run_id}] 检测到检查点,从{ckpt.state}step={ckpt.step}恢复")whileckpt.statenotin("DONE","FAILED"):handler=STATE_HANDLERS[ckpt.state]event=handler(ckpt)# 每个状态内部调用 LLM/工具,返回一个确定的事件ckpt=wf.fire(ckpt,event)# 由状态机决定这条边能不能走print(f"[{run_id}] ->{ckpt.state}(step={ckpt.step})")print(f"[{run_id}] 任务结束,终态={ckpt.state}")returnckpt STATE_HANDLERS={"PLANNING":lambdac:"need_research","SEARCHING":search_handler,"DRAFTING":draft_handler,"REVIEWING":review_handler,}这段代码里有几处设计取舍值得说明:
- 每个 handler 只返回“事件字符串”,绝不直接改状态。状态迁移的唯一入口是
wf.fire,这样所有跃迁都会经过守卫校验并落盘。如果让 handler 直接改ckpt.state,状态机就形同虚设了。 run_or_resume对“首次启动”和“恢复”走同一套循环。这正是检查点设计的价值——恢复不是一个特殊分支,而是主路径的自然延续。- 恢复时不用做任何“跳过已完成步骤”的特殊处理。因为已执行的副作用都记在
ckpt.effects里,call_tool_idempotent会自动跳过,业务代码零感知。
3.4 中断与人工介入
状态机天然适合做人工审核节点。当REVIEWING状态需要人拍板时,把事件来源从“自动”换成“外部输入”即可:
defreview_handler(ckpt):"""人审网关:此处可以阻塞等待,也可以直接挂起等 Webhook 唤醒"""ifckpt.context.get("auto_approve")andpasses_quality_gate(ckpt.context["draft"]):return"approved"decision=human_gate.wait_for_decision(ckpt.run_id)# 阻塞/挂起return"approved"ifdecision=="ok"else"rejected"human_gate的实现既可以是阻塞轮询,也可以是“写入检查点后直接退出进程,等人在后台点通过再触发resume”。后者更适合生产——人工审核可能要等几小时,不能让服务进程一直挂着。这正好又落回检查点的价值:因为状态已经落盘,进程可以安全退出,被唤醒时从REVIEWING原样恢复。
四、常见问题 FAQ
Q1:状态机的状态数量会不会爆炸?
不会。状态应该按“业务阶段”而不是“具体动作”来划分。一个典型任务 5~10 个状态足够。如果你发现状态超过 20 个,多半是把“循环内的重复动作”也拆成了独立状态,此时应该用“子流程”或“循环 + 索引”来建模,而不是继续加状态。
Q2:LLM 返回了非法事件怎么办?
两种情况要分开处理。如果返回的是不在枚举里的事件,说明结构化输出约束没生效,应直接判为可重试错误并记录;如果返回的是合法但守卫拒绝的事件,则应触发RETRY或NEED_HUMAN,而不是无限循环。永远给状态机配上最大重试次数和超时,否则一个守卫永远不通过的边会让任务卡死。
Q3:检查点存哪里?
小规模用 JSON 文件或 SQLite 足矣;生产环境推荐 PostgreSQL 存元信息 + 对象存储存大块产物(如长文档、日志),Redis 做热缓存。关键不是存储介质,而是保证写入原子性和读取一致性。文件方案里的os.replace、数据库里的事务,本质都在解决同一个问题。
Q4:恢复后模型会不会因为“忘了前面发生了什么”而做出错误决策?
这正是为什么检查点里要存“业务上下文 + 滚动摘要”。恢复时你不是把模型重放一遍,而是把关键结论喂给它。实践表明,恢复后的首轮决策质量,取决于摘要质量,而不是上下文长度。摘要写得好,恢复后几乎与从未中断一致。
Q5:状态机和 LangGraph、Temporal 这类框架冲突吗?
不冲突。LangGraph 本身就是状态图,Temporal 本身就是持久化工作流引擎。本文手写状态机是为了讲清楚原理,真正落地时完全可以(也应该)用这些框架。理解原理后你会发现,它们提供的能力——节点、边、条件转移、持久化、重放——和本文的四要素一一对应。
Q6:如何处理“整条流程需要回滚”的场景?
状态机擅长的是“向前恢复”,不是“事务回滚”。如果业务要求强回滚(比如一组操作要么全成功要么全撤),应该用 Saga 模式:每个正向动作配一个补偿动作,回滚时逆序执行补偿。状态机 + 检查点负责“记住走到哪”,Saga 负责“退回去”。两者经常一起用。
五、踩坑与优化建议
踩坑一:守卫写成“软约束”。很多团队把守卫写成“如果条件不满足就记个日志继续走”,结果状态机形同虚设。守卫必须是硬闸门:不满足就抛异常或转RETRY,绝不静默放行。
踩坑二:检查点里存了全量 messages。前面提过,这会让检查点体积爆炸且加剧漂移。正确做法是存摘要 + 结构化字段,原始消息在日志系统里留档即可(用于审计,不用于恢复)。
踩坑三:幂等键设计过粗或过细。过粗(比如全用run_id)会导致本该重试的调用被误跳过;过细(比如带时间戳)则完全失去去重效果。经验值是:幂等键应绑定“这次调用在业务上是否算重复”,通常由业务主键 + 动作类型构成。
踩坑四:恢复时不校验版本。如果代码升级后状态机多了一个状态,旧的检查点可能指向一个已不存在的状态。检查点里的version字段就是为此存在的——恢复时版本不匹配要能优雅降级(如从最近一个兼容状态重启),而不是直接崩溃。
优化建议一:给每个状态加超时和重试预算。状态机不只是管“往哪走”,也要管“走多久还不走就报警”。一个SEARCHING状态超时 5 分钟未产生事件,就该触发告警甚至强制转人工。
优化建议二:把状态转移做成可观测的指标。每个fire都发一条带trace_id的埋点,你就能在监控面板上看到“任务在哪个状态堆积最多”“哪条边的失败率最高”。这些是优化 Agent 最有价值的数据。
优化建议三:用状态机做成本控制。每个状态可以绑定自己的模型(简单判断用小模型、生成草稿用大模型)和 token 预算,超预算就转NEED_HUMAN。这比在单一超长对话里“祈祷模型省着点用”要可靠得多。
六、结语
把控制流交给概率模型,是当前很多 Agent 事故的根源。
更多硬核网安与AI工具包,请扫码获取完整源码!
而解法并不复杂:用状态机把“流程”钉死在代码里,用检查点把“进度”固化到存储里,让 LLM 只专注于它真正擅长的语义判断。确定性归确定性,概率归概率——这不是给 Agent 戴枷锁,而是给它一条走得稳、摔得起、看得见的轨道。当你的 Agent 能在第 18 步崩掉后从第 17 步的检查点毫发无损地续跑,能在发送前的最后一步稳稳停下等一个人点头,你就知道,它终于从一个“有趣的 Demo”变成了一个“可以托付的系统”。