统一模型层:一套接口驱动约三十家提供商
30 秒导读: 评测框架要同时打 Anthropic、OpenAI、Google、vLLM…… 每家 API 的请求格式、错误码、限流规则都不一样。Inspect 用一个抽象类
ModelAPI把「发一次生成请求」这件事统一成一个方法,再用一个Model包装器在外面套上缓存、重试、并发控制、用量记账、事件记录——评测代码只写一次,换厂商只换名字。本章只讲「一次生成请 求」本身;工具调用的多轮循环归 04。
1. 这是什么(零基础也能懂)
一句话定义: 模型层是 Inspect 的「万能插座」——上层无论想调哪家大模型,都用同一个 model.generate(...) 调用;插座内部认得三十种不同的「插头」(厂商 API)。
它解决什么问题。 假设你写了一份评测,想看同一批题目在 Claude、GPT-5、本地 vLLM 上分别得几分。这三家的 SDK 完全不同:
| 差异点 | 各家的花样 |
|---|---|
| 消息格式 | OpenAI 的 messages、Anthropic 的 system+messages+tools、Google 的 Content/Part |
| 限流与重试 | 谁返回 429、谁把「过载」塞进 body、Retry-After 头给不给 |
| 参数名 | 一个叫 max_tokens,一个要 max_completion_tokens |
| 能力 | 能不能传图、能不能远程跑 MCP、要不要合并连续消息 |
如果每份评测都去适配这些,代码会烂成一团。模型层把这些差异全部关进厂商实现里,对外只暴露一个干净接口。
给谁用: 写评测任务(Task/Solver)的人、写 Agent 的人,以及想接入一家新厂商的人。
用起来什么样。 换模型只是换一个字符串:
# 示意,非源码 —— 真实 API 见下文引用
async with get_model("anthropic/claude-opus-4-8") as model:
output = await model.generate("用一句话解释什么是评测")
print(output.completion) # 模型说的话
print(output.usage) # ModelUsage: 输入/输出/推理 token 数
# 想换成 OpenAI?只改前缀:
model = get_model("openai/gpt-5")
# 想本地跑?
model = get_model("vllm/meta-llama/Llama-3.1-8B")
一句话直觉: 把 ModelAPI 当成一个接口协议(像 USB 标准),每家厂商写一个驱动去实现它;Model 则是驱动外面的一层电源管理——限流、断电重连(重试)、省电缓存、用电计量,全在这层做,驱动本身只管「把请求发出去、把回答收回来」。
本节不碰底层。记住一件事:generate 只有一个,厂商有三十个。
2. 顶层全景(它大概怎么转)
怎么读下面这张图: 从上到下是一次 model.generate() 的生命线。上半部(Model)是所有厂商共享的通用外壳;虚线以下(ModelAPI 子类)才是各家自己的地盘。横切能力(缓存/重试/用量)像三条腰带,箍在通用外壳上。
上层评测 / Solver / Agent
│ model.generate("...")
▼
┌───────────────────────────────────────────────┐
│ Model(通用外壳,所有厂商共享) │
│ │
│ ① 归一化输入(str→ChatMessageUser、插系统消息) │
│ ② 合并配置 GenerateConfig │
│ ③ 并发闸门(_connection_concurrency) │
│ ④ _generate 内层: │
│ 缓存查 → 记事件 → 调驱动 → 记用量 → 缓存存 │
│ └── 外裹 @retry(重试 + 自适应并发) │
└───────────────────────────┬───────────────────┘
腰带: 缓存 _cache │ 重试 _retry │ 用量/成本 记账
│ await self.api.generate(...)
┌───────────── 虚线以下:各家驱动 ────────────┐
▼ ▼ ▼
AnthropicAPI OpenAIAPI MockLLM / vLLM / …
resolve_chat_input completion_params (约 30 个 @modelapi)
→ Anthropic 消息 → OpenAI 消息 每个把 Inspect 消息
→ 收回 → ModelOutput → 收回 → ModelOutput 翻译成自家格式再翻回
└───────── 跨厂商格式转换 _*_convert.py ──────┘
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
ModelAPI | 抽象基类,定义「一家厂商驱动」的契约 | model/_model.py:186 |
Model | 面向用户的包装器,叠加横切能力、跑生成流程 | model/_model.py:601 |
get_model | 按字符串解析并实例化模型(带记忆化) | model/_model.py:1653 |
ModelName | 把 provider/name 拆开,支持模式匹配 | model/_model.py:1600 |
modelapi | 注册装饰器,把驱动挂进全局注册表 | model/_registry.py:30 |
GenerateConfig | 一次生成的所有可调参数 | model/_generate_config.py:195 |
ChatMessage* / ModelOutput | 厂商中立的消息与输出数据结构 | model/_chat_message.py、model/_model_output.py |
| 转换层 | Inspect 消息 ↔ 各家原生格式 | model/_openai.py、_anthropic_convert.py、_google_convert.py |
| 横切三件套 | 缓存 / 重试 / token 与用量 | model/_cache.py、_retry.py、_tokens.py |
主线走一遍(高层,不进代码):
输入(str 或 消息列表)
→ Model 归一化 + 合并配置 + 占并发额度
→ 缓存命中?命中就直接返回,顺便记一条"cache read"事件
→ 未命中:记一条待完成事件 → 调对应驱动 self.api.generate()
驱动:把消息翻成自家格式 → 打真实 HTTP → 把回答翻回 ModelOutput
→ 记用量/算成本/查预算限额 → 写缓存 → 补完事件
→ 出错则按分类重试(429 缩并发、5xx 只标记)
→ 返回 ModelOutput
3. 核心机制(逐个拆)
3.1 抽象基类 ModelAPI:一家驱动要签的契约
它要解决的小问题: 三十家厂商千差万别,但上层只想调一个方法。得先定义「一家驱动至少要提供什么」。
思路: 用 Python 的抽象基类。只有一个方法是强制的——generate;其余几十个方法都有默认实现,厂商按需覆写去申报自己的怪癖。
唯一的抽象方法(子类必须实现):
# _model.py:309 ModelAPI.generate —— 真源码签名
@abc.abstractmethod
async def generate(
self,
input: list[ChatMessage],
tools: list[ToolInfo],
tool_choice: ToolChoice,
config: GenerateConfig,
) -> ModelOutput | tuple[ModelOutput | Exception, ModelCall]:
...
注意返回类型的巧思:可以只返回 ModelOutput,也可以返回 (输出, ModelCall) 的元组。ModelCall 装的是原始 HTTP 请求/响应,返回它,Model 层就能把它写进事件日志供事后取证(_model.py:326 的 docstring 说明了这点)。甚至允许返回 (Exception, ModelCall)——把「可恢复的错误」连同现场一起交回,由 Model 决定是否转成一条 refusal 输出。
其余方法都是「能力自述」,基类给保守默认,厂商覆写来声明差异。举几个有代表性的:
| 方法 | 默认 | 覆写它是在说什么 | 引用 |
|---|---|---|---|
max_connections() | DEFAULT_MAX_CONNECTIONS | 我这家默认能并发几路 | _model.py:413 |
connection_key() | "default" | 哪些实例该共享一个连接池/限流预算 | _model.py:417 |
should_retry(ex) | False | 这个异常该不该重试、算限流还是算瞬时 | _model.py:448 |
collapse_user_messages() | False | 我要求把连续 user 消息合并成一条 | _model.py:477 |
tools_required() | False | 消息流里出现过工具,就必须传工具定义 | _model.py:489 |
supports_remote_mcp() | False | 我支持远程执行 MCP 工具 | _model.py:493 |
tool_result_images() | False | 工具结果里可以带图 | _model.py:497 |
count_tokens() | 字符启发式 | 我有原生 token 计数,替掉估算 | _model.py:332 |
关键细节 —— API key 的两个身份。 基类 __init__ 里存了 self.api_key(会被 hook 轮换)和 self.initial_api_key(构造时固定、永不变)(_model.py:216-224)。为什么要两个?因为 connection_key() 若拿会变的 api_key 做键,一旦凭据热轮换,已学到的连接池/自适应并发状态就全丢了;固定的 initial_api_key 保证「同一账号」的池身份稳定(docstring 在 _model.py:417)。
3.2 Model 包装器:一次生成请求的完整生命线
它要解决的小问题: 驱动只管「发请求收回答」,但生产级评测还要缓存、重试、限流、记账、写日志。这些不该塞进每家驱动,否则三十份代码各写一遍。
思路: Model 把驱动 self.api 包在里面,所有横切逻辑集中在这一层。用户调 Model.generate,真正打 API 的是 self.api.generate。
外层 Model.generate:准备与收尾(_model.py:703)
它做的是「请求前的规整」和「响应后的盖章」:
- 若上层在跑评测,更新 epoch(让不同 epoch 的相同请求不会互相命中缓存)(
_model.py:728)。 - 先查消息数限额,已到限就直接抛错,省掉一次浪费的生成(
_model.py:740)。 - 合并配置(见 3.4),
max_tokens若为空则向驱动要默认值(_model.py:753)。 - 归一化输入:
str变成一条ChatMessageUser;config.system_message若有,插到最前面(_model.py:759-764)。 - 进并发闸门
_connection_concurrency,调内层_generate,再把真实起止时间、工作耗时盖回那条ModelEvent(_model.py:769-795)。
内层 _generate:先把消息「适配」到这家驱动(_model.py:977)
在真正打 API 之前,有一串按驱动能力自述来做的消息改写——这是「统一」二字的核心工程:
prepare_tools 准备工具定义(04 细讲)
→ 不支持远程 MCP 却传了 MCP server?直接报错 (_model.py:1004)
→ tool_choice 指定了某个工具?过滤掉其它工具 (_model.py:1013)
→ tool_choice=="none" 或无工具?清空工具(除非 tools_required) (_model.py:1022)
→ resolve_reasoning_history:按 reasoning_history 规则裁推理内容 (_model.py:1030)
→ resolve_tool_model_input:跑工具的自定义 model_input 处理器 (_model.py:1033)
→ 驱动不支持工具结果带图/带文档?把媒体抽成单独 user 消息 (_model.py:1042)
→ collapse_consecutive_messages_for_api:按驱动要求合并连续消息 (_model.py:1049)
resolve_reasoning_history(_model.py:1887)值得单看:推理模型(如 o 系列、Claude thinking)会吐「思考过程」,回灌历史时留多少由 reasoning_history 决定——all 全留、none 全删、last 只留最后一段;auto 则把决定权交回驱动的 auto_reasoning_history()。这正是「统一层定策略、厂商定细节」的缩影。
内层的内层:被 @retry 包住的 generate()(_model.py:1078)
真正打 API 的闭包,外面裹着 tenacity 重试。一次调用依次:
- 触发
emit_before_model_generate钩子(_model.py:1084)。 - 查缓存:命中就记一条
cache="read"事件、标记「本次是缓存命中」(供自适应并发跳过成功计数),直接返回(_model.py:1100-1136)。 - 未命中:
verify_model_apis()检查是否被INSPECT_DISABLE_MODEL_API禁用(_model.py:1141);记一条pending事件。 - 在可选的
attempt_timeout超时上下文里,await self.api.generate(...)(_model.py:1180)——这一行就是交给驱动的地方。 - 拆返回值(可能是
ModelOutput,也可能是(输出|异常, ModelCall));若是异常,包成带截断请求体的RuntimeError(最多留后 200 行,免得刷屏)(_model.py:1205-1221)。 - 给每个工具调用补
view、补完事件(complete(output, call))。 - 记用量:
record_and_check_model_usage(见 3.6 / 第 4 节),发emit_model_usage遥测。 - 写缓存
cache_store(_model.py:1251)。
出了这层闭包,外层还做三件「每次顶层生成只算一次」的事:记录模型 fallback、record_turn() 记一个回合、给自适应并发控制器发「干净成功」信号(无重试且非缓存命中才发)(_model.py:1266-1296)。
generate_loop(_model.py:802) 是薄薄一层便利封装:反复 generate + execute_tools,直到模型不再调工具为止,返回完整消息列表。工具循环的机制归 04,这里只指出它复用同一个 generate。
3.3 模型解析与注册:一个字符串怎么变成一个驱动实例
它要解决的小问题: 用户只写 "anthropic/claude-opus-4-8",系统怎么找到 AnthropicAPI 并 new 出来?
思路: 全局注册表 + 前缀匹配 + 记忆化。
注册端(_registry.py)。 每家驱动用 @modelapi(name="...") 装饰,把类型登记进全局注册表,类型标签 type="modelapi"。providers.py 集中放这些装饰过的工厂函数,而且延迟导入——只有真被用到时才 import anthropic,所以缺装某家 SDK 不影响整个包加载(_providers/providers.py:56-64 的 anthropic() 是范本)。本仓共约 30 个:
# _providers/providers.py —— 真源码,注册举例
@modelapi(name="anthropic")
def anthropic() -> type[ModelAPI]:
validate_anthropic_client("Anthropic API") # 版本校验
from .anthropic import AnthropicAPI # 延迟导入
return AnthropicAPI
解析端 get_model(_model.py:1653)。 关键步骤:
传进来已是 Model 实例?原样返回 (_model.py:1715)
"none" → "none/none" 的特判 (_model.py:1719)
解析 role(命名角色,如 grader/attacker),命中则返回 (_model.py:1723)
记忆化:用 model+role+config+base_url+api_key+model_args 拼 key
命中缓存直接 返回(mockllm 除外,它的输出是无限生成器)(_model.py:1768)
按 "/" 拆成 api_name + model:registry_find 找匹配的 modelapi
→ 实例化 modelapi_type(model_name=..., **model_args)
→ 包成 Model 返回;找不到则报清晰错误 (_model.py:1804-1832)
记忆化很重要:get_model() 同参多次调用返回同一个实例(_model.py:1667 docstring),这样一份评测里的三个模型(被测/攻击/评分)各自复用连接池,而不是每次 new。
ModelName(_model.py:1600) 是给任务做结构化模式匹配用的。它把 provider/name 拆成 .api 和 .name,并重写了 __eq__ 做子串包含匹配——于是任务里可以写 if ModelName(model) == "gpt-4": 来对「一族模型」条件化行为,不必精确写全名(_model.py:1632-1638)。
3.4 GenerateConfig:一次生成的所有旋钮,以及它们怎么合并
它要解决的小问题: 温度、max_tokens、推理力度、限流、缓存策略…… 几十个参数,有的全厂商通用、有的只对某家有效,还 得能层层覆盖。
结构(_generate_config.py:195)。 一个 Pydantic 模型,字段全部可空(None = 未设)。docstring 里逐个标注「谁支持」——例如 top_k 只对 Anthropic/Google/HF/vLLM 生效(_generate_config.py:243),effort 只对 Claude Opus 4.5+ 生效(_generate_config.py:283)。这是一个「超集配置」:统一层收下所有旋钮,厂商各取自己认得的。
它还做了两件防呆:
- 拒绝未知字段(除非在读旧日志):写错参数名当场报错,并提示「provider 专属选项请用
extra_body」(_generate_config.py:325)。 - 迁移旧格式:
reasoning_history=True/False自动转成"all"/"none"(_generate_config.py:349)。
合并语义 merge(_generate_config.py:361)。 规则一句话:非 None 的字段覆盖,None 的保持不动。这让配置能像图层一样叠加。
Model._resolve_config(_model.py:1449)把这套叠法用出了层次:
本模型自带 config
→ 若本模型是当前活动模型:整份合并「活动生成配置」
→ 否则:只继承其中的运营类参数
(max_connections / adaptive_connections / max_retries / timeout / cache)
→ 最后再合并调用时传入的 config(优先级最高)
「非活动模型也要继承运营参数」这条很实用:评分模型、攻击模型不是被测主角,但它们的限流、超时、缓存策略应当跟随全局设置,否则会各行其是。
3.5 消息与输出:厂商中立的数据结构
它要解决的小问题: 输入输出得有一套不绑定任何厂商的表示,转换层才有共同的「中间语言」。
输入侧 —— 四种 ChatMessage(_chat_message.py:210):
| 类型 | 角色 | 特有字段 |
|---|---|---|
ChatMessageSystem | system | — |
ChatMessageUser | user | tool_call_id(承接工具结果时) |
ChatMessageAssistant | assistant | tool_calls、model |
ChatMessageTool | tool | tool_call_id、function、error |
它们共享基类 ChatMessageBase,content 可以是纯字符串,也可以是 list[Content](文本/图/音/视频/文档/推理块混排)。一个便利属性 .text(_chat_message.py:89)把混排内容里的文本抽出来拼接,让 Solver 能「当纯字符串」处理消息。
输出侧 —— ModelOutput(_model_output.py:259):
ModelOutput
├─ choices: list[ChatCompletionChoice] # 每个含 message + stop_reason(+ logprobs)
├─ completion: str # 便利属性,= choices[0] 的文本
├─ usage: ModelUsage | None # token 计数
├─ fallback: ModelFallback | None # 是否被别的模型代打了
└─ time / metadata / error
ModelUsage(_model_output.py:16)是记账的原子单位,重写了 __add__——用 optional_sum 把可空字段(缓存读写、推理 token、成本)逐项累加(_model_output.py:45),这样多次调用的用量能直接 + 起来汇总。
StopReason(_model_output.py:95)把各家五花八门的结束原因收敛成 6 个标准值(stop/max_tokens/model_length/tool_calls/content_filter/unknown);as_stop_reason(_model_output.py:426)负责把厂商字符串("length"、"eos"、"function_call"…)映射过来。这是「统一」在输出侧的体现。
3.6 跨厂商格式转换:中间语言 ↔ 各家方言
它要解决的小问题: 上面那套中立结构,到了每家 API 门口都得翻译成人家认的样子,收回来再翻回中立结构。
思路: 每家一个转换模块,提供双向函数。命名很规整:messages_to_<家> / messages_from_<家> / model_output_from_<家>。
| 方向 | OpenAI | Anthropic | |
|---|---|---|---|
| Inspect → 原生 | messages_to_openai (_openai.py:295) | resolve_chat_input (_providers/anthropic.py:1399) | — |
| 原生 → Inspect | messages_from_openai (_openai.py:511) | messages_from_anthropic (_anthropic_convert.py:12) | messages_from_google (_google_convert.py:35) |
| 响应 → ModelOutput | model_output_from_openai (_openai.py:872) | model_output_from_anthropic (_anthropic_convert.py:33) | model_output_from_google (_google_convert.py:72) |
参数翻译单独有函数:openai_completion_params(_openai.py:308)把 GenerateConfig 逐字段挑进 OpenAI 的参数字典——只搬「这家认得」的,prompt_logprobs 这类小众项塞进 extra_body。
Anthropic 侧最能体现方言之深:resolve_chat_input(_providers/anthropic.py:1399)把系统消息、工具、MCP server、缓存断点分别拆出来;generate(_providers/anthropic.py:419)再据此拼请求,并按用到的工具类型逐个追加 beta 头(computer-use、interleaved-thinking、code-execution……)(_providers/anthropic.py:471-536)。这些琐碎全被关在驱动里,上层毫不知情。
4. 横切三件套:缓存、重试/自适应并发、token 与用量
这三样是「腰带」——不属于任何一家厂商,而是箍在 Model 层上、对所有厂商生效。
4.1 缓存(_cache.py)
动机: 迭代评测时反复打同样的请求很烧钱。相同请求应能命中磁盘缓存。
键怎么算(_cache.py:139 _cache_key) 是关键。它把「会影响输出的东西」全序列化进键:config(但排除 max_connections/max_retries/timeout/cache/batch 这些不影响结果的运营项)、消息(排除 id)、base_url、tool_choice、tools、过期时长、自定义 scopes,再按 per_epoch 决定是否掺入 epoch(_cache.py:140-171)。最后 md5 成文件名。
为什么掺 epoch: 多 epoch 时同一请求其实是「故意重复打多次」,scorer 要跨 epoch 聚合,所以每个 epoch 得单独缓存(_cache.py:91-98 注释说明)。
存取用 pickle 落到 inspect_cache_dir("generate") 下,读时顺带检查过期、过期即删(cache_fetch _cache.py:216、cache_store _cache.py:196)。CachePolicy(_cache.py:58)控制过期(默认 "1W")、是否 per-epoch、附加 scopes。
4.2 重试与自适应并发(_retry.py + RetryDecision)
动机: 限流(429)、瞬时 5xx、超时都该重试,但处理方式不同——限流要「退一步、把并发调低」,瞬时抖动只需重试别调整。
分类载体 RetryDecision(_model.py:136)。 一个 frozen dataclass,三个工厂:no()、transient()、rate_limit(),可携带服务端建议的 retry_after。它的 __bool__ 返回 retry 字段,所以老代码写 if api.should_retry(ex): 仍然工作——向后兼容