Skip to content
Diffumist
Go back

Agent with a11y

这个点子一开始是在 Twitter 上看到 alma 的作者 @yetone 看到的,对此我挺感兴趣的,于是我也尝试了一下,这篇文章只记录一个初步思路。

相比截图识别,在 a11y 下,LLM Agent 看到的对象是结构化的,桌面下的 Electron 应用对 a11y 支持非常出色。

一个最小模型

type Node struct {
	ID       string   `json:"id"`
	Role     string   `json:"role"`
	Name     string   `json:"name"`
	Value    string   `json:"value,omitempty"`
	Enabled  bool     `json:"enabled"`
	Focused  bool     `json:"focused"`
	Actions  []string `json:"actions,omitempty"`
	Children []Node   `json:"children,omitempty"`
}

type Action struct {
	Type   string `json:"type"`
	NodeID string `json:"node_id,omitempty"`
	Text   string `json:"text,omitempty"`
}

type A11yDriver interface {
	Snapshot() (Node, error)
	Do(action Action) error
}

这里的 Snapshot 用来拿到当前界面的 a11y tree。

Do 用来执行动作。

Action{
	Type:   "input",
	NodeID: "message-box",
	Text:   "hello",
}

把 UI 树交给模型

LLM Agent 需要看到当前 UI 的可理解描述。最简单的方式是把树转成文本。

func RenderTree(n Node, depth int) string {
	indent := strings.Repeat("  ", depth)

	line := fmt.Sprintf(
		"%s- id=%s role=%s name=%q value=%q enabled=%v focused=%v actions=%v\n",
		indent,
		n.ID,
		n.Role,
		n.Name,
		n.Value,
		n.Enabled,
		n.Focused,
		n.Actions,
	)

	for _, child := range n.Children {
		line += RenderTree(child, depth+1)
	}

	return line
}

得到的内容可能类似这样:

- id=root role=window name="Chat" value="" enabled=true focused=false actions=[]
  - id=input-1 role=textbox name="Message" value="" enabled=true focused=true actions=[input]
  - id=button-1 role=button name="Send" value="" enabled=true focused=false actions=[press]

然后把它放进 prompt:

func BuildPrompt(goal string, treeText string) string {
	return fmt.Sprintf(`You are controlling a desktop app through accessibility APIs.

Goal:
%s

Current accessibility tree:
%s

Return exactly one JSON action.
Allowed action types:
- press
- input
- wait

JSON schema:
{
  "type": "press | input | wait",
  "node_id": "target node id",
  "text": "text for input action"
}
`, goal, treeText)
}

Agent 循环

一个最小循环可以写成这样:

type LLMClient interface {
	Complete(prompt string) (string, error)
}

type Agent struct {
	Driver A11yDriver
	LLM    LLMClient
}

func (a *Agent) Run(goal string, maxSteps int) error {
	for step := 0; step < maxSteps; step++ {
		root, err := a.Driver.Snapshot()
		if err != nil {
			return err
		}

		treeText := RenderTree(root, 0)
		prompt := BuildPrompt(goal, treeText)

		raw, err := a.LLM.Complete(prompt)
		if err != nil {
			return err
		}

		var action Action
		if err := json.Unmarshal([]byte(raw), &action); err != nil {
			return fmt.Errorf("invalid action json: %w\nraw: %s", err, raw)
		}

		if action.Type == "wait" {
			time.Sleep(time.Second)
			continue
		}

		if err := a.Driver.Do(action); err != nil {
			return err
		}
	}

	return nil
}

这段代码没有处理复杂状态,也没有加入记忆、回退、历史动作分析。它只是表达一个最小闭环:观察 UI → 让模型选择动作 → 执行动作 → 再观察 UI

一个简化版 Driver

真实的 a11y driver 会和系统 API 打交道。macOS 有 Accessibility API,Windows 有 UI Automation,Linux 桌面环境通常可以看 AT-SPI。

这里先写一个假的 driver,真实实现里,MockDriver 会替换成系统级 a11y driver。

type MockDriver struct {
	Tree Node
}

func (d *MockDriver) Snapshot() (Node, error) {
	return d.Tree, nil
}

func (d *MockDriver) Do(action Action) error {
	switch action.Type {
	case "press":
		fmt.Printf("press node: %s\n", action.NodeID)
	case "input":
		fmt.Printf("input node: %s text: %q\n", action.NodeID, action.Text)
	default:
		return fmt.Errorf("unknown action type: %s", action.Type)
	}

	return nil
}

主函数:

func main() {
	driver := &MockDriver{
		Tree: Node{
			ID:      "root",
			Role:    "window",
			Name:    "Chat",
			Enabled: true,
			Children: []Node{
				{
					ID:      "input-1",
					Role:    "textbox",
					Name:    "Message",
					Enabled: true,
					Focused: true,
					Actions: []string{"input"},
				},
				{
					ID:      "button-1",
					Role:    "button",
					Name:    "Send",
					Enabled: true,
					Actions: []string{"press"},
				},
			},
		},
	}

	agent := &Agent{
		Driver: driver,
		LLM:    MockLLM{},
	}

	if err := agent.Run("Send a greeting message.", 3); err != nil {
		panic(err)
	}
}

输出结果类似:

input node: input-1 text: "hello from agent"

其他注意点



Previous Post
反工具化角色被处理成工具:叙事的自我证伪
Next Post
从 Pagefind 换到 MiniSearch 踩的坑