用 LangChainGo 构建 AI 代码审查器:从 CLI 工具到 Git Hook 与 CI/CD 的完整实战
【免费下载链接】langchaingoLangChain for Go, the easiest way to write LLM-based programs in Go项目地址: https://gitcode.com/GitHub_Trending/la/langchaingo
导读
本教程基于 LangChainGo(langchaingo)官方文档 code-reviewer.md 展开,手把手教你用 Go 打造一个智能代码审查助手:它能分析 Go 源码中的缺陷、风格问题与性能隐患,给出可执行的修复建议,并原生接入 Git 工作流与 CI/CD 流水线。读完本文,你将掌握 LangChainGo 的llms.Model调用、prompts.PromptTemplate模板渲染、JSON 结构化输出(WithJSONMode)等核心 API,并独立完成一个可直接用于日常开发的 AI 审查工具。
构建目标与前置条件
我们将实现一个名为code-reviewer的命令行工具,它具备四个核心能力:
- 分析单个 Go 源文件中的潜在问题(Bug、性能、风格、安全);
- 递归审查目录下的所有
.go文件; - 通过
git diff只审查当前工作区变更过的文件; - 为每一条建议提供 Why(为什么是问题)、How(如何修复)与严重级别(Critical / Warning / Suggestion)的说明。
前置条件:
- Go 1.21+(注意:当前仓库 go.mod 声明的是
go 1.24.4,建议直接使用与该版本匹配或更新的 Go 工具链,并开启 module 模式); - OpenAI 或 Anthropic 的 API Key;
- 已安装 Git。
mkdir ai-code-reviewer cd ai-code-reviewer go mod init code-reviewer go get github.com/tmc/langchaingogo get会拉取 langchaingo 主模块。得益于 Go module graph pruning(见 go.mod 顶部注释),你只 importllms/openai等具体包时,构建过程不会引入全部依赖,最终二进制保持精简。
核心审查器结构:理解 LangChainGo 的三个关键抽象
创建main.go,其中CodeReviewer结构体持有一个llms.Model和一个*prompts.PromptTemplate,这是 LangChainGo 最典型的"模板 + 模型"组合:
package main import ( "context" "flag" "fmt" "go/ast" "go/parser" "go/token" "log" "os" "os/exec" "path/filepath" "strings" "github.com/tmc/langchaingo/llms" "github.com/tmc/langchaingo/llms/openai" "github.com/tmc/langchaingo/prompts" ) type CodeReviewer struct { llm llms.Model template *prompts.PromptTemplate } func NewCodeReviewer() (*CodeReviewer, error) { llm, err := openai.New() if err != nil { return nil, err } template := prompts.NewPromptTemplate(` You are an expert Go code reviewer. Analyze this Go code for: 1. **Bugs and Logic Issues**: Potential runtime errors, nil pointer dereferences, race conditions 2. **Performance**: Inefficient algorithms, unnecessary allocations, string concatenation issues 3. **Style**: Go idioms, naming conventions, error handling patterns 4. **Security**: Input validation, sensitive data handling Code to review: '''go {{.code}} ''' File: {{.filename}} Provide specific, actionable feedback. For each issue: - Explain WHY it's a problem - Show HOW to fix it with code examples - Rate severity: Critical, Warning, Suggestion Focus on the most important issues first.`, []string{"code", "filename"}) return &CodeReviewer{ llm: llm, template: &template, }, nil } func (cr *CodeReviewer) ReviewFile(filename string) error { content, err := os.ReadFile(filename) if err != nil { return fmt.Errorf("reading file: %w", err) } // Parse Go code to ensure it's valid fset := token.NewFileSet() _, err = parser.ParseFile(fset, filename, content, parser.ParseComments) if err != nil { return fmt.Errorf("parsing Go file: %w", err) } prompt, err := cr.template.Format(map[string]any{ "code": string(content), "filename": filename, }) if err != nil { return fmt.Errorf("formatting prompt: %w", err) } ctx := context.Background() response, err := cr.llm.GenerateContent(ctx, []llms.MessageContent{ llms.TextParts(llms.ChatMessageTypeHuman, prompt), }) if err != nil { return fmt.Errorf("generating review: %w", err) } fmt.Printf("\n=== Review for %s ===\n", filename) fmt.Println(strings.Repeat("=", 80)) fmt.Println(response.Choices[0].Content) fmt.Println(strings.Repeat("=", 80)) return nil }下面对三个关键 API 做源码级拆解,帮助你真正理解背后发生了什么。
1.llms.Model与GenerateContent
CodeReviewer.llm的类型是llms.Model接口,其核心方法是 llms/llms.go 中的:
GenerateContent(ctx context.Context, messages []MessageContent, options ...CallOption) (*ContentResponse, error)openai.New()返回的*LLM通过 llms/openai/openaillm.go 的构造器创建,并显式实现了该接口(该文件中有var _ llms.Model = (*LLM)(nil)的编译期断言)。传入的消息类型MessageContent定义在 llms/generatecontent.go:它由Role(角色)与Parts(内容片段序列)组成;llms.TextParts(role, parts...)则是把若干字符串打包成一条带角色的文本消息的辅助函数(见 llms/generatecontent.go)。这里我们只发送一条ChatMessageTypeHuman(人类角色)消息——从 llms/chat_messages.go 可以看到,LangChainGo 预定义了Human、AI、System、Tool等角色常量,需要系统提示词时也可以改用ChatMessageTypeSystem组合多条消息。
GenerateContent返回的ContentResponse带有Choices列表,每个ContentChoice的Content字段就是模型生成的文本(llms/generatecontent.go),因此response.Choices[0].Content是取第一份候选回复。
2.prompts.PromptTemplate的渲染原理
NewPromptTemplate创建的是基于 Gotext/template语法的模板(prompts/prompt_template.go),模板字符串中的{{.code}}、{{.filename}}是占位变量。调用Format(map[string]any{...})时,模板会把传入的值与PartialVariables(若设置)合并后渲染(prompts/prompt_template.go)。
需要注意一个细节:NewPromptTemplate返回的是PromptTemplate值类型而非指针,所以教程代码里用template := prompts.NewPromptTemplate(...)再&template取址赋给结构体的*PromptTemplate字段,这是正确且常见的用法。模板的InputVariables(即第二个参数[]string{"code", "filename"})在渲染前会被校验,缺少变量会直接报错,这能尽早暴露提示词与传参不一致的问题。
3. 客户端初始化与环境变量
openai.New()不传任何参数也能工作,因为它会按优先级读取环境变量:token 读取OPENAI_API_KEY,模型读取OPENAI_MODEL,服务地址读取OPENAI_BASE_URL(llms/openai/openaillm_option.go)。如果需要显式控制,LangChainGo 提供了一组函数式选项:
openai.WithToken(token)——覆盖 API Key;openai.WithModel(model)——指定模型名(如gpt-4o);openai.WithBaseURL(url)——对接兼容 OpenAI 协议的第三方网关或本地服务;openai.WithAPIType(openai.APITypeAzure)与WithAPIVersion——用于 Azure OpenAI。
若OPENAI_API_KEY缺失,openai.New()会返回ErrMissingToken错误,提示你在环境变量中设置 Key(见 llms/openai/llm.go)。如果你改用 Anthropic,只需把llms/openai换成llms/anthropic,其构造器同样会从ANTHROPIC_API_KEY读取密钥(见 llms/anthropic/anthropicllm.go),因为两者都实现了llms.Model接口,其余代码几乎不用改动——这正是面向接口编程带来的可替换性。
4. CLI 入口与三种审查模式
main函数用标准库flag定义三个互斥的启动参数,并分派到三种审查路径:
func main() { var ( file = flag.String("file", "", "Go file to review") dir = flag.String("dir", "", "Directory to review (all .go files)") git = flag.Bool("git", false, "Review files changed in git working directory") ) flag.Parse() reviewer, err := NewCodeReviewer() if err != nil { log.Fatal(err) } switch { case *file != "": if err := reviewer.ReviewFile(*file); err != nil { log.Fatal(err) } case *dir != "": if err := reviewDirectory(reviewer, *dir); err != nil { log.Fatal(err) } case *git: if err := reviewGitChanges(reviewer); err != nil { log.Fatal(err) } default: fmt.Println("Usage:") fmt.Println(" code-reviewer -file=main.go") fmt.Println(" code-reviewer -dir=./pkg") fmt.Println(" code-reviewer -git") os.Exit(1) } } func reviewDirectory(reviewer *CodeReviewer, dir string) error { return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if strings.HasSuffix(path, ".go") && !strings.Contains(path, "vendor/") { return reviewer.ReviewFile(path) } return nil }) } func reviewGitChanges(reviewer *CodeReviewer) error { // This is a simplified version - you'd want to use a proper git library cmd := exec.Command("git", "diff", "--name-only", "HEAD") output, err := cmd.Output() if err != nil { return fmt.Errorf("getting git changes: %w", err) } files := strings.Split(strings.TrimSpace(string(output)), "\n") for _, file := range files { if strings.HasSuffix(file, ".go") && file != "" { if err := reviewer.ReviewFile(file); err != nil { log.Printf("Error reviewing %s: %v", file, err) } } } return nil }三个值得学习的工程细节:
- 审查前的 AST 校验:
ReviewFile在请求大模型之前先用go/parser.ParseFile做一次本地解析。这样做一举两得:一方面确保待审代码语法合法,另一方面为后面的结构化输出与自定义规则引擎提供了*ast.File与token.FileSet基础; - 错误包装:全程使用
fmt.Errorf("...: %w", err)保留错误链,便于errors.Is/errors.As判断根因,这是 Go 1.13+ 的错误处理惯例; - Git 集成:
reviewGitChanges通过git diff --name-only HEAD拿到已修改文件清单并过滤出.go文件。教程注释也提醒:生产环境建议换用专业的 Git 库(如 go-git)以处理暂存区、rename 等边界情况。
用带缺陷的样例代码验证
创建sample.go作为审查对象,它故意埋入三类典型问题:
package main import "fmt" func badCode() { // This has several issues var users []string for i := 0; i < len(users); i++ { fmt.Println(users[i]) // potential index out of bounds } // String concatenation in loop var result string for i := 0; i < 1000; i++ { result += fmt.Sprintf("item-%d,", i) } // Ignoring errors file, _ := os.Open("nonexistent.txt") file.Read(make([]byte, 100)) }这正好覆盖提示词中要求的四类检查点:
- 空切片
users上循环越界访问——Bugs(运行时风险); - 循环内用
+=拼接字符串——Performance(每次迭代都产生新的不可变字符串与分配,strings.Builder才是正解); - 忽略
os.Open错误并直接调用file.Read——Style / Bugs(错误处理模式错误,且file可能为 nil 导致 panic); - 读取"不存在的文件"——Security / Robustness(未做输入与资源校验)。
小提示:原教程的
sample.go使用了os包但 import 块里只有fmt,实际编译会报undefined: os。运行前请补上"os"导入——这本身也是很好的审查素材。
运行代码审查器
export OPENAI_API_KEY="your-openai-api-key-here" go run main.go -file=sample.go程序会打印审查分隔线与模型输出:
=== Review for sample.go === ================================================================================ [Critical] Potential index out of bounds ...(模型给出的具体分析) [Warning] String concatenation in loop; prefer strings.Builder ... ================================================================================你可以组合三种用法:
code-reviewer -file=main.go # 审查单个文件 code-reviewer -dir=./pkg # 递归审查目录 code-reviewer -git # 仅审查 Git 工作区变更进阶:结构化 JSON 输出
自由文本的审查结果难以被程序消费。LangChainGo 通过llms.WithJSONMode()选项(llms/options.go)让模型以 JSON 格式返回,其实现原理是把CallOptions.JSONMode置为true,后端据此设置 response format(如 OpenAI 的json_object)。对支持该能力但未开启 JSON 模式的模型,返回的ResponseFormatJSON也可通过WithResponseFormat显式下发(见 llms/openai/openaillm_option.go)。
创建reviewer.go,定义与模型约定一致的 JSON 结构:
package main import ( "encoding/json" "fmt" "go/ast" "go/parser" "go/token" ) type Issue struct { Severity string `json:"severity"` Type string `json:"type"` Line int `json:"line"` Description string `json:"description"` Suggestion string `json:"suggestion"` } type ReviewResult struct { Filename string `json:"filename"` Issues []Issue `json:"issues"` Score int `json:"score"` // 0-100 } func (cr *CodeReviewer) ReviewFileStructured(filename string) (*ReviewResult, error) { content, err := os.ReadFile(filename) if err != nil { return nil, fmt.Errorf("reading file: %w", err) } // Parse for line numbers fset := token.NewFileSet() node, err := parser.ParseFile(fset, filename, content, parser.ParseComments) if err != nil { return nil, fmt.Errorf("parsing Go file: %w", err) } template := prompts.NewPromptTemplate(` Analyze this Go code and return a JSON response with this exact structure: { "filename": "{{.filename}}", "issues": [ { "severity": "critical|warning|suggestion", "type": "bug|performance|style|security", "line": 42, "description": "Detailed issue description", "suggestion": "How to fix this issue" } ], "score": 85 } Code to analyze: '''go {{.code}} ''' Focus on real issues. Score: 100 = perfect, 0 = many serious issues.`, []string{"code", "filename"}) prompt, err := template.Format(map[string]any{ "code": string(content), "filename": filename, }) if err != nil { return nil, fmt.Errorf("formatting prompt: %w", err) } ctx := context.Background() response, err := cr.llm.GenerateContent(ctx, []llms.MessageContent{ llms.TextParts(llms.ChatMessageTypeHuman, prompt), }, llms.WithJSONMode()) if err != nil { return nil, fmt.Errorf("generating review: %w", err) } var result ReviewResult if err := json.Unmarshal([]byte(response.Choices[0].Content), &result); err != nil { return nil, fmt.Errorf("parsing JSON response: %w", err) } return &result, nil }结构化输出落地后,下游消费就变得极其简单:CI 脚本可以按issues数组逐条渲染 markdown 评论,也可以依据severity == "critical"的数量决定是否阻断合并,score字段甚至可以作为代码健康度的量化指标。
让审查器融入 Git 工作流:pre-commit Hook
在.git/hooks/pre-commit中挂接审查器,让每次提交前自动跑一遍代码审查:
#!/bin/bash echo "Running AI code review..." ./code-reviewer -git if [ $? -ne 0 ]; then echo "Code review found issues. Fix them or use --no-verify to skip." exit 1 fi echo "Code review passed!"然后赋予执行权限:
chmod +x .git/hooks/pre-commit这里有个值得注意的设计取舍:教程中的 pre-commit 脚本把审查失败当作硬阻断(exit 1)。由于code-reviewer -git本身只是"审查并打印",模型返回的缺陷并不会让进程退出非零,实际项目中你可以自行扩展——例如用上一节的结构化结果,在检测到 Critical 级别问题时返回非零退出码,从而真正"卡住"提交;或者反过来只打印报告不阻断,让开发者自行判断。结合--no-verify逃生通道,这套机制可以在"强制质量"与"开发效率"之间取得平衡。
高级特性:基于 AST 的自定义规则引擎
LLM 审查存在成本与随机性,而 Go 标准库的go/ast提供了确定性的静态检查能力。教程给出的思路是:把确定性规则当作固定拦截网,把 LLM 当作兜底语义分析器,两者互补。
type RuleEngine struct { rules []Rule } type Rule interface { Check(node ast.Node, fset *token.FileSet) []Issue } type NoGlobalVarsRule struct{} func (r NoGlobalVarsRule) Check(node ast.Node, fset *token.FileSet) []Issue { var issues []Issue ast.Inspect(node, func(n ast.Node) bool { if genDecl, ok := n.(*ast.GenDecl); ok && genDecl.Tok == token.VAR { for _, spec := range genDecl.Specs { if valueSpec, ok := spec.(*ast.ValueSpec); ok { pos := fset.Position(valueSpec.Pos()) issues = append(issues, Issue{ Severity: "warning", Type: "style", Line: pos.Line, Description: "Global variable found", Suggestion: "Consider using dependency injection or configuration structs", }) } } } return true }) return issues }NoGlobalVarsRule演示了规则引擎的核心模式:ast.Inspect深度遍历语法树,遇到token.VAR的*ast.GenDecl即命中"全局变量"模式,再用fset.Position()把 AST 位置换算成源码行号,填充到与结构化审查一致的Issue结构里。你可以按同样套路扩展更多规则,例如:
NoPanicRule:ast.CallExpr中Fun为panic的调用;NoFmtPrintlnRule:生产代码中fmt.Println的日志滥用;ErrorCheckRule:_ =赋值或if err :=后未处理的错误。
这些规则 0 成本、无延迟,适合放在"固定检查层",再把模板提示词聚焦到"语义级"问题(并发安全、逻辑缺陷、性能瓶颈),让每次 LLM 调用花在刀刃上。
集成 CI/CD:Docker 容器化
为了让审查器在任何 CI 环境(不依赖本地 Go 工具链)中稳定运行,教程给出了两阶段构建的Dockerfile:
FROM golang:1.21-alpine AS builder WORKDIR /app COPY . . RUN go build -o code-reviewer FROM alpine:latest RUN apk --no-cache add git WORKDIR /root/ COPY --from=builder /app/code-reviewer . ENTRYPOINT ["./code-reviewer"]- 第一阶段用
golang镜像编译出静态二进制; - 第二阶段只保留极简的
alpine运行时,并安装git(因为-git模式依赖git diff命令)。
接入 GitHub Actions,实现 Pull Request 自动审查:
name: AI Code Review on: [pull_request] jobs: review: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Run AI Code Review env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: | docker build -t code-reviewer . docker run --rm -v $PWD:/code code-reviewer -dir=/code关键点解读:
- API Key 通过 GitHub Secrets(
secrets.OPENAI_API_KEY)注入环境变量,绝不写死在镜像里; -v $PWD:/code把 CI 工作区挂载进容器,配合-dir=/code审查整个代码库;- 由于 Actions 的
checkout会带出 PR 的合并结果,你也可以把命令换成code-reviewer -git来只审变更文件,进一步节省 token 成本。
延伸方向与小结
教程最后给出了清晰的演进路线,你也可以在此基础上继续扩展:
- 支持多语言:为 Python、JavaScript 等语言编写对应的解析器与提示词;
- 从反馈中学习:收集开发者对审查结果的"采纳/忽略"标记,反向优化提示词与规则权重;
- Web 界面:把
ReviewResult渲染成团队可共享的审查看板; - IDE 集成:借助 LSP 协议把 Issue 映射为编辑器内联诊断。
本教程完整展示了 LangChainGo 的能力边界远不止聊天机器人:llms.Model抽象让 OpenAI / Anthropic 等后端可以即插即用,prompts.PromptTemplate让提示词工程化、可复用,WithJSONMode让模型输出可直接被程序消费,而 Go 标准库的go/ast则提供了确定性的本地检查作为补充。一个"本地静态规则 + LLM 语义审查"的复合工具,足以成为开发流程中既便宜又可靠的质量防线。
【免费下载链接】langchaingoLangChain for Go, the easiest way to write LLM-based programs in Go项目地址: https://gitcode.com/GitHub_Trending/la/langchaingo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考