跳到主要内容

UI-TARS Desktop — 架构与原理

30 秒导读: UI-TARS Desktop 是字节开源的「原生 GUI agent」桌面应用——你打一句话("帮我打开 VS Code 的自动保存,把延迟设成 500ms"),它就反复地截屏 → 让一个视觉语言模型(VLM)看屏并决定下一步 → 在真机上点鼠标/敲键盘,直到任务完成。它和多数 computer-use 系统最大的不同是单模型:看屏、推理、给出点击坐标全由一个 UI-TARS 模型端到端完成,不需要另一个独立的定位模型。整套控制循环在一个可复用的 SDK 里(@ui-tars/sdk),Electron 应用只是它的一个宿主。


1. 这是什么(零基础也能懂)

一句话定义: UI-TARS Desktop 是一个能自己操作你电脑的桌面应用——给它一句自然语言指令,它就像人一样看着屏幕、动鼠标键盘,把 GUI 里的任务做完。

解决什么问题 / 给谁用:

很多任务没有 API,只能在图形界面里点点点。比如:

"帮我打开 VS Code 设置,开启 autosave,并把 AutoSave 延迟改成 500 毫秒。"

这件事全发生在界面上:要打开设置、搜索选项、点开关、填数字。UI-TARS Desktop 把「看屏幕 → 想 → 操作」这个人类循环自动化,面向想要桌面自动化 / GUI agent 的开发者和研究者,支持 Windows / macOS,以及浏览器和远程机器。

它能做什么:

  • 用自然语言控制本地电脑(鼠标、键盘、热键、拖拽、滚动)。
  • 控制本地浏览器(基于 Puppeteer),或字节托管的远程电脑 / 远程浏览器(免配置、免费)。
  • 全程纯视觉:只靠截图,不读 DOM、不接辅助功能树(浏览器模式会额外做高亮增强)。
  • 把控制循环封装成 SDK,任何 Node 程序都能 new GUIAgent(...).run(指令)

用起来什么样:

SDK 的最小用法(概念示意,真实签名见 packages/ui-tars/sdk/src/GUIAgent.ts:66):

// 示意,非源码:把「模型 + 操作器」装进 agent,然后跑一句指令
const agent = new GUIAgent({
model: { baseURL, apiKey, model: 'ui-tars' }, // 一个 UI-TARS VLM 端点
operator: new NutJSOperator(), // 决定动作落到哪(本机/浏览器/远程)
onData: ({ data }) => render(data), // 每一步的截图+思考+动作流式回调
});
await agent.run('Open VS Code settings and enable autosave');

一句话直觉/类比: 把它想成一个只用眼睛和手工作的实习生。你不给它任何"这个按钮的 id 是什么"的提示,它就盯着屏幕截图,自己说"我要点左上角那个蓝色保存图标 → 它大概在屏幕 (0.12, 0.08) 这个位置",系统再把这个比例坐标换算成真实像素替它按下去。所有智能都在那一个模型里。


2. 顶层全景(它大概怎么转)

整个系统是一个感知-行动循环(perceive-act loop):每一轮都从一张新截图开始,到模型给出一个动作、动作执行完为止;然后再截图,进入下一轮。

怎么读下面这张图: 从上到下是一轮循环的数据流;右边标注了每一步落在哪个真实文件。循环体在 GUIAgent.runwhile (true) 里(packages/ui-tars/sdk/src/GUIAgent.ts:130)。

主要部件一句话职责:

部件干什么在哪个文件
GUIAgent主循环大脑:截图→调模型→执行动作→再截图,管暂停/停止/重试/错误packages/ui-tars/sdk/src/GUIAgent.ts
UITarsModel封装对 VLM 的调用(OpenAI 兼容),压缩图片、拼消息、调完再交给解析器packages/ui-tars/sdk/src/Model.ts
actionParser把模型吐出的 click(start_box='(279,81)') 文本解析成结构化动作 + 真实像素坐标packages/ui-tars/action-parser/src/actionParser.ts
Operator(抽象)定义 screenshot()execute() 两个方法;换实现=换控制目标packages/ui-tars/sdk/src/types.ts:66
NutJSOperator本地电脑操作器:用 nut-js 真的移动鼠标/敲键盘packages/ui-tars/operators/nut-js/src/index.ts
BrowserOperator浏览器操作器:用 Puppeteer 的 page.mouse / page.keyboardpackages/ui-tars/operators/browser-operator/src/browser-operator.ts
Electron 应用SDK 的宿主:选操作器、装配 agent、把每步数据渲染到 UIapps/ui-tars/src/main/services/runAgent.ts

主线走一遍(高层,不进代码):

  1. Electron 端按用户选的模式(本地电脑 / 本地浏览器 / 远程)new 一个对应的 Operator,再装配成 GUIAgent(apps/ui-tars/src/main/services/runAgent.ts:128)。
  2. GUIAgent.run 进入 while (true):先 operator.screenshot() 拿一张 base64 截图(GUIAgent.ts:185)。
  3. 把「系统提示 + 历史 + 最近 ≤5 张截图」拼成 OpenAI 消息,model.invoke() 调 VLM(GUIAgent.ts:270)。
  4. 模型返回一段文本,如 Thought: ...\nAction: click(start_box='(279,81)')actionParser 把它解析成动作类型 + 真实像素坐标
  5. 对每个解析出的动作,operator.execute() 在真机上落地(GUIAgent.ts:385);若动作是 finished() / call_user() 就跳出循环,否则回到第 2 步。

一个关键认知:UI-TARS 是「单模型」路线。 与 Agent S 那种「大脑模型 + 独立定位模型」的两段式不同,UI-TARS 把看屏、推理、给坐标全塞进同一个 VLM——模型直接在输出里写出 start_box='(279,81)'。SDK 完全不做视觉理解,只做坐标反缩放(把模型空间的数字换算成屏幕像素)和一堆工程胶水。这是理解整个项目的第一主线:智能全在模型里,SDK 是一台可靠的「截图-执行」机器。


3. 核心机制

下面钻三个最能体现"精华"的子系统:主循环的工程细节、坐标反缩放(视觉定位的落地)、以及为省 token 而做的图像滑动窗口。

3.1 感知-行动主循环:一个把 VLM 变成可靠 agent 的状态机

它要解决的小问题: VLM 只会"看一张图、说一句话"。要把它变成能连续操作几十步、还能暂停/停止/容错的 agent,需要一层循环把「截图—推理—执行」编排起来,并处理各种边界。

思路: 用一个无限 while 循环,每轮从新截图开始;循环顶部集中检查所有退出/暂停条件,循环体只管"截图→调模型→执行"。这样状态管理和主流程解耦。

循环顶部的守卫依次检查(packages/ui-tars/sdk/src/GUIAgent.ts:130-180):

检查触发的结果
isPaused 且有 resumePromise挂起,await 直到 resume()
isStopped / signal?.aborted / 状态非 RUNNING/PAUSE跳出循环(用户停止)
loopCnt >= maxLoopCountREACH_MAXLOOP_ERROR(默认上限见下)
snapshotErrCnt >= MAX_SNAPSHOT_ERR_CNTSCREENSHOT_RETRY_ERROR(连续 10 次截图失败)

真实实现的几处巧思:

  • 截图必须是有效图片才算一轮。 拿到 base64 后用 Jimp.fromBuffer 解出宽高,若解不出(isValidImage 为 false)就loopCnt 减回去snapshotErrCnt++、睡 1 秒重来——坏截图不消耗宝贵的循环预算(GUIAgent.ts:191-209)。
  • 每一步都流式回调。 截图、模型思考、动作都通过 onData 只推送最后一条对话(conversations.slice(-1)),让 UI 能实时显示"它现在在想什么、要点哪"(GUIAgent.ts:344-349)。
  • 三类重试各自独立配置。 截图、模型调用、动作执行都包在 async-retry 里,重试次数分别可配(retry.screenshot / retry.model / retry.execute,GUIAgent.ts:185267383)。模型调用遇到 abort 会 bail 立刻放弃、不重试。
  • 动作里的"终止信号"必须在执行后才跳出。 finished / call_user 这类内部动作,是先执行 operator、再 break(GUIAgent.ts:414-421),保证收尾动作也真的落地。

关键常量(都在 packages/ui-tars/shared/src/constants/vlm.ts):

常量含义
MAX_LOOP_COUNT100硬上限循环步数(vlm.ts:6);Electron 侧默认传 settings.maxLoopCount
MAX_SNAPSHOT_ERR_CNT10连续截图失败上限(sdk/src/constants.ts:9)
MAX_IMAGE_LENGTH5送给模型的截图窗口大小(见 3.3)

3.2 视觉定位的落地:坐标反缩放,而不是"再问一个模型"

它要解决的小问题: 模型说"点 (279, 81)",但这串数字不是屏幕像素——它是模型训练空间里的归一化坐标。要真的点对地方,得把它换算回当前屏幕的物理像素。

思路: UI-TARS 输出的坐标是相对 factors(默认 [1000, 1000],sdk/src/constants.ts:10)的整数,即"把屏幕宽高各当成 1000 格,目标在第几格"。换算就是:先除以 factor 归一化到 0~1,再乘以屏幕真实宽高,最后乘 DPR 缩放因子。

原理演示(把核心算法演出来):

// 示意,非源码:模型给的整数框 → 屏幕像素中心点
function boxToPixel(x1, y1, x2, y2, screenW, screenH, factor = 1000, dpr = 1) {
const cx = ((x1 + x2) / 2) / factor; // 归一化到 0~1(取框中心)
const cy = ((y1 + y2) / 2) / factor;
return {
x: Math.round(cx * screenW) * dpr, // 乘真实宽 + 设备像素比
y: Math.round(cy * screenH) * dpr,
};
}

真实实现: 这段逻辑分两处:

  • 解析阶段在 parseActionVlm 里,对参数名含 start_box / end_box 的先除以 factors[factorIndex] 归一化,再算出中心点像素写进 start_coords / end_coords(packages/ui-tars/action-parser/src/actionParser.ts:191-240)。
  • 执行阶段操作器再调一次工具函数 parseBoxToScreenCoords(packages/ui-tars/sdk/src/utils.ts:29),本地操作器 NutJSOperator.execute 用它算出 (startX, startY)mouse.move(...).click()(packages/ui-tars/operators/nut-js/src/index.ts:102)。

一个非平凡细节——V1.5 的 smart resize。 UI-TARS 1.5 的坐标不是相对固定的 1000,而是相对图片被 smart-resize 后的实际宽高。所以解析时若模型版本是 V1_5,会先跑 smartResizeForV15(把宽高各向上取整到 28 的倍数、并约束总像素在 min/max 之间),用这对"resize 后尺寸"当分母,而不是 1000(actionParser.ts:28-59202-206)。这解释了为什么代码里到处带着 IMAGE_FACTOR = 28MAX_PIXELS_V1_5 这些常量(shared/src/constants/vlm.ts:9-14)——它们必须和模型训练时的图像预处理逐像素对齐,否则点击就会系统性偏移。

同一套坐标,多种落点(Operator 抽象的价值): 因为坐标换算在 SDK 里做完了,不同操作器只需把 (x, y) 喂给各自的执行后端:

操作器点击怎么落地文件
NutJSOperatormouse.move(...) + mouse.click(Button.LEFT)operators/nut-js/src/index.ts:182-189
BrowserOperatorpage.mouse.move(x,y) + page.mouse.click(x,y)operators/browser-operator/src/browser-operator.ts:327-329

3.3 图像滑动窗口:只给模型看最近 5 张截图

它要解决的小问题: agent 跑几十步后会攒下几十张截图。全塞给 VLM 既贵(token 爆炸)又慢,还可能超上下文。但历史又不能全丢——模型需要知道"我刚才做了什么"。

思路(两条腿):

  1. 图片只留最近 N 张,老截图丢掉;
  2. 文字历史用摘要保留更长,让模型仍知道操作轨迹。

真实实现:

  • 图片滑动窗口。 processVlmParams 检查图片数是否超过 MAX_IMAGE_LENGTH(=5),超了就从头砍掉多余的图片,并同步删掉对应的 <image> 占位对话,保证图文对齐(packages/ui-tars/sdk/src/utils.ts:58-88)。
  • 文字历史窗口更长。 formatHistoryMessages 保留最近 30 条历史消息拼成 ## History Messages 段落塞进首条消息(utils.ts:228-246)——文字便宜,所以留得比图片多。
  • Response API 模式下主动删除服务端状态。 当用 OpenAI Responses API(有服务端会话)时,UITarsModel 记录"最靠前那张图片对应的 responseId";一旦图片窗口滑动、头图变了,就调 openai.responses.delete(...) 把过期的服务端响应删掉,避免服务端上下文无限膨胀(packages/ui-tars/sdk/src/Model.ts:159-186236-243)。这是很多人忽略的一步——滑动窗口不仅要管本地消息数组,还要管远端会话状态。

发送前还会压缩每张图。 invoke 里按模型版本选 maxPixels(V1.0 / V1.5 / Doubao 各不同),对每张图跑 preprocessResizeImage 等比缩放到像素上限内、并压到 quality 60 的 PNG(Model.ts:292-306utils.ts:193-226)。


4. 巧妙之处与局限

值得偷师的设计:

  • 把"智能"和"胶水"彻底分开。 所有视觉理解、规划、定位都外包给一个 VLM,SDK 只做确定性的事:截图、缩放、坐标换算、重试、窗口裁剪。这让 SDK 极稳、极小,也让"换个更强的模型"几乎零成本。
  • Operator 抽象只有两个方法。 screenshot() + execute()(packages/ui-tars/sdk/src/types.ts:66-73)就把"控制什么"完全解耦。本地电脑、浏览器、远程机器共用同一个循环和同一套坐标数学,新增一种控制目标只要实现这两个方法。
  • 动作空间即 prompt。 每个操作器用静态 MANUAL.ACTION_SPACES 声明自己支持哪些动作(operators/nut-js/src/index.ts:37),buildSystemPrompt 把它拼进系统提示的 {{action_spaces_holder}}(GUIAgent.ts:515-525)——"模型被允许做什么"和"操作器真能做什么"由同一份清单驱动,不会漂移。
  • 坐标数学和模型预处理严格对齐。 smart-resize + 28 的倍数 + min/max 像素这套约束(actionParser.ts:28),是把"训练侧图像处理"原样搬到"推理侧解析",保证像素级不偏移——这类"两侧必须一致"的隐性契约,是 GUI agent 最容易踩的坑。
  • Windows 打字走剪贴板。 type 在 Windows 上不逐字敲,而是写剪贴板 + Ctrl+V 再还原(operators/nut-js/src/index.ts:243-250apps/ui-tars/src/main/agent/operator.ts:89-100),绕开 IME / 特殊字符问题。

边界与局限:

  • 纯视觉、无语义。 不读 DOM / 辅助功能树(浏览器模式只是给可点元素加高亮再截图),所以定位精度完全取决于模型;小控件、低对比、动态动画都可能点偏。
  • 强串行、每步一次模型调用 + 一次截图。 一轮至少一次 VLM 往返 + 一次抓屏,长任务慢且贵;MAX_LOOP_COUNT=100 是硬墙,复杂任务可能撞墙即 REACH_MAXLOOP_ERROR
  • 单显示器假设。 Electron 操作器截取主显示器(primaryDisplayId,apps/ui-tars/src/main/agent/operator.ts:52-71),多屏场景下非主屏的目标够不到。
  • 动作即执行,无干运行。 循环里解析出动作就直接在真机点下去,没有"先确认再执行"的护栏(仅有 pause / stop / abort 信号);误点会真的发生。
  • 错误不外抛,只走 onError run 内部吞掉异常、只通过回调上报(GUIAgent.ts:451-453 注释明确写了不 throw),调用方必须自己订阅 onError 才知道失败。

代码地图(导航索引)

关注点文件说明
主循环 / 状态机packages/ui-tars/sdk/src/GUIAgent.tsGUIAgent.runwhile(true):截图→调模型→执行;暂停/停止/重试/错误全在这
动作解析 + 坐标换算packages/ui-tars/action-parser/src/actionParser.tsparseActionVlm / parseAction / smartResizeForV15:把 click(start_box=...) 变成像素坐标
VLM 调用封装packages/ui-tars/sdk/src/Model.tsUITarsModel.invoke / invokeModelProvider:压图、拼消息、Chat vs Responses API、服务端会话清理
消息拼装 / 滑窗 / 坐标工具packages/ui-tars/sdk/src/utils.tsprocessVlmParams(图片滑窗)/ formatHistoryMessages(文字历史)/ parseBoxToScreenCoords
Operator 抽象packages/ui-tars/sdk/src/types.tsOperator 抽象类(screenshot + execute + MANUAL.ACTION_SPACES)
本地电脑操作器packages/ui-tars/operators/nut-js/src/index.tsNutJSOperator:nut-js 鼠标/键盘/热键/拖拽/滚动/剪贴板打字
浏览器操作器packages/ui-tars/operators/browser-operator/src/browser-operator.tsBrowserOperator:Puppeteer page.mouse / page.keyboard,截图前高亮可点元素
系统提示 / 动作空间packages/ui-tars/sdk/src/constants.tsSYSTEM_PROMPT / SYSTEM_PROMPT_TEMPLATE / DEFAULT_FACTORS / 内部动作枚举
VLM 常量packages/ui-tars/shared/src/constants/vlm.tsMAX_LOOP_COUNT / MAX_IMAGE_LENGTH / MAX_PIXELS_* / UITarsModelVersion
Electron 装配apps/ui-tars/src/main/services/runAgent.ts选操作器(本地/浏览器/远程)、装配 GUIAgent、把每步数据渲染到 UI
Electron 本地操作器覆写apps/ui-tars/src/main/agent/operator.tsNutJSElectronOperator:用 desktopCapturer 截图、Windows 剪贴板打字