跳到主要内容

Webwright 与厂商无关的模型后端

本章讲什么: 一个基类怎么用「严格 JSON schema」把模型的输出焊死成可执行动作,以及解析、重试、 token 计量这些工程细节。看完你能加一个新厂商,或调解析/重试策略。

文件:src/webwright/models/base.py(基类)、openai_model.pyanthropic_model.pyopenrouter_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_commandpython_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.jsonmodel.usage—— README.md 里那张 Webwright vs Codex 的 token 对比表就是靠它。这也是 Webwright 省 token 的证据来源。


6. 一个额外能力:纯文本补全(给工具用)

BaseModel.__call__(base.py:565)走 _complete_text_async——不套 JSON schema、不解析动作, 就是「一堆消息进,一段文本出」。这是给 image_qaself_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.pyBaseModel
主查询流程 + 解析重试src/webwright/models/base.pyBaseModel._query_async
JSON 解析 + done 降级src/webwright/models/base.pyparse_json_output
严格动作 schemasrc/webwright/models/base.pyBaseModel._response_schema
bash 语法校验src/webwright/models/base.py_validate_bash_command
HTTP 重试(限速/瞬时)src/webwright/models/base.pyBaseModel._post_with_retries
错误分类src/webwright/models/base.py_is_rate_limit_error / _is_transient_http_error
观察→消息src/webwright/models/base.pyBaseModel.format_observation_messages
纯文本补全(工具用)src/webwright/models/base.pyBaseModel._complete_text_async / BaseModel.__call__
OpenAI 后端src/webwright/models/openai_model.pyOpenAIModel
Anthropic 后端 + 重试上调src/webwright/models/anthropic_model.pyAnthropicModel
模型工厂src/webwright/models/__init__.pyget_model

下一步: 工作区模式凭什么敢让「模型说完成」不算数?看 04-completion-gate.md