1. 从一次“点不动”的导航说起:MAI-UI 到底在解决什么
如果你最近在翻阿里通义 MAI-UI 的源码,大概率会有一种“文件不多但链路很长”的感觉:src/下几个 Agent 文件,加上 MCP 工具声明、截图回灌、坐标归一化,读着读着就不知道一次点击到底从哪来、到哪去。这篇就从代码阅读的视角,把 MAI-UI 从 MCP 到 GUI Agent 的 API 调用链拆开,让你能自己定位关键接口、验证链路是否跑通。
MAI-UI 是一个原生设备-云协作的 GUI Agent 框架,核心能力是把冗长的 UI 操作压缩成少量 API 调用,比如把“打开地图、搜地址、切公交、读时长”这一串点击,换成一次mcp_call("amap_route", ...)。它适合两类人:一是想快速理解 Agent 分层设计的开发者,二是准备把 MCP 工具接进自己 GUI Agent 的工程同学。热词里的 MAI-UI、MCP、GUI Agent、Agent、API 调用链,其实就是它全部的设计重心。
我读这套代码时最大的感受是:它并不自己实现 MCP,而是做 MCP 的消费方。理解这一点,后面所有链路都会顺。下面按“问题场景 → 前置准备 → 可复制配置 → 验证请求 → 报错排查 → 继续深入”的顺序展开,每一步都给出可跟做的命令和定位方法。
2. 读代码前的前置准备:把 MAI-UI 的模块依赖先理清
在拆调用链之前,得先把仓库的依赖关系摸清楚,否则你会在import报错里耗掉半天。MAI-UI 的目录结构有个很关键的特点:src/内部是扁平导入,也就是文件之间直接from base import ...,而不是包内相对导入。这意味着调用方必须把src/插进sys.path,否则一运行就ModuleNotFoundError。
先克隆并看结构:
git clone <MAI-UI 仓库地址> cd MAI-UI find . -maxdepth 2 -name "requirements*.txt"你会看到两套独立的依赖文件:根目录的requirements.txt是 agent 客户端用的,evaluation/grounding/requirements.txt是评测用的,里面含 vLLM、torch 这类重依赖。读代码阶段建议只装客户端那套,评测那套等真要跑 benchmark 再说,不然环境能装到你怀疑人生。
接着把src/加进路径,验证扁平导入能通:
import sys, os sys.path.insert(0, os.path.abspath("src")) from mai_navigation_agent import MAIUINavigationAgent from mai_grounding_agent import MAIGroundingAgent print("import ok")两个核心 Agent 的分工要记牢:MAIGroundingAgent(src/mai_grounding_agent.py)负责单步 UI 元素定位,输出<grounding_think>...</grounding_think>加{"coordinate":[x,y]},坐标基于SCALE_FACTOR=999归一化;MAIUINavigationAgent(src/mai_navigation_agent.py)负责多步移动端 GUI 导航,支持ask_user与mcp_call,输出<tool_call>{json}</tool_call>,多轮带历史截图。
理依赖时可以用一条命令快速看模块引用关系,定位谁依赖谁:
grep -rn "^from \|^import " src/ | sort | uniq -c | sort -rn | head -30这一步能帮你确认base、prompt模板、工具 schema 这些公共件被哪些文件引用。实测下来,把依赖图先画在纸上,后面追调用链会快很多。前置准备做到这里就够了:环境能 import、两套 requirements 分清楚、两个 Agent 职责明确。
3. 可复制配置:MCP 工具声明与 Agent 实例化
MAI-UI 把 MCP 工具的能力以“额外动作”的形式注入 prompt,让模型在合适的时候直接调工具,而不是去点屏幕。关键实现是:实例化 Agent 时把工具清单(JSON-Schema 风格)通过mcp_tools=[...]传进去,Agent 用 Jinja2 把清单渲染进 system prompt 的## MCP Tools区块。
先写一份可复制的工具声明配置,存成mcp_tools.json:
{ "mcp_tools": [ { "name": "amap_route", "description": "查询两点之间的路线与耗时,支持驾车、公交、步行", "parameters": { "type": "object", "properties": { "from": {"type": "string", "description": "起点名称或坐标"}, "to": {"type": "string", "description": "终点名称或坐标"}, "mode": {"type": "string", "enum": ["driving", "transit", "walking"]}, "constraints": { "type": "object", "properties": { "radius_km": {"type": "number"}, "max_duration_min": {"type": "number"} } } }, "required": ["from", "to", "mode"] } }, { "name": "github_commits", "description": "查询指定仓库的提交历史", "parameters": { "type": "object", "properties": { "repo": {"type": "string"}, "limit": {"type": "integer", "default": 10} }, "required": ["repo"] } } ] }然后实例化导航 Agent,把这份清单喂进去:
import json, sys, os sys.path.insert(0, os.path.abspath("src")) from mai_navigation_agent import MAIUINavigationAgent with open("mcp_tools.json", "r", encoding="utf-8") as f: tools = json.load(f)["mcp_tools"] agent = MAIUINavigationAgent( model="your-model-id", mcp_tools=tools, history_n=3, )这里有三件套必须对齐:Base URL、Key、Model ID。如果你走的是兼容 OpenAI 协议的接入方式,配置大致如下(把占位符换成你自己的):
client = OpenAI( base_url="https://taotoken.net/api", api_key=os.environ["TAOTOKEN_API_KEY"], )注意:
mcp_tools列表只在 system prompt 里渲染一次,由 Jinja2 的{% if tools %}控制,不会每步重发。所以工具描述写清楚一点,对模型判断走 GUI 还是走 MCP 很关键。
配置阶段还要确认_build_messages()的行为:每一步会把最近history_n张截图重新 base64 后塞回 messages。如果某一步没有新截图、而是带了mcp_response,它只追加一段纯文本 user 消息,不追加图片,这一步视觉 token 增量为 0。理解这点,你才能解释后面为什么 MCP 能把 token 压下来。
4. 验证请求:一次完整 MCP 往返的调用链检查
配置好之后,最该做的是验证调用链是否真的跑通。MAI-UI 的一次完整 MCP 往返有三个关键步骤:模型吐tool_call→ 外层调真实 MCP → 结果作为纯文本mcp_response回灌下一步predict。Agent 本身不执行网络调用,只负责传话。
先构造一个最小任务,观察模型是否输出 MCP 调用:
task = "帮我查一下从阿里云谷到招商银行的公交路线,总时长控制在2小时内" result = agent.predict(task) print(result)如果链路正常,你会看到类似这样的输出:
<tool_call>{"name":"amap_route","arguments":{"from":"阿里云谷","to":"招商银行","mode":"transit","constraints":{"max_duration_min":120}}}</tool_call>拿到这个tool_call后,外层客户端负责真正去调 MCP 服务。项目本身不内嵌 MCP 客户端、不内嵌网络层,所以你要自己接通:
import re, json, requests def dispatch(tool_call_text, mcp_endpoint): m = re.search(r"<tool_call>(.*?)</tool_call>", tool_call_text, re.S) payload = json.loads(m.group(1)) name, args = payload["name"], payload["arguments"] resp = requests.post(mcp_endpoint, json={"tool": name, "arguments": args}, timeout=10) return resp.text # 作为 mcp_response 回灌回灌时把结果塞进下一步的obs["mcp_response"],Agent 在_build_messages时把它作为新的 user 消息追加。检查链路是否跑通,重点看三处:
一是模型对 GUI 动作和 MCP 调用是否用同一种输出语法,都是<tool_call>{name,arguments}</tool_call>,客户端通过name是否在mcp_tools里来分流;二是回灌后 messages 里是否只多了文本、没多图片;三是下一步模型是否基于mcp_response继续决策,而不是又去点屏幕。
用一段对比能直观看出压缩效果。纯 GUI 路径下,查公交路线要点 5-10 屏,每步都带一张截图,messages 体积从 step 0 的约 3k token 一路涨到 step 3 的约 10k token。换成 GUI+MCP 后,step 1 直接tool_call: amap_route(...),step 2 回灌 JSON 文本,只有真正需要操作笔记 App 时才重新截图。一个“规划路线并写进笔记”的任务,纯 GUI 约 19 步、约 190k vision token,GUI+MCP 约 7 步、约 51k token,步数降约 63%,视觉 token 降约 73%。
验证时建议打印每步的 token 估算和消息类型,确认 MCP 步确实没带图:
for i, step in enumerate(agent.trajectory): has_img = step.get("screenshot") is not None has_mcp = step.get("mcp_response") is not None print(f"step {i}: img={has_img}, mcp={has_mcp}")5. 本篇常见错排查:401、local proxy failed 与 reading choices
读代码和跑链路时,报错基本集中在几类。下面按真实报错对照排查。
401 Unauthorized:多半是 Key 没读到或 Base URL 写错。先确认环境变量:
echo $TAOTOKEN_API_KEY如果为空,说明没导出;如果非空但仍 401,检查base_url是否写成了带路径的完整地址。Key 和 Base URL 必须成对匹配,换一个就要同步换另一个。
local proxy failed/ 连接被拒:这类通常是本地网络层或端口没起。MAI-UI 本身不内嵌网络层,MCP 调用完全在仓库之外,所以你要确认自己接的 MCP 服务端是否在监听、端口是否对得上。用curl直接打一下端点,排除是 Agent 侧还是服务侧的问题:
curl -X POST http://127.0.0.1:8000/mcp -H "Content-Type: application/json" -d '{"tool":"amap_route","arguments":{}}'reading 'choices'/KeyError: 'choices':这是响应结构不符合预期,常见于返回体不是标准 chat completion 格式,或者请求根本没到模型、被中间层返回了错误页。打印原始响应体再判断:
resp = client.chat.completions.with_raw_response.create(...) print(resp.text[:500])OAuth相关报错:如果接入的是需要 OAuth 的服务,token 过期会直接抛错。检查 token 有效期,重新走一次授权流程,别把过期 token 硬塞进配置。
ModuleNotFoundError: base:回到第 2 节,src/没插进sys.path。这是扁平导入的必然结果,不是代码 bug。
tool_call解析失败:模型输出的 JSON 不合法,或arguments里带了注释。用json.loads前先做一次清洗,把 markdown 代码围栏去掉。另外确认mcp_tools的 schema 是合法 JSON-Schema,字段名拼错会导致模型生成时对不上。
排查顺序建议:先看 Key/Base URL,再看网络端点,最后看响应结构。大部分“链路没跑通”其实是配置问题,不是 Agent 逻辑问题。
6. 继续深入:把调用链读成一张可复用的图
走到这里,你应该能自己定位关键接口了。把整条链路收束成一句话:模型用统一的<tool_call>语法表达 GUI 动作与 MCP 调用,客户端按name是否在mcp_tools里分流,MCP 的真实执行在仓库之外,结果以纯文本mcp_response回灌,_build_messages决定这一步是加图还是加文本。
想继续往下读,建议按这个顺序:先看mai_grounding_agent.py的坐标归一化(SCALE_FACTOR=999),再看mai_navigation_agent.py的_build_messages和ask_user分支,最后看 prompt 模板里## MCP Tools区块怎么被 Jinja2 渲染。ask_user是当指令模糊时主动暂停、生成提问动作的机制,比如“把最近的文件发给他”缺收件人和文件,它会先问清楚再动,这条分支值得单独读一遍。
如果你准备长期做编码类 Agent,把 MCP 工具接进自己的流程时,记得工具 schema 一次渲染、结果文本回灌、执行放仓库外这三条原则,能省掉大量重复代码。需要对照接口细节时,可以翻接入文档;想先验证模型对工具调用的判断是否合理,用模型对话跑几个tool_call样例最直接;长期跑多步导航任务,Coding Plan 更适合持续调试。链路读通之后,剩下的就是按你的业务把 MCP 服务一个个接上。