Harness - 03 怎么做 Harness Engineering

文章目录

怎么做 Harness Engineering:五个介入时机、四个判定问题和一份最小可行清单

前两篇分别讲了这个词是什么、以及为什么需要它。这一篇只处理一个问题:具体怎么写。

先说一个观察。大多数人开始做 harness 的路径是这样的:agent 干了件蠢事,你打开项目的规则文件,加一行「不要那样做」。三个月后这个文件有两百行,没人知道哪些还生效,模型也开始出现「读了但没照做」的情况------因为两百行里真正重要的那三条,被淹没在一百九十七条琐碎约定里了。

问题不在于你写的内容不对,而在于你只有一个地方可以写。Claude Code 这类运行时实际上提供了至少五个不同性质的介入点,它们在时机、强制力和反馈速度上完全不同。把所有约束都堆在同一个地方,等于用一把螺丝刀干完全部的活。

所以这篇的顺序是:先讲清楚有哪些位置可以写,再讲怎么判断某件事该写在哪个位置,最后逐层给出可以直接抄走的配置,以及那些抄了会踩的坑。

一、五个介入时机

按照介入时刻从早到晚排列:

介质 介入时刻 强制力 反馈延迟 适合承载
CLAUDE.md / AGENTS.md 生成之前 软(提高概率) 无(进入生成) 项目背景、约定、偏好、决策理由
.claude/skills/*/SKILL.md 被调用时 软(但按需加载) 多步流程、检查清单、参考资料
settings.jsonpermissions 工具调用之前 硬(直接拒绝) 即时 危险命令、敏感文件、不可逆操作
Hooks 工具调用前后 硬(可编程) 秒级 自动格式化、条件阻断、注入上下文
Linter / 类型检查 / 测试 本地提交之前 硬(退出码) 秒到分钟 可形式化的代码规则、契约
CI 推送之后 硬(阻止合并) 分钟级 全量验证、跨环境验证、最后防线

这张表有两个地方值得多看一眼。

一是强制力和反馈延迟不是同一个维度。CI 的强制力最强(合不进去就是合不进去),但它的反馈最慢,而慢反馈会让 agent 在错误的基础上继续写十个文件。本地钩子的强制力和 CI 一样是硬的,反馈却快两个数量级。所以「放 CI 里更安全」这个直觉经常是错的------同一个检查,放前面既更安全也更省钱,放 CI 只是因为你需要一道无法被绕过的关口。

二是前两行的「软」不是缺点。软约束的代价是上下文,收益是灵活性:模型可以理解规则的意图并在边缘情况下做合理变通。硬约束反过来,它不会被误解,也不会被通融。把「优先用 pnpm」写成硬约束是过度工程,把「不许 force push 到 main」写成软约束是失职。

二、四个判定问题,顺序不能换

面对一条新的约束,我按下面四个问题依次判断它该落在哪一层。顺序是关键------前一个问题的答案会限制后面的选项。

问题一:这件事能不能被机器判定?

如果一条规则可以写成一段返回真假的程序,它就应该是硬约束。「函数不超过五十行」可以判定,「函数应该职责单一」不能。「不要 import @prisma/client」可以判定,「注意性能」不能。

不能判定的部分不是不重要,而是只能放进软约束层,靠模型理解。这里最常见的错误是把不可判定的东西硬塞进 linter,写出一堆基于正则和 AST 形状猜测意图的规则,最后误报把人和 agent 都逼得开始加豁免注释。规则的可信度一旦破产,它就变成噪音。

问题二:违反一次的代价有多大?

这个问题决定了强制力的必要等级,也是唯一值得为之付出灵活性的理由。把代价分三档:

  • 不可逆(删除未提交的工作、覆盖远端分支、动生产数据、泄漏密钥)→ 必须是权限层的硬拒绝。这类事情没有「概率降低」的说法,只有「可能」和「不可能」。
  • 可逆但昂贵(跑错迁移、装错依赖、提交了没跑测试的代码)→ 钩子或本地检查阻断。
  • 可逆且便宜(命名不一致、少写了一个注释)→ 软约束,或者干脆不管。

第三档需要一点纪律。为便宜的事情写硬约束,是 harness 膨胀的主要来源。

问题三:反馈需要多快?

模型在错误路径上走得越远,纠正的成本越高,而且不是线性增长------它可能已经基于错误的接口写了三个调用方。所以判断标准是:**这个错误如果在十分钟后才被发现,需要回滚多少工作?**如果答案是「一大堆」,检查必须尽可能靠前,哪怕这意味着要写一个钩子而不是复用现成的 CI 步骤。

问题四:多久发生一次?

一年一次的事情写进文档就够了。一天三次的事情值得自动化。这个问题排在最后,因为它只用来决定「值不值得花时间」,不用来决定「放在哪一层」。一件极其危险但一年只可能发生一次的事,仍然应该是硬拒绝,只是你可以用一行配置解决它而不是写一套工具。

三、权限层:把不该发生的事变成技术上不可能

这是唯一一层我认为任何项目都应该在第一天就配好的。它的配置量很小,收益是唯一无法用别的层替代的那种:确定性。

一份可以直接作为起点的 .claude/settings.json

json 复制代码
{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "permissions": {
    "allow": [
      "Bash(npm run lint)",
      "Bash(npm run test *)",
      "Bash(git status)",
      "Bash(git diff *)"
    ],
    "deny": [
      "Bash(curl *)",
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./secrets/**)",
      "Edit(./.env)",
      "Edit(./.env.*)"
    ]
  }
}

规则的语法是 工具名(匹配式)Bash 的匹配式作用于命令字符串,Read / Edit 的匹配式作用于路径,支持 gitignore 风格的通配。Bash(npm run lint) 是精确匹配,Bash(npm run test *) 允许后面跟任意参数。不带括号的裸工具名(比如 WebFetch)表示这个工具的全部调用。

优先级和合并规则

配置可以出现在多个位置,从高到低是:企业托管配置 > 命令行参数 > .claude/settings.local.json(个人的、不进版本库)> .claude/settings.json(项目的、进版本库)> ~/.claude/settings.json(用户全局)。

这里有一个必须记住的例外:权限规则不是覆盖,而是合并。 普通配置项(比如模型选择)由高优先级的位置胜出,但权限规则是把所有位置的规则并到一起生效。这意味着两件事------你在项目里加的一条 deny,不会被某个同事的本地配置解除;而你自己 settings.local.json 里图方便加的一条宽松 allow,也不会因为项目配置更「权威」就被忽略。规则集只会变长,不会互相抵消。

另一个容易浪费半小时的细节:用户、项目、本地这三个文件是严格解析的,任何一处字段名写错、类型不对、多一个逗号,整个文件会被整体拒绝,而不是跳过那一行。表现出来就是「我明明配了权限但完全没生效」。加上第一行的 $schema 让编辑器提前报错,是几秒钟换半小时的交易。

权限层做不到什么

这一层最危险的用法是把它当沙箱。它不是。它匹配的是命令的文本形式,不是命令的语义。一条 Bash(rm *) 的拒绝规则挡不住 find . -name '*.ts' -delete,挡不住 bash -c "rm -rf build",也未必挡得住一条用 && 串起来、危险部分在后半段的复合命令。攻击面在于自然语言到 shell 的映射是多对一的,而黑名单只能枚举其中一种写法。

所以权限层的正确定位是:防止意外,不防止规避。 它挡住的是模型在没想清楚的时候顺手执行的危险操作,这占了真实事故的绝大多数。如果你的威胁模型里包含「模型可能主动绕过限制」,那需要的是容器或者一次性沙箱环境,是隔离而不是规则。这两者不冲突,但不能互相替代。

顺带一个和权限无关但同属这个文件的实用项:如果你不想让 agent 在提交信息里加署名,可以显式设定

json 复制代码
{
  "attribution": { "commit": "", "pr": "" }
}

留空即不添加,也可以填成你自己团队的标记文本。这类配置的意义不在于功能,而在于它把「团队的提交规范」从口头传统变成了仓库里的一行声明。

四、钩子层:在动作前后插入你自己的代码

权限层只能表达「允许」和「拒绝」。当判断需要条件------「只有当这个文件在 src/ 下面时才检查」「改完之后顺手格式化」「提交前确认测试跑过了」------就需要钩子。

先把结构写对

这是错误率最高的一处配置,因为它有三层嵌套,而凭直觉写出来的往往是两层:

json 复制代码
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh" }
        ]
      }
    ]
  }
}

外层的键是事件名,它的值是一个数组,数组里每个元素是一个「匹配器分组」,分组里的 hooks 又是一个数组,每个元素才是具体命令。把 matchercommand 写成同一层的兄弟字段是最常见的错法------它不会报错,只会静默地什么都不做,然后你花二十分钟怀疑事件名写错了。

命令通过标准输入拿到一个 JSON,里面包含会话信息、工具名和工具参数。用 $CLAUDE_PROJECT_DIR 引用脚本路径,这样钩子在子目录里启动的会话中也能找到自己。

matcher 是正则,而正则会多匹配

matcher 按正则匹配工具名,于是有一个几乎所有人都会踩一次的坑:Edit.* 同时匹配 EditNotebookEdit。如果你只想匹配 Edit,写成 ^Edit$。想匹配两个具体工具就用 ^(Edit|Write)$,不要图省事写 Edit|Write------它在这个例子里恰好也匹配 NotebookEdit,因为正则是子串匹配。

阻断靠退出码,而且只有 2 有效

这是第二个反直觉的地方:退出码 2 阻断操作,退出码 1 不阻断。

exit 1 会被当作「钩子自己出错了」,操作照常继续,你只会在某个地方看到一条错误。想真正拦下来,必须 exit 2,此时 stderr 的内容会被送给模型。写一个「非零就阻断」的脚本是无效的 harness------它看起来在工作,实际上一次也没挡住过。

一个有意义的 PreToolUse 例子,禁止改动生成的文件:

bash 复制代码
#!/usr/bin/env bash
# .claude/hooks/guard-generated.sh
path=$(jq -r '.tool_input.file_path // empty')
case "$path" in
  */generated/*|*.gen.ts|*/prisma/client/*)
    echo "$path 是生成产物,不要直接改。" >&2
    echo "改上游的模板或 schema,然后跑 npm run codegen 重新生成。" >&2
    exit 2
    ;;
esac
exit 0

也可以不用退出码,改成 exit 0 并在标准输出返回一段 JSON:

json 复制代码
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "生成产物不可直接编辑,请修改上游 schema。"
  }
}

两种方式的效果相同,JSON 形式的好处是可以把理由结构化,也能表达 allow 从而跳过后续确认。需要注意的是这两条路不能混着用来「加强」彼此:exit 2 一旦发生就是终局,JSON 无法把它改回允许。

时机决定能力

PreToolUse 能阻断,PostToolUse 不能------操作已经发生了,阻断没有意义。但 PostToolUse 有一个常被浪费的能力:它写到 stderr 的内容会被送到模型面前。所以「改完文件立刻跑一次类型检查,有错就把错误原文喂回去」是一个非常划算的钩子,它把「模型改完 A 忘了改 B」这类问题的发现时间从「下次跑 CI」压到「下一步」。

bash 复制代码
#!/usr/bin/env bash
# .claude/hooks/typecheck.sh
out=$(npx tsc --noEmit 2>&1) || {
  echo "类型检查未通过,先修掉这些再继续:" >&2
  echo "$out" | head -30 >&2
}
exit 0

注意最后是 exit 0。这里不需要阻断------模型看到错误自己就会去修,而 exit 2PostToolUse 上的语义是打断当前流程,反而更麻烦。

五、本地检查层:把「约定」变成 error

前两层管的是「不许做什么」,这一层管的是「做出来的东西对不对」。它的特点是你项目里大概已经有了------linter、类型检查、测试------只是通常没有被当成 harness 来设计。

被当成 harness 来设计意味着两件事:规则要针对你实际踩过的坑,而不是抄一份社区推荐配置;报错要写给读它的人看,而那个人现在是 agent。

一个具体例子,和一个错误示范

假设约定是这样的:Prisma 客户端只能在 src/server/ 下面引用,其他地方必须走仓储层封装。这是一条典型的、模型不会自己知道的项目约定,而且违反了以后很难在 review 里被发现------代码能跑,只是把数据库耦合渗进了不该渗的地方。

我见过用 no-restricted-syntax 加 AST 选择器实现它的写法,形如 ImportDeclaration[source.value='@prisma/client'] ~ *。这条选择器是错的,而且错得很有教育意义:~ 是后继兄弟选择器,它匹配的是那条 import 之后的每一个兄弟节点。于是只要文件里出现了这个 import,文件里之后的所有语句都会各自报一次错。一个二十行的文件报十九个错,报错位置全都指向无关代码。人看到这种输出会去查配置,模型看到这种输出会开始瞎改。

正确的做法是用专门的规则,它本来就是为这件事设计的:

js 复制代码
// eslint.config.js
import tseslint from "typescript-eslint";

const restrictPrisma = {
  "no-restricted-imports": ["error", {
    paths: [
      {
        name: "@prisma/client",
        allowImportNames: ["Prisma"],
        message: "只有 src/server/ 可以直连 Prisma。其他位置请用 src/repositories/ 下的仓储函数;只需要类型时从这里导入 Prisma 命名空间即可。",
      },
    ],
    patterns: [
      {
        group: ["**/prisma/generated/*"],
        message: "生成产物不是公共 API,请通过 src/repositories/ 访问。",
      },
    ],
  }],
};

export default tseslint.config(
  { files: ["src/**/*.ts", "src/**/*.tsx"], rules: restrictPrisma },
  { files: ["src/server/**/*.ts"], rules: { "no-restricted-imports": "off" } },
);

几个值得注意的点。paths 里的 name 必须写在其他键之前。allowImportNamesimportNames 是互斥的,只能选一个方向表达;上面用前者,因为「类型可以导,运行时客户端不行」比枚举所有禁止的名字更稳定。如果项目用 TypeScript,还可以加 allowTypeImports: trueimport type 整体豁免。patterns 里的 group 用 gitignore 风格,取反项必须放在最后,而且不能重新包含一个父目录已被排除的路径。

例外通过第二个配置对象、按 files 范围关掉,而不是靠一堆 eslint-disable 注释。这个区别很重要:范围豁免是结构化的、可 review 的,行内豁免是散落的,而且 agent 一旦学会写豁免注释,你的规则就等于不存在了。

提交这道关口

本地检查需要一个执行时机,否则它只是一个「可以手动跑」的命令。husky 现在的安装方式已经比几年前的教程简单很多:

bash 复制代码
npm install --save-dev husky
npx husky init

init 会创建 .husky/pre-commit 并在 package.json 里写好 prepare 脚本。钩子文件就是普通的 POSIX shell 脚本,官方现在的示例里已经不再出现那行 . "$(dirname -- "$0")/_/husky.sh" 的 preamble------如果你手上的教程还带着它,那份教程是旧的。想临时全局关掉钩子用 HUSKY=0

只检查改动过的文件而不是全仓库,是把反馈从半分钟压到两秒的关键。官方给的模式是这个形状:

shell 复制代码
# .husky/pre-commit
prettier $(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') --write --ignore-unknown
git update-index --again

实际项目里更常见的是交给 lint-staged 统一管理格式化、lint 和按需的类型检查。这两种做法没有优劣,区别只在于你是否需要按文件类型分派不同命令。

一个正在生效的变化

如果你的项目是 Next.js,这里有个必须知道的时效性问题:next lint 在 15.5 被标记废弃,在 16 里已经移除 ,同时 next build 不再自动执行 lint。

这件事的 harness 含义比它的表面更大。很多项目的「代码检查」实际上是靠构建顺带完成的,从来没有独立的 lint 步骤。升级到 16 之后,构建照样成功,检查静默消失,而且没有任何告警------你的 harness 少了一层,而唯一的症状是问题变少了。迁移方式是直接用 ESLint CLI,在 package.json 里显式写一个 "lint": "eslint .",官方也提供了对应的 codemod。

这正是前一篇讲的「假设会过期」的具体形态:你依赖的不是自己写的规则,而是某个工具「顺便帮你做了」这件事,而这种依赖在升级时最容易断,因为它从来没被写下来过。

六、报错信息就是你写给 agent 的提示词

这一节如果只能留一句,就是这句:在有 agent 的工作流里,报错信息不再是给人看的日志,它是一段会被立刻消费的提示词。

理解这一点会改变你写规则的方式。人看到一条含糊的报错会去查文档、翻代码、问同事------人有旁路信息。模型看到一条含糊的报错,手上只有这段文本和当前上下文,它会做统计上最可能的动作:把代码改成另一种同样可能违规的写法,或者加一条豁免注释绕过去。含糊的报错不是「不够友好」,它是在主动诱导错误的修复。

对比一下。这是一条典型的、能跑但没用的报错:

复制代码
error  Unexpected use of restricted import  no-restricted-imports

模型从这里能推出的信息只有「这个 import 不行」。它不知道该用什么替代,不知道为什么不行,也不知道有没有例外。最可能的下一步是把 import 换个写法再试。

这是同一条规则可以写成的样子:

复制代码
error  只有 src/server/ 可以直连 Prisma。
       当前文件在 src/app/ 下,请改用 src/repositories/user.ts 中的
       findUserById() 等仓储函数。
       如果只需要类型,用 import type { User } from '@prisma/client'。
       该约束的目的是让数据访问集中在一处,便于加缓存和审计。
       no-restricted-imports

四个要素齐了:违反了什么、正确的做法是什么(带具体的文件和函数名)、豁免条件是什么、以及为什么有这条规则。第四条经常被认为多余,其实是最有价值的------知道理由的模型能在你没预料到的场景下做出合理判断,只知道禁令的模型只会在边界上反复试探。

同样的标准适用于所有会被模型读到的输出。钩子的 stderr、测试失败的断言消息、脚本的 usage、CI 的日志摘要,全都是提示词。判断标准很简单:**把这段文本单独抄出来,一个不了解项目的人能不能照着改对?**如果不能,模型也不能。

这也带来一个反过来的用法。当你发现模型反复在同一个地方犯同一个错,除了加约束,先检查一下现有报错是怎么写的。很多时候不需要新规则,只需要把已有那条规则的 message 从一句抱怨改成一句指令。这是投入产出比最高的 harness 改动,没有之一。

七、验收契约:让「完成」不再是一句声明

到这里的四层都在管过程。还差最后一件,也是长程任务里最关键的一件:怎么确定任务真的完成了。

前一篇讲过为什么这件事不能交给实现者判断。这里给具体做法,参考的是 Anthropic 那个长程 agent 示例仓库的做法,它的三个部件可以整体搬走。

第一个部件:一份默认失败的清单。

在仓库里放一个 test-results.json,把每条验收标准列成条目,全部初始为 false

json 复制代码
{
  "frame-reorder-api": { "passes": false },
  "rectangle-fill-tool": { "passes": false },
  "onion-skin-toggle": { "passes": false }
}

规则是:只有测试真的执行过并且真的绿了,才允许把某一条改成 true。默认失败这个设计的价值在于它反转了举证责任------不是「没发现问题就算通过」,而是「通过需要被挣来」。它顺便让「进度」变成了一个可以 grep 的事实,于是外层循环的终止条件就是一行:

bash 复制代码
while grep -q '"passes": false' test-results.json; do
  claude -p "读 PROGRESS.md,按 CLAUDE.md 的约定实现下一个未完成的功能。"

  VERDICT=$(claude --agent evaluator -p "对照规格评审最近一次提交。")
  if [ "$(echo "$VERDICT" | head -1)" != "PASS" ]; then
    echo "$VERDICT" > NEXT_FINDINGS.md
  fi
done

注意这个循环的终止条件在模型之外,由 grep 的退出码决定。这就是上一篇说的「让停止条件不由被停止的对象来评估」的最小实现。

第二个部件:一个不能写代码的评审者。

评估用一个独立的子代理,跑在全新的上下文里,工具集里没有写入能力。三个约束各有各的道理:独立是为了让判断不被生成过程的前缀污染;新鲜上下文是为了让它看不到「我刚才是怎么想的」,只能看到产物;不能写是为了它无法顺手把不合格的地方改好然后判自己通过。

Anthropic 在那个像素编辑器项目里给评估者加了一条硬性证据标准:每条否决必须给出 文件:行号,以及一句具体的修法。给不出这两样的否决不算成立。这条标准双向有效------它防止评估者放水(说不出具体位置的「感觉不对」被过滤掉),也防止评估者空转(「建议提高代码质量」这类无法执行的意见进不了下一轮)。

第三个部件:一份由 agent 自己维护的交接文件。

PROGRESS.md 记录当前进度、已确认的决策、以及踩过的坑。配一个在会话结束时自动提交的钩子,保证进度记录和代码状态属于同一次提交,不会各自漂移。下一轮开始时第一件事就是读它。

这三个部件加起来的效果,用那个仓库自己的话说:agent 无法宣称一个它没有观测到的成功。这句话是整个验收契约的设计目标,也是判断你的契约有没有做对的唯一标准。

仓库作者还留了一句很克制的免责声明,值得一起抄下来:这些是示例材料,不是一套开箱即用的 harness。照抄结构是对的,照抄细节大概不对,因为你的验收标准和它的不一样。

八、约束层之外:Skills 是认知层

前面五层都在回答「不许做什么」和「做对了没有」。还有一类内容不属于约束,而属于知识:怎么发一个版本、怎么排查一类线上问题、这个项目的领域概念是什么意思。这些东西写进 CLAUDE.md 会有一个具体的坏处------它每次会话都被加载,长期占用上下文,而其中九成的会话根本用不到它。

Skills 解决的正是这个成本问题。它的机制是按需加载:目录放在 .claude/skills/<名字>/SKILL.md,目录名就是命令名,正文只有在这个技能被真正调用时才进入上下文。官方给的判断标准我认为非常准确------CLAUDE.md 里的某一段从「一条事实」长成了「一套流程」,它就该搬进 skill 了。 事实适合常驻,流程适合按需。

一份最小的例子:

markdown 复制代码
---
name: release
description: 执行发布流程------版本号、changelog、tag、发布说明
disable-model-invocation: true
allowed-tools: Read Bash(npm run build) Bash(git tag *)
---

1. 确认当前在 main 且工作区干净
2. 跑 npm run build 和 npm test,任一失败就停下报告
3. 按 CHANGELOG.md 里未发布的条目决定语义化版本号
4. ...

几个实用细节。disable-model-invocation: true 表示只有人能触发,模型不会自己调用它------发布、迁移、清理这类你希望始终由人按下按钮的流程都该加上。allowed-tools 用空格分隔,写法和权限规则一致,作用是把这个技能能碰的东西收窄到它需要的范围。在个人和项目技能里,name 只影响列表里的显示名称,实际命令名来自目录名,所以不要指望改 name 就能改命令。

如果你希望同一个技能也能在 claude.ai 或者 API 里用,把 frontmatter 限制在开放规范的六个字段内:namedescriptionallowed-toolsmetadatalicensecompatibility。Claude Code 自己支持的额外字段在那些环境里会触发「未知键」的校验错误。

还有一个在单仓多包项目里很好用的性质:嵌套目录里的 .claude/skills/ 会在 agent 第一次读写该子目录下的文件时自动变得可用。这意味着 apps/web/ 可以带自己的一套技能,只在真正动到那个包的时候才加载。

自定义命令(.claude/commands/*.md)已经和技能合并,两者创建的斜杠命令行为一致,老文件继续有效。新写的建议用技能,因为它可以带目录、带附属文件、带 frontmatter。

九、把上下文当预算来管

前面每一层都在往系统里加东西,而其中两层------项目规则和技能------花的是同一笔钱:上下文。这笔钱是有限的,而且花超了不会报错,只会让模型的注意力被稀释。所以 harness 里有一件和「加规则」相反方向的工作:管住规则的体积。

规则文件里只放事实和判断依据,不放流程。

一条好的项目规则长这样:「测试用 npm run test:unit,端到端用 npm run test:e2e,后者需要先 docker compose up -d db。」它是事实,短,每次会话都值得加载。一条不该放在这里的内容长这样:「发布流程:第一步确认分支......第七步更新发布说明。」它是流程,长,九成的会话用不到------它属于第八节讲的技能。这个区分不是洁癖,它直接决定了你的规则文件在半年后是三十行还是三百行。

写具体到可以执行的程度。

「注意错误处理」在任何一层都是废话。「所有 API 路由必须用 src/lib/api.ts 里的 withErrorBoundary 包裹,它负责把异常转成统一的错误响应格式」是可执行的。判断方法和第六节的报错标准一样:一个不了解项目的人能不能照着做对。

写理由,不只写结论。

「不要在组件里直接调 fetch」是结论,「不要在组件里直接调 fetch,因为服务端渲染阶段没有 cookie,请求会以匿名身份发出,这个 bug 我们踩过两次」是结论加理由。带理由的规则在你没预料到的场景里仍然能被正确应用,只有结论的规则会在边界上被反复试探。理由那句话也是半年后决定要不要删它的唯一依据。

接受压缩会发生,并为它做设计。

长会话的上下文会被压缩,压缩保留的是「看起来重要的部分」,而不是「对三小时后的决策必要的部分」。这两者经常不同:一个在开头就被否决的方案(「不要用 WebSocket,网关不支持」)在摘要里几乎一定会消失,而它恰恰是后面最可能被重新踩上的坑。

对策很朴素------凡是必须活过压缩的信息,都要落到文件里。已确认的技术决策追加进项目规则文件,当前进度写进 PROGRESS.md,本轮发现的问题写进一个下一轮开头会读的 findings 文件。判断方法就是上一篇那个测试:假设 agent 现在崩溃,一个全新实例接手,它需要知道什么?把答案逐项列出来,然后检查每一项现在存在哪里。存在对话历史里的,都算没存。

这件事有个反直觉的地方:主动往文件里写,比努力把上下文塞满更省钱。 一份两百行的 PROGRESS.md 只在需要时被读一次,而同样的信息留在对话历史里会在每一轮被重复发送。把状态外置既提高了可靠性,也降低了成本,很少有工程选择能同时做到这两件事。

十、怎么知道 harness 真的起作用了

这是最容易被跳过的环节。绝大多数人的 harness 只有添加操作,没有评估操作,于是无法回答一个很基本的问题:这五十行规则里,哪些还在起作用?

删除测试。 把一条你怀疑已经过时的规则注释掉,跑一周。如果什么都没发生,删掉它。这个方法很笨,但它是唯一可靠的------没有任何工具能告诉你哪条规则已经失效,因为失效的规则不报错,只是安静地增加成本。

同任务对照。 挑一个有代表性的任务,在开启和关闭某组约束的两个分支上各跑三次,比较需要人工介入的次数。注意衡量的指标是「人工介入次数」而不是「是否完成」------harness 的收益主要体现在减少你的注意力消耗,而不是提高成功率。

每次模型升级后复查一遍。 这是纪律问题不是技术问题。模型换代时把规则文件通读一次,逐条问「这条现在还需要吗」,把不确定的先注释掉。上一篇提到的那个真实案例值得记住:为了对付旧模型的上下文焦虑而设计的周期性重置机制,在新模型上变成了纯粹的死重量,每次触发都在白扔有用的上下文,而且不会有任何报错提醒你。

记一份「为什么」。 每条规则旁边写一行它对应的那次事故。这行注释在半年后决定要不要删它的时候,是唯一有用的信息。规则本身说明它禁止什么,只有这行注释说明它当初解决了什么。

十一、什么时候不该做 harness

反面清单同样重要,因为过度约束的症状是模型变笨,而你会以为是模型的问题。

规则还没被违反过的时候。 预防性 harness 的命中率极低,因为你猜的失败模式和真实的失败模式往往不是一回事。Mitchell 那个效果接近完美的 AGENTS.md 之所以有效,恰恰因为它的每一行都是被具体事故逼出来的,不是设计出来的。先让它犯错,再针对性地封住。

探索阶段。 你自己都还不确定项目该长什么样的时候,任何约束都是在锁定一个你还没验证过的决定。这个阶段应该把约束限制在「不可逆操作」这一类上,其余全部放开。

不可形式化的东西。 「代码应该优雅」写不进任何一层,硬要写就会变成一堆基于形状猜测意图的规则,误报会毁掉整个规则集的可信度。

一次性任务。 harness 的收益来自复用。只做一次的事情,直接在对话里说清楚更快。

代价便宜的事情。 违反一次的成本是一条 review 评论的事情,不值得占用一条永久规则的位置。

十二、一个最小可行 harness

如果今天开始做,我会按这个顺序,一周之内完成:

  1. 一份 .claude/settings.json,只配 deny 密钥文件、生产配置、Bash(curl *)、以及你项目里那几个不可逆的脚本。先不碰 allow------过早收紧白名单会让你在接下来一周不停地被打断。
  2. 一份 CLAUDE.md,只写事实。 技术栈、目录含义、跑测试的命令、以及三到五条你已经被违反过的约定。不要预防性地写规则,留空间给后面几天真实发生的事。
  3. 一个 PostToolUse 钩子,改完文件跑类型检查,把错误原文喂回去。 这是投入产出比最高的一个钩子,因为它把最常见的一类错误的发现时间从「下次构建」压到「下一步」。
  4. 把已有 linter 里最重要的三条规则的报错信息重写一遍。 按第六节的四要素:违反了什么、正确做法(带具体文件和函数名)、豁免条件、为什么。这一步不需要写任何新规则,收益却往往比新增规则更大。
  5. 给需要多轮的任务加上一份默认失败的验收清单。 一个 test-results.json,条目全是 false,外层循环用 grep 判断是否继续。
  6. 把每条规则对应的那次事故记在旁边。 一行注释,为了半年后的自己。

这份清单里没有多代理编排,没有沙箱,没有自定义 MCP 服务器。它们都有用,但都不是起点。起点是把「不可逆的事情不可能发生」和「完成不是一句声明」这两件事先落地,剩下的可以等真实的失败告诉你该加什么。

harness engineering 说到底是一门很朴素的手艺:观察失败,判断它属于哪一类结构性缺口,在对应的位置补上那个器件,然后在模型进步之后记得把不再需要的部分删掉。前三步大多数人会做,第四步很少有人做------而它才是把这件事和「不断打补丁」区分开的地方。

参考资料

相关推荐
也非非也4 小时前
DeepSeek 又开源了一个新东西——DeepSeek Harness
人工智能·开源·agi·deepseek·harness·dsh
特立独行的猫a13 小时前
一切皆插件:DeepSeek Harness 的架构哲学,以及与主流 Agent 的对比
人工智能·架构·agent·deepseek·harness
小小工匠14 小时前
Harness - 01 什么是 Harness Engineering
harness
兮动人21 小时前
DeepSeek 把模型的“马具“开源了:拆解 Harness
gpt·deepseek·harness·dsh
一个有温度的技术博主1 天前
DeepSeek Harness 深度解析:与 LangChain / LangGraph 的本质区别
数据库·oracle·langchain·harness
Jay-r1 天前
DeepSeek Harness 极简上手:装好、玩熟、让它自己长新能力
人工智能·windows·ai·github·ai编程·deepseek·harness
小马过河R1 天前
不只是又一个 Agent 框架:DeepSeek Harness 如何重新定义“可组合”
人工智能·机器学习·系统架构·agent·ai编程·harness
cyadyx1 天前
DeepSeek Harness(DSH)
deepseek·harness·dsh
SHIPKING3931 天前
【Harness Engineering】02_Prompt 不是人格,Prompt 是控制平面
prompt·harness