记录移动应用中人工智能智能体运行的循环结构
记录移动应用中AI智能体运行的循环:每个循环必须回答的四个问题、从点击到切片过程中的六个嵌套循环,以及那些未能捕获任何异常的检查。
只需告诉智能体让登录测试通过,它就会立即自行设计循环逻辑。通常会出现两种情况:要么不断重试直到所有指标都变为绿色,即便此时测试的标准比之前更低;要么它会读取自身的日志,发现没有错误后便宣布任务已完成。这两种行为都属于循环结构,而且在这两种情况下,模型都是根据其读取到的信息来选择相应的设计方案。
循环工程将这种设计决策从模型中移除。由人工提前以书面形式确定哪些内容会运行、它会读取什么信息来做出决策、何时结束任务以及何时需要停止并寻求人类帮助。这里是将该技术应用于移动应用。
设置
这个示例应用是一款面向 Android 和 iOS 的语音消息应用,主要通过 Claude Code 来开发。每个会话都会创建一个独立的 git 分支,称为工作树。在个工作树中,智能体负责构建应用、将其安装到模拟器上,通过 Maestro 这一 YAML 测试运行工具操作用户界面——该工具能够读取操作系统提供的屏幕可访问性结构(即带有标签的控制元素),随后将开发成果提交到版本控制中。这些循环中的任何部分都不依赖于这个特定的应用,它们只需要一个能够执行操作并观察结果的智能体即可。
“工具集”指的是决定智能体能看到什么、能做些什么的各种工具、文件、设备和规则。而“循环”则涉及另一个方面,即随着时间推移智能体如何利用这些工具集来表现其行为。
四个问题
以最简单的循环为例:点击按钮,读取无障碍访问树并检查是否出现了预期的标签。如果没有,则等待后再读取一次,十秒后仍未出现则判定失败。无论智能体设置中的循环规模有多大,直至“该功能是否完成”这一阶段,都必须回答四个问题:
- 什么会被执行?
- 它需要读取什么信息来做出决策?
- 什么会终止该循环?
- 何时停止并请求人工干预?
如果没有这四个问题的答案,那就不存在循环,只会有一个仅运行一次并寄予希望的脚本。为智能体运行的每个循环回答这些问题就是整个学科的核心内容,其余的都是示例。
第3个循环——登录流程——展示了在实际辅助工具上标记的这四个答案。所有基于状态的测试流程都是从这里开始的:
- launchApp:
stopApp: true # 1. what runs: a cold launch, every time
- retry:
maxRetries: 3 # 3. what ends it: Home on screen, or three tries
commands:
- runFlow:
when:
visible:
text: "Continue" # 2. what it reads: the Login screen's button
file: login.yaml # -> sign in only if it's showing
- runFlow: assert-home.yaml # -> then prove Home is on screen
# 4. when it asks a human: never. A fourth failure is red, and red is the answer.
这次重试有其物理层面的原因。在系统冷启动后,启动画面会同时覆盖登录界面和主界面,持续时间约为三秒,因此初次查看时可能看不到任何界面。重试次数限制为三次。如果仅设置“直到主界面出现为止才停止重试”,在构建失败的情况下将永远无法结束,而三次重试的设定可以包容较慢的启动速度,同时仍能在真正出现故障时显示红色提示。
六个嵌套的循环
这六个循环按从小到大的顺序运行,每个循环都嵌套在下一个循环之中。每个测试中都会执行数十次循环1,每个测试又在双设备测试环境中运行,每次测试又处于某个提交的检查流程中,而每个提交则属于某项工作内容的一部分。
应先构建最内层的循环,因为所有外层循环都依赖于它。循环1、3和6分别负责触摸操作循环、登录以及设备租赁功能。其余三个循环则需要进一步解释。
循环2属于有限次重试机制。在缓存为空的情况下,新工作树中的首次冷构建可能会在Kotlin编译器中崩溃。代理程序的内存文件中包含一条规则:在判定所测试的变更存在问题之前先尝试重试一次。这一限制非常重要——如果允许无限次重试直到结果正常,就会掩盖真正的故障;而仅一次重试就能让已知的偶发问题消失,从而使第二次失败具有实际意义。
循环4是提交前钩子。该仓库中没有集成测试,因此这个钩子是唯一会在每次变更时自动触发的循环。它的执行结果是直接拒绝提交:当缺少密钥扫描工具gitleaks时,就会拒绝该提交。此外,它还会在各种检查机制运行之前先执行这些检查自身的测试,因为那些无法找到输入数据的检查机制只会输出“OK”。简而言之,其功能如下:
#!/bin/sh
set -u
if ! command -v gitleaks >/dev/null 2>&1; then
echo "pre-commit: gitleaks not found — refusing to commit without a secret scan." >&2
exit 1
fi
gitleaks git --pre-commit --staged --redact --no-banner || exit 1
后半部分如下:
# The gates' own tests run first: a discovery bug produces no answer,
# after which every gate iterates an empty list and prints OK.
python3 ./scripts/test_gates.py >/dev/null 2>&1 || exit 1for gate in check_string_parity audit_hardcoded_strings; do
[ -x "./scripts/$gate.sh" ] && { "./scripts/$gate.sh" || exit 1; }
done
第5轮循环是围绕每项工作单元的外层循环。一个功能片段是指可单独交付的小部分功能,它需要经历四个步骤:Propose阶段会记录变更的内容、原因以及验收标准;Apply阶段负责执行该变更;Verify阶段会根据记录检查结果;Archive阶段则同步相关规范并结束该功能片段的处理流程。Verify是一个独立的步骤,会有专门的输出结果,而非实施者简单的“测试通过”备注,这样编写代码的机器人就不是唯一为结果背书的人。
人工检查点以表格形式呈现,标明哪些决策是机器人可独立做出的,哪些需要先进行询问。该表格遵循一条准则:如果撤销变更所需的重新工作时间超过一天左右,就必须先询问。若机器人跳过了某个步骤,则必须停下来说明原因并获得批准。
内存是用于在循环之间以及不同会话间传递信息的机制:它是一组简短的笔记,Claude Code会在每次会话开始时重新读取这些笔记。该项目中共有52条这样的规则,每条都类似于循环2中的那种格式。如果没有这些规则,新的会话将不得不从零开始重复之前的诊断过程。
绝不会失败的检查
所有这些循环都会在某个检查显示“完成”时结束。如果在未检测到任何问题的情况下就显示完成,循环就会默默终止。在这个代码库中出现了五种这样的情况。
一个绝不会失灵的关卡。本地化关卡是一种脚本,用于在用户界面中直接使用硬编码的英文文本而非从字符串目录读取时阻止代码提交。该脚本包含一个名为filter_animation_labels的过滤器,旨在仅丢弃那些看起来像是动画标签的匹配项。该过滤器在awk中通过window ~ tagbuilder来进行检测,但实际上从未有tagbuilder参数被传递给awk。由于未设置的awk变量相当于空字符串,而window ~ ""会匹配所有内容,因此该过滤器会丢弃所有检测到的匹配项。这个关卡从未触发过一次。
该过滤器在应用程序的紧急功能对应的14个文件中发现了49条硬编码字符串,其中包括SOS按钮上的英文contentDescription,旁边还有一段未被使用的翻译内容。设备上的截图揭示了该区域存在的所有实际本地化缺陷,而检测机制却未能发现任何问题。作为第4轮测试的结束条件,它对两天的所有提交记录进行了检查,但仍未发现异常。
一个毫无作用的检测机制。需求规定构建过程应当失败,但由于没有集成测试环境,因此没有任何机制来强制执行这一要求。该钩子的初稿还使用了exec gitleaks命令,这会替换shell环境,从而导致后续指令无法执行。
一个对所有内容都会触发的钩子。 if: 模式可以将 Claude Code 的钩子限制在特定命令上。原本为 gh pr create 设计的钩子仅使用了 Bash(gh pr create *),范围过于狭窄。当遇到 $()、$VAR 或 heredoc 时,仅基于命令名的模式会失效。解决办法是使用 if: Bash(gh *),让脚本本身过滤从标准输入读取的命令。
一个什么都不检测的测试套件。 在三周的时间里,iOS 测试流程在首次操作时就终止了,但没有任何提示显示错误,因为根本没有运行任何测试。本应检测 SOS 按钮的步骤虽然什么都没做,却显示为“已完成”,因此某次 SOS 测试在正常代码和故障代码上都给出了相同的通过结果。
对错误对象进行了正确的检测。有三次绿色检测结果针对的是其他会话安装的APK。虽然其中一个规格描述的是数月前就已删除的界面,但规格验证工具仍批准了182项中的182项。某个PATCH请求发送到尚未完成迁移的后端,后端返回200状态并悄悄忽略了该字段。
在让某个检测结果获得信任之前,必须先在其设计的检测范围内重现确切的缺陷并确认其显示为红色,之后再移除该缺陷并验证结果变为绿色,所有操作都应在编写该检测的同一会话中完成。这里的文档审计脚本正是遵循这一流程:每次检测显示为红色时,就改变其状态、添加无效链接或删除差异内容。其设计说明用一句话概括了这一原则——无法出错的检测仅具装饰性。它也不保存任何基准文件,因为为了让检测结果显示为绿色而忽略292个已知问题,会被视为测试覆盖率。
从何处开始
首先在任何代码之前写下四个答案,作为流程、脚本或规则顶部的注释。这样缺失的答案会立即显现,而不会在默默通过的执行过程中被忽略。
接下来是最小的循环——轻触循环,它有两个要求:控件必须带有标签:Android使用contentDescription或testTag,iOS则使用accessibilityLabel;此外,智能体需要对屏幕的每一次交互都有一条固定的规则。这些规则存储在仓库的指令文件中,也就是Claude Code在每次会话开始时加载的文件,其内容可概括为以下几点:
Driving the emulator:
- Before every tap: `adb shell uiautomator dump`, and find the label you intend to tap.
- Tap the centre of that node's bounds. Never reuse a coordinate from an earlier dump.
- After the tap: dump again and confirm the expected label appeared. Poll up to 10 s,
then stop and report red — do not guess a second tap.
- If the app won't launch: `pm dump <pkg> | grep enabled=` before suspecting the build.
那四句话构成了代理程序运行的循环。实际执行的操作是“输出、检测、再输出”。它会读取树结构,当出现标签或10秒后循环结束。它从不询问人类,因为红色就是答案。
最后,在信任用于结束循环的检测结果之前,故意打破这个循环。重新命名标签,看看循环是否会变为红色。如果仍为绿色,则说明没有数据被读取。
整个设置其实由六部分组成:
- 检测循环,即上述规则,写在代理程序的指令文件中。其他所有部分都假定代理程序能够先执行操作再进行检查。
- 会导致失败提交的预提交钩子,通过一个简短的设置脚本被提交到仓库并放入
.git/hooks目录中,这样每次检出时都会运行已提交的版本。没有扫描器就不存在提交操作。
总而言之,这就构成了一整套工作流程:可构建的作业树、可用于登录的账户、代理能够读取的界面、用于运行的设备,以及当检测到某些操作失败时会触发的循环。