如何理解 Harness

之前我一直没搞懂 Harness 到底是什么。


文章翻了不少,都在讲 Harness 很重要。

说它关系到权限、可靠性、验证、可控性、安全性、约束。

看完我反而更糊涂了。

每个词都听着对,但没有一篇讲清楚它具体是个什么东西。


后来认真研究了一下。

发现 Harness 的实现思路,就是让 LLM 做选择题,而不是问答题。

一、Harness 是怎么来的

在说 Harness 之前,得先说说它出现之前经历了什么。


这几个概念不是谁发明的理论,而是工程问题一步步暴露出来的结果。

Prompt

最早的问题是 Prompt。

模型刚出来的时候,大家发现同一个问题换个问法,结果就差很多。

提示词怎么写,很大程度上决定了模型输出的质量。

于是有了 Prompt Engineer 这个角色,研究怎么提问能让模型答得更好。


但 Prompt 能解决的问题是有限的。

它只能影响模型在给定输入分布上「更可能说对话、写对代码」。

它解决不了模型缺乏边界感的问题,也解决不了模型接入真实系统后的风险。

Context

接着发现光会提问不够。

模型是无状态的,每次调用都是接收一段输入 token,输出一段 token。

本身不维护持久系统状态。


它不知道仓库里有什么分支、生产环境有哪些约束、什么算上线、什么算事故。

这些东西只能通过文本告诉它。

于是有了 Context Engineer,研究如何为这一轮调用,准备正确、充分的输入信息。


Context 解决的是信息完整性问题。

让模型在这一轮看到更多关键信息,从而减少胡编乱造。

但它仍然解决不了执行层面的问题。

Harness

再往后,模型开始接入真实系统——读写文件、执行命令、操作数据库。

这时候问题变了。


不再是答对答错的问题了。

之前讲 Prompt 和 Context,不管怎么讲,都是想让模型「答得好一点」。

但模型一旦真的动起手来,问题就不是答得好不好的问题了。


关键是,模型有幻觉。

你写在提示词里的规则,它不一定照着执行。

它可能自己脑补,胡乱执行一通。


比如:

  • 你让它改某个文件,它顺手把不相干的几个文件也改了

  • 你让它修个 bug,它跑偏去重写了整个模块

  • 你让它跑个命令,它生成了错误的代码去执行

  • 它可能误删文件,可能胡乱往外发请求,可能改了不该改的配置


提示词里写得再清楚都没用。

因为它不是在「遵守规则」,它只是在「生成像是在遵守规则的输出」。

一旦输出落到真实系统里,就由不得你了。


所以 Harness 要解决的问题是:

模型接入真实系统后,怎么让它尽量不要乱执行。

怎么让它只做你预期内的事,不要顺手搞出别的来。


这就是 Harness 要解决的问题。

Loop

光有 Harness 还不够。

因为前面这些都还是单次执行的场景——模型动一步,本质上是单轮的事。

但 Agent 真正的用法,是要长时间、一轮一轮自己跑下去的。


这里说的 Loop,不是简单的「人需要审批每一步」。

而是人们在实践里发现,「迭代开发」这种模式可以抽象出来。

一个目标,拆成一轮一轮去做,每一轮做完看结果、再决定下一轮怎么走。

这种模式提炼出来,就是 Loop。


平台需要把这种模式抽象成一种能力,提供给开发出来的 agent。

agent 可选地启用这种特性。


但它也有适用性。

如果你本身的工作流就不是这种迭代的、一轮一轮推进的,那强行套一个 Loop 上去,意义也不大。


这四个概念是递进的:

概念 解决的问题
Prompt 单次回答的质量
Context 信息的完整性
Harness 单次执行的安全性
Loop 长时间迭代的可靠性

每一层都建立在上一层的基础上。

二、为什么我之前总是理解不了

因为这些术语是给平台开发者说的。


什么是平台开发者?

开发「能开发 Agent 的工具」的人。


  • Anthropic 公司的工程师,写的是 Claude Code

  • Cursor 公司的工程师,写的是 Cursor

  • OpenAI 公司的工程师,写的是 Codex


对他们来说,这些概念对应着具体需要实现的工程问题:

术语 对平台开发者意味着什么
Prompt 如何设计系统提示词,让模型理解自己的工作方式和边界
Context 如何管理上下文窗口,如何拼装和裁剪信息,如何处理记忆
Harness 如何搭那套壳——模型怎么接、工具怎么调、权限怎么控、验证怎么跑
Loop 如何设计任务调度、重试策略、异常处理、人工接管机制


这些都是平台层需要解决的实际问题。

因为它们是用平台开发出来的每一个 agent,都会遇到的共性问题。


但大多数人并不是平台开发者。


如果在整个 Agent 工程里分个层,会更清楚:

是谁 在干什么
平台层 平台开发者 搭好那套壳,让所有 Agent 在同一个壳里跑;模型怎么接、工具怎么调、权限怎么控、验证怎么跑,都是他们定的
Agent 开发者层 做扩展的人 在别人搭好的壳里,写 Skills、配规则、写 MCP 工具
Agent 使用者层 用现成产品的人 通过自然语言或简单界面让 Agent 干活,自己审查结果、做决策


大多数人都在中间这层,或者最上面那层。

不是造平台的人,自然理解不了 Harness 在解决自己什么痛点。

三、那 Harness 到底解决了什么问题

回到 Harness 本身。


它要解决的问题是:模型接入真实系统后,怎么让它不要乱执行。


前面说了,模型有幻觉,不照提示词执行,可能胡乱执行。

那 Harness 是怎么解决这个问题的呢?


办法其实很简单——不让模型自由生成命令,只给它几个写好的工具去调。

模型只负责「调哪个工具、传什么参数」。

工具被调用之后干什么,是提前写好的代码决定的,不由模型说了算。


这就是「做选择题,而不是问答题」。

答案空间 谁说了算
问答题 无限的,模型想写什么写什么 模型
选择题 有限的,每个选项干什么是确定的 代码


举个例子。

模型要改文件,不是让它直接 rm、直接 echo、直接重写整个仓库。

是给它一个 write_file(path, content) 的工具。

调用之前,先检查这个 path 在不在允许目录里。

不在,直接拒绝。

在,才执行。


模型要跑测试,不是让它自己拼 shell 命令。

是给它一个 run_tests(pattern) 的工具。

背后是写好的测试流程,跑完之后结果会被结构化地返回给模型。


模型要操作 git,不是让它自己敲 git push。

是给它 commit_changes(message) 这样的工具,背后自动在 feature 分支上干活,禁止推主分支。


这样一来,模型能做的事,就被限定在这几个工具里了。

它再有幻觉,最多也就是选错了工具、传错了参数。

但工具背后干什么,是代码定的,它改不了。

它绕不过去。


模型可能选错,但选错的结果不是直接操作系统。

而是调用了某个工具——工具的执行结果可以被验证、被回滚、被记录。

这就是「可控」的真正含义。

四、内置工具

前面一直在说「给模型几个工具去调」,那这些工具是哪来的,先交代清楚。


ReadWriteEditBashGlobGrepWebFetch 这些,都是平台自带的内置工具。

不是每个项目自己造的,而是平台层统一提供的。

所有在这个平台上跑的 agent,默认都能用这一套。


平台为什么要内置这些?

因为读写文件、跑命令、搜代码,是几乎所有 agent 都要做的事。

每个项目都自己写一遍,没必要,也写不齐。

平台把这些公共能力收进来,顺手在每一个工具外面,包上权限检查、参数校验、日志记录。

这样 agent 一上来,能干的事就是受限的、可观测的。


至于这些工具的 schema 怎么传给模型、模型怎么决定调哪个、平台怎么执行、结果怎么回到模型,是一条完整的调用链路,放在文末附录展开。

五、出错了怎么办

光拦住还不够,模型还是会选错,工具调下去还是会出问题。


所以 Harness 还得兜住出错的情况。

关键操作之后,强制验证——改完代码跑测试,测试没过,就把错误信息结构化地丢回给模型,让它继续改。

「完成」也不是模型说做完了就算数,是平台检查该跑的验证跑没跑、过没过。

失败的时候,区分是可重试的错,还是必须人工介入的硬失败,分别处理。

整个过程记进日志,错了能查到、能回滚。


这其实就是把「交付要测试、要 review、错了要回滚」这件很朴素的事,从人盯,变成了代码自动盯。

六、一条完整的链路

举个具体的例子。

一个 TypeScript monorepo 项目,Agent 接到一个任务:修复 src/foo.ts 里的 bug,并补上测试。


链路大致是这样:

1
2
3
4
5
6
7
1. 读取文件      Agent 通过工具读相关文件和已有测试
2. 生成方案 Agent 生成 patch,平台应用到临时工作区(不动主代码)
3. 强制测试 平台跑对应测试
- 过了:进入候选完成
- 没过:错误结构化反馈给 Agent,再改一轮
4. 二次校验 项目 Linter 和安全扫描跑一遍,过了才标记可提交
5. 人工 review 合并到主分支,整个链路写进日志


整个过程里,模型始终在壳内提建议。

什么时候读文件、怎么改代码、怎么解释失败原因。

而所有「能不能真的动仓库」「什么时候算完成」「失败怎么处理」,都是平台说了算。

七、那为什么文章都爱讲这些

讲到这里,应该能看出来 Harness 做的事其实不复杂。


它做的事情其实很朴素:

  • 把模型能干的事,收窄成几个受控的工具

  • 在工具周围包上检查、验证、回滚

  • 出错的时候自动收场,而不是任由它越跑越偏


那为什么网上那些文章,都把它说得那么复杂、那么玄呢?


因为写文章的是平台方,站的是自己的视角。

把自己的内部架构说得越复杂,平台就显得越有价值。

读者焦虑了,觉得搞不定,最后买他们的服务。


这不是阴谋,只是立场不同。

每个做平台的人,都希望自己的平台看起来更强大、更专业。


但作为开发者,得有自己的判断。

知道什么是自己该关心的,什么不是。


有个简单的自查方法:

你主要在写 你处在哪一层
SDK、runtime、权限、验证、状态、日志 平台层工程(Prompt、Context、Harness、Loop 真正的落地点)
系统提示、规则文件、Skills、MCP、CLI Agent 层产品与扩展(重要,但不同于平台层的壳)
发任务、审查结果、做决策 使用者层(关注怎么用、什么时候该介入,而不是怎么造)

八、结语

本文主要想说的是,Harness 这个概念没有那么复杂。


它就是让 LLM 做选择题,而不是问答题。

把模型能干的事收窄成几个受控的工具,在工具周围包上检查、验证、回滚。


这些术语是平台开发者在解决自己的工程问题时用的。

对于 Agent 开发者来说,用好平台提供的功能就够了。

对于 Agent 用户来说,那就更不用管了。

附录:tool call 的完整调用过程

前面说 Harness 让 LLM 做选择题、平台负责执行,但没展开具体是怎么调的。

这一节把整条链路走一遍,工具 schema 怎么传、模型怎么决定调哪个、平台怎么执行、结果怎么回到模型。

以 Anthropic 的 Messages 协议为例。

1. 工具是谁写的,怎么注入的

工具分两类。

一类是平台自带的内置工具,像 ReadWriteEditBashGlobGrepWebFetch,平台工程师写死的,所有 agent 一上来都能用。

一类是项目自己加的,通过 MCP、Skills、CLI 注入进来,agent 开发者写的,只在特定项目里生效。


每个工具都是一个独立模块,自己定义四样东西:

  • name:叫什么名字
  • description:是干什么的
  • input_schema:要填哪些参数、什么类型、哪些必填
  • call:被调用时真正干活的逻辑


平台在每轮请求前,把当前生效的工具收拢成一张列表,塞进请求里的 tools

2. 传:把工具 schema 塞进请求

得先说一个前提:LLM 是无状态的。

它不记得上一轮自己说了什么,每次调用都是一次全新的请求。

所以平台每一轮都要把之前的对话历史,原样塞进请求里,模型才能接上之前的上下文。


平台发给模型的请求,大致是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"model": "claude-...",
"system": "你是编码助手,工作目录是 /repo,只能改 src/...", // 身份和约束
"messages": [
// 下面整段对话历史,每轮都要原样重传,因为模型是无状态的
{ "role": "user", "content": "修复 src/foo.ts 里的 bug" },
{ "role": "assistant", "content": [{ "type": "tool_use", "id": "toolu_01", "name": "Read", "input": { "file_path": "src/foo.ts" } }] },
{ "role": "user", "content": [{ "type": "tool_result", "tool_use_id": "toolu_01", "content": "export function foo() {...}" }] }
// ... 之前每一轮的 user/assistant/tool_result 都在这
],
"tools": [ // 这轮可用的工具列表(菜单)
{ "name": "Read", "description": "读取文件", "input_schema": { /* file_path: string */ } },
{ "name": "Write", "description": "覆盖文件", "input_schema": { /* file_path, content */ } },
{ "name": "Bash", "description": "执行命令", "input_schema": { /* command: string */ } }
]
}


模型做选择就读三块:

  • system:身份和约束,大边界
  • messages:对话历史,用户要它干嘛、之前调过哪些工具、每次结果是什么,它据此判断现在该干什么
  • tools:这轮可用的菜单,每个工具的 description 说这道菜是干什么的,input_schema 说要填哪些参数


tools 这张菜单是收窄动作空间的第一道关。

里面没有 rm 这种裸命令。

模型只能点菜单上有的菜,点不到 rm

3. 选:模型决定调哪个

模型把这三块读进去,预测出「下一步该调哪个工具、参数填什么」,回一个 assistant 消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"role": "assistant",
"content": [
{ "type": "text", "text": "我先读一下这个文件。" }, // 可以先说句话
{ // 再点菜
"type": "tool_use",
"id": "toolu_02ABC", // 这次调用的唯一标识,回传结果靠它对号
"name": "Write", // 点的哪道菜
"input": { "file_path": "src/foo.ts", "content": "..." } // 照 schema 填的参数
}
],
"stop_reason": "tool_use" // 停下来,等平台执行完把结果塞回来
}


content 是个数组,可以同时装 texttool_use 好几种 block。


注意,到这里为止,什么都没被执行。

模型只是说了一句「我想这么干」。

它不是在执行,是在生成一段结构化的选择。

4. 调:平台执行

平台拿到这个 tool_use,先不急着执行,过几道检查:参数校验、权限检查,过了才真正调用工具的 call

对照源码里的 Tool 定义,每个工具自己管这几件事:

  • validateInput:参数对不对,缺没缺
  • checkPermissions:这个操作允不允许
  • call:真正干活的逻辑
  • 还有 isReadOnlyisDestructiveisConcurrencySafe 这些标记,决定能不能并发、是不是危险


检查不过,直接拒绝。

过了,才真正执行。

5. 回:结果回到模型

注意,平台不是只回传一条 tool_result

它回传的,还是上面那个完整结构——modelsystemmessagestools 原样都在,只是把新结果追加到 messages 末尾,然后作为下一轮请求整体发给模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"model": "claude-...",
"system": "你是编码助手,工作目录是 /repo,只能改 src/...", // 身份和约束
"messages": [
// 下面整段对话历史,每轮都要原样重传,因为模型是无状态的
{ "role": "user", "content": "修复 src/foo.ts 里的 bug" },
{ "role": "assistant", "content": [{ "type": "tool_use", "id": "toolu_01", "name": "Read", "input": { "file_path": "src/foo.ts" } }] },
{ "role": "user", "content": [{ "type": "tool_result", "tool_use_id": "toolu_01", "content": "export function foo() {...}" }] },
{ "role": "assistant", "content": [{ "type": "tool_use", "id": "toolu_02ABC", "name": "Write", "input": { "file_path": "prod.config.ts", "content": "..." } }] },
{ "role": "user", "content": [
{
"type": "tool_result",
"tool_use_id": "toolu_02ABC", // 对上前面的 tool_use 的 id
"is_error": true, // 成功 false,失败 true
"content": "Error: path not writable" // 结果正文(输出或错误信息)
}
] }
// ... 之前每一轮的 user/assistant/tool_result 都在这
],
"tools": [ // 这轮可用的工具列表(菜单)
{ "name": "Read", "description": "读取文件", "input_schema": { /* file_path: string */ } },
{ "name": "Write", "description": "覆盖文件", "input_schema": { /* file_path, content */ } },
{ "name": "Bash", "description": "执行命令", "input_schema": { /* command: string */ } }
]
}


新结果就是最后那条 tool_resulttool_use_id 对上前面的 tool_useid,模型就知道这次结果是给哪次调用的。

is_error 区分成败,content 装结果正文。


模型在下一轮看到这个结果,再决定接下来点哪个菜。

6. 一轮接一轮

所以整个流程就是这样一个循环:

1
传(tools + messages) → 选(tool_use) → 调(校验并执行) → 回(tool_result) → 追加到 messages → 下一轮 ...

模型从头到尾干的事情只有一件:不停地输出「下一步调哪个工具、带什么参数」。

至于这个调用要不要执行、执行成什么样,都是平台控制的。


模型的角色,始终是个「出主意的」,真正动手的是平台。