Компьютерные агенты ломаются на пикселях: как перейти на stable UI refs через accessibility tree и перестать чинить flaky desktop automation
Я Denis Shokhirev, Agentic AI Systems Architect из Freiburg im Breisgau, Germany, и в DennisCraft AI Studio я собираю production AI-системы для DACH B2B на Claude, Supabase, n8n, Doppler и self-hosted Postgres. Самая дорогая поломка в desktop automation, которую я вижу у клиентов, почти никогда не связана с моделью: агент уверенно кликает “туда же”, но после небольшого UI-сдвига попадает уже не в тот элемент.
Если агент управляет приложением через скриншоты и координаты, он живет в мире пикселей. Этот мир нестабилен: меняется DPI, тема, локализация, масштабирование, порядок панелей, состояние окна. В production это быстро превращается в endless cycle из hotfix’ов, таймаутов и ручных перезапусков. Переход на stable UI refs через accessibility tree меняет не “точность модели”, а саму поверхность управления.
Почему pixel-based automation ломается даже у хороших агентов
В демо pixel-based подход выглядит убедительно: модель видит кнопку, находит ее на скриншоте, двигает мышь, кликает. Но в рабочих процессах это fragile по причинам, которые не исправить prompt engineering.
Проблема 1: координата не равна элементу
Координата — это точка на экране, а не ссылка на UI-объект. Если окно сдвинулось на 12 пикселей, рядом появился баннер, изменилась ширина колонки или системный шрифт стал крупнее, агент формально делает то же действие, но уже по другой цели.
Проблема 2: скриншот не знает семантику
Скриншот не говорит, что перед агентом textbox, checkbox, menuitem или disabled button. Accessibility tree, наоборот, содержит role, name, state, иногда value и parent-child связи. Для автоматизации это критично: вы не просто “ищете серый прямоугольник”, а обращаетесь к объекту с понятной функцией.
Проблема 3: локализация и тема ломают CV-эвристику
В DACH-среде один и тот же desktop flow часто живет на немецком, английском и иногда французском интерфейсе. Светлая и темная темы, системный scaling 125% и 150%, разные window managers — все это увеличивает drift. Я не раз видел, как агент стабильно работал 2 недели, а потом падал после обычного системного обновления.
Что дает accessibility tree вместо пикселей
Подход простой: агент сначала пытается работать с семантическими ссылками на элементы, а скриншот оставляет как fallback или канал верификации. Это особенно полезно там, где десктопное приложение отдает accessibility metadata через OS API или встроенный automation layer.
| Подход | Что является целью | Типичная поломка | Поведение в production |
|---|---|---|---|
| Pixels + coordinates | Точка на экране | Смещение layout, DPI, popup | Высокий drift, много ручных фиксов |
| OCR + CV heuristics | Текстовый паттерн на изображении | Шрифт, тема, язык, низкий контраст | Чуть лучше, но нестабильно |
| Accessibility tree refs | role/name/state элемента | Переименование labels, плохая a11y-разметка | Самый стабильный вариант |
| Hybrid | Semantic ref + screenshot verification | Частичная потеря metadata | Лучший компромисс |
Anthropic в документации по computer use прямо показывает важность структурированного представления интерфейса и аккуратного tool-use вокруг действий агента; это не случайность, а отражение реального ограничения visual control loops. Для production систем я исхожу из правила: если элемент можно адресовать как объект, нельзя по умолчанию адресовать его как картинку.
Как я перевожу desktop automation на stable UI refs
Практически это не “переписать все с нуля”. Нужен тонкий слой адаптации между агентом и UI.
1. Введите внутренний формат ссылки на элемент
Агенту нужен не raw dump дерева, а нормализованный ref: окно, role, name, path, state, confidence, timestamp. Тогда следующий шаг может опираться на ID и повторно разрешать его в живом интерфейсе.
from dataclasses import dataclass
from typing import Optional
@dataclass
class UIRef:
app: str
window: str
role: str
name: str
enabled: bool
path: str
value: Optional[str] = None
def is_actionable(ref: UIRef) -> bool:
return ref.enabled and ref.role in {
"button", "menuitem", "textbox", "checkbox", "link"
}2. Разрешайте действие через поиск по role/name/state
Вместо “кликнуть x=812, y=544” агент должен запрашивать “найти кнопку name=Save в окне Invoice Editor”. Если таких элементов несколько, добавляйте ancestor path и индекс только как вторичный признак.
def resolve_target(tree: list[dict], role: str, name: str) -> dict | None:
candidates = [
node for node in tree
if node.get("role") == role and node.get("name") == name
]
enabled = [n for n in candidates if n.get("enabled", True)]
return enabled[0] if enabled else None
target = resolve_target(ax_tree, role="button", name="Save")
if not target:
raise RuntimeError("Target not found")
perform_click(target["path"])3. Логируйте ref resolution отдельно от действия
Если агент не нашел элемент, это один класс инцидента. Если нашел не тот — другой. Если нашел верно, но приложение отказало по бизнес-правилам — третий. Без этого у вас все ошибки выглядят как “LLM тупит”, хотя проблема часто в UI contract.
4. Оставьте screenshot как проверку, а не первичный control channel
Скриншот все еще полезен: верифицировать, что после клика открылся правильный диалог, проверить наличие warning banner, приложить evidence в audit trail. Но не используйте его как единственный источник истины.
Минимальная orchestration-схема для production
Ниже — рабочий паттерн. n8n координирует шаги, Claude принимает решение, Postgres хранит состояние сессии, а UI-adapter вытаскивает accessibility tree и исполняет действие.
version: "1"
steps:
- name: snapshot_ui
tool: ui-adapter
action: get_accessibility_tree
- name: decide
tool: claude
input: ax_tree + task + history
- name: resolve_ref
tool: ui-adapter
action: resolve_and_validate
- name: execute
tool: ui-adapter
action: click_or_type
- name: persist
tool: postgres
action: write_eventДля regulated сред это еще и удобнее с точки зрения аудита. Вы можете сохранять не только финальный screenshot, но и structured event: какой role, какое name, какой window, какой операторский intent, какой результат. Это лучше ложится на внутренние требования контроля изменений и на ожидания вокруг трассируемости, чем набор PNG-файлов без контекста.
Где подход все еще ломается
Accessibility tree не магия. Некоторые legacy desktop apps публикуют плохую metadata, custom controls скрывают role, виртуализованные списки меняют структуру на лету. Поэтому я обычно использую hybrid strategy:
- semantic ref как primary selector;
- скриншот как post-action verification;
- retry только после нового UI snapshot, а не “кликнуть еще раз”;
- human handoff, если changed state не подтвержден за 2–3 шага.
Если у приложения плохая accessibility, не пытайтесь замаскировать это еще большим количеством prompt logic. Лучше построить adapter, который делает локальную нормализацию дерева, aliases для labels и белый список допустимых действий. Это дешевле, чем месяцами лечить flaky automation.
FAQ
Когда pixel-based automation все еще имеет смысл?
Когда у вас нет доступа ни к accessibility tree, ни к automation API, а процесс редкий и tolerates manual recovery. Но даже тогда я бы ограничивал такой подход узким контуром и держал человека в loop.
Что важнее: accessibility tree или OCR?
Если есть выбор, accessibility tree почти всегда лучше для управления. OCR полезен как дополнительный канал наблюдения, особенно для PDF, canvas или legacy экранов без семантики.
Нужно ли менять модель, чтобы перейти на UI refs?
Обычно нет. Смена control surface дает больше эффекта, чем замена модели, если корень проблемы в нестабильной адресации элементов.
Как хранить refs в Postgres?
Я храню snapshot дерева в JSONB, а события действий — отдельно, с session_id, step_id, target_ref и result. Это упрощает replay, расследование инцидентов и сравнение между версиями UI.
Что делать, если labels меняются от локализации?
Добавьте словарь aliases и разрешение по нескольким признакам: role, localized name, parent group, automation path. Не завязывайтесь на один текстовый label, если процесс живет в нескольких языках.
Если у вас уже есть computer-use агент в desktop flow, где именно сейчас копится больше инцидентов: на поиске элемента, на подтверждении changed state или на ручном разборе логов после сбоя? Мне правда интересно сравнить это с тем, что я вижу в DACH production. I run a free 30-min stack audit for DACH founders building AI in regulated markets. DM me on LinkedIn or write to @ger_dennis_ai.