资讯详情

资讯详情

Agent运行机制拆解:上下文、检查点与任务恢复的工程实践

1. Agent运行机制的整体设计思路1.1 为什么需要拆解运行机制很多人第一次接触Agent开发注意力都放在提示词怎么写、工具怎么接、模型选哪个上结果项目跑起来之后发现真正让人头疼的根本不是这些。模型调用一次成功很容易难的是让它连续跑二十步还不乱、断了能接上、跑飞了能拉回来、资源不爆掉。这些问题的根源都在运行机制上而不是在提示词上。我自己的体会是Agent和普通的LLM调用最大的区别在于它是一个有状态的循环系统。普通调用是输入进、输出出一次结束Agent是输入进、模型决定下一步、执行、把结果塞回上下文、再决定下一步如此往复直到任务完成或者被强制终止。这个循环里每一个环节都可能出问题而运行机制就是保证这个循环可控、可恢复、可观测的那套底层设计。标题里提到的五个关键词——上下文、检查点、任务恢复、循环执行、资源管控——其实正好对应了Agent运行时的五个核心问题模型每一步看到什么、系统在什么时候保存状态、中断之后怎么接着跑、循环靠什么驱动和终止、以及怎么防止它把资源吃光。把这五个问题想清楚Agent的骨架就立起来了。1.2 一个最小可用的运行模型在展开细节之前先把整体结构摆出来。一个典型的Agent运行时可以抽象成下面几个部分上下文管理器负责组装每一步送给模型的输入包括系统提示、历史消息、工具返回结果、记忆检索内容等。执行循环驱动思考-行动-观察这个循环判断何时继续、何时终止。检查点存储在关键节点把当前状态序列化落盘供恢复使用。恢复控制器启动时检测是否存在未完成的检查点决定是新建任务还是续跑。资源管控层限制步数、token消耗、工具调用次数、并发数、超时时间等。这五块不是孤立的它们之间有明确的调用关系。执行循环每转一圈都要向上下文管理器要输入、向资源管控层申请配额、在关键节点通知检查点存储。恢复控制器则在循环启动之前介入决定这次循环的起点在哪里。注意不要一上来就把这五块全部做成独立服务。我见过不少项目初期就拆成五个微服务结果调试成本高得离谱。单进程内用几个类或者模块实现等真的遇到扩展瓶颈再拆是更务实的做法。1.3 方案选型背后的取舍运行机制的设计有几个常见的分岔路口选哪条路取决于你的场景。第一个分岔是循环驱动方式是用固定步数的for循环还是用while配合终止条件还是用事件驱动。固定步数最简单但不够灵活事件驱动最灵活但调试困难。我一般推荐while循环加多重终止条件既能处理大多数场景又不会太复杂。第二个分岔是状态保存粒度是每一步都存还是每隔几步存还是只在工具调用前后存。每步都存最安全但IO开销大只在关键节点存性能好但可能丢状态。折中方案是工具调用前后必存纯推理步骤按间隔存。第三个分岔是上下文策略是全量保留历史还是滑动窗口还是做摘要压缩。全量保留会撑爆上下文窗口滑动窗口会丢信息摘要压缩会引入额外模型调用。实际项目里通常是组合使用近期消息全量保留远期消息做摘要。这些取舍没有标准答案但有一个判断原则先保证正确性再优化性能。宁可前期多存一点、多留一点等系统稳定了再根据实际瓶颈做裁剪。2. 上下文管理的核心细节与实操要点2.1 上下文到底装了什么很多人以为上下文就是对话历史其实远不止。一个成熟的Agent上下文通常包含以下几类内容按优先级从高到低排列内容类型作用是否可裁剪系统提示定义角色、能力边界、输出格式不可裁剪当前任务描述本次要完成的目标不可裁剪工具定义可用工具的schema可精简描述近期消息最近几轮的思考和观察保留最近N轮远期消息较早的历史可摘要压缩记忆检索结果从长期记忆召回的相关内容按相关性裁剪工具返回结果外部系统返回的数据可截断或摘要这个表看起来简单但实际组装的时候有个关键问题总长度必须控制在模型上下文窗口以内。假设你用的是128K窗口的模型系统提示占2K工具定义占3K任务描述占1K那么留给消息和记忆的只有122K。如果历史消息累积到150K就必须做裁剪。2.2 上下文组装的具体步骤我通常把上下文组装拆成下面几步每一步都有明确的输入输出计算预算用模型窗口大小减去系统提示、工具定义、任务描述的固定开销得到动态内容的可用预算。预留输出空间模型生成也需要占用窗口一般预留2K到8K取决于期望的输出长度。填充近期消息从最近一条往前填直到用完一半预算。填充记忆检索结果按相关性排序填到预算的80%。填充远期摘要如果还有空间放入压缩后的历史摘要。最终校验用tokenizer实际计算总长度超了就继续裁剪。这里有个容易踩的坑不同模型的tokenizer不一样估算的token数和实际值可能差20%以上。我吃过亏用估算值组装完上下文结果调用时被模型拒绝报上下文超限。后来改成用目标模型对应的tokenizer做精确计算虽然慢一点但稳。# 上下文组装的简化示例 def build_context(task, history, memory, tools, model_window128000): fixed_overhead count_tokens(SYSTEM_PROMPT) count_tokens(task) count_tokens(tools) output_reserve 4096 budget model_window - fixed_overhead - output_reserve context [SYSTEM_PROMPT, task] used 0 # 近期消息优先 recent history[-10:] for msg in reversed(recent): msg_tokens count_tokens(msg) if used msg_tokens budget * 0.5: break context.insert(2, msg) used msg_tokens # 记忆检索结果 for mem in sorted(memory, keylambda x: x.score, reverseTrue): mem_tokens count_tokens(mem.content) if used mem_tokens budget * 0.8: break context.insert(2, mem.content) used mem_tokens return context2.3 上下文裁剪的三种策略当上下文超预算时必须裁剪。我常用的三种策略各有适用场景滑动窗口最简单只保留最近N轮消息。适合对话式Agent缺点是早期的重要信息会丢失。我一般把N设成10到20具体看单轮消息的平均长度。摘要压缩是把早期消息用模型总结成一段话。适合长任务缺点是摘要本身要调用模型增加延迟和成本。我的做法是每累积10轮就触发一次摘要把最老的10轮压成一段。关键信息提取是从历史里抽取结构化信息比如用户提到了订单号12345这种。适合需要精确记忆的场景缺点是需要额外的提取逻辑。实际项目里我通常三者组合近期用滑动窗口中期用摘要关键实体用提取。这样既控制了长度又保住了重要信息。实操心得摘要压缩有个隐蔽的坑——摘要本身可能引入错误。模型总结时可能把用户说不要退款总结成用户要求退款。我的应对是摘要里保留原始消息的引用ID需要时能回溯原文。2.4 上下文污染的防治上下文污染是指无关或错误的信息混入上下文导致模型判断失误。常见来源有三个工具返回的冗余数据、失败重试时的重复消息、记忆检索召回的无关内容。防治手段我总结了几条工具返回结果做结构化裁剪只保留模型需要的字段不要把整个JSON塞进去。重试时先清理上一轮的失败消息避免模型看到上次失败了而产生犹豫。记忆检索设相关性阈值低于阈值的直接丢弃宁可少召回也不要召回噪声。每轮循环开始前做一次上下文健康检查检测是否有明显的重复或矛盾。3. 检查点机制与任务恢复的完整实现3.1 检查点该存什么检查点的本质是把Agent的运行时状态序列化使得任何时候中断都能从最近的状态恢复。那状态到底包括什么我列一个清单任务ID和任务描述当前执行到第几步完整的消息历史或可重建历史的最小信息已调用工具的记录和结果当前上下文快照资源消耗统计已用token、已用步数等时间戳和版本号这里有个设计决策存全量还是存增量。全量存每次写完整状态恢复简单但存储开销大增量存只写变化部分存储省但恢复时要重放。我一般选全量存因为Agent的状态通常不大几十KB到几MB而恢复的可靠性比存储成本重要得多。3.2 检查点的触发时机不是每一步都要存检查点那样IO会成为瓶颈。我通常在这几个时机触发任务开始时存一个初始检查点标记任务已创建。每次工具调用前工具调用是副作用操作调用前存点能保证恢复时不会重复调用。每次工具调用后记录调用结果保证恢复时能看到结果。每隔N步纯推理步骤按间隔存N一般取5。任务结束时存最终状态标记任务完成。工具调用前后必存是因为工具往往有副作用比如发邮件、下单、写数据库。如果调用后没存检查点就崩了恢复时不知道到底调没调可能重复执行。存了检查点就能明确知道调用已发起但结果未知恢复时可以走查询确认逻辑。3.3 任务恢复的判断逻辑恢复控制器启动时的判断流程大致是这样def resume_or_start(task_id): checkpoint load_latest_checkpoint(task_id) if checkpoint is None: return start_new_task(task_id) if checkpoint.status completed: return load_result(checkpoint) if checkpoint.status failed: # 判断是否可重试 if checkpoint.retry_count MAX_RETRY: return resume_from(checkpoint) else: return mark_as_failed(checkpoint) if checkpoint.status running: # 可能是崩溃留下的检查是否超时 if is_stale(checkpoint.timestamp): return resume_from(checkpoint) else: # 可能另一个实例还在跑 return wait_or_abort(checkpoint) return resume_from(checkpoint)这里最微妙的是running状态的处理。如果检查点显示任务在跑但当前又启动了恢复可能是两种情况原实例崩了或者原实例还在跑而你不小心启动了第二个。前者应该恢复后者应该避免重复执行。我的做法是加一个心跳机制检查点里记录最后心跳时间超过阈值比如5分钟就认为是崩溃可以安全恢复。3.4 恢复时的状态重建从检查点恢复不是简单地把状态读出来就完事还要做几件事验证检查点完整性。检查点可能写到一半就崩了导致文件损坏。我一般用写临时文件原子重命名的方式保证要么写成功要么没写再加一个校验和字段检测完整性。重建上下文。检查点里存的消息历史要重新组装成模型能接受的格式。如果检查点存的是压缩后的历史恢复时可能需要重新展开。对齐资源计数。检查点里的token消耗、步数统计要恢复到内存计数器里否则资源管控会失效。处理未完成的工具调用。如果检查点显示某个工具调用已发起但没记录结果恢复时要先查询这个调用的实际状态再决定是重试还是跳过。踩过的坑有一次恢复时没处理未完成的工具调用结果Agent重新发了一遍邮件用户收到了两封。后来我在工具调用前先写一个pending标记恢复时看到pending就先查状态确认没执行才重试。3.5 检查点存储的选型存储介质的选择取决于部署形态场景推荐存储理由单机开发本地文件简单无需额外依赖单机生产SQLite支持事务单文件易备份分布式Redis 持久化读写快支持过期强一致要求PostgreSQL事务保证可查询大规模对象存储 元数据库成本低扩展性好我自己的项目大多用SQLite起步因为Agent状态不大SQLite完全够用而且单文件方便迁移。等并发上来了再换Redis或Postgres。4. 循环执行与资源管控的落地细节4.1 循环的驱动与终止Agent的执行循环本质是一个while循环但终止条件必须设计得足够周全否则要么死循环要么提前退出。我通常设置这几类终止条件任务完成模型输出明确的完成信号比如返回特定的结束标记。步数上限硬性限制最大步数防止无限循环。一般设20到50步看任务复杂度。token上限累计消耗超过预算就终止。时间上限总执行时间超过阈值就终止。连续失败连续N次工具调用失败就终止。无进展检测连续几步的输出高度相似说明卡住了终止。这几类条件是或的关系任何一个满足就退出。退出时要记录退出原因方便排查。def run_loop(task, max_steps30, max_tokens100000, max_seconds600): state init_state(task) start_time time.time() consecutive_failures 0 for step in range(max_steps): if state.tokens_used max_tokens: return terminate(state, token_limit) if time.time() - start_time max_seconds: return terminate(state, time_limit) context build_context(state) response call_model(context) state.tokens_used response.usage.total_tokens if is_completion(response): return terminate(state, completed) if needs_tool(response): result execute_tool(response.tool_call) if result.failed: consecutive_failures 1 if consecutive_failures 3: return terminate(state, tool_failures) else: consecutive_failures 0 state.history.append(result) save_checkpoint(state) return terminate(state, step_limit)4.2 资源管控的维度资源管控要管的不只是token还有几个容易被忽视的维度并发控制。如果Agent会调用外部API并发太高可能触发限流。我一般给每个外部服务设一个信号量控制同时进行的调用数。内存控制。消息历史无限增长会吃光内存。除了上下文裁剪还要给历史列表设上限超了就丢弃最老的。工具调用频率。某些工具比如搜索、爬取有频率限制需要做令牌桶限流。成本控制。token消耗直接对应钱要设日预算、任务预算超了就停。磁盘控制。检查点文件会累积要设保留策略比如只保留最近10个检查点。4.3 并发场景下的特殊处理当多个Agent实例同时跑时会引入新的问题检查点冲突。两个实例可能同时写同一个任务的检查点。解决方法是给检查点加锁或者用乐观并发控制版本号重试。资源竞争。多个实例抢同一个外部API的配额。解决方法是集中式配额管理或者每个实例分配独立配额。任务重复执行。同一个任务被两个实例捡起来跑。解决方法是任务领取时用原子操作标记比如Redis的SETNX。我遇到过一次线上事故就是因为任务领取没做原子操作同一个订单处理任务被跑了两次用户收到了两份确认。后来改成用数据库的唯一约束来保证任务只能被领取一次。4.4 可观测性建设运行机制再完善没有可观测性也是黑盒。我通常在这几个地方埋点每一步的输入上下文长度、输出长度、耗时每次工具调用的名称、参数、结果状态、耗时检查点的写入时间、大小、状态资源消耗的实时统计循环退出的原因这些指标汇总到一个看板上能快速定位问题。比如发现某一步上下文突然暴涨可能是工具返回了超大结果发现某类工具调用频繁失败可能是外部服务出问题了。实操心得日志里一定要记录每一步的上下文摘要但不要记录完整上下文否则日志会爆炸。我一般记录上下文的长度、包含的消息条数、以及最后一条消息的前100个字符足够定位问题又不占空间。4.5 常见问题速查问题现象可能原因排查方向循环不终止终止条件没覆盖检查完成信号识别逻辑恢复后重复执行检查点未记录工具调用状态检查工具调用前后的存点逻辑上下文超限裁剪逻辑失效检查token计算和预算分配资源消耗异常某类调用失控看埋点数据定位异常步骤并发冲突缺少锁或原子操作检查任务领取和检查点写入恢复后状态错乱检查点不完整检查写入原子性和校验和5. 从零搭建时的几个关键决策5.1 先做单机版还是直接上分布式我的建议是先做单机版。单机版能跑通整个循环、检查点、恢复流程验证逻辑正确性。分布式只是把存储和并发控制换成分布式组件核心逻辑不变。如果一上来就搞分布式调试成本会高很多而且很多分布式问题在单机阶段根本暴露不出来。单机版跑稳之后迁移到分布式的路径也很清晰把本地文件换成Redis或数据库把进程内锁换成分布式锁把内存计数器换成集中式计数。核心的循环逻辑、上下文组装、检查点格式都不用改。5.2 检查点格式的设计检查点格式要兼顾可读性和紧凑性。我一般用JSON因为可读性好、调试方便而且大多数语言都有成熟的库。如果状态特别大可以考虑MessagePack或Protobuf但除非真的遇到性能瓶颈否则JSON够用。格式设计上要留版本号字段方便后续格式升级时做兼容处理。还要留扩展字段方便加新状态而不破坏旧检查点的解析。5.3 恢复策略的选择恢复策略有两种从最近检查点恢复和从头重放。前者快但依赖检查点完整性后者慢但更可靠。我一般用前者但保留后者的能力作为兜底。如果检查点损坏就从头重放虽然慢但至少能跑通。还有一种策略是从检查点恢复但重放最后几步用于处理检查点可能不精确的情况。比如检查点记录到第10步但第10步的结果可能没完全落盘就从第8步开始重放。这个策略在工具调用有副作用时要特别小心可能重复执行。5.4 测试怎么做运行机制的测试比普通功能测试难因为涉及中断和恢复。我的做法是单元测试测上下文组装、检查点序列化、恢复判断逻辑。集成测试模拟完整循环在随机步骤注入中断验证恢复后能继续。混沌测试随机杀进程、随机让工具失败、随机让存储不可用验证系统的健壮性。回放测试记录一次完整执行的所有输入输出回放验证逻辑一致性。混沌测试最有价值能暴露很多边界问题。我一般用故障注入的方式在代码里埋一些开关测试时随机触发。6. 实际项目中的经验与教训6.1 上下文不是越多越好刚开始做Agent时我总觉得给模型的信息越多越好恨不得把整个知识库都塞进去。结果发现模型反而更容易迷失因为无关信息太多注意力被分散了。后来改成精准召回只给最相关的几条效果反而更好。这个道理其实和给人交代任务一样你把所有背景都讲一遍对方反而抓不住重点。给关键信息让模型聚焦是更有效的做法。6.2 检查点要早做我第一个Agent项目没做检查点跑长任务时经常因为网络抖动或者进程重启就前功尽弃。后来补上检查点虽然增加了一些复杂度但可靠性提升了一个档次。现在我做任何Agent项目检查点都是第一优先级的功能比工具接入还早做。6.3 资源管控要留余量资源上限不要设得太紧。我一开始把token上限设得刚好够用结果遇到稍微复杂一点的任务就超限终止。后来改成留30%余量虽然成本高一点但任务完成率明显提升。资源管控的目的是防止失控不是精确控制留余量是必要的。6.4 循环退出原因要记录每次循环退出都要记录原因这个习惯帮我省了很多排查时间。有一次发现大量任务以step_limit退出一看日志发现是某个工具返回格式变了模型一直解析失败反复重试。如果没有退出原因记录可能要排查很久。6.5 恢复逻辑要单独测试恢复逻辑平时不触发一旦触发就是关键时刻所以必须单独测试。我一般会写专门的恢复测试用例模拟各种中断场景确保恢复后状态正确。这个投入很值因为线上真出问题时恢复逻辑的可靠性直接决定损失大小。最后分享一个小技巧如果你的Agent任务比较长可以在检查点里额外存一个人类可读的进度摘要比如已完成数据收集正在做分析。这样运维人员看检查点就能知道任务进展不用去解析复杂的内部状态。这个字段不影响恢复逻辑但大大提升了可维护性。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →