跳到主要内容

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 层不管。要看 headroomopencodedexter
  • 多个动作怎么并发——它顺序执行,默认提示词也要求"每次恰好一个动作"。并发看 haystackopenai-agents-jskimi-code
  • 工具怎么定义——它只有一个"跑 bash"的动作,没有工具清单这回事。要看 mcp-typescript-sdkbeeai-framework

坑与代价

  • 配置默认值与发行配置不一致。 代码里写的默认花费上限是 3 美元,但随包发行的配置文件把它覆盖成"无上限"。照代码默认值推断真实预算会被误导。 (依据:前沿库 · mini-SWE-agent · 主循环与“异常即控制流” —— AgentConfig.cost_limit 的 dataclass 默认是 3.0,但发行的 default.yaml 覆盖为 0.(无上限),实际开箱行为是不限成本)

    这条对我们是个直接教训:默认值要在一个地方写死,不能代码一份、配置一份。

  • 没有 token 级预算,只有花费 / 步数 / 墙钟三种。
  • 只增不改的历史意味着不能压缩。 这是"最小"的代价——跑长了就撑爆。