手机端 Agent 怎么动起来:Kotlin 与 Python 之间的桥


上篇把 Python 打进了 APK,但有个问题没回答:Agent 要"动"起来,它得能看屏幕、能点按钮。

可 Python 又不是安卓亲生的------它自己不会截图、不会点控件。谁来看屏?谁来点击?在我这个项目里,答案是:Python 内核发令,Kotlin 壳去执行。中间那座桥,就是这篇要讲的。

没有这座桥,Agent 只是个会说话、不会动手的大脑。

先放一张总览,免得后面绕晕------整座桥就这么几块:

虚线是"注入/回传",粗实线是"Python 真的下指令"。看懂这张,后面两段就是把它拆开讲。

第一向:Kotlin 把门先开开

App 一启动,Kotlin 这边先把 Python 解释器拉起来,再把一个"派发器"塞进 Python 的世界:

kotlin 复制代码
// KernelHostService.attachKernel()
if (!Python.isStarted()) {
    Python.start(AndroidPlatform(this))
}
val py = Python.getInstance()
val bridgeMod = py.getModule("hachimi_kernel.bridge")
// 把安卓侧的派发器注入 Python
bridgeMod.callAttr("register_bridge", PrimitiveDispatcher())

callAttr 就是 Chaquopy 给的"跨语言函数调用"------Kotlin 这边写 模块.callAttr("函数名", 参数),对面就是 Python 里一个同名函数。

这一步干的事很简单:把能操作真机的对象,交给 Python 握着。后面 Python 想干啥,调这个对象就行。

第二向:Python 反过来指挥真机(最反直觉)

一般人会以为,Python 要截图,得 import android 然后调安卓 API。但在 Chaquopy 里,Python 不import 安卓任何东西------它只调 Kotlin 刚才塞进来的那个对象:

python 复制代码
# bridge.py:Kotlin 注入的派发器,存在模块全局
_bridge = None
def register_bridge(dispatcher):
    global _bridge
    _bridge = dispatcher

# Agent 要观察屏幕时,就这么一行
raw = _bridge.call("observe", json.dumps({"compact": True}, ensure_ascii=False))
result = json.loads(raw)

observe 这个名字会被传回 Kotlin,那边有个 when 把它派发到真正的安卓代码:

kotlin 复制代码
// PrimitiveDispatcher.call()
fun call(name: String, argsJson: String): String {
    val svc = HachimiAccessibilityService.instance ?: return error("a11y not bound")
    val args = JSONObject(argsJson)
    return when (name) {
        "observe"    -> svc.observe(args.optBoolean("compact", false))
        "tap_by_id" -> svc.tapById(args.optString("view_id"))
        "tap_xy"    -> svc.tapXY(args.optDouble("x"), args.optDouble("y"))
        "type_text" -> svc.typeText(args.optString("text"))
        "launch_app" -> svc.launchApp(args.optString("package"))
        // ......还有 gesture / screenshot / press
    }.toString()
}

看到没?Agent 的"看一眼""点一下",最后全落到这段 when 里。它就是 Agent 的手和眼睛在具体手机上的落点。

这反直觉的一点很重要:Python 不直接碰安卓,它只发指令;真活儿是 Kotlin 干的。这样内核可以保持纯净,不依赖任何安卓细节,换台设备也好迁移。

数据怎么传:一个真实的坑

两层语言之间传数据,最顺手的方式是用 JSON 字符串。原因是个坑:

Chaquopy 返回的 PyObject,你直接 .toString() 拿到的是 Python 的 repr (带单引号那种),Kotlin 这边的 JSONObject 解析不了。所以两侧约定:进出都先 json.dumps / json.loads 包一层,字符串过去了再各自解开。

python 复制代码
raw = _bridge.call("observe", json.dumps(args, ensure_ascii=False))
return json.loads(raw)   # 解开再用

这个坑不大,但第一次遇到会莫名其妙------明明数据是对的,Kotlin 那边就是解析失败。记一笔:跨语言传复杂数据,显式 JSON 字符串最稳。

用一张图对比两种传法就清楚了:

其实这个 JSON 协议不只跨语言用------Agent 的工具参数和返回、喂给大模型的消息、本地轨迹落盘,全是用同一套 dict,边界完全同构。一座桥的协议,顺手把整条链路也统一了。

工具面:Agent 的手和眼到底有哪些

桥通了,但 Python 能调的"原语"具体是哪些?它们写在 bridge.py 的 TOOL_SPECS 里,每个原语注册成一个工具,背后就是一行 _bridge.call:

python 复制代码
# bridge.py:把八原语注册成 Agent 可用的工具
for spec in TOOL_SPECS:
    tools[spec["name"]] = {"description": spec["description"],
                           "parameters": spec.get("parameters", {"type": "object"}),
                           "call": _call(spec["name"])}   # _call 闭包里就是 _bridge.call(name, ...)

列一下 Agent 真正能做的动作,基本就是"看"和"动"两类:

  • 看 :observe(读当前界面 UI 树)、screenshot / grab_frame(拿像素图,视觉兜底用) - 动 :tap_by_id(按控件 id 点)、tap_xy(按坐标点)、type_text(输字)、gesture(手势)、launch_app(开应用)、press(按键,如返回) - 辅助 :wait(等界面加载)、look(显式问一眼屏幕)

还有两个不在 Kotlin 那边、纯 Python 实现的:load_skill / search_skill------让 Agent 调出以前沉淀过的操作套路。这俩是下一篇的主角,先埋个伏笔。

桥在 Agent 主循环里的实际位置

光说"能调"还不够直观。我把一次真实任务的完整走查摆出来,你看桥站在哪:

  1. 你在 app 里点"开始",Kotlin 收到 HTTP 的 /task 请求,调 bridgeMod.callAttr("start_task", body); 2. Python 的 start_task 在后台线程 拉起 MiniLoop(Agent 主循环),把目标和大模型配置传进去; 3. MiniLoop 每轮让大模型决策:reply = brain.chat(messages, tool_specs),模型返回"下一步调 observe"; 4. 循环里 self._dispatch(name, args, i) 执行这个工具 → 也就是 tools["observe"]["call"](args) → 背后 _bridge.call("observe", ...); 5. Kotlin 真去读无障碍树,把结果 JSON 传回 Python; 6. Python 把这个"看屏幕"的结果当工具消息喂回模型,模型看着屏幕决定"点这个按钮" → 又走一遍 _bridge.call("tap_by_id", ...); 7. Kotlin 真去点。模型确认目标达成 → 调 task_done,循环结束。

看一眼、点一下、再决定------Agent 在手机上跑起来的每一帧,桥都在中间传话两次(出一次指令、回一次结果)。上篇把 Python 塞进去只是地基,这篇才是 Agent 长出手脚、还接上了大脑的那一步。

把上面 1~7 步走查画成一张循环图,桥站在哪一目了然:

几个真实护栏:Agent 在手机上得"安全动"

能跑还不够,得防止它瞎点。这些都在 MiniLoop 里,是真实代码不是设想:

  • 卡住检测 :连续 4 次调完全相同的 (工具, 参数),直接判 stuck 终止------避免它在一个点不下来的按钮上死循环。 - 换方案提醒(RepeatGuard) :同一个工具连续失败 3 次,往上下文里塞一句"请更换执行方案",逼模型别再重复同一个失败调用。 - task_done 强制校验 :启用了完成判据时,模型说"我做完了"会被 verify 拦下来复核;没通过就打回让它继续,防止幻觉式宣布完成。 - 预算 :默认最多 24 步、墙钟 600 秒,超限按"未完成"终止,不伪装成功。

这几道护栏加在一起,Agent 才是一个"能动、但乱动会被叫停"的东西,而不是一个你不敢放手让它跑的脚本。

为什么这么设计桥接(几点取舍)

顺手说下这么搭桥的理由,都是踩过或权衡过的:

  • 注入式,而不是 Python 直接 import 安卓 :内核保持纯净、能在电脑上单测,不绑任何安卓细节。代价是 Python 侧的 API 是"被喂进来的",得约定清楚。 - 八原语单一事实来源 :PrimitiveDispatcher 同时被调试桥(电脑直连发指令测)和 Python 桥共用,保证两边说的"原语"是同一套,不会各写各的。 - JSON 协议贯穿三层:Python↔Kotlin、模型↔工具、轨迹落盘,全用同一套 dict。边界同构,出问题时一眼能追到是哪一层。

桥的另一头:手机里的 Agent 也能被远程指挥

桥接层还顺带解决了一个更实用的需求------Agent 不只能在你点"开始"时跑,也能被外部驱动。

KernelHostService 起了一个本地回环 HTTP(8071 端口):/task 让电脑或脚本 POST 一段目标就触发手机 Agent 跑任务,/health 查状态,/stop /pause /resume 控制它。也就是说,你人在电脑前,发一条指令,手机自己就开始操作自己------桥把"手机 Agent"变成了一个可被远程调用的能力,而不只是个 app 里的按钮。

收尾

把上面几块拼起来:Kotlin 起解释器、把派发器注入 Python、Python 用 JSON 指挥真机、MiniLoop 把这变成 observe→思考→行动的循环、再加几道护栏和一个远程控制面------Agent 在手机上"动起来"这件事,底座就是这座桥。

上篇是地基,这篇是手脚。到这,一个手机端 Agent 从打包进 APK、到两层语言打通、到真能在手机上动起来的完整链路,就讲完了。

仓库地址:github.com/momo-null/hachifish

相关推荐
高晶1 小时前
Android 新手实战:从零搭建 WebRTC 在线直播系统
android·程序员
alexhilton1 小时前
为什么你的 LazyColumn 仍然卡顿
android·kotlin·android jetpack
>Andre<3 小时前
系统架构全景
android
奈落245 小时前
独立的AI编程助手正在消失?从文心快码并入百度搭子、Qwen Code转向CLI,看编程工具正被吸收进更大的智能体平台
百度·agent·ai编程·ai 编程
七牛开发者5 小时前
从 Bun 到 Deno,AI 时代的开发工具正在经历什么?
agent·deno·bun
用户721746588265 小时前
默认校验只覆盖 3.18%:bl dataset validate 抽样窗与 capability 三种返回态的字段级拆解
llm·agent·ai编程
Csvn5 小时前
延迟别再看平均值了:AI 应用指标与告警实战(O03)
人工智能·aigc·agent
用户21296835261686 小时前
我给自己的 LLM 框架重写了记忆系统,然后三天吃出两个真 Bug
agent
hpoenixf6 小时前
Agent 持续进化的秘诀:让你的 Agent 从大哥大进化成智能手机
agent