可插拔模型家族:standard 与 custom 两条路
30 秒导读: Midscene 的主循环(01-agent-loop)只认一件抽象——
ModelAdapter。 Qwen、Doubao、Gemini、GLM、GPT、Kimi、UI-TARS、auto-glm……十几个多模态模型家族,各自 返回的坐标格式、思考开关、规划范式都不同,却都被压成同一个ModelAdapter交给上层。 本章讲清楚这层适配是怎么长的,以及它凭什么分出 standard(通用 XML 规划)和 custom (自带规划器)两条截然不同的路。
1. 这是什么(零基础也能懂)
一句话定义: ModelAdapter 是"把某个具体模型的怪癖,翻译成主循环能懂的统一接口"的适配层。
先看它要解决的麻烦。Midscene 想让你写一句 aiTap('登录按钮') 就能点到屏幕上的登录按钮,
背后是"截图 → 问模型这个按钮在哪 → 拿到坐标 → 点下去"。问题是:每个模型回话的方式都不一样。
- Qwen 直接吐像素坐标;GLM/Doubao 吐的是 0–1000 的归一化坐 标。
- Gemini 把坐标写成
[y, x]顺序,别人是[x, y]。 - Kimi 只给一个点,别人给一个框(bbox)。
- 打开"思考模式"的参数,Qwen 叫
enable_thinking、GLM 叫thinking、GPT-5 叫reasoning_effort。 - UI-TARS / auto-glm 这类模型干脆不走通用规划,自带一套专属的 prompt 和输出语法。
如果把这些差异全散落在主循环里,循环就会写满 if (model === 'qwen') ... else if ...。
Midscene 的做法是:把差异全部收进一个 ModelAdapter 对象,主循环永远只跟这个统一对象打交道。
一句话直觉/类比: 就像旅行万能插头转换器——不管墙上的插座是英标、美标还是欧标,
转换器提供一个统一的插口给你的笔记本。ModelAdapter 就是模型侧的"插头转换器",
上层循环这台"笔记本"完全不需要知道墙里是哪个国家的电。
用起来什么样: 你几乎不直接碰它。你在配置里写一个 modelFamily(如 qwen3-vl),
Midscene 就用它去查表拿到对应的适配器:
// 示意,非源码:上层只需给出模型家族名,拿回一个统一适配器
const runtime = getModelRuntime(config); // config.modelFamily === 'qwen3-vl'
runtime.adapter.planning.kind; // 'standard' —— 走通用规划
runtime.adapter.locate.resultAdapter; // 统一的坐标翻译器
本章只讲模型侧的适配与坐标约定的来源。通用循环怎么转看 01; 归一化坐标怎么变成真实屏幕像素看 02 和 04。
2. 顶层全景(它大概怎么转)
整层就三步:声明 → 注册 → 解析。作者用声明式配置描述每个模型的怪癖,
注册表按 modelFamily 把配置收在一张大表里,解析器把"配置"编译成运行时"适配器"。
怎么读下面这张图:从上到下是一次"拿适配器"的调用链,左边是家族名,右边是最终产物。
config.modelFamily = 'gemini'
│
▼
getModelRuntime(config) registry.ts:75
│ 取 config.modelFamily
▼
getModelAdapter('gemini') registry.ts:52
│ ①先查缓存 Map,命中直接返回
│ ②未命中:去大表取声明式配置
▼
MODEL_ADAPTER_CONFIGS['gemini'] registry.ts:20 (缺省 → default.ts)
│ = { chatCompletion, locate:{resultAdapter:{order:'yx'...}} }
▼
new ResolvedModelAdapter(config, family) resolve.ts:37
│ 把"定义(Definition)"编译成"运行时(Adapter)"
▼
ModelAdapter { jsonParser, chatCompletion, types.ts:202
imagePreprocess, planning, locate }
│
▼
交给主循环 aiAct(见 01)
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
ModelAdapterDefinition | 作者手写的声明式配置(可全省,省了就吃默认) | model-adapter/types.ts:215 |
MODEL_ADAPTER_CONFIGS | 家族名 → 定义 的注册大表,把各家族文件拼在一起 | models/registry.ts:20 |
getModelAdapter | 按 modelFamily 取定义、编译、带缓存返回适配器 | models/registry.ts:52 |
ResolvedModelAdapter | 把定义编译成填满默认值的运行时对象 | model-adapter/resolve.ts:37 |
ModelAdapter | 主循环真正消费的统一接口(5 个字段) | model-adapter/types.ts:202 |
3. ModelAdapter 的形状(统一接口长什么样)
主循环看到的 ModelAdapter 恰好有 5 个字段(model-adapter/types.ts:202-208):
| 字段 | 管什么 | 家族差异的例子 |
|---|---|---|
jsonParser | 把模型回的文本解析成 JSON(含容错修复) | Doubao/UI-TARS 有专门的 bbox 修复解析器 |
chatCompletion | 拼调用参数、思考开关、抽取 content/reasoning | Qwen enable_thinking vs GPT-5 reasoning_effort |
imagePreprocess | 送图前的预处理策略(如按块补边) | qwen2.5-vl padBlockSize: 28 |
planning | 规划范式:standard 还是 custom | Qwen=standard;UI-TARS/auto-glm=custom |
locate | 定位与坐标约定:standard(带 resultAdapter)或 custom | 各家 shape/order/normalizedBy 不同 |
其中 planning 和 locate 是本章的主角——它们各自是一个可辨识联合(tagged union),
用 kind: 'standard' | 'custom' 区分两条路(types.ts:122-130、types.ts:163-173)。
3.1 声明式定义 → 运行时适配器
作者写的是 ...Definition(字段几乎全 Partial,能省则省);解析器负责填默认值。
以 ResolvedModelAdapter 构造函数为例(resolve.ts:44-55):它逐字段调用
resolveJsonParser / resolveChatCompletion / resolveImagePreprocess / resolvePlanning / resolveLocate,
把稀疏的定义补成完整对象。
一个关键细节:custom planning 与 custom locate 可能共享同一个"规划器定义"。
构造函数先把 config.planning.planner 解析成 resolvedCustomPlanner,再同时喂给
resolvePlanning 和 resolveLocate(resolve.ts:48-54)——auto-glm 就是靠这个让"定位"复用
"规划器"里的 prompt(见 §5.2)。
4. 注册与解析:按 modelFamily 取适配器
4.1 注册大表 MODEL_ADAPTER_CONFIGS
每个家族一个文件,导出一个 {家族名: 定义} 小对象;registry.ts 用展开语法把它们拼成一张大表
(models/registry.ts:20-30):
// 真实源码结构,models/registry.ts:20
export const MODEL_ADAPTER_CONFIGS = {
...qwenAdapters, ...doubaoAdapters, ...geminiAdapters,
...uiTarsAdapters, ...glmAdapters, ...autoGlmAdapters,
...gptAdapters, ...kimiAdapters, ...mimoAdapters,
} satisfies Record<TModelFamily, ModelAdapterDefinition>;
satisfies Record<TModelFamily, ...> 是道编译期护栏:TModelFamily 是一个封闭的联合类型
(packages/shared/src/env/types.ts:287-304,列了 qwen2.5-vl、gemini、vlm-ui-tars、auto-glm、
gpt-5、kimi、xiaomi-mimo 等 17 个成员),漏注册某个家族会直接编译报错。
4.2 getModelAdapter:取、编译、缓存
getModelAdapter(modelFamily)(models/registry.ts:52-73)的逻辑:
输入 modelFamily(可空)
│
├─ 空? → cacheKey = 'default'
│
├─ 查缓存 modelAdapterCache.get(cacheKey) —— 命中即返回(适配器无状态,可安全复用)
│
├─ 未命中:取配置
│ modelFamily 有值 → MODEL_ADAPTER_CONFIGS[modelFamily]
│ modelFamily 为空 → defaultOpenAICompatibleAdapterConfig ← 缺省兜底
│
├─ 配置不存在 → 抛错 "No model adapter registered for modelFamily: X"
│
└─ new ResolvedModelAdapter(config, cacheKey) → 存缓存 → 返回
两个值得记的点:
- 缺省兜底。 没给
modelFamily(或走空路径)时,落到default.ts的defaultOpenAICompatibleAdapterConfig——一个几乎全空的 OpenAI 兼容配置,planning/locate都不写,于是解析后两者都是 standard(见 §5)。这让任何 OpenAI 兼容的通用 VLM 不必注册就能用。 - 抛错而非静默。 家族名拼错、表里没有,直接抛
Error(registry.ts:62-66),符合仓库 "出错就抛、不返空值"的设计取向。
5. 两条路:standard 与 custom
这是全章的核心。planning 和 locate 各带一个 kind,standard 是"共用大厨房",custom 是"自带私厨"。
先看主循环怎么用这个 kind 分叉。规划时它只做一个三目选择(agent/tasks.ts:577-580):
// 真实源码,agent/tasks.ts:577
const planImpl =
planningModel.adapter.planning.kind === 'custom'
? planningModel.adapter.planning.planFn // custom:用适配器自带的规划函数
: genericXmlPlan; // standard:用通用 XML 规划
也就是说:主循环对两条路唯一的分支就在这里——选哪个 planImpl。选完之后,后续
"执行—重规划"的骨架完全一致(01)。genericXmlPlan 只是通用规划
plan 的别名(workflows/planning/index.ts:4) 。
5.1 两条路的对比
| 维度 | standard | custom |
|---|---|---|
| 规划入口 | genericXmlPlan(通用 XML 规划) | 适配器自带的 planFn |
| 定位方式 | resultAdapter 声明式坐标翻译 | locateFn 或 planningTapLocator |
| 输出解析 | 通用 JSON / bbox | 专属 parser(自定义语法) |
支持 deepThink | 是 | 否(自动降级,见 §6) |
supportsActionDeepLocate | 默认 true(planning.ts:64) | 默认 false(planning.ts:38,52) |
supportsSearchArea | 默认 true(locate.ts:47) | 默认 false(locate.ts:40) |
| 典型家族 | Qwen / Doubao / Gemini / GLM / GPT-5 / Kimi | UI-TARS / auto-glm |
一句话总结差异:standard 的模型"配合"Midscene 的通用规划语法;custom 的模型有自己的一套 "语言",Midscene 反过来去"迁就"它。
5.2 custom 路:planFn + 专属解析
custom 的关键是一个 生命周期定义 CustomPlanningDefinition(custom-planning-types.ts:31)。
作者不写整个规划循环,只填几个钩子,由 runCustomPlanning(workflows/planning/custom-planning.ts)
按固定次序调用(custom-planning.ts:134-145):
runCustomPlanning
│
├─ messages.buildSystemPrompt() → 专属 system prompt
├─ (调模型,拿 rawResponse)
├─ parseResponse(raw, input) → 解析成该模型的私有结构 TParsed
├─ transformActions(parsed) → 翻成 Midscene 通用的 PlanningAction[]
└─ shouldContinuePlanning(parsed) → 判断是否还要再规划一轮
resolvePlanning(planning.ts:31-57)把这套定义包装成统一的 planFn:要么作者直接给
planFn,要么给 planner 定义、由 runCustomPlanning 闭包成 planFn。两个真实例子:
-
UI-TARS(
models/ui-tars/adapter.ts:createUiTarsAdapter):planning.kind = 'custom',planner = createUiTarsPlanner(version)。它的 prompt 放进 user-message(不是 system), 规划坐标是point / xy / normalizedBy:1(ui-tars/planning.ts:22),shouldContinuePlanning盯的是动作里有没有Finished。注意 UI-TARS 规划是 custom,但定位仍是 standard——它给locate.resultAdapter(ui-tars/adapter.ts末尾,bbox / xy / 1000),两条路可以一 custom 一 standard。 -
auto-glm(
models/auto-glm/adapter.ts:createAutoGlmAdapter):两条路都 custom。规划用createAutoGlmPlanner,输出被parseAutoGLMPlanningResponse解析成<think>…</think><answer>…</answer>结构(auto-glm/planning.ts);定位则用locate.kind = 'custom'+planningTapLocator(auto-glm/adapter.ts:42)。这里就用到了 §3.1 说的"共享规划器"——resolveLocate在没有locateFn时,拿planningTapLocator和resolvedCustomPlanner一起resolvePlanningTapLocator拼出locateFn(locate.ts:18-36),所以定位能复用规划器的 prompt 体系。
5.3 standard 路:通用 XML 规划 + 声明式坐标
standard 家族的 planning 往往整个不写,解析后就是一个空壳 { kind:'standard', cacheEnabled:true, ... }
(planning.ts:59-65);真正有内容的是 locate.resultAdapter。作者只用一个声明式对象描述坐标约定,
不写任何解析代码——这就引出下一节。
6. 坐标为什么被抽成 resultAdapter
要解决的小问题: 每个模型返回坐标的"方言"都不同,但主循环只想要一个统一的
像素框 [left, top, right, bottom]。
思路: 不让主循环去认方言。把"方言 → 统一像素框"这件事,声明成一小段配置 coordinates,
统一交给 02 的坐标翻译器去执行。三个正交维度足以描述绝大多数模型
(model-locate-result/types.ts:65-70):
| 维度 | 取值 | 含义 |
|---|---|---|
shape | 'bbox' | 'point' | 模型给的是框还是点(点会补成小框) |
order | 'xy' | 'yx' | 坐标对的顺序(Gemini 是先 y 后 x) |
normalizedBy | 数字 | 省略 | 归一化基数:1000=坐标是 0–1000 需缩放;省略=已是像素 |
各家族的坐标约定(全部来自各自适配器文件的 resultAdapter.coordinates):
| 家族 | shape | order | normalizedBy | 出处 |
|---|---|---|---|---|
qwen2.5-vl | bbox | xy | 省略(像素) | models/qwen.ts |
qwen3-vl / qwen3 / 3.5 / 3.6 | bbox | xy | 1000 | models/qwen.ts:qwen3Adapter |
doubao-vision / doubao-seed | bbox | xy | 1000 | models/doubao.ts:doubaoVisionAdapter |
gemini | bbox | yx | 1000 | models/gemini.ts:geminiAdapters |
glm-v | bbox | xy | 1000 | models/glm.ts:glmAdapters |
gpt-5 | bbox | xy | 省略(像素) | models/gpt.ts:gptAdapters |
kimi | point | xy | 1 | models/kimi.ts:kimiAdapters |
vlm-ui-tars* | bbox | xy | 1000 | models/ui-tars/adapter.ts |
auto-glm*(规划坐标) | point | xy | 1000 | models/auto-glm/planning.ts:26 |
为什么值得抽象: 看这张表就懂了——差异是数据,不是逻辑。九个家族用同一个坐标翻译器,
只是喂进去三个不同的参数。主循环、locate 工作流一行都不用改。归一化 → 像素的实际换算
(乘以截图尺寸、除以 normalizedBy、按 order 排列)不在本章,见 02;
像素框再落到真实设备坐标见 04。
留给"方言"的逃生口: 少数模型光靠三个维度不够。声明里还能挂两个可选钩子
(model-locate-result/types.ts 的 StandardLocateResultAdapterDefinition):
parseRawLocateValue——修复/兜底原始值。Doubao 会把123 456这种空格分隔硬修成123,456再解析(doubao.ts:preprocessDoubaoLocateJson+parseDoubaoRawLocateValue);qwen2.5-vl 用parseQwen25RawLocateValue判断返回的是框还是点。mapLocateResultToPixelBbox——自定义"点补框"的尺寸等。qwen2.5-vl 用normalizeQwen25ResultToPixelBbox把点补成 20px 的小框(qwen.ts:topLeftPointToPixelBbox)。
关键设计取向:能用声明式 coordinates 就别写函数;函数只留给真正模型特有的修复/映射
(types.ts 里 StandardLocateResultAdapterDefinition 的注释明确了这条纪律)。
7. custom 不支持 deepThink:一次诚实的降级
deepThink(二次精定位)和 deepLocate 依赖 standard 那套"先定位参照元素、再缩小搜索区"的
两段式流程,custom 家族给不出。Midscene 不硬撑,而是在运行时检测并降级 + 打警告
(agent/agent.ts:897-914):
// 真实源码,agent/agent.ts:897
let deepThink = opt?.deepThink === true;
if (deepThink && planningModel.adapter.planning.kind === 'custom') {
warn(`The "deepThink" option is not supported for aiAct with custom
planning adapters (modelFamily: ...). It will be ignored.`);
deepThink = false; // 静默降级,不报错、不中断
}
deepLocate 同理:检测 supportsActionDeepLocate(custom 默认 false),不支持就降级
(agent.ts:905-914)。这两处的判据正是 §5.1 表里那两个 supports* 布尔——它们的默认值
(standard=true / custom=false)在 resolvePlanning、resolveLocate 里给定,把"能力差异"变成了
可查询的开关,而不是散落各处的 if 家族名。
types.ts:107-120 的注释把原因说透了:custom 规划模型可能只返回动作坐标、不 返回目标元素描述,
这类结果能当"直接命中"用,但驱动不了 deepLocate 的第二段定位——因为那一段还需要一句描述目标
元素的 query prompt。
8. 边界与局限(诚实)
- 家族名是封闭集合。 只有
TModelFamily(shared/env/types.ts:287)列出的 17 个名字被认; 没在表里、又不走空缺省,getModelAdapter直接抛错。想接新模型 = 加一个家族文件 + 进MODEL_ADAPTER_CONFIGS+ 进TModelFamily。 - custom 家族功能受限。 天然不支持
deepThink;deepLocate/搜索区默认关。要更精的定位, 得该家族自己实现,或退回 standard 模型。 - 坐标声明覆盖不了的,只能写函数。 三维度
coordinates覆盖主流,遇到怪异响应结构仍需parseRawLocateValue/mapLocateResultToPixelBbox手写(见 Doubao/qwen2.5-vl)。 - UI-TARS 有意"冻结"。 其 JSON 修复逻辑和 Doubao 目前完全一样,却被刻意复制成独立一份
(
ui-tars/adapter.ts注释),为的是不让 UI-TARS 的行为被未来 Doubao 的改动牵连——这是维护取向, 不是技术必需。
9. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 统一接口(5 字段) | packages/core/src/ai-model/model-adapter/types.ts | ModelAdapter |
| planning 联合(standard/custom) | packages/core/src/ai-model/model-adapter/types.ts | PlanningAdapter |
| locate 联合(standard/custom) | packages/core/src/ai-model/model-adapter/types.ts | LocateAdapter |
| 注册大表 | packages/core/src/ai-model/models/registry.ts | MODEL_ADAPTER_CONFIGS |
| 取/编译/缓存适配器 | packages/core/src/ai-model/models/registry.ts | getModelAdapter |
| 定义 → 运行时编译 | packages/core/src/ai-model/model-adapter/resolve.ts | ResolvedModelAdapter |
| planning 解析 + 默认值 | packages/core/src/ai-model/model-adapter/planning.ts | resolvePlanning |
| locate 解析 + 默认值 | packages/core/src/ai-model/model-adapter/locate.ts | resolveLocate |
| custom 规划生命周期 | packages/core/src/ai-model/model-adapter/custom-planning-types.ts | CustomPlanningDefinition |
| 主循环选 planFn | packages/core/src/agent/tasks.ts | planImpl 三目(:577) |
| 通用 XML 规划别名 | packages/core/src/ai-model/workflows/planning/index.ts | genericXmlPlan |
| deepThink/deepLocate 降级 | packages/core/src/agent/agent.ts | runAiAct(:897) |
| 坐标三维度声明 | packages/core/src/ai-model/shared/model-locate-result/types.ts | LocateResultCoordinates |
| 缺省 OpenAI 兼容配置 | packages/core/src/ai-model/models/default.ts | defaultOpenAICompatibleAdapterConfig |
| 家族名封闭集合 | packages/shared/src/env/types.ts | TModelFamily |
| standard 例:Qwen | packages/core/src/ai-model/models/qwen.ts | qwenAdapters |
| custom 例:UI-TARS(规划) | packages/core/src/ai-model/models/ui-tars/adapter.ts | createUiTarsAdapter |
| custom 例:auto-glm(规划+定位) | packages/core/src/ai-model/models/auto-glm/adapter.ts | createAutoGlmAdapter |
相邻章节: 主循环 01-agent-loop · 纯视觉定位 02-vision-grounding · 设备与动作落地 04-device-and-action-space · 对外形态 05-surfaces-cli-mcp-rdp · 全景导读 index