Harness

读 190 行代码,把 harness 这个词彻底讲清楚

harness 这个词现在到处都是,但很少有人点破它同时指着两样东西:跑循环的那个程序,和你在自己仓库里为它准备的那些文件。分不清这两者,你读到的每一篇 harness 文章都会在某处突然对不上。这篇用 mini-swe-agent 的主循环把它们拆开——190 行,一口气读得完,而指令、工具、环境、状态、反馈这五个子系统,在里面各能指到具体的行号。

基于 SWE-agent/mini-swe-agent@25941c8 · 读于 2026年8月26日

引用 31 处

一、模型很强,活还是干不成

Anthropic 做过一次对照实验:同一个模型(Opus 4.5)、同一个任务(做一个复古 2D 游戏制作器)、跑两遍。

第一遍让模型裸跑,20 分钟、9 美元。作者的原话是「我的实体出现在屏幕上了,但对输入毫无反应」。第二遍套上一个 planner + generator + evaluator 的三 agent 结构,6 小时、200 美元——「我真的能移动我的实体,能玩这个游戏了」。

他们自己的总结是:贵了 20 多倍,但产出质量的差别一眼就能看出来。

模型一个字没换。换的是模型外面那圈东西。

那圈东西就叫 harness。可一旦你想动手改进它,第一个问题就来了:**我到底该去改什么?**去改我装的那个 agent 吗?还是在仓库里加一个 AGENTS.md?两种说法你都见过,而且都自称在讲 harness。

二、这个词指两样东西,这是你没懂的原因

问题出在定义本身。目前流传最广的说法把 harness 定义成「模型权重之外的一切工程基础设施」,然后紧接着举例说 Claude Code、Cursor、Codex 都是 harness。

两句话都对,但它们说的不是一层东西:

  • 一层是机制。「问一次模型 → 执行 → 把结果给回模型」这个环怎么转、什么时候停、模型能碰到哪些工具、结果以什么形态回灌。mini-swe-agent、Claude Code、Codex 都属于这一层,而这一层是别人写好的程序。
  • **一层是内容。**指令文件、验证命令、进度记录、目录约定——真正流经机制、最终被送到模型眼前的那些东西。它有一部分是程序出厂自带的,其余归你。

它们的关系不是并列,是插槽与填充物:机制那层的代码开出若干个槽,内容是流经这些槽的东西。

分清这两层,一个很常见的困惑就解开了——既然 Claude Code 本身就是个 harness,那我还谈什么「harness engineering」?我又没在写 Claude Code。

答案是这两层的归属不一样。机制那层确实轮不到你改。但槽里能被你改动的那部分,全都直接落在模型眼前——你写 AGENTS.md、定下验证命令、留一份进度文件,做的就是这件事。所以 harness engineering 不是「造一个 harness」,是把别人开好的槽填对

不过「往槽里填」这个比喻要打个折扣:槽不是空的,两边都自带填充。mini-swe-agent 的那份就是随仓库出厂的 config/mini.yaml,Claude Code、Codex 这类现成工具(装上就能用、你看不到里面)则把系统提示、工具清单、截断规则预置在程序内部。真正的差别是看不看得见:前者你打得开、读得完、改得动;后者你只能在它之上再叠一层自己的。每个槽能叠多少差别很大,第四节会把这笔账按槽算清。

OpenAI 那支用 Codex 写了一百万行代码、五个月里没人手写过一行的团队,把这半边说得更直白。他们描述自己工作性质变化时用的原话是:一个软件工程团队的主要工作不再是写代码,而是**「设计环境、明确意图、搭建反馈回路」**。环境、指令、反馈——三样都是下面要讲的槽。他们没在改 Codex 本身,他们在填槽。

而这也正是光读概念文章学不会 harness 的原因:槽长什么样,只有读代码才看得见。

下面这 190 行,就是把槽画出来给你看。

三、190 行代码里的五个插槽

SWE-agent/mini-swe-agent 的核心是 src/minisweagent/agents/default.py。仓库 README 自称「some 100 lines」,我们在 25941c8 这一版实际数出来是 190 行(去掉空行与注释 171 行)——数字有出入,但结论不变:它小到可以整个读完。

整个循环只有两个方法。run() 是一个 while True,而 step() 只有一行:

def step(self) -> list[dict]:
    """Query the LM, execute actions."""
    return self.execute_actions(self.query())

问模型,执行模型要求的动作。一个 agent 的全部主干就是这个。

**循环怎么停?**注意这里,它是整个设计里最值得学的一处:

if self.messages[-1].get("role") == "exit":
    break

停止条件不是「模型说它做完了」,而是消息列表的最后一条带着 exit 这个角色。模型没有权限直接写出这样一条消息——它只能通过触发某个具体事件,让别人替它写。谁有这个权限?第四节和第五节各回答一半。

接下来要往这个骨架上按的,是一个借来的分法:把 harness 拆成指令、工具、环境、状态、反馈五个子系统。它出自 learn-harness-engineering 的第二讲。

五个槽在这 190 行里都指得到具体行号。下面每一节先用一句话说清这个槽回答的是什么问题,再在真实代码里把它指出来——概念文章通常只做前半件,而槽长什么样,只有后半件才看得见。

槽 1 · 指令

**指令回答的问题是:模型在开口之前,知道些什么。**它是你对模型说的、不随每一步变化的那部分话——它是谁、守什么规矩、这次要干什么、什么算完成。五个槽里只有它纯粹由文字构成,也因此最容易被误当成 harness 的全部。

run() 开头,两个模板被渲染成头两条消息:

self.add_messages(
    self.model.format_message(role="system", content=self._render_template(self.config.system_template)),
    self.model.format_message(role="user", content=self._render_template(self.config.instance_template)),
)

system_templateinstance_template 是配置项,不是代码。它们住在 config/mini.yaml 里,你可以改。

模板里留了占位符,要拿别处的值来填。比如指令里有一行写着 {{system}} {{release}} {{version}} {{machine}},这四个值都来自环境——第四节会看到它们具体从哪儿来。填这些占位符用的是 Jinja2 的 StrictUndefined有一个填不上就当场抛错。

Jinja2 默认不是这个行为。默认是把填不上的占位符渲染成空字符串,然后若无其事地继续。放在这里,那意味着模型会收到一份少了一句话的指令,照常开工,而你从头到尾不会收到任何信号。选 StrictUndefined 就是拒绝这个结局:指令缺一块,宁可整个不跑。

槽 2 · 工具

**工具回答的问题是:模型能对世界做什么。**除了工具,它只能产出文字——所以这个槽的大小,就是 agent 能力的上限。

outputs = [self.env.execute(action) for action in message.get("extra", {}).get("actions", [])]

多数 agent 会给模型一份工具清单read_filewrite_filesearchrun_command……每一项都得声明名字、参数和类型,模型每一步从清单里挑一个来用。

mini-swe-agent 的清单只有一项:一个叫 bash 的工具,而它的参数表也只有一个 commandmini.yaml 把这份「清单」原样写给模型看:

Call the bash tool with your command as the argument:

  • Tool: bash
  • Arguments: {"command": "your_command_here"}

也就是说,模型每一步能做的事只有一件——说出一条 shell 命令。要读文件就 cat,要改文件就 sed,要搜索就 grep

这么做的收益是:**你永远不用再加工具。**机器上装了什么,模型就能用什么——jqrggitpython,不需要在 harness 这边为它们各写一份参数声明和一套错误处理。

代价是模型得真的会用命令行。所以 mini.yaml 里专门拿出 46 行教它:怎么用 heredoc 建文件、怎么用 sed 按行替换、怎么用 nl 带行号看文件。**工具那边省下的复杂度,原样搬进了指令里。**两个槽加起来的工作量没变,只是换了个地方付。

槽 3 · 环境

**环境回答的问题是:工具动作落在哪儿。**同一句 rm -rf 落在你的笔记本上和落在一个一次性容器里是两件事——这个槽决定的是后果的范围。

Environment 是一个只有三个方法的 Protocol——Python 里的一种「接口约定」:任何一个类,只要身上长着这三个方法,它就算是一个 Environment,不需要继承谁,也不需要去哪儿注册。所以下面方法体里的 ... 不是我省略了代码,是文件里真的没有实现——约定只规定形状,不规定做法。

class Environment(Protocol):
    config: Any
    def execute(self, action: dict, cwd: str = "") -> dict[str, Any]: ...
    def get_template_vars(self, **kwargs) -> dict[str, Any]: ...
    def serialize(self) -> dict: ...

三个方法各管一件事:

  • execute —— 跑一条命令,把结果交回来。槽 2 那个 action 字典就是喂给它的,整个工具槽最后都落在这一个方法上。
  • get_template_vars —— 方向相反:把环境自己的信息(操作系统、版本……)交出去,给指令模板填占位符。这是环境反过来影响指令的那条线,第四节会专门看它。
  • serialize —— 把环境自身导出成一个 dict,槽 4 那份轨迹里就有它一份。

LocalEnvironment 是最朴素的实现:用 subprocess.Popen(command, shell=True, ...) 起一个新的子 shell 来跑,带 30 秒默认超时。

超时的时候它杀的是整个进程组killpg),不是只杀那个子 shell。原因在 & 上:shell 里给命令末尾加一个 &,意思是「放到后台跑,别等它」,命令立刻返回。模型很容易这么写——比如 python -m http.server & 起个服务器,下一步再去 curl 它。可这么派生出来的进程挂在子 shell 底下,只杀子 shell 的话它们就成了孤儿:没了父亲,但没死,继续留在你机器上跑。killpg 是连整个进程组一起收掉。

而这三个方法就是全部的约定。所以你换一个「在 Docker 容器里执行」的实现塞进来,agent 那 190 行一个字都不用改。这就是「槽」的字面意思:形状钉死,填什么随你。

槽 4 · 状态

**状态回答的问题是:下一次调用时,模型还记得什么。**模型本身是无状态的,每次调用都从零开始,你不重放的东西对它就不存在。所以「这个 agent 记得什么」从来不是模型的属性,是 harness 的属性。

self.messages: list[dict] = []

一个列表。这就是全部的运行时状态。

另一半状态要活过进程本身,靠的是 save():把 messages、成本、调用次数、配置一起序列化成 JSON 写进文件——也就是常说的轨迹(trajectory),事后复盘、比对、算账看的都是它。

关键在于这句调用写在哪儿run() 的循环里,包着 step() 的那个 tryfinally 分支。finally 的语义是「不管这段代码怎么收场都要执行」——即便 step() 正在抛异常,也得先让 save() 跑完,异常才接着往外传、去找 run() 里那些接得住它的 except 分支。finally 只是插在中间做收尾,它不会把异常吞掉。

这在别处只算好习惯,在这里是必需的,因为这个 agent 的绝大多数收场都是异常:模型宣布完成抛 Submitted,撞上成本闸抛 LimitsExceeded,超时抛 TimeExceeded(第五节讲这几道闸)。要是把 save() 写在正常返回的那条路上,最该留下现场的那几种结局反而一片空白。

槽 5 · 反馈

**反馈回答的问题是:执行结果以什么形态回到模型眼前。**它必然要经过加工,因为上下文窗口有限而命令输出无限——问题从来不是要不要处理,只是在哪儿处理、按什么规则。

执行结果不是直接扔回去的,它要先过一层模板:

observation_template: |
  {%- if output.output | length < 10000 -%}
  { "returncode": {{ output.returncode }}, "output": {{ output.output | tojson }} }
  {%- else -%}
  { "output_head": ..., "output_tail": ..., "elided_chars": ..., "warning": "Output too long." }
  {%- endif -%}

一条 find / 能打出几十万字符,直接回灌会把上下文窗口一次吃光。所以超过一万字符就只留头尾各 5000,中间换成一个「省略了多少」的计数。

这件事有个正经名字,叫上下文预算管理:窗口就那么大,谁有资格占位置,总得有人决定。在大一些的 harness 里它往往是一整套装置——专门的组件、负责摘要的模型、分级存储。而在这里,它的全部实现就是上面那个 {%- if -%}:一个长度阈值,截头留尾。

更值得注意的是它落在配置里,而不是代码里。想让模型多看见一些,把 10000 改大就行,Python 一行都不用碰——按第二节那个划分,上下文预算在这个 harness 里属于内容,不属于机制。

四、你写的东西,是往哪个槽里填

现在回头看那些「harness 最佳实践」文章让你做的事,每一件都能对上一个槽。

mini.yamlinstance_template 里,装的正好是那些文章要你写进 AGENTS.md 的东西——推荐工作流、硬约束、完成的定义。比如这条硬约束:

Directory or environment variable changes are not persistent. Every action is executed in a new subshell.

这句是字面意义上的真:每次 execute 都新起一个 subprocess,上一条命令 cd 去了哪儿、export 了什么,下一条完全不知情。所以它不是在提醒模型小心,是在如实描述槽 3 的实现。

指令层和环境层必须对得上。假如这句话没写,模型很可能先敲 cd src,下一步直接 ls,以为看的是 src/ 里的东西——实际上它早就回到了起点,看到的是别处,而且它不会察觉。模型只能照着指令描述的那个世界行动;描述错了,它的每一步都会稳定地错在同一个地方。

顺带一个值得注意的巧合:mini.yaml 里这份 instance_template 正好 96 行(去掉空行 69 行)。而 OpenAI 那支团队试过「一个大 AGENTS.md」并且失败了,最后落到的做法是把 AGENTS.md目录而不是百科全书,长度「roughly 100 lines」,细节推到 docs/ 里按需读取。一个是给 agent 的开场指令,一个是给 agent 的仓库入口,两边互不相干,却收敛到同一个量级。

更有意思的是两个槽之间的连线LocalEnvironment.get_template_vars() 会把 platform.uname() 整个塞进模板变量,于是 mini.yaml 里可以写:

{%- if system == "Darwin" -%}
<important>
You are on MacOS. For all the below examples, you need to use `sed -i ''` instead of `sed -i`.
</important>
{%- endif -%}

**环境把自己描述进了指令。**你在 macOS 上跑,模型收到的指令就和 Linux 上不一样。这种细节在概念文章里永远看不到,因为它不属于任何一个子系统——它属于两个子系统的接缝。

「完成」的定义也在槽里

那些文章反复强调:不能让 agent 自己说做完了。这个原则在这里被实现成了一条具体的路径。

指令层告诉模型完成的方式是执行一条特定命令:

Submit your changes and finish your work by issuing the following command: echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT.

而环境层在每次执行之后嗅探 stdout:

lines = output.get("output", "").lstrip().splitlines(keepends=True)
if lines and lines[0].strip() == "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT" and output["returncode"] == 0:
    raise Submitted({"role": "exit", ...})

第一行必须正好是那个魔法字符串,而且返回码必须是 0。条件成立才抛出 Submitted,而 Submitted 携带的正是那条 role: "exit" 的消息——第三节那个停止条件,到这里才闭合。

所以「模型不能自己宣布完成」在代码里的含义是:模型能做的只是发出一条 shell 命令;这条命令得真的执行成功,环境才会替它写下那条退出消息。判定权在环境手里,不在模型手里。

你的份额,按槽算

上面这些都是在 mini-swe-agent 里说的——那里每个槽的填充物都摊在 mini.yaml 里,你想动哪个动哪个。换成 Claude Code 这类现成工具,填充物封在程序内部,于是每个槽你能动多少,差别很大:

机制(程序定死) 内容(你供)
指令 何时注入、读哪些文件、谁压过谁 AGENTS.md / CLAUDE.md、skill 正文、你这次提的需求
工具 清单、调用协议、权限模型 MCP server、skill 捎带的脚本,以及机器上装了什么命令
环境 本机起 subprocess 还是进容器 选哪个,以及里面装了什么、有没有网、数据库可不可写
状态 消息列表、上下文压缩、读哪个 memory 文件 进度文档写什么、记到什么粒度
反馈 截断规则、结果怎么回灌 你的命令打印出什么——尤其是失败的那几行

有两处值得单独点出来。

**工具那一栏看着最不归你,其实不然。**只要清单里有一个 shell 工具,这个槽的内容就变成了「你这台机器上装了什么」——装上 rgjqgh,模型立刻多三个工具,harness 一行都不用改。这就是第三节那笔账的现实版:mini-swe-agent 把它做到了极端,现成工具则是把 shell 混在清单里一起给你。

反馈的机制归 agent,内容几乎全归你。截断规则你动不了,但回灌给模型的那段字是你的仓库产生的。一个测试跑完只打 1 test failed,和一个打出断言 diff 加文件行号的,模型的下一步完全不同;类型检查是静默退出还是列出 12 个错误,也是你决定的。OpenAI 那句「搭建反馈回路」指的就是这件事——他们没在改 Codex,他们在让失败说人话

五、四道闸:agent 为什么会跑飞,以及怎么拦

AgentConfig 上有四个限制,它们是这 190 行里最该被抄走的部分:

step_limit: int = 0
cost_limit: float = 3.0
wall_time_limit_seconds: int = 0
max_consecutive_format_errors: int = 3

前三道闸的检查都在 query() 里,在调用模型之前

if 0 < self.config.step_limit <= self.n_calls or 0 < self.config.cost_limit <= self.cost:
    raise LimitsExceeded(...)
if 0 < self.config.wall_time_limit_seconds <= int(time.time() - self._start_time):
    raise TimeExceeded(...)

0 < self.config.step_limit <= self.n_calls 是 Python 的链式比较,读作两个条件同时成立:step_limit > 0(这道闸开着)并且 n_calls >= step_limit(已经撞上了)。

于是把限额设成 0,就等于关掉这道闸——左半边 0 < 0 当场为假,右半边连算都不会算。这是个常见的哨兵值约定:配置项总得有个值用来表示「不限」,这里挑的是 0。

再回头看默认值:step_limit = 0wall_time_limit_seconds = 0cost_limit = 3.0。所以开箱跑起来只有成本闸是活的,3 美元封顶;步数不限,墙钟(真实流逝的时间)也不限。

第四道闸管的是另一类失败:模型的输出解析不出动作。它不是一次就死——max_consecutive_format_errors 数的是连续次数,而任何一次干净的 step 都会把计数清零:

self.step()
self.n_consecutive_format_errors = 0  # reset on any clean step

偶尔抽风不算问题,连着三次抽风才算跑飞了。

还有一个只有读代码才知道的细节。格式错误的分支里有这么一行,带着一句注释:

# The call was billed before parsing failed, so query() never got to charge it.
self.cost += e.messages[0].get("extra", {}).get("cost", 0.0)

模型已经出过话、账已经扣了,只是解析失败没走到正常记账的地方。不补这一笔,一个反复输出错格式的模型就能绕过成本闸无限烧钱——因为成本永远停在原地。

这四道闸加起来不到十行。它们回答的是「agent 为什么既越界又完不成」这个问题——答案不是提示词写得不够好,是没人给它设终止条件

六、所以,harness 是什么

回到开头那个词。现在可以给一个不会再让你卡住的说法:

harness 是模型外面那圈东西,它由两半组成。一半是机制:跑循环的那个程序规定了有哪些槽——指令、工具、环境、状态、反馈——以及什么时候必须停。另一半是流经这些槽的内容。前一半你通常改不了;后一半也未必全归你,但能被你改动的东西,全在这里

mini-swe-agent 的价值在于它把前一半压缩到了 190 行,小到你能一次看完,于是那些槽第一次变成了具体的东西:指令是 run() 开头渲染的两个模板,工具是 env.execute(),环境是一个三方法的 Protocol,状态是一个 list 加一次 save(),反馈是一个带 if 的模板。

看清楚槽在哪儿,你才知道自己写的那份 AGENTS.md 到底是在往哪儿塞。

参考与出处

本文的代码论断全部来自 SWE-agent/mini-swe-agent25941c8,逐条引用见文首的证据条。外部材料:

本站也收录了这个项目:mini-swe-agent 的目录页,有 star 曲线和一段简介。