Go 服务用 AI 写代码:工具链白送了半套 harness,你只是没拧紧

Posted on 四 11 6月 2026 in Tech

Abstract Go 服务用 AI 写代码:工具链白送了半套 harness,你只是没拧紧
Authors Walter Fan
Category Tech
Status v0.1
Updated 2026-06-11
License CC-BY-NC-ND 4.0

Go 服务用 AI 写代码:工具链白送了半套 harness,你只是没拧紧

短大纲

  • 同样用 AI,Go 后端比 Spring Boot 那套好伺候——因为 Go 工具链白送了半套 harness
  • 但"白送"不等于"拧紧":多数团队连 go vet-race-cover 都没接进闸门
  • AI 在 Go 里真正爱翻的三块:吞错误、并发竞态、幻觉依赖
  • 第一步:把工具链白送的那半套拧到 CI 里,一条红就不许合
  • 第二步:补上缺的半套——AGENTS.md 上下文、internal 边界、depguard、表驱动测试、godog 行为契约
  • 一个从止血到治本的渐进顺序 + 可抄的配置、行动清单、检查清单

一、为什么 Go 服务更好伺候

我在上一篇《传统 Java 项目用 AI 写代码总翻车?先把 harness 修好》里有一句话:Spring Boot + MyBatis + MySQL + Kafka 这套,比一个小巧的 Go 服务难伺候得多。有读者追问:那 Go 项目的 harness 到底怎么修?这篇就来还这个债。

先抛个观点,可能有点反直觉:Go 项目修 harness,比 Java 容易,因为 Go 的工具链已经白送了半套。

gofmt 让格式没得吵——AI 再怎么发挥,gofmt -l 一过,风格全归一,省掉了 Java 世界里 Checkstyle/Spotless 那一整套配置仗。go vet 白送基础静态检查,go test 是语言内置而不是第三方框架,go test -race 直接给你一个竞态检测器,go test -cover 顺手就出覆盖率。go build ./... 编不过就是编不过——这本身就是拦截"幻觉 API"的第一道墙。

这些在 Java 里都得额外攒:JUnit、JaCoCo、各种 lint 插件、还得在 Maven/Gradle 里拼半天。Go 把它们塞进了一个命令行工具里,开箱即用。

但白送的,不等于拧紧的。 我见过太多 Go 项目,工具链躺在那儿,CI 里却只跑了一句 go test ./...——-race 没开、覆盖率不卡、go vet 看心情、golangci-lint 没接。等于发动机和护栏都白送给你了,你却只拧上了一颗螺丝就上路。AI 一上来,那些没拧紧的地方就开始漏。

所以这篇的主线只有两句:先把白送的那半套拧紧,再补上缺的那半套。

harness 是什么,这个系列前两篇已经讲透了,这里只用一句话复述:harness 就是你给 AI 准备的工作环境和约束系统,让它即使失忆、看不到全局、不为线上故障负责,也能干出靠谱的活。 想看完整定义和 Java 落地,去翻那篇


二、AI 在 Go 里真正爱翻的三块

Java 的难,难在隐性知识密度高(事务代理、XML 散落、Kafka 幂等)。Go 不一样,Go 的代码大多"上下文自足",隐性规矩少。但这不代表 AI 在 Go 里就不翻车,它只是翻在别处。按我的观察,AI 写 Go 最爱翻的是这三块——而且每一块,Go 工具链刚好都有对症的药。

1. 吞错误:Go 的命根子,AI 的盲区

Go 没有异常,错误就是个返回值,全靠 if err != nil 的纪律撑着。AI 偏偏最爱在这儿偷懒:

// AI 经常写出来的三种"吞错误"
data, _ := json.Marshal(req)        // 忽略 err
resp, err := http.Get(url)
defer resp.Body.Close()             // resp 可能是 nil,err 没判先 defer
if err != nil { panic(err) }        // 在库代码里 panic,等于埋雷

更隐蔽的是丢掉错误链:本该 fmt.Errorf("query order: %w", err) 把底层错误包进去,AI 写成 errors.New("query failed"),上游再也 errors.Is 不到根因,排查时一脸懵。

2. 并发竞态:跑起来都对,压上去就崩

Go 把并发做得太顺手了,go func(){} 一写就是一个 goroutine。AI 也学得很快,但它对"共享内存要加锁""goroutine 要有退出路径""context 要一路传下去"这些没有切肤之痛:

// 典型翻车:闭包里并发写同一个 map
results := map[string]int{}
for _, id := range ids {
    go func(id string) {
        results[id] = fetch(id)   // data race!map 并发写直接 panic 或脏数据
    }(id)
}
// 还少了 WaitGroup,主协程根本没等它们完成

这种 bug 最毒的地方在于:单测跑一遍是绿的,本地点几下也正常,一上线在并发压力下才崩。人眼 review 经常看不出来。

3. 幻觉依赖:编一个看起来很像的包

AI 训练数据里见过太多库,于是它会自信地 import 一个根本不在你 go.mod 里的包,或者调一个标准库里压根不存在的函数(strings.ReverseString?没有这个东西)。再或者,为了实现一个本可以用标准库三行搞定的功能,它顺手给你引入一个庞大的第三方依赖。

Go 社区有很强的"能用标准库就别加依赖"的文化,AI 不懂这个分寸,容易把你的 go.mod 喂胖。

顺带一块:破坏 internal 边界

Go 用 internal/ 目录和包结构来表达边界。AI 改大功能时,最容易随手 import 了不该碰的内部包,把本该解耦的模块连成一团。

把这四点列张表,对应的药也就清楚了:

AI 在 Go 爱翻的 后果 对症的工具
吞错误 / 丢错误链 故障无声,排查困难 errcheckerrorlintgo vet
并发竞态 / goroutine 泄漏 线上偶发,难复现 go test -racego vet
幻觉依赖 / 幻觉 API 编不过,或 go.mod 变胖 go build ./...depguard、code review
破坏 internal 边界 模块缠绕、爆炸半径大 depguardgo-arch-lint

你会发现:前两块 Go 工具链白送的就能治,后两块需要你额外补。 这正好对应下面两节。


三、第一步:把白送的那半套拧紧

别急着上花活。修 Go 项目的 harness,性价比最高的第一步,是把工具链白送的东西接进 CI、变成会拦人的红灯。不接闸门,它们就只是躺在文档里的好意。

1. gofmt:格式不许吵架

gofmt 不是"建议格式化",是"必须格式化"。在 CI 里加一句,有未格式化的文件就失败:

# 列出所有未按 gofmt 格式化的文件;非空就让 CI 失败
test -z "$(gofmt -l .)"

好处不止是好看:格式统一后,AI 改动的 diff 才干净,review 时一眼能看出它到底改了什么逻辑,而不是被一堆缩进噪音淹没。

2. go vet:白送的基础静态检查

go vet ./... 能抓出一票低级错误:Printf 占位符对不上、struct tag 写错、复制了带锁的结构体、明显的不可达代码。这是零配置的,AI 写出来的低级毛病很多能在这儿拦住。

3. go build ./...:治幻觉 API 和幻觉依赖

听起来废话,但很多 CI 只跑测试不单独 build,而测试可能没覆盖到的文件里,AI 编的那个不存在的函数就溜过去了。go build ./... 强制全量编译——幻觉 API 在这一步必死。再配一句 go mod tidy 后看 git diff 有没有变化,能发现 AI 偷偷加的依赖。

4. go test -race:白送的竞态检测器

这是 Go 最被低估的宝贝。把测试用 -race 跑,竞态检测器会在运行时盯着内存访问,逮到并发读写同一块内存就报警,精确到哪个 goroutine、哪一行

go test -race ./...

对前面说的"并发翻车",这几乎是唯一可靠的自动防线。代价是测试慢几倍——但 CI 里慢这几倍,换来线上不被偶发竞态半夜叫醒,太值了。前提是:你得有能触发并发路径的测试,否则 -race 也无从发现(这就接到了第四节的表驱动测试)。

5. go test -cover:覆盖率卡一条线

go test -race -coverprofile=cover.out ./...
go tool cover -func=cover.out | tail -1   # 看 total 覆盖率

覆盖率不是越高越好,但"低于某条线就构建失败"能逼住底线。具体卡多少看项目,核心业务包可以单独卡高一点。

这一步的本质,是把"工具链能做但没人接"的东西,全部接进一道 CI 闸门。 一个最小可用的 Makefile 目标长这样:

.PHONY: verify
verify:
    test -z "$$(gofmt -l .)"     # 格式
    go vet ./...                  # 基础静态
    go build ./...                # 幻觉 API/依赖必死
    go test -race -cover ./...    # 竞态 + 覆盖率

光这一个 make verify 接进 CI,就已经把 AI 在 Go 里最爱翻的"吞错误的一部分 + 并发竞态 + 幻觉 API"挡掉一大半了。而它几乎是零成本的——这些工具你早就装了,只是没拧紧。


四、第二步:补上缺的那半套

白送的拧紧之后,剩下的短板得自己补:上下文、边界、规约、行为契约,以及一个能管住错误处理 / 依赖 / 安全的 meta-linter。沿用这个系列的"拼图"视角,挨个落到 Go 里。

1. PKB / AGENTS.md:把 Go 的约定写给 AI 看

Go 的隐性知识比 Java 少,但不是没有:错误怎么包、context 怎么传、并发怎么收口、依赖什么时候才允许加——这些都是你团队的"不成文规矩"。在仓库根目录放一个 AGENTS.md(或 CLAUDE.md),别写正确的废话,专写踩过坑的规矩:

# AGENTS.md — order-service (Go)

## 系统地图
- cmd/server      程序入口,只做装配,不写业务
- internal/order  订单域:下单、查询
- internal/refund 退款域:本次要改的就是这块
- internal/platform  基础设施:db、kafka、http client 封装

## 必须遵守的约定(踩过坑的)
1. 错误一律 fmt.Errorf("...: %w", err) 往上包,禁止吞错(不许 _ = err)。
2. 库代码禁止 panic;panic 只允许出现在 main/初始化的"起不来就该死"场景。
3. 所有对外调用(DB/HTTP/Kafka)第一个参数必须是 context.Context,并真正传下去。
4. 并发写共享状态必须加锁或用 channel;每个 goroutine 必须有明确的退出路径。
5. 金额用 int64 以"分"为单位,禁止 float64。
6. 能用标准库就别加依赖;加任何第三方包要在 PR 里说明理由。
7. 跨域只能 import 对方的接口包,禁止 import 对方的 internal 实现。

## 标准样板
改写链路前先读 internal/refund/service.go 的 Refund(),照这个结构来:
入参带 ctx → 校验 → 业务 → 落库 → 发消息,每步错误都 %w 包好。

三件事最值得写:系统地图(哪个目录干什么)、踩过坑的约定(错误/panic/context/并发/金额/依赖)、一个可抄的样板函数。AI 不是检索引擎,改退款时直接把 internal/refund/ 下相关文件拍给它,比让它"自己去仓库找"靠谱得多。

2. 边界:用 internal/ + depguard 把分层焊死

Go 天生有个好东西:internal/ 目录下的包,只能被其父目录及其子树 import,编译器级别拦截外部依赖。先利用好它,按域分包,让目录结构本身就是边界:

order-service/
├── cmd/server/         # 入口
└── internal/
    ├── order/          # 订单域
    ├── refund/         # 退款域(本次要改)
    │   ├── api/        # HTTP handler
    │   ├── service/    # 业务
    │   └── store/      # DB 访问
    └── fulfillment/    # 履约域:退款只能通过接口通知它

internal/ 只能挡"包外访问包内",挡不住"同一个 module 内部,退款域偷偷 import 履约域的实现"。这时候用 depguard(golangci-lint 内置的一个 linter)把跨域、跨层依赖写成会失败的规则:

# .golangci.yml 片段(depguard 配置在不同版本语法略有差异,按你的版本微调)
linters-settings:
  depguard:
    rules:
      refund-domain:
        files:
          - "**/internal/refund/**"
        deny:
          - pkg: "order-service/internal/fulfillment/store"
            desc: "退款域不许直连履约实现,只能走 fulfillment 的接口"
      service-layer:
        files:
          - "**/internal/*/service/**"
        deny:
          - pkg: "net/http"
            desc: "service 层不许碰 http,HTTP 细节留在 api 层"

这相当于 Java 世界里 ArchUnit 干的活(参见那篇)。如果想要更完整的分层 / 依赖方向校验,可以上专门的 go-arch-lint,用一个 yaml 声明组件和允许的依赖方向:

# .go-arch-lint.yml(示意,按工具版本调整)
version: 3
workdir: internal
components:
  api:     { in: "*/api" }
  service: { in: "*/service" }
  store:   { in: "*/store" }
deps:
  api:     { mayDependOn: [service] }
  service: { mayDependOn: [store] }
  store:   { mayDependOn: [] }          # store 不许反向依赖

go-arch-lint check,反向调用、跨层依赖立刻红。边界一旦被测试 / linter 焊死,AI 即使犯错,爆炸半径也被关在一个房间里

3. SDD + 表驱动测试:把大功能拆成"测试即题面"

Go 写大功能也会翻车,原因和 Java 一样:你给的是个大需求,AI 只能边猜边写。解法不变——先写一页规约,拆成小任务,再让 AI 逐个实现,每个任务配一个测试。

Go 的杀手锏是表驱动测试(table-driven test),它天然适合把"验收标准"一条条钉死。与其用自然语言反复跟 AI 描述边界 case,不如把它们摆成一张表,这就是给 AI 的最精确题面:

func TestRefund(t *testing.T) {
    tests := []struct {
        name    string
        order   Order
        amount  int64
        wantErr error
    }{
        {"全额退款成功", paidOrder(100_00), 100_00, nil},
        {"未支付订单不可退", unpaidOrder(), 100_00, ErrNotRefundable},
        {"退款额超过可退额", paidOrder(100_00), 200_00, ErrAmountExceeded},
        {"重复退款应幂等", refundedOrder(100_00), 100_00, ErrAlreadyRefunded},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            svc := newRefundService(tt.order)
            err := svc.Refund(context.Background(), tt.amount)
            if !errors.Is(err, tt.wantErr) { // 注意 errors.Is,配合 %w 才管用
                t.Fatalf("Refund() err = %v, want %v", err, tt.wantErr)
            }
        })
    }
}

HTTP handler 用标准库 net/http/httptest 测,不用起真服务:

func TestRefundHandler(t *testing.T) {
    body := strings.NewReader(`{"amount":10000}`)
    req := httptest.NewRequest(http.MethodPost, "/orders/1001/refund", body)
    rec := httptest.NewRecorder()

    newRouter(stubRefundService{}).ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
    }
}

把这些测试先写好、丢给 AI:"让这些测试在 go test -race 下变绿。"它就有了客观的成功标准,不再自我感觉良好;改坏了别处,对应的红灯立刻亮。对老代码,则反过来用:让 AI 重构前,先给现有逻辑补一层"特征测试"(characterization test),把当前行为固化下来,重构后只要还绿,就说明行为没变。护栏跟着战线走,你让 AI 动哪块,就先给哪块织网。

4. BDD:给最怕错的业务流写 godog 契约

技术正确靠表驱动测试,业务正确有时还得一层。Go 里有 godog(Cucumber 的官方 Go 实现),用 Given/When/Then 把关键业务流写成几乎是大白话的场景,特别适合状态机、消息幂等这类"边界一堆、最容易扯皮"的地方:

# refund.feature
场景: 重复收到退款消息时不能退两次
  假如 订单 1001 已经退款成功
   系统再次收到订单 1001 的退款消息
  那么 不应再发起一次退款
  并且 账户余额保持不变

step 定义写一次,之后场景复用。这种场景对 AI 极友好:它把重复消息、乱序、超时这些容易被漏掉的边界显式摆上台面,AI 照着实现即可,不用猜业务语义。别全项目铺开——只给最怕错、最难讲清的那几条核心流写就够本。

5. 度量闸门:golangci-lint 一把梭

最后一块拼图,是把上面这些 + Go 特有的坑,统统接进一个会拦人的 meta-linter。Go 生态里这件事的标准答案就是 golangci-lint——它把几十个 linter 打包成一个命令,跑得快、配置集中。挑对症的几个开起来:

# .golangci.yml(最小可用版,linter 名称按你的版本核对)
run:
  timeout: 5m
linters:
  enable:
    - errcheck       # 没处理的 error 直接报错 —— 治"吞错误"
    - errorlint      # 强制正确用 %w / errors.Is
    - govet          # 基础静态
    - staticcheck    # 强大的静态分析
    - gosec          # 安全扫描
    - bodyclose      # http resp.Body 忘了 Close
    - sqlclosecheck  # sql.Rows 忘了 Close
    - noctx          # 发请求没带 context
    - contextcheck   # context 没一路传下去
    - depguard       # 依赖 / 分层约束(见上)
    - revive         # 风格 / 命名约定

这里面每一个,几乎都精准对着 AI 在 Go 里的某个毛病:errcheck/errorlint 治吞错误和丢错误链,bodyclose/sqlclosecheck 治资源泄漏,noctx/contextcheck 治 context 断链,depguard 治越界依赖,gosec 治安全坑。把脑子里的约定,变成 AI 绕不过去的红灯。

老项目别慌一次性全红:golangci-lint 支持 --new-from-rev,只检查相对某个 commit 的新增改动——老债先冻住,新债一分不许欠。和 ArchUnit 的 FreezingArchRule 是同一个思路。


五、把闸门串起来:一份能抄的 CI

前面所有努力,最后都要落到一道任意一条红就不许合并的闸门上。否则约定再多,AI(和赶工期的人)总能绕过去偷偷上线。

诀窍是:先把所有检查收敛进一个 Makefile,本地和 CI 共用同一套命令。 这样"我本地是绿的,怎么 CI 红了"这类扯皮就没了。一份能直接抄的 Makefile

# Makefile —— 把前面的方法串成可执行的闸门
COVER_MIN ?= 70          # 覆盖率阈值
NEW_FROM  ?= origin/main  # 老项目增量 lint 的基线

.DEFAULT_GOAL := verify

# ---- 第一步:拧紧白送的那半套 ----
fmt:                       ## 检查 gofmt,有未格式化文件就失败
    @u="$$(gofmt -l .)"; [ -z "$$u" ] || { echo "未格式化: $$u"; exit 1; }

vet:                       ## 基础静态检查
    go vet ./...

build:                     ## 全量编译,幻觉 API/缺失依赖必死
    go build ./...

tidy-check:                ## go.mod/go.sum 不许被偷偷改,揪出多余依赖
    go mod tidy
    @git diff --exit-code go.mod go.sum

test:                      ## 竞态检测 + 生成覆盖率
    go test -race -coverprofile=cover.out -covermode=atomic ./...

cover: test                ## 覆盖率低于阈值就失败
    @t=$$(go tool cover -func=cover.out | awk '/^total:/{gsub(/%/,"",$$3);print $$3}'); \
    echo "coverage: $$t%"; \
    awk -v t="$$t" -v m="$(COVER_MIN)" 'BEGIN{exit (t+0<m+0)}' || \
        { echo "覆盖率 $$t% 低于 $(COVER_MIN)%"; exit 1; }

# ---- 第二步:补缺的那半套 ----
lint:                      ## golangci-lint 总闸门
    golangci-lint run

lint-new:                  ## 老项目只拦新增违规
    golangci-lint run --new-from-rev=$(NEW_FROM)

arch:                      ## 架构/分层边界校验
    go run github.com/fe3dback/go-arch-lint@latest check

# ---- 聚合闸门:CI 直接调它 ----
verify: fmt vet build tidy-check lint arch cover  ## 合并前过一遍
    @echo "all checks passed"

verify-new: fmt vet build tidy-check lint-new arch cover  ## 老项目专用

.PHONY: fmt vet build tidy-check test cover lint lint-new arch verify verify-new

平时本地随手跑 make test / make lint,提交前跑 make verify;老项目先用 make verify-newlint 只拦新增、老债慢慢还。可调参数:make verify COVER_MIN=80

CI 这边就薄薄一层,把环境装好然后调 make verify

# .github/workflows/verify.yml
name: verify
on: [push, pull_request]
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.22' }
      - run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
      - run: make verify

GitLab CI 同理,一个 verify job 里跑 make verify 即可。如果你不想用 make 当中间层,也可以把每步拆成独立的 CI step,本质一样——一份完全展开的 GitHub Actions 长这样:

# .github/workflows/verify.yml
name: verify
on: [push, pull_request]
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.22' }

      - name: 格式
        run: test -z "$(gofmt -l .)"

      - name: 依赖没被偷偷改
        run: go mod tidy && git diff --exit-code go.mod go.sum

      - name: 编译(幻觉 API 必死)
        run: go build ./...

      - name: 静态 + 安全 + 依赖约束
        uses: golangci/golangci-lint-action@v6

      - name: 竞态 + 覆盖率
        run: go test -race -cover ./...

      - name: 架构边界
        run: go run github.com/fe3dback/go-arch-lint@latest check

关键不在用 make 还是裸 CI、用 GitHub 还是 GitLab,而在这几道检查全绿才允许合并——这是整套 harness 真正生效的开关。


六、别一口气全上:渐进落地顺序

看到一堆工具就想全装,是最容易劝退团队的做法。给一个从止血到治本的顺序,每一步都该让 AI 干活的成功率肉眼可见地往上走:

  1. 拧紧白送的(半天,ROI 最高)gofmt -l + go vet + go build ./... + go test -race -cover 接进 CI。先把这半套白嫖到位。
  2. golangci-lint 上场:先开 errcheckerrorlintbodyclosegosec 这几个最对症的,老项目用 --new-from-rev 只拦新增。
  3. AGENTS.md:系统地图 + 错误/context/并发/依赖的约定 + 一个样板函数。
  4. internal/ 边界:按域分包,用 depguard 把跨域、跨层依赖焊死。
  5. SDD + 表驱动测试:从此大功能先写规约、拆任务,每个任务配表驱动测试再交给 AI。
  6. godog 补关键业务流:只给最怕错的那几条(消息幂等、状态机)写 Given/When/Then。

前两步是纯白嫖,今天就能做完;后四步按项目节奏补。


总结

回到开头那个反差:同样用 AI,为什么 Go 服务比 Spring Boot 那套省心?因为 Go 的工具链把测试、竞态检测、格式化、静态检查全塞进了一个命令行里,白送了半套 harness

可白送不等于拧紧。多数 Go 项目的问题不是缺工具,而是工具躺在原地没接进闸门——-race 没开、覆盖率不卡、golangci-lint 没装、internal 边界没人守。AI 一来,这些松动的螺丝就开始漏。

所以这篇就两句话:第一步,把白送的那半套拧紧——gofmtgo vetgo buildgo test -race -cover,全接进 CI;第二步,补上缺的那半套——AGENTS.md 给上下文、internal + depguard 给边界、表驱动测试给红绿灯、godog 给业务契约、golangci-lint 当总闸门。

说到底,这和 Java 篇是同一份功夫:让 AI 写好 Go 代码的功夫,和让一个 Go 团队写好代码的功夫,是同一份功夫。 你为 AI 拧紧的每一颗螺丝,最后受益的也是每一个活人。

思维导图

@startmindmap
* Go 服务的 AI harness
** 为什么好伺候
*** 工具链白送半套
*** gofmt/vet/race/cover 开箱即用
*** 白送≠拧紧
** AI 爱翻的三块
*** 吞错误/丢错误链
*** 并发竞态/goroutine 泄漏
*** 幻觉依赖/幻觉 API
*** (顺带) 破坏 internal 边界
** 第一步 拧紧白送的
*** gofmt -l 格式
*** go vet 静态
*** go build 治幻觉API
*** go test -race 竞态
*** go test -cover 覆盖率
** 第二步 补缺的半套
*** AGENTS.md 上下文
*** internal + depguard 边界
*** SDD + 表驱动测试
*** godog 行为契约
*** golangci-lint 总闸门
** 落地顺序
*** 1 拧紧白送
*** 2 golangci-lint
*** 3 AGENTS.md
*** 4 internal 边界
*** 5 SDD+表驱动
*** 6 godog 补业务
@endmindmap

Go 服务 AI harness 思维导图

行动清单(今天就能做前两条)

  1. 在 CI 里加一道 make verifygofmt -l 非空即失败、go vet ./...go build ./...go test -race -cover ./...
  2. go mod tidy && git diff --exit-code go.mod go.sum,揪出 AI 偷偷加的依赖。
  3. golangci-lint,先开 errcheckerrorlintbodyclosegosec,老项目用 --new-from-rev 只拦新增。
  4. 写一个 AGENTS.md:系统地图 + 错误/context/并发/依赖约定 + 一个样板函数。
  5. depguardgo-arch-lint 加一条会失败的边界规则(如"service 层不许 import net/http")。
  6. 把下一个大功能先写成一页规约 + 表驱动测试,再分小任务交给 AI。

检查清单(合并前过一遍)

  • [ ] gofmt -l . 输出为空
  • [ ] go vet ./... 通过
  • [ ] go build ./... 通过(无幻觉 API / 缺失依赖)
  • [ ] go mod tidygo.mod/go.sum 无变化(无偷加依赖)
  • [ ] go test -race ./... 通过(无竞态)
  • [ ] 覆盖率不低于约定阈值
  • [ ] golangci-lint run 通过(errcheck/errorlint/bodyclose/gosec 等)
  • [ ] 边界规则(depguard / go-arch-lint)通过
  • [ ] 所有错误都用 %w 包装,库代码无 panic、无 _ = err
  • [ ] 对外调用都带 context.Context 并真正传递
  • [ ] 关键业务流有表驱动测试或 godog 场景覆盖边界 case

扩展阅读

附录:一份完整的 AGENTS.md 示例

正文「第二步 → PKB」那节给的是骨架版,这里补一份贴着上面 order-service 的完整版,可直接抄去改。它和本文的目录结构、make verify、Go 约定一一对应,刻意压在 100 行以内——AGENTS.md 短到能一口气读完,AI 才会真读。

# AGENTS.md — order-service

Go 后端服务,负责订单与退款。人看的总览在 README.md;
深层架构与 runbook 在 man/ 和 docs/adr/,本文件不复述,只给链接。

## Context Map
- `cmd/server/` — 程序入口,只做装配,禁止写业务逻辑
- `internal/order/` — 订单域:下单、查询
- `internal/refund/` — 退款域:`api/`(HTTP)、`service/`(业务)、`store/`(DB)
- `internal/fulfillment/` — 履约域:退款只能通过其接口通知,禁止直连实现
- `internal/platform/` — 基础设施:db、kafka、http client 封装
- 深入阅读:架构见 `man/index.md`,关键决策见 `docs/adr/`

## Commands
- 拉依赖:`go mod download`
- 构建:`make build`(= `go build ./...`,幻觉 API/缺依赖必死)
- 测试:`make test`(= `go test -race -cover ./...`- 静态/安全/依赖闸门:`make lint`(= `golangci-lint run`- 提交前全量闸门:`make verify`
- 跑单个测试:`go test -race -run TestRefund ./internal/refund/...`

## Harness Rules
- 不许编造:不臆造包、函数、文件、命令或运行结果;不确定就说不确定。
- 重大歧义先问:当某个选择会改变行为/接口/数据时,先问再写。
- 先想后写:多步改动先说清假设和简短计划。
- 简单优先:用解决问题的最小代码,不做没要求的抽象与配置项。
- 外科手术式改动:只动任务需要的地方,沿用现有风格,不顺手重构无关代码。
- 完工先自证:跑 `make verify` 并报告结果;没绿不许声称"做完了"。

## Project Rules(Go 专属,踩过坑的)
- 错误:一律 `fmt.Errorf("...: %w", err)` 往上包;禁止 `_ = err`。库代码禁止 `panic`(只允许 main/init)。
- context:所有 DB/HTTP/Kafka 调用第一参数必须是 `ctx context.Context`,且真正传下去。
- 并发:共享状态必须加锁或走 channel;每个 goroutine 必须有退出路径。测试一律 `-race` 跑。
- 金额:`int64` 以"分"为单位,禁止 `float64`- 依赖:能用标准库就别加第三方;新增依赖需在 PR 说明理由。改完跑 `go mod tidy``go.mod`/`go.sum` 的 diff 必须是有意的。
- 边界:跨域只走接口,禁止 import 别的域的 `store`/内部实现;由 depguard 强制。
- 分层:HTTP 细节留在 `api`,业务在 `service`,DB 在 `store``service` 不许 import `net/http`- 日志:用结构化日志;禁止打手机号/身份证/卡号等 PII。

## 标准样板
改写链路前先读 `internal/refund/service.go``Refund()`,照它的结构来:
入参带 `ctx` → 校验 → 业务 → 落库 → 发消息,每步错误都 `%w` 包好。

## AI Tooling
- 主要面向:Codex / Claude Code / Cursor。
- Cursor 规则放在 `.cursor/rules/`- 可选:把 `CLAUDE.md``GEMINI.md` 软链到本文件(`ln -s AGENTS.md CLAUDE.md`)。

## Keeping Current
- 触发更新:新增域/包、命令变化、或出现需要新护栏的 AI 反复犯错时。
- 学习闭环:同一个问题纠正 AI 两次以上,就在这里加一条规则;同时删掉过时规则,保持文件短到能读完。

附录:一份完整的 .golangci.yml 示例

正文「第二步 → 度量闸门」给的是最小可用版,这里补一份带 linters-settingsdepguard 分层规则和测试豁免的完整版。

提醒一句:golangci-lint 的配置 schema 跨版本有调整(尤其 depguard 的规则语法、以及 v2 引入的顶层 versionlinters.settings 等)。下面这份按当下主流的 v1 风格写,落地前请用 golangci-lint --version 对应的官方文档核对一遍,别照抄。

# .golangci.yml — order-service
run:
  timeout: 5m
  tests: true            # 测试文件也检查

linters:
  # 不用默认集,显式开启,避免版本升级时行为漂移
  disable-all: true
  enable:
    - errcheck       # 没处理的 error 直接报错 —— 治"吞错误"
    - errorlint      # 强制正确用 %w / errors.Is / errors.As
    - govet          # 基础静态检查
    - staticcheck    # 强大的静态分析
    - ineffassign    # 无效赋值
    - unused         # 未使用的代码
    - gosec          # 安全扫描
    - bodyclose      # http resp.Body 忘了 Close
    - sqlclosecheck  # sql.Rows/Stmt 忘了 Close
    - rowserrcheck   # 忘了检查 rows.Err()
    - noctx          # 发 HTTP 请求没带 context
    - contextcheck   # context 没一路传下去
    - depguard       # 依赖 / 分层约束
    - gocritic       # 一批有用的代码风格/性能检查
    - revive         # 可配置的风格 / 命名约定
    - misspell       # 拼写错误

linters-settings:
  errcheck:
    check-type-assertions: true   # v, ok := x.(T) 也要检查
    check-blank: true             # 禁止用 _ 吞掉 error

  govet:
    enable-all: true
    disable:
      - fieldalignment            # 结构体字段对齐太吵,关掉

  gosec:
    excludes:
      - G104                      # 和 errcheck 重复,避免双重报错

  revive:
    rules:
      - name: error-return        # error 必须是最后一个返回值
      - name: error-naming        # error 变量命名规范
      - name: context-as-argument # context 必须是第一个参数
      - name: unreachable-code

  depguard:
    rules:
      # 退款域不许直连履约实现,只能走接口
      refund-domain:
        files:
          - "**/internal/refund/**"
        deny:
          - pkg: "order-service/internal/fulfillment/store"
            desc: "退款域不许直连履约实现,只能走 fulfillment 的对外接口"
      # service 层不许碰 HTTP,HTTP 细节留在 api 层
      service-layer:
        files:
          - "**/internal/*/service/**"
        deny:
          - pkg: "net/http"
            desc: "service 层不许依赖 net/http,HTTP 处理留在 api 层"
      # 全局禁用项:金额禁用浮点、日期统一用 time
      global:
        deny:
          - pkg: "math/big"
            desc: "金额请用 int64(分),不要引入 big 计算"

issues:
  max-issues-per-linter: 0        # 不限制,全部暴露
  max-same-issues: 0
  exclude-rules:
    # 测试文件放宽:允许忽略错误、允许 gosec 的部分告警
    - path: _test\.go
      linters:
        - errcheck
        - gosec
        - bodyclose
  # 老项目落地用:只拦相对基线的新增违规,老债先冻住
  # 命令行用 golangci-lint run --new-from-rev=origin/main 也可,二选一
  # new-from-rev: origin/main

几个落地要点:

  • disable-all: true + 显式 enable,是为了让规则集稳定——升级 golangci-lint 时不会因为默认集变化而突然多出一堆红,这对"规则即纪律"很重要。
  • errcheckcheck-blank,专门治 AI 爱写的 _ = err
  • depguarddesc 会出现在报错里,等于拦人时顺手告诉 AI 该怎么改
  • 测试文件用 exclude-rules 放宽,避免为了过 lint 把测试写得别扭。
  • 老项目优先用 --new-from-rev(或 new-from-rev)只拦新增,配合 make lint-new

附录索引

三份可直接复用的示例,方便你提取出来建 gist 或拷进项目:


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。