跳到主要内容

Solver 与 TaskState:可组合的求解步骤

30 秒导读: 一条评测样本从"初始 prompt"走到"可打分的最终答案",中间的过程由一串 Solver(求解步骤)负责。它们共享一个可变的状态盒子 TaskState——里面装着对话历史、模型输出、工具、是否结束等。每个 Solver 拿到状态、改一改、传给下一个;generate()(调一次模型)是默认的那一步,basic_agent() 则把"想—调工具—看结果"包成一个最小智能体循环。

本章讲过程侧。样本怎么被读进来、怎么调度、怎么打分,分别在 01-eval-loop05-scorer-metrics;模型层在 03-model-layer;功能更全的 react 智能体在 04-tools-agents。这里只盯住:一串 Solver 如何把 TaskState 变换到 output


1. 这是什么(零基础也能懂)

一句话定义: Solver 是"求解一条评测样本的一个步骤";TaskState 是这些步骤之间传来传去的"状态盒子"。

它解决什么问题。 评测一个大模型,很少是"把问题丢给模型、拿回答案"这么直。你常要:先塞一段系统提示、把题目套进模板、给模型装几把工具、让它反复调用工具直到给出答案、有时还要多选题按字母判分。把这些步骤拆成可插拔的小块、再串起来,就是 Solver 要干的事。

一句话直觉。 把它想成 Unix 管道:

初始 TaskState ──▶ [系统提示] ──▶ [装工具] ──▶ [调模型] ──▶ 最终 TaskState
每个方块 = 一个 Solver,盒子 = TaskState 一路被改

用起来什么样。 一个任务(Task)的 solver 参数就是一串 Solver;不给就默认只有一步 generate():

# 示意,非源码:一个任务把三个 solver 串成求解过程
Task(
dataset=my_dataset,
solver=[
system_message("你是一个严谨的数学助教"), # 第 1 步:插系统提示
use_tools([calculator()]), # 第 2 步:装工具
generate(), # 第 3 步:调模型(默认这步)
],
scorer=match(),
)

读者读完本节只需记住:Solver = 一个步骤,TaskState = 步骤间的共享状态,串起来 = 求解过程。


2. 顶层全景(它大概怎么转)

2.1 两个协议 + 一个状态

整个过程侧只有三个主角:

主角是什么定义位置
Solver一个 async 可调用:(state, generate) -> state,即"改状态的一步"solver/_solver.py:77 Solver
Generate一个 async 回调:"调一次模型、把回复追加进状态",发给每个 Solver 用solver/_solver.py:36 Generate
TaskState步骤间传递的可变状态盒子(对话、输出、工具、是否完成…)solver/_task_state.py:139 TaskState

注意一个容易忽略的设计:Solver 自己不知道怎么调模型。调模型的能力由运行器从外面注入——就是那个 generate 参数。这样同一个 Solver,在不同 provider / 不同并发调度下都能复用(注入点见 §5.2)。

2.2 一条样本的过程侧全景

┌─────────────────────────────────────────┐
Sample(题目) ──▶ │ TaskState(状态盒子) │
│ messages / output / tools / store / │
│ completed / choices / target ... │
└─────────────────────────────────────────┘
│ 被依次传入

task.solver ──resolve──▶ Plan([ solver_1, solver_2, ..., generate() ])
│ for each solver:
│ state = await solver(state, generate)
│ 若 state.completed → 提前跳出

最终 TaskState.output ──▶ 交给 Scorer 打分(→ 05 章)

怎么读这张图: 从上到下是数据流。Sample 先被包成 TaskState;task 的 solver 列表被解析成一个执行器(内部是 Plan),按顺序把 state 喂给每个 solver;任何一步把 completed 置真就提前收尾;最后拿 output 去打分。

各部件一句话职责:

部件干什么在哪
resolve_solver / resolve_plan把用户给的 solver 列表/单个 solver 统一成一个执行器_eval/task/task.py:474_eval/task/run.py:279
Plan / Chain顺序执行一串 solver,completed 时提前退出solver/_plan.py:21solver/_chain.py:53
generate 闭包运行器造好、注入给每个 solver 的"调模型"回调_eval/task/run.py:539
task_generate那个回调背后真正干活的:调模型 + 跑工具循环_eval/task/generate.py:11 task_generate

3. 核心原理(逐个机制,由浅入深)

3.1 Solver 协议:一个改状态的 async 步骤

要解决的小问题: 怎么定义"一步"才能既灵活又能互相串联?

思路: 定成一个结构化协议(Protocol),而不是继承某个基类——只要你是"接收 (state, generate)、返回 state 的 async 可调用",你就是个 Solver。函数、类实例都行。

真实定义(solver/_solver.py:77 Solver):

@runtime_checkable
class Solver(Protocol):
async def __call__(
self,
state: TaskState,
generate: Generate,
) -> TaskState: ...

一句话:契约就是"拿状态、可选地调 generate、还回状态"。Solver 可以只做 prompt 工程(改改消息就返回),也可以真去调模型。

怎么写一个 Solver:@solver 装饰一个"返回 Solver 的工厂函数"。外层函数收配置参数,内层 solve 才是真正那一步。

# 示意,非源码:一个最简单的 prompt 工程 solver
@solver
def prompt_cot(template: str) -> Solver:
async def solve(state: TaskState, generate: Generate) -> TaskState:
state.user_prompt.text = template.format(prompt=state.user_prompt.text)
return state # 只改 prompt,没调模型
return solve

这个"工厂 + 内层 solve"的两层结构,是所有内置 solver 的统一写法(对照 solver/_prompt.py:142 chain_of_thought,几乎一模一样)。

3.2 @solver 装饰器:注册 + 状态追踪注入

要解决的小问题: 装饰器不只是登记名字,它还偷偷干了两件必须的事。

其一,注册到 registry,这样 solver 能被按名字创建(CLI/配置里用字符串指定 solver)。名字解析、registry_tag 都在 solver_wrapper 里(solver/_solver.py:194)。

其二,也是关键细节:每次 solver 跑完,自动把最新 state 存进一个 ContextVar,方便其它地方(如打分、fork)随时取"当前样本状态"。看函数式 solver 的包裹(solver/_solver.py:226-232):

@wraps(solver)
async def registered_solver(state, generate):
state = await solver(state, generate)
set_sample_state(state) # 每步跑完,登记为"当前样本状态"
return state

set_sample_state / sample_state 就是那对 ContextVar 存取器(solver/_task_state.py:448-456)。对类形式的 solver(如 Chain),它改用打补丁 __call__ 的方式注入同样逻辑(solver/_solver.py:208-220)——因为要保留类型,好让别处 isinstance 还能认出 Chain/Plan

SolverSpec 与按名创建。 SolverSpec(solver/_solver.py:63)记录"solver 名字 + 参数",用于从配置/CLI 重建 solver;名字可以是简单名,也可以是 file.py@name(从某文件里取某个 solver)。solver_create(name, **kwargs)(solver/_solver.py:131)据此从 registry 造出实例。

3.3 TaskState:承载状态的盒子

要解决的小问题: 步骤之间要共享哪些东西?

TaskState 把"一条样本求解到一半的全部现场"装在一个对象里。最常用的几格:

字段类型干什么位置
messageslist[ChatMessage]对话历史;generate 每次把模型回复追加到这里_task_state.py:258
outputModelOutput"最终输出";简单评测就是最后一条模型消息,复杂 solver 可直接改写它_task_state.py:273
tools / tool_choice工具列表 / 选择指令交给模型的可用工具,由 use_tools 装配_task_state.py:293
storeStore跨 solver 的共享临时数据/草稿(键值袋)_task_state.py:287
completedbool置真则求解链提前收尾;读取时还会顺带查"是否被操作员中断"_task_state.py:380
choicesChoices多选题的选项集,仅 multiple_choice_task_state.py:185
target / scores打分目标 / 已得分给打分环节用_task_state.py:401:407

诚实说明: 代码里 TaskState 没有 scratch 字段(全库 grep 无此属性)。要在 solver 之间存临时草稿,用的是 store(_task_state.py:287 store)——它就是那个"草稿板/便签袋"。若需要带类型的草稿,还能用 state.store_as(MyModel)(_task_state.py:434)把 store 映射成一个 pydantic 模型。

几个贴心的便捷属性,让 prompt 工程 solver 好写:

  • state.user_prompt(_task_state.py:237):直接读写"用户那条 prompt 消息",省得自己在 messages 里翻。
  • state.input_text(_task_state.py:211):把初始输入当纯字符串取(list 输入时取最后一条 user 文本)。
  • 三个 *_limit(message / token / cost):setter 里顺手做越限检查并同步给活动样本(如 _task_state.py:321 message_limit),所以改上限会立刻生效、可能当场触发终止。

Choices 的小花样(多选题防作弊)。 Choices.shuffle(_task_state.py:105)会打乱选项顺序,同时用 original_position 记住原位——为了防模型靠"背数据集里正确答案总是 A"取巧。判分完再"假装没洗牌"把消息历史还原(见 §3.6)。

3.4 组合机制:chain 与 Plan

要解决的小问题: 怎么把多个 solver 拼成"一个 solver"?

chain() 是公开推荐的拼法(solver/_chain.py:12)。它把传入的一堆 solver / agent / 嵌套列表拍平(unroll,_chain.py:37),塞进一个 Chain 对象。Chain 本身也是个 Solver,__call__ 里顺序跑、遇到 completed 就 break(_chain.py:77-92):

async def __call__(self, state, generate):
for slv in self._solvers:
async with solver_transcript(slv, state) as st: # 记录这步的状态变更
state = await slv(state, generate)
st.complete(state)
if state.completed: # 提前收尾:后面的 solver 不再跑
break
return state

注意每步外面包了 solver_transcript(solver/_transcript.py:26):它在前后各拍一次 state 快照,json_changes 求差,把"这步改了什么"作为 StateEvent 写进 transcript——这是 06-log-transcript-sandbox 里能看到"每个 solver 干了啥"的来源。

Plan 是旧写法,但仍是运行时的实际执行器。 Plan(solver/_plan.py:21)比 Chain 多两样:finish(即使提前退出也一定跑的收尾 solver)和 cleanup(哪怕抛异常也跑的清理钩子,在 finally 里,_plan.py:119-127)。对用户,@planPlan 已废弃,建议改用 chain()(_plan.py:65:186warn_once)。

但有个反直觉的关键点:运行器最终仍把你的 solver 列表包成一个 Plan(internal=True) 来执行(_eval/task/run.py:279 resolve_plan)——internal=True 只是为了不触发那句废弃警告(_plan.py:62)。也就是说:你写 chain,内部落地成 Plan;Plan 作为执行引擎没死,只是不再作为公开 API。

两者的选择关系:

用户写法 运行时执行器
───────── ──────────
solver=[a, b, c] ──resolve──▶ Plan([a, b, c], internal=True)
solver=chain(a,b) ──resolve──▶ Plan([a, b], internal=True)
solver=my_agent ──as_solver─▶ Plan([<agent包成的solver>])

3.5 默认求解器 generate():与模型层的衔接

要解决的小问题: 最常见的一步——"调一次模型"——长什么样?

generate()不指定 solver 时的默认 solver(_eval/task/task.py:71 的默认参数就是 generate())。它的定义薄得几乎透明(solver/_solver.py:267-294):

@solver
def generate(tool_calls="loop", **kwargs) -> Solver:
async def solve(state, generate):
return await generate(state, tool_calls=tool_calls, **kwargs) # 就是调那个注入的回调
return solve

真正干活的是注入进来的 generate 回调,它由运行器现造(_eval/task/run.py:539),转手调 task_generate(_eval/task/generate.py:11)。后者才是模型调用 + 工具循环的本体:

task_generate(state):
loop:
output = await model.generate(state.messages, state.tools, tool_choice, ...)
state.messages.append(output.message) # 追加助手回复
if state.completed: return
if 有 tool_calls 且 tool_calls != "none":
messages, output = await execute_tools(...) # 跑工具
state.messages.extend(messages)
if tool_calls == "single": return # 只跑一轮工具就停
else:
return # 没有工具调用 → 结束

tool_calls 三档决定循环行为(_eval/task/generate.py:21-65):

取值行为
"loop"(默认)反复"调模型→跑工具",直到模型不再调工具或撞上限
"single"最多解析一轮工具调用就返回
"none"完全不碰工具(需要你自己去调 call_tools)

一个巧妙细节:如果 tool_choice 是"强制调某工具",跑完第一轮后会被改回 "auto"(generate.py:60),否则会一遍遍强制、陷死循环。

3.6 常用内置 Solver 速览

这些都遵循 §3.1 的"工厂 + solve"结构。按用途分三类:

① 提示工程类(只改 messages,不调模型)——都在 solver/_prompt.py:

Solver干什么位置
system_message插一条系统消息(放在已有系统消息之后)_prompt.py:45
user_message / assistant_message追加一条用户/助手消息_prompt.py:78:104
prompt_template用模板改写用户 prompt({prompt} 占位)_prompt.py:17
chain_of_thought给 prompt 套一段"逐步推理并在末行给 ANSWER"_prompt.py:142

小细节:这些模板里的可填参数,自动并入了样本的 metadatastore(如 _prompt.py:68state.metadata | state.store._data | params)——所以模板里能直接引用样本元数据。

② 工具装配类——use_tools(solver/_use_tools.py:11):把工具塞进 state.tools、设 tool_choice,供后续 generate() 使用。支持 append=True 追加而非替换(_use_tools.py:55),也能吃 ToolSource 动态展开一组工具(_use_tools.py:41)。它只装配、不调用——真正触发工具执行的是 generate()

③ 多选题类——multiple_choice(solver/_multiple_choice.py:238)。它是个"自带 generate"的成套 solver,solve 里一条龙(_multiple_choice.py:311-345):

把选项按 A) B) C) 套进模板 → generate() 调模型 → 正则抠出 "ANSWER: X"
→ 标记哪些 Choice 为真 → (若洗过牌)把消息历史还原成没洗牌的样子

其中 parse_answers(_multiple_choice.py:82)用两级正则容错地抠答案(先严格匹配"末行 ANSWER:",不中再宽松匹配),支持 "AB"/"A,B"/"A B" 多种写法;pretend_we_didnt_shuffle(_multiple_choice.py:164)则把洗牌后的展示还原,免得日志里 target 和答案对不上。注意它内部已调 generate(),你不用再串一个 generate()(_multiple_choice.py:253)。


4. basic_agent:最小内置智能体循环

它要解决的小问题: 前面的 generate() 工具循环在"模型不再调工具"时就停了。但很多任务要的是:模型自己反复行动,直到它主动 submit() 一个答案。这就是最小 ReAct 智能体。

思路: 给模型一把特殊的 submit() 工具,然后在一个 while 循环里反复"调模型→跑工具",直到模型调了 submit(或撞上限)。basic_agent(solver/_basic_agent.py:50)把这套包成一个 solver。

它最终返回的是一个 chain(_basic_agent.py:256-263),把四段拼起来:

basic_agent = chain(
init # ① 系统提示(默认一段 ReAct 指令,教模型"每条消息调一个函数")
+ [ tools, # ② 装工具(use_tools)
submit_tool, # ③ 追加 submit() 提交工具
basic_agent_loop ]) # ④ 主循环

主循环的骨架(_basic_agent.py:170-251),读它就懂 ReAct 最小形态:

resolve message_limit(没给且没 token_limit 就默认 50,防止跑不停)
while not state.completed:
output = await model.generate(messages, tools) # 想 + 决定动作
messages.append(output.message)
if 上下文溢出(stop_reason == "model_length"): break
if 有 tool_calls:
tool_results = await execute_tools(...) # 执行动作
messages.extend(tool_results)
answer = 从结果里找 submit 的返回
if answer 有:
output.completion = answer # 采纳为最终答案
attempts += 1
if attempts >= max_attempts: break # 用完提交次数
if 打分==1.0: break # 答对了,收工
else: 追加"你错了,再试"消息 # 允许重试
else:
追加"请继续"消息 # 模型没动作就催它

几个值得带走的设计点:

  • 默认消息上限 50(_basic_agent.py:176):既没消息上限又没 token 上限时兜底,防模型永不 submit 把任务跑死。
  • 多次尝试 max_attempts:提交答案后当场用任务的 scorer 打分(_basic_agent.py:226 score(state)),对了就停,错了就把 incorrect_message 塞回去让它再试——这需要"评测过程里就能打分",呼应 05 章
  • submit 工具是临时造的:submit()(_basic_agent.py:134)本体只是"原样返回 answer",靠 tool_with 改名成用户指定的 submit_name(_basic_agent.py:150)。

basic_agent 是"够用的最小智能体";功能更全、可复用的 react 智能体在 04-tools-agents,这里不展开。


5. 巧妙之处 / 边界

5.1 as_solver:Agent 与 Solver 的桥

边界问题: Inspect 有两套"求解者"抽象——Solver(签名 (state, generate))和 Agent(签名 (AgentState, ...),见 04 章)。想在 task 的 solver 位放一个 agent,得有个转换器。

as_solver(agent/_as_solver.py:24)就是这座桥。它把 Agent 包成 Solver:

solve(state, generate):
agent_state = AgentState(messages=state.messages) # TaskState → AgentState
try:
with apply_limits(limits):
agent_state = await agent(agent_state, ...) # 跑 agent
finally: # 关键:即使抛异常也回写
state.messages = agent_state.messages
if agent_state.output: state.output = agent_state.output
return state

妙在 finally 回写(_as_solver.py:74-81):就算 agent 中途抛异常(比如撞了 limit),也要把已产生的消息和输出写回 TaskState,好让它出现在日志里、能被打分。

这座桥是双向自动的:@solver 装饰时若发现返回的是 agent,会自动 as_solver 包一层(solver/_solver.py:197-199);chainunroll 遇到 agent 也自动转(_chain.py:45)。resolve_solver 里也一样(task.py:474)。所以用户几乎感知不到两套抽象的边界。

5.2 Generate 为什么是注入的,而不是 import 来的

前面反复出现的那个 generate 参数,是运行器在每次 eval run 里现造的闭包(_eval/task/run.py:539-553),它闭包了当前 modelgenerate_config、缓存策略等,再统一转调 task_generate

好处: solver 代码完全不碰"具体是哪个模型、什么并发、什么缓存"——这些运行期上下文由外部一次性注入。这也是为什么同一个 solver 能在约三十家 provider 上原样跑(provider 细节见 03-model-layer)。

5.3 边界与局限(诚实清单)

  • Plan / @plan 已废弃给用户,但仍是运行时执行引擎(§3.4);混用会看到废弃警告,但功能仍在。
  • TaskState 没有 scratch;跨步临时数据一律走 store(§3.3 说明)。
  • multiple_choice 内建了 generate(),再手动串一个会重复调模型。
  • generate() solver 的工具循环在"模型不再调工具"时就停——要"直到模型 submit"的行为,得用 basic_agent/react,而非 generate()
  • 提前退出只认 completed:solver 想中止后续步骤,唯一方式是把 state.completed = True;chain/Plan 靠它 break(_chain.py:88_plan.py:109)。撞 limit 的中止走的是异常路径,不在这条 break 逻辑里。

6. 代码地图(导航索引)

主题文件路径符号名
Solver 协议src/inspect_ai/solver/_solver.pySolver
Generate 回调协议src/inspect_ai/solver/_solver.pyGenerate
solver 装饰器(注册+状态注入)src/inspect_ai/solver/_solver.pysolver / create_solver_wrapper
按名注册/创建src/inspect_ai/solver/_solver.pysolver_register / solver_create / SolverSpec
默认求解器src/inspect_ai/solver/_solver.pygenerate
状态盒子src/inspect_ai/solver/_task_state.pyTaskState
多选题选项src/inspect_ai/solver/_task_state.pyChoice / Choices
当前样本状态(ContextVar)src/inspect_ai/solver/_task_state.pysample_state / set_sample_state
组合(公开)src/inspect_ai/solver/_chain.pychain / Chain / unroll
组合(废弃,仍是执行器)src/inspect_ai/solver/_plan.pyPlan / plan
solver 状态变更事件src/inspect_ai/solver/_transcript.pysolver_transcript / SolverTranscript
模型调用+工具循环本体src/inspect_ai/_eval/task/generate.pytask_generate
注入的 generate 闭包src/inspect_ai/_eval/task/run.pygenerate(闭包)/ resolve_plan
solver→执行器解析src/inspect_ai/_eval/task/task.pyresolve_solver
提示工程 solversrc/inspect_ai/solver/_prompt.pysystem_message / prompt_template / chain_of_thought
工具装配 solversrc/inspect_ai/solver/_use_tools.pyuse_tools
多选题 solversrc/inspect_ai/solver/_multiple_choice.pymultiple_choice / parse_answers
最小智能体循环src/inspect_ai/solver/_basic_agent.pybasic_agent / basic_agent_loop
Agent→Solver 桥src/inspect_ai/agent/_as_solver.pyas_solver

相关章节: 调度与主循环见 01-eval-loop;模型层见 03-model-layer;工具与 react 智能体见 04-tools-agents;打分见 05-scorer-metrics;transcript/日志见 06-log-transcript-sandbox