ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Agent 停不下来:终止条件与不收敛的判定

Agent 停不下来:终止条件与不收敛的判定 说明本文讨论的是 Agent 任务的终止条件与不收敛判定属于 Agent 工程落地话题不涉及具体模型版本与价格。AI 领域版本迭代极快凡涉及版本号、价格、可用性请以你阅读时的官方页面为准。文中代码为结构示意请按自己的技术栈调整后再上生产。一、停不下来的四种样子Agent 跑飞了第一反应通常是模型不行。但如果把 trace 从头翻一遍看到的往往不是能力问题而是任务层面从来没有定义过什么算做完。模型每一步都在正常输出每一步都在推进它理解中的那份计划只是这份计划没有终点。先看四种最典型的表现。它们在日志里的样子完全不同根子是同一个。**第一种反复调用同一个工具。**同一次读文件、同一次检索、同一次查询参数基本一致隔几轮回来一次。它的内在动因是刚才那次结果可能不准再确认一次。在 trace 里表现为工具名与参数高度相似中间只夹着一次模型输出。**第二种在两个方案之间来回改。**先写成方案 A落盘下一轮觉得 B 更好覆盖再下一轮又回到 A。表面上每一步都在优化实际上是在一个二元素集合里做往复运动。**第三种把再确认一次当成进展。**模型把已确认数据库连接正常写进本轮小结下一轮再说一遍。确认这个动作被当成了产出而它其实只是前置检查。**第四种把结论包装成新的待办。**最隐蔽的一种。模型不交付结果而是输出一份建议的后续动作清单然后把清单第一条当成新任务继续做。任务是收敛不了的因为每一轮都在生产新的起点。这四种和一次工具调用失败的区别在于可观测性。调用失败会抛异常、会进错误日志、会让编排器进入重试分支重试本身怎么做不在本篇范围。而打转全程没有异常工具返回成功模型有输出流程状态是运行中。系统自认为一切正常只有时间和成本在累积。还有一个不对称的地方。调用失败是单点问题改一处就行打转是持续性问题它跑十分钟和跑十小时的区别只是花掉多少过程中不会有任何人收到通知。所以它不能靠报警被发现只能靠主动设一个停止条件。它也不随任务复杂度线性出现而是随步数放大。三步以内的任务每一步的合理性和整体方向基本重合不存在打转的空间到二三十步的量级一条局部正确的路径和一条能走到终点的路径开始分叉而分叉点在单步上完全看不出来。这解释了为什么同一个提示词在短任务上表现正常换成长任务就开始原地转圈——不是模型换了是任务的形状换了。二、完成判定三类信号与两条判据终止条件的另一头是完成。说不清什么算完成就没法判断该不该继续。工程上可用的完成信号有三类可靠性依次上升接入成本也依次上升。类型判据来源适用场景典型误判模式声明式完成模型输出里的收尾标记低风险、产物可被人一眼核验宣布完成但缺产物做完了不收尾验证式完成外部校验器的返回值有确定性校验器的任务如结构校验、编译、测试校验器覆盖不全只验形状不验内容判据式完成任务自定义的业务断言集合交付物是数据、文件、记录的任务断言写得太宽等价于没判三类的关键差别是谁来宣布完成。声明式由模型自己宣布另外两类由外部宣布。一个不严谨但实用的结论是只要任务有副作用或者有正式交付物就不该由模型单方面宣布完成。声明式不是不能用是要限定场景。如果任务是回答一个问题、生成一段草稿这类产物可以直接被人读的东西模型说完成就是完成因为核验成本极低——人扫一眼就知道。反过来如果产物是一批写进数据库的记录、一批发出去的文件看起来做完了和真的做完了之间差着一整轮校验。验证式完成是性价比最高的一档把完成绑定到一个可重放的校验器上比如结构校验、类型检查、编译通过、测试通过、接口返回码加业务断言。它的好处是判据不随模型的表达方式变化——模型换个说法不影响判定结果。它的误判模式也很清楚校验器只覆盖了产物的形状。一批记录都符合结构、关键字段却全为空结构校验照样给通过。判据式完成是把业务语义写成断言。它通常在验证式之上再叠一层任务相关的东西⚠️ 代码待验证fromdataclassesimportdataclassdataclassclassCriterion:name:strcheck:callable# 返回 (bool, 说明)required:boolTruedefbuild_criteria(ctx)-list:完成判据不是模型的一句话而是一组可执行的断言。return[Criterion(产物存在,lambda:path_exists(ctx.artifact)andfile_size(ctx.artifact)0),Criterion(字段非空,lambda:required_fields_filled(ctx.rows)),Criterion(条数达标,lambda:len(ctx.rows)ctx.expected_min),Criterion(无未决项,lambda:count_pending(ctx)0),# 负向判据]defis_done(ctx)-tuple:results[(c.name,*c.check())forcinbuild_criteria(ctx)]failed[nameforname,ok,_inresultsifnotok]returnlen(failed)0,{failed:failed,detail:results}为什么至少要两条判据才收工因为单条判据很容易被形式上满足绕过文件存在不代表内容对测试通过不代表覆盖了这次改动的路径。比较稳的组合是一条正向加一条负向正向回答产物在不在负向回答还有没有悬空的东西。负向判据尤其重要因为打转最常见的收尾形态恰好就是留下了一堆待办。判据从哪里来这一点经常被做错。判据要在任务定义阶段写死不能由模型在运行时生成。让模型自己说我这个任务完成的标准是什么等于把终止权交回给它——一个正在打转的模型会自然地把判据解释成我已经尽力了。可行的做法是按任务类型建判据模板数据类任务是产物存在加字段非空加条数达标代码类任务是编译通过加测试通过加改动文件落在预期目录内检索类任务是引用可解析加条数在区间内。模板定下来之后具体阈值可以带参数但判据的条数和种类不该由单次运行的模型决定。另外判据别贪多四到六条足够超过这个量级会出现互相打架的断言反而没法判断。三、不收敛检测动作指纹与状态指纹判断打转不能靠读日志。日志是给人看的判定要写成代码。这里需要两个概念。动作指纹是对这一步做了什么的摘要把工具名和关键参数拼起来归一化之后取哈希。状态指纹是对外部世界被改成了什么样的摘要目标资源的版本标识、已写入内容的内容哈希、受影响记录的计数这一类。两者的差别决定了它们各自能发现什么。动作指纹能发现重复请求发现不了换了姿势在做同一件事状态指纹能发现世界没有变化发现不了做了无效但有副作用的动作。维度动作指纹状态指纹输入工具名加关键参数外部资源的版本标识与内容摘要归一化剔除时间戳、请求标识、随机数、不稳定排序只保留业务字段忽略审计字段能发现同参数重复调用连续多轮世界无变化发现不了参数微调后的重复有副作用但无意义的动作计算成本低编排层即可完成需要一次世界状态读取只有动作指纹不够原因就是参数微调。模型只要在参数里动一个字——偏移量加一、查询词补个同义词、把确认换成复验——字符串比较就失效了。所以判定规则要拆成两级疑似打转连续 N 步动作指纹相同忽略纯思考步。这时先不终止而是注入一次提醒——“你刚才的动作与第 k 步一致请说明这次与前一次的区别”。确定打转连续 N 步状态指纹未变化。到这一级就是硬事实——模型做了什么描述不重要世界没有被推动。⚠️ 代码待验证importhashlibimportjson VOLATILE{ts,timestamp,trace_id,request_id,nonce,tmp_cursor}defnormalize(args:dict)-dict:去掉易变字段数字做区间化避免 offset 加一这类微调绕过比较。out{}fork,vinsorted(args.items()):ifkinVOLATILE:continueifisinstance(v,(int,float)):out[k]round(v,-2)# 同一数量级即视为同一个动作elifisinstance(v,str):out[k]v.strip().lower()[:200]else:out[k]vreturnoutdefaction_fp(tool:str,args:dict)-str:rawjson.dumps({tool:tool,args:normalize(args)},sort_keysTrue)returnhashlib.sha256(raw.encode()).hexdigest()[:16]defstate_fp(world)-str:read_summary() 返回目标资源的版本标识与内容摘要不含审计字段。returnhashlib.sha256(world.read_summary().encode()).hexdigest()[:16]检测窗口 N 的取值没有理论最优解只能按任务类型调。检索型任务天然会重复调用同一个工具N 应该大一些写入型任务的动作本该每步不同N 小一点就能早发现异常。真正让判定站得住的是两级分开这个结构疑似只提醒确定才终止。四、预算熔断要分层光靠检测打转不够因为还存在另一种情况每一步都有进展但整体成本已经超出这个任务的价值。这就是预算熔断存在的理由。四种预算触发后果完全不同混在一起就会做出错误处置。预算计量对象触发后果注意点步数已完成步骤数停在步骤边界交付半成品最易实现但长任务里步数抖动大wall-clock从任务开始到现在的时长立即停或先降级再停要区分在干活和在等外部token累计消耗的输入输出量优先降级其次停降级手段另有一篇专讲这里只把它当停止阈值外部调用次数对下游接口的调用次数排队、限频或交人目的是保护下游不是保护自己四者的关键差别在触发后的动作。步数耗尽和时长耗尽都是该停了继续投入的边际收益已经很低成本预算耗尽通常该降级——把上下文裁短、把工具返回体积压低、把并发度降下来任务可能还能在更低的成本下收尾外部调用次数预算是该排队或者该交人因为瓶颈不在这个任务身上而在下游容量。这里最容易写错的是 wall-clock。一个任务跑了 90 秒不代表它有 90 秒在做有效工作——其中 85 秒可能都花在等一个外部接口上。只看总时长就熔断会把慢但正确的任务误杀。可行的做法是记录最后一次状态指纹变化的时刻用自上次有效进展以来的时长当真正的时间预算。⚠️ 代码待验证budgets:steps:limit:40# 超过即停停在步骤边界on_exhaust:stopwall_clock:limit_seconds:900idle_limit_seconds:180# 自上次状态指纹变化起算超时即停on_exhaust:stopcost:limit_units:120000warn_ratio:0.8# 到八成先降级不直接停degrade:[trim_context,cap_tool_output]on_exhaust:stopexternal_calls:limit:60on_exhaust:hand_over# 交人不静默失败warn_ratio那一行值得单独说。预算只做到顶就停等于把问题全部推到终点给一个提前量让系统在还来得及的时候先换成便宜的模式相当一部分任务能因此跑完而不是被砍掉。还有一条纪律**预算要按任务类型分档不要全局一套。**同一个步数上限套在三种任务上结果一定是简单的任务永远用不完、复杂的任务永远不够用。判分档的依据是任务的可预期长度而可预期长度要用观测数据反推——先按当前值跑一批看步数分布落在哪再把上限设在 P95 稍上的位置。硬要凭直觉填一个整数通常会在上线两周内被改三次。五、停在哪一层判定逻辑写在哪决定了它能拿到什么输入。三个候选位置各有盲区。位置能看到看不到适合做的判定编排层计划全貌、步序号、剩余预算、历史步骤摘要适配层补全后的最终参数任务该不该继续、循环检测、预算熔断、终止码生成工具层工具名、最终参数、单次调用上下文任务进度与用户意图单工具级的重复调用短路网关层租户、配额、速率、错误率任务语义资源保护类的硬限流结论是主判据放编排层理由有三个。第一只有编排层掌握任务级的信息。打转的定义是连续多步世界没变这个多步只有编排层数得出来。工具层能看出这一次调用和上一次一样但它不知道这是第 30 步还是第 2 步。第二只有编排层能产出交付物。终止不只是把循环跳出去还要输出已完成什么、为什么停、缺什么。这个动作需要一个能看到全局的角色来做。第三工具层和网关层看到的都是局部切片。网关层能看出这个租户超额了却不知道这个任务是该停还是该换条更省的路。把它们当兜底是合理的——工具层做去重短路、网关层做资源保护——当主判据则会误伤。⚠️ 代码待验证defrun(task,ctx):seen_action,last_state,stagnant,idle_since{},None,0,now()forstepinrange(ctx.budgets.steps):fpstate_fp(ctx.world)# 先算世界状态再判预算iffplast_state:stagnant1else:stagnant,idle_since0,now()last_statefpifstagnant3:returnfinish(ctx,reasonNO_PROGRESS,detail{state_fp:fp})ifelapsed(idle_since)ctx.budgets.idle_limit_seconds:returnfinish(ctx,reasonBUDGET_IDLE,detail{idle:elapsed(idle_since)})actctx.model.plan(ctx.history).next_action()keyaction_fp(act.tool,act.args)ifseen_action.get(key,0)2:ctx.history.add_hint(f动作{act.tool}与前文重复请给出不同的做法)seen_action[key]seen_action.get(key,0)1ctx.execute(act)# 执行重试与超时策略不在本篇范围ok,detailis_done(ctx)# 完成判定放在执行之后ifok:returnfinish(ctx,reasonCOMPLETED,detaildetail)returnfinish(ctx,reasonBUDGET_STEPS,detail{})这段骨架里有三处顺序上的讲究。先算状态指纹再看预算否则预算会先于循环检测触发把打转误报成步数耗尽。重复动作先提醒不终止因为动作重复存在合理场景比如轮询一个外部状态直到它就绪。完成判定放在执行之后因为产物是执行的结果不是计划的承诺。完整版资料清单本文用到的完成判据清单、指纹归一化字段表和预算配置模板都整理在里面了扫码即可获取六、终止之后做什么把循环跳出去只完成了工作的一半。一次合格的终止要交出三样东西已完成的结论、未完成的原因、需要的下一步输入。第一件是给用户的。已经产出的东西必须可核验地交出去附上产物引用和它是在哪一步确定下来的而不是一句任务中止了。第二件是给系统的。终止原因必须是机器可读的枚举不是自然语言句子。下游要靠它做路由、统计和告警。一个没有终止码的停止在监控里和进程崩溃长得一模一样。第三件是给下一轮的。如果这个任务还有救缺的是什么——一份权限、一条人工决策还是一次上游数据更新。写清缺什么任务才有被重新发起并跑完的可能。⚠️ 代码待验证终止码 含义 之后做什么 COMPLETED 判据全部满足 正常交付 BUDGET_STEPS 步数预算耗尽 交付半成品标记可续 BUDGET_IDLE 无有效进展超时 交付半成品提示检查外部依赖 BUDGET_COST 成本预算耗尽 降级后重跑一次仍超则交人 NO_PROGRESS 状态指纹连续未变 交人附带走过的路径摘要 BLOCKED_INPUT 缺权限或关键输入 交人明确列出缺什么 NEEDS_HUMAN 命中必须人工判断的判据 交人进入人工处理队列 ERROR_FATAL 不可恢复错误 直接失败不重试转人工的判据也要写死否则什么时候交人会退化成随机。三条就够用终止码属于NO_PROGRESS、BLOCKED_INPUT、NEEDS_HUMAN之一同一任务因为同一个终止码停了两次说明自动路径已经试过并且不成立终止时缺的是权限或决策而这两样机器无法自己产出。还要避开一个坏习惯用再试一次代替终止。终止条件触发了却把任务原样重发那不是终止只是把问题推给下一轮。真要重跑必须至少改变一个条件——换预算档、换工具集、或者补上缺失的输入。关于停和等的边界也要说清楚。本篇讲的是任务自己给自己设的停止条件。至于动作执行前需要人确认的那种停顿是另一套机制判据与状态管理都不在这里展开。终止记录里最少要有这几项任务标识、终止码、停在的步序号、已完成产物的引用、最后一次状态指纹变化的步序号、缺失输入的字段名。前五项回答发生了什么最后一项回答怎么继续。缺了步序号复盘时只能重放整个过程缺了缺失输入的字段名接手的人得自己再跑一遍才知道卡在哪。七、怎么验证终止逻辑是有效的终止逻辑的难点在于它平时不触发。一个配置合理的任务可能几个月都不打转等真打转时才发现判据写错了。所以不能等线上偶发事件来验证要主动构造。**用故意无解的任务做回归。**三类样本能覆盖大部分情况判据矛盾型要求一组不可能同时满足的断言看它是停在NO_PROGRESS还是傻跑到步数上限必然打转型工具返回值恒定模型每步都读到相同内容看它能否在 N 步内识别出来缺输入型故意不给某个必要权限看它是否明确输出BLOCKED_INPUT而不是重试到预算耗尽。这三类样本要进回归集每次改判定逻辑都跑一遍。它们验证的不是模型能不能做对而是框架能不能停对后者才是这篇的主题。**统计步数的分位不只看均值。**平均值会掩盖长尾。真正要盯的是 P95如果 P95 已经逼近步数预算上限说明有相当比例的任务是在被熔断的边缘结束的——要么预算定得紧要么任务类型已经变了。这个信号比平均步数可靠因为一个卡住的任务就能把均值拉高而你分不清是一个任务卡了 500 步还是一百个任务各多跑了 5 步。**盯熔断过紧的比例。**这是终止系统最容易做错的方向判据定得过严把本来能跑完的任务也砍了。口径是被熔断的任务中人工接管后在不改任何条件的情况下跑完的比例。这个比例偏高说明阈值需要放宽或者终止判据把正常行为误判成了打转。指标口径异常信号终止码分布各终止码的占比NO_PROGRESS占比突增步数 P95按任务类型分组统计逼近步数上限熔断过紧率熔断后人工重跑成功占比偏高判据需要放宽打转检出步数从真实打转开始到终止之间的步数偏高检测窗口太大人工接管率转人工任务在全部任务中的占比偏高自动路径可用性不足⚠️ 代码待验证metrics:termination_code_ratio:expr:count(agent_run_stopped) by (reason) / count(agent_run_stopped)note:NO_PROGRESS 突增通常意味着完成判据与真实任务脱节steps_p95:expr:histogram_quantile(0.95,agent_steps_bucket)labels:[task_type]note:逼近步数预算即为预警不要等它撞上限over_budget_false_positive:expr:count(rerun_completed_without_change) / count(agent_run_stopped)note:熔断过紧的直接代价用来反向放宽阈值detect_lag_steps:expr:avg(stopped_step-first_stagnant_step)note:检测窗口 N 的调参依据回到落地本身。终止条件的价值不在于少花钱而在于让任务有一个确定性的结束方式。判断它做得好不好标准很朴素随便挑一次异常终止能不能从终止码、已完成产物、缺失输入这三样里说清楚发生了什么。能说清楚那么无论任务是按判据收尾还是转人手处理系统都在掌控里说不清楚这个任务其实从来没有真正结束过只是暂时不再消耗资源。完整版资料清单本文用到的终止码枚举、指纹字段清单和指标定义都整理在里面了扫码即可获取附表 A关键取舍一览本文涉及的所有工程判断集中在这里方便按需回看。取舍本文结论判断依据位置判断打转靠什么状态指纹为主动作指纹为辅参数微调会让字符串比较失效第三章重复动作怎么处置先提醒不直接终止轮询型任务存在合理重复第三章最低完成判据一条正向加一条负向单条判据易被形式满足绕过第二章能否由模型宣布完成有副作用或交付物时不行核验成本与误判代价不对称第二章谁宣布完成优先外部校验器与业务断言判据不随模型表达方式变化第二章wall-clock 怎么算用自上次状态指纹变化起的时长总时长会把慢但正确误杀第四章成本预算触发后先降级再考虑停降级后任务仍可能收尾第四章外部调用预算触发后排队或交人瓶颈在下游容量不在本任务第四章主判据放哪一层编排层只有它能看到任务级进度并产出交付物第五章工具层与网关层的角色去重短路与资源保护局部切片当主判据会误伤第五章循环里的判定顺序先状态指纹再预算最后完成判定顺序颠倒会把打转误报为预算耗尽第五章终止必须产出什么已完成结论、终止码、缺失输入无终止码的停止与崩溃无法区分第六章什么时候转人工特定终止码、同码二次停止、缺权限或决策否则交人时机退化成随机第六章能否原样重发不行必须改变至少一个条件原样重发只是把问题推给下一轮第六章怎么验证终止逻辑用故意无解的任务做回归正常任务几乎不触发终止路径第七章最该盯的指标熔断过紧率与步数 P95检出遗漏与误杀的代价不对等第七章附表 B术语速查表术语含义终止条件判定任务应当结束的规则集合含完成判据与各类熔断阈值声明式完成由模型输出收尾标记来宣布完成的方式验证式完成由外部校验器返回值来宣布完成的方式判据式完成由任务自定义业务断言集合来宣布完成的方式负向判据回答还有没有悬空项的断言用于阻断形式化收尾动作指纹工具名与归一化关键参数拼接后取哈希得到的摘要状态指纹外部世界影响摘要的哈希用于判断是否真的有进展参数归一化剔除易变字段并对数值做区间化使微调后的调用可比疑似打转动作指纹连续重复先提醒不终止的判定级别确定打转状态指纹连续未变化可直接终止的判定级别预算熔断按步数、时长、成本、外部调用量四个维度设置的停止阈值空闲时长自上一次状态指纹变化以来经过的时间用于时间预算终止码机器可读的终止原因枚举供下游路由与统计使用熔断过紧率被熔断但人工不改条件即可跑完的任务比例误杀口径写在最后这篇用到的资料写这篇文章时我把几个 Agent 项目里终止相关的取舍重新梳理了一遍顺手整理成几份配套的东西大模型学习路线图从 LLM 基础到 Agent 开发各阶段该学什么、用什么资料大模型全套教程按主题分好的视频与文档清单大模型实战好书24 本附每本适合的阶段资料是我自己整理的放在下面这个码上扫码即可获取添加时备注「大模型」优先通过。拿到之后建议先看学习路线图那一份先定位自己在哪个阶段再决定学什么比一上来就啃框架效率高得多。
返回列表