多轮:一道题要来回好几轮
baseline 一道题就是一次 API 调用。二十问、工具调用这类题要来回十几轮,题面每轮都在变,还牵扯第二个模型,塞不进一问一答。Evals 让 eval 自己握着对话和环境,每轮给答题方现出一份题面;答题方的状态按样本隔开。本篇只读了源码,没有实跑,每节只贴源码模版。
环境模型由 eval 自己建
问题:二十问要两个模型:被测方提问、猜词,出题人知道答案、答 yes / no / skip。要是照模型打分的办法,让「命令行上的最后一个当出题人」,就分不清哪个才是被测的,出题人用什么模型也变成每次跑都得记着的命令行约定。
功能:多轮题写成 SolverEval,它只收一个答题方:
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 自己按名字建出来:
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:
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,
)
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 插一条提示,不去问出题人;只有提问才轮到出题人回答。循环有两个上限:问题数,和被测方的总回复数。后一个防的是答题方一直不提问、卡在死循环里:
# 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 在每条样本开头把答题方拷一份,这一条样本里所有轮次都用这一份:
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)
默认的拷贝是深拷贝;组合答题方连里面的子答题方一起拷,子答题方自己有特殊的拷贝办法也照样生效:
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)
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 给的历史,跟上一轮的公开部分逐条核对,对不上就报错;对上了,就用带私下推理的完整历史,接上这一轮的新消息:
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 答题方每轮开头取记忆,结尾存记忆:
if self.interaction_cache:
# Add in previous (private) memories
task_state.messages = self.interaction_cache.load_private_interaction(task_state)
# Save the interaction
if self.interaction_cache:
self.interaction_cache.save_private_interaction(task_state)
旧的 Eval 想用答题方,得先把答题方包成一个被测接口。包装每次调用都拷一份新的答题方,记忆留不住,所以碰上带持久记忆的 CoT 直接报错,要么关掉记忆,要么把 eval 改写成 SolverEval:
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`."
)
# 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 消息喂回去;轮数到顶就收:
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」:
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
调用协议不依赖任何一家的 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,
)
-
openai/evals 的
evals/solvers/README.md,项目对答题方框架的说明,2026-10-20 读。 ↩