跳到主要内容

统一模型层:一套接口驱动约三十家提供商

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
ModelNameprovider/name 拆开,支持模式匹配model/_model.py:1600
modelapi注册装饰器,把驱动挂进全局注册表model/_registry.py:30
GenerateConfig一次生成的所有可调参数model/_generate_config.py:195
ChatMessage* / ModelOutput厂商中立的消息与输出数据结构model/_chat_message.pymodel/_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)

它做的是「请求前的规整」和「响应后的盖章」:

  1. 若上层在跑评测,更新 epoch(让不同 epoch 的相同请求不会互相命中缓存)(_model.py:728)。
  2. 先查消息数限额,已到限就直接抛错,省掉一次浪费的生成(_model.py:740)。
  3. 合并配置(见 3.4),max_tokens 若为空则向驱动要默认值(_model.py:753)。
  4. 归一化输入:str 变成一条 ChatMessageUser;config.system_message 若有,插到最前面(_model.py:759-764)。
  5. 进并发闸门 _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 重试。一次调用依次:

  1. 触发 emit_before_model_generate 钩子(_model.py:1084)。
  2. 查缓存:命中就记一条 cache="read" 事件、标记「本次是缓存命中」(供自适应并发跳过成功计数),直接返回(_model.py:1100-1136)。
  3. 未命中:verify_model_apis() 检查是否被 INSPECT_DISABLE_MODEL_API 禁用(_model.py:1141);记一条 pending 事件。
  4. 在可选的 attempt_timeout 超时上下文里,await self.api.generate(...)(_model.py:1180)——这一行就是交给驱动的地方
  5. 拆返回值(可能是 ModelOutput,也可能是 (输出|异常, ModelCall));若是异常,包成带截断请求体RuntimeError(最多留后 200 行,免得刷屏)(_model.py:1205-1221)。
  6. 给每个工具调用补 view、补完事件(complete(output, call))。
  7. 记用量:record_and_check_model_usage(见 3.6 / 第 4 节),发 emit_model_usage 遥测。
  8. 写缓存 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-64anthropic() 是范本)。本仓共约 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):

类型角色特有字段
ChatMessageSystemsystem
ChatMessageUserusertool_call_id(承接工具结果时)
ChatMessageAssistantassistanttool_callsmodel
ChatMessageTooltooltool_call_idfunctionerror

它们共享基类 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_<家>

方向OpenAIAnthropicGoogle
Inspect → 原生messages_to_openai (_openai.py:295)resolve_chat_input (_providers/anthropic.py:1399)
原生 → Inspectmessages_from_openai (_openai.py:511)messages_from_anthropic (_anthropic_convert.py:12)messages_from_google (_google_convert.py:35)
响应 → ModelOutputmodel_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_urltool_choicetools、过期时长、自定义 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:216cache_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): 仍然工作——向后兼容做得很干净(_model.py:167)。

重试配置 model_retry_config(_retry.py:27)。 用 tenacity:

  • 退避:wait_exponential_jitter(initial=3, max=30*60, jitter=3)——3 秒起跳,第 10 次约 25 分钟,封顶 30 分钟(_retry.py:65)。
  • 停止:max_retriestimeout 谁设了用谁,都没设就永远重试(_retry.py:97-105)。

分类怎么落地(Model.should_retry _model.py:1313)。 AttemptTimeoutError 一律重试(算 transient);否则问驱动的 api.should_retry(ex),拿到 RetryDecision 就据 kindreport_http_retry(kind=...)——rate_limit 触发自适应控制器缩容,transient 只标记「本次重试过」,让随后的成功不计入扩容。Anthropic 的实现是范本(_providers/anthropic.py:1288):429 → rate_limit,过载/内部错误 → transient,并从 body 里嗅探 streaming 场景下没设对状态码的过载错误。

并发闸门 _connection_concurrency(_model.py:1386)据此在「自适应」与「静态」两条路间选:显式设了 max_connections 或开了 batch 就走静态;否则默认走自适应,由控制器在 min/start/max 之间动态调节。

4.3 token 计数与用量/成本(_tokens.py + 记账函数)

动机: 上下文压缩需要「这堆消息大概多少 token」,评测报告需要「总共花了多少钱」。

估算(_tokens.py)。 默认实现用 tiktoken 的 o200k_base 编码,再乘 1.1 上浮——因为多数 agent 常用模型词表更小,o200k_base 系统性少算约 10%,而压缩触发场景下「宁多勿少」(count_text_tokens _tokens.py:108)。媒体(图/音/视/文档)按类型和(data URI 的)解码大小做保守估算,一律故意高估(count_media_tokens _tokens.py:123)。驱动可覆写 count_tokens 换成厂商原生计数(基类默认在 _model.py:332)。

记账(record_and_check_model_usage _model.py:2362)。 每次生成拿到 usage 后:

  • 查模型成本表算钱 compute_model_cost(_model.py:2495:输入/输出/缓存读写各按每百万 token 单价)。
  • 把用量累加进「按模型」「按角色」「按样本」多个 ContextVar 维度。
  • check_token_limit() / check_cost_limit()——超预算就抛错,把成本硬约束接进限额系统。

5. provider 实现举例:同一契约,三种画风

对照看三家怎么实现同一个 generate,最能体会抽象的价值。

维度AnthropicAPIOpenAIAPIMockLLM
入口_providers/anthropic.py:419_providers/openai.py:495_providers/mockllm.py:72
做法手工拼 request、逐条加 beta 头在 completions / responses 两套 API 间分流直接吐预置输出
消息合并collapse_user/assistant 都 True视模型而定
tools_requiredTrue(消息流有工具就必须传定义)默认
重试分类429→rate_limit,过载→transient配额耗尽不重试,其余委托分类器
用途生产生产测试:无网跑通全链路

MockLLM 为什么重要(_providers/mockllm.py:15): 它实现同一个 ModelAPI 契约,却不打任何网络——custom_outputs 可以是列表、生成器,甚至一个 (input, tools, tool_choice, config) → ModelOutput 的回调(_providers/mockllm.py:88)。于是整条 Model 层流水线(缓存、重试、事件、用量)都能在确定性、离线下被测试。它还贴心地在 usage 未给时用 count_tokens 补一份、把工具 token 也算进去,以模仿真实 API 的记账口径(_providers/mockllm.py:106-133)。这就是好抽象的副产品:换一个「假驱动」,上层零改动。

OpenAI 侧展示了另一种复杂度——generate(_providers/openai.py:495)会依据「是否要图像输出/是否配了原生工具/是否 responses 模型」在 Completions API 与 Responses API 两套协议间分流,但对 Model 层而言仍只是「一个 generate」。


6. 巧妙之处(可借鉴的技术)

  • 能力自述式抽象。 差异不靠 if provider == "anthropic" 散在各处,而是每家覆写一堆布尔小方法(collapse_user_messagestool_result_images……),Model 层据此统一改写消息。新增一家厂商 = 实现一个类 + 覆几个方法,不用碰通用流程。依据:_model.py:477-519

  • 返回类型即协议扩展点。 generate 允许返回 ModelOutput | (ModelOutput|Exception, ModelCall),让驱动可选地上交原始 HTTP 现场供日志取证,还能把「可恢复错误」连同现场交回。一个联合类型省掉一整套回调。依据:_model.py:309_model.py:1198-1221

  • 两个 API-key 身份。 initial_api_key(固定)专供连接池/自适应并发做键,api_key(可轮换)供实际鉴权。凭据热轮换时不丢已学到的并发状态。依据:_model.py:216-224connection_key _model.py:417

  • 缓存键精挑「影响输出的字段」。 显式排除并发/超时/重试等运营项,并按 per_epoch 掺 epoch——既最大化命中,又不让多 epoch 互相污染。依据:_cache.py:139

  • 重试三态而非布尔。 RetryDecision 区分 rate_limit(缩容)/transient(仅标记)/no,把「重试」与「并发调节」两件事解耦,又通过 __bool__ 兼容老式布尔返回。依据:_model.py:136

  • token 估算故意高估。 tiktoken 结果 ×1.1、媒体按上界估——因为它服务于「压缩触发」,少算比多算危险。依据:_tokens.py:108


7. 边界与局限(诚实)

  • 本层不管工具「怎么执行」。generate 只负责把工具定义发给模型、把模型想调用哪个工具收回来;真正跑工具、把结果喂回下一轮是 execute_tools 的事,归 04generate_loop(_model.py:802)只是把两者串起来的便利壳。

  • 默认 token 计数是估算,不是真值。 除非驱动覆写 count_tokens 用厂商原生计数,o200k_base ×1.1 只是启发式;用于压缩触发够用,别拿它当计费依据。依据:_tokens.py:108

  • 缓存不感知模型「同名不同物」。 键里有 base_url 和 model 字符串,但若一个自建端点在你两次运行之间换了底层权重却没改名,旧缓存仍会命中。缓存假定「同名 + 同配置 + 同输入 ⇒ 同输出」。依据:_cache.py:139

  • 无限重试是默认。 max_retriestimeout 都不设时,stop_never 会一直退避重试(封顶 30 分钟一次)。长时挂起的评测,记得设其一。依据:_retry.py:97

  • 能力自述靠厂商自觉。 若某家驱动漏声明某个怪癖(比如该合并消息却没覆写 collapse_user_messages),Model 层无从得知,错误会推迟到真实 API 报错才暴露。抽象的代价是「契约的正确性依赖实现者」。


8. 代码地图(导航索引)

主题文件关键符号
驱动契约(抽象基类)model/_model.pyModelAPIModelAPI.generateshould_retryconnection_key
用户包装器与生成流程model/_model.pyModelModel.generateModel._generateModel.generate_loop
重试分类载体model/_model.pyRetryDecision(.no/.transient/.rate_limit)
模型解析与记忆化model/_model.pyget_modelresolve_modelscached_modelModelName
消息适配(发前改写)model/_model.pyresolve_reasoning_historycollapse_consecutive_messages_for_apitool_result_media_as_user_message
用量 / 成本记账model/_model.pyrecord_and_check_model_usagecompute_model_cost
注册表model/_registry.pymodelapimodelapi_register
注册清单(约 30 家)model/_providers/providers.py@modelapi(name=...)validate_*_client
生成配置model/_generate_config.pyGenerateConfigGenerateConfig.mergeGenerateConfigArgs
中立消息结构model/_chat_message.pyChatMessageChatMessageBase.text
中立输出结构model/_model_output.pyModelOutputModelUsageChatCompletionChoiceStopReasonas_stop_reason
格式转换(OpenAI)model/_openai.pymessages_to_openaimessages_from_openaiopenai_completion_paramsmodel_output_from_openai
格式转换(Anthropic)model/_anthropic_convert.pymessages_from_anthropicmodel_output_from_anthropic
格式转换(Google)model/_google_convert.pymessages_from_googlemodel_output_from_google
驱动范本(Anthropic)model/_providers/anthropic.pyAnthropicAPI.generateresolve_chat_inputshould_retry
驱动范本(OpenAI)model/_providers/openai.pyOpenAIAPI.generate(completions/responses 分流)
测试驱动model/_providers/mockllm.pyMockLLMMockLLM.generate
缓存model/_cache.pyCacheEntry_cache_keycache_fetchcache_storeCachePolicy
重试配置model/_retry.pymodel_retry_configModelRetryConfig
token 估算model/_tokens.pycount_tokenscount_text_tokenscount_media_tokens

相邻章节:主循环怎么把生成一条条打完分见 01;Solver 与 TaskState 见 02;工具调用与 Agent 循环见 04;打分与聚合见 05;日志/事件/沙箱见 06