1. 从一次任务中断说起:Agent循环执行的真实痛点
很多人第一次接触Agent开发,脑子里想的都是"给它一个目标,它自己跑完就行"。但真正把Agent放到生产环境里跑上几天,你会发现最头疼的问题根本不是模型聪不聪明,而是它跑到一半断了怎么办。
我最早做Agent项目的时候,踩过一个特别典型的坑:一个数据清洗Agent,需要依次处理200多个文件,每个文件都要调用模型做分类和摘要。跑到第87个文件的时候,接口超时了,整个进程直接挂掉。重启之后,它从第1个文件重新开始跑——前面86个文件的工作全部白费,而且因为重复处理,还产生了一堆脏数据。那次事故让我意识到,Agent的可靠性不取决于它单步有多强,而取决于它的运行机制有多健壮。
这就是"上下文、检查点、任务恢复、循环执行、资源管控"这一整套机制存在的意义。它们不是锦上添花的功能,而是Agent能不能从"玩具Demo"变成"生产工具"的分水岭。这篇文章我会把这五个环节拆开揉碎讲清楚:每个环节解决什么问题、内部是怎么运转的、实际落地时有哪些坑、以及我自己的项目里是怎么处理的。不管你是刚入门Agent开发,还是已经在做多Agent编排,这套运行机制的底层逻辑都值得吃透。
先给一个整体认知:一个健壮的Agent运行时,本质上是一个带状态的循环执行器。它每一轮做四件事——读取上下文、决定下一步动作、执行动作、把结果写回状态。而检查点和任务恢复,就是保证这个循环在任意一步崩溃后都能"接着上次的地方继续"。资源管控则是给这个循环装上刹车和油门,防止它失控烧钱或者卡死。
2. 上下文到底装了什么:Agent的"工作记忆"分层
2.1 上下文不等于聊天记录
很多人把Agent的上下文简单理解成"对话历史",这是最容易出问题的地方。对话历史只是上下文的一部分,而且往往不是最重要的那部分。一个成熟的Agent上下文,至少包含以下几层:
- 系统指令层:角色定义、行为约束、输出格式要求。这部分通常固定不变,放在上下文最前面。
- 任务状态层:当前任务的目标、已完成步骤、待办步骤、中间产物。这是Agent"知道自己走到哪了"的关键。
- 工具描述层:可用工具的schema、调用方式、参数说明。工具多了之后这部分会非常占token。
- 历史交互层:过去的动作和观察结果,也就是常说的trajectory。
- 外部检索层:从知识库、文件、数据库里动态拉进来的内容。
我见过太多项目把这几层混在一起塞进一个messages数组,结果就是上下文窗口很快被撑爆,而且模型经常"忘记"自己该干什么。正确的做法是分层管理、按需注入。
2.2 上下文窗口的预算分配
上下文窗口是有限资源,必须像管钱一样管它。我的经验是给每一层设一个token预算上限,比如一个128K窗口的模型,可以这样分配:
| 上下文层 | 建议预算占比 | 说明 |
|---|---|---|
| 系统指令 | 5% | 固定开销,尽量精简 |
| 任务状态 | 15% | 核心,不能省 |
| 工具描述 | 10% | 工具多时用检索动态加载 |
| 历史交互 | 50% | 最容易被撑爆,需要压缩策略 |
| 外部检索 | 20% | 按相关性动态调整 |
这个分配不是死的,但核心思路是:任务状态永远优先保留,历史交互可以压缩。当上下文快满的时候,先砍历史交互里最老的、最不相关的部分,而不是砍任务状态。
2.3 历史交互的压缩策略
历史交互的压缩是上下文工程里最考验功力的地方。我试过几种方案,各有适用场景:
第一种是滑动窗口,只保留最近N轮。简单粗暴,但会丢失早期的重要信息。适合任务步骤之间相对独立的场景。
第二种是摘要压缩,把老的交互用模型总结成一段话。好处是保留了语义,坏处是摘要本身可能丢细节,而且摘要也要花token和时间。
第三种是关键节点保留,只保留那些改变了任务状态的交互,中间的探索性动作全部丢弃。这个方案效果最好,但实现起来需要对"什么算关键节点"有明确定义。
我现在的项目里用的是混合策略:最近5轮完整保留,5到20轮做摘要,20轮以前只保留状态变更记录。实测下来,在长任务场景里能把上下文占用降低60%以上,而且任务完成率基本不掉。
提示:压缩历史交互时,一定要把"工具调用的原始结果"和"模型对结果的解读"分开处理。原始结果往往很长但可以丢弃,模型的解读才是真正需要保留的。
3. 检查点机制:让Agent拥有"存档"能力
3.1 检查点该存什么
检查点(Checkpoint)这个词借用了游戏存档的概念,核心思想是在关键节点把Agent的完整状态持久化下来。问题是,什么算"完整状态"?
我的定义是:只要能从检查点恢复出一个能继续执行的Agent,这个检查点就是完整的。具体来说,一个检查点至少要包含:
- 当前任务的唯一标识和进度指针
- 完整的任务状态(目标、已完成、待办、中间产物)
- 最近一轮的上下文快照
- 已执行动作的幂等标识(防止恢复后重复执行)
- 资源消耗的累计值(token数、调用次数、耗时)
注意最后两项,很多人会忽略。幂等标识是防止恢复后重复执行副作用操作的关键,比如"发邮件"这种动作,恢复后绝对不能重发。资源累计值则是资源管控的基础,恢复后要接着之前的消耗继续算。
3.2 检查点的触发时机
检查点不能太频繁,否则存储和序列化开销会拖垮性能;也不能太稀疏,否则崩溃后要重做的步骤太多。我的经验是三种触发方式结合:
按步骤触发:每完成一个"原子步骤"就存一次。原子步骤的定义是"要么全做完,要么全不做"的最小单元。比如"读取文件并解析"是一个原子步骤,"调用模型生成摘要"是另一个。
按时间触发:每隔固定时间(比如30秒)存一次,防止某个步骤卡死太久导致进度丢失。
按状态变更触发:当任务状态发生实质性变化时(比如从"处理中"变成"已完成"),强制存一次。
实际项目里我会把这三个条件做成"或"的关系,任意一个满足就触发检查点。存储介质用Redis做热存储、对象存储做冷备份,恢复时优先读Redis,读不到再读冷备份。
3.3 检查点的序列化陷阱
这里有个特别容易踩的坑:不是所有状态都能被序列化。比如你上下文里存了一个数据库连接对象、一个文件句柄、一个协程,这些都没法直接序列化成JSON。
我的处理原则是:检查点只存"数据",不存"资源"。数据库连接、文件句柄这类东西,恢复的时候重新建立就行,不需要存。真正需要存的是"我连的是哪个库、打开的是哪个文件"这种描述性信息。
还有一个坑是时间戳和随机数。如果检查点里存了"下次重试时间"这种基于当前时间算出来的值,恢复的时候时间已经变了,这个值就失效了。正确做法是存"重试次数"和"基准时间",恢复时重新计算。
# 不好的做法:存了绝对时间 checkpoint = { "next_retry_at": time.time() + 60 # 恢复后这个时间已经过了 } # 好的做法:存相对信息 checkpoint = { "retry_count": 3, "last_attempt_at": time.time(), "retry_interval": 60 # 恢复时用 last_attempt_at + retry_interval 重算 }4. 任务恢复:从断点续跑的完整链路
4.1 恢复流程的五个阶段
任务恢复不是简单地"读检查点然后继续跑",它是一条完整的链路。我把它拆成五个阶段:
第一阶段:状态校验。读取检查点后,先校验状态的一致性。比如检查点说"已完成87个文件",但实际输出目录里只有85个文件,这就说明状态和现实不一致,需要做对账。
第二阶段:资源重建。重新建立数据库连接、文件句柄、工具客户端等运行时资源。这一步要特别注意超时和重试,因为恢复时外部服务可能还没准备好。
第三阶段:幂等检查。对于检查点里标记为"执行中"的动作,要判断它到底执行完了没有。这个判断依赖幂等标识——如果动作有唯一ID,就去查这个ID对应的结果是否已经产生。
第四阶段:上下文重建。把检查点里的上下文快照加载回来,并根据需要补充最新的外部信息。注意这里不要盲目信任快照,有些信息可能已经过期了。
第五阶段:循环重启。从进度指针的位置继续执行主循环。
4.2 幂等性是恢复的生命线
我前面反复强调幂等,因为这是任务恢复最容易出事的地方。举个真实例子:一个Agent负责给客户发通知,流程是"生成通知内容→发送→记录发送状态"。如果在"发送"和"记录状态"之间崩溃了,恢复后Agent不知道通知到底发出去没有,如果重发就会造成客户收到两条。
解决这个问题的标准做法是两阶段提交:发送前先写一条"待发送"记录(带唯一ID),发送时带上这个ID,发送后把记录改成"已发送"。恢复时检查这个记录:如果是"待发送",说明可能没发出去,需要查询下游确认;如果是"已发送",直接跳过。
def send_notification_with_idempotency(notification, notif_id): # 第一阶段:写待发送记录 db.upsert("notifications", { "id": notif_id, "status": "pending", "content": notification }) # 第二阶段:发送(下游支持按ID去重) result = downstream.send(notification, idempotency_key=notif_id) # 第三阶段:更新状态 db.update("notifications", notif_id, {"status": "sent", "result": result})4.3 恢复失败的兜底策略
不是所有恢复都能成功。当恢复失败时(比如检查点损坏、外部依赖不可用),需要有兜底策略。我的做法是分级降级:
- 一级降级:回退到上一个检查点重试。适合当前检查点损坏的情况。
- 二级降级:从任务起点重新执行,但跳过所有已确认完成的步骤。适合检查点链断裂的情况。
- 三级降级:人工介入。当自动恢复连续失败N次后,把任务标记为"需人工处理",并输出详细的诊断信息。
这里的关键是恢复失败本身也要被记录和告警,否则你会以为任务在正常跑,实际上它已经卡在恢复循环里了。
5. 循环执行:Agent的心跳与节奏控制
5.1 主循环的标准结构
Agent的主循环看起来简单,但要写得健壮不容易。一个标准的主循环长这样:
def agent_loop(task, checkpoint_store): state = load_or_init_state(task, checkpoint_store) while not state.is_done: # 1. 检查资源预算 if not resource_manager.has_budget(state): state.mark_paused("budget_exhausted") break # 2. 构建上下文 context = build_context(state) # 3. 模型决策 action = model.decide(context) # 4. 执行动作 result = execute_action(action, state) # 5. 更新状态 state.update(action, result) # 6. 存检查点 if should_checkpoint(state): checkpoint_store.save(state) # 7. 循环保护 if state.loop_count > MAX_LOOPS: state.mark_failed("loop_limit_exceeded") break return state这七步里,第1步和第7步是很多人会漏掉的。没有资源检查,Agent可能烧光预算;没有循环保护,Agent可能陷入死循环。
5.2 死循环的识别与打断
Agent陷入死循环是常见故障。典型表现是:反复调用同一个工具、反复生成相似的内容、在两个状态之间来回跳。识别死循环有几个信号:
- 连续N轮的动作类型完全相同
- 连续N轮的上下文相似度超过阈值
- 状态指针长时间不前进
打断死循环的方式不能简单粗暴地"直接退出",因为有时候Agent确实需要重试。我的做法是分级干预:第一次检测到疑似循环,注入一条提示让模型"换个思路";第二次检测到,强制切换工具或策略;第三次还不行,才终止任务并标记失败。
5.3 循环的节奏控制
Agent跑得太快会烧钱,跑得太慢会超时。节奏控制的核心是根据任务类型和资源状况动态调整。比如:
- 探索性任务(需要大量试错)可以跑快一点,但要有预算上限
- 精确性任务(如数据处理)要跑稳一点,每步都要校验
- 当剩余预算紧张时,自动切换到"保守模式",减少模型调用、增加规则判断
我在项目里会给每个任务配一个"节奏档位",从"激进"到"保守"分五档,根据实时资源消耗自动切换。这个机制在长任务里特别有用,能显著降低尾部任务的失败率。
6. 资源管控:给Agent装上刹车和仪表盘
6.1 需要管控哪些资源
Agent消耗的资源比普通程序复杂,至少包括:
| 资源类型 | 计量单位 | 典型限制 |
|---|---|---|
| 模型token | 输入+输出token数 | 单任务上限、单日上限 |
| 工具调用 | 调用次数 | 按工具分别限制 |
| 执行时间 | 秒 | 单步超时、总时长 |
| 存储 | 字节 | 检查点、中间产物 |
| 并发 | 同时运行的任务数 | 全局上限 |
这些资源里,token和执行时间是最容易失控的。我见过一个Agent因为工具返回了超大结果,单次调用就吃掉了80%的上下文预算。
6.2 预算的分配与回收
资源管控的核心是预算制。给每个任务分配一个总预算,任务内部再细分到每个步骤。关键设计点:
预算是硬约束还是软约束?我的经验是硬约束用于防止灾难,软约束用于优化。比如"单任务token上限"是硬约束,超了就停;"单步token建议值"是软约束,超了只是告警。
预算怎么回收?如果一个步骤实际消耗远低于预算,剩余部分应该回收给任务总池,供后续步骤使用。这个机制能让预算利用率提升30%以上。
预算耗尽怎么办?不能直接杀掉任务,而应该优雅暂停:保存检查点、记录进度、通知用户、等待补充预算后恢复。这样任务不会白跑。
6.3 实时监控与告警
资源管控离不开监控。我建议至少监控这几个指标:
- token消耗速率:突然飙升往往意味着Agent在死循环或处理异常大的输入
- 工具调用成功率:成功率骤降说明外部依赖出问题了
- 单步耗时分布:长尾步骤是超时的主要来源
- 检查点保存频率:频率异常说明状态在剧烈变化
告警阈值不要设死,用动态基线更好。比如token消耗速率超过过去1小时均值的3倍就告警,比设一个固定值更灵敏。
注意:资源管控的粒度要适中。管得太细,每个动作都要检查,开销大;管得太粗,等发现超了已经晚了。我的经验是在步骤边界检查,在动作内部采样。
7. 五个机制的协同:一个完整的运行实例
7.1 场景设定
假设有一个"批量文档处理Agent",任务是处理500个PDF,每个PDF要做:提取文本→分类→生成摘要→存入数据库。我们看看五个机制怎么协同。
上下文:系统指令固定;任务状态记录"已处理到第N个";工具描述包含PDF解析、分类、摘要三个工具;历史交互只保留最近3个文档的处理记录;外部检索按需加载分类规则。
检查点:每处理完一个PDF存一次,包含进度指针、已处理ID列表、累计token消耗。
任务恢复:如果处理到第237个时崩溃,恢复时读取检查点,校验第237个是否真的处理完(查数据库),然后从第238个继续。
循环执行:主循环每轮处理一个PDF,包含四个子步骤。循环保护设置为"连续10个PDF处理失败就暂停"。
资源管控:总预算100万token,已用47万;单文档预算2000token,超了就告警;总时长限制2小时,超时优雅暂停。
7.2 崩溃恢复的完整走查
假设在第237个文档处理到"生成摘要"这一步时,模型接口超时导致进程崩溃。恢复流程:
- 读取检查点,发现进度指针指向第237个,状态是"处理中"
- 查数据库,发现第237个文档的摘要还没入库,说明这一步没完成
- 重建模型客户端、数据库连接
- 从"生成摘要"这一步重新执行第237个文档
- 成功后更新检查点,继续第238个
整个过程对用户透明,任务看起来只是"卡了一下又继续了"。
7.3 资源耗尽的优雅处理
假设处理到第400个文档时,token预算只剩5万,按当前速率只够处理20个。这时候资源管控触发:
- 主循环检测到预算不足,标记任务为"暂停"
- 保存检查点,记录"已处理400个,剩余100个"
- 发送告警通知用户
- 用户补充预算后,从第401个恢复
如果没有这套机制,Agent可能会在第420个文档时突然报错退出,前419个的工作虽然保住了,但用户不知道发生了什么,也不知道从哪继续。
8. 落地时的经验与踩坑记录
8.1 检查点存储的性能陷阱
我最早用关系型数据库存检查点,每个检查点一条记录,包含一个大JSON字段。任务跑起来之后,检查点写入成了瓶颈——每秒几十次写入,数据库压力很大。后来改成Redis存最新检查点+对象存储存历史检查点,写入性能提升了两个数量级。
还有一个坑是检查点太大。如果上下文快照包含完整的历史交互,一个检查点可能几MB。我的优化是检查点只存状态,不存完整上下文,恢复时根据状态重建上下文。这样检查点能控制在几十KB。
8.2 恢复时的"幽灵状态"
有个特别隐蔽的bug:恢复后Agent的行为和崩溃前不一致。排查了很久才发现,是因为检查点里存了"模型上次返回的原始文本",但恢复时模型版本更新了,同样的上下文返回了不同的结果,导致状态机走岔了。
解决办法是检查点里存"决策结果"而不是"决策依据"。也就是说,存"下一步要调用分类工具,参数是X",而不是存"模型说应该调用分类工具"。这样恢复后直接执行决策,不依赖模型重新决策。
8.3 资源管控的"误杀"
资源管控太严格会误杀正常任务。我有一次把单步超时设成10秒,结果一个正常的PDF解析步骤因为文件特别大,跑了12秒被强制中断,任务失败。后来改成动态超时:根据历史同类步骤的耗时分布,设置P99耗时作为超时阈值,再留20%余量。这样既不会误杀,又能及时发现真正的卡死。
8.4 循环保护的"假阳性"
死循环检测也会误报。有些任务确实需要连续调用同一个工具(比如批量查询),如果检测逻辑太激进,会把正常任务判成死循环。我的经验是结合多个信号判断:不仅看动作类型,还要看参数是否变化、状态是否前进、上下文是否收敛。只有多个信号同时报警,才判定为死循环。
8.5 上下文压缩的信息丢失
前面提到用摘要压缩历史交互,这里有个坑:摘要可能丢掉关键的失败信息。比如Agent之前尝试过一个方案失败了,摘要里只写"尝试了方案A",没写"方案A失败了因为X",恢复后Agent可能又去试方案A。
解决办法是在摘要里强制保留"失败记录"和"约束条件",这两类信息对后续决策至关重要,不能压缩掉。
9. 从单Agent到多Agent:机制的扩展
9.1 多Agent场景下的上下文隔离
单Agent的上下文管理相对简单,多Agent就复杂了。每个Agent有自己的上下文,Agent之间通过消息传递信息。这里的关键是上下文隔离:Agent A的完整上下文不应该直接塞给Agent B,而应该只传递B需要的那部分。
我的做法是给Agent之间定义消息契约:发送方只发结构化消息(任务、数据、约束),接收方根据自己的角色构建上下文。这样既保护了各自的上下文预算,又避免了信息过载。
9.2 检查点的分布式协调
多Agent协作时,检查点不能各存各的,否则恢复时状态对不上。需要有一个协调者来管理全局检查点:当所有Agent都到达某个同步点时,协调者统一保存全局状态。
这个同步点的选择很关键。太频繁,协调开销大;太稀疏,恢复时回退太多。我的经验是在任务的关键里程碑设同步点,比如"所有Agent完成数据收集"、"所有Agent完成分析"。
9.3 资源管控的全局视角
多Agent场景下,资源管控要有全局视角。单个Agent可能都没超预算,但加起来超了。所以需要一个全局资源池,每个Agent从池子里申请预算,用完归还。当池子见底时,所有Agent都要降速或暂停。
这个机制实现起来要注意避免死锁:Agent A持有预算等Agent B的结果,Agent B等Agent A释放预算。解决办法是预算申请带超时,超时后释放已持有的预算并重试。
10. 我个人的一些实践体会
这套运行机制我打磨了大概一年多,从最早的"能跑就行"到现在的"生产级健壮",中间踩的坑比写这篇文章的字数还多。如果让我给刚入门的同学几条建议,我会说:
第一,检查点要早做。不要等到任务开始失败了才想起来加检查点,那时候改造成本很高。从第一个Agent项目开始,就把检查点机制设计进去。
第二,资源管控要留余量。预算不要卡得太死,留20%的缓冲。真实环境里的消耗总是比预估的高,卡太死会导致大量任务在最后关头失败。
第三,恢复逻辑要单独测试。不要指望"崩溃了自然就能恢复",要主动模拟崩溃:在随机步骤kill进程、注入网络超时、模拟数据库不可用。只有反复测试过的恢复逻辑才是可靠的。
第四,上下文压缩要有损可查。压缩掉的信息不是消失了,而是"降级"了。要保证需要的时候能找回原始信息,比如把完整历史存到对象存储,上下文里只放摘要和指针。
第五,监控比管控更重要。很多时候你不需要主动干预,只需要能看见Agent在干什么。一个好的监控面板,能让你在问题变大之前就发现苗头。
最后分享一个我最近在用的技巧:给Agent的每个循环轮次打一个"健康分",综合考量资源消耗速率、动作多样性、状态前进速度等指标。健康分低于阈值时自动降速或暂停。这个机制帮我避免了好几次潜在的失控,比单纯的硬性限制灵活得多。