跳到主要内容

第 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 回复里拿命令的方式,体现在两个模型类上:

路线怎么拿命令适合
工具调用(默认推荐)LitellmModelresponse.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__.pyGlobalModelStats)。每次模型调用都 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__.pyget_model:模型名里含 anthropic/sonnet/opus/claude 就设 set_cache_control="default_end")。

这又是“变化点封装”的范例:缓存逻辑既不在 Agent、也不在 query 主流程,而是一个独立可测、可关掉(mode=None)的处理器。

3.8 观察消息怎么回填

执行完命令,Model 还负责把环境输出格式化成模型看得懂的观察消息(format_observation_messages)。两条路线又分叉:

  • 工具调用路线把它包成 role="tool" + tool_call_id 的工具结果消息(actions_toolcall.py:78-112),并对没执行成功的动作补一条占位(not_executed,actions_toolcall.py:87-88),保证工具调用和结果一一配对——否则有些 API 会报错。
  • 纯文本路线就包成普通 role="user" 消息(actions_text.py:43-70)。

观察模板(observation_template)还内置了长输出截断:超过 1 万字符就只留头尾各 5000 字 + 一句“输出太长,换个命令”的警告(config/default.yaml:114-141config/mini.yaml 里另有 JSON 版)。防止一条 cat 大文件 把上下文撑爆。

3.9 边界与坑

  • drop_params: true(config/default.yaml:142-143config/mini.yaml):litellm 遇到某模型不支持的参数会自动丢弃而不是报错,提升跨模型兼容。
  • 不止 litellm: 还有 openrouter、portkey、requesty 等专用模型类(models/ 目录),以及 *_response_model.py(走 OpenAI 的 /responses 端点)和测试用的 DeterministicModel。它们都遵循同一 Protocol。
  • 成本为 0 的本地模型容易触发成本计算报错,必须显式设 cost_tracking=ignore_errors——新手常踩。

3.10 代码地图

主题文件符号
模型契约(Protocol)src/minisweagent/__init__.pyModel
查询 + 解析 + 计费 + 重试编排src/minisweagent/models/litellm_model.pyLitellmModel.query
带 bash 工具发请求src/minisweagent/models/litellm_model.pyLitellmModel._query BASH_TOOL
工具调用 → 动作src/minisweagent/models/utils/actions_toolcall.pyparse_toolcall_actions
纯文本 → 动作(正则)src/minisweagent/models/utils/actions_text.pyparse_regex_actions
纯文本模型类src/minisweagent/models/litellm_textbased_model.pyLitellmTextbasedModel
成本计算(算不出报错)src/minisweagent/models/litellm_model.pyLitellmModel._calculate_cost
全局成本/调用限额src/minisweagent/models/__init__.pyGlobalModelStats GLOBAL_MODEL_STATS
指数退避重试src/minisweagent/models/utils/retry.pyretry
不重试的异常src/minisweagent/models/litellm_model.pyLitellmModel.abort_exceptions
Anthropic 缓存标记src/minisweagent/models/utils/cache_control.pyset_cache_control
模型选择 + 自动开缓存src/minisweagent/models/__init__.pyget_model get_model_class
观察消息格式化(工具结果)src/minisweagent/models/utils/actions_toolcall.pyformat_toolcall_observation_messages