Webwright 与厂商无关的模型后端
本章讲什么: 一个基类怎么用「严格 JSON schema」把模型的输出焊死成可执行动作,以及解析、重试、 token 计量这些工程细节。看完你能加一个新厂商,或调解析/重试策略。
文件:src/webwright/models/base.py(基类)、openai_model.py、anthropic_model.py、
openrouter_model.py(三个子类,各 ≈150–200 行)。
1. 设计:一个基类 + 几个钩子
它要解决的小问题: 三个厂商的 API 各不相同(OpenAI 用 Responses API、Anthropic 用 Messages API), 但「发请求→拿文本→解析成动作→算 token→重试」这套流程完全一样。别重复三遍。
思路: 把共性全放进 BaseModel(base.py:225),子类只重写四类钩子:
| 钩子 | 干什么 |
|---|---|
_request_headers / _post_url | 认证头、端点 URL |
_build_payload / _build_text_payload | 把消息拼成该厂商的请求体 |
_extract_text | 从响应里抠出模型文本 |
_usage_metrics_from_payload | 读该厂商的 token 用量字段 |
新增一个厂商 = 建个子类填这几个钩子 + 声明 _API_KEY_FIELD / _ENV_VAR 等类常量。模型由
get_model(models/__init__.py:22)按配置的 model_class 造出来。
2. 严格 JSON:逼模型只吐结构化动作
它要解决的小问题: 01 章说动作是一段 JSON,但模型很容易吐出散文、code fence、 多个对象。得从源头逼它规规矩矩。
两道防线:
2.1 第一道:请求侧的 schema 约束(OpenAI 最强)
_response_schema(base.py:307)生成一个固定 schema:thought / <action_field> / done /
final_response 四个必填字段。OpenAI 子类把它塞进 Responses API 的 text.format 用
"type": "json_schema", "strict": True(openai_model.py:133)——API 层就保证返回是合法 JSON。
注意 action_field 是配置项(bash_command 或 python_code),所以同一套代码两种模式通用。
Anthropic/OpenRouter 没有等价的强约束,只能靠提示词(base.yaml 的 system 反复强调「single strict
JSON object, no code fences」)+ 第二道防线兜底。
2.2 第二道:解析 + 修复重试
_query_async(base.py:463)拿到模型文本后调 parse_json_output(base.py:107)解析。若失败,
不是直接报错,而是把错误当成一条「修复消息」追加进去,让模型重试(最多 MAX_JSON_PARSE_RETRIES=3,
base.py:28):
model.query
│
▼
拼 payload ──▶ 发 HTTP(带重试) ──▶ 抽文本 ──▶ parse_json_output
│
┌─── 成功 ────────┤
│ │
▼ └─ 失败且还有次数 ──▶ 追加修复消息,回到「拼 payload」
组装 assistant 消息
(thought + actions + done + usage)
│
用完 3 次仍失败 ──▶ 抛 FormatError(携带一条纠错 user 消息)
parse_json_output 里还有个贴心处理:若模型给了非空动作又把 done=true(严格 schema 下不该出现,
但非严格厂商会),就把 done 降级为 false(base.py:117)——宁可多跑一步,也不误判完成。
2.3 bash 命令还要过语法检查
工作区模式下,组装动作时会对 bash_command 跑一次 bash -n 语法检查(_validate_bash_command,
base.py:123)。语法错就抛 FormatError 让模型重写——避免把一条语法坏的命令送去执行浪费一整步。
3. 观察怎么变成消息
format_observation_messages(base.py:376)把环境返回的观察渲染成 user 消息:用配置的 Jinja2
observation_template 渲染文字,再按 attach_observation_screenshot 决定要不要把截图作为
input_image 附上(base.py:391)。图像用 base64 data-url 承载(image_part_from_path,base.py:143)。
各厂商对「文本+图像」的序列化不同,由子类的 payload 构造处理:OpenAI 走
_serialize_response_input(openai_model.py:38,把 system 映射成 developer 角色);
Anthropic 走 _serialize_anthropic_messages(anthropic_model.py:54,把 system 抽成顶层
system 字段,图像转成 {type:image, source:...})。