数据截至 (上游 commit d8fc8fcbde74)
Solver 与 TaskState:可组合的求解步骤
30 秒导读: 一条评测样本从"初始 prompt"走到"可打分的最终答案",中间的过程由一串 Solver(求解步骤)负责。它们共享一个可变的状态盒子 TaskState——里面装着对话历史、模型输出、工具、是否结束等。每个 Solver 拿到状态、改一改、传给下一个;
generate()(调一次模型)是默认的那一步,basic_agent()则把"想—调工具—看结果"包成一个最小智能体循环。
本章讲过程侧。样本怎么被读进来、怎么调度、怎么打分,分别在 01-eval-loop、05-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:78 Solver |
Generate | 一个 async 回调:"调一次模型、把回复追加进状态",发给每个 Solver 用 | solver/_solver.py:37 Generate |
TaskState | 步骤间传递的可变状态盒子(对话、输出、工具、是否完成…) | solver/_task_state.py:140 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:568、_eval/task/run.py:395 |
Plan / Chain | 顺序执行一串 solver,completed 时提前退出 | solver/_plan.py:21、solver/_chain.py:53 |
generate 闭包 | 运行器造好、注入给每个 solver 的"调模型"回调 | _eval/task/run.py:799 |
task_generate | 那个回调背后真正干活的:调模型 + 跑工具循环 | _eval/task/generate.py:11 task_generate |
3. 核心原理(逐个机制,由浅入深)
3.1 Solver 协议:一个改状态的 async 步骤
要解决的小问题: 怎么定义"一步"才能既灵活又能互相串联?
思路: 定成一个结构化协议(Protocol),而不是继承某个基类——只要你是"接收 (state, generate)、返回 state 的 async 可调用",你就是个 Solver。函数、类实例都行。
真实定义(solver/_solver.py:78 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:198)。
其二,也是关键细节:每次 solver 跑完,自动把最新 state 存进一个 ContextVar,方便其它地方(如打分、fork)随时取"当前样本状态"。看函数式 solver 的包裹(solver/_solver.py:231-238):
@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:470-501)。对类形式的 solver(如 Chain),它改用打补丁 __call__ 的方式注入同样逻辑(solver/_solver.py:212-225)——因为要保留类型,好让别处 isinstance 还能认出 Chain/Plan。
SolverSpec 与按名创建。 SolverSpec(solver/_solver.py:64)记录"solver 名字 + 参数",用于从配置/CLI 重建 solver;名字可以是简单名,也可以是 file.py@name(从某文件里取某个 solver)。solver_create(name, **kwargs)(solver/_solver.py:132)据此从 registry 造出实例。
3.3 TaskState:承载状态的盒子
要解决的小问题: 步骤之间要共享哪些东西?
TaskState 把"一条样本求解到一半的全部现场"装在一个对象里。最常用的几格:
| 字段 | 类型 | 干什么 | 位置 |
|---|---|---|---|
messages | list[ChatMessage] | 对话历史;generate 每次把模型回复追加到这里 | _task_state.py:260 |
output | ModelOutput | "最终输出";简单评测就是最后一条模型消息,复杂 solver 可直接改写它 | _task_state.py:275 |
tools / tool_choice | 工具列表 / 选择指令 | 交给模型的可用工具,由 use_tools 装配 | _task_state.py:295 |
store | Store | 跨 solver 的共享临时数据/草稿(键值袋) | _task_state.py:289 |
completed | bool | 置真则求解链提前收尾;读取时还会顺带查"是否被操作员中断" | _task_state.py:402 |
choices | Choices | 多选题的选项集,仅 multiple_choice 用 | _task_state.py:187 |
target / scores | 打分目标 / 已得分 | 给打分环节用 | _task_state.py:423、:407 |
诚实说明: 代码里 TaskState 没有
scratch字段(全库 grep 无此属性)。要 在 solver 之间存临时草稿,用的是store(_task_state.py:289store)——它就是那个"草稿板/便签袋"。若需要带类型的草稿,还能用state.store_as(MyModel)(_task_state.py:456)把 store 映射成一个 pydantic 模型。
几个贴心的便捷属性,让 prompt 工程 solver 好写:
state.user_prompt(_task_state.py:239):直接读写"用户那条 prompt 消息",省得自己在 messages 里翻。state.input_text(_task_state.py:213):把初始输入当纯字符串取(list 输入时取最后一条 user 文本)。- 三个
*_limit(message / token / cost):setter 里顺手做越限检查并同步给活动样本(如_task_state.py:323message_limit),所以改上限会立刻生效、可能当场触发终止。
Choices 的小花样(多选题防作弊)。 Choices.shuffle(_task_state.py:106)会打乱选项顺序,同时用 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-94):
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:124-132)。对用户,@plan 和 Plan 已废弃,建议改用 chain()(_plan.py:65、:186 的 warn_once)。
但有个反直觉的关键点:运行器最终仍把你的 solver 列表包成一个 Plan(internal=True) 来执行(_eval/task/run.py:395 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:86 的默认参数就是 generate())。它的定义薄得几乎透明(solver/_solver.py:271-298):
@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:799),转手调 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 |
小细节:这些模板里的可填参数,自动并入了样本的 metadata 和 store(如 _prompt.py:68 的 state.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:241)。它是个"自带 generate"的成套 solver,solve 里一条龙(_multiple_choice.py:314-348):
把选项按 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:167)则把洗牌后的展示还原,免得日志里 target 和答案对不上。注意它内部已调 generate(),你不用再串一个 generate()(_multiple_choice.py:256)。
4. basic_agent:最小内置智能体循环
它要解决的小问题: 前面的 generate() 工具循环在"模型不再调工具"时就停了。但很多任务要的是:模型自己反复行动,直到它主动 submit() 一个答案。这就是最小 ReAct 智能体。
思路: 给模型一把特殊的 submit() 工具,然后在一个 while 循环里反复"调模型→跑工具",直到模型调了 submit(或撞上限)。basic_agent(solver/_basic_agent.py:51)把这套包成一个 solver。
它最终返回的是一个 chain(_basic_agent.py:263-270),把四段拼起来:
basic_agent = chain(
init # ① 系统提示(默认一段 ReAct 指令,教模型"每条消息调一个函数")
+ [ tools, # ② 装工具(use_tools)
submit_tool, # ③ 追加 submit() 提交工具
basic_agent_loop ]) # ④ 主循环
主循环的骨架(_basic_agent.py:177-258),读它就懂 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:183):既没消息上限又没 token 上限时兜底,防模型永不 submit 把任务跑死。 - 多次尝试
max_attempts:提交答案后当场用任务的 scorer 打分(_basic_agent.py:233score(state)),对了就停,错了就把incorrect_message塞回去让它再试——这需要"评测过程里就能打分",呼应 05 章。 - submit 工具是临时造的:
submit()(_basic_agent.py:141)本体只是"原样返回 answer",靠tool_with改名成用户指定的submit_name(_basic_agent.py:157)。
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:201-203);chain 的 unroll 遇到 agent 也自动转(_chain.py:45)。resolve_solver 里也一样(_eval/task/task.py:568)。所以用户几乎感知不到两套抽象的边界。
5.2 Generate 为什么是注入的,而不是 import 来的
前面反复出现的那个 generate 参数,是运行器在每次 eval run 里现造的闭包(_eval/task/run.py:799-813),它闭包了当前 model、generate_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:90、_plan.py:112)。撞 limit 的中止走的是异常路径,不在这条 break 逻辑里。
6. 代码地图(导航索引)
| 主 题 | 文件路径 | 符号名 |
|---|---|---|
| Solver 协议 | src/inspect_ai/solver/_solver.py | Solver |
| Generate 回调协议 | src/inspect_ai/solver/_solver.py | Generate |
| solver 装饰器(注册+状态注入) | src/inspect_ai/solver/_solver.py | solver / create_solver_wrapper |
| 按名注册/创建 | src/inspect_ai/solver/_solver.py | solver_register / solver_create / SolverSpec |
| 默认求解器 | src/inspect_ai/solver/_solver.py | generate |
| 状态盒子 | src/inspect_ai/solver/_task_state.py | TaskState |
| 多选题选项 | src/inspect_ai/solver/_task_state.py | Choice / Choices |
| 当前样本状态(ContextVar) | src/inspect_ai/solver/_task_state.py | sample_state / set_sample_state |
| 组合(公开) | src/inspect_ai/solver/_chain.py | chain / Chain / unroll |
| 组合(废弃,仍是执行器) | src/inspect_ai/solver/_plan.py | Plan / plan |
| solver 状态变更事件 | src/inspect_ai/solver/_transcript.py | solver_transcript / SolverTranscript |
| 模型调用+工具循环本体 | src/inspect_ai/_eval/task/generate.py | task_generate |
| 注入的 generate 闭包 | src/inspect_ai/_eval/task/run.py | generate(闭包)/ resolve_plan |
| solver→执行器解析 | src/inspect_ai/_eval/task/task.py | resolve_solver |
| 提示工程 solver | src/inspect_ai/solver/_prompt.py | system_message / prompt_template / chain_of_thought |
| 工具装配 solver | src/inspect_ai/solver/_use_tools.py | use_tools |
| 多选题 solver | src/inspect_ai/solver/_multiple_choice.py | multiple_choice / parse_answers |
| 最小智能体循环 | src/inspect_ai/solver/_basic_agent.py | basic_agent / basic_agent_loop |
| Agent→Solver 桥 | src/inspect_ai/agent/_as_solver.py | as_solver |
相关章节: 调度与主循环见 01-eval-loop;模型层见 03-model-layer;工具与 react 智能体见 04-tools-agents;打分见 05-scorer-metrics;transcript/日志见 06-log-transcript-sandbox。