跳到主要内容

第 2 章 · 多模型分发、Prompt 设计与回退

本章讲三件事:①一个入口怎么分发到十来个模型;②系统 prompt 怎么把"操作电脑"这件开放的事约束成模型能稳定输出的 4 种 JSON 动作;③任何一家模型出错,框架怎么逐级回退兜底。


2.1 分发:一个 if-else 打发所有模型

所有模型走同一个入口 get_next_action(operate/models/apis.py:34)。它就是一串 if model == ...,把每个模型名映射到一个具体的 call_* 协程/函数:

# apis.py:34-65(结构示意,真实符号见下)
if model == "gpt-4": return call_gpt_4o(messages), None
if model == "gpt-4-with-ocr": return await call_gpt_4o_with_ocr(...), None
if model == "gpt-4.1-with-ocr": return await call_gpt_4_1_with_ocr(...), None
if model == "o1-with-ocr": return await call_o1_with_ocr(...), None
if model == "claude-3": return await call_claude_3_with_ocr(...), None
if model == "qwen-vl": return await call_qwen_vl_with_ocr(...), None
if model == "gemini-pro-vision":return call_gemini_pro_vision(...), None
if model == "llava": return call_ollama_llava(messages), None
if model == "gpt-4-with-som": return await call_gpt_4o_labeled(...), None
raise ModelNotRecognizedException(model)

返回值总是 (operations, session_id) session_id 在所有分支都是 None——它是预留字段,当前没被真正使用。不认识的模型名抛 ModelNotRecognizedException(operate/exceptions.py:1),被主循环捕获后终止。

各调用函数的骨架都长一样

除了具体调哪家 API、用哪种定位法,每个 call_* 的流程几乎相同:

sleep(1) → 初始化客户端(config.initialize_*)
→ confirm_system_prompt(重置 messages[0])
→ 截屏 → base64 编码
→ 选 user prompt(首轮 vs 后续)
→ 组 vision_message(文字 + 图) 追加到 messages
→ 调模型 API
→ clean_json 清洗 → json.loads
→ (若含 click)用定位法补 x/y
→ 追加 assistant 消息
→ 返回 processed_content

call_gpt_4o_with_ocr(apis.py:314)为最典型代表。区别只在:调 OpenAI 还是 Anthropic、click 用 OCR 还是 YOLO(见 03-grounding.md)。


2.2 首轮与后续用不同的 user prompt

每个 call_* 里都有这句判断(如 apis.py:335-338):

if len(messages) == 1:
user_prompt = get_user_first_message_prompt()
else:
user_prompt = get_user_prompt()
  • 首轮OPERATE_FIRST_MESSAGE_PROMPT(prompts.py:198),它额外提醒模型:"你现在在终端里,代码正跑在这个终端标签页,想离开终端就去 OS 里搜别的程序"——帮模型建立初始情境。
  • 后续用更短的 OPERATE_PROMPT(prompts.py:205),只说"请给下一步最佳动作"。

2.3 Prompt 设计:把"操作电脑"压成 4 种 JSON 动作

这是整个框架最关键的约束设计。系统 prompt 明确告诉模型:你只有 4 个动作,而且输出会被 json.loads 解析,所以必须是合法 JSON 数组。

三套系统 prompt 对应三种定位法,靠 get_system_prompt(prompts.py:210)按模型名选:

系统 prompt用于哪些模型click 动作怎么表达
SYSTEM_PROMPT_OCR(prompts.py:132)ocr 系 / claude-3 / qwen-vl"text": "要点的按钮/链接上的文字"
SYSTEM_PROMPT_LABELED(prompts.py:69)gpt-4-with-som"label": "~x"(点某个编号的框)
SYSTEM_PROMPT_STANDARD(prompts.py:11)其余(gpt-4、gemini、llava)"x": "0.10", "y": "0.13"(直接给百分比)

三者的动作契约一致——都是 4 个动作,只有 click 的定位字段不同。以 OCR 版为例,prompt 里直接给出格式(prompts.py:139-154):

1. click - 找要点的文字
[{ "thought": "...", "operation": "click", "text": "按钮上的文字" }]
2. write - 打字
[{ "thought": "...", "operation": "write", "content": "要写的字" }]
3. press - 按热键
[{ "thought": "...", "operation": "press", "keys": ["要按的键"] }]
4. done - 完成
[{ "thought": "...", "operation": "done", "summary": "做了什么" }]

Prompt 里几处巧妙的"驯服"技巧

  • 强制 thought 字段: 每个动作都要先写一句想法。这是让模型"先想再动"的轻量 chain-of-thought,也方便人读日志。
  • 给 few-shot 例子: prompt 里带了 2~3 个完整例子(如"搜索 Google Chrome 并打开"、"在地址栏输入网址"),用 {os_search_str}{cmd_string} 按操作系统填不同热键(Mac 用 command space,Windows 用 win)——见 get_system_prompt 的平台判断(prompts.py:215-226)。
  • 明确反拒答: prompt 里写"不许说你无法协助——你可以通过发文字给用户来间接操作 OS"(prompts.py:63),压住模型的安全性拒答倾向。
  • OCR 版给了逃生舱: 允许模型在"没有相关文字可点"时返回 "nothing to click",框架会换别的方法(prompts.py:139)。

2.4 清洗:clean_json 把模型的啰嗦剥掉

模型经常把 JSON 包在 ```json ... ``` 代码围栏里,或加前后废话。clean_json(apis.py:1117)负责剥壳:

# apis.py:1117-1139(简化)
if content.startswith("```json"): content = content[len("```json"):].strip()
elif content.startswith("```"): content = content[len("```"):].strip()
if content.endswith("```"): content = content[:-len("```")].strip()
content = "\n".join(line.strip() for line in content.splitlines())

剥掉围栏、逐行 strip,再交给 json.loads。这是应对 LLM "话痨"输出的常见防御。


2.5 回退:一切失败都回到裸 gpt-4o

SOC 的容错哲学是"出错就降级"。每个 call_*except 分支都不是简单报错,而是回退到一个更基础的模型:

call_qwen_vl_with_ocr ─┐
call_gpt_4o_with_ocr ─┤ except → gpt_4_fallback() ─► call_gpt_4o(裸 gpt-4o)
call_gpt_4_1_with_ocr ─┤
call_o1_with_ocr ─┤
call_claude_3_with_ocr ─┘

call_gemini_pro_vision ── except → call_gpt_4o(直接)
call_gpt_4o_labeled ── except → call_gpt_4o(直接)
call_gpt_4o ── except → 递归重试 call_gpt_4o(自己再试一次)
call_ollama_llava ── except → 递归重试 call_ollama_llava

gpt_4_fallback(apis.py:1077)做的事:把 messages[0] 的系统 prompt 换成标准版,然后调 call_gpt_4o

Claude 回退最有意思:现场转消息格式

Anthropic 和 OpenAI 的图片消息格式不同(Anthropic 用 source.data,OpenAI 用 image_url.url)。当 call_claude_3_with_ocr 失败要回退到 gpt-4o 时,它得把整段 Claude 格式的消息历史现场翻译成 OpenAI 格式再回退(apis.py:1034-1060):

# apis.py:1040-1050(简化:把 image 块从 Anthropic 格式转成 OpenAI 格式)
if item["type"] == "image":
updated_content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{item['source']['data']}"},
})

另一处 Claude 专属韧性: Claude 有时输出不合法 JSON,call_claude_3_with_ocr再发一次请求,让 Claude 自己"修正上一段 JSON"(apis.py:952-968)——把 JSONDecodeError 的报错文本喂回去当系统提示。这是很实用的"让模型自我纠错"技巧。


2.6 配置:一个管所有 API key 的单例

Config(operate/config.py:12)是单例(__new___instance 那套),负责:

  • 各家 API 客户端初始化:initialize_openaiinitialize_anthropicinitialize_googleinitialize_qweninitialize_ollama
  • 按需索要 key: validation(config.py:131)根据选的模型判断需要哪个 key;缺了就弹对话框让用户输入,并追加写进 .env(save_api_key_to_env,config.py:184)。
  • Qwen 复用 OpenAI SDK,只是把 base_url 指向阿里 DashScope 的兼容端点(config.py:87-92)。

2.7 边界与局限

  • 分发是硬编码 if-else: 加模型要改 get_next_actionget_system_promptconfig 三处,没有注册表(对比 cua 的 loop 注册表)。
  • 回退可能悄悄换模型: 你选了 claude-3,出错后实际在用 gpt-4o,日志里才看得出来。
  • gpt-4o 硬编码为回退底座: 没有 OpenAI key 时回退链会直接崩。
  • session_id 是死字段: 全程 None,暗示原本想接有状态 session 但没落地。

2.8 代码地图

主题文件符号
模型分发operate/models/apis.pyget_next_action
典型 OCR 调用operate/models/apis.pycall_gpt_4o_with_ocr
Claude 调用 + 自纠错 + 格式转换operate/models/apis.pycall_claude_3_with_ocr
回退底座operate/models/apis.pygpt_4_fallbackcall_gpt_4o
JSON 清洗operate/models/apis.pyclean_json
重置系统 promptoperate/models/apis.pyconfirm_system_prompt
三套系统 promptoperate/models/prompts.pySYSTEM_PROMPT_OCRSYSTEM_PROMPT_LABELEDSYSTEM_PROMPT_STANDARD
prompt 选择 + 平台热键operate/models/prompts.pyget_system_prompt
user promptoperate/models/prompts.pyget_user_first_message_promptget_user_prompt
配置 / key 管理operate/config.pyConfigvalidationsave_api_key_to_env