把Agent从Demo推上真实业务,最大的分水岭往往不是模型选得多强,而是你有没有一套能约束它、能让它出错后接着干的运行机制。最近几个月我一直在做某跨平台自动化系统的Agent调度层,踩的坑基本就三类:任务跑到一半上下文乱了,执行到第N步崩溃后只能从头再跑,以及没人盯着预算结果一次任务烧掉大量Token。后来把上下文、检查点、任务恢复、循环执行、资源管控这五块完整串起来之后,这套系统才算真正能稳定用了。这篇文章就把这套机制完整拆开讲一遍,按我们内部工程基线的编号,叫它23.3方案,讨论的是完整运行流程的拆解、设计理由和落地中的各种细节。适合正在写Agent框架、做自动化任务编排、或者被长任务稳定性折磨的开发者参考。
1. 为什么单次模型调用撑不起Agent长任务
1.1 单次调用本质上是个“无状态函数”
很多人刚开始做Agent时有个误区:以为Agent就是一个模型API在线等待多轮对话。实际上单次模型调用只是一次无状态的输入输出转换,你给它一段上下文,它返回一段文字,仅此而已。
但真实任务不是这样的。比如你要让Agent去某管理后台批量导出报表、做数据清洗、再按固定格式发通知,这个链路至少包含五六步。每一步都可能依赖上一步的中间结果,比如数据表的ID、某个筛选条件、用户确认过的格式参数。这些状态放哪里、怎么传递、怎么保证中途不丢,单次调用完全不管。
这就是为什么需要有“运行机制”而不是“调接口”。运行机制解决的不是模型聪明不聪明的问题,而是让多个模型调用像流水线一样协作的问题。上下文、检查点、任务恢复、循环执行、资源管控,这五块本质上是把一次性的模型调用,包装成一个可靠、可观测、可恢复的长期运行单元。
1.2 五个核心机制的分工
这五个机制是配套使用的,缺一个都可能出大问题。
| 机制 | 解决的核心问题 | 可以类比的场景 |
|---|---|---|
| 上下文管理 | Agent的“工作记忆”如何组织和维护 | 厨房里面案板上的订单便签 |
| 检查点 | 如何保存阶段性状态 | 游戏里的手动存档 |
| 任务恢复 | 崩溃之后如何从存档继续 | 读档继续打游戏 |
| 循环执行 | 如何让Agent反复迭代直到完成 | 流水线上工人反复质检返工 |
| 资源管控 | 如何限制预算、次数、时长 | 闸机口控制进入人数 |
只用其中一两块容易出问题。比如只做检查点不设计循环,Agent就只会线性执行,不会自我校验;只做资源管控没有检查点,超预算熔断后所有进度都丢掉。这五个机制需要组合成一个闭环。
1.3 23.3这个编号到底指什么
“23.3”在我们这里不是某个公开产品版本,而是项目内部的一套配置基线编号。它的含义是:在2023年的第三个内部迭代周期里,我们把上文说的五块机制的接口格式、存储方式、触发条件全部固化成了标准约定。
为什么强调版本基线?因为Agent任务一旦出现异常,需要排查的往往是“当时用哪套逻辑跑的”。如果每个人各写各的上下文格式、各用各的检查点存储,出了问题根本没法复盘。定一个内部基线,意味着所有Agent运行时共用同一套接口、同一套日志结构、同一套资源计算规则。你在自己的项目里也建议这么做,哪怕记录很简陋,至少先让状态结构稳定下来。
2. 上下文管理:把工作记忆管成三层结构
2.1 上下文里到底应该装什么
做上下文管理之前,先得搞清楚上下文里塞的是什么。我拆过自己系统里大量Agent运行日志,发现上下文内容基本逃不出这几类:
- 用户的核心意图和明确限制条件,比如“只处理本月数据”“不要动正式环境”
- 系统提示词,也就是告诉Agent它是谁、该按什么风格干活
- 工具定义和调用规范,Agent需要知道有哪些工具可用、参数怎么传
- 历史决策记录,也就是之前怎么推导出某一步结论的
- 临时中间结果,比如刚读出来的数据、刚生成的代码、待确认的下一步计划
这五类内容的重要性完全不同。用户意图和系统提示词基本全程不能丢,工具定义一般保持稳定,而临时结果和历史决策是最容易爆掉上下文窗口的部分。
2.2 三层上下文结构:固定层、任务层、临时层
我推荐把上下文按生命周期分成三层管理,而不是把所有内容混在一个消息数组里。
固定层是放进每次调用系统提示词里的内容,包括角色定义、工具清单、全局约束。它们变化频率极低。
任务层是当前这个任务的核心目标、用户原始输入、任务级参数。任务不发生切换,这一层就不动。
临时层是中间输出,比如某次工具调用的返回结果、Agent刚刚写的代码片段、最近几步的思考过程。这一层更新最频繁,也是裁剪压缩的主要对象。
实际落地时,我习惯在代码里把上下文组织成一个带元信息的结构体,而不是裸拼接字符串。每个消息块都标上layer字段,比如system、task、scratch。这样压缩和裁剪的时候,可以按层处理,不至于误删关键信息。举个例子:
{ "session_id": "task_20230601_001", "layers": { "fixed": [ {"role": "system", "content": "你是某跨平台系统的数据助理,只能调用已注册工具。"} ], "task": [ {"role": "user", "content": "导出2023年5月销售数据,并按区域生成汇总表。"} ], "scratch": [ {"role": "assistant", "content": "计划:先调用list_tables,再调用query_sales_data。"}, {"role": "tool", "name": "query_sales_data", "content": "返回120行数据,已截断。"} ] } }有了这层标记,压缩时只需要处理scratch层就够了。fixed层和task层基本原样保留,这样既保护了核心信息,又控制了长度。
2.3 上下文压缩和裁剪的三个原则
上下文管理最核心的实操问题就是:临时层膨胀太快怎么办。常见的解法有摘要压缩、滑动窗口、关键信息抽取,但我用下来有几个原则性经验。
第一,摘要是为了提炼结论,不是为了留全文本。把Agent前几轮“翻来覆去”的思考过程压缩成一句结论,比留一大段废话强得多。比如“已确认数据源A,筛选条件为region=华东”,这比完整保留Agent推理链更有效,因为恢复执行时最需要的是结论,不是推理过程。
第二,滑动窗口不能只按轮数切,还要保留“锚点信息”。如果你的窗口只保留最近五轮对话,但第五轮正在处理的数据表ID是在第一轮得到的,那压缩后Agent就会断片。我会把任务层里的关键变量单独抽出来,即使滑窗清空了历史,这些变量也永远在上下文中可见。
第三,要警惕上下文漂移。所谓漂移,就是经过几轮循环以后,Agent慢慢偏离原始任务目标。解决办法是定期把“当前正在做什么”和“原始任务目标”对比一次,如果偏差超过阈值,就把任务层重新置顶。我自己是在每轮循环开始前,往上下文里重新注入一段很短的“当前任务摘要”,成本很低,但能明显减少跑偏。
3. 检查点:在关键时机给Agent存档
3.1 检查点要捕获的状态集合
检查点不是单纯把对话记录存个档就行。设计检查点之前,先画出这个状态集合:
- 当前执行到任务流程的第几步
- 用户原始输入和任务最终目标
- 已完成的中间步骤及其输出摘要
- 待执行的下一步计划,包括参数和期望结果
- 所有已调用过的工具及其入参出参
- 外部副作用状态,比如是否已经发过邮件、是否已经创建过工单
- 当前资源使用量,已消耗的Token数、已用的工具调用次数
前几项比较容易想到,但很多人会漏掉外部副作用状态。Agent的真实任务往往不只是对话,还有写文件、发请求、发通知等外部操作。如果检查点里不记录这些副作用是否已经完成,恢复执行时极有可能重复执行,造成数据重复或资金损失。
3.2 快照式与事件溯源式检查点
主流的检查点实现有两条路线:状态快照和事件溯源。
状态快照最简单,就是每隔一段时间或每个关键步骤结束后,把当前整个状态打包存储。优点是恢复快,缺点是存储量大。事件溯源则是记录完整事件日志,恢复时按顺序重放事件,重建状态。优点是信息完整、可排查,缺点是重放成本高。
实际项目中我用的混合方案:常规步骤用事件日志记录,每满N个事件或者到达任务关键节点时,生成一次快照。恢复时优先加载最近快照,然后按需重放快照之后的事件。这个方案的恢复速度和信息完整度比较均衡。
| 方案 | 存储开销 | 恢复速度 | 排查能力 | 适用场景 |
|---|---|---|---|---|
| 状态快照 | 较高 | 快 | 一般 | 步骤明确、中间状态价值有限 |
| 事件溯源 | 较低 | 慢 | 强 | 需要审计、需要逐步回放 |
| 混合方案 | 中等 | 较快 | 较强 | 长流程、有审计需要的生产任务 |
3.3 检查点存储设计与版本兼容
检查点我建议直接用结构化格式存储。简单场景一个JSON文件就够,复杂任务可以落到SQLite或对象存储。唯一要注意的是:检查点格式必须带schema_version字段。
为什么?因为Agent任务的运行时逻辑会升级,检查点格式也会变。我有一个真实教训:有一次把上下文里的字段改名后,旧检查点全部无法解析,线上所有长任务全部宕掉。后来我在检查点里加了版本号,加载时对新旧格式做兼容转换,才算彻底解决。好的检查点结构通常长这样:
{ "checkpoint_id": "cp_20230601_008", "schema_version": 3, "session_id": "task_20230601_001", "created_at": "2023-06-01T12:00:00Z", "task_status": { "current_step": "step_4", "completed_steps": ["step_1", "step_2", "step_3"], "pending_steps": ["step_5", "step_6"] }, "side_effects": { "emails_sent": ["mail_1001"], "tickets_created": ["tic_20230601001"], "files_written": ["report_202305.xlsx"] }, "resource_usage": { "tokens_consumed": 35400, "tool_calls": 7 } }检查点是给Agent“失忆”之后用来恢复记忆的,所以里面的每一项都应该是恢复执行时可以直接读取的,而不是还要让模型去猜的信息。
4. 任务恢复:别让一次报错赔上全部进度
4.1 恢复动作的标准顺序
拿到一个检查点之后,恢复执行的顺序很重要。我反反复复试出来的稳定流程是四步:
第一步,加载检查点。读取状态快照和最近事件日志,重建内存里的任务状态对象。
第二步,重建上下文。把检查点里的任务目标、已完成步骤摘要、待执行计划重新包装成模型可读的上下文。这里千万别把整个历史消息全塞回去,那样既浪费Token又容易让模型混淆重点。
第三步,校验外部副作用。检查已经执行过的外部操作,比如邮件是否真的发出去了、文件是否真的写成功了。这一步的目的是防止重复执行,而不是让Agent“以为”执行过。
第四步,让Agent先确认“我到哪里了”。恢复后不要直接续跑,而是先让Agent结合已有进度输出一个恢复声明,比如“当前进度是已完成步骤1和2,下一步准备执行步骤3”。这个确认动作成本很低,但能极大降低恢复后的混乱概率。
4.2 幂等性设计:外部副作用不能重放
任务恢复最大的坑是外部副作用。如果Agent在崩溃前已经给用户发了一封邮件,恢复后你又让它执行一次原来的计划,它极有可能再发一封一模一样的邮件。
我的解法是引入“副作用登记表”。每次Agent调用外部工具前,先检查登记表里有没有同名操作;如果已经有成功记录,就直接复用结果,不再真正执行工具。如果操作本身支持幂等标识,比如API请求带上request_id字段,那是最完美的。
这里有个实操心得:外部工具的调用一定要设计成“先登记、再执行、最后回写状态”三步。先登记的意思是,在调用前就把计划写入副作用表,状态标记为pending;执行成功后再回写为completed。如果崩溃发生在这三步之间,恢复时看到pending状态的记录,就知道这个操作可能已执行也可能未执行,需要单独判断,而不会盲目重跑。
4.3 恢复后的上下文重建技巧
恢复执行时,上下文不应该简单粘贴旧检查点,而是要做一层“提纯”。你给模型的信息越聚焦,恢复后的输出越稳。
我一般把重建后的上下文组织成三个模块:任务目标、已确认事实、下一步行动。其中“已确认事实”是恢复时最重要的信息,它包含了已经确定下来的结论、数据选择、参数值。下一步行动则给出一个明确建议,让模型从断点继续,而不是重新规划一切。
还有一个小提醒:恢复后第一次执行,建议让模型输出结构化的确认信息,而不是直接让它输出最终结果。比如强制要求它先说计划、再做动作。虽然多花一次调用,但对长任务来说,这个确认步骤能筛掉大量恢复后的命令混乱问题。
5. 循环执行:让Agent反复迭代到合格为止
5.1 循环的两个层次
循环执行在Agent里其实有两个层次,很多人只关注了第一层。
第一层是任务内部的步骤循环,也就是经典的思考、行动、观察循环。Agent根据观察结果决定下一步动作,再执行,再看结果,如此反复直到完成某一步。这层循环解决的是“单步任务怎么做对”。
第二层是任务级重试循环,用于处理整体任务的校验和重试。比如生成一份报告后,先校验数据是否完整、格式是否符合要求,不合格就重新生成或修复。这层循环解决的是“整项任务怎么做完做对”。
两层循环都要进入运行机制的设计范围之内。最典型的场景是:Agent第一步调用工具失败,任务级循环负责重试;如果工具成功后输出结果质量不满足要求,内部循环会再次调整参数或策略继续尝试。没有这两层循环,很多Agent任务只是“面的执行”,谈不上“可靠完成”。
5.2 终止条件怎么定
循环执行太最怕没有刹车。我在代码里会明确设置如下几种终止条件:
- 最多循环次数,比如内部循环最多5次,任务级重试最多3次
- 结果满意判定,当某次输出通过校验函数时,立刻终止
- 异常风险终止,比如发现工具连续返回错误、上下文超限、资源预算耗尽
- 用户确认终止,当Agent自己判断需要人工决策时,暂停并请求确认
下面是我常用的一段循环控制伪代码,核心就是每次迭代先查终止条件,再执行动作:
WHILE step_is_not_finished AND loop_count < max_loops: IF resource_budget_exceeded(): BREAK IF user_requires_confirmation(): PAUSE_AND_ASK_USER() CONTINUE OR STOP based on user reply observation = execute_tool_action(action) result = validate_output(observation) IF result.is_satisfactory: MARK_STEP_FINISHED() BREAK ELSE: new_action = revise_action(action, observation, loop_count) action = new_action loop_count += 1关键点是,每次循环迭代前都要重新评估是否应该继续。千万不要只靠“模型自己觉得还要继续”来判断,因为模型很容易在不确定时反复试探,消耗大量Token后仍然停在原地。
5.3 死循环和决策震荡的检测
长任务循环最头疼的问题是死循环,也就是Agent反复执行同一动作,每次得到相似的结果,然后还是继续试。典型表现是:同样的工具调用,参数几乎一样,返回结果也一样,但Agent就是不停。
我用来防死循环的手段有以下几种:
第一,行为哈希。把Agent每次的工具名和入参序列化后做哈希,如果最近若干个动作哈希高度重复,就判定进入循环。
第二,轮次上限。无论逻辑多复杂,同一个步骤超过设定的轮数上限就强制中断。中断后优先转向检查点恢复方案,而不是继续给它机会重试。
第三,行为相似度检测。有些循环不是完全重复,只是扰动参数但本质相同,比如反复转换日期格式。这种情况下哈希不够,我会抽取动作向量,比如工具名、主要参数范围、结果状态,计算相似度,超过阈值就报警。
经验之谈:给每个循环迭代写一个“决策日志”,记录这次循环为什么选择了这个动作、上一次结果给了什么反馈。有了决策日志,死循环出现时你一眼就能看出模型卡在哪,排查速度会快很多。
6. 资源管控:预算、限额和约束缺一不可
6.1 资源维度远超Token数
资源管控不能只统计Token消耗。Agent运行过程中会消耗多类资源:
- Token成本,包括输入和输出两部分
- 工具调用次数,有些外部API是按次数收费的
- 外部服务配额,比如每分钟最大请求数
- 单任务运行时长,任务拖太久会造成调度拥堵
- 并发执行数,多个Agent同时跑会抢占资源池
这些维度可能需要分别设限。比如某任务模型调用很便宜,但外部API极其昂贵,那资源管控的重点就不在Token而在工具调用次数上。如果你只用Token预算来管所有Agent,很可能出现Token没超但工具调用成本爆炸的情况。
6.2 预算池与按步扣费
我采用的方案是“预算池”模型。每个任务在开始时从全局分配一个资源预算,后续每一步执行先估算成本,从预算池里预扣,剩余足够才继续。这个方案很像手机套餐的流量池:总池子固定,每个App分走的流量实时扣减,池子空了自动断网。
核心伪代码是这样的:
task_budget = allocate_budget(session_id, max_tokens, max_tool_calls, max_duration) WHILE task_not_finished: estimated_cost = estimate_step_cost(next_action) IF task_budget.remaining() < estimated_cost: LOG_BUDGET_EXHAUSTED(session_id, task_budget) SAVE_CHECKPOINT(session_id, reason="budget_exhausted") BREAK task_budget.pre_commit(estimated_cost) result = execute_action(next_action) actual_cost = calculate_actual_cost(result) task_budget.commit(actual_cost)注意这个设计里有两个细节很有价值。第一个是预扣机制:先估算再执行,防止某一步实际花费超出预期时把整个池子直接透支。第二个是预算耗尽时先保存检查点再中断,这样后续可以扩容预算后恢复执行,而不是重头再来。
6.3 并发排队与检查点联动
资源管控的另一块重要内容是并发约束。多个Agent任务同时运行时,网络请求、外部API、模型调用都会产生竞争。最简单的做法是任务队列加信号量:同一时间最多允许多少个Agent活跃执行,其余任务排队等待。
这里我强烈建议把“排队等待”也做成可检查点的状态。比如任务排了很长时间队,网络断了一下,恢复机制仍然能从排队状态继续,而不是重新入队或者从头执行。资源管控和检查点联动的意义就在于此:限量使用的总量上限可变化,例如预算不足时把任务暂停,而不是凭空取消。
最后还有一个容易被忽视的点:资源管控数据本身要计入检查点。每次任务恢复时,必须把已经消耗的Token、已经用完的工具调用次数还原到当时的数值,而不是使用全局初始配额重新计算。否则会出现一种很尴尬的情况:任务恢复后以为自己还有充足预算,结果跑一半又断掉,反复几次就是一笔巨额开销。
7. 从任务下发到完成的一次完整串联
7.1 一次长任务的真实运行时序
把前面几块机制串起来之后,整个运行流程就非常清晰了。我用一次“读取数据生成报告并发送通知”的任务举例:
任务下发时,系统初始化任务上下文,把用户诉求写入任务层,同时初始化全局预算池,注册外部副作用登记表。
每一步执行前,Agent先规划下一步动作,并估算成本。系统检查任务级循环条件,确认没有触发任何终止条件后,允许执行。
执行工具调用后,返回结果写入临时层。系统判断这一步是否到达关键节点,比如是否刚完成一次大查询、是否即将发送外部通知。如果到达关键节点,自动生成检查点,保存当前步骤、输出摘要、副作用状态和资源消耗。
任务中途如果有一次工具调用返回异常,触发内部循环重试。假设重试后仍然失败,系统检查剩余预算和最大重试次数,决定暂停任务并保存检查点。运维人员稍后修复外部服务,通过恢复机制加载最近检查点,Agent先确认进度、再继续执行未完成步骤。
最终所有步骤完成后,进入结果校验循环。Agent生成报告,校验函数检查数据完整性,不合格就重复生成,直到通过或超过循环次数上限。整个过程的每一步都记录了Token消耗、工具次数、耗时,最终归档到任务日志。
7.2 实测中遇到的三个典型坑
第一个坑是检查点存得太频繁。一开始我几乎在Agent每执行一步后都同步存一次检查点,导致运行时的写IO开销非常大,长任务的整体耗时翻了一倍。后来改成“每满N个事件或到达重要节点才存档”,性能问题基本消失。检查点频率不是越高越好,关键是要在“崩了之后丢多少进度”和“写存档影响多少性能”之间找平衡。
第二个坑是恢复后上下文混乱。恢复机制加载旧检查点时,如果不做提纯,把几万字的历史记录全部塞回上下文,模型很容易被里面互相矛盾的信息带偏。这个问题通过“恢复引导摘要”解决了:恢复时只给模型看任务目标、已确认事实、下一步行动三者,历史细节有需要再按需检索。
第三个坑是预算阈值设置太死。一次任务里,单步骤调用模型成本波动很大,固定阈值容易误杀。后来我把预算检查从“单步超过X就停”改成了“预估一步运行后剩余预算是否仍然为正,为正就继续”,同时加了一个最低保留预算的概念,给正常收尾留出余量。
8. 常见问题与排查速查表
| 现象 | 常见原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 任务运行几轮后回答跑偏 | 上下文漂移 | 对比任务层目标与最近输出 | 每轮循环前重新注入任务摘要 |
| 恢复执行后重复发送邮件 | 副作用登记表未生效 | 查副作用表是否有该操作记录 | 工具调用前强制登记,恢复后先校验状态 |
| 同一动作反复执行 | 死循环 | 查动作哈希、轮次上限 | 设置行为哈希去重,超限强制中断 |
| Token没超但外部API费用超支 | 工具调用次数未单独限制 | 查工具调用计数 | 预算池同时约束Token数与调用次数 |
| 检查点加载后模型“失忆” | 上下文结构不合理 | 检查检查点里是否保存了任务目标 | 三层上下文结构,目标层常驻 |
| 任务恢复后进度奇怪 | 恢复时把历史全塞进上下文 | 查看恢复引导摘要内容 | 只注入目标、已确认事实、下一步行动 |
| 预算频繁熔断 | 预扣估算不准确 | 查估算值与实际值偏差 | 给预估成本加安全系数 |
| 并发任务互相拖慢 | 缺少并发控制 | 查看同时活跃任务数 | 引入信号量限制并发执行数 |
8.1 几个真正值钱的避坑经验
检查点不是越大越完整越好。真正运行久了你会发现,90%的历史细节都不需要被存档,核心就那几条:执行到哪一步、已拿到什么结论、做了哪些不可逆操作、还有多少预算。其他信息都可以通过重新推导或重新查询得到。
恢复后的第一步动作永远应该是最保守的。不要一恢复就执行大动作,先让Agent输出一份“当前进度+下一步计划”,人工或规则校验通过后再继续。这能拦住一大半因为检查点数据不完整而导致的混乱。
资源管控最好和告警联动。预算池剩余量低于阈值时,不要直接杀掉任务,先触发告警并自动保存检查点。这样运维人员可以做两种选择:扩容预算继续跑,或者人工终止。直接杀掉长任务是最浪费的做法。
循环次数上限不是硬编码一个数字就完事。要按任务类型配置,比如“数据查询类重试3次”“代码生成类重试5次”“下游操作类只允许1次重试”。下游操作类指发邮件、下订单等带有副作用的动作,这类动作重试代价最高,必须严格限制。
我个人在实际操作中体会最深的一点是:检查点是开销,不是收益。不要沉迷于“多存点多安心”的想法,而是根据任务失败概率的关键节点来定存储策略。同一个系统里,重要的最终确认步骤可以每步存,普通的数据搬运步骤完全可以靠事件日志兜底。这套机制真正跑顺之后,你会发现最值钱的不是某一个花哨功能,而是它把Agent从“跑得快的玩具”变成了“交得了活的生产工具”。