Go 开发 AI 辅助 Code Review 工具——调用 LLM 自动审查 Git diff
Go 开发 AI 辅助 Code Review 工具——调用 LLM 自动审查 Git diff
适读人群:Go 开发者、想用 AI 提升代码质量的工程师、CI/CD 工具链建设者 | 阅读时长:约 18 分钟 | 核心价值:用 Go 调用 Claude API,实现集成到 CI 流水线的自动化 Code Review 工具,含成本分析
去年年底,我们团队做了一次复盘,发现 30% 的线上 bug 是在 Code Review 时应该能发现但没发现的——要么是 reviewer 太忙看得粗心,要么是某些模式的问题需要经验积累才能识别。
当时正好在研究 Claude API,我想了个周末项目:写一个 Go 工具,自动对每个 PR 的 diff 跑一次 AI Review,把发现的问题以评论形式发到 GitHub PR 上。
结果在团队里跑了两个月,工具确实发现了一些有价值的问题,特别是一些潜在的并发 bug 和错误处理遗漏。这篇文章把完整实现写出来。
工具设计
git diff (本地) 或 GitHub PR diff
↓
按文件分割
↓
每个文件 diff → Claude API → Review 意见
↓
汇总 → 输出到终端 / GitHub PR 评论核心实现
package reviewer
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
anthropic "github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
)
// ReviewConfig Code Review 配置
type ReviewConfig struct {
Model string
MaxTokensPerFile int // 每个文件的 diff 最多多少 tokens
Language string // 代码语言(go / java / python...)
FocusAreas []string // 重点关注领域
Severity string // 最低报告级别:info / warning / error
}
var DefaultGoConfig = ReviewConfig{
Model: "claude-3-5-sonnet-20241022",
MaxTokensPerFile: 4000,
Language: "go",
FocusAreas: []string{
"并发安全(goroutine, mutex, channel)",
"错误处理(error 是否被忽略,错误信息是否清晰)",
"资源泄漏(file, connection, goroutine 是否正确关闭)",
"SQL 注入和安全问题",
"边界条件和 nil 检查",
"性能问题(不必要的内存分配,O(n²) 操作)",
},
Severity: "warning",
}
// FileDiff 单个文件的 diff
type FileDiff struct {
Filename string
OldPath string
NewPath string
Status string // added / modified / deleted
Diff string
LinesAdded int
LinesRemoved int
}
// ReviewComment 单条 Review 意见
type ReviewComment struct {
Filename string
LineNo int // 0 表示文件级别评论
Severity string // info / warning / error / critical
Category string // 问题类别
Message string
Suggestion string
}
// ReviewResult 整体 Review 结果
type ReviewResult struct {
PRTitle string
TotalFiles int
ReviewedFiles int
Comments []ReviewComment
Summary string
InputTokens int
OutputTokens int
CostUSD float64
}
// Reviewer Code Review 工具
type Reviewer struct {
client *anthropic.Client
config ReviewConfig
}
func NewReviewer(config ReviewConfig) *Reviewer {
return &Reviewer{
client: anthropic.NewClient(option.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY"))),
config: config,
}
}
// ReviewDiff 对 git diff 内容进行 Review
func (r *Reviewer) ReviewDiff(ctx context.Context, diffs []FileDiff) (*ReviewResult, error) {
result := &ReviewResult{
TotalFiles: len(diffs),
}
for _, diff := range diffs {
if shouldSkipFile(diff.Filename) {
continue
}
comments, inputTokens, outputTokens, err := r.reviewFileDiff(ctx, diff)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to review %s: %v\n", diff.Filename, err)
continue
}
result.ReviewedFiles++
result.Comments = append(result.Comments, comments...)
result.InputTokens += inputTokens
result.OutputTokens += outputTokens
}
// 计算成本(claude-3-5-sonnet-20241022 价格)
result.CostUSD = float64(result.InputTokens)*3.0/1_000_000 +
float64(result.OutputTokens)*15.0/1_000_000
// 生成总体摘要
summary, err := r.generateSummary(ctx, result)
if err == nil {
result.Summary = summary
}
return result, nil
}
func (r *Reviewer) reviewFileDiff(ctx context.Context, diff FileDiff) ([]ReviewComment, int, int, error) {
// 如果 diff 太长,截断
diffContent := diff.Diff
if estimateTokens(diffContent) > r.config.MaxTokensPerFile {
diffContent = truncateDiff(diffContent, r.config.MaxTokensPerFile)
}
prompt := r.buildPrompt(diff.Filename, diffContent)
resp, err := r.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.F(anthropic.Model(r.config.Model)),
MaxTokens: anthropic.F(int64(2048)),
System: anthropic.F([]anthropic.TextBlockParam{
{
Type: anthropic.F(anthropic.TextBlockParamTypeText),
Text: anthropic.F(r.buildSystemPrompt()),
},
}),
Messages: anthropic.F([]anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
}),
})
if err != nil {
return nil, 0, 0, err
}
responseText := resp.Content[0].(anthropic.TextBlock).Text
comments := parseReviewResponse(diff.Filename, responseText)
return comments,
int(resp.Usage.InputTokens),
int(resp.Usage.OutputTokens),
nil
}
func (r *Reviewer) buildSystemPrompt() string {
focusAreas := strings.Join(r.config.FocusAreas, "\n- ")
return fmt.Sprintf(`你是一位资深的 %s 工程师,正在做 Code Review。
重点关注领域:
- %s
评审标准:
- 只报告真实存在的问题,不要挑剔代码风格
- 每个问题要说明:位置、问题描述、为什么是问题、如何修复
- 严重程度分级:
- critical:可能导致线上事故(数据丢失、安全漏洞、崩溃)
- error:代码逻辑错误,功能不正确
- warning:潜在问题,最佳实践建议
- info:可选的优化建议
输出格式(JSON数组):
[
{
"line": 42,
"severity": "error",
"category": "错误处理",
"message": "问题描述",
"suggestion": "修复建议"
}
]
如果没有发现问题,返回空数组 []`, r.config.Language, focusAreas)
}
func (r *Reviewer) buildPrompt(filename, diff string) string {
return fmt.Sprintf("请审查以下文件的变更:\n\n文件:%s\n\n```diff\n%s\n```\n\n请分析这些变更,识别潜在问题。只关注新增的代码(+开头的行)。", filename, diff)
}
func (r *Reviewer) generateSummary(ctx context.Context, result *ReviewResult) (string, error) {
if len(result.Comments) == 0 {
return "本次 PR 未发现明显问题。", nil
}
// 统计各级别问题数量
counts := make(map[string]int)
for _, c := range result.Comments {
counts[c.Severity]++
}
var parts []string
for _, level := range []string{"critical", "error", "warning", "info"} {
if n := counts[level]; n > 0 {
parts = append(parts, fmt.Sprintf("%s x%d", level, n))
}
}
return fmt.Sprintf("发现 %d 个问题(%s),审查了 %d/%d 个文件",
len(result.Comments),
strings.Join(parts, ","),
result.ReviewedFiles,
result.TotalFiles,
), nil
}
// shouldSkipFile 判断是否跳过该文件
func shouldSkipFile(filename string) bool {
skipPatterns := []string{
".pb.go", // protobuf 生成的代码
"_mock.go", // mock 文件
"_test.go", // 测试文件(可选,看团队情况)
"vendor/",
"node_modules/",
".min.js",
"go.sum",
}
for _, pattern := range skipPatterns {
if strings.Contains(filename, pattern) {
return true
}
}
// 跳过非代码文件
codeExts := map[string]bool{
".go": true, ".java": true, ".py": true, ".ts": true,
".js": true, ".rs": true, ".cpp": true, ".c": true,
}
ext := strings.ToLower(filepath.Ext(filename))
return !codeExts[ext]
}Git Diff 解析
// GetGitDiff 获取当前工作区的 git diff
func GetGitDiff() ([]FileDiff, error) {
// 获取 staged diff(准备提交的变更)
cmd := exec.Command("git", "diff", "--cached", "--name-only")
output, err := cmd.Output()
if err != nil {
// 如果没有 staged,获取 HEAD diff
cmd = exec.Command("git", "diff", "HEAD")
} else if len(strings.TrimSpace(string(output))) == 0 {
cmd = exec.Command("git", "diff", "HEAD")
} else {
cmd = exec.Command("git", "diff", "--cached")
}
diffOutput, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git diff: %w", err)
}
return parseDiff(string(diffOutput)), nil
}
// parseDiff 解析 unified diff 格式
func parseDiff(diffText string) []FileDiff {
var diffs []FileDiff
var current *FileDiff
var diffLines []string
lines := strings.Split(diffText, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "diff --git ") {
if current != nil && len(diffLines) > 0 {
current.Diff = strings.Join(diffLines, "\n")
diffs = append(diffs, *current)
}
// 解析文件名:diff --git a/path b/path
parts := strings.Fields(line)
if len(parts) >= 4 {
current = &FileDiff{
OldPath: strings.TrimPrefix(parts[2], "a/"),
NewPath: strings.TrimPrefix(parts[3], "b/"),
}
current.Filename = current.NewPath
}
diffLines = nil
} else if current != nil {
diffLines = append(diffLines, line)
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
current.LinesAdded++
} else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
current.LinesRemoved++
}
}
}
if current != nil && len(diffLines) > 0 {
current.Diff = strings.Join(diffLines, "\n")
diffs = append(diffs, *current)
}
return diffs
}踩坑实录
踩坑 1:diff 太长导致 Claude 注意力分散
现象:一个文件 diff 超过 500 行时,AI 给出的问题质量明显下降,有时候甚至把没改动的代码行当作新代码来分析。
原因:超长 prompt 里,LLM 的注意力会分散,早期内容容易被忽略。
解法:单文件 diff 超过 150 行就拆分成多个 chunk,每个 chunk 单独 Review,最后合并结果。另外在 prompt 里明确强调"只看 + 开头的新增行,不要分析 - 开头的删除行"。
踩坑 2:AI 误报太多,开发者开始忽略所有评论
现象:刚上线时,AI 报了很多"info"级别的建议,比如"可以考虑给变量起更清晰的名字",PR 评论里全是这类噪音,开发者反映"AI Review 没用,都是废话"。
解法:
- 默认只显示 warning 及以上级别
- 给 AI 强调"不要报告代码风格问题,只报告真实的 bug 和安全隐患"
- 加人工反馈机制:开发者可以标记"误报",积累数据后优化 prompt
踩坑 3:token 成本超预期
现象:团队有 20 个人,每天几十个 PR,每个 PR 平均 5 个文件,每次 Review 平均输入 6000 tokens + 输出 800 tokens,月账单 $180,比预期高。
原因:每个文件都发一次 API 请求,加上 system prompt 每次都带,成本叠加了。
解法:
- 把多个小文件合并到一次请求里(控制总 token 数在 8000 以内)
- 对修改行数少于 10 行的文件,用 claude-3-haiku 而不是 sonnet(成本降低 10 倍)
- 对非 critical 路径文件(测试、配置),降低 Review 级别或跳过
优化后月成本降到 $40 以下。
实测成本数据
以中等规模团队为例(20人,每天 30 个 PR):
| 配置 | 每次 PR 成本 | 月成本 |
|---|---|---|
| 全用 Sonnet-3.5 | $0.30 | $270 |
| 分级(大文件用 Sonnet,小文件用 Haiku) | $0.08 | $72 |
| 优化合并请求 | $0.05 | $45 |
对比人工 Code Review 的时间成本,$45/月 是非常划算的。
CI/CD 集成
# .github/workflows/ai-review.yml
name: AI Code Review
on:
pull_request:
types: [opened, synchronize]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: AI Code Review
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
go run ./cmd/reviewer \
--base=${{ github.event.pull_request.base.sha }} \
--head=${{ github.event.pull_request.head.sha }} \
--pr=${{ github.event.pull_request.number }} \
--repo=${{ github.repository }}