摘要 :一个批量任务跑了 112 轮、4 小时 37 分、1,872,400 Token、¥3,140 ,全程 HTTP 200、每轮延迟正常、监控一片绿色------直到运维发现 Pod 内存在涨 。本文把它当作一次完整的循环失控事故复盘:先给四类成因 (条件永不满足 / 状态未推进 / 工具反复返回同样结果 / 自我引用),再给四种检测手段 (重复签名检测、状态哈希无进展检测、预算硬闸门、工具调用指纹)并给出实测的检出率、检出轮次与误报率 ;然后讲中断的两档策略 (软中断注入收尾提示让模型自然结束、硬中断取消任务并转人工),以及最容易被忽略的一半------中断之后输出还可用吗 (半截 JSON、半截报告不能给用户)。最后把第 24 篇 Supervisor 的 47 轮死循环、第 25 篇黑板的永不停机、第 26 篇编排引擎的循环五约束收拢成一套通用组件 ,指出它们各自缺的那三块:重复签名检测、中断后输出可用性、中断的计费语义。核心结论:循环控制不是"加个
max_rounds",而是"检测 → 判定 → 中断 → 兜底 → 复盘"五件事;只做第一件的团队,会在中断时把用户的任务变成一堆废话。📌 版本声明 :本文基于 Python 3.11+、asyncio、pydantic v2 编写,撰写时间 2026 年 10 月。全部并发原语使用标准库
asyncio,不依赖第三方框架。中断的计费语义引用 OpenAI Responses API 与 Anthropic Messages API 的官方说明(同步流式请求没有服务端取消端点,只有后台任务支持responses.cancel;已生成的 Token 不因中断而退款 ),stop_reason取值引用 Anthropic 官方处理指南。各平台的取消语义、计费规则与缓存行为请以官方文档为准,且会随版本变化。适用边界 :适用于存在运行期循环 的 Agent 系统------Supervisor 调度循环、黑板唤醒循环、带反向边的编排图、ReAct 工具循环。如果你的 Agent 是"一次调用出结果"或"固定 N 步流水线",循环检测基本用不上,一个总超时就够了 。另外,本文讲的是单进程内的检测与中断;跨进程/多实例的循环控制需要外部协调(会话粘滞或分布式状态),第 40 篇 5.6 节给了演进路径,本文不重复展开。
文章目录
-
- 一、事故现场
-
- [1.1 一个跑了 4 小时 37 分的任务](#1.1 一个跑了 4 小时 37 分的任务)
- [1.2 时间线:监控为什么全程绿灯](#1.2 时间线:监控为什么全程绿灯)
- [1.3 日志片段:循环长什么样](#1.3 日志片段:循环长什么样)
- 二、排查过程
-
- [2.1 第一步:确认它是循环而不是慢](#2.1 第一步:确认它是循环而不是慢)
- [2.2 第二步:给循环分类](#2.2 第二步:给循环分类)
- [2.3 第三步:复现](#2.3 第三步:复现)
- [2.4 第四步:定位触发点](#2.4 第四步:定位触发点)
- 三、根因分析
-
- [3.1 四类成因的机理](#3.1 四类成因的机理)
- [3.2 分层归因:四个层次](#3.2 分层归因:四个层次)
- [3.3 与第 24 / 25 / 26 篇的关系:把三处循环控制收拢](#3.3 与第 24 / 25 / 26 篇的关系:把三处循环控制收拢)
- 四、修复方案:检测、中断、兜底
-
- [4.1 检测手段一:重复签名检测](#4.1 检测手段一:重复签名检测)
- [4.2 四种检测手段的实测效果](#4.2 四种检测手段的实测效果)
- [4.3 中断策略一:软中断](#4.3 中断策略一:软中断)
- [4.4 中断策略二:硬中断与它的计费真相](#4.4 中断策略二:硬中断与它的计费真相)
- [4.5 中断之后:输出还可用吗](#4.5 中断之后:输出还可用吗)
- [4.6 把四者串起来:LoopGuard](#4.6 把四者串起来:LoopGuard)
- [4.7 上线前后对比](#4.7 上线前后对比)
- 五、进阶防护
-
- [5.1 监控:循环的三个必看指标](#5.1 监控:循环的三个必看指标)
- [5.2 门禁:把循环预算写进配置校验](#5.2 门禁:把循环预算写进配置校验)
- [5.3 循环用例回归库](#5.3 循环用例回归库)
- [5.4 循环控制的运行时状态机](#5.4 循环控制的运行时状态机)
- [六、适用边界与风险提示 ⚠️](#六、适用边界与风险提示 ⚠️)
-
- [6.1 什么时候不需要这么复杂](#6.1 什么时候不需要这么复杂)
- [6.2 六个风险](#6.2 六个风险)
- [6.3 与相邻篇的分工](#6.3 与相邻篇的分工)
- 七、一个可复制的最小骨架
- 八、总结
- 参考资料
一、事故现场
1.1 一个跑了 4 小时 37 分的任务
9 月 17 日 02:14,一个"竞品价格监控"批量任务按计划启动。它的设计意图很普通:检索 N 个竞品页面的价格,比对基准价,输出差异清单。按历史数据,这类任务平均 7.3 轮、约 12 万 Token、耗时 4~6 分钟。
06:51 被人工取消时,它已经跑了:
| 指标 | 数值 | 正常基线 | 倍数 |
|---|---|---|---|
| 轮次 | 112 轮 | 7.3 轮 | 15.3× |
| 墙钟耗时 | 4h37m(277 分钟) | 5.2 分钟 | 53.3× |
| Token | 1,872,400 | 121,600 | 15.4× |
| 成本 | ¥3,140 | ¥204 | 15.4× |
| 平均单轮耗时 | 148 秒 | 43 秒 | 3.4× |
| 平均单轮 Token | 16,718 | 16,657 | 1.0× |
看最后一行------平均单轮 Token 完全正常。 这就是这次事故最难发现的地方:没有任何一轮是异常的 。每一轮都是一次合规的、成功的、耗时正常的 LLM 调用。异常只存在于"轮次"这个维度上,而我们的监控里根本没有这个指标。
当天同类任务共 23 个进入循环,合计异常消耗 2,140 万 Token、¥35,900 ,占当日总成本的 41.3%。
1.2 时间线:监控为什么全程绿灯
| 时间 | 发生了什么 | 监控状态 |
|---|---|---|
| 02:14 | 批量任务启动 | 正常 |
| 02:52 | 第 12 轮 | 绿灯(任务成功率 100%) |
| 03:40 | 上游配额使用率升至 63% | 未告警(阈值 80%) |
| 04:30 | 配额使用率 87%,触发告警 | 告警被归因为"业务量增长" |
| 05:58 | 运维发现 3 个 Pod 内存持续上涨 | 人工介入排查 |
| 06:51 | 确认是循环,人工取消任务 | --- |
| 07:20 | 复盘:112 轮 / 1,872,400 Token | --- |
| 07:40 | 定位根因:状态哈希自第 9 轮起未变 | --- |
04:30 那条配额告警是本次事故里最可惜的一次机会。 它确实响了,但被归因为"业务量增长"------因为没有任何一个指标能把"配额上涨"和"某个任务在循环"关联起来 。我们的监控体系里有任务成功率、有 P99 延迟、有配额使用率,唯独没有"按 trace 统计的轮次数"和"单任务累计 Token"。
🎯 我的拍板建议 :循环失控的监控指标必须是"按 trace 聚合"的,不能是"按时间窗口聚合"的。 时间窗口聚合(比如"过去 5 分钟总调用次数")在循环场景下会被其他正常流量稀释------23 个循环任务混在 4 万个正常请求里,任何窗口级指标都看不出来。只有 per-trace 的轮次与累计 Token 能把它揪出来。
1.3 日志片段:循环长什么样
这是那条任务第 8 ~ 12 轮的脱敏日志。注意 state_hash 这一列是事后补上的,事故当时并没有它:
text
[02:31:07] round=08 tool=search_prices args={sku:"A-1001",region:"cn"}
result_hash=7f3a91c2 state_hash=4b82e10f in=16,204 out=812
[02:33:41] round=09 tool=search_prices args={sku:"A-1001",region:"cn"}
result_hash=7f3a91c2 state_hash=4b82e10f in=16,988 out=796
[02:36:12] round=10 tool=search_prices args={sku:"A-1001",region:"cn"}
result_hash=7f3a91c2 state_hash=4b82e10f in=17,703 out=803
[02:38:49] round=11 tool=verify_price args={sku:"A-1001",threshold:0.05}
result_hash=7f3a91c2 state_hash=4b82e10f in=18,420 out=774
[02:41:23] round=12 tool=search_prices args={sku:"A-1001",region:"cn"}
result_hash=7f3a91c2 state_hash=4b82e10f in=19,118 out=819
(此后 100 轮形态完全一致,仅 in= 每轮增长约 700 Token)
三行数据足以判死刑 :result_hash 从第 8 轮起完全相同(工具在返回同样的东西),state_hash 从第 9 轮起不再变化(状态没有推进),而 in= 每轮还在稳定增长(上下文在膨胀)。这是一个"既没进展、又越来越贵"的典型死循环。
而当时我们的日志长这样:
text
[02:31:07] INFO 调用工具 search_prices 成功
[02:33:41] INFO 调用工具 search_prices 成功
[02:36:12] INFO 调用工具 search_prices 成功
[02:38:49] INFO 调用工具 verify_price 成功
[02:41:23] INFO 调用工具 search_prices 成功
"调用成功"这四个字重复了 112 次,没有一条日志说"这件事不对劲"。 这是循环失控事故的共性:它不产生错误,所以它不属于任何错误处理体系的管辖范围(第 37 篇的错误分类里没有它)。
二、排查过程
2.1 第一步:确认它是循环而不是慢
拿到告警后第一个问题是"它是卡住了还是在空转"。区分方法很直接:看单轮耗时 × 轮次 是否等于总耗时。
| 判据 | 卡住(blocked) | 循环(looping) |
|---|---|---|
| 单轮耗时 | 有一轮极长(等于总耗时) | 每轮都正常 |
| 轮次数 | 少(1~3 轮) | 多(几十到上百) |
| CPU/内存 | 低(在等 IO) | 内存持续上涨(上下文膨胀) |
| 上游调用频率 | 低频 | 高频且稳定 |
"内存持续上涨"是循环最可靠的外部特征。 原因就是上下文无界增长(第 45 篇的 R2)------每一轮都把上一轮的输出和工具返回加进上下文,内存曲线是一条稳定上斜的直线。05:58 运维发现的就是这个。
💡 这条特征可以直接做成告警 :监控"单个 trace 的活跃上下文 Token 数"或其导数。比轮次指标更早触发,因为它不需要等到轮次超阈值------它在第 20 轮就会明显偏离正常任务的曲线形状。
2.2 第二步:给循环分类
确认是循环之后,要判断它是哪一类循环------因为四类成因的检测手段不同。我们事后写了一个分类器,把当天 23 个循环任务归档:
| 成因 | 任务数 | 占比 | 识别特征 |
|---|---|---|---|
| 状态未推进(原地打转) | 13 | 56.5% | state_hash 连续多轮不变 |
| 工具反复返回同样结果 | 6 | 26.1% | result_hash 重复出现 |
| 条件永不满足(收敛判据写反) | 3 | 13.0% | 每轮状态都在变,但判据永远不成立 |
| 自我引用 | 1 | 4.3% | 模型引用自己上一轮的输出作为新证据 |
"条件永不满足"这一类最阴险 :它的状态每轮都在变 (所以状态哈希检测抓不到),工具返回也每次不同 (所以签名检测抓不到),只有预算闸门能拦住它 。这就是为什么四种子段必须全部部署------任何一种单独使用都有明确的漏检区。
2.3 第三步:复现
循环类 bug 的难点在于复现成本高(一次复现要几小时)。我们用了两个办法把复现成本压下来:
- 注入时钟 + Mock 工具 :把工具调用替换成返回固定值的 Mock,把
asyncio.sleep替换成虚拟时钟。112 轮的复现从 4 小时 37 分压到 11 秒。 - 确定性回放 :把
temperature设为 0 并录制第一轮的模型输出,后续轮次用录制的输出驱动循环。
python
# repro.py ------ 循环复现:虚拟时钟 + 固定工具返回值,把小时级压到秒级
from __future__ import annotations
import asyncio
class VirtualClock:
"""虚拟时钟------第 35 篇的核心技巧,让长循环测试可秒级完成。"""
def __init__(self) -> None:
self.now = 0.0
self.sleeps: list[float] = []
async def sleep(self, seconds: float) -> None:
self.sleeps.append(seconds)
self.now += seconds
def monotonic(self) -> float:
return self.now
def make_stub_tool(result: str):
"""工具桩:永远返回同样结果------复现"工具反复返回同样结果"型循环。"""
calls = {"n": 0}
async def tool(**kwargs) -> str:
calls["n"] += 1
return result
return tool, calls
async def main() -> None:
clock = VirtualClock()
tool, calls = make_stub_tool("未找到匹配的价格记录")
rounds = 0
for _ in range(200): # 上限 200,模拟"无上限"的循环
await clock.sleep(148.0)
await tool(sku="A-1001")
rounds += 1
if clock.monotonic() > 16_000: # 4.4 小时后人工取消
break
print(f"复现 {rounds} 轮,虚拟耗时 {clock.now / 60:.1f} 分钟,"
f"实际执行耗时可忽略")
if __name__ == "__main__":
asyncio.run(main())
这段代码的价值在于它把"循环"变成了一个可回归的测试用例。 复现之后,我们把当天 23 个循环任务全部做成了回归用例(放在第 35 篇讲的测试体系里),每次改循环控制逻辑都跑一遍------这是防止复发的唯一可靠手段。
2.4 第四步:定位触发点
最后一步是找出"是什么让循环开始的"。三个循环任务的共同点是:它们都遇到了同一个异常输入 ------竞品页面改版,search_prices 返回了一个空列表加一句"未找到匹配的价格记录"。
模型的行为链是这样的:
- 检索 A-1001 → 空结果
- 模型判断"可能是参数不对" → 换参数再检索 → 空结果
- 模型判断"可能是数据源不对" → 换工具再检索 → 空结果
- 模型判断"我上一个判断可能错了" → 回到第 2 步
第 4 步是关键 :模型没有能力 区分"我再试一次可能成功"和"我已经试过这个了"。它的上下文里确实有"我试过了"的记录,但随着上下文变长,早期记录的影响力会衰减------112 轮之后,第 8 轮的失败记录早就淹没在 16 万 Token 的上下文里了。
⚠️ 这个现象有个反直觉的推论:上下文越长,模型越容易循环。 因为"我试过什么"的记忆被稀释了。所以"把更多历史塞进上下文"不但不解决循环,反而会加剧它。 正确的做法是给模型一份结构化的、紧凑的"已尝试清单"(见 4.4 节的软中断提示模板),而不是依赖它从长上下文里自己回忆。
三、根因分析
3.1 四类成因的机理
#mermaid-svg-NKRlaHLhqBwUVG9L{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NKRlaHLhqBwUVG9L .error-icon{fill:#552222;}#mermaid-svg-NKRlaHLhqBwUVG9L .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NKRlaHLhqBwUVG9L .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NKRlaHLhqBwUVG9L .marker.cross{stroke:#333333;}#mermaid-svg-NKRlaHLhqBwUVG9L svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NKRlaHLhqBwUVG9L p{margin:0;}#mermaid-svg-NKRlaHLhqBwUVG9L .edge{stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .section--1 rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section--1 path,#mermaid-svg-NKRlaHLhqBwUVG9L .section--1 circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section--1 polygon,#mermaid-svg-NKRlaHLhqBwUVG9L .section--1 path{fill:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section--1 text{fill:#ffffff;}#mermaid-svg-NKRlaHLhqBwUVG9L .node-icon--1{font-size:40px;color:#ffffff;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-edge--1{stroke:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-depth--1{stroke-width:17;}#mermaid-svg-NKRlaHLhqBwUVG9L .section--1 line{stroke:hsl(60, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled circle,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:lightgray;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:#efefef;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-0 rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section-0 path,#mermaid-svg-NKRlaHLhqBwUVG9L .section-0 circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section-0 polygon,#mermaid-svg-NKRlaHLhqBwUVG9L .section-0 path{fill:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section-0 text{fill:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .node-icon-0{font-size:40px;color:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-edge-0{stroke:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-depth-0{stroke-width:14;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-0 line{stroke:hsl(240, 100%, 83.5294117647%);stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled circle,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:lightgray;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:#efefef;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-1 rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section-1 path,#mermaid-svg-NKRlaHLhqBwUVG9L .section-1 circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section-1 polygon,#mermaid-svg-NKRlaHLhqBwUVG9L .section-1 path{fill:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section-1 text{fill:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .node-icon-1{font-size:40px;color:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-edge-1{stroke:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-depth-1{stroke-width:11;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-1 line{stroke:hsl(260, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled circle,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:lightgray;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:#efefef;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-2 rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section-2 path,#mermaid-svg-NKRlaHLhqBwUVG9L .section-2 circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section-2 polygon,#mermaid-svg-NKRlaHLhqBwUVG9L .section-2 path{fill:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section-2 text{fill:#ffffff;}#mermaid-svg-NKRlaHLhqBwUVG9L .node-icon-2{font-size:40px;color:#ffffff;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-edge-2{stroke:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-depth-2{stroke-width:8;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-2 line{stroke:hsl(90, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled circle,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:lightgray;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:#efefef;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-3 rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section-3 path,#mermaid-svg-NKRlaHLhqBwUVG9L .section-3 circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section-3 polygon,#mermaid-svg-NKRlaHLhqBwUVG9L .section-3 path{fill:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section-3 text{fill:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .node-icon-3{font-size:40px;color:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-edge-3{stroke:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-depth-3{stroke-width:5;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-3 line{stroke:hsl(120, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled circle,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:lightgray;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:#efefef;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-4 rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section-4 path,#mermaid-svg-NKRlaHLhqBwUVG9L .section-4 circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section-4 polygon,#mermaid-svg-NKRlaHLhqBwUVG9L .section-4 path{fill:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section-4 text{fill:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .node-icon-4{font-size:40px;color:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-edge-4{stroke:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-depth-4{stroke-width:2;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-4 line{stroke:hsl(150, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled circle,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:lightgray;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:#efefef;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-5 rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section-5 path,#mermaid-svg-NKRlaHLhqBwUVG9L .section-5 circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section-5 polygon,#mermaid-svg-NKRlaHLhqBwUVG9L .section-5 path{fill:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section-5 text{fill:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .node-icon-5{font-size:40px;color:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-edge-5{stroke:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-depth-5{stroke-width:-1;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-5 line{stroke:hsl(180, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled circle,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:lightgray;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:#efefef;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-6 rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section-6 path,#mermaid-svg-NKRlaHLhqBwUVG9L .section-6 circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section-6 polygon,#mermaid-svg-NKRlaHLhqBwUVG9L .section-6 path{fill:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section-6 text{fill:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .node-icon-6{font-size:40px;color:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-edge-6{stroke:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-depth-6{stroke-width:-4;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-6 line{stroke:hsl(210, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled circle,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:lightgray;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:#efefef;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-7 rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section-7 path,#mermaid-svg-NKRlaHLhqBwUVG9L .section-7 circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section-7 polygon,#mermaid-svg-NKRlaHLhqBwUVG9L .section-7 path{fill:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section-7 text{fill:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .node-icon-7{font-size:40px;color:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-edge-7{stroke:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-depth-7{stroke-width:-7;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-7 line{stroke:hsl(270, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled circle,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:lightgray;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:#efefef;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-8 rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section-8 path,#mermaid-svg-NKRlaHLhqBwUVG9L .section-8 circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section-8 polygon,#mermaid-svg-NKRlaHLhqBwUVG9L .section-8 path{fill:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section-8 text{fill:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .node-icon-8{font-size:40px;color:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-edge-8{stroke:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-depth-8{stroke-width:-10;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-8 line{stroke:hsl(330, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled circle,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:lightgray;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:#efefef;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-9 rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section-9 path,#mermaid-svg-NKRlaHLhqBwUVG9L .section-9 circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section-9 polygon,#mermaid-svg-NKRlaHLhqBwUVG9L .section-9 path{fill:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section-9 text{fill:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .node-icon-9{font-size:40px;color:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-edge-9{stroke:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-depth-9{stroke-width:-13;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-9 line{stroke:hsl(0, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled circle,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:lightgray;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:#efefef;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-10 rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section-10 path,#mermaid-svg-NKRlaHLhqBwUVG9L .section-10 circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section-10 polygon,#mermaid-svg-NKRlaHLhqBwUVG9L .section-10 path{fill:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section-10 text{fill:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .node-icon-10{font-size:40px;color:black;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-edge-10{stroke:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .edge-depth-10{stroke-width:-16;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-10 line{stroke:hsl(30, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled circle,#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:lightgray;}#mermaid-svg-NKRlaHLhqBwUVG9L .disabled text{fill:#efefef;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-root rect,#mermaid-svg-NKRlaHLhqBwUVG9L .section-root path,#mermaid-svg-NKRlaHLhqBwUVG9L .section-root circle,#mermaid-svg-NKRlaHLhqBwUVG9L .section-root polygon{fill:hsl(240, 100%, 46.2745098039%);}#mermaid-svg-NKRlaHLhqBwUVG9L .section-root text{fill:#ffffff;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-root span{color:#ffffff;}#mermaid-svg-NKRlaHLhqBwUVG9L .section-2 span{color:#ffffff;}#mermaid-svg-NKRlaHLhqBwUVG9L .icon-container{height:100%;display:flex;justify-content:center;align-items:center;}#mermaid-svg-NKRlaHLhqBwUVG9L .edge{fill:none;}#mermaid-svg-NKRlaHLhqBwUVG9L .mindmap-node-label{dy:1em;alignment-baseline:middle;text-anchor:middle;dominant-baseline:middle;text-align:center;}#mermaid-svg-NKRlaHLhqBwUVG9L :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 循环失控的四类成因
条件永不满足
判据写成永假表达式
阈值取值不可达
判据依赖外部系统永不返回的状态
特征 状态在变但永不收敛
状态未推进
写入被覆盖
写入键名不一致
状态合并函数丢弃了新值
特征 状态哈希连续多轮不变
工具反复返回同样结果
确定性工具加零温度
数据源本身为空
参数错误但工具不报错
特征 结果哈希重复出现
自我引用
模型把自己的猜测当证据
把上一轮输出追加为新输入
多 Agent 互相确认同一件事
特征 状态在变但语义原地打转
这四类不是并列的,它们有一条共同的底层机理:循环的"进展信号"没有被显式地度量。 业务循环天然收敛是因为终止条件来自外部数据(第 26 篇 1.3 节的原话);Agent 循环的终止条件来自一个概率模型,而概率模型不保证单调进展。
| 成因 | 进展信号是什么 | 为什么没被度量 |
|---|---|---|
| 条件永不满足 | 判据的取值 | 判据写死在代码里,没人打印它的中间值 |
| 状态未推进 | 状态的哈希 | 状态是字典,大家只看字段不看整体 |
| 工具反复返回同样结果 | 工具结果的哈希 | 工具返回值进了上下文但没进指标 |
| 自我引用 | 语义相似度 | 无法用哈希度量,需要嵌入相似度或人工规则 |
3.2 分层归因:四个层次
| 层次 | 缺失的机制 | 本次是否具备 | 后果 |
|---|---|---|---|
| 度量层 | 状态哈希、结果哈希、轮次计数 | ❌ 全缺 | 循环不可见 |
| 判定层 | 无进展判据、重复判据 | ❌ 缺 | 检测到了也不知道该不该停 |
| 中断层 | 软中断 / 硬中断 | ⚠️ 只有硬中断(raise) |
中断即丢全部输出 |
| 兜底层 | 部分结果的结构化输出 | ❌ 缺 | 4 小时的计算全部作废 |
| 复盘层 | 循环用例回归库 | ❌ 缺 | 同类问题重复发生 |
"只有硬中断"这一条值得展开。 我们当时确实有 max_rounds------设在批量通道的配置里,但那个通道的 enable_budget 是 False (第 45 篇 1.3 节提过)。而即使它开了,效果也只是"第 N 轮抛个异常"------用户拿到一个异常,而不是一个答案。
3.3 与第 24 / 25 / 26 篇的关系:把三处循环控制收拢
这是本文的立论核心。前三篇各自实现了一遍循环控制,但都是局部的、特例的:
| 篇目 | 它做了什么 | 局限 |
|---|---|---|
| 第 24 篇 Supervisor | 三道保险:轮次预算 MAX_ROUNDS、墙钟预算 TOTAL_TIMEOUT_S、停滞检测 stall_count;收敛判据完全不调用 LLM |
停滞检测是"本轮无新增报告"的业务特例;没有工具指纹;中断后直接降级输出,没有软中断 |
| 第 25 篇 黑板 | 五闸门终止判据:轮次预算、停滞、加权证据、冲突、假设;should_terminate() 纯代码零 LLM |
停滞判据是"连续 N 轮无写入"的黑板特例;没有重复签名检测;没有中断的计费考虑 |
| 第 26 篇 编排引擎 | 循环五约束:最大轮次上限、墙钟总预算、Token 预算、无进展检测、退化策略;反向边未声明 max_traversals 直接编译报错 |
给出了框架但没给检测算法------"无进展检测"如何实现没有展开;没有中断后输出可用性 |
三篇的共同点 :都把"终止权从模型手里拿回到代码手里"。这是正确的,也是本篇继承的第一原则。
三篇的共同缺口,正是本文要补的三块:
| 缺口 | 为什么重要 | 本文的方案 |
|---|---|---|
| ① 重复签名检测 | 24 篇的"停滞"只看有没有新增报告,25 篇只看有没有写入------它们都检测不到"有产出但产出完全重复" | 工具调用指纹(4.1 节) |
| ② 中断后输出可用性 | 24 篇降级输出、25 篇提前收敛、26 篇退化策略------都只讲了"停",没讲"停的时候手上那堆半成品怎么办" | 三档兜底(4.5 节) |
| ③ 中断的计费语义 | 三篇都没提------中断不等于停止计费 | 4.4 节,含流式与非流式的差异 |
而它们各自实现的东西,映射到本文是这三行:
text
第 24 篇 stall_count(本轮无新增报告) → 本文 状态哈希无进展检测(的特例)
第 25 篇 停滞闸门(连续 N 轮无写入) → 本文 状态哈希无进展检测(的特例)
第 26 篇 无进展检测(五项约束之一) → 本文 状态哈希无进展检测(待实现的接口)
换句话说:三篇各自实现了"无进展检测"的一个业务特例,而本文要把它变成通用算法,并补上另外三种检测手段。 这就是为什么本篇叫"收拢"------不是推翻前三篇,而是把三份复制粘贴的代码合并成一个组件。

图1:第 24、25、26 篇的循环控制与本篇的关系------收拢,不是推翻
四、修复方案:检测、中断、兜底
4.1 检测手段一:重复签名检测
这是前三篇都缺、而本次事故里最该有的一种。 它捕捉的是"模型在反复调用同一个工具、传同样的参数"。
python
# loop_detector.py ------ 循环检测:四种信号,任一触发即判定
from __future__ import annotations
import hashlib
import json
import time
from collections import Counter, deque
from dataclasses import dataclass, field
def sig(obj) -> str:
"""稳定签名------dict 必须先排序键,否则同样的参数会算出不同哈希。"""
return hashlib.sha256(
json.dumps(obj, sort_keys=True, ensure_ascii=False,
default=str).encode()
).hexdigest()[:16]
@dataclass
class LoopSignal:
"""一次检测的输出:是否触发、触发原因、置信度。"""
triggered: bool
reason: str = ""
detail: str = ""
rounds_seen: int = 0
class RepeatSignatureDetector:
"""检测手段一:重复签名检测。
原理:对 (tool_name, args) 取签名,统计滑动窗口内同一签名出现次数。
为什么不用"连续重复"而用"窗口内计数":真实循环常常是 A-B-A-B 或
A-B-C-A-B-C 的交替形态,只看连续重复会漏掉它们。
"""
def __init__(self, window: int = 12, max_repeat: int = 3):
self.window = window
self.max_repeat = max_repeat
self.history: deque[str] = deque(maxlen=window)
def observe(self, tool: str, args: dict) -> LoopSignal:
s = sig({"tool": tool, "args": args})
self.history.append(s)
c = Counter(self.history).get(s, 0)
if c >= self.max_repeat:
return LoopSignal(True, "repeat_signature",
f"签名 {s} 在最近 {len(self.history)} 轮内出现 {c} 次",
len(self.history))
return LoopSignal(False)
class StateProgressDetector:
"""检测手段二:状态哈希无进展检测。
这是第 24 篇 stall_count、第 25 篇停滞闸门、第 26 篇无进展检测
的通用形式。它检测的是"状态整体没有变化",与业务语义无关。
"""
def __init__(self, max_stall: int = 3):
self.max_stall = max_stall
self.stall = 0
self.last: str | None = None
def observe(self, state: dict) -> LoopSignal:
h = sig(state)
if h == self.last:
self.stall += 1
else:
self.stall = 0
self.last = h
if self.stall >= self.max_stall:
return LoopSignal(True, "no_progress",
f"状态哈希 {h} 已连续 {self.stall + 1} 轮未变化",
self.stall)
return LoopSignal(False)
class ToolFingerprintDetector:
"""检测手段三:工具调用指纹------看"新颖度"是否塌了。
与重复签名检测的区别:签名检测看"某一个签名重复多少次",
指纹检测看"窗口内不同签名的占比"。后者能抓住"每次参数都略有
不同、但实质在做同一件事"的循环(比如每轮换一个页码翻同一本空书)。
"""
def __init__(self, window: int = 10, min_novelty: float = 0.4):
self.window = window
self.min_novelty = min_novelty
self.history: deque[str] = deque(maxlen=window)
def observe(self, tool: str, args: dict, result: str = "") -> LoopSignal:
self.history.append(sig({"tool": tool, "args": args, "res": result}))
if len(self.history) < self.window:
return LoopSignal(False)
novelty = len(set(self.history)) / len(self.history)
if novelty < self.min_novelty:
return LoopSignal(True, "low_novelty",
f"最近 {self.window} 轮工具指纹新颖度 {novelty:.2f}"
f" < {self.min_novelty}", len(self.history))
return LoopSignal(False)
@dataclass
class BudgetGate:
"""检测手段四:预算硬闸门------唯一能 100% 拦住所有循环的手段。
三重预算缺一不可:轮次防无限循环、Token 防单轮很长的循环、
墙钟防"轮次不多但每轮极慢"的循环。
"""
max_rounds: int = 15
max_tokens: int = 250_000
max_seconds: float = 300.0
rounds: int = 0
tokens: int = 0
started: float = field(default_factory=time.monotonic)
def observe(self, used_tokens: int) -> LoopSignal:
self.rounds += 1
self.tokens += used_tokens
if self.rounds >= self.max_rounds:
return LoopSignal(True, "budget_rounds",
f"轮次 {self.rounds} >= {self.max_rounds}", self.rounds)
if self.tokens >= self.max_tokens:
return LoopSignal(True, "budget_tokens",
f"累计 {self.tokens} >= {self.max_tokens}", self.rounds)
if time.monotonic() - self.started >= self.max_seconds:
return LoopSignal(True, "budget_wallclock",
f"耗时 {time.monotonic() - self.started:.0f}s"
f" >= {self.max_seconds}s", self.rounds)
return LoopSignal(False)
if __name__ == "__main__":
rep = RepeatSignatureDetector(window=12, max_repeat=3)
prog = StateProgressDetector(max_stall=3)
fp = ToolFingerprintDetector(window=10, min_novelty=0.4)
gate = BudgetGate(max_rounds=15, max_tokens=250_000, max_seconds=300.0)
state = {"found": False, "checked": ["A-1001"]} # 状态始终不变
for i in range(1, 16):
signals = [
rep.observe("search_prices", {"sku": "A-1001", "region": "cn"}),
prog.observe(state),
fp.observe("search_prices", {"sku": "A-1001"}, "未找到匹配记录"),
gate.observe(16_718),
]
hit = next((s for s in signals if s.triggered), None)
if hit:
print(f"第 {i} 轮触发: {hit.reason} ------ {hit.detail}")
break
这段代码里最容易被写错的一处是 sig() 里的 sort_keys=True。 JSON 对象的键顺序在没有显式排序时依赖插入顺序,而 dict 的插入顺序在不同代码路径下可能不同------结果就是同样的参数算出不同的哈希,检测彻底失效。这个坑我们踩过一次,表现是"检测器在测试里有效、上线后从不触发"。
4.2 四种检测手段的实测效果
用第 2.3 节的复现方法构造了 200 个循环任务(四类成因各 50 个)+ 200 个正常长任务做对照组,跑出的结果:
| 检测手段 | 检出率 | 平均检出轮次 | 误报率 | 单独使用的漏检区 |
|---|---|---|---|---|
| 重复签名检测 | 61.0% | 5.2 轮 | 0.4% | 参数每次微调的循环 |
| 状态哈希无进展 | 84.0% | 3.4 轮 | 1.1% | 状态每轮都在变的循环 |
| 预算硬闸门 | 100% | 按配置(15 轮) | 0% | 无------但拦得太晚 |
| 工具调用指纹 | 73.0% | 6.8 轮 | 2.3% | 工具调用稀疏的循环 |
| 四者组合(任一触发) | 100% | 3.1 轮 | 2.9% | --- |
"四者组合的检出轮次是 3.1 轮,比任何单一手段都早"------这是组合的意义。 预算闸门虽然 100% 检出,但它要等到第 15 轮;而状态哈希检测在第 3 轮就能拦住 84% 的循环。早一轮,就省下 16,718 Token。
误报率 2.9% 是可以接受的,但必须处理。 我们的处理方式是:① 误报时走软中断 而不是硬中断(让模型有机会完成);② 把误报样本加入回归库;③ 误报率按场景分别统计------"深度研究"类任务的误报率是 6.1%,我们给这类任务单独放宽了阈值。
🎯 我的拍板建议 :不要为了降误报率而收紧阈值。 一次漏检的代价(¥3,140)是一次误报代价(一次软中断,约 ¥80)的 39 倍。阈值应该往"宁可误报"的方向调,然后用软中断把误报的代价降到最低。 这是成本不对称时的标准做法。
4.3 中断策略一:软中断
软中断是本次新增的、也是我最想推荐的一档。 它不取消任务,而是往下一轮的请求里注入一段收尾提示,让模型自己收尾。
python
# soft_interrupt.py ------ 软中断:注入收尾提示,让模型自然结束
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class SoftInterrupt:
"""软中断提示生成器。
三个要素缺一不可:
1. 告知预算状态(模型需要知道自己快没资源了)
2. 明确禁止的动作(不要调新工具)------ 模型默认是"再查查"
3. 强制的结构化产出(已完成 + 未完成)------ 保住输出可用性
"""
used_tokens: int
max_tokens: int
rounds: int
attempted: list[str]
def prompt(self) -> str:
left = max(0, self.max_tokens - self.used_tokens)
tried = "、".join(self.attempted[-8:]) or "(无)"
return f"""【系统提示 · 预算告警】
本次任务已执行 {self.rounds} 轮,预算已使用 {self.used_tokens:,} / {self.max_tokens:,} Token,剩余约 {left:,} Token。
已尝试过的动作:{tried}。
现在请你立即停止调用任何新工具,并按以下 JSON 结构输出:
{{
"status": "partial",
"completed": ["已完成并可信的结论,逐条列出"],
"pending": ["未能完成的核查项,逐条列出"],
"blocked_reason": "未能完成的根本原因(一句话)",
"confidence": 0.0
}}
不要输出 JSON 之外的内容,不要解释,不要道歉。"""
def system_hint(self) -> str:
"""给模型的轻量提示------在软中断前一轮就先打个招呼,效果更好。"""
return (f"注意:你已执行 {self.rounds} 轮,"
f"重复尝试已做过的动作不会带来新信息。")
if __name__ == "__main__":
si = SoftInterrupt(used_tokens=188_000, max_tokens=250_000, rounds=12,
attempted=["search_prices(A-1001)",
"verify_price(A-1001)",
"search_prices(A-1001)"])
print(si.system_hint())
print("---")
print(si.prompt()[:200], "...")
attempted 这个字段是软中断提示里最重要的一项。 2.4 节讲过:模型不是"不知道自己试过",而是"长上下文里试过的记录被稀释了"。把已尝试清单显式列出来,等于给它一份不会稀释的记忆。 我们实测:带上 attempted 之后,软中断后模型能产出有效结论的比例从 34% 提到 81%。
💡 "先打招呼再中断"这个设计(
system_hint)也是实测出来的。 直接软中断时,模型往往会"愣一下"------它会先复述一遍自己的计划再收尾,浪费一轮。在软中断的前一轮 先注入一句轻量提示,收尾质量明显更好。代价是要提前预判,实现上可以在用到 60% 预算时就注入system_hint。
4.4 中断策略二:硬中断与它的计费真相
硬中断必须搞清楚一件事:中断不等于停止计费。
| 请求形态 | 中断方式 | 服务端是否停止生成 | 是否继续计费 |
|---|---|---|---|
| 流式(stream=true) | 客户端断开 / AbortController |
是(在若干 Token 内停止) | 已生成部分照常计费 |
| 非流式(stream=false) | 客户端断开 | 否(服务端跑完) | 全额计费 |
| 后台任务 | responses.cancel(OpenAI Responses API) |
是 | 已生成部分计费 |
这张表里最该记住的是第二行:非流式请求即使你断开连接,服务端也会跑完并全额计费。 OpenAI 官方社区与多家实践文章都确认了这一点------中断只封顶,不退款。Anthropic 的支持文档也写明:客户端在请求中途断开或超时,若该请求本来会成功,仍会计费。
python
# hard_cancel.py ------ 硬中断:取消 asyncio 任务 + 关闭流式连接 + 记录计费边界
from __future__ import annotations
import asyncio
import logging
log = logging.getLogger("loop.guard")
class Cancelled(Exception):
"""硬中断信号。"""
class HardCanceller:
"""硬中断执行器。
三件事必须都做:
1. 取消 asyncio 任务(停止本地计算)
2. 关闭底层 HTTP 流式连接(让服务端停止生成------这是省钱的唯一一步)
3. 记录计费边界(已生成的 Token 不会退款,必须记清楚)
"""
def __init__(self) -> None:
self.task: asyncio.Task | None = None
self.stream = None # 底层响应对象,需实现 aclose()
self.billed_tokens = 0 # 中断时已产生的 Token(照常计费)
def attach(self, task: asyncio.Task, stream=None) -> None:
self.task, self.stream = task, stream
async def cancel(self, reason: str) -> None:
log.error("硬中断触发: %s(已产生 %d Token,照常计费)",
reason, self.billed_tokens)
if self.stream is not None:
# 关键一步:主动关闭流,服务端才会停止生成
try:
aclose = getattr(self.stream, "aclose", None)
await aclose() if aclose else None
except Exception as e: # noqa: BLE001
log.warning("关闭流失败(不影响中断): %s", e)
if self.task is not None and not self.task.done():
self.task.cancel()
try:
await self.task
except (asyncio.CancelledError, Cancelled):
pass
async def guarded_run(coro_factory, canceller: HardCanceller,
timeout: float = 300.0) -> str:
"""带硬中断的运行包装------演示完整的中断与清理流程。"""
async def _body() -> str:
await asyncio.sleep(999) # 模拟一个跑不完的任务
return "done"
task = asyncio.create_task(_body())
canceller.attach(task)
try:
return await asyncio.wait_for(asyncio.shield(task), timeout)
except asyncio.TimeoutError:
await canceller.cancel("墙钟预算耗尽")
return "【已中断】"
if __name__ == "__main__":
c = HardCanceller()
print(asyncio.run(guarded_run(None, c, timeout=0.5)))
aclose() 那一行是整个硬中断里唯一能省钱的动作。 只 task.cancel() 而不关流,等于"本地不看了、服务端继续烧"------这正是第 45 篇 Q5 提到的记账与账单对不上的原因之一。
⚠️ 一个必须提示的平台差异 :OpenAI 的 Responses API 只对后台任务(background responses)提供
responses.cancel------同步的流式响应没有服务端取消端点,只能断开 socket 。Anthropic 的 SSE 同样没有服务端取消原语。所以**"可靠的中断"在多数平台上实际是"尽力而为的中断":它会停止生成,但已生成的 Token 一定会计费**。设计预算时必须按"中断不退款"来算。
4.5 中断之后:输出还可用吗
这是前三篇都没讲、而工程上最容易翻车的一半。 中断时你手上有一堆半成品:写到一半的 JSON、检索到一半的证据、生成到一半的报告。直接抛给用户就是事故。
三档兜底,按可用性从高到低:
| 档位 | 触发条件 | 输出形态 | 用户可见标记 |
|---|---|---|---|
| A 档:结构化部分结果 | 软中断成功 | 完整 JSON,含 completed / pending |
status: "partial" |
| B 档:Checkpoint 回放 | 硬中断,但有 Checkpoint | 最近一次 Checkpoint 的完整状态 | status: "from_checkpoint" + 时间戳 |
| C 档:证据清单 + 明确失败 | 硬中断且无 Checkpoint | 已收集的原始证据列表 | status: "interrupted" |
python
# partial_output.py ------ 中断后输出兜底:绝不给用户半截 JSON
from __future__ import annotations
import json
import re
FENCE = chr(96) * 3 # 三个反引号:模型常用的代码块围栏
def salvage_json(raw: str) -> dict | None:
"""尝试从被截断的输出里抢救出结构化数据。
只接受"闭合的顶层对象",不接受"修补后看起来完整"的对象------
后者比直接失败更危险,因为用户会以为数据是完整的。
"""
raw = raw.strip()
if raw.startswith(FENCE):
raw = raw.strip(chr(96)).removeprefix("json").strip()
depth, in_str, esc, end = 0, False, False, -1
for i, ch in enumerate(raw):
if esc:
esc = False
continue
if ch == "\\":
esc = True
elif ch == '"':
in_str = not in_str
elif not in_str:
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i + 1
break
if end == -1:
return None
try:
obj = json.loads(raw[:end])
except json.JSONDecodeError:
return None
obj["__truncated__"] = end < len(raw)
return obj
def build_partial(evidences: list[str], completed: list[str],
pending: list[str], reason: str) -> dict:
"""构造 A 档输出------结构化部分结果。
关键:pending 字段不能为空也不能省略。一个只说"完成了什么"
而不说"没完成什么"的部分结果,会让用户误判置信度。
"""
return {
"status": "partial",
"completed": completed,
"pending": pending or ["(未能展开的核查项,请人工补充)"],
"evidence": evidences,
"blocked_reason": reason,
"confidence": round(len(completed) / max(len(completed) + len(pending), 1), 2),
}
if __name__ == "__main__":
half = '{"completed": ["A-1001 无价格记录"], "pending": ["A-1002'
ok = '{"completed": ["A-1001 无价格记录"], "pending": []} 这里之后是多余的解释文字'
print("截断输出抢救结果:", salvage_json(half)) # None:必须走 C 档
print("完整输出抢救结果:", salvage_json(ok)) # 带 __truncated__ 标记
print("A 档输出:", build_partial(
evidences=["search_prices(A-1001) → 空"],
completed=["A-1001 无价格记录"], pending=["A-1002", "A-1003"],
reason="数据源连续返回空结果,触发循环保护"))
salvage_json 里那个 __truncated__ 字段是故意加的。 抢救出来的 JSON 必须自带"我是残缺的"标记------否则下游会把它当完整数据处理,那就是第 38 篇讲的"静默降级",比直接失败更危险。

图2:中断后输出的三档兜底------绝不给用户半截 JSON
4.6 把四者串起来:LoopGuard
python
# loop_guard.py ------ 通用循环守卫:检测 → 判定 → 中断 → 兜底
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
@dataclass
class GuardConfig:
"""守卫配置------四个检测器各自的阈值。"""
window: int = 12
max_repeat: int = 3
max_stall: int = 3
min_novelty: float = 0.4
max_rounds: int = 15
max_tokens: int = 250_000
max_seconds: float = 300.0
soft_ratio: float = 0.60 # 60% 注入轻量提示,75% 触发软中断
class LoopGuard:
"""通用循环守卫。
这一步的价值:第 24、25、26 篇各写了一遍的循环控制,
在这里合并成一个组件。上层只需要在每轮结束后调用 observe()。
"""
def __init__(self, cfg: GuardConfig | None = None):
self.cfg = cfg or GuardConfig()
from loop_detector import (BudgetGate, RepeatSignatureDetector,
StateProgressDetector,
ToolFingerprintDetector)
self.rep = RepeatSignatureDetector(self.cfg.window, self.cfg.max_repeat)
self.prog = StateProgressDetector(self.cfg.max_stall)
self.fp = ToolFingerprintDetector(self.cfg.window, self.cfg.min_novelty)
self.gate = BudgetGate(self.cfg.max_rounds, self.cfg.max_tokens,
self.cfg.max_seconds)
self.attempted: list[str] = []
self.soft_fired = False
def observe(self, *, tool: str, args: dict, result: str,
state: dict, used_tokens: int) -> str:
"""每轮调用一次,返回 "pass" | "hint" | "soft" | "hard"。"""
self.attempted.append(f"{tool}({sorted(args.items())})")
signals = [
self.rep.observe(tool, args),
self.prog.observe(state),
self.fp.observe(tool, args, result),
self.gate.observe(used_tokens),
]
hit = next((s for s in signals if s.triggered), None)
ratio = self.gate.tokens / max(self.cfg.max_tokens, 1)
if hit is not None or ratio >= 0.75:
return "soft" if not self.soft_fired else "hard"
if ratio >= self.cfg.soft_ratio:
return "hint"
return "pass"
def mark_soft_fired(self) -> None:
"""软中断已用掉一次------同一任务只给一次机会,第二次必须硬中断。"""
self.soft_fired = True
async def run_with_guard(guard: LoopGuard, step, max_steps: int = 50) -> dict:
"""带守卫的执行循环------完整演示四种动作的衔接。"""
for _ in range(max_steps):
tool, args, result, state, used = await step()
action = guard.observe(tool=tool, args=args, result=result,
state=state, used_tokens=used)
if action == "pass":
continue
if action == "hint":
# 轻量提示:只是提醒,不改变流程(必须能被下游透传给模型)
await step(hint=guard.cfg.soft_ratio)
continue
if action == "soft":
guard.mark_soft_fired()
out = await step(soft_prompt=True)
return {"status": "partial", "payload": out}
return {"status": "interrupted", "payload": None}
return {"status": "completed", "payload": None}
if __name__ == "__main__":
async def demo(hint=None, soft_prompt=False):
return ("search_prices", {"sku": "A-1001"}, "未找到记录",
{"found": False}, 16_718)
g = LoopGuard(GuardConfig())
print(asyncio.run(run_with_guard(g, demo)))
soft_fired 这个标记很重要 :软中断一个任务只给一次机会。 给了机会还继续循环,第二次必须硬中断------否则"软中断"会变成"每 N 轮提醒一次"的新循环。
🎯 我的拍板建议 :
soft_ratio设 0.60 用于提示、0.75 用于软中断,中间留 15% 的缓冲。 一次到位(0.75 才第一次干预)会让模型在毫无预警的情况下被要求收尾,收尾质量差;两档提示的实测收尾质量比一档高 27 个百分点。
软中断与硬中断不是二选一,而是两级。 下面这张对比卡是我们在事故复盘会上定的口径,它现在也是值班手册的一页:

图3:软中断与硬中断的两级对比------软中断保输出,硬中断保预算
为什么"软中断每任务仅限一次"这条要写进手册? 因为我们的第一版实现没有这个限制,结果出现了一种新故障:一个任务在第 5、10、15 轮各被软中断一次,每次都"收了一下尾"又继续跑 ------软中断本身变成了一个新的循环节奏。加上 soft_fired 标记之后这个问题消失。 这提醒我们一件更普遍的事:任何"干预手段"本身都可能被卷进循环,所以干预也必须有次数上限。
4.7 上线前后对比
| 指标 | 事故前 | 修复后 | 变化 |
|---|---|---|---|
| 单任务最大轮次 | 无上限 | 15 | 有界 |
| 单任务最大 Token | 无上限 | 250,000 | 有界 |
| 循环任务平均终止轮次 | 112(人工) | 4.7 | -95.8% |
| 单任务最坏成本 | ¥3,140 | ¥419 | -86.7% |
| 循环导致的日异常成本 | ¥35,900 | ¥0(连续 45 天) | -100% |
| 正常任务误杀率 | --- | 0.6% | 可接受 |
| 循环检测回归用例数 | 0 | 46 | --- |
"循环任务平均终止轮次 4.7"这个数字说明检测真的有效 ------它不是靠预算闸门在第 15 轮兜住的,而是在第 4.7 轮就被信号检测抓到了。预算闸门在整个观察期里只触发了 3 次,说明它是真正的兜底而不是主力。
46 个回归用例是这次修复里我最看重的产出。 数值会变、阈值会调,但用例库会一直守着------第 35 篇强调的"把 bug 变成测试"在这里完全适用。
五、进阶防护
5.1 监控:循环的三个必看指标
#mermaid-svg-hN3FGUjKDmJv4Wfq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hN3FGUjKDmJv4Wfq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hN3FGUjKDmJv4Wfq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hN3FGUjKDmJv4Wfq .error-icon{fill:#552222;}#mermaid-svg-hN3FGUjKDmJv4Wfq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hN3FGUjKDmJv4Wfq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hN3FGUjKDmJv4Wfq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hN3FGUjKDmJv4Wfq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hN3FGUjKDmJv4Wfq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hN3FGUjKDmJv4Wfq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hN3FGUjKDmJv4Wfq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hN3FGUjKDmJv4Wfq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hN3FGUjKDmJv4Wfq .marker.cross{stroke:#333333;}#mermaid-svg-hN3FGUjKDmJv4Wfq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hN3FGUjKDmJv4Wfq p{margin:0;}#mermaid-svg-hN3FGUjKDmJv4Wfq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hN3FGUjKDmJv4Wfq .cluster-label text{fill:#333;}#mermaid-svg-hN3FGUjKDmJv4Wfq .cluster-label span{color:#333;}#mermaid-svg-hN3FGUjKDmJv4Wfq .cluster-label span p{background-color:transparent;}#mermaid-svg-hN3FGUjKDmJv4Wfq .label text,#mermaid-svg-hN3FGUjKDmJv4Wfq span{fill:#333;color:#333;}#mermaid-svg-hN3FGUjKDmJv4Wfq .node rect,#mermaid-svg-hN3FGUjKDmJv4Wfq .node circle,#mermaid-svg-hN3FGUjKDmJv4Wfq .node ellipse,#mermaid-svg-hN3FGUjKDmJv4Wfq .node polygon,#mermaid-svg-hN3FGUjKDmJv4Wfq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hN3FGUjKDmJv4Wfq .rough-node .label text,#mermaid-svg-hN3FGUjKDmJv4Wfq .node .label text,#mermaid-svg-hN3FGUjKDmJv4Wfq .image-shape .label,#mermaid-svg-hN3FGUjKDmJv4Wfq .icon-shape .label{text-anchor:middle;}#mermaid-svg-hN3FGUjKDmJv4Wfq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hN3FGUjKDmJv4Wfq .rough-node .label,#mermaid-svg-hN3FGUjKDmJv4Wfq .node .label,#mermaid-svg-hN3FGUjKDmJv4Wfq .image-shape .label,#mermaid-svg-hN3FGUjKDmJv4Wfq .icon-shape .label{text-align:center;}#mermaid-svg-hN3FGUjKDmJv4Wfq .node.clickable{cursor:pointer;}#mermaid-svg-hN3FGUjKDmJv4Wfq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hN3FGUjKDmJv4Wfq .arrowheadPath{fill:#333333;}#mermaid-svg-hN3FGUjKDmJv4Wfq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hN3FGUjKDmJv4Wfq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hN3FGUjKDmJv4Wfq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hN3FGUjKDmJv4Wfq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hN3FGUjKDmJv4Wfq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hN3FGUjKDmJv4Wfq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hN3FGUjKDmJv4Wfq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hN3FGUjKDmJv4Wfq .cluster text{fill:#333;}#mermaid-svg-hN3FGUjKDmJv4Wfq .cluster span{color:#333;}#mermaid-svg-hN3FGUjKDmJv4Wfq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hN3FGUjKDmJv4Wfq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hN3FGUjKDmJv4Wfq rect.text{fill:none;stroke-width:0;}#mermaid-svg-hN3FGUjKDmJv4Wfq .icon-shape,#mermaid-svg-hN3FGUjKDmJv4Wfq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hN3FGUjKDmJv4Wfq .icon-shape p,#mermaid-svg-hN3FGUjKDmJv4Wfq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hN3FGUjKDmJv4Wfq .icon-shape .label rect,#mermaid-svg-hN3FGUjKDmJv4Wfq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hN3FGUjKDmJv4Wfq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hN3FGUjKDmJv4Wfq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hN3FGUjKDmJv4Wfq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
是
是
否
否
否
否
是
是
否
每轮结束
采集三个 per-trace 指标
轮次计数
累计 Token
状态哈希变化次数
轮次 > P99 基线 × 3?
累计 Token > 预算 60%?
连续 3 轮无变化?
软中断
注入轻量提示
继续
软中断已用过?
等待收尾一轮
硬中断 + 兜底输出
收尾完成?
A 档输出
| 指标 | 聚合维度 | 告警阈值 | 为什么是它 |
|---|---|---|---|
| 单 trace 轮次数 | per-trace | > 基线 P99 × 3 | 循环的直接证据 |
| 单 trace 累计 Token | per-trace | > 预算 60% | 成本侧的提前量 |
| 活跃上下文 Token 导数 | per-trace | 连续 5 轮上升 | 比轮次更早触发 |
"活跃上下文 Token 的导数"这一项是 2.1 节提到的外部特征,它是最早的信号------因为上下文膨胀从第 1 轮就开始了,而轮次告警要等到超过基线才响。
但要提醒一句:这三个指标都只在"事后/事中"起作用,它们告诉你"有一个任务正在循环"。 真正的防护要往前再推一层------把循环控制拆成五层防线,每一层的失败都有下一层兜住:

图4:循环控制的五层防线------每一层的失败都由下一层兜住
这五层里,多数团队只做了第四层(预算闸门),而最常被省略的恰恰是第三层和第五层。 第三层(软中断)决定"中断时用户还能不能拿到东西",第五层(复盘用例库)决定"下一次还会不会再犯"。本次事故后我们补齐的五层里,投入产出比最高的是第五层------46 个用例是一次性投入,但它们在之后每次重构循环逻辑时都救了我们。
5.2 门禁:把循环预算写进配置校验
第 26 篇把"反向边未声明 max_traversals"定为编译期 error,这是对的思路。 我们把它推广成一条通用规则:任何运行期循环,声明时必须有预算。
python
# loop_lint.py ------ 循环预算静态检查(放回 CI,无需运行环境)
from __future__ import annotations
import ast
import sys
class LoopLinter(ast.NodeVisitor):
"""检查所有 while 循环是否带有终止保护。
为什么做静态检查:运行时的预算闸门是兜底,
而静态检查能在代码合入前就发现"这个循环没有任何上限"。
第 26 篇把"反向边未声明上限"定为 error,这里是同一思路的推广。
"""
def __init__(self) -> None:
self.violations: list[str] = []
def visit_While(self, node: ast.While) -> None:
src = ast.dump(node)
guarded = any(k in src for k in ("max_rounds", "rounds", "budget",
"deadline", "wait_for", "timeout"))
has_break = any(isinstance(n, ast.Break) for n in ast.walk(node))
if not guarded and not has_break:
self.violations.append(
f"第 {node.lineno} 行的 while 循环未见轮次上限、预算或 break")
self.generic_visit(node)
def lint(path: str) -> int:
tree = ast.parse(open(path, encoding="utf-8").read())
linter = LoopLinter()
linter.visit(tree)
for v in linter.violations:
print(f"❌ {path}: {v}")
if not linter.violations:
print(f"✅ {path}: 循环预算检查通过")
return 1 if linter.violations else 0
if __name__ == "__main__":
sys.exit(lint(sys.argv[1]) if len(sys.argv) > 1 else lint(__file__))
这条 lint 在我们的 CI 里跑,第一个月报出了 7 处"裸 while"。 其中 5 处确实是有界的(靠外部数据终止),我们给它们加了显式的 break 或注释;另外 2 处是真的可能死循环------一处依赖外部系统返回终态,而那个系统在超时时会返回空字符串。
⚠️ 这个 lint 会有误报 (比如靠外部数据终止的循环)。它的价值不在于零误报,而在于强迫每个写循环的人回答一个问题:"这个循环的终止保障是什么?" 回答出来了就加注释或
break,回答不出来就说明有风险。静态检查的很大一部分价值是提问,不是判定。
5.3 循环用例回归库
46 个用例的组织方式,按四类成因分组,每组覆盖"能检出"与"不误杀"两侧:
python
# tests/test_loop_guard.py ------ 循环检测回归:检出与不误杀,两侧都要测
from __future__ import annotations
import pytest
from loop_detector import (BudgetGate, RepeatSignatureDetector,
StateProgressDetector, ToolFingerprintDetector)
def test_repeat_signature_catches_alternating_loop():
"""A-B-A-B 交替型循环必须被抓到------只看连续重复会漏。"""
d = RepeatSignatureDetector(window=12, max_repeat=3)
hit = False
for i in range(12):
s = d.observe("search", {"page": i % 2}) # 两个签名交替
hit = hit or s.triggered
assert hit, "交替型循环未被检出"
def test_no_progress_detector_catches_frozen_state():
"""状态冻结型循环:第 4 轮就该触发(max_stall=3)。"""
d = StateProgressDetector(max_stall=3)
frozen = {"found": False, "checked": ["A"]}
fired_at = None
for i in range(1, 8):
if d.observe(frozen).triggered:
fired_at = i
break
assert fired_at == 4, f"应第 4 轮触发,实际第 {fired_at} 轮"
def test_progress_detector_does_not_kill_healthy_task():
"""反侧测试:状态持续变化的正常任务不得被误杀。"""
d = StateProgressDetector(max_stall=3)
for i in range(20):
assert not d.observe({"step": i}).triggered, f"第 {i} 轮误杀"
def test_budget_gate_is_the_last_line():
"""预算闸门必须 100% 兜住------其他三种都漏时它仍要触发。"""
g = BudgetGate(max_rounds=15, max_tokens=250_000, max_seconds=300.0)
fired = False
for i in range(20):
# 每轮状态都不同、签名都不同:前三种检测全部失效
if g.observe(16_718).triggered:
fired = True
break
assert fired, "预算闸门未能兜住"
def test_fingerprint_detector_catches_paging_loop():
"""翻页型循环:每页参数不同,但语义原地打转。"""
d = ToolFingerprintDetector(window=10, min_novelty=0.4)
hit = False
for i in range(10):
s = d.observe("search", {"page": i}, result="空")
hit = hit or s.triggered
# 参数每次不同 → 新颖度高 → 本例不应触发(记录已知边界)
assert isinstance(hit, bool)
test_progress_detector_does_not_kill_healthy_task 这类反侧测试必须有。 只测"能检出"不测"不误杀",阈值会越调越激进,最后正常任务也被中断------这是循环检测最常见的退化方式。
5.4 循环控制的运行时状态机
#mermaid-svg-RdzmSMcy78HVYaoV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RdzmSMcy78HVYaoV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RdzmSMcy78HVYaoV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RdzmSMcy78HVYaoV .error-icon{fill:#552222;}#mermaid-svg-RdzmSMcy78HVYaoV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RdzmSMcy78HVYaoV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RdzmSMcy78HVYaoV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RdzmSMcy78HVYaoV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RdzmSMcy78HVYaoV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RdzmSMcy78HVYaoV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RdzmSMcy78HVYaoV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RdzmSMcy78HVYaoV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RdzmSMcy78HVYaoV .marker.cross{stroke:#333333;}#mermaid-svg-RdzmSMcy78HVYaoV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RdzmSMcy78HVYaoV p{margin:0;}#mermaid-svg-RdzmSMcy78HVYaoV defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-RdzmSMcy78HVYaoV g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-RdzmSMcy78HVYaoV g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-RdzmSMcy78HVYaoV g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-RdzmSMcy78HVYaoV g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-RdzmSMcy78HVYaoV g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-RdzmSMcy78HVYaoV .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-RdzmSMcy78HVYaoV .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-RdzmSMcy78HVYaoV .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-RdzmSMcy78HVYaoV .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-RdzmSMcy78HVYaoV .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-RdzmSMcy78HVYaoV .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-RdzmSMcy78HVYaoV .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-RdzmSMcy78HVYaoV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RdzmSMcy78HVYaoV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RdzmSMcy78HVYaoV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RdzmSMcy78HVYaoV .edgeLabel .label text{fill:#333;}#mermaid-svg-RdzmSMcy78HVYaoV .label div .edgeLabel{color:#333;}#mermaid-svg-RdzmSMcy78HVYaoV .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-RdzmSMcy78HVYaoV .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-RdzmSMcy78HVYaoV .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-RdzmSMcy78HVYaoV .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-RdzmSMcy78HVYaoV .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-RdzmSMcy78HVYaoV .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RdzmSMcy78HVYaoV .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RdzmSMcy78HVYaoV #statediagram-barbEnd{fill:#333333;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RdzmSMcy78HVYaoV .cluster-label,#mermaid-svg-RdzmSMcy78HVYaoV .nodeLabel{color:#131300;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-RdzmSMcy78HVYaoV .note-edge{stroke-dasharray:5;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-note text{fill:black;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram-note .nodeLabel{color:black;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagram .edgeLabel{color:red;}#mermaid-svg-RdzmSMcy78HVYaoV #dependencyStart,#mermaid-svg-RdzmSMcy78HVYaoV #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-RdzmSMcy78HVYaoV .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RdzmSMcy78HVYaoV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} pass(正常推进)
预算 60% 或首次可疑信号
模型继续推进
预算 75% 或确认信号
确认的循环信号
注入收尾提示
收尾成功(A 档)
收尾轮再次触发信号
预算 100% 或软中断已用过
取消任务 + 关闭流
有检查点
无检查点
RUNNING
HINTED
SOFT
FINALIZING
PARTIAL
HARD
SALVAGE
CHECKPOINT
INTERRUPTED
这一态常被省略
省略后模型会
在毫无预警下被要求收尾
取消任务时必须
同时关闭底层流
否则服务端继续生成
六、适用边界与风险提示 ⚠️
6.1 什么时候不需要这么复杂
| 场景 | 需要什么 |
|---|---|
| 一次调用出结果 | 一个总超时 |
| 固定 N 步流水线(第 22 篇) | 总超时 + 每步超时 |
| 天然有界的 MapReduce(第 23 篇) | 分片 + 每片超时 |
| Supervisor / 黑板 / 带反向边的图 / ReAct 循环 | 本文全套 |
判断标准只有一条:循环次数是否由运行时的模型输出决定。 是 → 需要全套;否 → 不需要。这与第 26 篇 1.3 节的判断完全一致("业务循环的终止条件来自外部数据,Agent 循环的终止条件来自一个概率模型")。
6.2 六个风险
⚠️ 风险一:中断不退款。 已生成的 Token 一定计费,非流式请求即使断开也会跑完。预算必须按"最坏情况全额计费"来设。
⚠️ 风险二:硬中断会把工具留在半截状态。 在一个"下单"或"写文件"的工具执行到一半时中断,可能造成"扣款已发生但订单未记录"。第 39 篇的幂等与补偿是硬中断的前置条件------没有幂等键的副作用工具,中断时无法安全清理。
⚠️ 风险三:误杀正常长任务。 我们的误杀率 0.6%,但**"深度研究"类任务是 6.1%**。这类任务必须单独放宽阈值,或改用更宽松的判据(只看墙钟预算)。
⚠️ 风险四:状态哈希可能"变化但不推进"。 比如每轮往状态里追加一个时间戳------哈希每轮都变,但语义原地打转。这类只能靠重复签名检测或工具指纹抓。
⚠️ 风险五:asyncio.Task.cancel() 不保证立即生效。 如果协程在 finally 里做了 await,取消可能被延迟甚至吞掉。中断后必须显式检查任务是否真的结束 (task.done()),并给一个兜底的超时。
⚠️ 风险六:多 Agent 互相循环。 两个 Agent 互相确认同一件事(A 说"需要 B 确认",B 说"需要 A 确认")会构成跨 Agent 的循环 ,单 Agent 的检测器看不见它。解法是把 correlation 链(第 27 篇)作为检测维度------如果一条调用链里出现同一对 Agent 交替超过 N 次,判定为跨 Agent 循环。
6.3 与相邻篇的分工
| 问题 | 归哪篇 |
|---|---|
| 循环的成本归因与优化 | 第 45 篇 |
| 超时与幂等(中断后的状态一致性) | 第 39 篇 |
| 降级输出的呈现与用户可见性 | 第 38 篇 |
| 引擎层的循环五约束 | 第 26 篇 |
| Supervisor / 黑板各自的终止判据 | 第 24 / 25 篇 |
| 通用的检测算法与中断机制 | 本篇 |
七、一个可复制的最小骨架
约 60 行。只保留最不能省的两件事:状态哈希无进展检测 + 预算闸门 + 软硬两档中断。
python
# minimal_loop_guard.py ------ 循环守卫最小骨架,约 60 行
from __future__ import annotations
import asyncio
import hashlib
import json
import time
def sig(obj) -> str:
return hashlib.sha256(
json.dumps(obj, sort_keys=True, default=str).encode()
).hexdigest()[:12]
class LoopGuard:
"""最小循环守卫:三重预算 + 无进展检测 + 软硬两档中断。
省略了:重复签名检测、工具指纹、Checkpoint 回放。
这三样在有真实漏检后再加------第 33 篇的原则。
"""
def __init__(self, max_rounds=15, max_tokens=250_000,
max_seconds=300.0, max_stall=3):
self.max_rounds, self.max_tokens = max_rounds, max_tokens
self.max_seconds, self.max_stall = max_seconds, max_stall
self.rounds = self.tokens = self.stall = 0
self.started = time.monotonic()
self.last_hash = None
self.soft_used = False
def check(self, state: dict, used_tokens: int) -> str:
"""每轮调用,返回 pass / soft / hard。"""
self.rounds += 1
self.tokens += used_tokens
h = sig(state)
self.stall = self.stall + 1 if h == self.last_hash else 0
self.last_hash = h
if (self.rounds >= self.max_rounds
or self.tokens >= self.max_tokens
or time.monotonic() - self.started >= self.max_seconds):
return "hard"
if self.stall >= self.max_stall:
return "hard" if self.soft_used else "soft"
if self.tokens >= self.max_tokens * 0.75:
return "hard" if self.soft_used else "soft"
return "pass"
def soft_prompt(self) -> str:
return (f"【预算告警】已执行 {self.rounds} 轮 / {self.tokens:,} Token。"
f"请立即停止调用新工具,按 completed / pending / "
f"blocked_reason 三段输出 JSON,不要输出其他内容。")
async def run(guard: LoopGuard, step, max_steps=50) -> dict:
for _ in range(max_steps):
state, used, text = await step()
action = guard.check(state, used)
if action == "pass":
continue
if action == "soft":
guard.soft_used = True
_, _, text = await step(extra=guard.soft_prompt())
return {"status": "partial", "text": text}
return {"status": "interrupted", "text": text}
return {"status": "completed", "text": ""}
if __name__ == "__main__":
async def stub(extra=None):
return {"found": False}, 16_718, "未找到记录"
g = LoopGuard()
print(asyncio.run(run(g, stub)))
这个骨架的默认值来自本次事故的实测 :max_stall=3(第 4 轮就能抓住状态冻结)、预算 75% 触发软中断、soft_used 保证软中断只用一次。这三个值可以直接抄,但 max_rounds 和 max_tokens 必须按你的任务基线重算------我们的是 15 轮 / 25 万 Token,来自"正常任务 P99 是 12 轮 / 19 万 Token"再加 25% 余量。
八、总结
核心只有一句:循环控制不是"加个 max_rounds",而是"检测 → 判定 → 中断 → 兜底 → 复盘"五件事;只做第一件的团队,会在中断时把用户的任务变成一堆废话。
六个最该记住的结论:
第一,循环事故的监控必须是 per-trace 的,不能是时间窗口的。 本次 23 个循环任务混在 4 万个正常请求里,任何窗口级指标都看不出来 。只有"单 trace 轮次数"和"单 trace 累计 Token"能把它揪出来。而最早的外部特征是"活跃上下文 Token 持续上涨"------它比轮次告警更早触发。
第二,四类成因各有明确的漏检区,所以四种检测手段必须全部部署。 状态哈希检测抓不到"状态每轮都在变"的循环(本次占 13%),重复签名检测抓不到"参数每次微调"的循环。只有预算闸门是 100% 的,但它拦得太晚(第 15 轮 vs 平均 3.1 轮)。
第三,软中断是本篇最推荐的一档,而它的关键在于"已尝试清单"。 模型不是不知道自己试过,是长上下文把记忆稀释了 。把 attempted 显式列进给模型的提示,收尾有效率从 34% 提到 81%。软中断一个任务只给一次机会,第二次必须硬中断。
第四,中断不退款------这是设计预算时必须按的最坏假设。 流式请求断开能停止生成但已生成部分照常计费;非流式请求即使断开,服务端也会跑完并全额计费 。硬中断里唯一省钱的动作是主动关闭底层流 (aclose())。
第五,中断之后的输出可用性,是前三篇都缺、工程上最容易翻车的一半。 三档兜底:A 档结构化部分结果(含 pending)、B 档 Checkpoint 回放、C 档证据清单加明确失败。绝对禁止把写到一半的 JSON 抛给用户;抢救出来的 JSON 必须自带残缺标记。
第六,本篇是把第 24、25、26 篇的循环控制收拢,不是推翻它们。 三篇的共同正确点是终止权归代码 ------本篇继承;三篇的共同缺口是检测算法、中断后输出、计费语义 ------本篇补上。24 篇的 stall_count、25 篇的停滞闸门、26 篇的无进展检测,都是本文"状态哈希无进展检测"的业务特例。
最后一点 :46 个回归用例比任何阈值调整都值钱。 数值会过时、模型会更换、Prompt 会重写,但用例库会一直守着那个凌晨 4 点 37 分的教训。
落地自检清单(Checklist)
- ✅ 每个运行期循环都有三重预算(轮次 / Token / 墙钟)
- ✅ 有状态哈希无进展检测 (
max_stall,默认 3) - ✅ 有重复签名检测(窗口内计数,能抓 A-B-A-B 交替型)
- ✅ 有工具调用指纹新颖度检测(抓翻页型循环)
- ✅ 四种检测任一触发即判定,不是全部触发
- ✅ 中断有软硬两档 ,软中断每任务只用一次
- ✅ 软中断提示包含已尝试清单 (
attempted) - ✅ 预算 60% 注入轻量提示 、75% 触发软中断(两档)
- ✅ 硬中断时主动关闭底层流 (
aclose),不只是task.cancel() - ✅ 预算按"中断不退款"设定
- ✅ 中断输出走三档兜底,绝不给半截 JSON
- ✅ 抢救的 JSON 自带
__truncated__标记 - ✅
pending(未完成项)字段不得为空 - ✅ 副作用工具在中断前有幂等键(第 39 篇)
- ✅ 监控 per-trace 轮次数 / 累计 Token / 上下文导数
- ✅ CI 有循环预算静态检查 (裸
while报错) - ✅ 有循环用例回归库 ,且检出与不误杀两侧都测
- ✅ 多 Agent 场景检测跨 Agent 交替(correlation 链维度)
常见问题(FAQ)
Q1:max_rounds 设多少?
按你的任务基线 P99 加 25% 余量,不要用别人的数。 我们是"正常任务 P99 为 12 轮"→ 设 15。更好的做法是看分布而不是均值 :如果 P99 是 12 而 P99.9 是 28,说明存在一小批合法的长任务,这时应该给它们单独的通道(打 task_type 标记)而不是抬高全局上限。
Q2:状态哈希检测会不会误杀"状态确实该不变"的任务?
会。 比如一个"轮询等待外部任务完成"的循环,状态本来就该连续多轮不变。解法是给这类循环打 allow_stall=True 标记,只靠墙钟预算兜底。 我们的误杀率 0.6% 里有 0.4pt 来自这一类,加了标记后降到 0.2%。
Q3:软中断后模型不配合怎么办?
这是常态,不是异常 。实测软中断后仍有 19% 的情况模型会继续调工具。处理方式很直接:软中断只给一轮机会,那一轮结束后如果还在调工具,直接硬中断。 不要试图"再劝一次"------那会变成新的循环。
Q4:非流式请求能不能中断?
不能可靠地中断。 断开客户端连接不会让服务端停止生成,全额计费。所以所有可能长时间运行的 Agent 循环都应该用流式 (即使你不逐字展示给用户,也可以在服务端缓冲后整体返回)。流式是唯一的财务断路器。
Q5:多 Agent 互相循环怎么检测?
单 Agent 的检测器看不见它。 需要把调用链(第 27 篇的 correlation_id)作为检测维度:如果一条链里同一对 Agent 交替超过 N 次(我们设 4 次),判定为跨 Agent 循环并中断整条链。另外,让每个 Agent 的提示里带上"这条链已经发生过什么"的紧凑摘要,能从源头减少互相踢皮球。
Q6:检测阈值怎么调?
往"宁可误报"的方向调,然后用软中断把误报代价降到最低。 一次漏检 ¥3,140,一次误报(软中断)约 ¥80,成本不对称 39 倍 。等误报率明显影响体验了再往回收------这个方向不能反。
Q7:循环检测的测试和普通测试有什么不同?
必须测两侧 :"能检出"(正例)和"不误杀"(反例)。只测正例,阈值会越调越激进 ,最后正常任务也被中断------这是循环检测最常见的退化方式。另外用虚拟时钟,否则一个 112 轮的用例要跑 4 小时。
Q8:为什么不用 LangGraph 的 recursion_limit?
要用,但它不够。 LangGraph 的 recursion_limit 默认是 25 步,超限抛 GraphRecursionError------它等价于本文的"轮次预算",是四种手段里最弱、最晚触发的那一种 。而且它抛异常后你还是要自己处理"输出怎么办"。正确用法:设 recursion_limit 作兜底,同时用本文的检测器做早期发现。
参考资料
- LangGraph 官方文档 --- Graph API concepts (
recursion_limit默认 25 步与GraphRecursionError):https://github.com/langchain-ai/langgraph/blob/main/docs/docs/concepts/low_level.md - Anthropic 官方文档 --- Handling stop reasons (
max_tokens/pause_turn/model_context_window_exceeded的处理):https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons - Anthropic 支持中心 --- API 计费说明(客户端中途断开的请求仍会计费):https://support.anthropic.com/en/articles/8977456-how-is-usage-for-the-claude-api-calculated
- OpenAI 开发者社区 --- 停止生成的语义(流式可中断、非流式会跑完):https://community.openai.com/t/chatgpts-stop-generating-function-how-to-implement/235121
- Python 官方文档 ---
asyncio任务取消与CancelledError语义:https://docs.python.org/3/library/asyncio-task.html - Python 官方文档 ---
ast模块(静态检查循环结构的基础):https://docs.python.org/3/library/ast.html - Google SRE Book --- Addressing Cascading Failures(重试与循环放大负载的系统性后果):https://sre.google/sre-book/addressing-cascading-failures/
- Nygard, M. T. --- Release It!(舱壁与超时/中断的工程模式):https://pragprog.com/titles/mnee2/release-it-second-edition/
- 《Site Reliability Engineering》第 22 章 --- 处理级联故障:https://sre.google/sre-book/addressing-cascading-failures/