给 LLM 装上双手:Open-AutoGLM 如何通过 ADB 操纵 Android 手机
一个具体的问题:支付页面的黑屏
假设你让 AI 在美团点一杯咖啡。它打开美团,搜索商品,加入购物车,进入结算页面。然后,屏幕变黑了。
这是 Android 系统的安全机制:支付页面禁止截图。当 ADB 执行 screencap 命令时,系统返回 Status: -1。此时 AI Agent 看不到屏幕,无法判断当前状态。
Open-AutoGLM 要解决的就是这类问题:如何让大语言模型操控物理手机。具体来说:
- 不同设备屏幕分辨率不同(720p 到 4K,刘海屏到折叠屏),如何确定点击坐标?
- UI 会动态变化(加载、弹窗、动画),如何判断当前状态?
- ADB 原生的
input text不支持中文,如何输入中文? - 支付密码、验证码等敏感操作,如何实现人机协同?
本文分析 Open-AutoGLM 的源码,看它如何处理这些问题。重点关注一个指令从文字到物理点击的完整流程。
核心链路:从截图到点击的完整流程
我们跟踪一个简单的指令:"打开微信"。看看代码里到底发生了什么。
第一步:截图(phone_agent/adb/screenshot.py)
def get_screenshot(device_id: str | None = None) -> Screenshot:
# 1. 在设备上截图
subprocess.run(["adb", "shell", "screencap", "-p", "/sdcard/tmp.png"])
# 2. 拉取到本地
subprocess.run(["adb", "pull", "/sdcard/tmp.png", temp_path])
# 3. 读取图片并转为 base64
img = Image.open(temp_path)
buffered = BytesIO()
img.save(buffered, format="PNG")
base64_data = base64.b64encode(buffered.getvalue()).decode()
return Screenshot(
base64_data=base64_data,
width=img.width,
height=img.height
)
这里没什么特殊的,就是标准的 ADB 截图流程。但有一个细节:截图后立即转成 base64,因为后面要通过 HTTP 发送给模型。
第二步:获取当前应用(phone_agent/config/apps.py)
def get_current_app(device_id: str | None = None) -> str:
result = subprocess.run(
["adb", "shell", "dumpsys", "window"],
capture_output=True, text=True
)
# 解析输出,找到当前焦点窗口
# 输出示例: mCurrentFocus=Window{abc com.tencent.mm/...}
for line in result.stdout.split("\n"):
if "mCurrentFocus" in line or "mFocusedApp" in line:
for app_name, package in APP_PACKAGES.items():
if package in line:
return app_name
return "System Home"
通过 dumpsys window 获取当前焦点窗口,然后在预定义的应用包名映射表中查找。映射表有 50+ 个常用应用:
APP_PACKAGES = {
"微信": "com.tencent.mm",
"淘宝": "com.taobao.taobao",
"美团": "com.sankuai.meituan",
# ...
}
第三步:构建多模态消息(phone_agent/model/client.py)
# 第一条消息:系统提示词
{
"role": "system",
"content": "你是一个智能体分析专家...(省略完整 Prompt)"
}
# 第二条消息:用户任务 + 当前屏幕
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{screenshot.base64_data}"
}
},
{
"type": "text",
"text": "打开微信\n\n{\"current_app\": \"System Home\"}"
}
]
}
这是标准的 OpenAI 多模态消息格式。图片用 base64 编码内联在请求中。
第四步:AI 推理(phone_agent/model/client.py)
def request(self, messages: list[dict]) -> ModelResponse:
stream = self.client.chat.completions.create(
messages=messages,
model="autoglm-phone-9b",
temperature=0.0, # 确定性输出
stream=True
)
# 流式接收响应
raw_content = ""
for chunk in stream:
content = chunk.choices[0].delta.content or ""
raw_content += content
# 解析思考和动作
# 输出格式: <think>...</think><answer>do(action='Launch', app='微信')</answer>
thinking, action = self._parse_response(raw_content)
return ModelResponse(thinking=thinking, action=action)
模型会输出两部分:
thinking:AI 的推理过程(如"当前在桌面,需要启动微信")action:具体操作指令(如do(action='Launch', app='微信'))
第五步:解析动作(phone_agent/actions/handler.py)
def parse_action(response: str) -> dict:
# 输入: "do(action='Launch', app='微信')"
# 输出: {"_metadata": "do", "action": "Launch", "app": "微信"}
tree = ast.parse(response, mode="eval")
call = tree.body
action = {"_metadata": call.func.id} # "do" 或 "finish"
for keyword in call.keywords:
key = keyword.arg
value = ast.literal_eval(keyword.value)
action[key] = value
return action
这里用 ast.parse 而不是 eval(),避免代码注入风险。ast.literal_eval() 只允许解析字面量(字符串、数字、列表等)。
第六步:执行动作(phone_agent/adb/device.py)
def launch_app(app_name: str, device_id=None):
package = APP_PACKAGES.get(app_name)
if not package:
raise ValueError(f"Unknown app: {app_name}")
subprocess.run([
"adb", "shell", "monkey",
"-p", package,
"-c", "android.intent.category.LAUNCHER",
"1"
])
通过 monkey 命令启动应用。这比 am start 更可靠,因为不需要知道具体的 Activity 名称。
循环执行
以上是一个步骤的完整流程。执行完 Launch 后,Agent 会继续:
- 再次截图
- 再次调用模型
- 模型输出下一个动作(比如
Tap点击某个位置) - 执行动作
- ...直到
finish()或达到最大步数
整个过程的数据流向:
屏幕截图 (PNG)
↓
Base64 编码
↓
HTTP 请求 → AI 模型
↓
文本响应: <think>...</think><answer>do(...)</answer>
↓
AST 解析 → Python dict
↓
ADB 命令 → 手机执行
↓
重新截图(下一轮)
关键工程问题与解决方案
问题1:不同分辨率的坐标问题
不同手机屏幕分辨率差异很大:
- 低端机:720x1280
- 中端机:1080x2400
- 高端机:1440x3200
- 折叠屏:2208x1840
如果让 AI 直接输出绝对坐标,它需要知道当前屏幕分辨率。更麻烦的是,训练数据中的坐标无法跨设备使用。
解决方案:0-999 归一化坐标系
AI 输出的坐标范围固定为 0-999,然后由代码转换为实际像素:
def _convert_relative_to_absolute(element, screen_width, screen_height):
"""
element: [x, y],范围 0-999
返回: 实际像素坐标
"""
x = int(element[0] / 1000 * screen_width)
y = int(element[1] / 1000 * screen_height)
return x, y
示例:
- AI 输出:
[500, 300](屏幕中间偏上) - 在 1080x2400 屏幕上:
(540, 720) - 在 1440x3200 屏幕上:
(720, 960)
这样做的好处:
- AI 不需要知道具体分辨率
- 训练数据可以在不同设备间共享
- 坐标有直观含义(500, 500 就是屏幕中心)
问题2:图片占用大量内存
每张截图的 base64 编码约 1-2MB。如果保留所有历史截图:
- 10 步 = 10-20MB
- 50 步 = 50-100MB
- 100 步 = 100-200MB
这会导致:
- Python 进程内存溢出
- 发送给模型的上下文过大
- 推理速度变慢
解决方案:执行后立即删除图片
代码中有一个关键操作(phone_agent/agent.py:_execute_step):
def _execute_step(self):
# 1. 添加带图片的消息
self._context.append(
MessageBuilder.create_user_message(
text=f"** Screen Info **\n\n{screen_info}",
image_base64=screenshot.base64_data # 包含图片
)
)
# 2. 调用模型(使用当前图片)
response = self.model_client.request(self._context)
# 3. 解析动作
action = parse_action(response.action)
# 4. 关键:立即删除图片
self._context[-1] = MessageBuilder.remove_images_from_message(
self._context[-1]
)
# 5. 执行动作
self.action_handler.execute(action, ...)
remove_images_from_message 的实现:
@staticmethod
def remove_images_from_message(message: dict) -> dict:
"""只保留文本,删除图片"""
if isinstance(message.get("content"), list):
message["content"] = [
item for item in message["content"]
if item.get("type") == "text"
]
return message
删除前:
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}, # ~1.5MB
{"type": "text", "text": "{\"current_app\": \"微信\"}"}
]
}
删除后:
{
"role": "user",
"content": [
{"type": "text", "text": "{\"current_app\": \"微信\"}"} # ~100B
]
}
效果:100 步从 100MB 降到 10KB,节省 99.99% 内存。
AI 仍然能看到完整的文本历史(知道之前做了什么),但不需要保留所有截图。
问题3:ADB 不支持中文输入
ADB 原生的 input text 命令不支持中文:
adb shell input text "你好" # 输出乱码或失败
解决方案:ADB Keyboard
使用第三方输入法 ADB Keyboard,通过 Android 广播发送 UTF-8 文本。
完整流程(phone_agent/adb/input.py):
def type_text(text, device_id=None):
# 1. 检测当前输入法
result = subprocess.run(
["adb", "shell", "ime", "list", "-s"],
capture_output=True, text=True
)
original_ime = result.stdout.strip()
# 2. 切换到 ADB Keyboard
subprocess.run([
"adb", "shell", "ime", "set",
"com.android.adbkeyboard/.AdbIME"
])
# 3. 清空现有文本
subprocess.run([
"adb", "shell", "input", "keyevent", "KEYCODE_MOVE_END"
])
subprocess.run([
"adb", "shell", "input", "keyevent",
"--longpress", "$(printf 'KEYCODE_DEL %.0s' {1..50})"
])
# 4. 通过广播发送文本
encoded_text = text.replace(" ", "%s")
subprocess.run([
"adb", "shell", "am", "broadcast",
"-a", "ADB_INPUT_TEXT",
"--es", "msg", encoded_text
])
# 5. 恢复原输入法
subprocess.run([
"adb", "shell", "ime", "set", original_ime
])
关键在于第 4 步:ADB Keyboard 监听 ADB_INPUT_TEXT 广播,接收到后直接输入文本。这个过程支持完整的 UTF-8 编码(中文、emoji、特殊字符)。
用户感知:无感知,输入法会自动切换和恢复。
问题4:敏感页面的黑屏处理
回到开头的支付页面问题。当截图失败时,代码是这样处理的(phone_agent/adb/screenshot.py):
def get_screenshot(device_id=None):
# 执行截图
result = subprocess.run(
["adb", "shell", "screencap", "-p", "/sdcard/tmp.png"],
capture_output=True, text=True
)
# 检测失败
if "Status: -1" in result.stdout or "Failed" in result.stdout:
# 返回黑屏 + 敏感标记
black_img = Image.new("RGB", (1080, 2400), color="black")
buffered = BytesIO()
black_img.save(buffered, format="PNG")
base64_data = base64.b64encode(buffered.getvalue()).decode()
return Screenshot(
base64_data=base64_data,
width=1080,
height=2400,
is_sensitive=True # 关键标记
)
# 正常截图流程...
当 AI 收到黑屏时,它被训练为输出特定动作:
do(action="Take_over", message="请手动完成支付")
这会触发人工接管回调(phone_agent/actions/handler.py):
def _handle_takeover(self, action, width, height):
message = action.get("message", "需要人工操作")
self.takeover_callback(message)
return ActionResult(success=True, should_finish=False)
# 默认实现
def _default_takeover(message: str):
input(f"{message}\n按 Enter 继续...")
流程:
- 系统阻止截图 → 返回黑屏
- AI 识别黑屏 → 输出
Take_over - 程序暂停 → 提示用户手动操作
- 用户按 Enter → Agent 继续下一步
这样既保证了安全性,又不会中断整个任务流程。
Prompt Engineering:如何约束 AI 行为
AI 模型本身不知道该怎么操作手机,需要通过 Prompt 来约束。看看系统提示词(phone_agent/config/prompts_zh.py)是怎么设计的:
SYSTEM_PROMPT = """
今天的日期是: {formatted_date}
你是一个智能体分析专家,可以根据操作历史和当前状态图执行一系列操作来完成任务。
# 输出格式
<think>思考过程</think>
<answer>动作</answer>
# 可用操作
- do(action="Launch", app="应用名")
- do(action="Tap", element=[x, y])
- do(action="Type", text="文本内容")
- do(action="Swipe", start=[x1, y1], end=[x2, y2])
- do(action="Back")
- do(action="Wait")
- finish(message="完成原因")
# 重要规则
1. 检查 current_app 是否是目标应用,如果不是先启动
2. 如果进入无关页面,执行 Back 返回
3. 页面未加载完成时,最多 Wait 三次
4. 坐标范围为 0-999,与实际分辨率无关
5. 输入中文时使用 Type 动作,不要使用 input text
6. 遇到敏感页面(黑屏)时,执行 Take_over
7. 如果连续三次操作失败,考虑更换策略或调用 finish
8. 不要重复执行相同的失败操作
...(共 18 条规则)
"""
这些规则是从实际使用中总结出来的。比如:
- 规则 2:AI 经常会误点进入其他页面,需要明确告诉它返回
- 规则 3:页面加载需要时间,但不能无限等待
- 规则 7:避免死循环,三次失败后要换思路
Prompt 工程在这个项目中非常关键。如果规则写得不好,AI 会:
- 反复点击同一个位置
- 在错误的应用里操作
- 无限等待页面加载
- 输入错误的内容
这也是为什么需要 9B 参数的专门模型,而不能直接用通用大模型——通用模型很难遵守这些复杂的操作规则。
当前的局限性
基于源码分析和技术方案文档,这个系统存在以下局限:
速度问题
每一步操作需要:
- 截图:~0.5s
- AI 推理:~2-3s
- 执行动作:~0.5s
总计 3-4 秒/步。一个需要 10 步的任务就要 30-40 秒。而人类完成同样任务可能只需要 10 秒。
根本原因:
- 视觉模型推理慢(9B 参数)
- 每一步都要完整的"看-思考-行动"循环
- 无法像人类一样"预判"下一步
坐标精度问题
0-999 的坐标系虽然解决了分辨率问题,但精度有限:
- 在 1080p 屏幕上,最小单位约为 1 像素
- 在 4K 屏幕上,最小单位约为 4 像素
对于小按钮(如关闭广告的 ×),容易点不准。更好的方案是基于 UI 元素 ID,但需要:
- 每次都执行
uiautomator dump获取 UI 树 - 解析 XML 并提取元素信息
- AI 理解 UI 树结构
这会增加复杂度和延迟。
动态界面的误判
当前方案是"截图→分析→执行",但如果截图时页面正在:
- 播放动画
- 加载数据
- 滚动列表
AI 看到的可能不是最终状态,导致错误判断。代码里用 Wait 动作来缓解,但无法完全解决。
人类有"眼动追踪"和"预判"能力,AI 目前还做不到。
应用适配成本
APP_PACKAGES 映射表需要手动维护。新应用或应用更新包名后,需要修改代码。
更大的问题是不同应用的 UI 逻辑差异很大。AI 能泛化到未见过的应用,但准确率会下降。要达到生产可用,需要针对每个应用进行测试和优化。
人类操作 vs AI 操作的差异
| 维度 | 人类 | AI (Open-AutoGLM) |
|---|---|---|
| 速度 | 10 秒完成一个任务 | 30-40 秒(10 步任务) |
| 精度 | 手指可以精确点击 | 坐标有时不准 |
| 适应性 | 能快速适应新应用 | 未训练过的应用准确率低 |
| 并行能力 | 只能操作一台设备 | 可以同时控制多台设备 |
| 一致性 | 会疲劳、会出错 | 不会疲劳,但会重复同样的错误 |
| 成本 | 人力成本高 | 硬件成本 + API 成本 |
AI 操作的优势不在于"比人类快"或"比人类准",而在于:
- 可以 24 小时运行
- 可以并行处理大量设备
- 不需要培训就能操作新应用(虽然准确率有限)
- 操作过程可记录、可审计
适用场景:
- 自动化测试(重复执行相同流程)
- 数据采集(批量操作)
- 辅助功能(为视障用户提供语音控制)
- RPA(机器人流程自动化)
不适用场景:
- 实时性要求高的任务(如游戏、抢购)
- 需要 100% 准确率的关键业务
- 复杂的创意工作
总结
Open-AutoGLM 通过几个关键设计解决了多模态 Agent 的核心问题:
- 坐标归一化:用 0-999 坐标系抹平设备差异
- 内存优化:执行后删除图片,只保留文本历史
- 中文输入:用 ADB Keyboard 绕过 ADB 限制
- 安全机制:黑屏检测 + 人工接管
- 安全解析:用 AST 替代 eval()
这些不是"黑科技",而是工程上的务实选择。每个设计都有清晰的 trade-off:
- 坐标归一化牺牲了精度,换来了通用性
- 删除图片节省了内存,但损失了完整历史
- ADB Keyboard 增加了依赖,但解决了中文输入
从架构上看,这是一个典型的"感知-决策-执行"循环系统。核心难点在于:
- 让 AI 理解屏幕(VLM)
- 让 AI 遵守操作规则(Prompt Engineering)
- 让操作稳定可靠(工程优化)
项目代码质量不错,模块划分清晰,错误处理完善。如果要基于它做二次开发,可以:
- 替换模型(只要兼容 OpenAI API)
- 自定义动作(扩展 ActionHandler)
- 修改 Prompt(调整 AI 行为)
- 添加新应用(更新 APP_PACKAGES)
相关论文:AutoGLM: Autonomous Foundation Agents for GUIs (2024)