给 LLM 装上双手:Open-AutoGLM 如何通过 ADB 操纵 Android 手机

117 阅读13分钟

给 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 会继续:

  1. 再次截图
  2. 再次调用模型
  3. 模型输出下一个动作(比如 Tap 点击某个位置)
  4. 执行动作
  5. ...直到 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 继续...")

流程:

  1. 系统阻止截图 → 返回黑屏
  2. AI 识别黑屏 → 输出 Take_over
  3. 程序暂停 → 提示用户手动操作
  4. 用户按 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 的核心问题:

  1. 坐标归一化:用 0-999 坐标系抹平设备差异
  2. 内存优化:执行后删除图片,只保留文本历史
  3. 中文输入:用 ADB Keyboard 绕过 ADB 限制
  4. 安全机制:黑屏检测 + 人工接管
  5. 安全解析:用 AST 替代 eval()

这些不是"黑科技",而是工程上的务实选择。每个设计都有清晰的 trade-off:

  • 坐标归一化牺牲了精度,换来了通用性
  • 删除图片节省了内存,但损失了完整历史
  • ADB Keyboard 增加了依赖,但解决了中文输入

从架构上看,这是一个典型的"感知-决策-执行"循环系统。核心难点在于:

  • 让 AI 理解屏幕(VLM)
  • 让 AI 遵守操作规则(Prompt Engineering)
  • 让操作稳定可靠(工程优化)

项目代码质量不错,模块划分清晰,错误处理完善。如果要基于它做二次开发,可以:

  • 替换模型(只要兼容 OpenAI API)
  • 自定义动作(扩展 ActionHandler)
  • 修改 Prompt(调整 AI 行为)
  • 添加新应用(更新 APP_PACKAGES)

项目地址:github.com/zai-org/Ope…

相关论文:AutoGLM: Autonomous Foundation Agents for GUIs (2024)