跳到主要内容

第 1 章 · 主循环与“异常即控制流”

本章讲清楚:DefaultAgent 这约 100 行(src/minisweagent/agents/default.py)是怎么把“模型说话 → 执行命令 → 喂回结果”转成一个稳定循环的;以及它最聪明的一招——用异常类型当控制流

1.1 它要解决的小问题

一个 agent 循环看着简单(“问模型、执行、重复”),但要处理一堆“不正常退出”:任务完成了、花钱超了、跑太久了、用户按了 Ctrl-C、模型输出格式不对……如果用一堆 if/return 标志位去管,主循环会变成意大利面。mini 的答卷是:把每种“该中断正常流程的事”做成一个异常类

1.2 思路/直觉:消息列表是唯一的真相

先记住一件事:agent 的全部状态几乎就是一个 self.messages 列表(default.py:42)。每一步只往里 append,从不改旧的(add_messages,default.py:69-72)。所以“轨迹”和“喂给 LM 的消息”是同一个东西——这就是 README 强调的“完全线性历史”,调试和微调都极友好。

1.3 主循环长什么样

run() 先放两条初始消息,然后无限循环,直到最后一条消息的 role"exit":

# 示意,非源码:run() 的骨架,对应 default.py:88-122
def run(self, task):
self.messages = []
self.add_messages(
system_message(render(self.config.system_template)), # 教模型怎么输出
user_message(render(self.config.instance_template)), # 把 task 填进去
)
while True:
try:
self.step() # 问模型 + 执行
self.n_consecutive_format_errors = 0 # 干净的一步就清零
except FormatError as e:
... # 格式错:把纠错消息追加回去
except InterruptAgentFlow as e:
self.add_messages(*e.messages) # 完成/超限/打断:追加异常自带的消息
except Exception as e:
self.handle_uncaught_exception(e); raise
finally:
self.save(self.config.output_path) # 每步都落盘轨迹
if self.messages[-1].get("role") == "exit":
break
return self.messages[-1].get("extra", {})

重点看两处:finally 里每一步都 save 轨迹——哪怕崩了也有现场。② 循环的退出条件不是某个布尔标志,而是“最后一条消息是不是 exit 角色”。真实代码见 default.py:96-122run

1.4 step / query / execute_actions:三层薄包装

一步 = 查询 + 执行,真的就一行(default.py:124-126):

def step(self) -> list[dict]:
"""Query the LM, execute actions."""
return self.execute_actions(self.query())
  • query()(default.py:128-150):先检查“步数/花费/墙钟时间”是否超限,超了就抛 LimitsExceeded/TimeExceeded;否则 self.model.query(self.messages) 拿到模型消息,累加成本,append。
  • execute_actions()(default.py:152-155):对模型消息里 extra.actions 的每个动作调 self.env.execute(action),再把观察格式化成消息 append 回去。

注意一个职责划分:Agent 不解析 LM 的输出。动作(那条 bash 命令)是 Model 在 query() 里就解析好、塞进 message["extra"]["actions"] 的(详见 第 3 章)。Agent 只管“拿出 actions 去执行”。这让 Agent 对“工具调用格式 vs 纯文本格式”完全无感。

1.5 精华:异常即控制流

所有“非正常推进”的事件,都继承自一个基类 InterruptAgentFlow(src/minisweagent/exceptions.py:1-26):

# 真实结构,exceptions.py:1-26
class InterruptAgentFlow(Exception):
def __init__(self, *messages: dict):
self.messages = messages # 异常自己携带“要追加进历史的消息”

class Submitted(InterruptAgentFlow): ... # 任务完成
class LimitsExceeded(InterruptAgentFlow): ... # 花费/步数超限
class TimeExceeded(LimitsExceeded): ... # 墙钟超时(注意:是 LimitsExceeded 的子类)
class UserInterruption(InterruptAgentFlow): ...# 用户打断
class FormatError(InterruptAgentFlow): ... # LM 输出格式不对

妙在哪: 异常对象自带一个 messages 字段,里面就是“出了这事之后该追加进对话历史的消息”。于是 run()except InterruptAgentFlow as e: 一行 self.add_messages(*e.messages) 就能统一处理“完成 / 超限 / 打断”——它们要么追加一条 role="exit" 的消息让循环退出,要么追加一条提示消息后继续。抛异常的地方(比如环境检测到完成)只需把“该说什么话”打包进异常,无需知道循环长什么样。

继承关系也用上了:TimeExceededLimitsExceeded 的子类,所以想“捕获所有限额类问题”写 except LimitsExceeded 就连超时一起抓了——交互式 agent 正是靠这点把“可以让用户提高的限额”和“提高了也没用的墙钟超时”区分开(agents/interactive.py:75-94)。

1.6 格式错误的“连击”保护

FormatError 被单独处理(default.py:100-112),因为它不该立刻退出——模型偶尔格式写错,提示一下就好。但如果连续错太多次,说明模型卡死了:

# 示意,非源码:default.py:99-112 的逻辑
except FormatError as e:
self.n_consecutive_format_errors += 1
if 0 < self.config.max_consecutive_format_errors <= self.n_consecutive_format_errors:
self.add_messages(*e.messages, exit_message("RepeatedFormatError")) # 放弃
else:
self.add_messages(*e.messages) # 把纠错提示喂回去,再来一轮

每次干净的 step() 之后计数清零(default.py:99)。默认连击上限是 3(AgentConfig.max_consecutive_format_errors,default.py:32)。这条边界很重要:它防止 agent 在“模型反复输出畸形动作”时烧钱无限循环。

1.7 限额检查:在 query 入口而非循环里

值得一提的设计:步数 / 花费 / 墙钟的检查不在 while 条件里,而在 query() 开头(default.py:130-145)。这样限额触发就抛异常,和其它中断走同一条路——又是“异常即控制流”的体现。配置项:step_limitcost_limitwall_time_limit_seconds,都在 AgentConfig(default.py:19-35)。

注意 dataclass 默认与发行配置不一致: AgentConfig.cost_limit 的 dataclass 默认是 3.0 美元(default.py:28),但随包发行的 config/default.yaml:105 把它覆盖为 0.(即无成本上限)。所以走 mini / hello_world 这些用 default.yaml 的路径时,开箱即用的实际行为是“不限成本”,而非 3 美元——靠 dataclass 默认值来推断真实预算会被误导。要限成本得在 YAML 或 CLI 里显式给 cost_limit

1.8 模板变量从哪来

system/instance 模板用 Jinja2 渲染,且开了 StrictUndefined(default.py:66-67)——模板里引用了不存在的变量会直接报错,而不是悄悄渲染成空串。变量来自一次 recursive_merge(default.py:52-64):把 agent 配置、环境的模板变量(如操作系统名)、模型配置、运行时计数(n_model_calls/model_cost/elapsed_seconds)、还有 task 全并起来。所以 prompt 里能写 {{task}}{{system}}(操作系统)这种占位符。

1.9 边界与坑

  • 顺序执行,无并发动作: execute_actions 是个普通列表推导(default.py:154),一条条跑。默认 prompt 也要求“每次恰好一个动作”。
  • 未捕获异常会 re-raise: 真出了预料外的错(default.py:115-117),它先把异常信息存成 exit 消息、save,然后继续往上抛——不假装没事。
  • 没有 token 级别预算,只有 cost / step / wall-time: 上下文窗口塞爆由模型层报 ContextWindowExceededError(见 第 3 章abort_exceptions),Agent 这层不主动裁剪历史。

1.10 代码地图

主题文件符号
主循环 / 退出判定src/minisweagent/agents/default.pyDefaultAgent.run
一步 = 查询+执行src/minisweagent/agents/default.pyDefaultAgent.step
限额检查 + 调模型src/minisweagent/agents/default.pyDefaultAgent.query
执行动作、收观察src/minisweagent/agents/default.pyDefaultAgent.execute_actions
消息只增不改src/minisweagent/agents/default.pyDefaultAgent.add_messages
模板变量合并src/minisweagent/agents/default.pyDefaultAgent.get_template_vars
配置项(限额/连击)src/minisweagent/agents/default.pyAgentConfig
控制流异常基类src/minisweagent/exceptions.pyInterruptAgentFlow
完成/超限/超时/打断/格式错src/minisweagent/exceptions.pySubmitted LimitsExceeded TimeExceeded UserInterruption FormatError
三个角色 Protocolsrc/minisweagent/__init__.pyAgent Model Environment