自动化脚本失败时,怎么打一个可复现的「案发现场包」

自动化项目跑久了你会发现一个反直觉的事实——写主流程其实不难,难的是出问题之后能不能复现。

界面识别错了一次,到底是模型识别失败,还是当时画面卡了?决策错了一次,是局面本身刁钻,还是上游传了脏数据?这些问题,单看一行日志根本答不上来。

最早遇到这种问题的时候,处理方式很无脑——让用户截图发群里、问”刚才操作过什么”、隔一段时间自己也忘了细节。一来二去,复现成功率约等于零。

后来痛定思痛,搞了一套”失败现场打包”机制。出错的时候自动把那一刻能拿到的所有上下文一起冻起来,事后能完整复盘。

一个失败包里该装什么

先看实际跑出来的目录长这样:

1
2
3
4
5
6
7
8
9
10
logs/failure_2025-02-03_14-32-15_auto_purchase/
├── meta.json # 任务元数据:哪个任务、哪一步、哪个版本
├── exception.txt # 异常类型 + traceback
├── screenshot.png # 失败瞬间的屏幕截图
├── yolo_detections.json # YOLO 那一帧的所有检测框 + 置信度
├── ocr_results.json # OCR 当时识别到的所有文本
├── device_info.json # 设备分辨率、状态、ADB 连接信息
├── task_context.json # 任务执行到这一步时的内部状态
├── config_snapshot.json # 用户配置(脱敏后)
└── recent_logs.txt # 失败前最后 N 行日志

这一套打包出来通常 1~5 MB。对比起原来”日志里一行 ERROR + 用户截图模糊得没法看”,信息量差出几个数量级。

代码上对应一个工具类,封装在 src/utils/task_failure_package.py,配合 task_dumper.py 做现场冻结。

为什么不光记日志

最常见的问题:日志能解决一切吗?

不能。原因有几个:

1. 日志只记”已知重要”的信息

你日志里记的是你写代码时认为重要的字段。但一旦遇到没预想到的失败,那些”没被记进日志”的字段往往才是关键——比如某个 buff 状态、某个隐藏 modal 的出现。临时加日志?等你下次复现已经是一周后了。

2. 日志没有”那一刻的画面”

视觉自动化最怕的事就是”日志说找到了一个按钮,但你不知道那是个什么按钮”。截图一截,立马明白——哦,是个广告 popup,YOLO 误识别成正常按钮了。

3. 日志是文本,结构化数据丢了

YOLO 返回的检测框是结构化的(坐标、类别、置信度),日志只能字符串化。事后想重新跑一遍 NMS 或者过一遍分类,原始数据已经不在了。

失败包补的就是这些”日志补不到”的部分。

打包的时机

最容易踩坑的设计点——什么时候打包?

最朴素的想法:在最外层 try-except 里包一下。问题是这时候很多上下文已经被 GC 掉了。比如那一帧的截图,任务往下走了几步可能已经被新截图覆盖。

我们的做法是在关键步骤进入时记一个”快照点”,异常发生的时候,从最近的快照点开始打包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class TaskDumper:
def snapshot(self, label: str, context: dict):
"""记录一个可能用于失败打包的现场快照"""
self._latest = {
"label": label,
"timestamp": time.time(),
"context": context,
"screenshot": self.device.get_screenshot(), # 复制一份
"yolo_results": self.engine.latest_results,
...
}

def dump_on_failure(self, exception: Exception):
"""异常时调用,把最近快照 + 异常信息打包到 logs/"""
if self._latest is None:
return
pkg_dir = self._make_dir()
self._write_meta(pkg_dir, exception)
self._write_screenshot(pkg_dir, self._latest["screenshot"])
self._write_detections(pkg_dir, self._latest["yolo_results"])
...

调用方在每个有意义的步骤开始时调用 snapshot(),失败时由外层 try-except 调用 dump_on_failure()。snapshot 的开销是个浅拷贝 + 一次截图,可控。

截图的几个坑

视觉自动化里截图是失败包的灵魂,但坑也最多。

坑一:截图被覆盖

主线程持续抓屏,每次都写到同一个 numpy buffer。snapshot 的时候不复制一份,过一帧你拿到的就是新画面,不是失败那一刻的画面。

解决:snapshot 时立即 .copy(),强制内存隔离。

坑二:截图过大

高分辨率全屏 PNG 一张能到 3~5 MB。一天攒几十个失败包,磁盘吃不消。

解决:写盘时缩放到 1280px 宽(短边等比),用 JPG 而非 PNG,单张降到 200~400 KB。但要注意——用于复现的截图必须能再喂给模型推理,所以缩放比例要可逆,或者干脆同时存一份原图、一份压缩图(原图设短保留期,压缩图长期保留)。

坑三:含隐私信息

游戏画面可能含玩家 ID、昵称、好友列表。失败包要发给开发者排查时,这些都得脱敏。

解决:截图打包前过一道 mask——把已知的隐私区域用纯色覆盖。config_snapshot.json 也走脱敏函数,token、cookie、密码一律 [REDACTED]

meta.json 是排查的入口

打包目录里第一个该看的就是 meta.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"version": "0.8.2",
"task_name": "auto_purchase",
"step": "click_purchase_confirm",
"platform": "macOS",
"device": {
"type": "PlayCover+MaaTools",
"resolution": "1920x1080"
},
"exception": {
"type": "ElementNotFoundError",
"message": "Expected button 'PurchaseConfirm' not found in current frame"
},
"snapshot_age_ms": 47,
"task_duration_ms": 12300
}

snapshot_age_ms 这个字段特别有用——告诉你失败那一刻距离最近的 snapshot 隔了多久。隔得短,说明截图就是案发瞬间;隔得久(比如几秒),可能这中间发生了别的事,要看 recent_logs.txt 补完。

task_duration_ms 是另一个隐性指标——任务正常完成只要几秒,但这次跑了几十秒还失败,多半是某个等待环节卡住了。

一次真实的排查

举个真实例子说明这套东西怎么用的。

用户反馈”自动购买偶尔会失败”。打开他发来的 failure 包:

  1. meta.json:异常是 ElementNotFoundError,找不到”购买确认”按钮
  2. screenshot.png:屏幕上确实没有按钮——出现了一个”商品已售罄”的提示框
  3. yolo_detections.json:YOLO 正确检测到了 “SoldOutModal”,置信度 0.94
  4. task_context.json:任务状态显示”等待购买确认按钮出现”
  5. recent_logs.txt:日志里有一行 “Skipping unknown modal: SoldOutModal”——原来代码里见过这个 modal,但没写关闭逻辑

诊断结论清清楚楚——商品售罄的情况没被处理。修起来 5 分钟,写测试用例验证完事。

如果只有日志,光看 “ElementNotFoundError” 这个异常名根本指不到方向。

落地的几条规矩

写一套失败包系统不难,难的是用得起来——很多类似系统造出来之后没人用,最后失活。我们这套能持续运转,靠的是几条死规矩:

1. 默认开启,不能依赖用户手动启用。

加个开关让用户”如果遇到问题再打开”,那等于没做——用户遇到问题的时候开关是关的,错过现场。常态运行,磁盘吃得起。

2. 失败包目录要简单可压缩。

logs/failure_* 一个目录一个失败,用户一键打包成 zip 发上来。别搞数据库、别搞分布式存储,普通文件夹最香。

3. 大小要有兜底。

设个上限——比如最多保留 50 个失败包,超过的按时间删旧的。免得磁盘吃光。

4. 失败包本身打包失败不能影响主流程。

dump_on_failure 内部全部 try-except 包住,自己挂了写个 ERROR 日志就完事,绝不能往上抛——主任务已经在异常路径上了,再来一波异常就乱了。

5. 脱敏机制提前写好。

不是出问题之后再想”哦对截图里有手机号”。脱敏函数随 dump 一起写,每个失败包出去前都过一遍。

收个尾

自动化项目跑久了你会发现,复现能力比解决问题的能力更稀缺

你能修任何 bug,前提是 bug 能复现。复现不了的 bug,再厉害的人也只能干瞪眼。失败包就是把”无法复现”这个最大的拦路虎搬走——出问题的那一刻发生了什么,全冻在那个目录里,你随时可以回去看。

写这套机制大概花了几天工夫,回头看是这个项目里 ROI 最高的几件事之一。每次有人在群里说”刚才出错了”,我都说”把 logs 目录里最新的那个失败包发我看看”,剩下的就好办了。

不打失败包的自动化项目,跟没装行车记录仪的车一样——平时没事,出事就抓瞎。

欢迎关注我的其它发布渠道