在前几篇文章中,我们已经分别分析了 UI-TARS 的几个关键点:
text
prompt.py 如何定义 Thought + Action 输出格式;
parse_action 如何用 AST 解析函数调用式 Action;
point / start_box / end_box 如何统一;
Qwen2.5-VL 绝对坐标如何映射回真实屏幕;
smart_resize 为什么影响坐标还原。
这些内容其实都汇聚到 action_parser.py 里的一个核心函数:
python
parse_action_to_structure_output(...)
如果说 parse_action 是"解析一个动作函数调用",那么 parse_action_to_structure_output 就是更上层的总控函数。
它负责把模型完整输出:
text
Thought: 我需要点击搜索框。
Action: click(point='<point>850 120</point>')
转换成结构化动作字典:
json
[
{
"reflection": null,
"thought": "我需要点击搜索框。",
"action_type": "click",
"action_inputs": {
"start_box": "[0.4427, 0.1111, 0.4427, 0.1111]"
},
"text": "Thought: 我需要点击搜索框。\nAction: click(start_box='(850,120)')"
}
]
这篇文章就来完整拆解这个函数的处理流程。
一、parse_action_to_structure_output 的定位
先看 codes/README.md 对这个函数的说明。
官方文档中写到,parse_action_to_structure_output 的作用是把模型输出的 action instruction 解析成结构化字典,并自动处理坐标缩放以及 box/point 格式转换;它的返回值是一个 structured actions 列表,每个 dict 包含 action_type、action_inputs、thought 等字段。
这句话其实已经说明了它的三个职责:
text
第一,解析 Thought / Action 文本;
第二,解析 Action 里的函数名和参数;
第三,处理坐标格式转换和缩放。
所以它不是普通的字符串解析函数。
它是 UI-TARS 从"模型文本输出"进入"程序动作字典"的关键入口。
完整链路可以理解为:
text
模型输出文本
↓
parse_action_to_structure_output
↓
结构化 actions
↓
parsing_response_to_pyautogui_code
↓
pyautogui 自动化脚本
二、函数签名说明
源码中函数签名如下:
python
def parse_action_to_structure_output(text,
factor,
origin_resized_height,
origin_resized_width,
model_type="qwen25vl",
max_pixels=16384 * 28 * 28,
min_pixels=100 * 28 * 28):
官方 README 中也列出了同样的 API 参数:text 是模型输出字符串,factor 是缩放因子,origin_resized_height / origin_resized_width 是原始图像高宽,model_type 用于区分模型坐标协议,max_pixels / min_pixels 用于图像像素上下限控制。
这些参数可以分成三类。
第一类是文本输入:
text
text
也就是模型完整输出。
第二类是坐标缩放参数:
text
factor
origin_resized_height
origin_resized_width
model_type
它们决定模型输出坐标如何归一化。
第三类是 Qwen2.5-VL 图像预处理参数:
text
max_pixels
min_pixels
它们会影响 smart_resize 的结果,从而影响 Qwen2.5-VL 绝对坐标的还原。
三、第一步:清理文本
函数开头很简单:
python
text = text.strip()
这一步去掉模型输出前后的空白字符。
看起来很普通,但对解析很重要。
因为后面会用:
python
text.startswith("Thought:")
来判断文本结构。
如果模型输出前面多了换行或空格,strip() 可以先把它清理掉。
这也是处理大模型输出时常见的小技巧:
在正式解析之前,先做最基础的文本归一化。
四、第二步:把 <point>x y</point> 转成坐标
接下来源码判断:
python
if "<point>" in text:
text = convert_point_to_coordinates(text)
convert_point_to_coordinates 会匹配:
text
<point>850 120</point>
并转换成:
text
(850,120)
它还会去掉 [EOS]。源码中可以看到,该函数使用正则匹配 <point>(\d+)\s+(\d+)</point>,然后返回 (x,y) 格式。
例如模型原始输出是:
text
Action: click(point='<point>850 120</point>')
这一步之后变成:
text
Action: click(point='(850,120)')
这一步的意义是:
先把模型友好的 point 标签格式,转换成后续 parser 更容易处理的坐标字符串。
五、第三步:统一参数名
接下来是三个替换:
python
if "start_point=" in text:
text = text.replace("start_point=", "start_box=")
if "end_point=" in text:
text = text.replace("end_point=", "end_box=")
if "point=" in text:
text = text.replace("point=", "start_box=")
这一步非常关键。
模型可能输出:
text
click(point='(850,120)')
drag(start_point='(300,500)', end_point='(700,500)')
但内部统一成:
text
click(start_box='(850,120)')
drag(start_box='(300,500)', end_box='(700,500)')
源码中 parse_action_to_structure_output 确实会把 start_point 替换成 start_box,把 end_point 替换成 end_box,把 point 替换成 start_box。
为什么要这样做?
因为后续执行层主要处理的是:
text
start_box
end_box
而不是各种不同字段。
这就是前几篇文章讲过的设计:
text
模型输出层可以比较友好;
Parser 内部必须统一稳定。
六、第四步:如果是 qwen25vl,先计算 smart_resize 尺寸
接下来源码判断:
python
if model_type == "qwen25vl":
smart_resize_height, smart_resize_width = smart_resize(
origin_resized_height,
origin_resized_width,
factor=IMAGE_FACTOR,
min_pixels=min_pixels,
max_pixels=max_pixels)
这一步只在 model_type == "qwen25vl" 时执行。
原因是 Qwen2.5-VL 输出的是基于模型输入图像的绝对坐标,而模型输入图像可能经过 smart_resize。源码注释也写明:Qwen2.5-VL 输出 absolute coordinates,而 qwen2vl 输出 relative coordinates。
所以后面处理坐标时,需要知道:
text
smart_resize_width
smart_resize_height
否则就无法把 Qwen2.5-VL 输出的绝对坐标归一化。
这一点也说明,parse_action_to_structure_output 不只是文本解析函数,它还承担模型坐标协议适配职责。
七、第五步:识别 Thought / Reflection / Action_Summary 格式
接下来开始提取思考内容。
源码根据文本开头选择不同正则:
python
if text.startswith("Thought:"):
thought_pattern = r"Thought: (.+?)(?=\s*Action: |$)"
thought_hint = "Thought: "
elif text.startswith("Reflection:"):
thought_pattern = r"Reflection: (.+?)Action_Summary: (.+?)(?=\s*Action: |$)"
thought_hint = "Reflection: "
elif text.startswith("Action_Summary:"):
thought_pattern = r"Action_Summary: (.+?)(?=\s*Action: |$)"
thought_hint = "Action_Summary: "
else:
thought_pattern = r"Thought: (.+?)(?=\s*Action: |$)"
thought_hint = "Thought: "
从这里可以看出,UI-TARS 不只支持最普通的:
text
Thought: ...
Action: ...
还支持:
text
Reflection: ...
Action_Summary: ...
Action: ...
以及:
text
Action_Summary: ...
Action: ...
源码中确实会根据文本是否以 Thought:、Reflection:、Action_Summary: 开头来选择不同解析模式。
这说明 UI-TARS 的 parser 兼容了更复杂的 System-2 输出格式。
八、Thought、Reflection、Action_Summary 分别表示什么?
虽然源码只是做正则解析,但从 Agent 语义上看,这三个字段可以这样理解。
Thought 表示当前一步的推理:
text
我现在看到什么?
我下一步为什么要这么做?
Reflection 表示对之前动作的反思:
text
刚才的动作有没有成功?
如果失败,失败原因可能是什么?
Action_Summary 表示接下来动作的摘要:
text
下一步动作大概是什么?
目标元素是什么?
例如:
text
Reflection: 刚才点击后页面没有变化,可能没有点中搜索框。
Action_Summary: 重新点击搜索框。
Action: click(point='<point>850 120</point>')
parse_action_to_structure_output 会把 Reflection 和 Action_Summary 拆出来,其中 Reflection 放入 reflection 字段,Action_Summary 作为 thought 字段保存。源码中如果正则匹配到两个 group,就会把第一个 group 存为 reflection,第二个 group 存为 thought。
这让结构化动作不仅包含"做什么",还保留"为什么做"和"上一步反思"。
九、第六步:提取 Action 字符串
提取 Thought 后,源码会强制要求文本里包含:
python
assert "Action:" in text
然后取最后一个 Action: 后面的内容:
python
action_str = text.split("Action: ")[-1]
这一步说明:
对
parse_action_to_structure_output来说,Action 是必须存在的。
没有 Action,就无法生成结构化动作。
而且这里使用的是:
text
split("Action: ")[-1]
也就是取最后一个 Action: 后面的内容。
这对普通输出没问题:
text
Thought: ...
Action: click(...)
但如果 Thought 里也包含了字符串 Action:,可能会产生误切分。
从产品化角度看,这里可以增强为更严格的正则匹配或行级解析。
十、第七步:支持多动作拆分
拿到 action_str 后,源码会执行:
python
tmp_all_action = action_str.split(")\n\n")
all_action = []
也就是说,它尝试按:
text
)\n\n
拆分多个动作。
例如模型可能输出:
text
Action: click(start_box='(850,120)')
type(content='UI-TARS\n')
理论上可以被拆成多个 action。
然后每个 action 会进入循环:
python
for action_str in tmp_all_action:
...
if not action_str.strip().endswith(")"):
action_str = action_str.strip() + ")"
all_action.append(action_str)
如果拆分后动作末尾没有右括号,代码会补上。
源码中确实有这段多动作拆分和补括号逻辑。
不过从 GUI Agent 产品设计看,一般更推荐:
text
一轮一个主要 Action;
执行后重新截图;
再决定下一步。
因为 GUI 状态会在每个动作后变化,多动作连续执行更容易因为页面变化而失败。
十一、第八步:特殊处理 type(content='...')
type 是最容易出解析问题的动作。
因为 content 可能包含:
text
单引号
双引号
换行符
逗号
代码片段
路径
JSON
源码中如果发现:
python
if "type(content" in action_str:
就会进入特殊分支。
主要做几件事:
text
1. 如果末尾没有右括号,就补上;
2. 用正则匹配 type(content='...');
3. 提取 content;
4. 调用 escape_single_quotes 处理单引号;
5. 重新构造 type(content='...')。
源码中可以看到它使用 pattern = r"type\(content='(.*?)'\)" 匹配 type(content='...'),随后用 escape_single_quotes 处理内容,再重新拼成 type(content='...')。
这个分支说明,文本输入动作比点击动作复杂得多。
点击动作只要处理坐标。
输入动作则要处理任意字符串。
十二、第九步:调用 parse_action 解析每个 Action
完成清洗和修正后,源码调用:
python
parsed_actions = [
parse_action(action.replace("\n", "\\n").lstrip())
for action in all_action
]
这里有两个细节。
第一,action.replace("\n", "\\n")。
这会把真实换行替换成转义形式,避免 AST 解析时被换行破坏。
第二,lstrip()。
去掉动作前面的空白字符。
然后交给上一篇文章分析过的 parse_action。
parse_action 会用 AST 把:
text
click(start_box='(850,120)')
解析成:
json
{
"function": "click",
"args": {
"start_box": "(850,120)"
}
}
源码中 parse_action 使用 ast.parse(action_str, mode='eval'),检查是否为函数调用,然后提取函数名和关键字参数。
十三、第十步:解析失败则抛出异常
接下来:
python
for action_instance, raw_str in zip(parsed_actions, all_action):
if action_instance == None:
print(f"Action can't parse: {raw_str}")
raise ValueError(f"Action can't parse: {raw_str}")
也就是说,只要某个 Action 解析失败,就会直接抛异常。
这说明 UI-TARS 对 Action 格式要求比较严格。
模型可以在 Thought 中自由表达,但 Action 必须是 parser 可以处理的函数调用格式。
这也是 Prompt 里要求模型按固定格式输出动作的原因。
如果模型输出:
text
Action: click the search box
就不是合法函数调用,无法进入后续结构化处理。
十四、第十一步:生成 action_type 和 params
解析成功后,源码取出:
python
action_type = action_instance["function"]
params = action_instance["args"]
例如:
text
click(start_box='(850,120)')
会得到:
text
action_type = "click"
params = {
"start_box": "(850,120)"
}
再比如:
text
scroll(start_box='(600,720)', direction='down')
会得到:
text
action_type = "scroll"
params = {
"start_box": "(600,720)",
"direction": "down"
}
从这里开始,模型输出已经不再是纯文本,而开始进入结构化动作字典。
十五、第十二步:处理普通参数
源码中先初始化:
python
action_inputs = {}
然后遍历参数:
python
for param_name, param in params.items():
if param == "": continue
param = param.lstrip()
action_inputs[param_name.strip()] = param
这一步会把普通参数直接放进 action_inputs。
例如:
text
hotkey(key='ctrl c')
会得到:
json
{
"key": "ctrl c"
}
再比如:
text
type(content='UI-TARS\n')
会得到:
json
{
"content": "UI-TARS\\n"
}
对于不涉及坐标的动作,这一步基本就够了。
例如:
text
hotkey
type
finished
它们主要依赖普通字符串参数。
十六、第十三步:识别 start_box / end_box 坐标参数
如果参数名中包含:
text
start_box
end_box
源码会进入坐标处理逻辑:
python
if "start_box" in param_name or "end_box" in param_name:
ori_box = param
numbers = ori_box.replace("(", "").replace(")", "").split(",")
这里会把:
text
"(850,120)"
处理成:
text
["850", "120"]
如果是四个数:
text
"(100,200,300,400)"
就会处理成:
text
["100", "200", "300", "400"]
这一步之后,才会进入坐标归一化。
十七、第十四步:按模型类型做坐标归一化
源码对坐标有两种处理方式。
1. qwen25vl 分支
如果:
text
model_type == "qwen25vl"
则按 smart resize 后的尺寸归一化:
python
if (num_idx + 1) % 2 == 0:
float_numbers.append(float(num / smart_resize_height))
else:
float_numbers.append(float(num / smart_resize_width))
也就是说:
text
x / smart_resize_width
y / smart_resize_height
源码中这一段旁边的注释写明:Qwen2.5-VL 输出 absolute coordinates,而 qwen2vl 输出 relative coordinates。
2. 非 qwen25vl 分支
如果不是 Qwen2.5-VL,则:
python
float_numbers = [float(num) / factor for num in numbers]
也就是直接除以 factor。
例如 factor=1000 时:
text
500 → 0.5
300 → 0.3
README 的 Quick Start 示例中,调用 parse_action_to_structure_output 时传入 factor=1000,model_type="doubao",这正是非 qwen25vl 分支的典型用法。
十八、第十五步:二维点扩展成四维 box
如果归一化后只有两个数:
text
[x, y]
源码会扩展成四个数:
python
if len(float_numbers) == 2:
float_numbers = [
float_numbers[0], float_numbers[1],
float_numbers[0], float_numbers[1]
]
也就是:
text
[x, y] → [x, y, x, y]
源码中确实在坐标长度为 2 时做了这个扩展。
这一步的意义是统一结构。
无论模型输出的是点还是区域,最终都变成:
text
[x1, y1, x2, y2]
后续 pyautogui 阶段只要取中心点即可:
text
center_x = (x1 + x2) / 2
center_y = (y1 + y2) / 2
十九、第十六步:写入 action_inputs
坐标归一化完成后,源码会写回:
python
action_inputs[param_name.strip()] = str(float_numbers)
例如:
text
click(start_box='(850,120)')
在 factor=1000 的情况下,会变成:
json
{
"start_box": "[0.85, 0.12, 0.85, 0.12]"
}
如果是 Qwen2.5-VL,则会除以 smart resize 后的宽高。
最终 action_inputs 保存的不是原始坐标,而是归一化后的字符串形式 list。
这也是为什么后续 parsing_response_to_pyautogui_code 需要再把它解析出来,并乘以真实 image_width / image_height。
二十、第十七步:组装最终 action dict
最后,源码把每个动作组装成:
python
actions.append({
"reflection": reflection,
"thought": thought,
"action_type": action_type,
"action_inputs": action_inputs,
"text": text
})
返回的是:
python
return actions
也就是一个列表。
每个动作包含五个核心字段:
text
reflection:
模型对前一步或当前状态的反思。
thought:
模型当前动作摘要或推理内容。
action_type:
动作类型,例如 click、type、scroll、drag。
action_inputs:
动作参数,例如 start_box、content、direction。
text:
清洗和转换后的完整模型文本。
源码中最终返回的 dict 字段正是 reflection、thought、action_type、action_inputs 和 text。
这就是 UI-TARS 后续执行层真正消费的数据结构。
二十一、完整示例:click 动作
假设模型输出:
text
Thought: 我需要点击搜索框。
Action: click(point='<point>850 120</point>')
调用:
python
actions = parse_action_to_structure_output(
text=response,
factor=1000,
origin_resized_height=1080,
origin_resized_width=1920,
model_type="doubao"
)
处理流程是:
text
1. strip 清理文本;
2. <point>850 120</point> 转为 (850,120);
3. point= 替换成 start_box=;
4. 提取 Thought;
5. 提取 Action;
6. parse_action 解析 click 和 start_box;
7. 坐标除以 factor=1000;
8. [0.85, 0.12] 扩展成 [0.85, 0.12, 0.85, 0.12];
9. 组装 action dict。
最终结构大致是:
json
[
{
"reflection": null,
"thought": "我需要点击搜索框。",
"action_type": "click",
"action_inputs": {
"start_box": "[0.85, 0.12, 0.85, 0.12]"
},
"text": "Thought: 我需要点击搜索框。\nAction: click(start_box='(850,120)')"
}
]
这就是从模型文本到动作字典的完整转换。
二十二、完整示例:Reflection + Action_Summary
再看一个更复杂的输出:
text
Reflection: 刚才点击后页面没有变化,可能没有点中搜索框。
Action_Summary: 重新点击搜索框。
Action: click(point='<point>850 120</point>')
解析后:
json
[
{
"reflection": "刚才点击后页面没有变化,可能没有点中搜索框。",
"thought": "重新点击搜索框。",
"action_type": "click",
"action_inputs": {
"start_box": "[0.85, 0.12, 0.85, 0.12]"
},
"text": "..."
}
]
这里可以看到:
text
Reflection 被单独保存;
Action_Summary 被保存到 thought;
Action 被解析成结构化动作。
这让系统既能执行动作,也能保存模型的反思记录。
二十三、完整示例:drag 动作
假设模型输出:
text
Thought: 我需要把滑块拖到右侧。
Action: drag(start_point='<point>300 500</point>', end_point='<point>700 500</point>')
处理后:
text
start_point → start_box
end_point → end_box
再解析:
json
{
"function": "drag",
"args": {
"start_box": "(300,500)",
"end_box": "(700,500)"
}
}
如果 factor=1000,最终:
json
[
{
"reflection": null,
"thought": "我需要把滑块拖到右侧。",
"action_type": "drag",
"action_inputs": {
"start_box": "[0.3, 0.5, 0.3, 0.5]",
"end_box": "[0.7, 0.5, 0.7, 0.5]"
},
"text": "..."
}
]
后续 parsing_response_to_pyautogui_code 会把 start_box 和 end_box 还原成真实坐标,生成 pyautogui.moveTo(...) 和 pyautogui.dragTo(...)。源码中 drag/select 分支正是读取两个 box,计算中心点后生成拖拽代码。
二十四、parse_action_to_structure_output 和 pyautogui 生成的关系
parse_action_to_structure_output 的输出不是最终执行代码,而是结构化中间层。
README 中的 Quick Start 也展示了这一点:先调用 parse_action_to_structure_output 得到 parsed_dict,再调用 parsing_response_to_pyautogui_code 生成 pyautogui 脚本。
也就是说:
text
parse_action_to_structure_output:
模型文本 → 结构化动作
parsing_response_to_pyautogui_code:
结构化动作 → pyautogui 代码
这两个函数分别解决不同问题。
前者处理:
text
文本格式
Thought / Reflection / Action 提取
函数调用解析
坐标归一化
动作字典生成
后者处理:
text
真实坐标还原
鼠标点击
键盘快捷键
文本输入
拖拽滚动
任务结束
分层很清楚。
二十五、这个函数的核心价值
parse_action_to_structure_output 的核心价值可以总结成一句话:
把大模型不稳定的文本输出,收敛成稳定的动作字典。
模型输出天然有很多不稳定因素:
text
可能使用 point,也可能使用 start_point;
可能有 Thought,也可能有 Reflection;
可能输出一个 Action,也可能输出多个 Action;
坐标可能是 Qwen2.5-VL 绝对坐标,也可能是 factor 相对坐标;
type 内容可能包含引号和换行。
而这个函数把这些差异统一成:
text
[
{
"reflection": ...,
"thought": ...,
"action_type": ...,
"action_inputs": ...,
"text": ...
}
]
这就是 GUI Agent 工程化最重要的一步。
二十六、源码里的几个潜在风险
虽然这个函数设计很实用,但如果要用于真实产品,还需要注意几个问题。
1. assert "Action:" in text 不适合生产环境
生产环境里最好不要用 assert 处理用户或模型输入。
更建议改成:
python
if "Action:" not in text:
raise ValueError("Missing Action field")
因为 Python 在优化模式下可能禁用 assert。
2. text.split("Action: ")[-1] 可能误切
如果 Thought 内容中出现 Action: 字样,可能导致误解析。
更稳的方式是按行查找最后一个真正的 Action 字段,或者用正则明确匹配。
3. 多动作拆分比较脆弱
当前用:
python
action_str.split(")\n\n")
来拆多动作。
如果模型输出格式稍有变化,就可能拆错。
4. type(content='...') 处理不够通用
当前正则:
python
r"type\(content='(.*?)'\)"
对复杂文本可能不够稳,比如内容中包含特殊引号、多行代码、括号等。
5. 坐标解析假设比较强
代码假设 box 字符串可以通过:
python
replace("(", "").replace(")", "").split(",")
拆成数字。
如果模型输出空格分隔、括号不完整、带额外 token,就可能失败。
二十七、二次开发建议
如果你基于 UI-TARS 做自己的桌面自动化项目,可以在这个函数基础上增强几层。
第一,增加动作白名单:
python
ALLOWED_ACTIONS = {
"click", "left_double", "right_single",
"drag", "hotkey", "type",
"scroll", "wait", "finished"
}
第二,增加参数 schema:
python
click 必须有 start_box
drag 必须有 start_box 和 end_box
scroll 必须有 direction
type 必须有 content
第三,增加坐标校验:
text
长度必须是 2 或 4;
坐标必须是数字;
归一化坐标必须在 0 到 1 之间;
最终坐标不能超出屏幕。
第四,替换危险解析方式。
后续 pyautogui 代码生成里会用 eval(start_box) 还原 box;如果产品化,建议改成 ast.literal_eval 或自写安全解析器。源码中的 click、drag、scroll 等分支确实会通过 eval 解析 box 字符串。
第五,保存完整调试日志。
例如:
json
{
"raw_text": "...",
"normalized_text": "...",
"thought": "...",
"reflection": "...",
"action_type": "click",
"raw_coordinates": "(850,120)",
"normalized_coordinates": "[0.85,0.12,0.85,0.12]"
}
这样后续排查点击错误会方便很多。
总结
这篇文章我们完整拆解了 parse_action_to_structure_output。
它的处理流程可以概括为:
text
1. 清理模型输出文本;
2. 将 <point>x y</point> 转成 (x,y);
3. 将 point / start_point / end_point 统一成 start_box / end_box;
4. 如果是 qwen25vl,计算 smart_resize 后的宽高;
5. 提取 Thought / Reflection / Action_Summary;
6. 提取 Action 字符串;
7. 拆分多动作;
8. 特殊处理 type(content='...');
9. 调用 parse_action 用 AST 解析函数调用;
10. 提取 action_type 和参数;
11. 对 start_box / end_box 做坐标归一化;
12. 将二维点扩展成四维 box;
13. 返回结构化 actions 列表。
从 UI-TARS 的整体架构看,这个函数位于非常关键的位置:
text
prompt.py
约束模型输出 Thought + Action
parse_action_to_structure_output
把模型文本转成结构化 action dict
parsing_response_to_pyautogui_code
把 action dict 转成 pyautogui 执行代码
所以,parse_action_to_structure_output 是 UI-TARS 从"模型会说动作"走向"程序能执行动作"的关键桥梁。