跳转至

多轮:一道题要来回好几轮

baseline 一道题就是一次 API 调用。二十问、工具调用这类题要来回十几轮,题面每轮都在变,还牵扯第二个模型,塞不进一问一答。Evals 让 eval 自己握着对话和环境,每轮给答题方现出一份题面;答题方的状态按样本隔开。本篇只读了源码,没有实跑,每节只贴源码模版。

环境模型由 eval 自己建

问题:二十问要两个模型:被测方提问、猜词,出题人知道答案、答 yes / no / skip。要是照模型打分的办法,让「命令行上的最后一个当出题人」,就分不清哪个才是被测的,出题人用什么模型也变成每次跑都得记着的命令行约定。

功能:多轮题写成 SolverEval,它只收一个答题方:

模版 · evals/eval.py:186-194
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        assert (
            len(self.completion_fns) == 1
        ), f"{type(self).__name__} supports exactly one completion_fn, got {len(self.completion_fns)}."
        # Technically, instead of arg `completion_fns: list[CompletionFn]` we
        # should just have `solver: Solver` but we keep the args unchanged for
        # compatibility with the existing codebase.
        self._solver = maybe_wrap_with_solver(self.completion_fns[0])

项目对环境里其他模型的规定1:

evals may still use additional models e.g. for model-model interactions, but additional models belong to the environment and should be created by the eval itself rather than passed in as input.

二十问把出题人写成 eval 的一个参数,由 eval 自己按名字建出来:

模版 · evals/elsuite/twenty_questions/eval.py:23-42
    def __init__(
        self,
        completion_fns: List[CompletionFn],
        samples_jsonl: str,
        gamemaster_spec: str,
        max_questions: int = 20,
        max_replies: int = 40,
        num_shortlist_items: int = 20,
        shortlist_variant: bool = False,
        seed: int = 222024,
        n_samples: Optional[int] = None,
        *args,
        **kwargs,
    ):
        super().__init__(completion_fns, seed=seed, *args, **kwargs)

        self.samples_jsonl = samples_jsonl
        self.gamemaster_solver = maybe_wrap_with_solver(
            registry.make_completion_fn(gamemaster_spec)
        )

出题人换模型,改的是 eval 条目,被测方不用动。分数只记在被测方头上;出题人拒答的次数单独成一个指标,出题人掉链子时看得出来。

每轮重出题面:按角色改写对话

问题:同一段对话,被测方看来,自己的提问是 assistant,出题人的回答是 user;出题人看来正好反过来。中间还夹着 eval 自己插的「猜错了」「没按规则来」,也得在两边的对话里各有一个位置。

功能:对话由 eval 握着,消息角色记成 guesser、gamemaster、system。每轮轮到谁,就按谁的视角改写一遍:自己说的变成 assistant,其余的,包括对方的话和 eval 插的提示,都变成 user:

模版 · evals/elsuite/twenty_questions/utils.py:12-26
def generate_task_state_for(role: Literal["guesser", "gamemaster"], conversation: list[Message], word: Optional[str] = None, max_questions: int = 20, shortlist: Optional[List[str]] = None) -> TaskState:
    """Generates a TaskState for the given role and conversation."""
    if role == "guesser":
        prompt = PROMPTS["guesser"].format(max_questions=max_questions) if shortlist is None else PROMPTS["guesser_shortlist"].format(max_questions=max_questions, shortlist=shortlist)
    elif role == "gamemaster":
        prompt = PROMPTS[role].format(word=word)
    else:
        raise ValueError(f"Invalid role: {role}")

    formatted_conversation = format_msgs(conversation, role)

    return TaskState(
        task_description=prompt,
        messages=formatted_conversation,
    )
模版 · evals/elsuite/twenty_questions/utils.py:42-62
def format_msg(msg: Message, role: Literal["guesser", "gamemaster"]) -> Message:
    """Formats a single message from the perspective of the `role`."""

    # body
    is_others_msg = role not in msg.role
    new_content = msg.content

    if is_others_msg:
        new_role = "user"
    elif is_system_msg(msg):
        new_role = "system"
    else:
        new_role = "assistant"

    new_message = Message(content=new_content, role=new_role)

    # post-conditions
    assert isinstance(new_message.content, str)
    assert new_message.role in ["user", "assistant", "system"]

    return new_message

一轮的流程:被测方说一句,eval 解析它是 [GUESS 词]、是问句,还是两样都不是。猜错和违规都由 eval 插一条提示,不去问出题人;只有提问才轮到出题人回答。循环有两个上限:问题数,和被测方的总回复数。后一个防的是答题方一直不提问、卡在死循环里:

模版 · evals/elsuite/twenty_questions/eval.py:134-189
        # Contains fall-back condition to avoid infinite loops for solvers which never output questions.
        while (
            metrics["num_questions"] < self.max_questions
            and metrics["num_guesser_replies"] < self.max_replies
        ):
            task_state = generate_task_state_for(
                "guesser", conversation, max_questions=self.max_questions, shortlist=shortlist
            )
            guesser_response = solver(task_state)
            conversation += [Message(content=guesser_response.output, role="guesser")]
            metrics["num_guesser_replies"] += 1

            # Check if guess made:
            match = re.search(WORD_PATTERN, guesser_response.output)
            if match is not None:
                metrics["num_guesses"] += 1
                guess = match.group(1)
                if guess.lower() == sample["word"].lower():
                    response = {
                        "correct": True,
                        "score": self.max_questions - metrics["num_questions"],
                        "expected": sample["word"],
                        "word_difficulty": sample["difficulty"],
                        "picked": guess,
                        "num_guesses": metrics["num_guesses"],
                        "num_questions": metrics["num_questions"],
                        "num_violations": metrics["num_violations"],
                        "num_gamemaster_refusals": metrics["num_gamemaster_refusals"],
                        "incorrect_guesses": metrics["incorrect_guesses"],
                    }
                    evals.record.record_match(**response)
                    return response
                else:
                    metrics["incorrect_guesses"] += [guess]
                    conversation += [
                        Message(
                            content=PROMPTS["incorrect_guess"].format(guess=guess), role="system"
                        )
                    ]
                    continue
            elif "?" in guesser_response.output.strip():
                metrics["num_questions"] += 1
            else:  # Neither guess nor question.
                # TODO: Maybe make the guesser retry here?
                logger.warn(
                    f"Rule violation, no guess or question in output: {guesser_response.output}"
                )
                metrics["num_violations"] += 1
                conversation += [Message(content=PROMPTS["rule_violation"], role="system")]
                continue

            task_state = generate_task_state_for("gamemaster", conversation, sample["word"])
            gamemaster_response = self.gamemaster_solver(task_state)
            conversation += [Message(content=gamemaster_response.output, role="gamemaster")]
            if gamemaster_response.output.lower() == "skip":
                metrics["num_gamemaster_refusals"] += 1

题面每轮都从对话现生成,答题方不用自己记状态也能玩;猜中时的分数是还剩几个问题。

每个样本一份答题方

问题:答题方要是带记忆,十个线程共用同一个对象,第 3 题的记忆就会串进第 7 题。

功能:SolverEval 在每条样本开头把答题方拷一份,这一条样本里所有轮次都用这一份:

模版 · evals/eval.py:214-226
        def eval_sample(args):
            """
            Evaluate a single sample.
            """
            sample, idx = args
            base_name, split = self.name.split(".")[0:2]
            sample_id = f"{base_name}.{split}.{idx}"
            with recorder.as_default_recorder(sample_id):
                seed = f"{sample_id}:{self.seed}".encode("utf-8")
                rng = random.Random(seed)

                per_sample_solver = self._solver.copy()
                return idx, self.eval_sample(per_sample_solver, sample, rng)

默认的拷贝是深拷贝;组合答题方连里面的子答题方一起拷,子答题方自己有特殊的拷贝办法也照样生效:

模版 · evals/solvers/solver.py:122-125
    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)
模版 · evals/solvers/solver.py:191-197
    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

答题方状态的寿命就是一条样本。题面交给答题方时也是深拷贝(见被测对象),两个方向都碰不到对方手里那份。

私下推理自己记着

问题:CoT 答题方玩到第 2 轮,eval 给的历史里只有它第 1 轮交出去的那个问题,看不到当时的推理。重新推一遍,前后可能对不上。

功能:私有记忆(PersistentMemoryCache)。每轮结束时,记下整段消息,以及其中哪几条是私下的:推理提示、推理本身、抽答案的提示。下一轮先拿 eval 给的历史,跟上一轮的公开部分逐条核对,对不上就报错;对上了,就用带私下推理的完整历史,接上这一轮的新消息:

模版 · evals/solvers/memory.py:16-64
class PersistentMemoryCache:
    def __init__(
        self,
        interaction_length: int,
    ):
        self.private_interaction_length = interaction_length
        self.last_interaction = None

    def save_private_interaction(self, task_state: TaskState):
        #   Save the interaction
        interaction_messages = task_state.messages
        num_interaction_messages = len(interaction_messages)
        private_messages_ids = (
            [] if self.last_interaction is None else self.last_interaction.private_messages_ids
        )
        private_messages_ids += list(
            range(
                num_interaction_messages - self.private_interaction_length - 1,
                num_interaction_messages - 1,
            )
        )
        self.last_interaction = Interaction(interaction_messages, private_messages_ids)

    def load_private_interaction(self, task_state: TaskState) -> List[Message]:
        if self.last_interaction is None:
            return task_state.messages

        #   Check if task_state matches our last interaction
        interaction = self.last_interaction
        task_state_message_ix = 0
        for our_message_ix in range(0, len(interaction.messages)):
            if our_message_ix in interaction.private_messages_ids:
                continue
            else:
                if (
                    task_state.messages[task_state_message_ix]
                    != interaction.messages[our_message_ix]
                ):
                    raise ValueError(
                        (
                            f"task_state message {task_state_message_ix} different than the corresponding message "
                            "in the interaction history.\n"
                            f"task_state.messages:\n{task_state.messages}\n"
                            f"interaction.messages:\n{interaction.messages}\n"
                        )
                    )
                task_state_message_ix += 1

        return interaction.messages + task_state.messages[task_state_message_ix:]

CoT 答题方每轮开头取记忆,结尾存记忆:

模版 · evals/solvers/nested/cot_solver.py:55-57
        if self.interaction_cache:
            # Add in previous (private) memories
            task_state.messages = self.interaction_cache.load_private_interaction(task_state)
模版 · evals/solvers/nested/cot_solver.py:74-76
        # Save the interaction
        if self.interaction_cache:
            self.interaction_cache.save_private_interaction(task_state)

旧的 Eval 想用答题方,得先把答题方包成一个被测接口。包装每次调用都拷一份新的答题方,记忆留不住,所以碰上带持久记忆的 CoT 直接报错,要么关掉记忆,要么把 eval 改写成 SolverEval:

模版 · evals/completion_fns/solver_completion_fn.py:42-48
        if isinstance(self.solver, CoTSolver):
            if self.solver.interaction_cache is not None:
                raise ValueError(
                    "`CoTSolver` with persistent memory is incompatible with "
                    "CompletionFn-based `Eval` classes. "
                    "Please set `CoTSolver(persistent_memory=False)` or update the eval to a `SolverEval`."
                )
模版 · evals/completion_fns/solver_completion_fn.py:69-72
        # use a copy to avoid task state surviving across samples
        pure_solver = self.solver.copy()

        result = pure_solver(task_state, **kwargs)

eval 拿到的始终只有抽出来的答案。私下推理和它的位置只存在答题方自己那份拷贝里,核对失败就报错,不会悄悄拼出一段错位的历史。

工具循环:解析、执行、喂回、提醒

问题:bugged_tools 这道题要调工具来算,工具还可能有 bug,看模型能不能发现。模型的输出得解析成调用、执行,结果再喂回去,来回十几轮。

功能:一个通用的工具循环。工具说明填进任务说明;每轮让答题方说一句,解析其中的 (@名字: 输入) 和 (@Answer: …)。既没调工具也没给答案,或者调用失败,就追加一条提醒;工具结果拼成一条 user 消息喂回去;轮数到顶就收:

模版 · evals/elsuite/solver_tools_convo.py:51-100
    def run(self) -> RunnerResult:
        # Prepare initial task state
        tools = self.name_to_tool.values()
        tool_names_and_descriptions = self._get_tool_names_and_descriptions(tools)
        task_description = self.default_task_description.format(
            tool_names_and_descriptions=tool_names_and_descriptions
        )
        task_message = self.sample["task"]
        messages = [
            Message(role="user", content=task_message),
        ]
        task_state = TaskState(
            task_description=task_description,
            messages=messages,
            current_state=None,
        )

        # Loops until solver completes task or hits turn limit
        turn = 0
        final_answer = None
        while turn < self.max_turns:
            # Get result from solver
            solver_result = self.solver(task_state)
            parsed_solver_result = self._parse_solver_result(solver_result)
            final_answer = parsed_solver_result.final_answer

            # If solver failed to call tool or give final answer, prompt them to try again
            if parsed_solver_result.tool_calls == [] and final_answer is None:
                content = self.default_reminder_message
                task_state = self._add_eval_message(task_state, solver_result, content=content)
                turn += 1
                continue

            if final_answer is not None:
                return self._finish_run(task_state, solver_result, final_answer, turn)

            # Run tools. If solver gave tool incorrect input, prompt them to try again.
            assert parsed_solver_result.tool_calls != []
            tool_outputs = [self._run_tool_call(i) for i in parsed_solver_result.tool_calls]
            if any([i is None for i in tool_outputs]):
                content = self.default_reminder_message
                task_state = self._add_eval_message(task_state, solver_result, content=content)
                turn += 1
                continue

            # Add user message containing tool outputs
            task_state = self._add_tool_outputs(task_state, solver_result, tool_outputs)
            turn += 1

        return self._finish_run(task_state, solver_result, None, turn)

调用格式是纯文本标记,正则解析;Answer 和 Bugged 两个名字留给「交答案」和「报告工具有 bug」:

模版 · evals/elsuite/solver_tools_convo.py:136-151
    def _find_tool_messages(self, text: str) -> list[tuple[str, str]]:
        """
        Finds all tool calls, which are formatted [NAME: INPUT],
        where NAME != "Answer" and NAME != "Bugged"
        """
        pattern = r"\(@(?!Answer|Bugged)(\w+): (.+?)\)"
        matches = re.findall(pattern, text, re.DOTALL)
        return matches

    def _parse_final_answer(self, output: str) -> Optional[str]:
        """
        If a final answer exists of form [Answer: OUTPUT], returns the output,
        otherwise returns None
        """
        match = re.search(r"\(@Answer: (.*?)\)", output, re.DOTALL)
        return match.group(1) if match else None

工具收的也是题面的形状:一段「你叫什么、干什么用」的说明,加一条输入消息。工具可以是一段代码,也可以由模型扮演:

模版 · evals/elsuite/solver_tools_convo.py:153-180
    def _run_tool_call(self, tool_call: ToolCall) -> ToolCall:
        # Prepare task state
        tool_name = tool_call.tool_name
        tool = self.name_to_tool[tool_name]
        tool_input = tool_call.input
        tool_desc = self.name_to_tool[tool_name]._desc

        # Remove quotes if solver wrapped input
        if tool_input.startswith(("'", '"')) and tool_input.endswith(("'", '"')):
            tool_input = tool_input[1:-1]

        task_description = (
            f"Your name is {tool_name}. A description of your purpose is shown below:\n{tool_desc}"
        )
        messages = [Message(role="user", content=tool_input)]
        task_state = ToolTaskState(
            task_description=task_description, messages=messages, current_state=None
        )
        try:
            out = tool(task_state)
        except (TypeError, ValueError, IndexError):
            out = None

        if out is None:
            return None

        tool_call.output = out.output
        return tool_call

调用协议不依赖任何一家的 function calling,任何答题方都能玩,人类答题方也能;代价是解析全靠正则,格式写歪了只能靠提醒拉回来。

谁管什么

  • 程序:问题数、回复数、轮数上限;解析猜词、提问、工具调用和最终答案;按角色改写对话;插猜错、违规和提醒的提示;每条样本拷一份答题方;核对私有记忆,对不上就报错
  • 模型:被测方提问、猜词、调工具、判断工具有没有 bug;环境模型答 yes / no / skip,或者扮演工具
  • 用户:选被测的答题方;在 eval 条目里选出题人模型,设问题数和轮数上限

附录:源码

evals/elsuite/twenty_questions/eval.py 全文
import logging
import random
import re
from typing import Any, Dict, List, Optional, Union

import evals
import evals.metrics
from evals.api import CompletionFn
from evals.elsuite.twenty_questions.utils import PROMPTS, generate_task_state_for
from evals.eval import SolverEval
from evals.record import Recorder
from evals.registry import registry
from evals.solvers.human_cli_solver import HumanCliSolver
from evals.solvers.solver import Solver
from evals.solvers.utils import maybe_wrap_with_solver
from evals.task_state import Message

logger = logging.getLogger(__name__)
WORD_PATTERN = r"\[GUESS (.*?)\]"


class TwentyQuestions(SolverEval):
    def __init__(
        self,
        completion_fns: List[CompletionFn],
        samples_jsonl: str,
        gamemaster_spec: str,
        max_questions: int = 20,
        max_replies: int = 40,
        num_shortlist_items: int = 20,
        shortlist_variant: bool = False,
        seed: int = 222024,
        n_samples: Optional[int] = None,
        *args,
        **kwargs,
    ):
        super().__init__(completion_fns, seed=seed, *args, **kwargs)

        self.samples_jsonl = samples_jsonl
        self.gamemaster_solver = maybe_wrap_with_solver(
            registry.make_completion_fn(gamemaster_spec)
        )
        self.max_questions = max_questions

        if max_replies < max_questions:
            logger.warn(
                f"max_replies ({max_replies}) is less than max_questions ({max_questions}). Setting max_replies to {max_questions + 20}"
            )
        self.max_replies = max_replies if max_replies > max_questions else max_questions + 20
        self.num_shortlist_items = num_shortlist_items
        self.shortlist_variant = shortlist_variant

        self.n_samples = n_samples
        self.rng = random.Random(seed)

    def eval_sample(self, solver: Solver, sample: Dict, rng: random.Random) -> Dict[str, Any]:
        assert "word" in sample, "Sample must contain 'word' field"
        assert "difficulty" in sample, "Sample must contain 'difficulty' field"

        if not isinstance(solver, HumanCliSolver):
            logging.info(f"Running sample: {sample['word']}")

        # Generate the shortlist for the current sample if applicable.
        if self.shortlist_variant:
            assert self.num_shortlist_items <= len(
                self.shortlist
            ), "Number of shortlist items must be less than or equal to the total number of samples."
            shortlist_for_sample = rng.sample(self.shortlist, self.num_shortlist_items)
            if sample["word"] not in shortlist_for_sample:
                random_index = rng.randint(0, len(shortlist_for_sample) - 1)
                shortlist_for_sample[random_index] = sample["word"]
        else:
            shortlist_for_sample = None
        response = self._conversation_loop(solver, sample, shortlist_for_sample)

        return response

    def run(self, recorder: Recorder) -> Dict[str, Union[float, int]]:
        samples = self.get_samples()
        self.rng.shuffle(samples)
        samples = samples[: self.n_samples] if self.n_samples else samples

        if self.shortlist_variant:
            self.shortlist = [sample["word"] for sample in samples]

        self.eval_all_samples(recorder, samples)
        events = recorder.get_events("match")

        scores = [event.data["score"] for event in events]
        num_guesses = [event.data["num_guesses"] for event in events]
        num_questions = [event.data["num_questions"] for event in events]
        num_violations = [event.data["num_violations"] for event in events]
        num_gamemaster_refusals = [event.data["num_gamemaster_refusals"] for event in events]
        incorrect_guesses = [event.data["incorrect_guesses"] for event in events]
        word_difficulties = [event.data["word_difficulty"] for event in events]

        return {
            "score": sum(scores) / len(scores),
            "accuracy": evals.metrics.get_accuracy(events),
            "bootstrap_std": evals.metrics.get_bootstrap_accuracy_std(events),
            "average_num_guesses": sum(num_guesses) / len(num_guesses),
            "average_num_questions": sum(num_questions) / len(num_questions),
            "average_num_violations": sum(num_violations) / len(num_violations),
            "average_num_gamemaster_refusals": sum(num_gamemaster_refusals)
            / len(num_gamemaster_refusals),
            "average_num_incorrect_guesses": sum((len(ig) for ig in incorrect_guesses))
            / len(incorrect_guesses),
            "average_word_difficulty": sum(word_difficulties) / len(word_difficulties),
        }

    def _conversation_loop(
        self, solver: Solver, sample: Dict, shortlist: Optional[List[str]] = None
    ) -> Dict[str, Any]:
        """Maintains a conversation between the guesser and the gamemaster until the maximum number of questions is reached, or until a correct guess is made.

        Args:
            solver (Solver): any compatible solver, instantiated for the current sample.
            sample (Dict): current sample – one word to guess, and its associated difficulty.

        Returns:
            Dict[str, Any]: a dictionary containing the final result and metrics of the conversation.
        """

        metrics = {
            "num_guesses": 0,
            "num_questions": 0,
            "num_violations": 0,
            "num_guesser_replies": 0,  # num_guesses + num_questions + num_violations
            "num_gamemaster_refusals": 0,
            "incorrect_guesses": [],
        }
        conversation = []

        # Contains fall-back condition to avoid infinite loops for solvers which never output questions.
        while (
            metrics["num_questions"] < self.max_questions
            and metrics["num_guesser_replies"] < self.max_replies
        ):
            task_state = generate_task_state_for(
                "guesser", conversation, max_questions=self.max_questions, shortlist=shortlist
            )
            guesser_response = solver(task_state)
            conversation += [Message(content=guesser_response.output, role="guesser")]
            metrics["num_guesser_replies"] += 1

            # Check if guess made:
            match = re.search(WORD_PATTERN, guesser_response.output)
            if match is not None:
                metrics["num_guesses"] += 1
                guess = match.group(1)
                if guess.lower() == sample["word"].lower():
                    response = {
                        "correct": True,
                        "score": self.max_questions - metrics["num_questions"],
                        "expected": sample["word"],
                        "word_difficulty": sample["difficulty"],
                        "picked": guess,
                        "num_guesses": metrics["num_guesses"],
                        "num_questions": metrics["num_questions"],
                        "num_violations": metrics["num_violations"],
                        "num_gamemaster_refusals": metrics["num_gamemaster_refusals"],
                        "incorrect_guesses": metrics["incorrect_guesses"],
                    }
                    evals.record.record_match(**response)
                    return response
                else:
                    metrics["incorrect_guesses"] += [guess]
                    conversation += [
                        Message(
                            content=PROMPTS["incorrect_guess"].format(guess=guess), role="system"
                        )
                    ]
                    continue
            elif "?" in guesser_response.output.strip():
                metrics["num_questions"] += 1
            else:  # Neither guess nor question.
                # TODO: Maybe make the guesser retry here?
                logger.warn(
                    f"Rule violation, no guess or question in output: {guesser_response.output}"
                )
                metrics["num_violations"] += 1
                conversation += [Message(content=PROMPTS["rule_violation"], role="system")]
                continue

            task_state = generate_task_state_for("gamemaster", conversation, sample["word"])
            gamemaster_response = self.gamemaster_solver(task_state)
            conversation += [Message(content=gamemaster_response.output, role="gamemaster")]
            if gamemaster_response.output.lower() == "skip":
                metrics["num_gamemaster_refusals"] += 1

        logger.info(f"Ran out of questions for word: {sample['word']}")
        response = {
            "correct": False,
            "score": 0,
            "expected": sample["word"],
            "word_difficulty": sample["difficulty"],
            "num_guesses": metrics["num_guesses"],
            "num_questions": metrics["num_questions"],
            "num_violations": metrics["num_violations"],
            "num_gamemaster_refusals": metrics["num_gamemaster_refusals"],
            "incorrect_guesses": metrics["incorrect_guesses"],
        }
        evals.record.record_match(**response)
        return response
evals/solvers/memory.py 全文
from dataclasses import dataclass
from typing import List

from evals.task_state import Message, TaskState


@dataclass
class Interaction:
    #   All messages we've seen (except for the task_description)
    messages: List[Message]

    #   IDs of the CoT private internal messages
    private_messages_ids: List[int]


class PersistentMemoryCache:
    def __init__(
        self,
        interaction_length: int,
    ):
        self.private_interaction_length = interaction_length
        self.last_interaction = None

    def save_private_interaction(self, task_state: TaskState):
        #   Save the interaction
        interaction_messages = task_state.messages
        num_interaction_messages = len(interaction_messages)
        private_messages_ids = (
            [] if self.last_interaction is None else self.last_interaction.private_messages_ids
        )
        private_messages_ids += list(
            range(
                num_interaction_messages - self.private_interaction_length - 1,
                num_interaction_messages - 1,
            )
        )
        self.last_interaction = Interaction(interaction_messages, private_messages_ids)

    def load_private_interaction(self, task_state: TaskState) -> List[Message]:
        if self.last_interaction is None:
            return task_state.messages

        #   Check if task_state matches our last interaction
        interaction = self.last_interaction
        task_state_message_ix = 0
        for our_message_ix in range(0, len(interaction.messages)):
            if our_message_ix in interaction.private_messages_ids:
                continue
            else:
                if (
                    task_state.messages[task_state_message_ix]
                    != interaction.messages[our_message_ix]
                ):
                    raise ValueError(
                        (
                            f"task_state message {task_state_message_ix} different than the corresponding message "
                            "in the interaction history.\n"
                            f"task_state.messages:\n{task_state.messages}\n"
                            f"interaction.messages:\n{interaction.messages}\n"
                        )
                    )
                task_state_message_ix += 1

        return interaction.messages + task_state.messages[task_state_message_ix:]
evals/elsuite/solver_tools_convo.py 全文
import copy
import logging
import re
from dataclasses import dataclass
from typing import Any, Optional

from evals.elsuite.bugged_tools.tools import Tool, ToolTaskState
from evals.solvers.solver import Solver, SolverResult
from evals.task_state import Message, TaskState

logger = logging.getLogger(__name__)


@dataclass
class ToolCall:
    tool_name: str
    input: str
    output: Any


@dataclass
class ParsedSolverResult:
    tool_calls: list[ToolCall]
    final_answer: Optional[str]


@dataclass
class RunnerResult:
    final_task_state: ToolTaskState
    final_solver_result: SolverResult
    metrics: dict


class Runner:
    def __init__(
        self,
        solver: Solver,
        sample: Any,
        name_to_tool: dict,
        max_turns: int,
        default_task_description: str,
        default_reminder_message: str,
    ):
        self.solver = solver
        self.sample = sample
        self.name_to_tool = name_to_tool
        self.max_turns = max_turns
        self.default_task_description = default_task_description
        self.default_reminder_message = default_reminder_message

    def run(self) -> RunnerResult:
        # Prepare initial task state
        tools = self.name_to_tool.values()
        tool_names_and_descriptions = self._get_tool_names_and_descriptions(tools)
        task_description = self.default_task_description.format(
            tool_names_and_descriptions=tool_names_and_descriptions
        )
        task_message = self.sample["task"]
        messages = [
            Message(role="user", content=task_message),
        ]
        task_state = TaskState(
            task_description=task_description,
            messages=messages,
            current_state=None,
        )

        # Loops until solver completes task or hits turn limit
        turn = 0
        final_answer = None
        while turn < self.max_turns:
            # Get result from solver
            solver_result = self.solver(task_state)
            parsed_solver_result = self._parse_solver_result(solver_result)
            final_answer = parsed_solver_result.final_answer

            # If solver failed to call tool or give final answer, prompt them to try again
            if parsed_solver_result.tool_calls == [] and final_answer is None:
                content = self.default_reminder_message
                task_state = self._add_eval_message(task_state, solver_result, content=content)
                turn += 1
                continue

            if final_answer is not None:
                return self._finish_run(task_state, solver_result, final_answer, turn)

            # Run tools. If solver gave tool incorrect input, prompt them to try again.
            assert parsed_solver_result.tool_calls != []
            tool_outputs = [self._run_tool_call(i) for i in parsed_solver_result.tool_calls]
            if any([i is None for i in tool_outputs]):
                content = self.default_reminder_message
                task_state = self._add_eval_message(task_state, solver_result, content=content)
                turn += 1
                continue

            # Add user message containing tool outputs
            task_state = self._add_tool_outputs(task_state, solver_result, tool_outputs)
            turn += 1

        return self._finish_run(task_state, solver_result, None, turn)

    def _get_tool_names_and_descriptions(self, tools: list[Tool]):
        """
        Given sequence of tools, creates a string of each tools name
        and description, each tool's info separated by a newline
        """
        s = ""
        for tool in tools:
            s += f"{tool._name}: {tool._desc}\n"
        return s

    def _parse_solver_result(self, solver_result: SolverResult) -> ParsedSolverResult:
        output = solver_result.output
        tool_calls = self._parse_tool_calls(output)
        final_answer = self._parse_final_answer(output)
        return ParsedSolverResult(tool_calls=tool_calls, final_answer=final_answer)

    def _parse_tool_calls(self, output: str) -> Optional[list[ToolCall]]:
        tool_message_matches = self._find_tool_messages(output)
        if tool_message_matches == []:
            return []

        tool_calls = []
        for tool_name, tool_message in tool_message_matches:
            # Log warning if solver calls a tool that doesn't exist
            try:
                self.name_to_tool[tool_name]
            except KeyError:
                logger.warn(f"Solver tried to call '{tool_name}' tool which doesn't exist!")
                continue

            tool_call = ToolCall(tool_name=tool_name, input=tool_message, output=None)
            tool_calls.append(tool_call)
        return tool_calls

    def _find_tool_messages(self, text: str) -> list[tuple[str, str]]:
        """
        Finds all tool calls, which are formatted [NAME: INPUT],
        where NAME != "Answer" and NAME != "Bugged"
        """
        pattern = r"\(@(?!Answer|Bugged)(\w+): (.+?)\)"
        matches = re.findall(pattern, text, re.DOTALL)
        return matches

    def _parse_final_answer(self, output: str) -> Optional[str]:
        """
        If a final answer exists of form [Answer: OUTPUT], returns the output,
        otherwise returns None
        """
        match = re.search(r"\(@Answer: (.*?)\)", output, re.DOTALL)
        return match.group(1) if match else None

    def _run_tool_call(self, tool_call: ToolCall) -> ToolCall:
        # Prepare task state
        tool_name = tool_call.tool_name
        tool = self.name_to_tool[tool_name]
        tool_input = tool_call.input
        tool_desc = self.name_to_tool[tool_name]._desc

        # Remove quotes if solver wrapped input
        if tool_input.startswith(("'", '"')) and tool_input.endswith(("'", '"')):
            tool_input = tool_input[1:-1]

        task_description = (
            f"Your name is {tool_name}. A description of your purpose is shown below:\n{tool_desc}"
        )
        messages = [Message(role="user", content=tool_input)]
        task_state = ToolTaskState(
            task_description=task_description, messages=messages, current_state=None
        )
        try:
            out = tool(task_state)
        except (TypeError, ValueError, IndexError):
            out = None

        if out is None:
            return None

        tool_call.output = out.output
        return tool_call

    def _add_eval_message(
        self,
        task_state: TaskState,
        solver_output: SolverResult,
        content: str,
    ) -> TaskState:
        messages = copy.deepcopy(task_state.messages)
        messages.append(Message(role="assistant", content=solver_output.output))
        # NOTE: we assume that the order of tool_outputs is the same as the order of tool_calls

        messages.append(Message(role="user", content=content))
        new_task_state = TaskState(
            task_description=task_state.task_description,
            messages=messages,
            current_state=None,
        )
        return new_task_state

    def _add_tool_outputs(
        self,
        task_state: TaskState,
        solver_output: SolverResult,
        tool_outputs: list[ToolCall],
    ) -> TaskState:
        content = ""
        for tool_output in tool_outputs:
            name = tool_output.tool_name
            input = tool_output.input
            output = tool_output.output
            content += f"{name} output on input {input}: {output}\n"

        return self._add_eval_message(task_state, solver_output, content)

    def _finish_run(
        self,
        final_task_state: TaskState,
        solver_result: SolverResult,
        final_answer: Optional[str],
        turn: int,
    ) -> RunnerResult:
        expected_answer = self.sample["answer"]
        is_correct = False
        if final_answer is not None:
            final_answer = final_answer.lower().strip()
            # Remove quotes if solver wrapped input
            if final_answer.startswith(("'", '"')) and final_answer.endswith(("'", '"')):
                final_answer = final_answer[1:-1]
            is_correct = final_answer == expected_answer.lower().strip()

        metrics = {
            "is_correct": is_correct,
            "num_turns": turn + 1,  # zero-indexed,
        }

        return RunnerResult(
            final_task_state,
            solver_result,
            metrics,
        )

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