《AI Agent 高级编程》7.1 Computer Use 基础:截图/坐标动作/循环最小实现

Computer Use 的循环拆开只有三件事:截图、输出坐标动作、再截图。本节先用 PIL 合成一块 320×200 的假屏幕,再让脚本化假模型依次吐出 type / click 动作,真跑一遍「截图 → 动作 → 再截图」的最小闭环;随后对照 Anthropic computer use 工具的真实动作空间,说明我们简化掉了什么、这套循环的边界在哪。

7.1 Computer Use 基础:截图/坐标动作/循环最小实现

前面六章里,Agent 的动作全是「工具调用」:模型输出一个 JSON,你的 ToolRegistry.call 去执行。工具的参数是语义化的——search(query="...")、read_file(path="..."),模型永远碰不到像素。Computer Use 把这条边界推翻了:模型不再输出语义参数,而是输出屏幕坐标。它说「在 (90,140) 点一下」,至于那里是不是按钮、点了会发生什么,只有真实环境知道。这带来一个全新的循环形态,也带来全新的失败模式。

本节把「Agent 直接看屏幕、动鼠标键盘」这件事拆到最小:用 PIL 合成一块假屏幕,用脚本化假模型驱动,真跑一遍「截图 → 坐标动作 → 再截图」的闭环,再对照真实 computer use 工具的动作空间,标出我们简化掉的工程部分。本节不操作任何真实桌面、不访问任何真实网站,全程在本地合成屏幕上完成。

7.1.1 循环只有三件事

普通 Agent 循环的形状是「模型 → 工具 → 结果 → 模型」。Computer Use 的循环多了观察这一步,而且观察结果是图像:

  1. 截图(screenshot):把当前屏幕编码成 PNG,塞进消息里当作模型的输入。
  2. 坐标动作(action):模型输出一个动作,最典型的是 click(x, y)、type(text)、scroll(...)。
  3. 再截图:动作执行后,屏幕变了,必须重新截图,把新画面喂回去,模型才知道自己刚才干成了什么。

第三步最容易被漏掉。很多人第一次写 Computer Use,会让模型连续吐 5 个动作再截图一次——结果模型在「盲操」,点空了它也不知道。每一步动作之后都要重新观察,这是 Computer Use 与工具调用的最大区别:工具调用有返回值,坐标动作只有下一次截图。

7.1.2 为什么先用合成屏幕

真实桌面有三个不可控因素,任何一个都会让你的实验不可复现:

  • 分辨率与 DPI:同一段代码在 Retina 屏和普通屏上,click(90, 140) 的落点完全不同。
  • 窗口位置:目标按钮的绝对坐标依赖窗口在屏幕上的位置,窗口一动,坐标全废。
  • 状态污染:真实桌面上有别的窗口、通知弹窗、焦点抢断,你的截图里可能混进无关内容。

所以本节先用一块 320×200 的合成屏幕把循环跑通——它和真实屏幕的接口形状完全一致(screenshot / click / type),只是把「操作系统」换成了一个 Python 对象。循环对不对、动作对不对,可以脱离真实环境先验证。这也是本卷一贯的做法:先用手边的假件把机制跑通,再谈接真件。

7.1.3 合成屏幕:用 PIL 造一个能被「点击」的桌面

我们用 PIL 合成一块 320×200 的假屏幕。它只有两个状态:输入框里的文本、按钮是否被点过。screenshot() 把当前状态渲染成 PNG,click() / type() 改状态。

from __future__ import annotations
from io import BytesIO
from PIL import Image, ImageDraw


class FakeScreen:
    """一个最小的「合成屏幕」:状态 = 输入框文本 + 按钮是否被点过。"""
    W, H = 320, 200

    def __init__(self) -> None:
        self.text = ""
        self.clicked = False
        self._render()

    def _render(self) -> None:
        img = Image.new("RGB", (self.W, self.H), "white")
        d = ImageDraw.Draw(img)
        d.rectangle([20, 60, 300, 100], outline="black", width=2)   # 输入框
        d.text((28, 74), self.text or "(empty)", fill="black")
        fill = "#4caf50" if self.clicked else "#dddddd"             # 按钮
        d.rectangle([20, 120, 160, 160], fill=fill, outline="black", width=2)
        d.text((60, 134), "Submit", fill="black")
        d.text((20, 10), f"clicked={self.clicked}", fill="black")   # 状态行
        self._img = img

    def screenshot(self) -> bytes:
        self._render()
        buf = BytesIO()
        self._img.save(buf, format="PNG")
        return buf.getvalue()

    def click(self, x: int, y: int) -> str:
        if 20 <= x <= 160 and 120 <= y <= 160:
            self.clicked = True
            return "hit Submit button"
        return f"({x},{y}) hit nothing"

    def type(self, text: str) -> str:
        self.text += text
        return f"input now = {self.text!r}"

关键设计:click 不抛异常,点空返回一句描述。这和卷一 ToolRegistry.call 把错误包成 {"error": ...} 回灌是同一条原则——执行层永远把结果(含失败)回灌给模型,由模型决定下一步。点空在 Computer Use 里是常态,不是异常。

7.1.4 脚本化假模型:动作序列从哪来

真实场景里,模型看截图后决定动作。为了确定性复现,我们用一个脚本化的假模型:一个写死的动作列表。它代表「模型看完截图后决定做什么」,把注意力集中在循环本身。这和卷一 FakeModel 的思路一致——把不确定性最大的那一段(模型)换成确定件,剩下的循环、动作、观察才能被稳定地断言。真模型每次输出的动作可能不同,用它调试循环等于在噪声上找信号。

脚本化假模型还带来一个隐性好处:它强迫你先把动作空间定义清楚。你得先想明白「模型能吐哪几种动作、每种动作的字段是什么」,才能写出这个脚本;这一步想不清楚,接真模型时必然返工。

# 脚本化假模型:固定动作序列,代表「模型看截图后的决策」
SCRIPT = [
    {"action": "type", "text": "hello agent"},
    {"action": "click", "x": 90, "y": 140},
    {"action": "done"},
]


def run_agent(screen: FakeScreen, script: list[dict]) -> list[tuple]:
    trace = []
    for i, act in enumerate(script):
        shot = screen.screenshot()                 # 每步都先重新截图
        trace.append((i, "screenshot", f"{len(shot)} bytes PNG"))
        kind = act["action"]
        if kind == "done":
            trace.append((i, "final", "task complete"))
            break
        if kind == "click":
            detail = screen.click(act["x"], act["y"])
        elif kind == "type":
            detail = screen.type(act["text"])
        else:
            detail = f"unknown action {kind!r}"
        trace.append((i, f"action:{kind}", detail))
    return trace

注意循环里 screenshot() 出现在动作之前——这就是「观察先于行动」。真实的 Computer Use 里,第一次截图由系统在对话开始时提供,之后每次工具结果里都带一张新截图;我们的最小实现把它显式写成循环的一步,让依赖关系一目了然。

7.1.5 真跑输出

把上面三段拼起来,用 /tmp/agentbook/venv3/bin/python 跑(Python 3.14.6,Pillow 12.3.0):

[step 0] screenshot       2797 bytes PNG
[step 0] action:type      input now = 'hello agent'
[step 1] screenshot       2764 bytes PNG
[step 1] action:click     hit Submit button
[step 2] screenshot       2649 bytes PNG
[step 2] final            task complete
[verify] clicked=True text='hello agent' final_shot=2649 bytes

逐行读:step 0 先截图(2797 字节),模型决定 type,输入框变成 'hello agent';step 1 重新截图(2764 字节,体积变了,因为画面内容变了),模型决定点 (90,140),命中 Submit;step 2 再截图(2649 字节,按钮变绿了),模型判定 done。最后一行 clicked=True text='hello agent' 是独立复核——不靠模型自述,直接读屏幕对象的状态确认任务真的完成了。

截图字节数会随画面变化,这是个有用的信号:如果连续两步截图字节数完全相同,很可能屏幕根本没动,动作要么点空了、要么被忽略了。真实系统里可以直接做图像差分来检测「画面未变化」,避免模型在原地打转。

7.1.6 画面差分:检测模型在盲操

把上面的直觉写成可运行的检查。ImageChops.difference 给出两张图的逐像素差,我们数「有多少像素变了」得到变化占比:

from io import BytesIO
from PIL import Image, ImageChops
from cu_min import FakeScreen


def diff_ratio(a: bytes, b: bytes) -> float:
    """两张 PNG 的不同像素占比,0.0 表示画面完全没变。"""
    ia = Image.open(BytesIO(a)).convert("RGB")
    ib = Image.open(BytesIO(b)).convert("RGB")
    if ia.size != ib.size:
        return 1.0
    raw = ImageChops.difference(ia, ib).tobytes()      # 每像素 3 字节
    changed = sum(1 for i in range(0, len(raw), 3)
                  if raw[i] or raw[i + 1] or raw[i + 2])
    return changed / (ia.size[0] * ia.size[1])


s = FakeScreen()
shot0 = s.screenshot()
s.click(250, 20)                       # 点在空白处:画面不变
shot1 = s.screenshot()
s.type("hi")                           # 真的改了输入框
shot2 = s.screenshot()

真跑输出(Python 3.14.6 / Pillow 12.3.0):

点空白处前后 diff = 0.0000  -> 画面未变,动作无效
输入文本前后 diff = 0.0026  -> 画面已变,动作生效

点在空白处,diff 是 0.0000——画面一个像素都没动,说明这个动作白做了。输入文本后 diff 是 0.0026,画面确实变了。这个检查可以直接做循环护栏:连续 N 步 diff 都是 0,就判定卡死并中止,把控制权交回人或换策略。注意 0.0026 很小,因为假屏幕大部分是白底——阈值不能拍脑袋,要按具体界面的基线设。

7.1.7 坐标口径与循环护栏

两个工程细节,写进最小实现之前必须想清楚:

坐标是绝对像素还是归一化值? 我们的 click(x, y) 用的是绝对像素,屏幕就是 320×200。但真实屏幕分辨率会变,模型在 1280×800 上学到的 (90,140),到 1920×1080 上就点偏了。主流做法是让模型输出归一化坐标(0–1000 的整数),执行层再按当前分辨率还原:px = round(x / 1000 * width)。这样分辨率变化时动作语义不变,代价是多一层换算、多一个 bug 面。

def to_pixels(nx: int, ny: int, width: int, height: int) -> tuple[int, int]:
    """把模型的归一化坐标 (0-1000) 还原成当前分辨率的像素坐标。"""
    return round(nx / 1000 * width), round(ny / 1000 * height)


print("1280x800  ->", to_pixels(281, 700, 1280, 800))
print("1920x1080 ->", to_pixels(281, 700, 1920, 1080))

真跑输出(同一个归一化动作,两种分辨率):

1280x800  -> (360, 560)
1920x1080 -> (540, 756)

同一个「点屏幕偏左下」的意图,在两种分辨率下被还原成不同的像素坐标,落点却始终在同一个相对位置。这就是归一化的价值:让动作与分辨率解耦。

循环必须有硬护栏。工具调用循环靠「tool_calls 是否为空」判断收敛;坐标动作循环没有这个信号——模型可以永远点下去。所以至少要加三道闸:

护栏触发条件动作
步数上限超过 max_steps中止并返回当前状态
卡死检测连续 N 步 diff_ratio == 0中止,判定动作无效
超时单步动作超过 T 秒中止,避免卡在某个弹窗

这三道闸和卷一 run 里的 max_steps 是同源思想:任何可能不收敛的循环,都必须有外部硬上限,不能指望模型自己停下来。

7.1.8 动作空间对照:我们简化的 vs 真实的

我们的最小实现只有 screenshot / click / type 三个动作。真实的 computer use 工具动作空间大得多。下面是本机 anthropic 1.13.0 里 computer use 工具输入类型的实测字段(用 inspect 从 anthropic.types.beta 读出,非记忆):

真实动作输入字段(实测)我们的最小实现
screenshot无参数有,返回 PNG 字节
left_clickcoordinate: [x, y]有(click),但不区分左右键
typetext: str有
mouse_movecoordinate: [x, y]无(简化为直接点击)
scrollscroll_amount、scroll_direction、coordinate无
keytext: str、repeat: int | None无(不能按快捷键)
double_click / triple_clickcoordinate无
left_click_drag起止坐标无
wait / zoom / cursor_position各异无

工具类型字符串也是实测的:computer_20250124、computer_20251124,以及配套的 bash_20250124、text_editor_20250728。也就是说,真实 Computer Use 是「computer + bash + text_editor」三件套:模型既能点屏幕,也能开终端跑命令、直接编辑文件——能力越大,不可逆风险越大,这正是 7.3 要处理的问题。

7.1.9 这套循环替你做了什么、你让渡了什么

维度你得到的你让渡的
通用性不需要为每个网站写 API,任何能截屏的界面都能操作精确性——坐标是像素级的,改个分辨率全失效
观察模型直接「看见」界面,无需解析 DOM每一步都要截图,成本与延迟成倍上升
动作语义参数变成坐标,能力上限更高可测性——没有结构化返回值,只能靠图像差分
错误处理点空返回描述,模型自行纠偏需要显式的每步截图,否则模型在盲操

一句话:Computer Use 用通用性换掉了精确性与可测性。它不是工具调用的替代品,而是工具调用够不着时的兜底——当一个系统根本没有 API、只有界面时,截图+坐标是唯一的通路。

真实 Computer Use 还有个我们没展开的成本问题:每步一张截图,图像 token 开销随分辨率走。一块 1920×1080 的全屏截图,编码后进上下文,长期跑下来 token 账单会显著高于工具调用。这也是为什么 7.2 要往「浏览器 + DOM」方向退一步——能拿到结构化数据时,就别传像素。

下一节 7.2 浏览器 Agent 会把战场从「整块屏幕」缩到「浏览器页面」,用 Playwright 把浏览器操作工具化——那是 Computer Use 与工具调用之间的中间地带:既有坐标的通用性,又能拿到 DOM 的结构化结果。想先回看持久化执行如何让长任务可续跑,见 6.1 为什么需要持久化执行 。

阅读导航:上一节:6.3 与自研检查点对照:轮子与现成品边界 · 下一节:7.2 浏览器 Agent:Playwright 工具化完成网页取数 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距