ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI Agent停止策略:从失控到稳停的工程实践

AI Agent停止策略:从失控到稳停的工程实践

我第一次把Agent接到生产环境时,遇到的最尴尬问题不是模型回答不对,而是它停不下来。一个本该三步收尾的任务,Agent在工具调用和错误重试之间反复横跳,直到把预算全部耗尽。我翻日志,同一个动作重复了七次,换了个时间参数又重复了七次。那一刻我意识到,在做AI Agent相关项目时,跑得快是能力,停得稳是工程。停止策略不是附属功能,它直接决定系统会不会失控、成本会不会爆掉、用户会不会流失。这篇文章我会从失控现场讲起,拆清楚大模型为什么不擅长“自己停下”,再给出一套从请求级到全局级的分层停止策略,附上可以直接抄的代码模板和我在真实项目里踩过的坑。适合正在搭Agent的开发者、做AI应用落地的工程师,以及每一个被“一跑就停不下来”折磨过的人。

1. 停止策略到底在解决什么问题:从“跑起来”到“停得稳”

1.1 一个失控Agent的现场回放

我先描述一个非常典型的失控过程,你大概率也遇过类似情况。

某一次,Agent拿到一个半结构化的用户请求,进入工具调用循环。第一轮,它调用了一个查询工具,返回结果是“未找到相关信息”。按正常逻辑,这时应该换一种查询词,或者直接告诉用户信息不足。但模型没有这么做,它把同一个查询请求原封不动又提交了一次,第二次返回还是“未找到”。接着它猜测可能是参数格式不对,把日期字段改了半天,结果接口返回校验错误。第三轮它修好了日期,却又因为没有带上排序参数,返回了默认结果。模型认为“这个结果好像不够精准”,于是又回到第一步,重新发起相同查询。

整个过程中,同一个业务操作实际上执行了十几次,其中至少五次是重复或无效的。最终这条Agent没有被业务逻辑停下来,而是被外层硬编码的全局超时给掐断,用户在界面等了四十多分钟,只看到一句“任务超时,请稍后重试”。这种问题的根源,不是某个具体模型不行,而是整个执行链路缺少了“终止条件”的显式设计。

1.2 为什么大模型自己不会停

很多人第一反应是:模型不应该知道任务完成了吗?事实上,大模型在自回归生成时,每一轮的任务只是预测下一个token,它并不知道自己在真实环境里执行了几次工具调用、消耗了多少资源。模型能输出的“结束”,通常是它在训练数据里学到的自然语言模式,比如“好的,我的回答完毕”,但这种文本信号在Agent循环里并不可靠。

可以类比一个没有结算单的工单系统:工人接到任务后,如果没有人告诉他“这项工作完成了,交付物是什么,预算用掉多少”,他会一直按自己的理解继续操作。大模型作为生成模型,天然缺乏“执行状态”的概念。它看不到自己已经调用过哪些工具、花了多少token、距离业务目标还有多远,除非外部代码把这些信息显式写进上下文。

Token预算也只能算半个停止条件。如果只在请求层设置了max_tokens,模型生成到上限会被截断,但截断不意味着业务结束。它可能停在一个工具调用的中间状态,留给下游一堆半成品数据。所以停止策略必须从模型生成之外去考虑,这正是执行器存在的意义。

1.3 三种常见的错误停止姿势

我见过不少项目在停止策略上踩同样的坑,归纳下来有三种错误姿势。

第一种,只靠一个全局硬超时。设置“整个Agent最多跑60秒”,到点就杀。这种方式实现最简单,但粗粒度问题很大:简单任务可能在5秒内就该结束,硬等60秒浪费资源;复杂任务可能需要120秒,却在第60秒被一刀切,用户拿不到结果。全局硬超时只能作为最后兜底,不能作为主要停止机制。

第二种,靠“看结果好像对了”来启发式判定。比如某团队写了个规则,只要模型最后的输出文本里包含“完成”“成功”等关键词,就认为任务结束。表面上看很智能,实际上模型很擅长在中间步骤里说出“我已经完成了”,然后继续做下一件事。关键词误判率极高,尤其当任务本身需要多阶段输出时,一票否决式的终态判定很容易把正常流程打断。

第三种,没有任何止损漏斗。一条Agent从头跑到尾,中间没有阶段预算,没有循环检测,没有动态注入干预,直到异常被外部监控捕获。这种方式的隐患是:出问题之后连定位都很难,因为没有任何中间决策日志,只能靠猜。

这三种姿势都忽略了一个关键事实:停止策略应该像交通管理一样分层,而不是靠单一信号一竿子插到底。

2. 分层停止策略:请求、轨迹、全局三层缺一不可

2.1 请求级:生成参数和End Token约束

请求级停止是最基础的一道闸。它管的是“模型单次生成不能无限长”,对应的参数包括max_tokens、max_output_tokens,以及各类结束标志。

单一模型API如果允许设置终止序列,我一定会设置至少两个:一个是“FINISH”,另一个是“<|done|>”之类的显式结束符。原因是模型在自然对话里喜欢输出“好的,我已经完成了,下面是结果”这类话,但不会真正结束生成结构。给一个自定义的FINISH token,并要求模型在任务真正完成时单独输出一行,代码解析到之后就停止该轮生成,既能省token,又能拿到明确的语义信号。

请求级还需要考虑结构化输出。如果Agent产生的每轮动作都是JSON字符串,必须要求模型严格按照JSON格式输出,并在生成到闭合大括号后立即停止。很多框架已经支持JSON mode或结构化输出,这相当于给停止条件加了一个语法边界。没有这个边界,模型偶尔会在工具调用后继续输出无关解释,导致请求级token预算被白白消耗。

这一层不解决长期循环,但它能把每次模型调用的成本控制住,避免“一步生成两三千token全是废话”的低级浪费。

2.2 轨迹级:识别重复与空转

轨迹级的停止策略,关注的是Agent已经走过的路径。核心任务是回答两个问题:这个动作是不是刚刚做过了?这个循环是不是已经反复多次了?

重复识别要做的不是简单的文本比较,而是动作签名。每个工具调用可以抽象成一个动作三元组:工具名、参数经过归一化后的签名、返回结果的摘要。时间戳、随机ID、trace_id这类无关字段必须从签名里去掉,否则同一个业务操作会因为带上不同的trace_id而被误判为不同动作,循环检测就失效了。

归一化之后的签名可以快速做最近K步匹配。如果最近N步里同一个签名出现的次数超过阈值,就能判定进入了重复空转。更复杂的做法是维护一个滑动窗口,把窗口内的动作序列哈希后查重,发现相同模式立刻触发干预。比如窗口长度为5,如果当前第6步的动作模式与第1到5步完全一样,说明循环周期已经形成。

轨迹级停止还有一种场景是“进度停滞”:动作没有完全重复,但返回结果连续多轮都是空数据或错误码。这种也要触发分支切换,而不是继续让模型重试。因为模型在重试时往往只能微调参数,如果没有外部提示,它可能倾向于继续原地打转。

2.3 全局级:预算、熔断、降级

全局级策略负责整个Agent执行生命周期的资源控制。层级的衡量维度有三个:总步数、总token、总墙钟时间。

总步数预算是最常用的。它不是凭空拍出来的,而应该根据历史成功样本统计正常任务需要多少步,然后乘以一个安全系数。比如某类任务正常路径平均8步,标准差2步,那全局步数预算可以设置在15到20步之间。设置太紧会把长尾任务全部误杀,太松又起不到止损作用。

总token预算相对步数更贴近成本。同样是10步,如果工具返回的文本都很大,每一步可能吃掉3000 token,那么10步就是3万token。在做预算时,可以把“平均每步token消耗”与步数相乘,再加上模型输出部分,得到一条经验基线。

墙钟时间预算主要照顾用户体验。用户在等一个Agent任务时,心理预期通常只有几十秒。全局时间预算应该低于用户可接受的等待上限,而不是等于它。否则一旦接近上限,用户已经开始流失。

全局级策略的最后两个动作是熔断和降级。熔断不是简单终止,而是要保留现场证据:当前状态、已完成步骤、最后一段上下文。降级则决定了熔断后用户看到什么——可以返回已有部分结果,可以走一个兜底问答模板,也可以标记为需要人工介入。没有降级方案的熔断,本质上只是把问题从“Agent无限循环”变成了“用户得到一句冷冰冰的超时报错”。

3. 可落地的停止策略模板:参数选择与核心代码

3.1 预算对象与基础检查

先给一个非常朴素的预算对象,它能在任何Agent主循环里直接使用。

import time class StopBudget: def __init__(self, max_steps=15, max_tokens=30000, max_wall_time=90.0): self.max_steps = max_steps self.max_tokens = max_tokens self.max_wall_time = max_wall_time self.start_time = time.time() self.steps = 0 self.total_tokens = 0 def add_usage(self, tokens: int) -> None: self.steps += 1 self.total_tokens += tokens def should_stop(self) -> tuple[bool, str]: if self.steps >= self.max_steps: return True, "max_steps_reached" if self.total_tokens >= self.max_tokens: return True, "max_tokens_reached" if time.time() - self.start_time >= self.max_wall_time: return True, "max_wall_time_reached" return False, "" def remaining_steps(self) -> int: return max(0, self.max_steps - self.steps)

参数怎么定,我给出一个通用推算方法。max_steps先不要拍脑袋,找二十条历史日志,统计任务在正常情况下完成所需的步数。如果绝大多数任务集中在5到10步,那max_steps设为15比较合理,留出处理长尾的空间。max_tokens可以约等于平均每步消耗token数乘以max_steps,再乘一个1.2的浮动系数。max_wall_time则直接参考产品对用户等待时长的要求,通常是30到90秒。

3.2 动作签名与重复模式检测

轨迹级循环检测我一般用一个轻量实现:动作签名生成加上滑动窗口哈希。

import hashlib import json from collections import deque def normalize_args(args: dict) -> dict: # 去掉与业务无关、每轮都会变化的噪声字段 ignored_keys = {"trace_id", "request_id", "timestamp", "random_seed"} return {k: v for k, v in args.items() if k not in ignored_keys} def action_signature(tool_name: str, args: dict) -> str: normalized = normalize_args(args) raw = json.dumps( {"tool": tool_name, "args": normalized}, sort_keys=True, ensure_ascii=False ) return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] class LoopDetector: def __init__(self, window_size=5, repeat_threshold=3): self.window = deque(maxlen=window_size) self.repeat_threshold = repeat_threshold def record(self, tool_name: str, args: dict) -> bool: sig = action_signature(tool_name, args) self.window.append(sig) # 统计最近窗口内同一签名出现次数 count = sum(1 for x in self.window if x == sig) return count >= self.repeat_threshold

这段代码的主要逻辑是,对最近五步做滑窗统计。如果同一个动作签名在窗口内出现三次以上,就认为已经进入重复循环。十五步的步数预算配合这个检测器,能让很多“模型在同一个工具上反复横跳”的情况提前暴露。

要注意的是,这个检测器对参数归一化非常敏感。如果忽略字段没设置好,每次调用因为trace_id不同而全被当成新动作,检测器等于没写。反过来,如果把核心业务字段也当成噪声过滤掉,会导致不同查询被识别成同一个动作,产生误报。判断噪声字段的原则只有一个:去掉它之后两个调用是否仍然表示同一个业务行为。

3.3 熔断降级和现场证据

熔断不应该只是return,要把现场证据组合成一个结构化对象,方便排查和降级。下面是我常用的熔断出口:

def build_breakpoint(budget: StopBudget, trajectory: list, reason: str) -> dict: return { "stop_reason": reason, "steps_used": budget.steps, "tokens_used": budget.total_tokens, "elapsed_seconds": round(time.time() - budget.start_time, 2), "last_actions": trajectory[-5:], "has_partial_result": False }

为什么要把last_actions保留最近五条?因为排查循环问题时,最后几步往往比前面的状态更有价值。如果一个Agent在第十五步被熔断,前面十步可能都是正常铺垫,最后五步才暴露出重复模式。有了这些现场信息,后续回溯时可以直接看出问题触发点。

降级方案我通常用这样一个分支逻辑:如果已经产生了可用的中间结果,就把中间结果封装成“部分完成”返回给用户;如果没有任何可用结果,就返回一个兜底提示,并触发人工复核队列。这里最重要的一条原则是:不要把熔断当成异常隐藏,它应该被记录成一种正常的终止原因,纳入统计报表。

4. 收敛信号设计:让Agent学会“自我刹车”

4.1 显式完成工具与状态字段

很多Agent的失败,根源是“结束”这个动作没有被设计成工具。模型的自然语言能力很强,但让它靠感觉输出“我结束了”并不可靠。更好的做法是给Agent一个显式的完成工具,比如finalize(summary)。无论之前走了多少步,只要模型调用这个工具,执行器立刻把它当作真正的终态信号,停止继续规划。

这种做法在行为层面对模型非常友好。工具调用是模型经过训练后比较擅长表达的结构化行为,比自然语言结束语更确定。最终回答、结构化结果、任务ID都可以放进finalize的参数里。执行器解析到finalize之后,再把该轮次标记为stop_reason=explicit_finish。

同样重要的是每轮输出状态字段。我在设计Agent循环时,会强制要求模型在每轮生成JSON里带有should_continue字段,值为true或false,并附上continue_reason。即使在一次模型回答里没有调用工具,只要should_continue为true,执行器就再走一轮;只有显式false才进入下一阶段的判定。这样做的价值是,把“是否继续”从隐式的模型意图变成可解析的显式信号,为后续预算预警和日志分析提供稳定的数据源。

4.2 剩余预算提示词

预算信息不光要写在外部代码里,还要写进模型上下文。很多循环问题其实源于模型“不知道还剩多少步”。它以为可以无限试探,所以试错成本很低。如果每一步告诉它:当前是第7步,最多15步,你还有8次机会完成这个任务,模型通常会收敛得多。

我一般在每轮构造用户消息之前,插入一段系统级提示,类似:

当前预算状态:已使用7步,剩余8步。 请优先基于已有信息决策。如果无法取得进一步进展,请调用 finalize 提供当前结果。

这段提示不需要太长,但位置要稳定,放在对话上下文固定位置,避免模型忽略。剩余步数较少时,可以进一步强化措辞,比如“剩余2步,请勿继续尝试新方案,尽快交付当前最优结果”。实测下来,这种渐进式紧迫感能明显减少无意义分支探索。

有人担心提示词里写预算会让模型“有压力乱收尾”,实际并不会。模型不会因为压力而编造结果,它在显式约束下反而更倾向于调用finalize来结束。当然,前提是finalize工具本身可用,模型知道调用了它可以安全收场。没有finalize的Agent,收到“尽快结束”的指令后只能输出一段文字,执行器依然很难判断是否完成,等于白提示。

4.3 行为级探测与夹断指令

比提示词更强力的干预是“夹断指令”。当循环检测器判定重复模式后,直接向上下文中注入一段信息:

检测到你已经连续三次尝试相同操作。不要继续重试。 请评估当前数据是否足够回答用户;足够则调用 finalize,不足则明确说明缺少哪些信息。

这相当于在模型思考链路里强制打断重复。没有这段干预时,模型会认为自己每一次微调都是“新的尝试”,循环检测器即使报警,主循环也只负责终止,模型并不知道为什么被终止。有了夹断指令,模型有机会在终止前自己扭转方向,返回更有价值的最终结论。

行为级探测还不只限于动作重复。连续多轮工具返回空结果、连续多轮调用同一个工具但输入参数只有时间戳变化、连续多轮产生相同错误码,都值得触发不同的干预模板。每种模板都尽量做到“给出不重试的理由”和“告诉模型下一步可选方向”。只告诉模型“你错了”没太大用,模型只会换个姿势继续错;告诉它“不要再做A,可以试着做B或交付结果”,收敛效果会好很多。

5. 真实调优实录:三种典型Agent的停止策略修正

5.1 购物助手:单一阈值在多工具场景失效

某电商场景里,一个购物助手Agent负责根据用户描述筛选商品并形成推荐清单。初期只在全局设置了一个max_steps=20,结果频繁出现两类问题:简单刚需商品推荐3步就能搞定,却平均要跑12步;复杂多条件筛选任务在15步左右被无端截断,用户拿不到结果。

后来我按场景拆分了预算。用户提问里包含明确商品名、价格区间、配送要求时,视为“强约束任务”,max_steps设为8;包含模糊关键词,比如“适合送长辈的实用礼物”,视为“探索型任务”,max_steps设为18。拆完之后,强约束任务的平均步数从12降到6,用户等待时间明显缩短;探索型任务也保住了足够的空间,不再被一刀切误杀。

这里的关键不是数字本身,而是“单一预算无法适配多类型任务”。只要Agent面向多意图,预算就要跟随意图类型动态调整。代码里不需要复杂配置,一张任务类型到预算的映射表就够用。

5.2 数据处理Agent:结果终局证据缺失导致误判

另一个团队做一个数据处理Agent,负责从非结构化文档中抽取字段并写入结果表。最初的停止条件是:模型回答文本包含“完成”即停止。上线后经常出现两种情况:模型说完成了,结果表里关键的金额字段还是空的;模型反复补抽字段,流程迟迟不结束。

问题本质是“完成”被定义成模型的一句话,而不是结构化的终局证据。我帮他们把终止条件改成校验规则:只有当结果表行数大于0、必填字段非空、数量字段能通过合法性范围检查时,才允许finalize。校验不通过时,系统会把缺失字段列表返回给模型,让它只补缺失信息,不重跑全流程。

改完之后,错误终态大幅减少。这个案例给我最大的启发是:停止策略必须绑定“可验证的业务结果”,而不是绑定“模型的主观表达”。任何不校验结果的Agent,本质上都是在赌模型这次没说谎。

5.3 内容聚合工作流:循环检测从“人工看日志”到“半矢量识别”

还有一个内容聚合类的Agent,需要从多个来源抓取摘要,再合并成报告。它的循环比较隐蔽:动作签名并不完全相同,因为模型每次传入的源URL排序不同,但本质上还是在重复抓同一批来源。最初靠人看日志发现循环,显然不现实。

后来我把动作签名里的排序和分页参数做了归一化,同时对工具返回内容的标题列表计算哈希,只要来源集合没有明显变化,就判定为“同一批内容的重复拉取”。检测到重复后触发分支切换,让模型停止拉取,直接基于已有信息生成报告。上线后,重复拉取导致的空转步数下降约七成,整体token消耗也明显下降。

这个案例说明,循环识别不能只看工具名和参数表面,还要看“动作背后的目标”。同一个业务目标,换参数、换排序、换载体,仍然是同一个动作。签名归一化做得好不好,直接决定检测器是真报警还是狼来了。

6. 成本视角:停止策略是资源预算的第一道闸门

6.1 失控到底烧掉了什么:算一笔Token账

失控Agent最直接的代价是Token消耗。我经常用一个很简单的公式估算:单次失控成本等于平均每轮token消耗乘以实际轮数。假设平均每轮输入输出合计3000 token,正常任务12轮消耗36000 token,失控循环跑到40轮就是120000 token,成本翻了3倍以上。

这个数字放在单个请求上好像并不多,但放到生产环境的日活上,可能会变成一笔明显上涨的账单。更麻烦的是,成本上升和用户流失往往是同时发生的。用户等一个几十秒还完不了的任务,体验分已经掉到底;而系统方还在为这段失败体验烧掉几倍于正常路径的预算。

后续连锁成本同样不容忽视。失控Agent会产生大量错误写入、重复通知、重复外呼脚本调用,这些下游副作用比token成本更难清理。停止策略的意义就在这里:它是在成本失控的第一个环节拉闸,而不是等下游系统被污染后再强调错误处理。

6.2 可观测性和自动告警:把“停不下来”变成可度量事件

停止策略要有效,必须能观测。我建议每条Agent执行日志至少包含四个字段:stop_reason、steps_used、tokens_used、elapsed_seconds。这四个字段看起来简单,但它们能支撑起大多数成本问题的回溯。

stop_reason尤其重要。它把所有终止情况切成可聚合的类别:explicit_finish是正常完成,max_steps_reached是步数熔断,loop_detected是循环命中,budget_exhausted是Token超限。有了这些标签,就能按类别统计占比。如果某个Agent的loop_detected占比从2%涨到10%,不需要看原始日志,告警系统就能自动发现异常。

自动告警的阈值也不需要复杂。我一般会同时关注三个指标:单次运行步数超过预算的百分比、stop_reason中非正常终止占比、以及高Token消耗任务在总消耗里的占比。任何一个指标连续几个窗口走高,就触发一次人工排查。与其事后靠用户投诉发现问题,不如让告警先告诉你。

6.3 小团队的低成本落地方案

不是所有团队都有完整的监控基础设施。小团队或单人项目可以把成本观测做得很轻:结构化日志输出到本地或简单日志平台,再加一个定时任务统计聚合。

比如每晚跑一个脚本,统计当天所有Agent日志里的stop_reason分布和平均token消耗。哪怕没有仪表盘,一张文本表格也能说明问题。第二周再看就能发现趋势。我见过不少团队一开始就想上重型监控,反而被基建拖累。先把日志字段定好,后面接什么平台都容易。

成本治理的另一个低成本手段是“种子样本回归测试”。把之前出过循环问题的几十条请求固化成测试集,每次调整停止策略后,统一跑一遍,观察是否出现新的误停、漏停或成本上升。这组回归样本的成本很低,但能防止你修好A问题的同时改坏B场景,价值非常大。

7. 常见问题速查表与值得写进SOP的经验

7.1 高频故障现象与排查手顺

我把实际项目里反复出现的停止策略问题整理成一个速查表,排查的时候可以按顺序走。

现象可能根因排查顺序解决方案
Agent在同一个工具上无限重试循环检测未生效,参数归一化没过滤噪声字段先看日志里动作签名是否一致,再看窗口命中次数完善噪声字段过滤,设置重复阈值
任务明明完成,Agent还在继续没有显式finalize工具,或模型没学会调用它检查上下文里是否定义finalize,检查样例里是否有调用记录增加显式完成工具,并在示例中强化调用路径
简单任务也被熔断全局预算不区分任务类型按场景拆分执行耗时统计建立任务类型到预算的映射表
停止时没有结果返回熔断逻辑只有终止没有降级查看熔断出口是否返回已有中间结果增加部分结果返回和人工复核队列
调整阈值后正常流程频繁误停新策略没有做回归测试用历史正常请求跑一遍新策略建立种子样本回归集
日志看不出为什么停止停止原因没有统一字段检查日志是否包含stop_reason统一停止原因枚举并记录预算字段

排查顺序的核心原则是“先看现场,再改策略”。很多问题不需要改模型提示词,只需要把日志里的stop_reason和last_actions对上,就能定位到具体环节。

7.2 几条我踩坑后固定下来的经验

第一,不要只设一个全局max_steps就收工。按任务类型、用户意图、阶段进度拆分预算,效果会好很多。

第二,循环检测的签名归一化永远是重点。宁可多花时间理清哪些参数是噪声,也不要急着调阈值。归一化没做好,后面所有检测结果都不可信。

第三,终态判定必须绑定业务校验。模型说“完成”不算完成,结构化结果通过校验才算。校验规则最好独立于模型输出,能被代码精确执行。

第四,熔断后一定要有降级方案。哪怕只是把已有结果返回并加一句“部分完成,剩余内容已转人工”,也比冷冰冰的超时报错强。

第五,停止策略每一次调整,都要用种子样本回归。特别是涉及阈值和循环检测的改动,很容易在看不见的长尾场景里产生误伤。

第六,把停止策略参数配置化、版本化。不要让阈值只藏在代码常量里,配置项跟着版本走,后续复盘时才知道是哪一次改动影响了线上表现。

最后分享一个我固定下来的习惯:任何Agent落地检查清单里,一定会写“运行时能看到stop_reason、steps_used、tokens_used三件套”。线上出问题不可怕,可怕的是出了问题却没有任何现场证据。停止策略不是性能优化,它本质上是产品设计的一部分。用户不会关心模型内部跑了多少步,但他们会记住这个系统是“一个任务三秒出结果”还是“转圈半小时还没完”。把停止机制设计得足够清晰,Agent才真正从一个“能跑演示”的Demo,变成“能上生产”的系统。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进