你有没有仔细想过一个问题:一个Agent在跑长任务的时候,如果进程崩了、API超时了、或者跑到一半被运维喊停,它到底该怎么办?我在实际维护Agent服务的过程里见过太多类似场景:第一个月风平浪静,第二个月开始频繁出现“重复执行”“上下文混乱”“任务跑到一半就废了”的现象。追根溯源,几乎都是同一件事——只写了Agent的主循环,却没有设计它的运行机制。
这篇文章想把我在23.3版本里沉淀下来的一套做法完整拆开来讲。标题里的五个关键词:上下文、检查点、任务恢复、循环执行、资源管控,看起来是五件事,实际上是一个整体。它们解决的是同一个问题:如何让Agent像有状态的服务一样,能在无人值守环境中安全、稳定、可恢复地运行。这套机制不是某个开源框架的专属功能,而是任何一个要把Agent做成“长生命周期任务”的架构都必须面对的基础设施问题。适合正在自研Agent服务的开发者、做任务调度与运维的同学,以及刚接触Agent架构但被各种概念绕晕的初学者。
1. 先说结论:Agent运行机制到底在解决什么问题
很多人一上来就陷入“Agent怎么调工具选模型”的细节,却忽略了一个更基础的问题:一个任务从开始到结束,靠什么把状态稳定地维持住?Agent不是一次函数调用,它是一段可能持续几分钟、几小时甚至几天的执行过程。只要时间拉长,崩溃、限流、超时、重启就都是常态,而不是意外。
1.1 一次凌晨事故,让我决定重写运行机制
举个例子。某天凌晨3点,我们内部一个负责批量数据处理的Agent正在跑定时任务。2点50分时上游数据源接口超时,导致整个链路卡住。当时的重试逻辑很简单——异常就重试。于是Agent在接下来的十分钟里反复重试,重试成功后,它又因为没记住自己已经处理到哪个批次,把同一批数据任务重新跑了一遍。当天早上,下游系统收到了一堆重复的数据写入,检查点、任务恢复这些概念当时完全不存在。
那次事故之后,我们做了一次复盘,发现问题的本质不是重试策略不够好,而是Agent缺少一套完整的“运行机制”来约束自己:上下文没有持久化,崩溃后一切归零;恢复逻辑没有设计,重试变成了重复执行;循环没有资源护栏,一次失误就能烧掉大量预算。从那天起,我决定从头梳理这套机制,也就是后来23.3版本里的核心框架。
1.2 五块机制是一个整体,不是五件独立的事
这五块机制的关系,我习惯用下面这张表来理解:
| 机制 | 作用 | 通俗类比 |
|---|---|---|
| 上下文 | 工作记忆 | 任务当前做到哪一步、看到了什么 |
| 检查点 | 快照能力 | 把工作记忆定期存到磁盘 |
| 任务恢复 | 容错能力 | 从快照中重建现场,继续工作 |
| 循环执行 | 主循环 | 驱动Agent反复决策、执行、观察 |
| 资源管控 | 护栏 | 保证循环不会失控烧钱 |
它们不是并列的五个模块,而是有依赖顺序的:先有上下文,才能谈检查点;有检查点了,才有资格做任务恢复;恢复动作不能脱离循环独立存在,它必须挂在主循环的特定节点上;而循环一旦跑起来,资源管控就成了最后一道安全网。在23.3版本的设计里,我把顺序理解为“上下文是数据,检查点是存储,恢复是加载与重建,循环是调度框架,资源管控是安全边界”,缺一个,整套机制都会出现裂缝。
1.3 这套机制适合谁、不适合谁
先说结论:如果你的Agent只是做单轮问答、一次性Demo、或者用户主动触发的短任务,那这套机制确实过于重了,不需要为每个请求都落检查点。但如果你的Agent要做这些事情,它就是刚需:
- 无人值守的定时批处理任务
- 需要调用多个外部工具、跨多个步骤的长链路任务
- 需要并行运行多个Agent实例的场景
- 预算敏感、需要精确控制token消耗的场景
反过来说,如果你的场景只是“用户问一句,Agent答一句”,那完全不需要。过度设计同样是一种成本。我在早期就犯过这个错误:为一个演示项目加了一整套检查点体系,最后发现检查点的读写时间比任务本身还长,纯属浪费。这套机制的核心判断标准只有一个——你的Agent会不会“跑一半”,如果会,那它就必须有完整运行机制。
2. 上下文:Agent的“工作记忆”是怎么被管理的
上下文是整套机制的地基。它决定了Agent能看到什么、能记住什么、能不能做出连续一致的决策。但很多项目对上下文的管理方式非常原始:把对话历史一股脑塞进一个列表,超了就截断,完全没有把上下文当作一种需要精细化运营的资源。
2.1 上下文不只是提示词,而是任务状态的主体
大多数人把上下文理解成“聊天记录”,这个理解不够准确。在长任务场景里,上下文本质上承载的是整个任务的状态,包括:用户目标、系统指令、工具调用历史、工具返回结果、Agent的中间决策、关键变量的当前取值。当Agent调用一个工具时,工具的输出必须回到上下文中,下一次决策才能基于这个结果继续推进一步。
举一个具体例子。一个调研类Agent需要完成“调查某行业竞品并输出报告”这个任务。第一轮它搜索了行业整体概况,得到结果A;第二轮它基于结果A继续深挖其中一家公司,得到结果B。如果我们在管理上下文时只保留“最终结论”,而丢弃了中间搜索到的A,那Agent到了第三轮就会失去“为什么当时要深挖这家公司”的依据,后续决策很容易跑偏。所以在23.3版本里,我们把上下文分成两个层次:一个是给模型看的“活动窗口”,另一个是完整保存在检查点里的“任务账本”。活动窗口负责让模型做决策,任务账本负责让系统做复现。
2.2 token预算怎么算:一个真实计算例子
上下文管理的第一个现实约束是token窗口。很多人以为“只要不超过窗口大小就行”,但真正的问题在于:模型请求的token占用是输入加输出一起算的,你在设计每一轮时就必须为模型的输出预留空间。
假设某个任务的配置如下:
- 系统指令:500 token
- 用户任务描述:300 token
- 每轮模型输出:600 token
- 每轮工具返回结果:2000 token
那么当前上下文占用 = 初始占用 + Σ(每轮模型输出 + 每轮工具结果),同时还要为下一轮输出预留至少600 token。我们算一下:
| 轮次 | 本轮结束后上下文占用 | 距离窗口上限(8000)的余量 |
|---|---|---|
| 第1轮 | 800 + 600 + 2000 = 3400 | 4600 |
| 第2轮 | 3400 + 600 + 2000 = 6000 | 2000 |
| 第3轮 | 6000 + 600 + 2000 = 8600 | 已超限 |
这个表说明一件很重要的事:上下文不是“快满了才处理”,而是每一轮结束都要算账。第3轮结束就已经超限,所以第4轮开始之前就必须完成压缩或截断。如果不做这个计算,请求会直接报错,或者被迫丢弃关键内容。我在23.3版本里做了一块独立的上下文预算计算组件,每轮主循环结束时自动执行一次,把“还差多少到上限”变成每一步都可见的指标。
2.3 上下文压缩与截断策略
当上下文逼近窗口上限时,有两种主流处理方式:摘要压缩和滑动窗口截断。它们适用的场景完全不同。
摘要压缩是把旧消息交给模型生成一段摘要,用摘要替换原始记录。优点是保留全局信息,缺点是会丢细节,而且压缩过程本身要消耗额外的token。如果你的任务是深度分析、代码生成这类对细节极其敏感的类型,摘要丢掉的某个参数可能比整段删掉更致命。我见过一个代码生成Agent,压缩时丢了一个关键字段的说明,后续生成的代码全错,排查了很久才定位到是压缩环节的问题。
滑动窗口截断是只保留最近N轮原始消息,更早的全部丢弃。优点是不额外消耗token、速度快,缺点是Agent会彻底遗忘早期目标。对于检索型任务,比如“从一批文档中找到指定信息”,滑动窗口通常够用,因为每轮工具结果里已经携带了检索到的关键片段。在23.3版本里,我们的默认策略是:核心目标指令永远固定在上下文头部不被截断,工具返回体超过50KB的只保留摘要和引用路径,中间历史超过20轮后启用摘要压缩。这个组合基本覆盖了绝大多数长任务场景。
2.4 多任务场景下的上下文隔离
如果你的系统同时跑多个Agent,上下文管理还要考虑隔离问题。否则A任务的工具结果被B任务读取,决策必然混乱。在23.3版本里,每个任务的上下文都有独立的命名空间,所有追加消息都带着task_id,读取时只允许读取当前任务的数据,任务结束后统一清理。
还有一个容易被忽略的点:并发写同一任务上下文会造成消息乱序。比如两个异步步骤同时向同一个上下文里追加消息,后写入的未必排在后面,模型看到的消息顺序和实际执行顺序不一致,整个推理链就断了。任何基于内存或数据库的上下文存储,都必须加并发控制。我一般建议用单写者模型:每个任务同一时间只有一个执行协程在写上下文,其他协程只能读。这个设计看起来损失了一点并发性能,但换来的是极高的状态可预测性。
3. 检查点:把Agent运行状态“拍扁”保存下来
检查点这个名字来自机器学习训练,但在Agent场景里,它的含义更接近“存档”。它不仅保存模型权重(实际上不保存权重),而是保存一遍完整的、可重建的任务状态。没有检查点,一切关于恢复的讨论都是空谈。
3.1 检查点里到底要存什么
我见过不少项目把检查点简单实现成“把messages列表存成JSON”,这是不够的。一个可用的检查点至少需要包含以下内容:
| 字段 | 作用 |
|---|---|
| 任务ID与阶段标识 | 定位这是哪个任务、进行到哪个阶段 |
| 完整消息序列 | 恢复时重建上下文 |
| 待执行动作队列 | 还没执行的计划步骤 |
| 已执行动作清单 | 防止恢复后重复执行,含工具名、参数、返回结果、时间 |
| 状态机当前状态 | 当前处于哪个业务阶段 |
| 已消耗token与剩余预算 | 资源管控的续算依据 |
| 检查点版本号与创建时间 | 版本兼容判断与审计追踪 |
同时也要想清楚什么不应该存。明文API密钥绝不落盘,大体积二进制数据不直接存,工具返回的文件只保存路径引用。这一点我们早期踩过坑:某个工具返回了10MB的图片数据,我们直接塞进了检查点,结果每次落盘要几秒钟,把主循环拖慢了一个量级。后来改成存引用路径,落盘时间直接降到几十毫秒,任务恢复的时候再按路径重新读取文件内容。
3.2 什么时候落盘:策略与频率
检查点的落盘时机有两种常见选择:定时和事件驱动。定时策略是每隔30秒或1分钟保存一次,实现简单,但它有一个致命问题——可能在任务执行到一半、上下文还没更新完的时候保存,恢复出来是半截状态。事件驱动策略则是在关键节点落盘,更符合Agent任务的执行特征。
我推荐的做法是以事件驱动为主,落盘点包括:
- 一次完整循环结束(模型输出和工具返回都已写回上下文)
- 工具调用前(预执行检查点,用于判断副作用是否已经发生)
- 资源预算扣减后
- 状态机发生状态转移时
其中“工具调用前”这个落盘点特别重要,它和幂等设计配合,可以解决恢复时重复执行副作用的问题。这一点后面还会展开说。频率上不需要每个事件都落盘,每轮循环结束保存一次,加上工具调用前保存一次,已经足够覆盖绝大多数恢复场景。过度频繁的落盘会拖慢主循环,需要根据任务的耗时和风险来平衡。
3.3 原子写与版本管理:别让落盘本身出问题
检查点文件本身的写入方式也有坑。如果直接用文件句柄写,写到一半进程崩溃,留下的就是一个损坏的半截文件,下次恢复直接失败。正确的做法是原子写:先写临时文件,写入并fsync完成后,再用rename覆盖旧文件。这样任何时刻检查点目录里只有一个完整可用的文件。
版本管理同样不能省。检查点文件头必须记录schema_version,同时记录Agent代码版本和模型版本。为什么?因为模型换版本后,旧检查点不一定能恢复——上下文结构可能变了,工具的接口可能变了,状态机的定义可能不同了。在23.3版本里,恢复组件第一步就是校验版本,不匹配直接拒绝自动恢复,转人工处理。这一点看起来机械,但能避免大量“恢复后行为异常但找不到原因”的问题。
4. 任务恢复:从崩溃点接着跑
有了检查点,恢复才有意义。恢复的目标不是“重新执行一遍任务”,而是“从崩溃点继续”。但“继续”这个词背后有很多细节:继续哪些动作?跳过哪些动作?异常的外部副作用怎么处理?这几个问题不回答清楚,恢复机制就是半成品。
4.1 恢复的本质:状态重载 vs 状态回放
恢复实现有两条路线:状态重载和状态回放。通俗地类比,状态重载像是从相册里取出现场照片,直接把现场布置还原;状态回放则像是把录像倒回去重新播放,让事件再次发生、状态自然重建。
状态重载是更常用的方案。它直接从检查点加载消息序列和待执行队列,塞回主循环继续跑。优点是恢复速度快、实现简单,缺点是完全依赖检查点保存的完整性,一旦缺字段就没法继续。状态回放则需要保存完整的操作日志,恢复时从头读取日志,重放每一步操作来重建状态。它不依赖序列化完整性,恢复过程本身就是一次完整的任务执行,适合那些外部依赖无法序列化的场景。两条路线的取舍用一张表说明:
| 对比项 | 状态重载 | 状态回放 |
|---|---|---|
| 恢复速度 | 快 | 慢 |
| 依赖 | 检查点完整性 | 日志完整性与幂等性 |
| 适用场景 | 大多数Agent任务 | 外部依赖复杂、需要重新确认状态的场景 |
在23.3版本里,默认用状态重载,但在两类场景强制回放:一类是任务的工具调用链非常长,检查点可能遗漏边缘状态;另一类是恢复时需要向用户重新确认某些关键指令的合法性。
4.2 恢复后的三种处理路径
恢复动作不是只有“继续执行”一条路。回到现场之后,具体怎么走取决于当前状态和风险等级。
第一种是直接继续。状态完整、检查点版本匹配、没有异常副作用,就从待执行队列尾部继续。第二种是回退一步。如果工具返回了不可靠结果,或者上下文里出现了预期之外的脏数据,可以回退到上一次检查点,修正参数后重新执行。第三种是终止并人工介入。当状态损坏无法恢复,或者关键动作的副作用无法确认时,最好的决策是停下来,把问题抛给人。尤其是涉及外部写操作的工具,比如发邮件、创建订单、修改数据,如果它可能已经执行但结果未知,自动恢复的风险非常高。我定了一条规则:非幂等工具的动作,恢复时一律转人工确认,宁可慢一步,不能错一次。
4.3 幂等设计:恢复中最容易忽略的坑
恢复机制里最容易踩的坑就是重复执行副作用。一个工具调用如果已经发出去了,邮件已经发了、订单已经建了,这时候恢复机制重放了整个动作列表,就会造成重复发件、重复下单。这类问题比任务失败更严重。
所以要引入幂等设计。检查点里维护一个“已执行动作清单”:每个工具调用分配一个全局唯一的action_id,工具请求带上幂等键,外部系统根据幂等键去重。恢复时先查清单,已完成的动作直接跳过,只执行未完成的部分。前面提到的“工具调用前写预执行检查点”也在这里发挥作用:如果检查点显示动作已经发出但返回结果没有成功写回,系统会知道这个动作“可能已执行”,从而选择人工确认而不是自动重放。我最终在23.3版本里把工具分成了两类:幂等工具(查询、读取、无状态计算)可自动恢复;非幂等工具(写入、通知、扣费)必须有幂等键才能自动恢复,否则强制人工确认。
5. 循环执行与资源管控:让Agent永远在“可控的圈”里转
Agent的本质是一个循环:决策,行动,观察,再决策。循环执行机制负责把这个循环稳定地驱动起来,资源管控负责让循环永远在可控范围内。这两个机制往往被分开考虑,但实际上它们是同一个安全系统的两面。
5.1 Agent主循环是怎么转起来的
用一个简化的伪代码来展示23.3版本里主循环的核心逻辑:
while not terminated: # 1. 让模型基于当前上下文做决策 decision = run_llm(context) # 2. 根据决策类型分派动作 if decision.action == "call_tool": tool_result = execute_tool(decision.tool, decision.args) context.append(decision, tool_result) checkpoint() # 一轮完整循环结束后落盘 elif decision.action == "finish": mark_task_done() break else: # 模型输出了一条普通消息,也会更新上下文 context.append(decision) checkpoint()这段逻辑看起来简单,但真正的工程困难在于每一步都要回答三个问题:状态更新了吗?检查点保存了吗?资源账扣了吗?三个问题缺一个,循环就会产生不可恢复的偏差。我在早期版本里把检查点放在循环外面,结果就是任务跑到一半崩溃,之前所有步骤都白费,只能从头开始。移到每轮循环末尾之后,最坏情况也只是损失一轮的进度。
5.2 循环的终止条件与防失控设计
循环必须终止,而且必须有多层终止条件。只依赖Agent自己输出“我完成了”远远不够,因为模型在长上下文中可能产生幻觉,可能忘了目标,甚至可能在一次工具调用后认为任务已完成但实际上没有。
23.3版本中的循环终止条件由四个硬性条件组合构成:
- Agent的finish信号
- 最大轮数限制,根据任务复杂度设为10到30
- token预算耗尽
- 总执行时间超时
这四个条件任何一个触发,循环都会停止,进入对应的收尾逻辑。除终止条件外,还要加两道防失控手段。第一道是动作去重表,记录本轮任务已经尝试过的动作及参数,同一个动作重复出现达到一定次数就强制打断并转告警。第二道是工具异常退避机制,连续失败三次后不再重试,避免在同一个坑里反复消耗预算。我见过一个没有轮数上限的Agent连续调用搜索工具50次,直到预算耗尽才停下来,那正是因为它缺少了最基本的防失控设计。
5.3 token、时间、并发三类资源怎么管
资源管控的核心是预算化:每一项资源都要有总额、有账户、有扣减记录、有告警阈值。在Agent场景里,最重要的三类资源是token消耗、执行时间和并发额度。
token预算的设定不能拍脑袋。我的做法是先按任务复杂度预估一个基准值,再乘以1.5的安全系数。比如一个任务预计需要20轮循环、每轮平均消耗3000 token,那么基准值就是60000 token,预算设90000 token。每轮结束根据模型的usage字段累加消耗,预算低于20%时触发告警,并自动降级策略:比如切换更小的模型档位,或者先压缩上下文再继续。
时间预算和token预算类似,分两个层级:单轮超时和任务总超时。单轮超时主要防工具卡死,默认设为30秒,超时中断本轮并把这个工具标记为异常。任务总超时防止Agent陷入无声的长期空转,比如总时长上限设为7200秒,到了就强制停止并落盘检查点。并发这一块用信号量控制同时运行的Agent数量,防止任务数量过多把上游API打爆。排队等待的任务要注意上下文的时效性,在队列里等太久,恢复时需要考虑上下文是否仍然有效。
5.4 一个完整的资源管控流程
把上面这些东西串起来,一个完整的资源管控流程是这样的:
任务开始时,资源管理器先分配三样东西:token配额、任务时限、并发额度。然后才进入主循环。每轮循环结束后,资源管理器累加本轮token消耗,更新剩余预算,并把预算数据写入检查点。如果剩余预算低于告警阈值,触发降级策略;如果已经耗尽,直接中断循环。每个工具调用都受单轮超时约束,超时则记录异常并进入退避逻辑。任务结束时,无论成功还是失败,都要回收并发额度并清理临时资源。
一个可落地的配置示例是这样的:
MAX_STEPS=20 MAX_TOKENS_PER_TASK=90000 MAX_TASK_SECONDS=7200 TOOL_TIMEOUT_SECONDS=30 BUDGET_ALERT_RATIO=0.2 CHECKPOINT_EVERY_STEP=true这些配置建议用环境变量或配置中心下发,而不是硬编码在代码里。因为在生产环境里,不同任务的资源需求差异可能超过一个数量级,写死在代码里意味着每次调整都要发一次版本,非常低效。
6. 常见问题与排查技巧实录
机制设计得再完整,实际运行中总会遇到各种边界情况。这一节把我在23.3版本落地过程中实际遇到过的典型问题记录下来,供你排查时对照。
6.1 恢复后上下文错乱
现象:Agent从检查点恢复后,说话前言不搭后语,甚至忘了任务目标。
这种情况十有八九是消息序列保存出了问题。我排查的第一步是打印恢复后的前三条和后三条消息,检查角色顺序是否正常——正常情况下应该是system、user、assistant、tool交替出现,如果出现连续两个assistant或者tool消息没有对应的调用记录,那基本可以定位到保存时消息次序被破坏了。第二步是确认检查点文件头的schema_version和代码版本是否一致,如果Agent代码已经升级而检查点还是旧结构,恢复后当然对不上。第三就是检查是否在压缩环节引入了摘要替换,摘要生成的模型版本变了,压缩出来的信息侧重点完全不同,也会导致上下文混乱。
解决方案是在恢复组件里增加schema校验,消息至少包含role、content、create_time、sequence_id四个字段,缺少任何一个就拒绝恢复并转人工。这个校验在刚开始看起来多余,但能挡掉大量难以排查的疑难杂症。
6.2 检查点文件越来越大
现象:任务跑几个小时后,检查点文件膨胀到几百MB,落盘越来越慢。
这不是检查点机制的问题,而是我们在里面存了不该存的东西。最常见的是工具返回的大体积数据,比如图片、长文本网页内容,全量塞进消息序列后,检查点体积自然爆炸。解决思路是分层存储:小体积数据内联在检查点文件里,超过50KB的数据只存摘要和路径,恢复时按路径重新读取。另一个优化是设置历史轮次的保留上限,超过20轮的历史消息只保留摘要,原始内容定期清理到单独的归档存储里。
另外,检查点目录本身也要有生命周期管理。一个跑了很久的系统会积累大量旧版本检查点文件,需要定期清理,只保留最近N个版本用于回退,更早的转入冷存储或直接删除。
6.3 循环不收敛
现象:Agent反复执行同一个工具,输出几乎一模一样,进入明显的死循环。
这类问题的第一道防线是动作去重表。在23.3版本里,每次执行工具前先查表,如果发现完全相同的动作已经执行过多次,系统直接中止这一轮循环并产生告警。但如果你的系统还没加这个机制,排查时就要检查上下文里是否真的有上一次工具动作及结果。一个很常见的原因是工具返回内容没有有效进入上下文,导致模型每轮都是基于旧的决策信息做判断,自然重复执行。
还要看模型输出里的tool_call_id是否在复用。如果模型在补全时错误地复用了同一个工具调用ID,外部系统会认为是同一个请求,工具返回结果也可能一样,看起来就是循环不收敛。这种情况可以给动作去重表加上一层对tool_call_id的校验。
6.4 外部副作用重复执行
现象:一次崩溃恢复了任务,结果外部系统出现了重复订单、重复邮件。
这是我反复强调最严重的一类问题,原因通常是恢复时把已经执行过的动作当成了待执行动作。排查时第一步确认“已执行动作清单”是否完整保存、是否正确加载;第二步确认工具调用是否带了幂等键,外部系统是否根据幂等键去重。如果两个问题都存在,就说明不只是配置问题,而是机制设计缺陷。
根治方案是把工具按是否幂等分类,非幂等工具的动作记录必须包含完整的入参和执行状态,恢复时强制人工确认。这个规则一旦定下来,就在代码层面彻底堵住了“重放副作用”的可能。
6.5 快速排查清单
最后整理一份快速排查表,适合你在生产环境里快速定位问题:
| 现象 | 优先检查项 | 兜底方案 |
|---|---|---|
| 恢复后上下文错乱 | 消息序列顺序、schema版本 | 校验失败转人工 |
| 检查点文件过大 | 大体积工具返回、历史消息堆积 | 分层存储加定期清理 |
| 循环不收敛 | 动作去重表、上下文是否包含结果 | 轮数上限强制中断 |
| 外部副作用重复 | 已执行动作清单、幂等键 | 非幂等工具强制人工确认 |
| 任务静默卡死 | 单轮超时设置、总超时设置 | 强制中断并保存检查点 |
最后再分享一个我在这个版本里踩过的最深的坑。最初我把检查点放到了工具调用之后,以为这样一定能恢复到最新一步,结果工具已经执行了、副作用已经发生了,检查点却因为进程崩溃没写成功,恢复后系统重新执行了工具,造成了实际影响。后来改成“工具调用前先写预执行检查点,工具返回后再写后置检查点”,配合幂等键,才把重复执行这个问题彻底堵死。这套机制说到底不是为了架构上的完美,而是为了让你敢把Agent放到真正无人值守的环境里,同时确保每一次意外都能被兜住。