数据截至 (上游 commit bc26750af3ad)
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:...})。
4. 重试:分清「限速」和「瞬时故障」
它要解决的小问题: 高并发下 Claude 的组织级 ITPM 限额会卡好几分钟;网关偶发 5xx/超时也常见。 两类错误该用不同的退避节奏。
_post_with_retries(base.py:431)对每次 HTTP 请求分类:
| 类别 | 判定 | 退避 | 次数上限 |
|---|---|---|---|
| 限速 | 429 / 文本含 "rate limit" 等(_is_rate_limit_error,base.py:56) | _rate_limit_backoff | _MAX_RATE_LIMIT_RETRIES |
| 瞬时 | 超时/网络错/408,409,425,500,502,503,504(_is_transient_http_error,base.py:72) | _transient_backoff | _MAX_TRANSIENT_RETRIES |
| 其他 | —— | 不重试,记日志后抛 | —— |
Anthropic 子类大幅上调了这些数值:限速重试 50 次、退避 30–60 秒随机,还会读响应头
retry-after(anthropic_model.py:19 及 _rate_limit_backoff,anthropic_model.py:162)——
因为 Claude Opus 的限额更容易长时间打满。OpenAI 子类则用基类默认的 5 次(openai_model.py:115)。
所有重试事件都写进 runtime_errors.jsonl(_log_gateway_error,base.py:341)。
5. token 计量:请求侧估算 + 响应侧真实用量
每次调用维护两套指标,都分「本次」和「累计」:
- 请求侧(估算): 从序列化后的输入数消息数、文本/图像分片数、字符数
(
_request_metrics_from_serialized_input,base.py:160)。 - 响应侧(真实): 从各厂商 usage 字段读 input/output/cached/reasoning token
(OpenAI 的
_usage_metrics_from_response_payload,openai_model.py:85;Anthropic 读cache_read_input_tokens,anthropic_model.py:99)。
这些累计进 _usage_snapshot(base.py:321),最终写进 trajectory.json 的 model.usage——
README.md 里那张 Webwright vs Codex 的 token 对比表就是靠它。这也是 Webwright 省 token 的证据来源。
6. 一个额外能力:纯文本补全(给工具用)
BaseModel.__call__(base.py:565)走 _complete_text_async——不套 JSON schema、不解析动作,
就是「一堆消息进,一段文本出」。这是给 image_qa、self_reflection 这类工具用的:它们要问模型
视觉问题、要裁判截图,不需要动作协议。load_tool_model(见 04 章)让这些
工具复用 agent 用的同一个模型,所以 Anthropic 跑不需要额外 OpenAI key(README.md「Run」)。
7. 巧妙之处
- 解析失败 = 对话式修复,而非硬失败。 把 JSON 错误当成一条修复消息喂回,让模型自己纠正
(
base.py:494)——比直接报错健壮得多。 done+ 非空动作 = 自动降级。 防止非严格厂商误触发完成(base.py:117)。bash -n前置校验。 语法坏的命令根本不送去执行,省一整步(base.py:123)。- 重试策略按厂商定制。 同一套骨架,Anthropic 把限速重试拉到 50 次——针对性解决 Opus 限额痛点。
action_field一处配置、两种模式通用。 schema、动作组装、校验全读它,工作区/实时模式共用一套模型层。
8. 边界与局限
- 严格 schema 只有 OpenAI 真强。 Anthropic/OpenRouter 靠提示词 + 解析重试兜底,极端情况下仍可能
连错 3 次抛
FormatError。 - 只支持这三家 HTTP 后端。 本地模型/其他厂商要自己 写子类(填四类钩子即可)。
- 请求侧 token 是估算(数字符,非真实 tokenizer),只有响应侧用量是准的。
_complete_text_async会临时改max_output_tokens再恢复(base.py:537),非线程安全—— 工具是串行调用,所以没问题,但别并发复用同一个 model 实例做文本补全。
9. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 模型基类 | src/webwright/models/base.py | BaseModel |
| 主查询流程 + 解析重试 | src/webwright/models/base.py | BaseModel._query_async |
| JSON 解析 + done 降级 | src/webwright/models/base.py | parse_json_output |
| 严格动作 schema | src/webwright/models/base.py | BaseModel._response_schema |
| bash 语法校验 | src/webwright/models/base.py | _validate_bash_command |
| HTTP 重试(限速/瞬时) | src/webwright/models/base.py | BaseModel._post_with_retries |
| 错误分类 | src/webwright/models/base.py | _is_rate_limit_error / _is_transient_http_error |
| 观察→消息 | src/webwright/models/base.py | BaseModel.format_observation_messages |
| 纯文本补全(工具用) | src/webwright/models/base.py | BaseModel._complete_text_async / BaseModel.__call__ |
| OpenAI 后端 | src/webwright/models/openai_model.py | OpenAIModel |
| Anthropic 后端 + 重试上调 | src/webwright/models/anthropic_model.py | AnthropicModel |
| 模型工厂 | src/webwright/models/__init__.py | get_model |
下一步: 工作区模式凭什么敢让「模型说完成」不算数?看 04-completion-gate.md。