跳到正文
Hacker News· atulfiled·· 5 天前AI 评分66

Filed 联合创始人分享如何用 Perplexity decider 让编码智能体按团队规范写代码

Using Jev to make your agents write the code and design the app the way you do

AI 导读

Filed 联合创始人 Atul 介绍了团队用决策模型 Perplexity decider(与 TypeSafe 的 Jev 同类)审查编码智能体产出的做法:把每条代码规范写成 rules.yaml 中的是非题,按目录嵌套存放,智能体改代码前先读取适用规则,改完后由 CLI 一次性提交 diff 与全部问题,模型返回每条规则的概率,低于 80% 的成为提示。

正文 · AI 翻译

· 2026年10月12日

我跟你说实话:你的编码代理通过了 lint,通过了测试,在 CI 里一片绿,写出来的代码你在 review 时照样会打回去。每一天都如此。如果有人告诉你,只要有一套足够好的测试套件,你就可以放手让代理在你的代码库上撒欢,那我很抱歉,但有人卖给你的只是真相的一个略小版本。

测试检查的是代码做了什么。Lint 检查的是代码看起来的少数几件事。没人在检查代码是不是按照我们这里的写法写的。直到最近,这份工作还落在几位资深开发身上,在 review 里对着同样五个错误一遍又一遍地说“……嗯,不行”。

我是 Atul,Filed 的联合创始人。我们为税务专业人士构建代理,而现在我们的很多代码是由其他代理写的。前阵子我写过你能把多少工作委托给编码代理,当时我说第三条腿是“把你的最佳实践成文化,并让一个代理式审查者去检查它们”。这篇文章讲的是我们真正把它建出来之后它变成了什么,以及最初几个版本所有不奏效的地方。

前言

这不是一篇“测试已死”的文章。测试和 lint 仍然是你最先伸手去拿的东西,而且如果一段脚本能决定,那就该由脚本决定。下面讲的全是剩下的那些:在你团队的人眼里明显是错的、但在你拥有的所有工具眼里都隐形的东西。

另外,关于标题我得坦白一下。TypeSafe 的 Jev 是让我迷上这个想法的决策模型,但我们生产环境里实际跑的是 Perplexity 的 decider(pplx-decider)。同一种 API,但有一个大区别:Perplexity 的 decider 能看图像,这在检查 UI 是否遵循你的设计规则时非常有用(下面会细说)。所以在这篇文章里我会把“Perplexity decider / Jev”混着说。你就把它读作“一个决策模型”。

决策模型是这么工作的:你给它一些状态和一组是/否问题,它给你每个问题的概率。就这样。没有长篇大论。记住这一点,后面有用。

漏过去的东西并不稀奇

它们很无聊。这就是问题所在。

下面是我们所有流水线都能放行的那类东西:

  • 一个 except Exception: return None,把一次真实的失败变成一个空白屏幕和一个非常困惑的用户。
  • 一个工具在代理上被注册了两次,因为代理已经从另一个工具集里拿到了它。运行正常。测试通过。模型现在看到同一个工具两次,只能猜你指的是哪一个。真棒。
  • 共享代码每出现一个新情况就多长出一个 if/else 分支,而不是让那个情况拥有自己的模块。每个分支单看都无害。十个之后,共享代码就成了一堆没人想碰的特殊情况。
  • 每一个新的前端页面,哪怕你已经写下了设计指南和规则,做出来还是像通用的 Claude 生成的 UI。乍看不错。干净、现代,没有明显的问题。它就是不像你的应用。它缺少灵魂。

这些今天都不会弄坏任何东西。但每一个都会让明天的代码库更难在里面工作。而代理,作为代理,只要在两个地方看到这个模式,就会乐呵呵地重复它。

Lint 和测试能抓住它们被造出来要抓的东西。其余的一切都径直走过它们,进了 main。

我们试过把它写下来。代理们只是扫了一眼。

显而易见的做法是把你的约定写下来。于是我们这么做了。写在AGENTS.md里。写在CLAUDE.md里。写在一份最佳实践文档里。写在技能里。写在评审笔记里。大概还写在某个 Notion 页面里。

结果是这样的:智能体读了一部分,上下文窗口吃掉了剩下的,六个月后你手上有四份文档,它们对错误该不该向上冒泡这件事各执一词。散文会漂移。没人测试散文。而埋在 README 第九段里的一句“该做/不该做”,基本上就是个建议。

“直接让 LLM 审一下这个 PR”就是台凭感觉的机器

问它两次,得到两份评审。

下一个显而易见的做法是把 diff 丢给聊天模型,说“对照我们的标准审一下”。我们也试过。你拿回来一篇格式漂亮的文章。它挑出一个变量名。它夸了你的错误处理。它建议给一个单行函数加个文档字符串。再跑一遍,它挑出的是另一个变量名。

你没法给一篇文章设阈值。你没法判断你的指令是变好了还是变差了。你没法判断它到底看了你在意的东西,还是只是觉得想评论点什么。

我真正想要的其实笨得多:每条规则一个问题,每个问题一个数字。

登场:Perplexity decider / Jev——它只回答是或否

这正是决策模型发挥价值的地方。你发给它两样东西:一个state(它需要看的一切)和一组命名的questions。它每个问题返回一个数字。

但光有模型还不是系统。你需要智能体真正能调用的东西、供它阅读的规则,以及把它们串起来的循环。所以让我们一步步搭起来:CLI、规则、发给 Perplexity decider 的内容、返回的内容,以及智能体运行的循环。

第 1 步:给你的智能体造一个 CLI

还记得智能体们草草扫过我们文档的事吗?这一步解决的就是这个问题。任何智能体应该记得去读的东西,它最终都不会读。任何它能调用的东西,它每次都会调用,因为调用工具正是智能体擅长的事。

所以在做任何其他事之前,你需要一个 CLI。具体里面装什么我会在第 5 步讲,但你需要先有它,因为其他每一步都是智能体通过运行它来完成的。

我们的叫filed(或者filed.exe,给那些用 Windows 的可怜人)。它是智能体在我们代码库里构建和验证一次改动所需一切的唯一前门:启动本地栈、登录应用、截图、记录验证了什么。这个 CLI 就是你的挽具。对这篇文章来说,我们关心的部分是它里面的一个命令:review。

第 2 步:写规则

接下来是规则。它们放在rules.yaml文件里。每条规则都是模型能回答是或否的一个断言,外加该怎么处理它。(从这里开始,示例使用一个虚构的网店、编造的 diff 和示意性的数字。规则措辞是我们的真实措辞。)

# shop/docs/review/rules.yaml  (simplified: in reality these are split across folders, see below)

- id: code.errors-bubble-up
  applies: ["**/*.py", "**/*.ts"]
  mistake: The change catches an exception and then returns None, an empty list,
    a default value or a fallback instead of letting the error raise
  fix: Let the error raise; handle it only where the caller can act on it
  reference: payments/retry.py
  cases:
    violating:
      - file: orders/lookup.py
        diff: |
          +    except Exception:
          +        return None
    compliant:
      - file: orders/lookup.py
        diff: |
          +    raise OrderNotFound(order_id)

- id: code.identity-from-token
  applies: ["**/graphql/**"]
  mistake: A GraphQL field, mutation argument or input type accepts an account ID,
    user ID or role from the caller
  fix: Read the account and user from the auth token in the resolver
  reference: graphql/resolvers/me.py
  cases: ...

- id: code.no-product-branches-in-shared-code
  applies: ["**/shared/**", "orders/**"]
  mistake: The change adds an if-check or filter on a specific product, task type,
    provider, surface or flavour inside shared or generic code
  fix: Move the specific behaviour into that product's or provider's own module or class
  reference: shipping/carriers/README.md
  cases: ...

- id: code.one-code-path
  applies: ["**/*.py", "**/*.ts"]
  statement: The change keeps one code path, with no fallback, legacy,
    compatibility or workflow.patched branches for old data or old callers
  fix: Remove the old path and migrate its data or drain its runs
  reference: docs/migrations.md
  cases: ...

一条规则要么是mistake(当改动破坏它时为真),要么是statement(当改动遵循它时为真)。绝不可两者兼是。fix和reference是给智能体看的。cases是用来测试规则本身的,这个我们后面会讲。

上面我把全部四条规则放在一个文件里,是为了简单起见。现实中,规则不是一个大而扁平的文件。它们是嵌套的,而且就放在它们所评判的代码旁边。

任何在意的文件夹都可以有自己的docs/review/rules.yaml。一条规则适用于它所在的文件夹及其下所有内容。所以通用的东西放在顶层,具体的东西就放在它所针对的代码旁边:

shop/
├── docs/review/rules.yaml                 errors bubble up, one code path      (everything)
├── graphql/
│   ├── docs/review/rules.yaml             identity comes from the token        (only GraphQL)
│   └── inputs/create_order.py
├── orders/
│   ├── docs/review/rules.yaml             no N+1 queries on the order list     (only orders)
│   └── update.py
└── shipping/
    ├── docs/review/rules.yaml             one module per carrier               (only shipping)
    └── carriers/
        ├── docs/review/rules.yaml         every carrier implements prepare()   (only carriers)
        └── fedex.py

当 agent 即将修改 shop/graphql/inputs/create_order.py 时,CLI 会从该文件向上遍历,沿途收集每一个 rules.yaml:shop/graphql/ 中的一个和 shop/ 中的一个。它永远不会看到 shipping 规则或 carrier 规则,因为它没有触及那部分代码。如果它之后游荡到 shipping/carriers/fedex.py,它会再次询问,并得到一个不同的、同样简短的列表。

这很重要,原因有三:

  • agent 只读取相关内容。一个包含 200 条规则的扁平文件意味着 agent 必须读取 200 条规则,其中大部分会被忽略,但仍会分散它的注意力。嵌套集合则只给它适用于所在文件夹的那五条。
  • 规则与所有者同在。拥有 shipping 代码的人拥有 shipping 规则,就在 shipping 文件夹里。当代码移动或被删除时,规则随之而去,而不是在某个文档里腐烂。
  • 它让审查保持快速且低成本。每一条适用的规则都会让每次审查多一个问题,而且(正如我们将在成本部分看到的)每多一个问题都会让整个请求变得更大。将规则限定在其文件夹内是最便宜的优化。

我们还让仓库级规则保持精简:任何单个文件最多只能适用 15 条,如果这个数字悄悄上升,测试就会失败——这通常是因为有人懒得细化,就把规则限定到了 **/*。

目前我们的规则分散在六个仓库中大约 27 个这样的文件里,从顶层的仓库级规则一直到单个数据库代码文件夹。

诀窍在于,同一个文件有两个读者。

一个文件,两个读者。agent 在写入之前读取规则。decider 在之后用完全相同的文字来评判变更。

第 3 步:发送给 Perplexity decider 的内容

CLI 找到被修改的文件,计算出哪些规则适用于它们,并将每条规则转化为一个问题。问题就是规则本身的文字,前面加上它适用的位置:

POST https://api.perplexity.ai/v1/decisions
{
  "model": "pplx-decider-v1.1-27b",
  "state": {
    "context":  "Changes in a pull request to the codebase, with the author's
                 summary. Each question is about these changes only. references
                 holds existing files that show the codebase's established pattern
                 for a rule; use them to judge whether the change reuses or
                 duplicates what already exists.",
    "summary":  "Add a 'create order' mutation for the new checkout flow",
    "files": [
      { "repo": "shop", "file": "orders/lookup.py",
        "diff": "+    try:\n+        return repo.get(order_id)\n+    except Exception:\n+        return None" },
      { "repo": "shop", "file": "graphql/inputs/create_order.py",
        "diff": "+    account_id: str\n+    items: list[OrderItem]" },
      { "repo": "shop", "file": "orders/update.py",
        "diff": "+    if order.channel == Channel.WHOLESALE:\n+        notify_wholesale_team(order)" }
    ],
    "references": [
      { "repo": "shop", "file": "payments/retry.py", "content": "..." },
      { "repo": "shop", "file": "graphql/resolvers/me.py", "content": "..." },
      { "repo": "shop", "file": "shipping/carriers/README.md", "content": "..." }
    ]
  },
  "questions": {
    "q0": { "type": "noul", "instructions": "In the shop repository, for files matching
            **/*.py, **/*.ts: The change catches an exception and then returns None, an
            empty list, a default value or a fallback instead of letting the error raise" },
    "q1": { "type": "noul", "instructions": "In the shop repository, for files matching
            **/graphql/**: A GraphQL field, mutation argument or input type accepts an
            account ID, user ID or role from the caller" },
    "q2": { "type": "noul", "instructions": "In the shop repository, for files matching
            **/shared/**, orders/**: The change adds an if-check or filter on a specific
            product, task type, provider, surface or flavour inside shared or generic code" },
    "q3": { "type": "noul", "instructions": "In the shop repository, for files matching
            **/*.py, **/*.ts: The change keeps one code path, with no fallback, legacy,
            compatibility or workflow.patched branches for old data or old callers" }
  }
}

这就是整个提示词。没有“你是一名资深工程师”,没有评分标准,没有“一步一步思考”。只有变更、展示正确模式的文件,以及四个断言。

第 4 步:返回的内容

{
  "answers": {
    "q0": { "noul": 0.96 },
    "q1": { "noul": 0.93 },
    "q2": { "noul": 0.88 },
    "q3": { "noul": 0.94 }
  },
  "usage": { "input_tokens": 9412 }
}

每个数字都是该断言为真的概率。对于三条 mistake 规则,数字高是坏消息。对于一条 statement 规则,数字高是好消息。因此,在向任何人展示任何内容之前,CLI 会翻转错误(1 - p),这样每个结果的读法都一致:变更遵循规则的概率。

规则 类型 原始答案 合规性
errors-bubble-up mistake 0.96 4% hint
identity-from-token mistake 0.93 7% hint
no-product-branches-in-shared-code mistake 0.88 12% hint
one-code-path statement 0.94 94% quiet

任何低于 80% 的都会变成提示,按最差优先排序,并附上规则的 fix 和 reference。其他一切都保持安静。以下是 agent 在终端中实际得到的内容:

filed review: 3 hints (1 calls, 9412 input tokens)
  4%  code.errors-bubble-up  shop/orders/lookup.py
      fix: Let the error raise; handle it only where the caller can act on it
      see: shop/payments/retry.py
  7%  code.identity-from-token  shop/graphql/inputs/create_order.py
      fix: Read the account and user from the auth token in the resolver
      see: shop/graphql/resolvers/me.py
 12%  code.no-product-branches-in-shared-code  shop/orders/update.py
      fix: Move the specific behaviour into that product's or provider's own module or class
      see: shop/shipping/carriers/README.md
compliance 1/4 rules at 80% or above

因为它是一个数字而不是一段文字,许多事情突然变得可能:

  • 你可以对它设定阈值。低于 80% 是提示,其他一切都保持安静。
  • 你可以对它进行比较。相同的规则,相同的 diff,不同的措辞:哪一个能更好地区分好坏?
  • 你可以对它进行测试。稍后会详细说明,因为这是让整件事奏效的部分。

第 5 步:循环:代码前、代码、代码后

既然你已经看到了规则和 decider,下面是第 1 步中 CLI 内部的内容。review 命令有四个子命令:

filed review rules --files <paths...>    which rules apply to the files I'm about to change?
filed review run --summary <pr.md>       judge my changes against those rules
filed review test [--rule <id>]          is each rule actually worded well enough to work?
filed review dismiss <rule> "<reason>"   this hint is a false alarm, and here's why

Agent 不需要知道规则文件放在哪里、如何把 diff 打包进请求、该调用哪个模型,或者当提供商宕机时该怎么办。它只运行一条命令并读取输出。而且因为它是 CLI,所以对 Claude Code、Codex、后台 agent,或者凌晨 2 点的人类来说,用法都一样。

整个流程就是 agent 自行运行的一个循环,分为三个阶段。agent 的指令(AGENTS.md,在我们的例子中)说了两件事:在碰任何文件之前,始终先向 CLI 询问规则;在宣布完成之前,始终先运行 review。

关键之处在于模型实际被调用的位置:

阶段 agent 运行什么 Perplexity decider / Jev 调用
代码之前 filed review rules --files ... 无。它只是读取 YAML。
代码 lint 和测试 无。
代码之后(review) filed review run --summary ... 一次调用。所有规则,一个请求。

所以 agent 可以免费获取规则,想获取多少次都行,只在最后为模型付费一次。

代码之前:询问规则。代码:每次编辑都运行 lint 和测试。代码之后:review,然后修复或忽略每条提示。

1. 代码之前:agent 询问规则。这是主动的一半。在编辑任何内容之前,agent 带着它计划修改的文件运行 CLI,并得到类似这样的返回:

$ filed review rules --files shop/orders/update.py shop/graphql/inputs/create_order.py

code.errors-bubble-up  (docs/review/rules.yaml)
  avoid: The change catches an exception and then returns None, an empty list,
         a default value or a fallback instead of letting the error raise
  do: Let the error raise; handle it only where the caller can act on it
  see: payments/retry.py
code.no-product-branches-in-shared-code  (docs/review/rules.yaml)
  avoid: The change adds an if-check or filter on a specific product, task type,
         provider, surface or flavour inside shared or generic code
  do: Move the specific behaviour into that product's or provider's own module or class
  see: shipping/carriers/README.md
code.identity-from-token  (shop/graphql/docs/review/rules.yaml)
  avoid: A GraphQL field, mutation argument or input type accepts an account ID,
         user ID or role from the caller
  do: Read the account and user from the auth token in the resolver
  see: graphql/resolvers/me.py

三条规则,而不是三百条。只有那些 applies 与这两个文件匹配的规则,来自它们路径上的每个 rules.yaml。每条规则都说明要避免什么、应该改做什么,以及哪个现有文件展示了正确做法。最后一点比看起来更重要:“参见 payments/retry.py”给了 agent 一个代码库中的真实示例去复制,这胜过任何关于错误处理的冗长文字。

这里没有 Perplexity decider / Jev 调用。这只是 CLI 从磁盘读取 YAML 文件,所以不花任何成本,只需几毫秒。agent 每次转向一组新文件时都会运行它。如果任务进行到一半,它意识到还需要碰 GraphQL 层,它就会再次为那些文件询问。

2. 代码:每次编辑都运行 lint 和测试。这里没什么新东西。这是内层循环,速度很快,并且保持原样不变。

3. 代码之后:agent 请求 review。这是被动的一半。当 agent 认为完成时,它会写一份简短的变更摘要并运行 filed review run。这是模型被调用的唯一地方:一次 Perplexity decider / Jev 调用,包含整个变更以及适用于它的每条规则,一次性全部询问。(只有真正很大的变更才会为了适应请求限制而拆成几次调用。)返回的是一份简短的提示列表,最严重的排在最前面:

$ filed review run --summary pr.md

filed review: 2 hints (1 calls, 48210 input tokens)
  9%  code.identity-from-token  shop/graphql/inputs/create_order.py
      fix: Read the account and user from the auth token in the resolver
      see: shop/graphql/resolvers/me.py
 31%  code.no-product-branches-in-shared-code  shop/orders/update.py
      fix: Move the specific behaviour into that product's or provider's own module or class
      see: shop/shipping/carriers/README.md
compliance 3/5 rules at 80% or above

每条提示都有三部分,每一部分的存在都是为了让 agent 无需人类介入就能修复问题:

  • 哪条规则和哪个文件。不是“这个 PR 有问题”,而是“这条规则,在这个文件中”。
  • 修复方法。与它在编写之前看到的同一条“应该做”的说明,所以它不是在学新东西,只是被提醒。
  • 参考。展示正确模式的文件。agent 打开它,看看代码库其余部分是怎么做的,然后照做。

然后 agent 对每条提示做两件事之一。如果这是真问题,它就修复并再次运行 review。如果是误报,它用 filed review dismiss <rule> "<reason>" 记录原因,这个原因最终会出现在 PR 中,供人类做合理性检查。对同一变更进行三轮之后,它会停下来交给人类,因为到那时你就是在花钱让一个模型和另一个模型争论。

这种拆分的好处在于,同样的词会出现在两端。在写下一行之前,代理被告知“避免 X,做 Y,看 Z”。如果审查仍然标记 X,提示会原样重复同样的话。没有新的意见需要解读,只是提醒它已经同意的规则。

你的规则是错的。像测试代码一样测试它们。

这是我没想到的部分。

我最初的规则很糟糕。不是错,只是没用。它们读起来很漂亮,但模型无法用它们区分好的改动和坏的改动。

最好的例子:我们有一个 PR 注册了一个工具两次(上面列表中的那个)。我的第一条规则说“改动复用了现有的辅助函数”。它在有问题的 PR 上得分 0.97,在修复上也是 0.97。当然如此。如果你眯着眼看,一切都在“复用现有的辅助函数”。这条规则描述了一种美德,而模型很乐意同意,是的,这段代码非常有美德。

起作用的版本指出了实际机制:“当代理已经从另一个工具集获得工具时,不会向工具集添加工具”。有问题的 PR 上 0.17,修复上 0.82。

同一个 PR,同一个修复,两种措辞。只有一个是规则。另一个是赞美。

所以现在每条被评判的规则都必须携带标记案例:至少一个违反它的差异(或截图)和一个遵循它的差异。以下是我们“共享代码中没有产品分支”规则的案例样子(简化版,使用来自虚构在线商店的编造代码)。错误是“改动在共享或通用代码中针对特定产品、任务类型、提供者、界面或风味添加了 if 检查或过滤器”:

# violating: orders/update.py
 def update_order(order, status):
     order.status = status
+    if order.channel == Channel.WHOLESALE:
+        notify_wholesale_team(order)
     save(order)

# violating: shared/shipping/dispatch.py
 async def dispatch(carrier, parcels):
+    if carrier.name == "fedex":
+        parcels = repack_for_fedex(parcels)
     return await carrier.ship(parcels)

# compliant: the same behaviour, moved into the carrier that owns it
 class FedexCarrier(Carrier):
+    def prepare(self, parcels):
+        return sorted(parcels, key=lambda p: p.weight)

 async def dispatch(carrier, parcels):
-    return await carrier.ship(parcels)
+    return await carrier.ship(carrier.prepare(parcels))

# compliant: a change to shared code that has nothing to do with any product
 def update_order(order, status, notes):
     order.status = status
+    order.notes = [*order.notes, *notes]
     save(order)

注意合规案例不仅仅是“没有错误的代码”。其中一个是对违规案例的修复,另一个是对同一个共享文件的无害改动。这迫使规则关注 if 检查,而不是“有人碰了共享代码”。filed review test 对每个案例单独评分并计算一个边际:最低合规分数减去最高违规分数。简单说,就是规则将好改动与坏改动分开的清晰程度。低于 0.5 则规则失败。你重新措辞,保持含义,直到它通过。当它通过时,结果会被写回 YAML,并附上措辞和案例的哈希,这样如果以后有人编辑规则而没有重新测试,验证器就会注意到。

以下是我们一些规则重新措辞后边际的变化:

规则 边际,第一版措辞 边际,调整后
共享代码中没有产品分支 0.24 0.96
身份来自令牌,而不是输入 0.45 1.00
指令集中在一处 0.24 0.82
税务专业人士会写的文案 0.16 0.89

由此我得出两件事,现在我相当坚信:

  1. 写下错误,而不是美德。模型更擅长注意到某物存在,而不是注意到某物缺失。“改动捕获异常并返回 None”每次都胜过“错误被正确处理”。几乎所有调整良好的规则最终都以错误的形式表述。
  2. 你的案例会过拟合。你调整措辞直到你的三个例子很好地分开,然后一个真实的 PR 出现了,看起来完全不像它们。每个误报和每个遗漏都成为一个新案例。案例才是这里真正的资产。措辞只是当前的最佳猜测。

UI 方面:它查看截图

这就是为什么我们运行 Perplexity 决策器而不是普通的 Jev。

代码只是故事的一半。另一半是本文开头那种通用的 AI 生成 UI:一个能用的页面,乍看之下还不错,但看起来不像你的应用。再仔细看,它总是那几处老毛病。三个主按钮争抢注意力。每个标题都是全大写。每个标签旁边都有个可爱的小图标,却毫无用处。文案听起来像是开发者写的,因为确实是开发者(好吧,是一个 agent)写的。

你没法用 lint 检查这个。你也很难对它做单元测试。你可以写一个 Playwright 测试来检查页面上有一个文本为 "Save" 的按钮,但没法写一个测试来检查页面感觉像你应用的其他部分。这一直以来都是靠人在评审时盯着截图眯眼看。而那个人,同样还是那几位资深开发者之一。

Perplexity 的裁决器能读取图像,所以设计规则的工作方式和代码规则完全一样,只不过状态是截图而不是 diff:

UI 一致性:变更后的页面,在桌面端和手机宽度下分别截图,连同每条设计规则的一个问题一起发给裁决器。(这里的数字仅作示例。)

Review 会在已登录的浏览器中打开每个变更页面,在桌面端(1440x900)和手机端(390x844)宽度下截图,然后把两张截图连同适用于变更文件的设计规则一起发送:

POST
  state:      [ the PR summary + the page + the design guidance,
                desktop.png,
                phone.png ]
  questions:  q0: "Several filled, dark primary buttons appear side by side
                   in the same area of the page"
              q1: "List or table rows show their own visible Edit, Duplicate
                   or Delete buttons"
              q2: "Rows or cards carry extra colourful decorative symbols such as
                   stars, dots or shapes that add no meaning"
->
  answers:    q0: 0.92   <- that's a hint
              q1: 0.03
              q2: 0.11

而一条设计规则就放在同一个 rules.yaml 里,用截图代替 diff 作为它的用例。这是我们前端仓库里的真实规则:

- id: design.one-primary-action
  applies:
    - apps/web/src/**/*.tsx
    - packages/ui/src/**/*.tsx
  mistake: Several filled, dark primary buttons appear side by side in the same area of the page
  fix: Keep one primary action per area; make the others secondary or move them into a menu
  reference: docs/design/README.md
  cases:
    violating:
      - images: [cases/primary-buttons-routines.png]
      - images: [cases/primary-buttons-clients.png]
    compliant:
      - images: [cases/clean-routines.png]
      - images: [cases/clean-clients.png]
      - images: [cases/clean-templates.png]
  tested:
    margin: 0.99

这些用例从哪来?真实的本地页面。我们取一个干净的页面,截图,然后通过注入一点 DOM 或 CSS 精确地破坏一条规则,再截图。下面是这条规则背后的真实对照图(裁剪到页面顶部):

Compliant: one primary action, 'New routine task', top right.
合规:一个主操作,'New routine task',位于右上角。
Violating: same page, four extra filled buttons injected next to the title. Across all its cases, this rule separates with a margin of 0.99.
违规:同一个页面,在标题旁边注入了四个额外的填充按钮。在它的所有用例中,这条规则的区分裕度为 0.99。

两张截图恰好只在一个方面不同,这正是你希望模型注意到的。对于全大写标题、每一行表格上都有的 Edit/Delete 按钮、装饰性图标、标题下方拥挤的胶囊按钮栏,以及页眉中的方框文字按钮,我们都有同类的对照图。(提示:亲眼看看每一张被破坏的截图。如果注入没生效,你就把一张干净页面标成了损坏,这条规则将永远无法通过。)

这些设计规则调优的幅度和代码规则一样显著:

规则 裕度,初始措辞 裕度,调优后
每个区域一个主操作 0.63 0.99
次要操作悬停时显示 0.57 0.91
页眉操作是图标,不是文字按钮 0.64 0.98
无装饰性图标 0.13 0.94

和之前一样的教训:"页面有清晰的视觉层次"是一句赞美。"同一区域内并排出现多个填充的深色主按钮"才是一条规则。

任何你能测量的东西(尺寸、间距、对比度)仍然交给一个普通的老式设计检查脚本。裁决器只接收脚本无法回答的问题,那些过去需要一双人眼的问题。

提示是线索,不是判决

filed review: 1 hints (1 calls, 125613 input tokens)
 13%  code.no-product-branches-in-shared-code  shop/agents/shared/assistant.py, ...
      fix: Move the specific behaviour into that product's or provider's own module or class
      see: shop/shipping/carriers/README.md
compliance 4/5 rules at 80% or above

这一条基于一个真实的提示(名字已更改以保护有罪者):一个共享 agent 在悄悄根据调用它的是哪个产品来过滤自己的工具。模型给了它 13%,而且它是对的。

但 "13%" 并不是对任何东西的测量。它是模型在说"看这里"。用这种方式解读每一条提示。大多数是真的。有些不是。这就是为什么用书面理由驳回是循环中一等公民的一部分,而不是一个尴尬的逃生出口。

它要花多少钱,以及你要等多久

剧透:花的钱比你想象的少得多,花的时间比你希望的多。

以下是我们上一批针对真实功能分支的评审中的真实数据:

评审内容 要求的规则 调用次数 输入 token 成本
一个页面,仅设计规则 5 1 15k $0.0003
一个小型后端改动 5 1 126k $0.003
一个页面及其代码 10 1 203k $0.004
跨两个仓库的中型分支 17 1 333k $0.007
一个分支加两个页面 23 3 834k $0.02
一个大分支 26 1 2.2M $0.04

是的,你没看错。表中最大的那次评审花了大约四美分。Perplexity 决策器(pplx-decider-v1.1)的标价约为每百万输入 token 0.02 美元,输出免费,因为输出只是几个数字。Jev 也差不多,约为每百万 0.042 美元。作为对比,把同样 2.2M token 的评审发给前沿聊天模型会花上几美元而不是几美分,而且你会收到一篇长篇大论。(价格来自截至 2026 年 10 月的公开报价;做预算前请先核实。)

所以钱不是问题。延迟和注意力才是。不过,账单的形态值得理解,因为它和延迟的形态是一样的。

让我惊讶的是:token 并不随 diff 的大小线性增长。它们随 diff 的大小乘以问题数量 增长。从用量数据来看,每回答一个问题,状态就会被计算一次,所以一个带有 26 条规则的大 diff 在单次调用中就是 2.2M 输入 token。你往一个被广泛应用的文件夹里添加的每条规则,都会让 每一次 触及该文件夹的评审变得更贵。这就是我们严格限定 applies 范围、并将仓库级规则限制为每个文件 15 条的根本原因。与其说是为了账单,不如说是为了等待时间和噪音。

延迟在功能收尾时没问题,但在内循环中很糟糕:

  • 针对一个用例的一个问题 大约一两秒返回。filed review test 针对单条设计规则、五个截图用例需要六到八秒。
  • 一次完整评审 针对真实分支,两次调用约 1.7M token,耗时约一分钟。如果涉及页面,还要加上截图捕获时间。
  • 倒霉的一天 看起来就像等待一分钟后收到提供商的 504。我们记录中有两次运行就是这样结束的,评审报告“未运行”并让开了路。

所以经验法则是:lint 和测试在每次编辑时运行,评审在功能接近完成时运行一次,并在修复其提示后再运行一次,仅此而已。如果一次运行会发送超过 5M token,它会拒绝并告诉你要用文件夹或更近的基线分支来缩小范围。

现在说说那些不光彩的部分

把它说成全是好处并不公平,所以以下是细则:

  • 它便宜,但不是免费。 每次评审几美分不算什么,直到一个 agent 决定在每次编辑后、每个分支上、循环地运行它。无论如何还是给它设个预算吧。
  • 它会把你的代码发送给第三方。 摘要、diff、参考文件和截图都会发给提供商。对我们的代码来说没问题。但对任何接近纳税人数据的东西来说 不 行,所以评审从不在测试夹具或生产负载上运行。在接入之前,先确定你的界限在哪里。
  • 提供商会宕机。 额度会用完,密钥会过期,速率限制会发生。当这种情况发生时,评审会干净地退出,打印“评审未运行”,PR 也会如此说明。一个会阻塞合并的不稳定评审器比没有评审器更糟。
  • 这些管道工作不是免费的。六个仓库、带三层上下文的 diff、拆分文件和问题让每个请求都落在限制之内、对截图做 PNG 尺寸检查。这些都不难。但全都是你现在要维护的代码。
  • 它不能取代你的判断。它取代的是你判断的重复。第一次遇到一种新的错误时,那仍然得靠你。目标就是确保这是你最后一次需要把它说出口。

规则该放在哪里?

大部分价值其实在于决定不把规则放在哪里。我们用一套简单的阶梯,从最高一级开始。(这个想法——在能承载问题的最高层级上解决每个问题,把文字说明当作最后手段——直接来自 poteto 的演讲。去看。)

永远先尝试最高一级。模型是倒数第二的手段,不是第一手段。
  1. 代码能让这个错误不可能发生吗?那就这么做。一个类型、一种 API 形态、一个唯一入口的辅助函数。
  2. lint 或测试能抓住它吗?那就这么做。重复的工具注册现在被一个普通测试抓住了。不需要模型。
  3. 模型能从 diff 或截图判断它吗?那它就是一条判定规则,附带案例。
  4. 否则它就是一条指南条目:在 agent 写之前展示给它,从不判定。

那现在怎么办?

如果你在真实代码库上运行 agent,而且总是留下同样的评审意见,那你大概已经有规则了。你只是还没把它们写成任何东西能检查的形式。

在写一条规则之前,先问自己两个问题:

  1. 脚本能决定这件事吗?如果能,别读这篇文章了,去写 lint 规则或测试。它更便宜、更快,而且永远不会有状态不好的时候。
  2. 你能把它写成一条指出机制的错误,并附上一个会触发它的 diff 和一个不会触发它的 diff 吗?如果做不到,它还不是一条规则。它是一种感觉。感觉放在给 agent 的指南里没问题,但没有模型会替你执行它们。

如果两个问题都能回答,就把它放在代码旁边,反复测试措辞直到模型真的能区分这些案例,然后让 agent 在写之前读到它。这是我找到的最接近克隆你资深开发者那句“……我们这里不是这么做的”声音的方法,并让它在每个 PR 上出现,而不需要他们本人。


致谢

我们的 harness 的很多工作方式都受到他人思考的塑造。特别是:

  • poteto 的演讲,关于分层建立对 agent 工作的信任、在能承载问题的最高层级上解决每个问题,以及把代码库当作 agent 的记忆。上面的“规则该放在哪里”阶梯就是我们对这个想法的版本。
  • poteto 的验证技能示例,关于一个限定范围的、按功能划分的地图,让 agent 读取而不是去探索,以及为什么不用 wiki:上下文只付一次费,默认不加载任何东西。
  • Cursor 的 pstack 插件,尤其是 correct(文字说明是最后手段;优先架构,然后是类型或 lint,再然后是测试)、verify and ship(在真实应用上验证,构建一个 CLI 而不是一次性脚本,作者不评判自己的改动)、make it yours(只有在失败重复出现后才添加检查)以及 code-quality lens。
  • 这篇访谈,对同样的想法讲得更深入。
  • Chris Kiehl 的文章,展示了技术文章可以诚实地面对缺点,同时仍然读起来有趣。
  • TypeSafe,感谢 Jev,以及 Perplexity,感谢让这张截图的一半成为可能的决策者。

来源:Hacker News · blog.a7ul.com