CodexQA

行业与实践工具与框架

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

CodexQA 团队阅读约 9 分钟

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

本文目录

并行

概述

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)。

觉得有用,转给同事

微信扫码

用微信扫一扫,在手机上打开后即可转发。

用 RSS 订阅

提交勘误