mini-swe-agent — 本课题摘录
读了哪几篇: 01-agent-loop(主循环与异常即控制流)、02-environment-and-submission(执行环境与无 shell 会话)、03-model-layer(动作解析)。
其余一篇(配置与三种运行器)本轮没读。
这是"最小循环真能干活"最硬的一个证据:约 100 行,还能在真实软件工程基准上跑出分。
它对本课题回答了什么
决定一:一轮的输入怎么拼 —— 消息列表就是全部状态
agent 的全部状态几乎就是一个消息列表,每一步只往里追加,从不改旧的。 (依据:前沿库 · mini-SWE-agent · 主循环与“异常即控制流” —— agent 的全部状态几乎就是 self.messages 列表,每步只 append 不改旧的,所以「轨迹」和「喂给模型的消息」是同一个东西)
这条的连带好处很大: 存下来的运行轨迹和实际喂给模型的东西是同一份,调试和拿去微调都不用做任何转换。
初始只放两条消息:一条教模型怎么输出的说明,一条把任务填进去的用户消息。模板用严格模式渲染——引用了不存在的变量会直接报错,不会悄悄渲染成空串。 (依据:前沿库 · mini-SWE-agent · 主循环与“异常即控制流” —— system/instance 模板用 Jinja2 的 StrictUndefined 渲染,模板里引用不存在的变量直接报错)
决定四:什么时候停 —— 异常即控制流
这是这一家最值得抄的一条。
所有"该中断正常流程的事"——任务完成、花费超限、时间超时、用户打断、模型输出格式不对——全部做成异常类,继承同一个基类。
关键在于:异常对象自带一个"该往历史里追加什么话"的字段。
抛异常的地方 ──► 只需把「该说什么话」打包进异常
│
主循环 ──► except 基类: 把异常自带的消息追加进历史
│
└─► 有的追加一条「退出」角色的消息 → 循环结束
└─► 有的追加一条提示消息 → 继续转
(依据:前沿库 · mini-SWE-agent · 主循环与“异常即控制流” —— 所有中断事件继承自 InterruptAgentFlow,异常对象自带 messages 字段装「该追加进历史的消息」,run 的一行 add_messages(*e.messages) 统一处理完成/超限/打断)
循环的退出条件不是布尔标志,是"最后一条消息是不是退出角色"。
继承关系也被用上了:"超时"是"超限"的子类,所以想抓所有限额问题写一个 except 就连超时一起抓了——交互式版本正是靠这一点区分"用户可以提高的限额"和"提高了也没用的墙钟超时"。
限额检查放在调模型的入口,不放在循环条件里。 超了就抛异常,和其它中断走同一条路。 (依据:前沿库 · mini-SWE-agent · 主循环与“异常即控制流” —— 步数/花费/墙钟的检查在 query() 开头而非 while 条件里,超限即抛异常,与其它中断走同一条路)
决定四补充:格式错误要防"连击"
格式错误单独处理——模型偶尔写错,提示一下就好。但连续错太多次说明模型卡死了,就追加一条退出消息放弃。干净地走完一步就把连错计数清零。 (依据:前沿库 · mini-SWE-agent · 主循环与“异常即控制流” —— FormatError 单独捕获,连续次数达到 max_consecutive_format_errors 才追加退出消息放弃,否则把纠错提示喂回再来一轮;干净的一步把连错计数清零)
决定四再补充:完成不由模型自称,由环境认出来
它不设"完成工具",而是约定一个魔法字符串。 提示词里教模型:做完后执行 echo <哨兵字符串>。环境在每次执行后检查输出第一行,认出哨兵且返回码为 0,就抛"已提交"异常;哨兵之后的内容就是提交物。
(依据:前沿库 · mini-SWE-agent · 执行环境与“无 shell 会话” —— 完成靠环境在每次执行后检查输出第一行是否等于 COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT 且 returncode 为 0,命中则抛 Submitted,哨兵之后的内容作为提交物)
这是"什么时候停"的一个独立答案: cline 是"某个工具被标成完成工具",vercel 是"停止条件谓词",这里是**"环境在真实输出里认出信号"**——判定权不在模型也不在循环,在执行环境。
决定二:怎么认出模型要调工具 —— 解析不在循环里
一个干净的职责划分:agent 不解析模型输出。 动作是模型层在返回时就解析好、塞进消息的附加字段里的;agent 只管把附加字段里的动作拿出来执行。 (依据:前沿库 · mini-SWE-agent · 主循环与“异常即控制流” —— 动作由 Model 在 query() 里解析好塞进 message["extra"]["actions"],Agent 只管取出执行,因而对「工具调用格式 vs 纯文本格式」完全无感)
结果是循环对"用哪种工具协议"完全无感。 模型层里有两种实现并存:一种用原生工具调用解析,另一种用正则从纯文本里抠。换协议不动循环一行。
它的做法(可以抄的部分)
每条命令开一个全新子进程,不维护常驻 shell
这是它最反直觉、也最值得学的一点。
| 后果 | 好处 / 代价 |
|---|---|
| 命令彼此独立 | 把本地执行换成容器执行就直接跑在容器里;并行开 N 个实例毫无共享状态 |
| 没有持久状态 | cd 和环境变量不会保留到下一条命令 |
| 进程组隔离 | 超时能整组杀掉,不留孤儿进程 |
(依据:前沿库 · mini-SWE-agent · 执行环境与“无 shell 会话” —— 不维护常驻 shell,每条命令用 subprocess 开全新进程;好处是换成 docker exec 即得沙箱、并行无共享状态,代价是 cd/export 不跨命令保留)
"状态不持久"这个代价它不在代码里补,在提示词里告诉模型——"目录和环境变量改动不持久,但你可以用 VAR=1 cd /path && ... 串成一条命令"。
这条口径值得单独记:能丢给模型解决的,就不写进脚手架。
超时要杀整个进程组,不能只杀父进程。 模型常跑出会 fork 的命令(起服务器、跑测试),只杀父进程会留一堆吃 CPU 的孤儿。 (依据:前 沿库 · mini-SWE-agent · 执行环境与“无 shell 会话” —— 用 start_new_session 把命令放进新进程组,超时后 killpg 杀整组,防止 fork 出的子进程变孤儿)
每一步都落盘轨迹,哪怕崩了也有现场
主循环的 finally 里每步都存一次。未捕获的异常先存成退出消息再继续往上抛——不假装没事。
它没回答什么
- 历史超长怎么办——它这一层不裁剪历史。上下文塞爆由模型层报错,agent 层不管。要看
headroom、opencode、dexter。 - 多个动作怎么并发——它顺序执行,默认提示词也要求"每次恰好一个动作"。并发看
haystack、openai-agents-js、kimi-code。 - 工具怎么定义——它只有一个"跑 bash"的动作,没有工具清单这回事。要看
mcp-typescript-sdk、beeai-framework。
坑与代价
- 配置默认值与发行配置不一致。 代码里写的默认花费上限是 3 美 元,但随包发行的配置文件把它覆盖成"无上限"。照代码默认值推断真实预算会被误导。
(依据:前沿库 · mini-SWE-agent · 主循环与“异常即控制流” —— AgentConfig.cost_limit 的 dataclass 默认是 3.0,但发行的 default.yaml 覆盖为 0.(无上限),实际开箱行为是不限成本)
这条对我们是个直接教训:默认值要在一个地方写死,不能代码一份、配置一份。
- 没有 token 级预算,只有花费 / 步数 / 墙钟三种。
- 只增不改的历史意味着不能压缩。 这是"最小"的代价——跑长了就撑爆。