AI多Agent协作系统实战(十六):当AI Agent学会了“无限重试“——RETEST循环嵌套的血泪修复史
系列第16篇 | 从15层任务ID嵌套到最大重试3次,中间踩了哪些坑
引言
凌晨00:48,我收到一条飞书通知:
❌ 复核不通过: TEST-DEV-IOT-1.1-DATABASE
🔄 已重派: RETEST-TEST-DEV-IOT-1.1-DATABASE
30秒后又来一条:
❌ 复核不通过: RETEST-TEST-DEV-IOT-1.1-DATABASE
🔄 已重派: RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE
再30秒:
❌ 复核不通过: RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE
🔄 已重派: RETEST-RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE
我盯着屏幕,看着任务ID像俄罗斯套娃一样一层层叠加。15分钟后,统筹报告变成了这样:
| 9 | RETEST-RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE |
| 10 | RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE |
| 11 | RETEST-TEST-DEV-IOT-1.1-DATABASE |
| 12 | TEST-DEV-IOT-1.1-DATABASE |
4个任务,其实是同一个任务的4个"分身"。而且还在继续生…
问题1:RETEST无限嵌套
现象
从ws_server.log可以清晰看到嵌套过程:
00:48:50 [WS] 复核失败,review.py已自动重派RETEST: TEST-DEV-IOT-1.1-DATABASE
00:49:38 [WS] 复核失败,review.py已自动重派RETEST: RETEST-TEST-DEV-IOT-1.1-DATABASE
00:50:21 [WS] 复核失败,review.py已自动重派RETEST: RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE
00:51:04 [WS] 复核失败,review.py已自动重派RETEST: RETEST-RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE
每44秒一轮,任务ID不断变长。最夸张的时候,一个任务的ID变成了:
RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE
15层嵌套,52个字符的前缀。而且这个"怪物"还在继续生长。
原因
问题出在review.py的dispatch_retest()函数:
# 原始代码(无限制)
def dispatch_retest(task_id, reason):
retest_id = f"RETEST-{task_id}" # 每次都加前缀
# ... 派发任务
这个函数的逻辑是:
- 复核失败
- 生成新任务ID:
RETEST-+ 原ID - 派发新任务
看起来没问题?但问题是——没有上限。
当TEST-DEV-IOT-1.1-DATABASE复核失败,变成RETEST-TEST-DEV-IOT-1.1-DATABASE。
当RETEST-TEST-DEV-IOT-1.1-DATABASE又失败,变成RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE。
当RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE还失败…
无限循环。
修复
def dispatch_retest(task_id, reason):
"""自动重派测试任务(最多重试3次,防止无限循环)"""
# 检查重试次数:计算task_id中有多少层RETEST前缀
retry_count = task_id.count('RETEST-') - (1 if task_id.startswith('RETEST-') else 0)
if task_id.startswith('RETEST-'):
# RETEST-RETEST-xxx 的retry_count = 前缀中RETEST的个数
parts = task_id.split('-TEST-')
if len(parts) > 1:
retry_count = parts[0].count('RETEST')
if retry_count >= 3:
return f"⚠️ 已达最大重试次数(3次),跳过重派: {task_id}"
retest_id = f"RETEST-{task_id}"
# ... 继续派发
关键改动:
- 添加重试计数:从task_id中解析已重试次数
- 设置上限:最多3次
- 超限返回警告:不再继续派发
问题2:计数逻辑的坑
第一次尝试:简单count
retry_count = task_id.count('RETEST-')
看起来对?但有个边界情况:
RETEST-TEST-DEV-xxx→ count = 1 ✓RETEST-RETEST-TEST-DEV-xxx→ count = 2 ✓TEST-DEV-xxx→ count = 0 ✓
那RETEST-DEV-xxx呢?count = 1,但其实应该也是1次重试。好像没问题?
等等,再想想。如果原始任务ID本身就包含RETEST呢?比如FIX-RETEST-0714-xxx。这时count会误判。
第二次尝试:拆分解析
parts = task_id.split('-TEST-')
if len(parts) > 1:
retry_count = parts[0].count('RETEST')
这个逻辑更精确:
- 以
-TEST-为分隔符拆分 - 第一部分是前缀(如
RETEST-RETEST) - 统计前缀中
RETEST的个数
但还有个问题:如果原始任务ID是TEST-DEV-xxx,拆分后parts = ['TEST', 'DEV-xxx'],parts[0] = 'TEST',count(‘RETEST’) = 0。正确。
如果是RETEST-TEST-DEV-xxx,拆分后parts = ['RETEST', 'DEV-xxx'],parts[0] = 'RETEST',count = 1。正确。
如果是RETEST-RETEST-TEST-DEV-xxx,拆分后parts = ['RETEST-RETEST', 'DEV-xxx'],parts[0] = 'RETEST-RETEST',count = 2。正确。
最终方案
retry_count = task_id.count('RETEST-') - (1 if task_id.startswith('RETEST-') else 0)
if task_id.startswith('RETEST-'):
parts = task_id.split('-TEST-')
if len(parts) > 1:
retry_count = parts[0].count('RETEST')
两套逻辑双重验证,确保计数准确。
问题3:dev_id提取的连锁反应
RETEST前缀叠加不只影响计数,还影响了review.py中提取原始任务ID的逻辑。
原来的问题
review.py需要从task_id中提取dev_id来匹配MD文件:
# 从RETEST-TEST-DEV-IOT-1.1-DATABASE提取DEV-IOT-1.1-DATABASE
dev_id = task_id.replace('RETEST-', '')
当只有1层前缀时,这行代码能工作。但当有3层前缀时:
task_id = "RETEST-RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE"
dev_id = task_id.replace('RETEST-', '') # → "TEST-DEV-IOT-1.1-DATABASE"
还是不对,因为还有TEST-前缀。而且如果原始任务就是TEST-DEV-xxx,去掉RETEST-后还剩TEST-DEV-xxx,需要再去掉TEST-。
修复方案
def extract_dev_id(task_id):
"""从嵌套的RETEST前缀中提取原始dev_id"""
# 去掉所有RETEST-前缀
dev_id = task_id
while dev_id.startswith('RETEST-'):
dev_id = dev_id[8:] # len('RETEST-') = 8
# 如果还有TEST-前缀,也去掉
if dev_id.startswith('TEST-'):
dev_id = dev_id[5:] # len('TEST-') = 5
return dev_id
用循环确保无论嵌套多少层都能正确提取。
问题4:DB查询的模糊匹配
任务ID变形后,数据库查询也出了问题。
原来的问题
SELECT * FROM tasks WHERE task_id = 'TEST-DEV-IOT-1.1-DATABASE'
当任务被重派为RETEST-TEST-DEV-IOT-1.1-DATABASE后,这条SQL查不到原任务了。
修复方案
SELECT * FROM tasks WHERE task_id LIKE '%DEV-IOT-1.1-DATABASE'
用LIKE模糊匹配,只要ID包含原始任务名就能查到。
但这也带来新问题:如果同时存在DEV-IOT-1.1-DATABASE和DEV-IOT-1.1-DATABASE-MOBILE,模糊匹配会返回多条记录。
最终方案
SELECT * FROM tasks
WHERE task_id LIKE '%DEV-IOT-1.1-DATABASE'
AND task_id NOT LIKE '%-%-DEV-IOT-1.1-DATABASE'
ORDER BY created_at DESC LIMIT 1
排除带前缀的记录,只取最新的。
问题5:统筹报告的混乱
嵌套的任务ID让统筹报告变得不可读:
| 9 | RETEST-RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE | RETESTRETESTRETESTTESTDE |
| 10 | RETEST-RETEST-TEST-DEV-IOT-1.1-DATABASE | RETESTRETESTTESTDEVIOT1. |
| 11 | RETEST-TEST-DEV-IOT-1.1-DATABASE | RETESTTESTDEVIOT1.1DATAB |
| 12 | TEST-DEV-IOT-1.1-DATABASE | IOT1.1DATABASE |
第2列的"完善内容"被截断到10个字符,根本看不出是什么任务。
修复方案
在生成报告时,提取原始任务名:
def shorten_task_id(task_id):
"""提取可读的任务名"""
# 去掉所有RETEST-前缀
name = task_id
while name.startswith('RETEST-'):
name = name[8:]
# 去掉TEST-/FIX-前缀
for prefix in ['TEST-', 'FIX-']:
if name.startswith(prefix):
name = name[len(prefix):]
return name[:15] # 截断到15字符
经验总结
1. 自动化机制必须有上限
任何"自动重试"、"自动重派"的逻辑,都必须有最大次数限制。否则一个失败的任务会无限繁殖,最终拖垮整个系统。
2. 任务ID设计要考虑可追溯性
RETEST前缀叠加的设计虽然能保留历史,但会让ID变得不可读。更好的方案是在DB中单独记录retry_count字段,而不是修改任务ID本身。
3. 字符串操作要防御性编程
从task_id中提取信息时,不能假设ID的格式是固定的。要用循环、正则等稳健的方式处理各种边界情况。
4. 日志是最好的调试工具
这次问题能快速定位,全靠ws_server.log中清晰的记录。如果日志不详细,光看统筹报告的混乱输出,很难快速找到根因。
系列第16篇 | 2026-07-15
基于真实事件还原,所有代码和日志均来自实际系统
更多推荐

所有评论(0)