跳到主要内容

第 1 章 · 主循环与动作执行

本章讲清"一次完整的循环"是怎么走的:目标怎么变成消息、循环怎么攒历史、动作怎么用 pyautogui 落地、循环怎么停。这是全框架的骨架,读懂它就懂了 SOC 的 80%。


1.1 它要解决的小问题

模型一次只能"看一张截图、说一步动作"。要完成一个多步任务(打开浏览器→搜索→读结果),就得反复"截屏—问—做",并且让模型知道之前做过什么。这就是一个 agent 循环要解决的:循环驱动 + 记忆累积 + 何时停。


1.2 思路 / 直觉

SOC 的选择是最朴素的那种:

  • 记忆 = 一个只增不减的 messages 数组。 系统 prompt 打头,之后每轮把"截图+提示"(user)和"模型的动作"(assistant)都追加进去。模型靠读整段历史知道"我之前干了啥"。
  • 循环 = while True 没有复杂调度,就是转圈。
  • 停 = 两条线。 模型主动说 done,或者转了 10 圈还没完就熔断。

没有规划器、没有反思、没有状态机。简单到可以一眼看完。


1.3 图示:一轮的生命周期

messages = [系统prompt] ← operate.py:99-101,循环外只建一次


┌───── while True (operate.py:107) ─────────────────────────┐
│ │
│ get_next_action(model, messages, objective, session_id) │ apis.py:34
│ └─ 内部:截屏 → 调模型 → 清洗 JSON → 追加 user/assistant │
│ 到 messages → 返回 operations(动作数组) │
│ │
│ stop = operate(operations, model) │ operate.py:134
│ └─ 逐个动作:sleep(1) → click/write/press/done │
│ │
│ if stop: break ← done 或 遇到未知动作 │
│ loop_count += 1 │
│ if loop_count > 10: break ← 熔断 │
└────────────────────────────────────────────────────────────┘

1.4 真实实现:循环本体

目标进来后,先组装初始消息,只组一次:

# operate.py:99-101(示意行为,真实符号见下)
system_prompt = get_system_prompt(model, objective)
system_message = {"role": "system", "content": system_prompt}
messages = [system_message]

然后是循环主体,见 operate/operate.py:107-131(main 里的 while True)。核心三行:

operations, session_id = asyncio.run(
get_next_action(model, messages, objective, session_id)
)
stop = operate(operations, model)
if stop:
break
  • get_next_action 是异步的,这里用 asyncio.run 每轮同步跑一次(operate.py:111)。
  • session_id 在几乎所有模型路径里都是 None(见 02-models-and-prompts.md),基本是预留字段。
  • 熔断在 operate.py:120: if loop_count > 10: break。这是防止无限循环烧 API 的唯一护栏。

关键细节: 消息历史永远只增不裁。长任务下 messages 会越堆越大(每轮多一张 base64 截图),token 成本线性上涨——这是 SOC 没处理的一个现实局限。


1.5 真实实现:动作执行 operate()

模型回的是一个动作数组,operate()(operate/operate.py:134)逐个处理。每个动作前先 time.sleep(1)(operate.py:141),给 UI 反应时间。

动作只有四种,靠 operation.get("operation").lower() 分派(operate.py:142):

动作字段落地方式
press / hotkeykeys(列表)operating_system.press(keys)
writecontent(字符串)operating_system.write(content)
clickxy(百分比)operating_system.mouse({x,y})
donesummary打印总结,return True(停机)

注意一个坑: 落到 else 分支(未知动作)时,代码 return True——也就是未知动作会直接终止整个任务(operate.py:172-179),而不是跳过重试。


1.6 巧妙 / 有趣之处:会"画圈"的点击

真正执行 click 的是 OperatingSystem.click_at_percentage(operate/utils/operating_system.py:39)。它没有直接"移到目标就点",而是先让鼠标绕着目标转一个半径 50px 的小圆圈再落点:

# operating_system.py:54-61(真实逻辑,简化注释)
start_time = time.time()
while time.time() - start_time < circle_duration: # 转 0.5 秒
angle = ((time.time() - start_time) / circle_duration) * 2 * math.pi
x = x_pixel + math.cos(angle) * circle_radius # 半径 50
y = y_pixel + math.sin(angle) * circle_radius
pyautogui.moveTo(x, y, duration=0.1)
pyautogui.click(x_pixel, y_pixel)

为什么这么做? 这是一个纯粹的"演示友好"设计:让观看 demo 的人能肉眼看到鼠标绕着目标转一圈,清楚知道 AI 打算点哪里,再落点。功能上完全没必要,但对一个 2023 年的 demo 很讨喜。

坐标怎么变像素: 模型给的 x/y 是 0~1 的百分比,click_at_percentagepyautogui.size() 拿屏幕分辨率再乘算成像素(operating_system.py:48-50)。中间还过一道 convert_percent_to_decimal(operate/utils/misc.py:5)容错。


1.7 write 的一个小细节

OperatingSystem.write(operating_system.py:10)逐字符敲键,而不是一次性粘贴:

content = content.replace("\\n", "\n") # 把字面量 \n 还原成真换行
for char in content:
pyautogui.write(char)

逐字敲更像真人打字,也更能触发某些输入框的 JS 监听;\\n → \n 的替换是因为 JSON 里换行常被转义成字面 \n


1.8 边界与局限

  • 消息历史无上限: 长任务 token 爆炸,messages 里塞满历史截图。
  • 10 轮熔断太短: 稍复杂的任务经常没做完(operate.py:120)。
  • 未知动作 = 直接终止: 而非跳过或纠错(operate.py:172)。
  • 每步固定 sleep 1 秒: 简单可靠但慢,无自适应等待。

1.9 代码地图

主题文件符号
主循环 / 熔断operate/operate.pymain(while Trueloop_count > 10)
动作分派operate/operate.pyoperate
click 落地 + 画圈operate/utils/operating_system.pyclick_at_percentagemouse
write 逐字敲键operate/utils/operating_system.pywrite
press 热键operate/utils/operating_system.pypress
百分比容错operate/utils/misc.pyconvert_percent_to_decimal