1、agent 循环的退出逻辑是什么?
2、agent 怎么知道什么时候结束,返回结果给用户,还是继续下一轮循环?
两个问题其实是一件事的两面:循环靠什么继续、靠什么停。下面只讲设计思想。
一、先建立一个关键认知:循环本身不懂任务,它只是一个"协议信号解释器"
循环每一轮做的事极其简单:
- 把当前对话历史发给模型;
- 拿回一个回复;
- 只看一个结构性事实:回复里有没有工具调用 ;
- 有 → 执行工具,把结果追加进历史,回到第 1 步;
- 没有,只有文本 → 认为模型在说"我答完了",退出,把文本交给用户。
注意这里没有任何任务级判断------循环不读内容、不评估"用户的问题被解决了没有"、不检查"还差几步"。"是否结束"这个决策权,被协议化地交给了模型自己:模型通过"返回工具调用"或"返回纯文本"这两种法定信号来表达继续或结束。
这是整个设计的第一原则:结束不是循环算出来的,是模型申报的。循环只负责忠实地解释协议。
二、但系统绝不信任"我答完了"这句申报------退出逻辑的真正深度在这里
如果真的只看"有没有工具调用",现实中的模型会在四个方向上欺骗你,每一个方向都对应一类退出设计:
方向 1:模型假停------嘴上说完了,实际什么都没干
模型经常输出"好的,我现在来帮你查一下......"然后干净利落地停了,没有任何工具调用。对协议解释器来说这是"正常结束",对用户来说这是什么都没发生。
系统的对策是给"停止"加真实性校验,识别三种假停:
- 宣布了行动却没行动:回复结尾是"我接下来去做 X",但没有工具调用 → 不接受这个停止,追加一句"请实际执行"后继续,最多推两次;
- 干过活却烂尾:这一轮确实调过工具、拿到过结果,但最终回复是个残缺碎片 → 同样推它收尾,最多两次;
- 只输出思考没有答案:模型把内容写进了思维链字段、正文为空却以正常结束信号收尾 → 把思考内容提升为答案交付,同时记警告(因为"一直规划、从不行动"本身就是一种失能)。
核心思想:"停止"是一个需要被验证的声明,不是一个被无条件接受的事实。但所有"推它继续"的动作都有硬上限(两次),因为系统也假设推第三次、第四次大概率没用------宽容必须有界,否则保护机制自己变成了死循环。
方向 2:模型空转------停是停了,但没有任何可交付内容
空回复、协议要求的消息序列被破坏导致提供商返回空内容、输出写到一半超token被截断。这些情况下直接退出就是给用户发一条空气。
对策是一条恢复阶梯,而不是简单的报错:
- 能自动续的(输出被长度截断)→ 带着"请继续"再请求一次;
- 思维链写到一半被截断 → 重试,最多两次;
- 协议声明要调工具、工具列表却是空的(提供商异常)→ 专门的丢弃恢复;
- 阶梯全部走完仍拿不到内容 → 才以"部分结果 + 明确说明"退出。
思想是:异常退出是最后手段,不是第一反应;但每一级自动恢复都有次数上限,到顶就诚实地交付部分结果,而不是无限重试或假装成功。
方向 3:模型失控重复------既不结束,也不推进,陷入复读
这是最危险的形态:模型把整个输出预算用来重复同一段文字,或者反复调同一个工具拿同样的结果。朴素的"长度截断后续写"机制会把复读内容缝进最终回复,用户收到几万字的垃圾。
系统用内容指纹检测:当回复被大段逐字重复占据(有明确的长度、重复次数、覆盖率阈值,刻意做得保守,避免误杀"把同一句话说50遍"这类用户真实指令),直接判定退化,中止本轮并给出清晰的错误说明,而不是把垃圾缝进答案。
这里的设计观是:循环不能只会"正常结束"和"无限继续"两个状态,它必须能识别'在动但没有在前进'这种第三种状态。继续的合法性不取决于有没有下一次请求,而取决于状态有没有收敛。
方向 4:模型想继续,但世界不让------与模型意愿无关的硬退出
这一类退出完全不看模型想干什么:
- 迭代预算耗尽:本轮允许的模型调用次数有上限。主 agent 默认不设上限(信任用户在场监督),子 agent 默认 250 次(无人在场,必须有界);
- 墙钟时间预算耗尽:还可以给整轮运行设总时长;
- 人按下停止:软停、硬停、插话、转向(上一轮讲过);
- API 重试全部失败:网络/限流/鉴权走完整条重试梯子仍失败 → 错误信封退出;
- 上下文压缩失败:历史已经长到塞不进窗口,压缩又超时 → 交付一条特殊的终止消息,历史完整保留,让用户去开新会话,而不是毁掉历史重试;
- 未捕获异常:兜住循环体本身的异常,强制进入收口流程。
预算耗尽的处理有一个很讲究的细节:不是到点立刻掐断 。80% 时先注入一条"预算快用完了,请收尾"的通知,耗尽后还给最后一次"宽限调用",让模型有机会产出一段像样的总结,而不是话说到一半被杀。思想是:硬边界保护的是系统,宽限设计保护的是用户体验------强制结束也应该以一种体面的方式结束。
三、还有第四种状态:既不继续,也不退出------"挂起等人"
容易被忽略但很重要:当模型调用"向用户提问"或命中危险操作需要审批时,循环没有退出,而是阻塞在那一次工具调用内部,等人的答复。
这是一个刻意的建模选择------把"等待人"表达成一次耗时很长的工具调用,而不是一次回合结束:
- 退出意味着状态冻结、上下文要重新装配、工具的半截工作要善后;
- 阻塞在工具内部意味着人答复后,循环从断点原样苏醒,模型看到的就是"我问了、他答了",对话结构零损伤。
所以完整的状态机是四态,不是三态:
模型返回工具调用
┌──────────────────────────────┐
▼ │
┌─────────┐ 模型返回纯文本 ┌─────────┐
│ 继续执行 │ ───────────────→ │ 正常结束 │
└─────────┘ └─────────┘
│ 调用等人工具 ▲
▼ │(答复后回到循环)
┌─────────┐ 各种异常/预算/截断
│ 挂起等人 │ │
└─────────┘ ▼
┌───────────┐
│ 保护性退出 │
└───────────┘
四、所有退出路径共用"一扇门"------这是工程上最关键的设计
无论从哪条路径离开------正常答完、预算耗尽、人打断、重试失败、压缩超时、重复退化------全部以统一的结果信封经过同一个收口函数。
收口处统一完成三件事:回合边界盖章(标记"这些消息属于这一轮")、失败回合的结构闭合(保证历史里不会出现非法的消息连坐)、资源清理。
这一个设计同时买到了三样东西:
- 可恢复性:任何退出时刻,持久化的历史都是结构合法的,下次打开能直接继续;
- 可审计性:退出原因(正常 / 预算 / 中断 / 错误 / 部分完成)被统一标注,事后能区分"模型自己说做完了"和"系统强制它停的";
- 不泄漏:循环中间态的临时变量、语音通道、子任务资源不会因异常退出而残留。
这也回答了你两个问题的共同本质:退出逻辑的设计目标不是"让循环能停下来",而是"让循环在任何原因下停下来之后,世界都处于一个自洽、可解释、可继续的状态"。
总结成三句话
- 怎么决定结束:协议层面由模型申报(有工具调用就继续,纯文本就结束),循环不做任务理解;
- 怎么保证申报可信:系统对每次"结束"做假停、空转、烂尾、复读四类真实性校验,所有自动补救有硬次数上限,超限就诚实交付部分结果;
- 怎么保证结束安全:预算、人、错误三类硬边界独立于模型意愿强制退出,外加"挂起等人"第四态,所有路径走统一收口------结束的质量和开始的质量被当作同等重要的事来设计。
1、 选中文本: 系统的对策是给"停止"加真实性校验,识别三种假停 如何做真实性校验 ?
2、 选中文本: 输出写到一半超token被截断 如何判断被截断 ?
3、 选中文本: 模型失控重复 如何检测重复的?
这些检测器本质上都在解决同一个难题:在不理解任务语义的前提下,怎么判断一次"结束/中断/重复"是真的还是坏的。
一、真实性校验:怎么识破模型的"假停"
出发点:循环无法判断"任务做完没有",只能判断"停止的形态像不像做完了"
让系统去理解"用户的问题是否真的被解决"是死路------那需要任务级语义。可行的路子是反过来:不证明"真的做完了",而是枚举"哪些形态的停止,统计上几乎必然是假的"。这是一个典型的从"验证真"退到"识别假"的设计转换。系统识别四种假停形态,每一种的判据都围绕一个被误判的真实场景设计。
形态 1:"宣布要做,但没做"
典型表现:回复以"我现在去查一下......""接下来我来运行测试。"结尾,没有任何工具调用。
判据设计有三个讲究:
- 锚定句尾,不搜全文 。真实回答里出现"我会......"太正常了,只有一段话结束在一个未来行动宣言上才有信息量。所以检测的是结尾窗口(最后一两百字符),不是子串包含;
- 限定短语的"行动时态"。匹配的是"let me now / I'll now / next, I"这类明确指向"下一步立刻动手"的结构,而不是任何提到"我可以"的句子;
- 限长。四五百字符以内才算------写了四五百字实质内容、最后顺口说"我接下来再看看",这是完整回答,不是假停。
对"思考内容被误当成正文"的模型(把答案全写进思维链、正文为空)另有一条更宽的规则:思维链如果以第一人称行动计划收尾("让我并行跑这几个命令。"),视为卡住的规划独白。更宽是因为这种模型根本没有正文可供常规检测,但仍然只看句尾------"先让我查一下。答案是 42。"这种只是叙述过程,不拦。
形态 2:"干过活,但交上来一个碎片"
这一轮明明调过工具、拿到过结果,最后却用一个短得不正常的残片收尾(比如只有 "the"、"ing...")。
这条的设计难点全在保护简短但合法的答案 :"42"、"Done."、"SQLite"、":8080"、"你好。"------这些都短,但都是完美答案。所以判据不是"短",而是多个弱信号的交集:极短(二十几个字符以内)且 没有句末标点 且 不是纯数字/符号/路径形态。
还有一个跨语言设计:判断"这个碎片像不像人话"时要对照用户自己的文字。用户用中文提问,"是"、"好的"就是答案;用户用英文提问,回一个无意义的非英文字母残片才算碎片。检测标准跟随对话的书写系统,而不是硬编码英语中心的规则。
另外一个关键前提:本轮必须真的有工具结果。没有任何行动却回答得短,那可能只是简单问题;付出了行动却交残片,才是烂尾。
形态 3:"只有思考,没有答案"
正文为空、思维链里有内容、且提供商明确说"生成正常结束"。处理方式不是直接报错,而是把思维链提升为答案交付------但同时打警告日志,因为"一直规划、零次行动却报告完成"是模型失能的信号。
这里有个很重要的存储设计:提升的文本只作为答案旁路返回,绝不写进对话正文。因为思维链一旦作为普通正文入库,它在以后所有历史回放里就和真实回答无法区分了。表面位置和语义必须保持一致。
形态 4:"协议层自相矛盾"
提供商说"我要调工具"(finish_reason 是工具调用),工具数组却是空的------模型讲了一堆旁白但动作丢失了。这是典型的提供商/模型协议异常。系统不认这个停止,追加一句"请实际发出调用"重试。
所有这些校验共享三条纪律
- 纠正次数有硬上限 (两到三次)。追加的"请真正执行"提示语本身是临时脚手架,不进持久化历史。而且提示语本身会关闭"工具工作窗口"------这是个精巧的自限设计:推一次之后,本轮"调过工具"的事实窗口就重置了,所以第二次再交碎片,就认命,把碎片当答案。防止保护机制自己变成无限循环;
- 判据刻意保守,宁可漏不可错杀。每一条规则旁边都能看到它在保护谁:简短答案、非英语答案、合法的"我会"措辞、用户明确要求的重复。误杀一个好答案的代价高于放过一次假停------放过还有下轮补救,错杀直接破坏信任;
- 优先级有序。行动宣言优先于碎片判定,因为"我现在去做 X"比短残片信息更强;每种形态走同一个有界计数器,不管命中哪种,模型都最多被推两次。
二、截断检测:怎么知道"话没说完"
两类信号:显式的终止原因,和"终止原因的缺席"
第一类是提供商主动申报:响应带有 finish_reason=length(或 Responses 协议的 incomplete + 原因是输出上限)。不同协议的信号形态不同,系统先归一化成统一语义再决策。
第二类更难:流沉默 。流式输出中途断了,提供商什么终止信号都没发 ------可能是服务端/代理干净地关掉了连接,也可能是真实的传输异常。如果把"流结束"等同于"生成完成",半成品就会被当成完整回答交付。系统的做法是:流结束时如果没收到任何终止原因,就自己合成一个"长度截断"桩对象,强制走进恢复路径而不是正常完成路径。同时区分"干净 EOF"和"传输异常"两种沉默,因为给用户的解释和后续重试策略都不同。
核心原则:先归因,再决定救不救、怎么救
"被截断"只是表象,至少有四种完全不同的根因,根因不同,"续写"这个动作的性质就不同:
- 输出预算耗尽(话太长)→ 续写有意义,接着写就行;
- 输入塞满了上下文窗口(历史太长)→ 续写毫无意义,同样的大请求只会再次撞墙。系统从 usage 里读出"输入 token / 窗口容量",发现是窗口填满就直接终止并提示压缩,绝不续写;
- 先交付了部分内容、随后收到上下文溢出错误 → 更不能续写,那会形成"每次重试都让请求更大"的增长循环。标记为终态,历史保留,引导去新会话;
- 思维链吃光了全部预算、正文一个字没有 → 用相同配置重试只会以完全相同的方式再烧一遍 。所以重试时做两个一次性改变:关掉思维链、输出上限翻倍。绝不向同一个失败条件重发相同的请求------这是整个恢复阶梯的元规则。
恢复动作本身也有真假之分
即使判定"该续写",还要先检查手里的碎片是不是垃圾(见第三部分):碎片如果已经是复读循环,续写等于重新进入循环,直接终止。
通过所有检查后才进入续写:把碎片作为临时消息保留,追加"请继续",最多四次,最后把各段拼成完整答案。这些碎片消息都是临时的------续写成功才转正;如果四次都没写完,收口时丢掉碎片脚手架、只保留拼接好的部分结果,避免半截对话污染以后每一轮的历史。
工具调用被截断走另一条路:同一次调用重跑、输出预算指数放大,最多四次。没有可回退的完整状态时,才回滚到上一个完整的助手回合------宁可丢失最近一轮的进展,也不在一个结构残缺的对话上继续。
设计思想总结
截断处理不是一个 if 判断,是一条有序的分诊链 :先排除"续写必然失败"的情况(窗口满、溢出、垃圾碎片),再排除"该换路"的情况(内容审核拦截→先换备用提供商而不是重试主模型,因为拦截是内容决定的,重试必然再撞),最后才做成本最低的续写。顺序是承重墙------便宜的自动恢复放在昂贵动作之前,但"注定无效的恢复"放在一切之前排除。
三、重复检测:怎么区分"坏的复读"和"好的重复"
这是三个问题里最深刻的一个,因为重复本身不是错。用户可能就要求"把这句话说 50 遍";批量 SQL 插入、模板化 YAML、表格行天然高度相似;引用、标题、代码本来就在复用措辞。检测器要识别的不是"重复",而是**"没有信息增益的再生成"**。
文本复读:三条互补的检测器,因为任何单一形态都会漏
系统同时跑三种算法,任一命中即判定:
- 整行复读:归一化后某一行出现至少 5 次、且这些行覆盖了全文一半以上。这是最常见的崩溃形态,最便宜,先跑;
- 定长窗口计数 :取 60 字符的滑窗,统计某个窗口的出现次数,当它重复到足以覆盖一半文本时命中。为什么需要它?因为崩溃输出的每一遍可能带一个递增序号或噪声 token,整行比较永远不相等,但中间 60 字符的窗口是逐字相同的------它抓的是"带计数器的复读";
- 周期段展开 :最复杂也最关键。找到两个相同的 60 字符锚点,它们的间距就是候选周期,然后向两边逐字符扩展等式
text[i] == text[i+周期],还原出真正的重复单元,再用真实周期长度计算覆盖率。为什么需要它?长重复单元(比如 2000 字符一段的崩溃)里每个 60 字符窗口出现次数都不高,窗口计数抓不到,必须还原周期本身。
性能上第三条做了锚点采样(均匀取几十个点),保证检测开销对输出长度是线性的,而不是平方。
用"行的去重率"做最后一道防误杀闸门
即使覆盖率达标,如果文本有行结构,还要求去重后的行数不超过总行数的一半才算失控。这一条专门保护批量输出:20 条 INSERT 语句共享长前缀,窗口扫描很容易报警,但每一行都是不同的------有信息增益,放行。
同一个算法,在不同时点用两套阈值------这是最核心的不对称设计
- 在长度截断路径上:碎片已经烧掉了全部输出预算,最小检查长度只要 400 字符,命中即终止。因为此时输入本身就是可疑的(话没说完),且续写会把垃圾缝进答案;
- 在正常完成路径上 :模型正式声明"我答完了",要丢弃的是一份完整答案,门槛极高------16,000 字符起步。真实的完成态崩溃案例都是 8 万到 35 万字符的怪物,而用户要求的重复、模板化输出都在低 KB 区间。
同一个"坏重复"定义,处置成本越高,触发门槛越高。丢弃碎片便宜,阈值低;丢弃一份用户可能明确要求的完整回答代价大,阈值高到只有病理级规模才触发。
流中看门狗:不能等它说完再判
一个持续产出的流永远到不了"完成检查点",而没有输出上限的端点会让崩溃模型一直跑下去。所以有一个边接收边判断的看门狗:产出到 16k 字符时首检,之后检查间隔倍增、封顶在一个尾部窗口大小;每次只看最新的尾部文本并丢弃更早的缓冲。
三个设计目标同时达成:发现得早 (崩溃无论从长回复的哪里开始,最多再流一个窗口长度就会被抓到);开销有界 (总工作量线性,内存最多持有两个窗口);不误杀 (用户要求的重复天然在首检线以下)。命中后丢弃碎片时还有一条跨重启的纪律:绝不能把崩溃字节回放进历史------把它作为中断检查点重放,等于给下一次请求重新播种同样的循环,腐败会活过重启。替换成一句中性的"回复陷入重复循环,已中断"。
工具级复读:签名、连击、周期性批次
文本之外,模型还可能在行为上空转。这里的检测对象是工具调用序列:
- 签名归一化:工具名 + 参数的规范化 JSON(键排序、去空白)+ 结果哈希,三者全等才算"同一次调用"。参数键序不同不算差异;
- 连续相同调用:第三次完全相同的连击开始提示(容忍一次"再确认一遍"的合理双击),逐级升级到警告、硬阻断。阻断不是异常,是一句写成人话的工具结果回给模型:"同样的调用失败了 N 次,别原样重试,换策略或说明障碍";
- 周期批次检测:只抓连击会被 A,B,A,B... 交替复读绕过------每次交替都重置连击计数。所以还检测 2~4 个调用组成的批次周期:相同的参数、相同的结果、跑了若干"圈",判定无进展并终止;
- 失败型复读单独分类 :同一命令原样失败重试、同一工具换着命令失败、只读调用反复返回相同结果,三类严重程度不同。对终端、代码执行这类工具特别宽容------红测试、grep 无结果、页面超时都是正常工作产出,只有"原样重放且中间没有任何改变"才算数;
- 进展重置:这一轮里只要发生过一次变更性操作(写文件、打补丁、点击、导航),之前失败签名的计数全部清零------"编辑→重跑"是新实验,不是重放;
- 防自激:系统自己发出的"冗余调用拒绝"提示虽然带 error 字样,但不被计入失败次数,否则廉价保护会喂大自己的熔断计数;
- 无人在场时升级为硬停,有人值守时默认只警告。同一套检测,CLI/TUI 里人能看出来拦一把,定时任务里没有这个人,就必须自动硬终止。
总结:三个机制共享的四条设计哲学
-
不验证"好",只枚举"坏"的形态。任务语义不可得,但病理形态是可枚举的:行动宣言收尾、无终止信号的沉默、无信息增益的重复。检测器是形态学,不是语义学。
-
每条规则都必须能回答"它在保护谁不被误杀"。简短答案、非拉丁文字、批量数据、用户明确要求的重复------保守不是模糊的态度,而是逐条落实成脚本感知、长度门槛、去重率闸门这些具体豁免。
-
纠正而不是惩罚,且一切自动纠正有界。第一次发现问题永远是"把症状用语言告诉模型,给它自救机会"(续写提示、假停提示、复读警告都是作为消息回流);模型不自救才升级为终止。提示语是临时脚手架,次数有硬上限,防止保护机制自己成为不收敛的源头。
-
阈值随处置成本和观察时点移动 。丢碎片用低阈值,丢完整答案用高阈值;流中检查看尾部,截断检查看碎片,完成检查看全文------没有一个"全局正确"的灵敏度,只有"在这个时点、为这个动作、误判代价是多少"的局部最优。