跳转至

被测对象:从一个模型到模型加脚手架

baseline 里被测的就是脚本里那一行 API 调用:消息格式、调用参数、要不要 CoT、要不要 few-shot,全写死在 eval 里,换个模型或换种问法就得改 eval。Evals 在出题和答题之间只留一个窄口:出题的一侧交出 prompt 或题面,答题的一侧还回字符串。窄口两边谁都能换:裁判是一个被测接口,人也能当一个答题方。本篇只读了源码,没有实跑,每节只贴源码模版。

prompt 互转:一份题库喂两种模型

问题:题库按 chat 格式写,few-shot 示例是 name 为 example_user 的 system 消息;davinci 这类补全模型只吃一段字符串。

功能:prompt 互转。chat 转纯文本:每条消息加角色前缀,example_user 变成 User:,system 不加前缀,末尾补一个 Assistant: 让模型接着写;纯文本转 chat:整段包成一条 system:

模版 · evals/prompt/base.py:22-63
def chat_prompt_to_text_prompt(
    prompt: OpenAICreateChatPrompt,
    for_completion: bool = True,
    chat_to_prefixes: Optional[Dict] = None,
) -> str:
    """
    Render a chat prompt as a text prompt. User and assistant messages are separated by newlines
    and prefixed with "User: " and "Assistant: ", respectively, unless there is only one message.
    System messages have no prefix.
    """
    assert is_chat_prompt(prompt), f"Expected a chat prompt, got {prompt}"
    if chat_to_prefixes is None:
        chat_to_prefixes = {
            # roles
            "system": "",
            # names
            "example_user": "User: ",
            "example_assistant": "Assistant: ",
        }

    # For a single message, be it system, user, or assistant, just return the message
    if len(prompt) == 1:
        return prompt[0]["content"]

    text = ""
    for msg in prompt:
        role = msg["name"] if "name" in msg else msg["role"]
        prefix = chat_to_prefixes.get(role, role.capitalize() + ": ")
        content = msg["content"]
        text += f"{prefix}{content}\n"
    if for_completion:
        text += chat_to_prefixes.get(
            "assistant", "Assistant: "
        ).rstrip()  # rstrip to remove trailing whitespace
    return text.lstrip()


def text_prompt_to_chat_prompt(prompt: str, role: str = "system") -> OpenAICreateChatPrompt:
    assert isinstance(prompt, str), f"Expected a text prompt, got {prompt}"
    return [
        {"role": role, "content": prompt},
    ]

题库作者只写 chat 格式,换哪种模型由被测接口自己转。项目文档建议哪怕测的是补全模型也用 chat 格式写题。

被测接口:模型名和系统名走同一个入口

问题:要测的是一条 LangChain 链,或者一个先检索再回答的问答系统,不是裸模型。

功能:被测接口(项目叫 Completion Function)。协议1只有一条:可调用,收一个 prompt,回一个对象,对象的 get_completions() 给出若干段文本:

模版 · evals/api.py:16-40
class CompletionResult(ABC):
    @abstractmethod
    def get_completions(self) -> list[str]:
        pass


@runtime_checkable
class CompletionFn(Protocol):
    def __call__(
        self,
        prompt: Union[str, OpenAICreateChatPrompt],
        **kwargs,
    ) -> CompletionResult:
        """
        ARGS
        ====
        `prompt`: Either a `Prompt` object or a raw prompt that will get wrapped in
            the appropriate `Prompt` class.
        `kwargs`: Other arguments passed to the API.

        RETURNS
        =======
        The result of the API call.
        The prompt that was fed into the API call as a str.
        """

命令行上的名字按顺序解析:dummy、chat 模型名、API 里列得出的模型名,都不是就去注册表查被测接口和答题方:

模版 · evals/registry.py:120-151
    def make_completion_fn(
        self,
        name: str,
        **kwargs: Any,
    ) -> CompletionFn:
        """
        Create a CompletionFn. The name can be one of the following formats:
        1. openai-model-id (e.g. "gpt-3.5-turbo")
        2. completion-fn-id (from the registry)
        """
        if name == "dummy":
            return DummyCompletionFn()

        n_ctx = n_ctx_from_model_name(name)

        if is_chat_model(name):
            return OpenAIChatCompletionFn(model=name, n_ctx=n_ctx, **kwargs)
        elif name in self.api_model_ids:
            return OpenAICompletionFn(model=name, n_ctx=n_ctx, **kwargs)

        # No match, so try to find a completion-fn-id in the registry
        spec = self.get_completion_fn(name) or self.get_solver(name)
        if spec is None:
            raise ValueError(f"Could not find CompletionFn/Solver in the registry with ID {name}")
        if spec.args is None:
            spec.args = {}
        spec.args.update(kwargs)

        spec.args["registry"] = self
        instance = make_object(spec.cls)(**spec.args or {})
        assert isinstance(instance, CompletionFn), f"{name} must be a CompletionFn"
        return instance

OpenAI 的实现把 prompt 包成对应格式,带重试调 API,再自己记一条 sampling 事件:

模版 · evals/completion_fns/openai.py:149-181
    def __call__(
        self,
        prompt: Union[str, OpenAICreateChatPrompt],
        **kwargs,
    ) -> OpenAIChatCompletionResult:
        if not isinstance(prompt, Prompt):
            assert (
                isinstance(prompt, str)
                or (isinstance(prompt, list) and all(isinstance(token, int) for token in prompt))
                or (isinstance(prompt, list) and all(isinstance(token, str) for token in prompt))
                or (isinstance(prompt, list) and all(isinstance(msg, dict) for msg in prompt))
            ), f"Got type {type(prompt)}, with val {type(prompt[0])} for prompt, expected str or list[int] or list[str] or list[dict[str, str]]"

            prompt = ChatCompletionPrompt(
                raw_prompt=prompt,
            )

        openai_create_prompt: OpenAICreateChatPrompt = prompt.to_formatted_prompt()

        result = openai_chat_completion_create_retrying(
            OpenAI(api_key=self.api_key, base_url=self.api_base),
            model=self.model,
            messages=openai_create_prompt,
            **{**kwargs, **self.extra_options},
        )
        result = OpenAIChatCompletionResult(raw_data=result, prompt=openai_create_prompt)
        record_sampling(
            prompt=result.prompt,
            sampled=result.get_completions(),
            model=result.raw_data.model,
            usage=result.raw_data.usage,
        )
        return result

记采样是被测接口自己的事,eval 不用管。接 LangChain 只要写一个类、注册一行 YAML。被测接口还能套被测接口:注册表里的 cot/gpt-3.5-turbo 先让模型推理,再让同一个模型抽出答案。

答题方:eval 只交题面

问题:「GPT-4 在这题上多少分」本身没说清。项目原话2:

“How good is GPT-4 on this eval?” is an underspecified question. Interacting with a model requires scaffolding (prompting, tooling, etc.), and scaffolding can drastically change the model’s behavior

旧接口下,问法握在 eval 手里。Match 模板的 few-shot 是 eval 的参数,示例插在哪由 eval 定:

模版 · evals/elsuite/basic/match.py:38-44
        prompt = sample["input"]
        if self.num_few_shot > 0:
            assert is_chat_prompt(sample["input"]), "few shot requires chat prompt"
            prompt = sample["input"][:-1]
            for s in self.few_shot[: self.num_few_shot]:
                prompt += s["sample"]
            prompt += sample["input"][-1:]

项目的自写 eval 教程把示例写成带 name 的 system 消息,而 name 字段只有 OpenAI 的 chat 接口认。项目自己的总结2:

we've found that passing a prompt to the CompletionFn encourages eval designers to write prompts that often privileges a particular kind of Solver over others.

功能:答题方(项目叫 Solver)。eval 每轮交给它一份题面(项目叫 TaskState),三个字段:任务说明,同一个 eval 里固定不变;到目前为止的对话消息;可选的显式状态,比如比分、剩几轮:

模版 · evals/task_state.py:23-46
@dataclass
class TaskState:
    """
    TaskState is the object provided from an Eval to a Solver. This must
    contain all the information that a Solver needs to provide a response to
    the Eval environment.

    Args:
        task_description: A string describing the task, including instructions
            and the expected response. Fixed across all instances of the eval.
        messages: The list of messages in the conversation so far. For example,
            it is often useful to include an input sample as the first message.
            Any previous interactions should also be included here.
        current_state: Any relevant state variables that should be passed to
            the Solver. While the current state of the eval should be apparent
            from previous messages, it is sometimes useful to include explicit
            state information here (e.g. the current game score or number of
            turns remaining) for easy access by the Solver without having to
            parse the messages.
    """

    task_description: str
    messages: list[Message] = field(default_factory=list)
    current_state: Any = None

答题方还回一个字符串,外加任意元数据。调用时先把题面深拷贝一份,答题方怎么改都碰不到 eval 手里那份;再按顺序跑后处理:

模版 · evals/solvers/solver.py:59-97
    @abstractmethod
    def _solve(
        self,
        task_state: TaskState,
        **kwargs,
    ) -> SolverResult:
        """
        ARGS
        ====
        `task_state`: A `TaskState` object that contains the task description and the input.
        `kwargs`: Other arguments passed to the solver.

        RETURNS
        =======
        The result of the solver.
        """

    def __call__(
        self,
        task_state: TaskState,
        **kwargs,
    ) -> SolverResult:
        """Deepcopies task_state to prevent solvers from modifying the original object."""
        res = self._solve(deepcopy(task_state), **kwargs)

        if hasattr(self, "postprocessors"):
            # Iteratively apply postprocessors to the output
            for postprocessor in self.postprocessors:
                prev_output = res.output
                res = postprocessor(res)
                record_event(
                    "postprocessor",
                    {
                        "name": postprocessor.__class__.__name__,
                        "input": prev_output,
                        "output": res.output,
                    },
                )
        return res

OpenAI 答题方把任务说明当 system,后面接对话消息。碰上补全模型,它自己渲染成文本,用角色前缀当 stop,把模型自己续写出来的下一轮切掉:

模版 · evals/solvers/providers/openai/openai_solver.py:115-157
    def _solve(self, task_state: TaskState, **kwargs) -> SolverResult:
        raw_msgs = [
            {"role": "system", "content": task_state.task_description},
        ] + [msg.to_dict() for msg in task_state.messages]

        precheck_outcome = self._perform_prechecks(raw_msgs)
        if precheck_outcome is not None:
            return precheck_outcome

        msgs = self._process_msgs(raw_msgs)

        try:
            if self._is_chat_model(self.model):
                completion_result = self.completion_fn(prompt=msgs, **kwargs)

                completion_output = completion_result.get_completions()[0]

                # Chat model output is already parsed, just return it
                solver_result = SolverResult(
                    completion_output, raw_completion_result=completion_result
                )
            else:
                # Manually render the prompt for completion models so that we can
                # implement things like custom render formats and/or fixed_start
                prompt = self._render_completion_prompt(msgs)

                stop_sequences = self._get_msg_separators()
                if len(stop_sequences) > 4:
                    logging.warn("Using more than 4 stop sequences is unsupported")
                completion_result = self.completion_fn(prompt=prompt, stop=stop_sequences, **kwargs)

                completion_output = completion_result.get_completions()[0]

                # Completion model output needs to be parsed to remove role prefixes
                solver_result = SolverResult(
                    self._parse_completion_response(completion_output),
                    raw_output=completion_output,
                    raw_completion_result=completion_result,
                )
        except self._completion_exception as e:
            solver_result = self._handle_completion_exception(e)

        return solver_result

同一个 eval 换答题方,只改命令行第一个参数。oaieval gpt-4 <eval> 这种裸模型名,也会被自动包成 OpenAI 答题方:

模版 · evals/solvers/utils.py:28-55
def maybe_wrap_with_solver(ambiguous_executor: Union[Solver, CompletionFn]) -> Solver:
    """
    Converts a basic completion_fn into a Solver if it isn't already one.
    If it is already a Solver, it is returned unchanged.
    """

    if isinstance(ambiguous_executor, Solver):
        # Use the solver directly
        solver = ambiguous_executor
    elif isinstance(ambiguous_executor, SolverCompletionFn):
        # unwrap previously wrapped solver
        solver = ambiguous_executor.solver
    else:
        # Wrap the completion_fn in an appropriate solver for its type
        if isinstance(ambiguous_executor, OpenAIChatCompletionFn) or isinstance(
            ambiguous_executor, OpenAICompletionFn
        ):
            solver = OpenAISolver(
                completion_fn_options={
                    "model": ambiguous_executor.model,
                }
            )
            solver.completion_fn = ambiguous_executor
        elif isinstance(ambiguous_executor, DummyCompletionFn):
            solver = DummySolver()
        else:
            raise ValueError(f"Unsupported completion_fn type: {type(ambiguous_executor)}")
    return solver

组合答题方:CoT、投票、few-shot 在 YAML 里套

问题:同一个选择题 eval,想比四种做法:直接答;先推理再答;推理五次投票;先看三个示例再答。

功能:组合答题方(项目叫 NestedSolver)。构造参数里写子答题方的 spec,按名字取用;拷贝时连同子答题方一起拷:

模版 · evals/solvers/solver.py:137-186
class NestedSolver(Solver):
    """An abstract solver class that receives specification of any number of other solvers as an argument."""

    # TODO: Should we allow nested solvers to (also) take Solver classes instead of SolverSpecs?

    def __init__(self, *, postprocessors: list[str] = [], registry=None, **solver_specs):
        super().__init__(postprocessors=postprocessors)
        self.solver_specs = {}
        self._solver_cache = {}

        SolverSpecValidator = TypeAdapter(SolverSpec)
        for name, value in solver_specs.items():
            try:
                SolverSpecValidator.validate_python(value)
                self.solver_specs[name] = value
                self.get_solver(name)  # Initialize the solver
            except ValidationError:
                raise ValueError(f"Expected a sub-solver spec at '{name}', got '{value}'")

        assert (
            self.solver_specs
        ), f"{type(self).__name__} requires at least one sub-solver as an argument"

    def get_solver(self, solver_name: str) -> Solver:
        """
        IMPORTANT: All subclasses of NestedSolver should use this method to reference any
        sub-solvers, otherwise solver copies will not work properly.

        For convenience, your subclass can have a @property method like this:
        ```python
        @property
        def my_sub_solver(self) -> Solver:
            return self.get_solver("my_sub_solver")
        ```
        which is used in the _solve method like this:
        ```python
        def _solve(
            self,
            task_state: TaskState,
            **kwargs,
        ) -> SolverResult:
            ...
            solver_result = self.my_sub_solver(task_state=task_state, **kwargs)
            ...
        ```
        """
        if solver_name not in self._solver_cache:
            solver_spec = self.solver_specs[solver_name]
            self._solver_cache[solver_name] = self._create_solver(solver_spec)
        return self._solver_cache[solver_name]

CoT 是推理答题方加抽答案答题方。先追加一条「逐步推理,这次回复不计分」,拿到推理后,再追加「按要求的格式给出答案」,只把抽出来的答案交给 eval,推理放在元数据里:

模版 · evals/solvers/prompts/cot.py:1-4
DEFAULT_COT_TEMPLATE = "Please reason in a step-by-step manner before giving a response. (You now have an opportunity to reason privately; your next response will not be evaluated.)"
DEFAULT_EXTRACT_ANSWER_TEMPLATE = (
    "Given the above reasoning, your response in the format requested by the instructions is:"
)
模版 · evals/solvers/nested/cot_solver.py:50-81
    def _solve(
        self,
        task_state: TaskState,
        **kwargs,
    ) -> SolverResult:
        if self.interaction_cache:
            # Add in previous (private) memories
            task_state.messages = self.interaction_cache.load_private_interaction(task_state)

        # Reasoning step
        task_state.messages.append(Message(role="system", content=self.cot_template(task_state)))
        reasoning_result = self.cot_solver(task_state=task_state, **kwargs)
        reasoning_output = reasoning_result.output

        # Extract answer step
        task_state.messages.append(Message(role="assistant", content=reasoning_output))
        task_state.messages.append(
            Message(role="system", content=self.extract_template(task_state))
        )
        extracted_result = self.extract_solver(task_state=task_state, **kwargs)
        extracted_answer = extracted_result.output

        task_state.messages.append(Message(role="assistant", content=extracted_answer))

        # Save the interaction
        if self.interaction_cache:
            self.interaction_cache.save_private_interaction(task_state)

        return SolverResult(
            output=extracted_answer,
            reasoning_output=reasoning_output,
        )

在 YAML 里嵌套成一个名字:

模版 · evals/registry/solvers/defaults.yaml:25-43
generation/cot/gpt-3.5-turbo:
  class: evals.solvers.nested.cot_solver:CoTSolver
  args:
    cot_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-3.5-turbo
          extra_options:
            temperature: 1
            max_tokens: 512
    extract_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-3.5-turbo
          extra_options:
            temperature: 1
            max_tokens: 512

自洽投票:同一份题面独立采 N 次,几次推理互相看不见;各自抽出「The answer is」后面的答案计票。judge 模式改成让模型读全部推理,给出共识:

模版 · evals/solvers/nested/self_consistency_solver.py:77-88
        for i in range(self.num_generations):
            raw_result = self.solver(task_state)

            # We don't immediately append this to the task state messages because
            # doing so would influence subsequent reasonings, which we do not want.
            reasoning_completions.append(raw_result.output)
            try:
                answer = self._extract_answer(raw_result)
                unique_answers[answer] += 1
            except ValueError as ve:
                logger.info(f"ValueError while extracting answer: {ve}")
                continue
模版 · evals/registry/solvers/theory_of_mind.yaml:279-290
socialiqa/selfconsistency/gpt-4:
  class: evals.solvers.nested.self_consistency_solver:SelfConsistencySolver
  args:
    solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-4
          extra_options:
            temperature: 1
            max_tokens: 128
    mode: "judge"

few-shot:从训练集抽 n 条示例插到对话前面。插之前先查一遍,当前这道测试题要是混进了训练集,直接报错:

模版 · evals/solvers/nested/fewshot_solver.py:83-87
    def _modify_task_state(self, task_state: TaskState) -> TaskState:
        assert all(
            user_message not in task_state.messages
            for (user_message, assistant_message) in self.train_data
        ), f"The few-shot training data provided contains the current test set point: {task_state.messages}. Check datasets for contamination."
模版 · evals/registry/solvers/skill_acquisition.yaml:135-147
miskito_all/fewshot_direct/gpt-3.5-turbo:
  class: evals.solvers.nested.fewshot_solver:FewShotSolver
  args:
    train_jsonl: evals/registry/data/skill_acquisition/miskito/variants/miskito_train_all.jsonl
    n_shots: 3
    base_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-3.5-turbo
          extra_options:
            temperature: 1
            max_tokens: 512

few-shot 在旧接口里是 eval 的参数,到这里成了答题方的选择:被测方看到几个示例,不再由 eval 决定。

后处理:答案交出去前清一遍

问题:项目自己举的例子:选择题要求答 B,模型答 "B.",精确匹配判错,其实答对了。

功能:后处理链。在答题方的 YAML 里按顺序列类路径;每跑一步记一条 postprocessor 事件,前后两版都留着(见上一节 __call__ 里的循环):

模版 · evals/solvers/postprocessors/README.md:13-24
generation/direct/gpt-3.5-turbo:
  class: evals.solvers.providers.openai.openai_solver:OpenAISolver
  args:
    completion_fn_options:
      model: gpt-3.5-turbo-0125
      extra_options:
        temperature: 1
        max_tokens: 512
    postprocessors: &postprocessors
      - evals.solvers.postprocessors.postprocessors:Strip
      - evals.solvers.postprocessors.postprocessors:RemoveQuotes
      - evals.solvers.postprocessors.postprocessors:RemovePeriod
模版 · evals/solvers/postprocessors/postprocessors.py:5-43
class Strip(PostProcessor):
    """
    Strip leading and trailing whitespace from the output, including newlines.
    """

    def __call__(self, result: SolverResult) -> SolverResult:
        return SolverResult(
            result.output.strip(),
            **result.metadata,
        )


class RemoveQuotes(PostProcessor):
    """
    Remove quotes from the beginning and end of the output. This works only if:
    - The quotes are exactly at the beginning and end (if there is a space
      between the quote and the first/last character, the quote is not removed)
    - There is a matching pair of quotes (if there is only one quote at either
      end, it is not removed)
    """

    def __call__(self, result: SolverResult) -> SolverResult:
        if len(result.output) >= 2:
            if result.output[0] == '"' and result.output[-1] == '"':
                result._output = result.output[1:-1]
            elif result.output[0] == "'" and result.output[-1] == "'":
                result._output = result.output[1:-1]
        return result


class RemovePeriod(PostProcessor):
    """
    Remove a period from the end of the output. The period must be exactly the
    last character in the output or it will not be removed.
    """

    def __call__(self, result: SolverResult) -> SolverResult:
        result._output = result.output.rstrip(".")
        return result

顺序有讲究:先去空白,再去引号,最后去句号,\n"B."\n 才能清成 B。后处理放在答题方这一侧,因为格式毛病出在被测系统身上,判法不用跟着放宽。

各家差异关在答题方里,人也走同一个接口

问题:Claude 的 messages 里没有 system 角色,还要求 user / assistant 交替;可 eval 往题面里插的提示,常常是连着几条 system。

功能:各家 API 的规矩由各家的答题方吸收。Anthropic 答题方让任务说明走顶层的 system 参数,消息里的 system 改成 user,连续同角色的消息合并成一条:

模版 · evals/solvers/providers/anthropic/anthropic_solver.py:88-116
    @staticmethod
    def _convert_msgs_to_anthropic_format(msgs: list[Message]) -> list[MessageParam]:
        """
        Anthropic API requires that the message list has
        - Roles as 'user' or 'assistant'
        - Alternating 'user' and 'assistant' messages

        Note: the top-level system prompt is handled separately and should not be
        included in the messages list.
        """
        # enforce valid roles; convert to Anthropic message type
        anth_msgs = [
            MessageParam(
                role=oai_to_anthropic_role[msg.role],
                content=[ContentBlock(text=msg.content, type="text")],
            )
            for msg in msgs
        ]
        # enforce alternating roles by merging consecutive messages with the same role
        # e.g. [user1, user2, assistant1, user3] -> [user12, assistant1, user3]
        alt_msgs = []
        for msg in anth_msgs:
            if len(alt_msgs) > 0 and msg["role"] == alt_msgs[-1]["role"]:
                # Merge consecutive messages from the same role
                alt_msgs[-1]["content"].extend(msg["content"])
            else:
                alt_msgs.append(msg)

        return alt_msgs

分类题想让模型只吐一个合法选项:OpenAI 答题方接受 valid_answers,每个选项必须是单个 token,给它们加 100 的 logit bias,再配上 max_tokens: 1:

模版 · evals/solvers/providers/openai/openai_solver.py:287-297
    def _make_logit_bias(self, valid_answers: list[str], model: str) -> dict[int, float]:
        enc = tiktoken.encoding_for_model(model)
        token_ids = []
        for answer in valid_answers:
            encoded_answer = enc.encode(answer)
            if len(encoded_answer) > 1:
                raise ValueError(
                    f"Answer {answer} was encoded to {encoded_answer}, but we expected a single token."
                )
            token_ids.append(encoded_answer[0])
        return {token_id: 100 for token_id in token_ids}
模版 · evals/registry/solvers/defaults.yaml:47-55
classification/direct/gpt-3.5-turbo:
  class: evals.solvers.providers.openai.openai_solver:OpenAISolver
  args:
    completion_fn_options:
      model: gpt-3.5-turbo
      extra_options:
        temperature: 0
        max_tokens: 1
    valid_answers: ["A", "B", "C", "D"]

人类基线也是一个答题方:把题面打到终端,读一行输入当答案,照样记 sampling,模型名记成 human。多线程下各题的输出会混在一起,要配 EVALS_SEQUENTIAL=1 一题一题来:

模版 · evals/solvers/human_cli_solver.py:29-44
    def _solve(self, task_state: TaskState, **kwargs) -> SolverResult:
        msgs = [Message("system", task_state.task_description)]
        msgs += task_state.messages

        prompt = (
            "\n".join([f"{msg.role}: {msg.content}" for msg in msgs]) + f"\n{self.input_prompt}"
        )
        answer = input(prompt)

        record_sampling(
            prompt=prompt,
            sampled=answer,
            model="human",
        )

        return SolverResult(answer)

人和模型做的是同一份题面,人类基线不用另写一套。

谁管什么

  • 程序:chat 和纯文本互转;按名字解析被测接口和答题方,裸模型名自动包成答题方;题面深拷贝;后处理按顺序跑、每步记事件;各家角色规则的转换;valid_answers 换算成 logit bias;组合答题方按名字取子答题方,拷贝时连带
  • 模型:推理;按「要求的格式」给出答案;投票时各答各的;judge 模式下读几份推理定共识
  • 用户:选答题方和组合方式(CoT、投票、few-shot、后处理);改模板文字、温度、valid_answers;当人类基线时自己坐在终端前答题

附录:源码与文档

evals/solvers/solver.py 全文
import json
from abc import ABC, abstractmethod
from copy import deepcopy
from importlib import import_module
from typing import Any, Dict, TypeVar, Union

from pydantic import TypeAdapter, ValidationError
from typing_extensions import TypedDict

from evals.api import CompletionFn
from evals.record import record_event
from evals.task_state import TaskState

SolverSpec = TypedDict("SolverSpec", {"class": str, "args": Dict[str, Any]})
SolverType = TypeVar("SolverType", bound="Solver")


class SolverResult:
    def __init__(self, output: str, **metadata):
        self._output = output
        self._metadata = metadata

    @property
    def output(self) -> str:
        return self._output

    @property
    def metadata(self) -> dict:
        return self._metadata

    def to_json(self) -> str:
        return json.dumps(
            {
                "output": self.output,
                **self.metadata,
            },
            indent=2,
        )


class Solver(ABC, CompletionFn):
    # We need to inherit from CompletionFn because of how the oaival registry works.

    def __init__(
        self,
        postprocessors: list[str] = [],
        registry: Any = None,
    ) -> None:
        self.postprocessors: list = []
        for postprocessor_path in postprocessors:
            try:
                module_path, class_name = postprocessor_path.rsplit(":", 1)
                module = import_module(module_path)
                postprocessor_class = getattr(module, class_name)
                self.postprocessors.append(postprocessor_class())
            except AttributeError:
                raise ValueError(f"Invalid postprocessor: {postprocessor_path}")

    @abstractmethod
    def _solve(
        self,
        task_state: TaskState,
        **kwargs,
    ) -> SolverResult:
        """
        ARGS
        ====
        `task_state`: A `TaskState` object that contains the task description and the input.
        `kwargs`: Other arguments passed to the solver.

        RETURNS
        =======
        The result of the solver.
        """

    def __call__(
        self,
        task_state: TaskState,
        **kwargs,
    ) -> SolverResult:
        """Deepcopies task_state to prevent solvers from modifying the original object."""
        res = self._solve(deepcopy(task_state), **kwargs)

        if hasattr(self, "postprocessors"):
            # Iteratively apply postprocessors to the output
            for postprocessor in self.postprocessors:
                prev_output = res.output
                res = postprocessor(res)
                record_event(
                    "postprocessor",
                    {
                        "name": postprocessor.__class__.__name__,
                        "input": prev_output,
                        "output": res.output,
                    },
                )
        return res

    @property
    def name(self) -> str:
        """
        Name of the Solver. This is intended mostly for logging.

        RETURNS
        =======
        A human-readable name that describes this solver.
        """
        return type(self).__name__

    @property
    def model_version(self) -> Union[str, dict]:
        """
        Exact version of the underlying model used by the solver

        RETURNS
        =======
        Dictionary mapping name to exact model version. If no models
        are used (e.g. dummy solver) returns empty dictionary
        """
        return {}

    def copy(self: SolverType) -> SolverType:
        #   The deepcopy may be quite heavy for some solvers; if that's the
        #   case they should override this function.
        return deepcopy(self)


class DummySolver(Solver):
    def _solve(
        self,
        task_state: TaskState,
        **kwargs,
    ) -> SolverResult:
        return SolverResult("This is a dummy response.")


class NestedSolver(Solver):
    """An abstract solver class that receives specification of any number of other solvers as an argument."""

    # TODO: Should we allow nested solvers to (also) take Solver classes instead of SolverSpecs?

    def __init__(self, *, postprocessors: list[str] = [], registry=None, **solver_specs):
        super().__init__(postprocessors=postprocessors)
        self.solver_specs = {}
        self._solver_cache = {}

        SolverSpecValidator = TypeAdapter(SolverSpec)
        for name, value in solver_specs.items():
            try:
                SolverSpecValidator.validate_python(value)
                self.solver_specs[name] = value
                self.get_solver(name)  # Initialize the solver
            except ValidationError:
                raise ValueError(f"Expected a sub-solver spec at '{name}', got '{value}'")

        assert (
            self.solver_specs
        ), f"{type(self).__name__} requires at least one sub-solver as an argument"

    def get_solver(self, solver_name: str) -> Solver:
        """
        IMPORTANT: All subclasses of NestedSolver should use this method to reference any
        sub-solvers, otherwise solver copies will not work properly.

        For convenience, your subclass can have a @property method like this:
        ```python
        @property
        def my_sub_solver(self) -> Solver:
            return self.get_solver("my_sub_solver")
        ```
        which is used in the _solve method like this:
        ```python
        def _solve(
            self,
            task_state: TaskState,
            **kwargs,
        ) -> SolverResult:
            ...
            solver_result = self.my_sub_solver(task_state=task_state, **kwargs)
            ...
        ```
        """
        if solver_name not in self._solver_cache:
            solver_spec = self.solver_specs[solver_name]
            self._solver_cache[solver_name] = self._create_solver(solver_spec)
        return self._solver_cache[solver_name]

    def _create_solver(self, solver_spec: SolverSpec) -> Solver:
        return create_solver(solver_spec)

    def copy(self: SolverType) -> SolverType:
        # The NestedSolver needs to manually copy the sub-solvers, otherwise we will miss any
        # special copy logic they may have.
        solver_copy = deepcopy(self)  # TODO: We should deepcopy without copying the cache
        for name, solver in self._solver_cache.items():
            solver_copy._solver_cache[name] = solver.copy()
        return solver_copy

    @property
    def model_version(self) -> Union[str, dict]:
        """
        Retrieves model versions of each nested solver
        """
        model_versions = {}
        for solver_name, solver in self._solver_cache.items():
            solver_model_version = solver.model_version
            model_versions[solver_name] = solver_model_version

        return model_versions


def create_solver(solver_spec: dict) -> Solver:
    module_name, class_name = solver_spec["class"].split(":")
    module = import_module(module_name)
    cls = getattr(module, class_name)
    return cls(**solver_spec["args"])
evals/registry/solvers/defaults.yaml 全文
# ------------------
# Human input
# ------------------

human_cli:
  class: evals.solvers.human_cli_solver:HumanCliSolver


# ------------------
# gpt-3.5-turbo
# ------------------

# generation tasks

generation/direct/gpt-3.5-turbo:
  class: evals.solvers.providers.openai.openai_solver:OpenAISolver
  args:
    completion_fn_options:
      model: gpt-3.5-turbo
      extra_options:
        temperature: 1
        max_tokens: 512

generation/cot/gpt-3.5-turbo:
  class: evals.solvers.nested.cot_solver:CoTSolver
  args:
    cot_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-3.5-turbo
          extra_options:
            temperature: 1
            max_tokens: 512
    extract_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-3.5-turbo
          extra_options:
            temperature: 1
            max_tokens: 512

# classification tasks

classification/direct/gpt-3.5-turbo:
  class: evals.solvers.providers.openai.openai_solver:OpenAISolver
  args:
    completion_fn_options:
      model: gpt-3.5-turbo
      extra_options:
        temperature: 0
        max_tokens: 1
    valid_answers: ["A", "B", "C", "D"]

classification/cot/gpt-3.5-turbo:
  class: evals.solvers.nested.cot_solver:CoTSolver
  args:
    cot_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-3.5-turbo
          extra_options:
            temperature: 1
            max_tokens: 512
    extract_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-3.5-turbo
          extra_options:
            temperature: 0
            max_tokens: 1
        valid_answers: ["A", "B", "C", "D"]


# ------------------
# gpt-4
# ------------------

# generation tasks

generation/direct/gpt-4:
  class: evals.solvers.providers.openai.openai_solver:OpenAISolver
  args:
    completion_fn_options:
      model: gpt-4
      extra_options:
        temperature: 1
        max_tokens: 512

generation/cot/gpt-4:
  class: evals.solvers.nested.cot_solver:CoTSolver
  args:
    cot_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-4
          extra_options:
            temperature: 1
            max_tokens: 512
    extract_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-4
          extra_options:
            temperature: 1
            max_tokens: 512

# classification tasks

classification/direct/gpt-4:
  class: evals.solvers.providers.openai.openai_solver:OpenAISolver
  args:
    completion_fn_options:
      model: gpt-4
      extra_options:
        temperature: 0
        max_tokens: 1
    valid_answers: ["A", "B", "C", "D"]

classification/cot/gpt-4:
  class: evals.solvers.nested.cot_solver:CoTSolver
  args:
    cot_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-4
          extra_options:
            temperature: 1
            max_tokens: 512
    extract_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-4
          extra_options:
            temperature: 0
            max_tokens: 1
        valid_answers: ["A", "B", "C", "D"]


# ------------------
# gpt-4-turbo-preview
# ------------------

# generation tasks

generation/direct/gpt-4-turbo-preview:
  class: evals.solvers.providers.openai.openai_solver:OpenAISolver
  args:
    completion_fn_options:
      model: gpt-4-turbo-preview
      extra_options:
        temperature: 1
        max_tokens: 512

generation/cot/gpt-4-turbo-preview:
  class: evals.solvers.nested.cot_solver:CoTSolver
  args:
    cot_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-4-turbo-preview
          extra_options:
            temperature: 1
            max_tokens: 512
    extract_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-4-turbo-preview
          extra_options:
            temperature: 1
            max_tokens: 512

# classification tasks

classification/direct/gpt-4-turbo-preview:
  class: evals.solvers.providers.openai.openai_solver:OpenAISolver
  args:
    completion_fn_options:
      model: gpt-4-turbo-preview
      extra_options:
        temperature: 0
        max_tokens: 1
    valid_answers: ["A", "B", "C", "D"]

classification/cot/gpt-4-turbo-preview:
  class: evals.solvers.nested.cot_solver:CoTSolver
  args:
    cot_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-4-turbo-preview
          extra_options:
            temperature: 1
            max_tokens: 512
    extract_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-4-turbo-preview
          extra_options:
            temperature: 0
            max_tokens: 1
        valid_answers: ["A", "B", "C", "D"]


# ------------------
# gpt-4-base
# ------------------

# generation tasks

generation/hhh/gpt-4-base:
  class: evals.solvers.nested.hhh_solver:HHHSolver
  args:
    solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-4-base
          extra_options:
            temperature: 1
            max_tokens: 512

generation/cot_hhh/gpt-4-base:
  class: evals.solvers.nested.cot_solver:CoTSolver
  args:
    cot_solver:
      class: evals.solvers.nested.hhh_solver:HHHSolver
      args:
        solver:
          class: evals.solvers.providers.openai.openai_solver:OpenAISolver
          args:
            completion_fn_options:
              model: gpt-4-base
              extra_options:
                temperature: 1
                max_tokens: 512
    extract_solver:
      class: evals.solvers.nested.hhh_solver:HHHSolver
      args:
        solver:
          class: evals.solvers.providers.openai.openai_solver:OpenAISolver
          args:
            completion_fn_options:
              model: gpt-4-base
              extra_options:
                temperature: 1
                max_tokens: 512

# classification tasks

classification/hhh/gpt-4-base:
  class: evals.solvers.nested.hhh_solver:HHHSolver
  args:
    solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-4-base
          extra_options:
            temperature: 0
            max_tokens: 1
        valid_answers: ["A", "B", "C", "D"]

classification/cot_hhh/gpt-4-base:
  class: evals.solvers.nested.cot_solver:CoTSolver
  args:
    cot_solver:
      class: evals.solvers.nested.hhh_solver:HHHSolver
      args:
        solver:
          class: evals.solvers.providers.openai.openai_solver:OpenAISolver
          args:
            completion_fn_options:
              model: gpt-4-base
              extra_options:
                temperature: 1
                max_tokens: 512
    extract_solver:
      class: evals.solvers.nested.hhh_solver:HHHSolver
      args:
        solver:
          class: evals.solvers.providers.openai.openai_solver:OpenAISolver
          args:
            completion_fn_options:
              model: gpt-4-base
              extra_options:
                temperature: 0
                max_tokens: 1
            valid_answers: ["A", "B", "C", "D"]


# ------------------
# Assistants API
# ------------------

generation/assistants/gpt-3.5-turbo:
  class: evals.solvers.openai_assistants_solver:OpenAIAssistantsSolver
  args:
    tools:
      - type: code_interpreter
      - type: retrieval
    model: gpt-3.5-turbo

generation/cot_assistant/gpt-3.5-turbo:
  class: evals.solvers.nested.cot_solver:CoTSolver
  args:
    cot_solver:
      class: evals.solvers.openai_assistants_solver:OpenAIAssistantsSolver
      args:
        tools:
          - type: code_interpreter
          - type: retrieval
        model: gpt-3.5-turbo
    extract_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-3.5-turbo
          extra_options:
            temperature: 1
            max_tokens: 512

generation/assistants/gpt-4-turbo-preview:
  class: evals.solvers.openai_assistants_solver:OpenAIAssistantsSolver
  args:
    tools:
      - type: code_interpreter
      - type: retrieval
    model: gpt-4-turbo-preview

generation/cot_assistant/gpt-4-turbo-preview:
  class: evals.solvers.nested.cot_solver:CoTSolver
  args:
    cot_solver:
      class: evals.solvers.openai_assistants_solver:OpenAIAssistantsSolver
      args:
        tools:
          - type: code_interpreter
          - type: retrieval
        model: gpt-4-turbo-preview
    extract_solver:
      class: evals.solvers.providers.openai.openai_solver:OpenAISolver
      args:
        completion_fn_options:
          model: gpt-4-turbo-preview
          extra_options:
            temperature: 1
            max_tokens: 512

  1. openai/evals 的 docs/completion-fn-protocol.md,项目定义的被测接口协议,2026-10-20 读。 ↩

  2. openai/evals 的 evals/solvers/README.md,项目对答题方框架的说明,2026-10-20 读。 ↩↩