第 3 章 · 模型层:动作解析、成本、重试、缓存
本章讲清楚:Model 是怎么把“一段 LM 回复”变成“一条可执行的 bash 命令”的;两种解析路线(工具调用 vs 纯文本)有什么区别;以及成本计算、全局限流、指数退避重试、Anthropic 提示缓存这些“工程脏活”是怎么被封进这一层、让 Agent 完全无感的。
3.1 Model 的契约 + 一个关键职责
Model Protocol(src/minisweagent/__init__.py:43-58)有五个方法,核心是 query(messages) -> dict。这里有个 第 1 章提过的关键分工:动作解析发生在 Model,不在 Agent。
query() 返回的那条消息里,message["extra"]["actions"] 就是已经解析好的动作列表。Agent 的 execute_actions 只是 for action in message["extra"]["actions"](default.py:154)——它根本不知道这些动作是从工具调用里来的还是从文本里正则抠出来的。这层抽象是 mini 能“兼容所有模型”的底座。
3.2 两条动作解析路线
mini 提供两种从 LM 回复里拿命令的方式,体现在两个模型类上:
| 路线 | 类 | 怎么拿命令 | 适合 |
|---|---|---|---|
| 工具调用(默认推荐) | LitellmModel | 读 response.choices[0].message.tool_calls,要求是名为 bash 的工具 | 支持 function-calling 的现代模型 |
| 纯文本 | LitellmTextbasedModel | 用正则 ```mswea_bash_command\n(.*?)\n``` 从文本里抠 | 任意模型,连不支持工具调用的也行(这是 v1 / 原始 SWE-agent 的方式) |
两者共用同一个父类 LitellmModel,只是覆盖了 _parse_actions 和 _query(models/litellm_textbased_model.py:16-48)。这是教科书式的“变化点下沉”。
工具调用路线
LitellmModel._query(models/litellm_model.py:64-74)在每次请求里都带上一个固定的工具定义 BASH_TOOL(models/utils/actions_toolcall.py:11-27)——一个只有 command 一个参数的 bash 函数。解析在 parse_toolcall_actions(actions_toolcall.py:30-75):没有工具调用、工具名不是 bash、或缺 command 参数,都抛 FormatError。
纯文本路线
parse_regex_actions(models/utils/actions_text.py:15-40)从回复正文里 re.findall 那个代码围栏。严格要求恰好 1 个动作——0 个或 >1 个都抛 FormatError:
# 真实逻辑,actions_text.py:24-39
actions = [a.strip() for a in re.findall(action_regex, content, re.DOTALL)]
if len(actions) != 1:
raise FormatError({... "content": render(format_error_template, ...) ...})
return [{"command": action} for action in actions]
注意它用的围栏标记是自定义的 mswea_bash_command 而非普通 ```bash——这是为了避免和模型正文里顺手写的普通 bash 代码块撞车,降低误解析。
3.3 精华:FormatError 必须保住原始响应
mini 有一条明文的工程契约,直接写在代码注释里(litellm_model.py:87):所有 model.query() 实现在抛 FormatError 时,必须把原始响应持久化进异常。看 query() 的处理(litellm_model.py:88-97):
# 示意,非源码:litellm_model.py:88-97 的意图
try:
actions = self._parse_actions(response)
except FormatError as e:
try:
e.messages[0]["extra"]["response"] = response.model_dump(mode="json")
except Exception:
e.messages[0]["extra"]["response"] = repr(response) # 兜底:序列化失败也得留下点东西
raise
为什么重要: 格式错误时,你最想看的就是“模型到底回了啥”。如果只把纠错提示喂回去而丢了原始响应,轨迹里就留不下证据,事后没法 debug、也没法用这些样本做微调。这个 try/except 嵌套保证“即使 model_dump 失败也要 repr 一份”——契约无条件成立。
3.4 成本计算与“算不出就报错”
_calculate_cost(litellm_model.py:107-125)用 litellm 估算这次调用花了多少钱,并把它累加到 agent 的 self.cost(回看 default.py:148)。一个刻意的强硬选择:算不出成本默认直接抛 RuntimeError 让你停下,而不是悄悄当 0:
- 默认模式
cost_tracking="default":成本 ≤ 0 或算不出 → 抛错,并提示你可能没注册模型、给出怎么关掉的指引。 cost_tracking="ignore_errors"(或环境变量MSWEA_COST_TRACKING):跑本地/未注册模型时把成本当 0、继续。
这背后的哲学:默默把花费记成 0 是危险的(你以为没花钱,实际在烧),所以默认逼你显式处 理。
3.5 全局限流:跨实例的总闸
除了 第 1 章里 agent 级别的 cost_limit,还有一个进程级、线程安全的全局统计 GLOBAL_MODEL_STATS(models/__init__.py 的 GlobalModelStats)。每次模型调用都 GLOBAL_MODEL_STATS.add(cost)(litellm_model.py:86),一旦超过 MSWEA_GLOBAL_COST_LIMIT / MSWEA_GLOBAL_CALL_LIMIT 就抛 RuntimeError。
用途: SWE-bench 批量跑分时开多个 worker 线程并行(见 第 4 章),agent 级限额管不住“所有实例加起来花了多少”。这个带锁的全局计数器就是那道总闸,防止一晚上跑炸预算。
3.6 重试:指数退避 + 一批“别重试”的异常
模型 API 会抽风(限流、超时、5xx)。query() 把请求包在 retry()(models/utils/retry.py)里——tenacity 的指数退避(min 4s、max 60s,默认最多 10 次,可用 MSWEA_MODEL_RETRY_STOP_AFTER_ATTEMPT 调)。
关键是哪些错不该重试——LitellmModel.abort_exceptions(litellm_model.py:50-57)列了一组:
# 真实列表,litellm_model.py:50-57
abort_exceptions = [
litellm.exceptions.UnsupportedParamsError, # 参数不对,重试也白搭
litellm.exceptions.NotFoundError, # 模型不存在
litellm.exceptions.PermissionDeniedError,
litellm.exceptions.ContextWindowExceededError, # 上下文塞爆,重试只会再爆
litellm.exceptions.AuthenticationError, # key 不对
KeyboardInterrupt, # 用户要停
]
直觉: 只重试“瞬态”错误(网络抖 动、限流);配置性 / 永久性错误立刻放弃——重试它们既浪费时间又烧钱。AuthenticationError 还被加了一句友好提示教你怎么设 key(litellm_model.py:72-74)。
3.7 Anthropic 提示缓存:可插拔的消息处理器
对 Anthropic 模型,把“已经发过的长前缀”标记为可缓存能大幅省钱。mini 把这做成一个消息处理器 set_cache_control(models/utils/cache_control.py:49-67),在发请求前对消息做一遍变换(litellm_model.py:76-79 的 _prepare_messages_for_api)。
它的策略很简单——只在最后一条消息上打一个 cache_control: ephemeral 标记(default_end 模式):因为历史是线性只增的,最后一条之前的全部内容构成一个稳定前缀,Anthropic 会自动缓存到那个标记点。模型工厂还会自动识别 Anthropic 系模型并默认开启这个模式(models/__init__.py 的 get_model:模型名里含 anthropic/sonnet/opus/claude 就设 set_cache_control="default_end")。
这又是“变化点封装”的范例:缓存逻辑既不在 Agent、也不在 query 主流程,而是一个独立可测、可关掉(mode=None)的处理器。