CodexQA

Industry & PracticeTools & Frameworks

Inspect 并行:多个模型一起评,连接和沙箱各自设上限

CodexQA 团队9 min read

eval() 可同时评多个模型,各家 max_connections 不互相占用。任务默认一次一个;样本数低于连接上限才值得开 max_tasks。Docker 的 max_sandboxes 默认为评测可用 CPU 的两倍。

In this piece

并行

概述

Inspect 用并行的异步架构跑评测:一边尽快同时跑很多样本,一边用各种上限避免资源被占满,例如同时连接模型的数量上限、子进程数量上限。异步是说发起一步之后不必干等,可以先去做别的样本。

并发要考虑的事情是一层层来的。多数评测用 Inspect 的默认行为就够,有的则需要自己调整。下面覆盖这些内容:

  1. 并行评测多个模型。
  2. 并行评测多个任务。
  3. 沙箱环境的并发。沙箱是隔离执行命令的环境。
  4. 在自定义工具、求解器和评分器里写并行代码。求解器负责让模型做题,评分器给结果打分。

调整模型 API 的连接上限和速率限制处理(max_connections、自适应连接、重试)见 模型并发。速率限制是服务商在短时间内拒绝过多请求。

Inspect 默认用 asyncio 作为异步后端,也可以改成对着 trio 跑。更多细节见 异步后端。

多个模型

把模型列表传给 eval(),就可以并行评测多个模型。例如:

eval("mathematics.py", model=[
    "openai/gpt-4-turbo",
    "anthropic/claude-3-opus-20240229",
    "google/gemini-2.5-pro"
])
同时评测多个模型

每个模型服务商有自己的 max_connections,所以它们不互相抢连接。一家的名额用完,不会把另一家的连接池也占住。按模型调整见 模型并发。如果要评多个模型,并发去做是被强烈建议的。

用 --model 命令行参数或环境变量 INSPECT_EVAL_MODEL 指定多个模型时,用逗号隔开名字。例如:

INSPECT_EVAL_MODEL=openai/gpt-4-turbo,google/gemini-2.5-pro

多个任务

默认一次只跑一个任务。因为多数任务有 10 条或更多样本,样本级并行通常已经能把当前模型的 max_connections 用满。

如果每个任务的样本数明显低于 max_connections,并行跑多个任务才会有好处。用命令行 --max-tasks,或 eval() 的 max_tasks 参数。例如在当前工作目录跑全部任务,最多 5 个任务同时进行:

$ inspect eval . --max-tasks=5 

另一种常见情况是同一任务换不同超参数,例如提示词、生成配置。例如:

tasks = [
    Task(
        dataset=csv_dataset("dataset.csv"),
        solver=[system_message(SYSTEM_MESSAGE), generate()],
        scorer=match(),
        config=GenerateConfig(temperature=temperature),
    )
    for temperature in [0.5, 0.6, 0.7, 0.8, 0.9, 1]
]

eval(tasks, max_tasks=5)

必须强调:只有样本数很少时,这样做才会更快。例如数据集有 10 条样本,而 max_connections 也是 10,样本并行已经把连接用满,任务再并行也没有收益。

并行任务可以和并行模型合在一起:

eval(
    tasks, # 6 tasks for various temperature values
    model=["openai/gpt-4", "anthropic/claude-haiku-4-5"],
    max_tasks=5,
)

这段代码一共评 12 个任务(6 个温度,各对 2 个模型),同时最多跑 5 个任务。

数据集内存

跑评测时,全部样本会载入内存。多数评测没问题。数据集非常大时(例如几十万条、输入又很长),内存占用会变得可观。--max-dataset-memory 给每个任务的数据集样本数据设一个预算,单位是 MB。估计内存超过预算时,样本会自动分页写到磁盘上的临时文件,轮到某条样本执行时再读回来。

$ inspect eval --model openai/gpt-4 --max-dataset-memory 512

Python 里等价写法:

eval("task.py", model="openai/gpt-4", max_dataset_memory=512)

默认不设内存上限,所有样本都留在内存里。

沙箱环境

沙箱环境(例如 Docker 容器)常常按样本分配资源:一条样本一份容器或一份隔离环境,而不是所有样本挤在同一个进程里。环境里的命令通过 Inspect 的 subprocess() 执行,所以沙箱并发和子进程并发是绑在一起看的。

最大沙箱数

max_sandboxes 决定可以同时跑多少个沙箱。各个沙箱提供方可以自定默认上限。例如 Docker 提供方默认是这次评测能用的处理器数量的两倍。如果容器有 CPU 限制,例如 docker --cpus 或 Kubernetes 的 limits.cpu,用的是这个限制,而不是宿主机的处理器数。可以按需要改这个选项,但容器运行时有资源上限,顶到甚至超过上限会导致不稳定和评测失败。

设置了 max_sandboxes 之后,任务状态画面底部会出现一个指示:

任务的最大沙箱数

max_sandboxes 在使用同一类沙箱提供方的所有任务之间共享。每一类提供方有自己独立的上限。

最大子进程数

max_subprocesses 决定可以同时跑多少次子进程调用。默认是这次评测能用的处理器数量。容器有 CPU 限制时,用的是那个限制,而不是宿主机的处理器数。按沙箱里实际在执行什么,增加或减少 max_subprocesses 都可能有好处。

最大样本数

另一项是 max_samples,一个任务里同时跑的样本数上限。并发样本越多,吞吐越高,但已完成的样本也越不常写入日志。任务被打断时,能恢复的样本就会更少。

默认情况下,max_samples 跟着模型当前的连接上限走:自适应连接(默认)时跟控制器;静态并发时设成 max_connections。把它设得比 max_connections 还低,通常没有意义。上限较小,样本一般会较频繁地写入日志。上限很大时则相反,例如数据集有 100 条样本、又把 max_connections 设成 100,一旦中断,能恢复的样本可能所剩无几。

任务如果会调用工具或使用沙箱,通常会把 max_samples 设得比 max_connections 大。样本有时在调用模型,占用并发连接;有时在沙箱里执行代码,占用并发子进程。跑任务时可以实时看到连接和子进程的占用,再据此调整 max_samples。

求解器与评分器

REST API

自定义求解器、工具或评分器可能会调用别的 REST API。REST API 是用 HTTP 请求交换数据的接口。有两件事要记住:

  1. 连其他 API 必须用 async 的 HTTP 接口,也就是 httpx,而不是 requests。Inspect 的并行依赖所有事情都是 async。用 requests 做一次阻塞的 HTTP 调用,会把系统里其余工作全部拖住。
  2. 和模型 API 一样,这里也可能有速率限制,不能把连接打满。Inspect 会并行跑所有样本。如果有 500 条样本又不限制并发,就可能一次向这个 API 打出几百个调用。阻塞式客户端还会让事件循环停住,别的样本在这次 HTTP 返回之前都动不了。

下面是一段过分简化的示例,说明在 Inspect 组件里怎么调用 REST API。用 httpx 的 async 接口,并用 Inspect 的 concurrency() 把同时连接数限制为 10:

import httpx
from inspect_ai.util import concurrency
from inspect_ai.solver import Generate, TaskState

client = httpx.AsyncClient()

async def solve(state: TaskState, generate: Generate):
  ...
  # wrap the call to client.get() in an async concurrency 
  # block to limit simultaneous connections to 10
  async with concurrency("my-rest-api", 10):
    response = await client.get("https://example.com/api")

注意传给 concurrency() 的名字是 “my-rest-api”。这个名字划出一块范围,专门管理对这个 API 或服务的并发。

并行代码

一般来说,写在 Inspect 求解器、工具和评分器里的代码应尽量并行。核心想法是尽快把工作发出去,再靠上面那些并发闸门避免压垮远程 API 或本地资源。写并行代码有两个关键:

  1. 所有可能很贵的操作都用 async。调用远程 API 就用 httpx.AsyncClient。跑本地代码就用上面的 subprocess()。
  2. 如果这些 async 工作可以并行,用 asyncio.gather()。例如要用三个不同的模型 API 给一道任务打分,可以一起调用,等最慢的那个返回,而不是三次时间相加。要取 10 个网页时不必放在循环里一个个等,可以一次全取。

#### 模型请求

假设评分器用三个不同模型按多数票打分。可以这样并行发出全部模型 API 调用:

from inspect_ai.model import get_model

models = [
  get_model("openai/gpt-5"),
  get_model("anthropic/claude-sonnet-4-5"),
  get_model("mistral/mistral-large-latest")
]

output = "Output to be scored"
prompt = f"Could you please score the following output?\n\n{output}"

graders = [model.generate(prompt) for model in models]

grader_outputs = await asyncio.gather(*graders)

组建评分器列表时,不要对 model.generate() 做 await。列表里放的是还没等待的调用。asyncio.gather() 会等待其中每一个请求,全部完成才返回。Inspect 内部对模型 API 的 max_connections 会节流这些请求,所以一次放飞多少个都不必担心。

#### 网页请求

下面用 asyncio.gather() 把网页请求并行起来:

import asyncio
import httpx
client = httpx.AsyncClient()

pages = [
  "https://www.openai.com",
  "https://www.anthropic.com",
  "https://www.google.com",
  "https://mistral.ai/"
]

downloads = [client.get(page) for page in pages]

results = await asyncio.gather(*downloads)

组建 downloads 列表时不要 await 这些客户端请求。让 asyncio.gather() 一起等,全部结果齐了才返回。四个地址是同时发出的,总时间接近最慢的那一次,而不是四次相加。比起循环逐个下载,这会快非常多。如果请求的 REST API 可能有速率限制,应考虑用 concurrency() 包住 HTTP 请求,把同时出去的调用收住。例如:

from inspect_ai.util import concurrency

async def download(page):
  async with concurrency("my-web-api", 2):
    return await client.get(page)
  
downloads = [download(page) for page in pages]

results = await asyncio.gather(*downloads)

子进程

自定义求解器、工具或评分器可能要启动子进程去做各种事。子进程和调用 API 的考虑类似:不能挡住 Inspect 里的其余工作,所以要用 async 调用;也不能并发得太过,例如在 4 核机器上一次拉起 200 个进程。

为此 Inspect 提供 subprocess()。这个 async 函数接收命令和参数,异步执行,收集并返回标准输出和标准错误。它还会自动把并发子进程数限制为这次评测能用的处理器数量,避免在很少的核上一次拉起远超核心数的进程。容器有 CPU 限制时(例如 docker --cpus 或 Kubernetes 的 limits.cpu),用的是这个限制,而不是宿主机的处理器数。下面是 list_files() 工具实现里的例子:

@tool
def list_files():
    async def execute(dir: str):
        """List the files in a directory.

        Args:
            dir: Directory

        Returns:
            File listing of the directory
        """
        result = await subprocess(["ls", dir])
        if result.success:
            return result.stdout
        else:
            raise ToolError(result.stderr)

    return execute

并发子进程的上限可以用 --max-subprocesses 修改。例如:

$ inspect eval --model openai/gpt-4 --max-subprocesses 4

如果评测里要跑计算很重的代码,应始终把它放进 subprocess() 调用。放在子进程里,事件循环才不会被这段计算占死,并发和性能才比较合适。

#### 超时

如果要保证子进程运行不超过指定时长,可以用 timeout。例如:

try:
  result = await subprocess(["ls", dir], timeout = 30)
except TimeoutError:
  ...

发生超时会抛出 TimeoutError,这次调用就此结束,不会无限挂着。代码一般应按合适的方式处理它。

异步后端

Inspect 的异步代码用 AnyIO 写成。AnyIO 是不绑定某一种异步后端的实现,把任务、同步、子进程、流这些原语做成与后端无关的一层。

AnyIO 支持两种后端:Python 自带的 asyncio,以及 Trio 异步框架。默认用 asyncio,并且和直接使用原生 asyncio 函数的用户代码兼容。

使用 Trio

要把 Inspect 配成使用 Trio,设置环境变量 INSPECT_ASYNC_BACKEND:

export INSPECT_ASYNC_BACKEND=trio
inspect eval math.py

有些 Inspect 功能在 Trio 下还不能用。这些限制来自依赖库目前只支持 asyncio,不是评测任务本身不能跑。包括:

  1. 全屏任务显示用 textual 框架,目前只支持 asyncio。使用 Trio 时,Inspect 会自动改成 “rich” 任务显示,交互更少。
  2. 和 AWS S3 的交互(例如存日志)用 s3fs,目前只支持 asyncio。
  3. Bedrock 和 Grok 提供方依赖 asyncio,不能和 Trio 后端一起用。
  4. --acp-server 选项把正在跑的评测暴露到 Agent Client Protocol,让外部编辑器连上来。Agent Client Protocol 是编辑器连接智能体的协议。它依赖只支持 asyncio 的 acp 库。如果配了 Trio 后端又指定 --acp-server,Inspect 会在启动时给出明确错误。不用 --acp-server 的评测不受影响,在 Trio 下照常运行。

可移植的异步代码

如果在 Inspect 的求解器、工具、评分器或扩展里写异步代码,只要可能就用 AnyIO,不要直接用 asyncio。这样无论当前是哪种异步后端,代码都能正确工作。

AnyIO 在 asyncio 之上实现了类似 Trio 的结构化并发(structured concurrency,缩写 SC)。结构化并发是说一组并发任务有明确的开始和结束,外层结束时内层不会偷偷留下。它也和 Trio 自己的原生结构化并发配合。

进一步了解 AnyIO:

UK AI Security Institute,Parallelism,文档页无单独发布日期(检索于 2026-10-29),https://inspect.aisi.org.uk/parallelism.html ,MIT License(Copyright (c) 2024 UK AI Security Institute)。

Found it useful? Pass it on

WeChat

Scan with WeChat to open it on your phone and forward it.

Subscribe via RSS

Submit a correction