数据截至 (上游 commit fac568eea7da)
第 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 / hotkey | keys(列表) | operating_system.press(keys) |
write | content(字符串) | operating_system.write(content) |
click | x、y(百分比) | operating_system.mouse({x,y}) |
done | summary | 打印总结,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_percentage 用 pyautogui.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.py | main(while True、loop_count > 10) |
| 动作分派 | operate/operate.py | operate |
| click 落地 + 画圈 | operate/utils/operating_system.py | click_at_percentage、mouse |
| write 逐字敲键 | operate/utils/operating_system.py | write |
| press 热键 | operate/utils/operating_system.py | press |
| 百分比容错 | operate/utils/misc.py | convert_percent_to_decimal |