跳到主要内容

可插拔模型家族: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; 归一化坐标怎么变成真实屏幕像素看 0204


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
getModelAdaptermodelFamily 取定义、编译、带缓存返回适配器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/reasoningQwen enable_thinking vs GPT-5 reasoning_effort
imagePreprocess送图前的预处理策略(如按块补边)qwen2.5-vl padBlockSize: 28
planning规划范式:standard 还是 customQwen=standard;UI-TARS/auto-glm=custom
locate定位与坐标约定:standard(带 resultAdapter)或 custom各家 shape/order/normalizedBy 不同

其中 planninglocate 是本章的主角——它们各自是一个可辨识联合(tagged union), 用 kind: 'standard' | 'custom' 区分两条路(types.ts:122-130types.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,再同时喂给 resolvePlanningresolveLocate(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-vlgeminivlm-ui-tarsauto-glmgpt-5kimixiaomi-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.tsdefaultOpenAICompatibleAdapterConfig——一个几乎全空的 OpenAI 兼容配置,planning/locate 都不写,于是解析后两者都是 standard(见 §5)。这让任何 OpenAI 兼容的通用 VLM 不必注册就能用。
  • 抛错而非静默。 家族名拼错、表里没有,直接抛 Error(registry.ts:62-66),符合仓库 "出错就抛、不返空值"的设计取向。

5. 两条路:standard 与 custom

这是全章的核心。planninglocate 各带一个 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 两条路的对比

维度standardcustom
规划入口genericXmlPlan(通用 XML 规划)适配器自带的 planFn
定位方式resultAdapter 声明式坐标翻译locateFnplanningTapLocator
输出解析通用 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 / KimiUI-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 时,拿 planningTapLocatorresolvedCustomPlanner 一起 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):

家族shapeordernormalizedBy出处
qwen2.5-vlbboxxy省略(像素)models/qwen.ts
qwen3-vl / qwen3 / 3.5 / 3.6bboxxy1000models/qwen.ts:qwen3Adapter
doubao-vision / doubao-seedbboxxy1000models/doubao.ts:doubaoVisionAdapter
geminibboxyx1000models/gemini.ts:geminiAdapters
glm-vbboxxy1000models/glm.ts:glmAdapters
gpt-5bboxxy省略(像素)models/gpt.ts:gptAdapters
kimipointxy1models/kimi.ts:kimiAdapters
vlm-ui-tars*bboxxy1000models/ui-tars/adapter.ts
auto-glm*(规划坐标)pointxy1000models/auto-glm/planning.ts:26

为什么值得抽象: 看这张表就懂了——差异是数据,不是逻辑。九个家族用同一个坐标翻译器, 只是喂进去三个不同的参数。主循环、locate 工作流一行都不用改。归一化 → 像素的实际换算 (乘以截图尺寸、除以 normalizedBy、按 order 排列)不在本章,见 02; 像素框再落到真实设备坐标见 04

留给"方言"的逃生口: 少数模型光靠三个维度不够。声明里还能挂两个可选钩子 (model-locate-result/types.tsStandardLocateResultAdapterDefinition):

  • 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.tsStandardLocateResultAdapterDefinition 的注释明确了这条纪律)。


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)在 resolvePlanningresolveLocate 里给定,把"能力差异"变成了 可查询的开关,而不是散落各处的 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.tsModelAdapter
planning 联合(standard/custom)packages/core/src/ai-model/model-adapter/types.tsPlanningAdapter
locate 联合(standard/custom)packages/core/src/ai-model/model-adapter/types.tsLocateAdapter
注册大表packages/core/src/ai-model/models/registry.tsMODEL_ADAPTER_CONFIGS
取/编译/缓存适配器packages/core/src/ai-model/models/registry.tsgetModelAdapter
定义 → 运行时编译packages/core/src/ai-model/model-adapter/resolve.tsResolvedModelAdapter
planning 解析 + 默认值packages/core/src/ai-model/model-adapter/planning.tsresolvePlanning
locate 解析 + 默认值packages/core/src/ai-model/model-adapter/locate.tsresolveLocate
custom 规划生命周期packages/core/src/ai-model/model-adapter/custom-planning-types.tsCustomPlanningDefinition
主循环选 planFnpackages/core/src/agent/tasks.tsplanImpl 三目(:577)
通用 XML 规划别名packages/core/src/ai-model/workflows/planning/index.tsgenericXmlPlan
deepThink/deepLocate 降级packages/core/src/agent/agent.tsrunAiAct(:897)
坐标三维度声明packages/core/src/ai-model/shared/model-locate-result/types.tsLocateResultCoordinates
缺省 OpenAI 兼容配置packages/core/src/ai-model/models/default.tsdefaultOpenAICompatibleAdapterConfig
家族名封闭集合packages/shared/src/env/types.tsTModelFamily
standard 例:Qwenpackages/core/src/ai-model/models/qwen.tsqwenAdapters
custom 例:UI-TARS(规划)packages/core/src/ai-model/models/ui-tars/adapter.tscreateUiTarsAdapter
custom 例:auto-glm(规划+定位)packages/core/src/ai-model/models/auto-glm/adapter.tscreateAutoGlmAdapter

相邻章节: 主循环 01-agent-loop · 纯视觉定位 02-vision-grounding · 设备与动作落地 04-device-and-action-space · 对外形态 05-surfaces-cli-mcp-rdp · 全景导读 index