AI 写的代码,为什么总像“差不多对”?我用 opencode 把 commit 前审查做成三道门

我用opencode做代码审查,注重agents.md的质量,聚焦多轮核心BUG输出质量,减少MR被驳回的风险

  1. 审查阶段不要使用可写 agent。 可写 agent 看到问题后很容易顺手修复,修复后的代码又变成新的审查对象。我要的是报告,不是未经确认的工作树变化。
  2. AGENTS.md 不是越长越好。 我见过把抽象价值观、编码风格和所有历史事故都塞进去的规则文件,最后 AI 什么都读到了一点,却没有一条真正执行到位。规则应当短、具体、可验证。
  3. 不要只统计 AI 提了多少条意见。 我更看重高价值意见的比例、误报率、漏报案例和处理耗时。意见越多不等于审查越好。

我最近在 Go 项目里遇到过一个很典型的例子:我让 AI 把一个用 sync.Mutex 保护的缓存改成 sync.RWMutex,因为读请求远多于写请求。代码能编译,go test -race 也通过了,我正准备提交时又看了一遍 diff,才发现 AI 在 RLock() 之后没有为所有提前返回的分支补上 RUnlock()。原代码用 defer Unlock() 做了一次兜底,锁的种类变了,兜底结构却没有一起变。

这个问题不是函数体“完全写错”,而是局部正确、整体破坏。测试没有触发那个错误分支,竞态检测自然也抓不到。类似的情况我见过很多:错误被 _ 接住、context 半路丢失、goroutine 没有退出条件、异常路径没有补齐、依赖被悄悄换成团队没人维护的库。AI 生成代码的速度越快,diff 的影响半径就越容易被低估。

我现在把 commit 前的审查拆成三道门:机器门负责确定性检查,AI 自审负责挑出候选风险,人工 review 负责业务判断和最终签字。这篇文章记录我在 Go 项目里实践这套流程的方式,也会说明 opencode 适合放在哪一层、哪些事情仍然不能交给它。

蓝色 表示工具或事实,绿色 表示我的实践判断,红色 表示容易踩的坑,黄色 表示可以直接改写到自己项目里的模板。

一、先把 commit 当成一次小型发布

以前我把 git commit 当作保存进度,后来几次线上问题让我改了看法:commit 是把一组行为变化交给团队和环境的发布动作。即使还没有上线,它也已经影响了后续 review、合并、回滚和事故追踪。

我的三道门是串联关系:

git add -p
机器门:格式、构建、测试、静态分析、漏洞与密钥扫描
AI 自审:只读 staged diff,报告有证据的风险
人工 review:不变量、业务边界、上线与回滚
git commit

三道门各自负责不同的问题,不能互相替代:

关卡我让它负责什么不通过时怎么做
机器门可重复、可自动判断的检查修复后重新运行,不让失败静默通过
AI 自审从 diff 和仓库上下文里寻找候选风险对照证据逐条确认,区分阻断、待验证和误报
人工 review业务不变量、权限边界、边界条件和回滚由作者或领域负责人补充设计并重新审查

机器负责“确定”,AI 负责“提醒”,人负责“决定”。只要其中一层越权签字,流程就会变成形式。

二、第一道门:让机器先清掉机械问题

机器门只做我能稳定复现的检查。下面是一个 Go 项目的示例脚本;它是文章里的模板,不代表每个仓库都必须原样使用。工具版本、模块数量和测试耗时,都要按项目实际情况调整。

#!/usr/bin/env bash
# scripts/precommit.sh
set -euo pipefail

echo "==> gofmt"
test -z "$(gofmt -l .)" || {
  echo "请运行 gofmt -w . 后再提交"
  exit 1
}

echo "==> go vet"
go vet ./...

echo "==> go build"
go build ./...

echo "==> unit tests with race detector"
go test -race -count=1 ./...

echo "==> staticcheck"
staticcheck ./...

echo "==> govulncheck"
govulncheck ./...

echo "==> gitleaks on staged content"
gitleaks protect --staged --redact --no-banner

我会重点看下面四件事。

  1. 先统一格式,再开始 review。 gofmt 不是审美检查,而是在减少 diff 噪音。AI 生成的代码可能出现缩进、import 顺序或局部格式不一致;这些问题应该在 reviewer 看到业务逻辑之前被清掉。
  2. -race 真正执行,而不是只看 CI 结果。 go test -race 只能发现被测试路径触发的竞态,所以它不是并发正确性的证明;但把它放在本地门禁里,至少能尽早抓住共享 mapslice 或 channel 的明显问题。-count=1 用来避免测试缓存掩盖本次运行。
  3. go vetstaticcheck 当成互补工具。 go vet 负责 Go 工具链内置的可疑构造检查,staticcheck 覆盖更多静态分析场景。它们仍然不能替代业务 review,但能把“看起来能跑、语义上可疑”的代码提前暴露出来。
  4. 密钥扫描只扫本次要提交的内容。 我一开始用全仓库扫描,历史 example 文件里的占位字符串反复触发告警。后来改用 gitleaks protect --staged --redact,范围更接近 commit 本身,命中后也不会把敏感内容再次打印到日志里。

机器门的目标不是证明代码绝对正确,而是把明显、重复、机器擅长的问题在 commit 前清零。

三、第二道门:让 AI 自审,但不让 AI 签字

机器门通过后,我会让 AI 只读当前的 staged diff。这里的关键不是一句“请仔细审查”,而是给它清晰的范围、固定的输出字段和明确的停止条件

我现在使用的提示词大致如下:

你是本次 commit 的代码自审助手,只能审查,不能修改文件。

审查对象:git diff --staged

只报告能够被当前 diff 或仓库上下文直接支持的问题,不输出风格建议、泛泛的重构建议或无法定位的“考虑加测试”。

按以下五类检查,按严重程度排序,每类最多 3 条:
1. 错误处理:error 被忽略、上下文丢失、资源未释放;
2. 并发与资源:锁未释放、goroutine 无退出路径、共享状态未保护;
3. 安全与边界:输入未校验、请求无超时、响应体无大小限制、日志泄露敏感信息;
4. 接口契约:函数语义、字段兼容性、nil 行为或权限边界发生变化;
5. 范围越界:引入任务未要求的依赖、功能、重构或行为变化。

每条意见必须包含:
- 文件:行号
- 触发条件
- 证据(对应代码片段或调用关系)
- 验证方式
- 置信度:高 / 中 / 待验证

没有发现时写“未发现”。最后输出:
- 仍需人工关注的点:
- 未覆盖的检查项:
- 建议:阻断 commit / 待人工确认 / 通过 commit

我不再强迫 AI 禁用“可能”“或许”这类词,而是要求它给出置信度。不确定并不可怕,把不确定伪装成确定才危险。 没有文件、行号和证据的意见,我会直接归入噪音。

我处理审查结果时只做四种动作:

  1. 证据充分、触发条件明确: 修复问题,重新跑机器门,再审一次 diff。
  2. 信息不足但值得追查: 标记为“待验证”,补测试、日志或仓库上下文,不直接当成阻断结论。
  3. 对照代码后确认是误报: 记录不采纳的理由,避免下一轮重复争论。
  4. 涉及业务目标或权限边界: 交给领域负责人,AI 只能提供线索,不能替代决策。

如果我把这一步封装成 opencode 的自定义命令,可以放在项目的 .opencode/commands/commit-review.md

---
description: 审查当前 staged diff,只报告有证据的风险
agent: plan
---

你是一名 Go 项目的 commit 前审查助手。本次只审查,不修改。

审查以下变更:
!`git diff --staged --stat && git diff --staged`

遵循团队的 Go 审查规则。优先报告错误处理、并发与资源、安全边界、接口契约和范围越界问题。每条意见必须包含文件:行号、触发条件、证据、验证方式和置信度。

! 加反引号是 opencode 的 shell 插值语法,会在提交提示词前把命令输出插入上下文。这类插值会在 agent 权限流程之外执行,所以我只放入自己信任的、只读的命令,不把未经审查的参数拼进去。

四、opencode 的价值:把角色、权限和规则拆开

我使用 opencode,不是因为它“更会写代码”,而是因为它能把审查过程拆成几个可配置的角色。官方文档把 agent、command、skill、rule 和权限分别管理,这正好对应我对审查流程的需要。

角色或机制我让它做什么权限原则
plan作为审查主代理,阅读 staged diff 并输出报告禁止编辑; shell 默认询问
explore查找仓库内的调用链、相似实现和约束文档只读仓库
scout查看外部依赖的文档、版本和已知问题只读外部资料,结论附来源
general需要时做安全、性能等专项视角不默认启用,避免扩大权限和噪音
AGENTS.md保存团队长期规则进入 Git,短而具体
commands定义 /commit-review 这样的触发入口只编排动作,不堆所有规则
skills封装 Go 工具链和输出方式复用方法,按需加载

我会给审查主代理设置明确的权限边界。下面的配置重点是示意“编辑拒绝、读取 diff 可用”,实际的命令白名单要按自己的环境收紧:

{
  "$schema": "https://opencode.ai/config.json",
  "agent": {
    "plan": {
      "mode": "primary",
      "permission": {
        "edit": "deny",
        "bash": {
          "*": "ask",
          "git diff*": "allow",
          "git status*": "allow"
        }
      }
    }
  }
}

这里有一个容易误解的地方:plan 是工作模式,不等于所有命令都自动安全;真正决定能不能编辑、能不能执行 shell 的,是权限配置。我曾经在审查阶段使用可写的 build agent,结果它发现问题后直接替我改了代码,连修复本身都要重新排查。

  1. 让不同 agent 负责不同上下文。

我通常先让 plan 看 diff,再按需要调用 explorescout

@explore 查找 internal/order 中所有调用 OrderService.MarkPaid 的位置,
并找出与“订单支付幂等”相关的 README、AGENTS.md 和文档。

@scout 查看当前依赖的官方文档和变更记录,确认本次升级是否有兼容性变化,
不要只引用搜索摘要,给出可以复核的链接。

这样做的好处是,主审代理不需要一边猜仓库上下文、一边做风险判断。我把“找信息”和“下判断”分开后,审查意见明显更容易复核。

  1. 模型按任务难度分级,不要把版本写死。

我的经验是,仓库路径查找不需要最贵的模型,跨文件理解和安全专项才值得提高推理深度。可以按这个思路配置:

环节模型策略我关注的指标
仓库查找轻量、响应快是否找全调用点和规则文件
主审查上下文和工具调用能力优先是否能把风险落到证据
安全或性能专项必要时使用更强推理召回率是否值得额外成本

模型名称、可用 provider 和价格都会变化,我不把“最新版本”和固定费用写进团队规范。每次切换后,我会用 /model 确认实际模型;如果使用 variants,也会检查字段名和最终推理配置是否真的生效。我更关心每周的误报率、漏报案例和审查耗时,而不是一次调用的理论价格。

  1. 把规则放在项目里,而不是藏在个人记忆里。

我会把长期有效的规则放进项目根目录的 AGENTS.md,例如:

# AGENTS.md

## Commit 前审查纪律

1. 只读 staged diff,不修改代码;
2. 每条意见必须包含文件:行号、触发条件、证据和验证方式;
3. 不确定的内容标记“待验证”,不要伪装成结论;
4. 涉及业务不变量、权限和回滚的问题,必须留给人工确认。

## Go 运行时规则

1. 外部调用沿链路传递 context,不偷偷换成 context.Background();
2. 成功拿到资源后尽早安排释放,循环内的 defer 要特别检查;
3. goroutine 必须有退出路径,共享状态必须有明确的同步策略;
4. 外部输入、请求超时和响应体大小都要有边界。

规则不需要写成长篇风格手册。每条规则最好都能在几十秒内写出一个反例,否则它很可能还停留在口号层面。 CLAUDE.md 可以作为兼容入口,但我会优先使用项目级 AGENTS.md,避免两份规则同时存在时产生歧义。

  1. 用 skill 封装“怎么检查”。

command 解决“什么时候触发”,skill 解决“具体怎么做”。例如 .opencode/skills/go-quality/SKILL.md 可以定义:

---
name: go-quality
description: 按项目约定运行 Go 代码质量检查
license: MIT
---

## 检查顺序

1. gofmt -l .
2. go vet ./...
3. go test -race -count=1 ./...
4. staticcheck ./...
5. govulncheck ./...
6. gitleaks protect --staged --redact

## 输出要求

- 只报告非空结果;
- 记录每一步的退出状态;
- 不要用 `|| true` 吞掉失败;
- 环境缺少工具时明确写出,不要假装通过。

我会在 @go-quality 的结果出来后,再让 plan 解释高风险项,而不是让 AI 自己把工具失败改写成“检查通过”。LSP 也可以提供 gopls 的类型、引用和诊断信息,但它只是上下文来源,不是机器门的替代品

五、第三道门:人工只回答机器和 AI 回答不了的问题

当我走到人工 review 时,不再重复检查缩进和明显错误,而是把 diff 放回业务上下文里,依次问四个问题:

  1. 不变量还在吗? 原有 API 行为、权限边界、并发限制、数据一致性要求是否仍然成立?
  2. 异常路径完整吗? 空值、重复请求、超大输入、非法字符、超时和部分失败有没有明确行为?
  3. 资源生命周期清楚吗? 锁、文件、HTTP 响应、数据库 rows、goroutine 和 channel 谁创建、谁释放、谁负责关闭?
  4. 上线和回滚能执行吗? 是否需要灰度或 feature flag?数据库迁移能否回滚?出问题时用什么指标发现?

例如,下面这段代码单看每一行都很顺:

func (s *OrderService) MarkPaid(ctx context.Context, orderID string) error {
    order, err := s.repo.FindByID(ctx, orderID)
    if err != nil {
        return fmt.Errorf("find order: %w", err)
    }
    order.Status = "paid"
    return s.repo.Save(ctx, order)
}

但我把它放回支付回调的上下文后,会继续追问:

  1. 两个回调同时到达时,是否会重复写入?
  2. 已经是 paid 的订单再次调用时,是否应该幂等返回?
  3. 状态更新和审计记录是否需要同一个事务?
  4. Save 失败后,调用方能否安全重试?

这些问题不一定能从函数体本身得出答案,需要产品规则、数据模型和上线方案共同回答。人工 review 的价值不是再读一遍语法,而是判断这段代码有没有破坏系统原本承诺的行为。

六、让审查结果留下来:commit message 也是质量门

AI 审查如果只存在于一次 TUI 对话里,几天后就很难追溯。我会把结论摘要放入 PR 描述,并在 commit message 里留下变更原因、不变量、测试和风险。

下面是我会交给 AI 填充、再由自己确认的 commit message 模板:

<scope>: <一句话目标>

变更原因:
- 为什么需要改
- 由什么场景触发(issue、反馈或告警)

改动范围:
- 修改了哪些包、接口、配置或依赖
- 明确哪些内容没有改

不变量:
- 哪些 API、权限、数据一致性或性能行为不能改变

测试:
- 运行了哪些命令
- 是否覆盖失败路径、边界条件和并发场景

上线风险:
- 是否需要灰度或 feature flag
- 回滚动作是什么
- 用什么指标确认上线正常

我把 AI 自审报告附在 PR 的“AI 自审”区块里,至少保留:审查对象、使用的规则、工具结果、阻断项和人工未覆盖项。能被检索到、能被下一次复盘引用的审查,才算真正进入团队流程。

这里我有一个重要调整:机器检查适合放进 pre-commit,AI 自审是否放进 hook,要看团队是否接受网络依赖、模型耗时和服务失败。对我来说,AI 自审默认是一个明确执行的命令;如果要自动阻断 commit,就必须先定义超时、重试和人工放行规则,不能用 || true 偷偷软化。

七、我踩过的坑,以及 opencode 的边界

  1. 审查阶段不要使用可写 agent。 可写 agent 看到问题后很容易顺手修复,修复后的代码又变成新的审查对象。我要的是报告,不是未经确认的工作树变化。
  2. 不要用 || true 吞掉关键检查。 无论是 gitleaks、漏洞扫描还是 AI 命令,失败都应该让人看到。允许“失败但继续”之前,必须把结果写入可追溯的日志,并明确由谁决定是否放行。
  3. 不要把模型名称和费用当成永久事实。 provider、模型、字段名和价格都会变化;配置更新后要用实际命令确认,而不是只看配置文件里写了什么。
  4. AGENTS.md 不是越长越好。 我见过把抽象价值观、编码风格和所有历史事故都塞进去的规则文件,最后 AI 什么都读到了一点,却没有一条真正执行到位。规则应当短、具体、可验证。
  5. 不要只统计 AI 提了多少条意见。 我更看重高价值意见的比例、误报率、漏报案例和处理耗时。意见越多不等于审查越好。

opencode 也有清楚的边界:

  1. 它不能替我决定订单金额、租户隔离、权限层级等业务规则;
  2. 它不能替代 gofmt、测试、静态分析、漏洞扫描等确定性工具;
  3. 它看到的上下文取决于我提供的仓库、文档和调用链,没读到的事故复盘不会凭空出现在结论里;
  4. 它输出的“通过”不是合并授权,最终仍然要由人确认不变量、边界和回滚。

一个工具能清楚说明自己做不到什么,往往比一个声称什么都能做的工具更可靠。

给读者的启示

  1. 把 commit 当成一次小型发布。 先跑机器门,再做 AI 自审,最后由人结合业务上下文确认;顺序本身就是风险控制。
  2. 机器门先覆盖确定性问题。 Go 项目可以从 gofmtgo vetgo test -racestaticcheckgovulncheck 和 staged 密钥扫描开始,再按仓库实际情况调整。
  3. AI 自审要少而具体。 固定检查范围,要求文件:行号、触发条件、证据、验证方式和置信度;没有证据的意见就标成待验证。
  4. opencode 的核心价值是流程编排。plan 做主审,按需用 explore 查仓库、用 scout 查外部资料,再用 AGENTS.md、commands 和 skills 把规则与方法版本化。
  5. 不要让关键检查静默失败。 失败要可见,结果要可追溯,是否放行要由明确的人负责。
  6. commit message 也是一次自问。 如果我写不出不变量、测试范围和回滚方案,通常说明这次改动还没有被真正想清楚。

我现在的判断是:代码质量不是由 AI 写得多快决定的,而是由 commit 前的审查链路有多稳决定的。 opencode 这类工具真正有价值的地方,是让我可以把“谁来审、看什么、能不能改、用什么上下文、结果留在哪里”都配置清楚。AI 可以成为团队质量流程的一部分,但按下 commit 按钮的人,仍然必须知道自己在交付什么。

我参考的公开资料: