跳到主要内容

Agent 抽象与三大流派(被评测的主角)

30 秒导读: 一个 benchmark 要能公平比较 Terminus、Claude Code、oracle 这些五花八门的东西, 前提是它们在 harness 眼里长一个样。本章讲清那个"一个样"是什么——BaseAgent 这份契约—— 再看三种完全不同的东西怎么各自钻进这份契约里。

本章的位置:任务长什么样见 01-task-anatomy,一次 trial 怎么端到端跑见 02-harness-loop,agent 操作的那个终端沙箱见 03-terminal-tmux, 跑完怎么判分见 05-scoring-results。这里只讲被评测的主角本身


1. 这是什么(零基础也能懂)

一句话定义: 在 Terminal-Bench 里,"agent" 就是一个会拿到任务指令、被丢进一个真终端、 然后想办法把任务做完的东西。它可以是一段自写的 LLM 循环,也可以是别人家的命令行工具(如 claude),甚至可以是"直接把标准答案跑一遍"的作弊基线。

为什么需要一层抽象? 想象你要给这么多参赛者排名次:

参赛者它其实是什么
Terminus项目自带的一段 Python,循环调 LLM、把命令敲进终端
Claude CodeAnthropic 的现成 CLI,得先 npm install 装进容器再跑
oracle根本不是 AI,直接执行任务作者写好的 solution.sh

这三个从里到外都不一样。harness 不可能为每一个单独写一套跑法——那样加一个新 agent 就要改一次 harness。解决办法是定一份契约:不管你内部多怪,只要你实现"给你指令和终端、你还我一个结果" 这一个方法,harness 就能跑你。

一句话直觉: 就像电源插座。插座(harness)不关心插头后面是台灯还是电钻,只认插头的形状 (perform_task)。三大流派 = 三种"插头后面的电器",但插头形状必须一样。


2. 顶层全景(契约 + 三大流派怎么共存)

怎么读这张图: 中间那根竖线是唯一的契约 perform_task。左边是 harness(它只透过契约看 agent), 右边三支是三种把自己塞进契约的方式。三支内部天差地别,但对 harness 的那个"插头"完全一样。

┌─ 契约 (base_agent.py) ─┐
harness 端 │ │ 三大流派(右侧)
(见 02 章) │ perform_task( │
│ instruction, │ ①进程内 LLM 循环
拿到 agent 实例 ───调用──▶│ session, ────────┼──▶ Terminus / Terminus2
│ logging_dir │ (自己 prompt→LLM→敲键→读屏)
◀── AgentResult ─────────│ ) -> AgentResult │
(token/failure/marker)│ │ ②容器内现成 CLI
│ ├──▶ ClaudeCodeAgent 等
AgentFactory │ name() version │ (装 CLI 进容器,跑 claude -p)
(按名字/import 造实例) │ prompt_template │
│ │ ③oracle / 基线
└────────────────────────┘──▶ OracleAgent (跑标准答案)
naive / nop (对照组)

部件一句话职责:

部件干什么在哪
BaseAgent定义契约:抽象方法 perform_task / name,属性 version / prompt_templateagents/base_agent.py:38
AgentResultagent 交回的战报:token 数、失败模式、时间戳标记agents/base_agent.py:11
FailureMode失败原因枚举(超时 / 解析错 / 装不上等)agents/failure_mode.py:4
AgentName内置 agent 的名字枚举(CLI --agent 用的就是这些字符串)agents/agent_name.py:4
AgentFactory按名字或 import_path 造出 agent 实例(注册表 + 动态加载)agents/agent_factory.py:36

3. 契约本身:BaseAgentAgentResult

这节讲那份"插头形状"到底规定了什么。契约很短,就四样东西。

3.1 唯一的核心方法 perform_task

harness 跟 agent 打交道,从头到尾只调这一个方法(base_agent.py:124):

# 示意,非源码:契约签名
def perform_task(
self,
instruction: str, # 任务指令(任务的 task.yaml 里那段自然语言)
session: TmuxSession, # 一个活的终端会话,agent 往里敲键、从里读屏
logging_dir: Path | None, # 把轨迹/中间产物写哪
) -> AgentResult: # 交回战报
...

三个入参、一个返回,就是全部。agent 不自己起容器、不自己判分——容器已经由 harness 建好, session 递进来即用;判分是 agent 交回后 harness 另外做的(见 05 章)。 这条边界是三大流派能共存的根:agent 只管"在终端里把事做了",别的都不归它。

session 是什么、send_keys / capture_pane 怎么真正把键敲进 tmux,见 03-terminal-tmux。本章只需知道:agent 手里的终端就是这个对象。

3.2 交回的战报 AgentResult

AgentResult 是个 Pydantic 模型(base_agent.py:11),装四样:

字段含义默认
total_input_tokens这次任务累计输入 token0
total_output_tokens累计输出 token0
failure_modeagent 自己知道的失败原因;不知道就填 NONEFailureMode.NONE
timestamped_markers(时间戳, 文本) 列表,给 asciinema 录像打标签用[]

关键细节:failure_mode 是 agent 的自述,不是判决。 多数 agent(包括跑成功的 Terminus) 返回的就是 NONE——因为"任务对没对"由测试脚本说了算,不由 agent 自称。agent 只在它自己确知 自己挂了时才填非 NONE:比如 naive agent 解析不了 LLM 回复就填 FATAL_LLM_PARSE_ERRORnaive_agent.py:90),installed agent 装不上就填 AGENT_INSTALLATION_FAILEDabstract_installed_agent.py:166)。全部可选值:

UNSET / NONE / UNKNOWN
TEST_TIMEOUT / AGENT_TIMEOUT / UNKNOWN_AGENT_ERROR
PARSE_ERROR / FATAL_LLM_PARSE_ERROR
CONTEXT_LENGTH_EXCEEDED / OUTPUT_LENGTH_EXCEEDED
AGENT_INSTALLATION_FAILED

failure_mode.py:4

3.3 另外两样:versionprompt_template

契约还带两个属性,都可选:

  • versionbase_agent.py:52)——任意字符串,可由构造参数动态给。它的用处很具体: installed agent 的安装脚本用它锁版本,例如 npm install -g @anthropic-ai/claude-code@{version}
  • prompt_templatebase_agent.py:81)——一个 Jinja2 模板文件路径。给了它,_render_instructionbase_agent.py:104)就在把指令交给 agent 前先套一遍模板;没给就原样透传。这让你能不改代码、 只用 --agent-kwarg prompt_template=... 给同一个 agent 换外壳提示词。

4. 注册与加载:AgentFactory + AgentName

这节讲:你在命令行敲 --agent terminus,那个字符串怎么变成一个真实的 Python 对象。

4.1 两种寻址方式

AgentFactory.get_agentagent_factory.py:104)接受二选一的入参(给了两个或都不给都报错):

方式参数适合
按名字agent_name: AgentName内置 agent,查静态注册表
按导入路径import_path: "module:Class"库外自定义 agent,动态 import

内置注册表是一个"名字→类"的字典,在类体里一次性建好(agent_factory.py:37):

# 示意,非源码:注册表就是把每个类的 name() 当 key
AGENT_NAME_TO_CLASS = {
agent.name(): agent
for agent in [OracleAgent, NaiveAgent, Terminus, Terminus2,
ClaudeCodeAgent, ...] # 全部内置 agent 列在这
}
AGENT_NAME_TO_CLASS["terminus"] = Terminus # 老名字 terminus 兼容到 terminus-1

注意 key 来自每个类的静态方法 name(),而 name() 通常返回 AgentName 枚举的 .value (比如 AgentName.TERMINUS_1.value == "terminus-1")。所以枚举、name()、注册表三者要对得上, 枚举是这些字符串的单一事实源(agent_name.py:4)。

4.2 库外 agent:动态加载

get_agent_from_import_pathagent_factory.py:63)把 "my_pkg.my_mod:MyAgent" 拆成模块名和类名, importlib.import_module 加载,getattr 取类,并强制校验它是 BaseAgent 子类才放行:

# 示意,非源码
module = importlib.import_module(module_name)
agent_class = getattr(module, class_name)
if not issubclass(agent_class, BaseAgent): # 不签契约的一律拒绝
raise ValueError(...)

这一步是整个抽象的闭环:任何人写的 agent,只要继承 BaseAgent 实现 perform_task,就能被同一套 harness 跑,无需改动 Terminal-Bench 一行代码。


5. 流派一:进程内 LLM 循环 —— Terminus

这是"最纯"的一支:agent 的大脑(LLM 循环)跑在 harness 进程里,通过 session 隔着容器边界 遥控终端。Terminus 是 Terminal-Bench 自带的参考 agent,也是理解"agent 到底在干嘛"的最佳样本。

5.1 它的一个回合在做什么

Terminus 把任务拆成一串 episode,每个 episode 是一次"想—做—看"的闭环:

初始 prompt(指令 + JSON schema + 当前屏幕)


┌──────────── 一个 episode ────────────┐
│ 1. prompt 喂给 LLM │
│ 2. LLM 回一段 JSON (CommandBatchResponse)
│ 3. 解析成 Command 列表 │
│ 4. 逐条 send_keys 敲进终端 │◀── 隔着容器边界操作 tmux
│ 5. is_task_complete? ── 是 ──▶ 退出 │
│ 6. 否:capture_pane 抓屏 → 当下一轮 prompt
└──────────────────────────────────────┘
│(回到步骤 1,最多 max_episodes 轮)

返回 AgentResult(token 累计)

主循环 _run_agent_loopterminus_1.py:190)就是这张图的直译,perform_taskterminus_1.py:214) 负责拼初始 prompt 并起循环。

5.2 用 JSON schema 卡住 LLM 的嘴

Terminus 不让 LLM 自由发挥,而是要求它每轮都回一个固定结构的 JSON。结构由 Pydantic 模型 CommandBatchResponse 定义(terminus_1.py:49):

字段作用
state_analysisLLM 对当前终端状态的判断(逼它先观察再动手)
explanation这批命令要干嘛的简述
commands一批 Command,逐条敲进终端
is_task_completeLLM 自认为做完了没——这就是循环的退出信号

每个 Commandterminus_1.py:21)又带三个字段:keystrokes(要敲的键,支持 C-c 这种 tmux 转义)、 is_blocking(要不要等这条跑完再看输出)、timeout_sec(等多久)。

这个 schema 会被 model_json_schema() 序列化成文本,塞进给 LLM 的 prompt 里(terminus_1.py:92), 让模型照着填。prompt 模板见 agents/prompt-templates/terminus.txt——它明确告诉模型"你直接操作 tmux 会话,要执行命令得自己在末尾加 \n,遇到 less/vim 这类交互程序不要阻塞等待"。

5.3 敲键的两个魔鬼细节

命令怎么真正落到终端,在 _execute_commandsterminus_1.py:156)。两个不显然的处理值得记:

is_blocking 会被二次否决。 即使 LLM 说这条要阻塞等待,只要 keystrokes 去空白后以 EOF& 结尾,就强制阻塞(terminus_1.py:170-180):

# 示意,非源码:终点是 EOF(here-doc) 或 &(后台) 的命令,永不阻塞
block = command.is_blocking and not (
command.keystrokes.strip().endswith("EOF")
or command.keystrokes.strip().endswith("&")
)

道理很实在:here-doc(cat <<EOF ... EOF)和后台任务(cmd &)本来就不会"自己结束并回吐输出", 若真去阻塞等它,会把整轮卡死到超时。这是踩过坑之后的防呆。

② 超时不是崩溃,是喂回给 LLM 的一段话。 某条命令 send_keysTimeoutError_execute_commands 不往上抛异常,而是返回一段"超时了 + 当前屏幕"的文本(用 timeout.txt 模板拼), 让下一轮 prompt 告诉 LLM"你上一条超时了,屏幕现在长这样",由模型自己决定怎么补救。

5.4 退出、重试、记录

  • 退出:任一轮 LLM 回 is_task_complete=True,循环 breakterminus_1.py:209);否则跑满 max_episodes(默认 50,terminus_1.py:81)自然结束。注意:"做完"不等于"做对",退出后由测试脚本判分。
  • 重试_handle_llm_interaction 挂了 tenacity@retry,最多 3 次 (terminus_1.py:117),但上下文超长 / 输出超长这两类异常明确不重试(重试也没用,得换策略)。
  • 记录:每轮把 LLM 的原始回复当作一条 asciinema marker 记下(_record_asciinema_marker, terminus_1.py:238),日后能把"模型当时在想什么"和"屏幕录像"对齐着看。

5.5 terminus_2 有什么不同(概览)

Terminus2terminus_2/terminus_2.py:31)是同一流派的进阶版,思路一样但几处放开/加固,只做概览:

维度Terminus (1)Terminus2
输出格式严格 JSON,Pydantic 校验可选 json / xml宽松 plain parserterminus_2.py:69
回合上限默认 50默认近乎无限(1000000),显式限制反而会告警(terminus_2.py:53-60
上下文爆了怎么办不重试,直接抛回卷旧消息 + 自我总结再续(_query_llm, terminus_2.py:320
输出爆了怎么办不重试尝试从截断输出里"抢救"出合法响应,抢救不到就要求分块重发
完成确认一次 is_task_complete 即退双重确认:先问"你确定?",再答确定才真退(terminus_2.py:572

一句话:Terminus2 把 Terminus 那些"撞墙就抛异常"的地方,换成了"撞墙就自我修复继续跑"。


6. 流派二:装进容器的现成 CLI —— AbstractInstalledAgent

第二支解决的是另一类需求:我想评测一个别人做好的命令行 agent(claudeaidercodex……), 它有自己的一套交互逻辑,没法用我们外部遥控终端那套。 办法是把这个 CLI 装进任务容器里, 让它在容器内部自己跑。

6.1 为什么这支"是下策但必要"

源码开头的 docstring 说得很直白(abstract_installed_agent.py:1-11):这种方式给容器塞了额外依赖, 可能因为容器环境问题(卷约束、网络坏)而失败,而非 agent 本身不行,所以是 last resort。 但要评测现成 CLI,又绕不开它。

6.2 一次 installed-agent 任务的四步

perform_taskabstract_installed_agent.py:108)是模板方法,固定四步:

1. 拷脚本进容器 copy_to_container(install-agent.sh → /installed-agent/)

2. 写环境变量文件 把 _env(如 ANTHROPIC_API_KEY)写成 setup-env.sh
│ ——刻意在 session 外用 exec_run 写,避免 key 出现在终端录像里
│ source setup-env.sh

3. 装 agent source install-agent.sh || echo 'INSTALL_FAIL_STATUS'
│ 抓屏检查有没有 INSTALL_FAIL_STATUS
│ └─ 有 → 直接返回 failure_mode=AGENT_INSTALLATION_FAILED

4. 跑 agent _run_agent_commands(instruction) 逐条 send_command

三个设计点:

  • 密钥不进录像:环境变量文件用 container.exec_run 在会话之外写入 (abstract_installed_agent.py:122),注释明说"避免把 env 变量暴露在会话里"。
  • 装不上是可识别的失败:安装命令后面 || echo 'INSTALL_FAIL_STATUS',然后抓屏找这个魔法字符串 (abstract_installed_agent.py:144-160)。找到就返回装配失败,不会把"环境没装好"误判成"agent 太笨"。
  • 子类只需填三样_env(要注入的环境变量)、_install_agent_script_path(安装脚本)、 _run_agent_commands(怎么带着指令启动 agent)。骨架全在基类。

6.3 实例:ClaudeCodeAgent

ClaudeCodeAgentinstalled_agents/claude_code/claude_code_agent.py:12)就是把上面三个钩子填上:

  • _envclaude_code_agent.py:39):注入 ANTHROPIC_API_KEY(从 harness 宿主环境读), 可选把 model_name 去掉 anthropic/ 前缀后作为 ANTHROPIC_MODEL,另开两个后台任务开关。
  • 安装脚本claude-code-setup.sh.j2——装 nvm、装 node 22,再 npm install -g @anthropic-ai/claude-code@{{ version }}version 就来自契约里的 version 属性。
  • _run_agent_commandsclaude_code_agent.py:55):拼出真正的启动命令,核心是 claude -p (print/非交互模式),把指令 shlex.quote 转义后传入,并用 --allowedTools 把可用工具限死在一张 白名单里(Bash/Edit/Write/Read/Glob/Grep/...claude_code_agent.py:17):
# 示意,非源码:最终在容器里跑的那条命令
claude --verbose --output-format stream-json \
-p '<转义后的任务指令>' \
--allowedTools Bash Edit Write Read Glob Grep LS ...

对比流派一:这里 Terminal-Bench 完全不管 claude 内部怎么循环、怎么调模型——它只负责把 CLI 装好、 喂一句指令、等它跑完。agent 的"大脑"这次跑在容器里,不在 harness 进程里。


7. 流派三:oracle 与对照基线

第三支根本不是 AI,存在的意义是给分数定标尺

7.1 OracleAgent:满分应该长什么样

每个任务都自带作者写好的参考解(solution.shsolution.yaml,见 01 章)。 OracleAgentoracle_agent.py:27)做的事就是把这个标准答案跑一遍

  • 构造时遍历数据集,把每个任务的"指令 → 参考解"建成一张表(_init_solution_dict, oracle_agent.py:50)。
  • perform_taskoracle_agent.py:73)按指令查表,然后分两种执行(oracle_agent.py:81):
解的类型怎么跑
.shsolution.sh 拷进容器,bash /oracle/solution.sh
.yaml逐条把 TerminalCommand send_command 进终端

它的用途: oracle 跑出来应该是满分基线。如果连 oracle 都过不了某个任务,说明这个任务的 测试脚本或参考解本身有 bug——oracle 是任务质量的体检工具,不是参赛选手。

7.2 naive 与 nop:另外两根标尺

  • NaiveAgentnaive_agent.py:23):只调一次 LLM,让它一口气吐出所有命令,然后挨条敲进去, 不看任何反馈、没有循环。它是"最笨的 LLM 用法"下限——用来衬托 Terminus 那套"看反馈再动手"的循环 到底值多少分。解析不出 LLM 回复时它诚实上报 FATAL_LLM_PARSE_ERRORnaive_agent.py:90)。
  • NopAgentnull_agent.py:7):什么都不做,直接返回 NONE。这是绝对零分线——如果一个任务 连"啥也不干"都能过,那这任务的测试脚本形同虚设。

三根标尺合起来:nop(下限)→ naive(笨 LLM)→ 真实 agent → oracle(上限),任何真实 agent 的分数 都应落在这段区间里,落在区间外就是信号:要么任务坏了,要么测试太松。

一句带过 MCP 流派: 还有个 MCPTerminusmcp_agents/mcp_terminus.py:32),走 Model Context Protocol:不再解析文本命令,而是把终端能力暴露成 MCP 工具、用 LLM 的原生 tool-calling 调用。 本章不展开,属于第一支的变体。


8. LLM 适配层:ChatLiteLLM

流派一/三里的 LLM 调用,都走同一层薄封装。这节讲这层在契约之下多做了什么。

8.1 Chat:管对话历史与 token 累计

Chatllms/chat.py:6)包住一个 BaseLLM,做两件事:攒消息历史累加 token 计数。 关键细节在计数方式——它用的是累计计数器,而不是每次从当前消息重算(chat.py:15):

# 示意,非源码:为什么要单独攒累计值
self._cumulative_input_tokens += input_tokens # 只增不减
# ——因为像 terminus_2 会中途删掉旧消息来腾上下文,
# 若从"当前消息"重算,被删掉那部分的 token 账就丢了

这样即使后面把旧消息裁掉(terminus_2 的回卷机制),花掉的 token 账依然算数,AgentResult 里的 token 统计才诚实。

8.2 BaseLLM 的三个异常

BaseLLMllms/base_llm.py:24)只规定 callcount_tokens 两个抽象方法,但它同文件定义了三个 贯穿整套 agent 的异常类型,是错误处理的通用语言:

异常什么时候抛谁在乎
ContextLengthExceededError上下文窗口塞满了Terminus 不重试、Terminus2 触发总结回卷
OutputLengthExceededError回复被 max_tokens 截断(还带回截断内容)Terminus2 尝试抢救截断响应
ParseError回复解析不成目标结构Terminus 把它计入重试

8.3 LiteLLM:统一几十种模型的差异

LiteLLMllms/lite_llm.py:35)是 BaseLLM 的实测实现,底层用 litellm 库对接各家模型。它替上层 抹平了几处厂商差异:

  • 探测能力再降级:构造时用 get_supported_openai_params 查这个模型支不支持结构化输出 (response_format)和 temperaturelite_llm.py:50-55)。若不支持 response_format,就把 JSON schema 塞进 prompt 文字里、让模型"照着写",而不是用 API 的结构化字段(lite_llm.py:132-138)。
  • Anthropic 提示缓存:对 Anthropic/Claude 系模型,给最近 3 条消息打 cache_control: ephemeral 标记(add_anthropic_caching, lite_llm.py:149utils/anthropic_caching.py:7),省重复前缀的钱。
  • 把厂商异常翻成自家异常:捕获 litellm 的上下文超限异常,重抛成上面那个统一的 ContextLengthExceededErrorlite_llm.py:164);发现 finish_reason == "length" 就抛 OutputLengthExceededError附上被截断的内容供上层抢救(lite_llm.py:175)。
  • 退避重试call 挂了指数退避 @retry(最多 3 次,lite_llm.py:113),但上下文超限 / 输出超限 / 鉴权错这三类不重试(重试无意义或会泄露密钥问题)。
  • 暂不支持流式:拿到 CustomStreamWrapper 直接 NotImplementedErrorlite_llm.py:170)。

一句话总结这层:上面的 agent 循环只跟"发一句、回一句、超了会抛标准异常"打交道,各家模型的脾气差异 全被 LiteLLM 关在门内。


9. 边界与局限(诚实)

  • "任务完成"由 agent 自称,"任务正确"由测试脚本判is_task_complete / failure_mode=NONE 都 只代表 agent 认为自己做完了,不代表做对了。真正的判分在 agent 交回之后(05 章)。
  • installed-agent 支是脆的:它把成败和容器环境(网络、卷、apt/npm 能否装)绑在一起,源码自己都 标注"last resort"(abstract_installed_agent.py:1-11)。
  • 流式未支持LiteLLM 明确对流式响应抛 NotImplementedErrorlite_llm.py:170)。
  • oracle 依赖参考解的质量:oracle 只是"跑作者写的答案",答案本身错了它也跟着错——它体检的是 任务,不是 agent。

10. 代码地图(导航索引)

主题文件路径关键符号
契约:核心方法与结果terminal_bench/agents/base_agent.pyBaseAgent / perform_task / AgentResult / _render_instruction
失败模式枚举terminal_bench/agents/failure_mode.pyFailureMode
内置 agent 名字枚举terminal_bench/agents/agent_name.pyAgentName
注册表 + 动态加载terminal_bench/agents/agent_factory.pyAgentFactory / AGENT_NAME_TO_CLASS / get_agent_from_import_path / get_agent
流派①:LLM 循环terminal_bench/agents/terminus_1.pyTerminus / CommandBatchResponse / Command / _run_agent_loop / _execute_commands
流派①的 promptterminal_bench/agents/prompt-templates/terminus.txt(模板文本)
流派①进阶版terminal_bench/agents/terminus_2/terminus_2.pyTerminus2 / _query_llm / _summarize
流派②:容器内 CLI 骨架terminal_bench/agents/installed_agents/abstract_installed_agent.pyAbstractInstalledAgent / perform_task / _run_agent_commands
流派②实例:Claude Codeterminal_bench/agents/installed_agents/claude_code/claude_code_agent.pyClaudeCodeAgent / _env / ALLOWED_TOOLS
流派③:满分基线terminal_bench/agents/oracle_agent.pyOracleAgent / _init_solution_dict
对照基线terminal_bench/agents/naive_agent.py, null_agent.pyNaiveAgent / NopAgent
MCP 变体(未展开)terminal_bench/agents/mcp_agents/mcp_terminus.pyMCPTerminus
LLM 适配:对话与计数terminal_bench/llms/chat.pyChat / total_input_tokens
LLM 适配:抽象与异常terminal_bench/llms/base_llm.pyBaseLLM / ContextLengthExceededError / OutputLengthExceededError / ParseError
LLM 适配:LiteLLM 实现terminal_bench/llms/lite_llm.pyLiteLLM / call / count_tokens
Anthropic 提示缓存terminal_bench/utils/anthropic_caching.pyadd_anthropic_caching