返回博客列表

为什么 Go 是 AI 辅助软件工程的理想语言

2026-10-27T10:30:00+08:00
GoAI 编程goplsMCP软件工程

为什么 Go 是 AI 辅助软件工程的理想语言

决定 agent 能不能干活的,不是它多会写代码,而是它写错之后多久能知道。

Go 1.27.3 是当前的稳定版本,gopls 在 10 月 15 日发布了 v0.24.0。后者的官方文档里现在有一节叫《Gopls: Model Context Protocol support》——一条 gopls mcp 命令,就把这门语言的语言服务器变成了 12 个 MCP 工具,agent 可以直接问编译器「这个函数的签名是什么」「这个符号在哪些地方被引用」「把这次重命名做完」。作为对照,golang/go 仓库今天有 13.96 万颗星。这三件事凑在一起,很容易被读成「Go 官方在追 AI 热点」。

我认为恰恰相反。真正发生的事是:AI 编码 agent 的成本结构被「验证」支配,而不是被「生成」支配;而 Go 这门语言从 2009 年到现在,一直在为同一件事付代价——让机器在亚秒级给出确定的 yes/no。gopls 的 MCP 支持不是新方向,是把一门老语言已经具备的确定性,换一个接口暴露给新用户。

为了不写成读后感,我把 gopls/internal/mcp 的源码和 feature 文档读了一遍,在本机装了 Go 1.27.3,造了一个 200 个包的合成仓库和一个最小 HTTP 服务,量了七八组数字;同一份 Web 服务还用 Python 写了一遍做对照。下面所有耗时都是这台机器上跑出来的,环境与误差来源放在第七节。

本文提纲

  1. AI 编码的瓶颈不是生成,是验证
  2. 0.17 秒的编译器:反馈回路的成本决定 agent 的上限
  3. 只有一种写法:gofmt 和 go vet 把风格问题变成编译问题
  4. 啰嗦的错误处理,是留给 review 的接口
  5. gopls 变成 MCP 服务器:把「懂 Go」外包给编译器
  6. Go 省略了什么,代价是什么
  7. 该怀疑的地方
  8. 今天就能做的三件事

一、AI 编码的瓶颈不是生成,是验证

聊 AI 编程时,大家默认在比「生成」:谁的模型一次写对的概率高、谁的上下文窗口大、谁的工具调用更准。这些指标确实在飞快进步,但它们描述的是流水线的前半段。软件工程的后半段是另一件事:改完之后,你怎么知道它是对的?

我把 agent 干活的总成本拆成三项:生成成本、上下文成本、验证成本。第一项在过去两年掉了差不多一个数量级(同样一个函数,现在的模型用更少的 token、更少的轮次就能写完);第二项随着长上下文和检索工具在稳步下降;只有第三项,几乎完全由语言和工具链决定,模型再强也不改变它的量级。你没法靠「更聪明的模型」把一次全量编译从 40 秒变成 0.4 秒。

于是 agent 的实际产出质量更接近 min(生成能力, 验证能力),而不是生成能力本身。一个能一次写对的模型,如果每轮都要等两分钟编译、再被人肉读 300 行 diff 才能确认,它的有效吞吐仍然很低。

验证可以再分三层,每层的「可判定性」不同:

  • 语法与类型:能不能在编译期确定。这一层是硬的,对就是对,错就是错。
  • 风格与惯用法:能不能机器统一。这一层决定 diff 里有多少噪音。
  • 行为:只能靠测试和 review。这一层永远是概率。

Go 的特殊之处是:第一层是错误而不是警告,第二层根本不存在争议(只有一个格式化器,没有配置项),第三层所需的基础设施(go test、race detector、覆盖率)都在同一个 go 命令里。Python 要把 ruff + mypy + pytest + pre-commit 拼起来才能凑齐这三层,而且三套工具对「什么算错」的定义互相不一致——mypy 过了 pytest 挂,pytest 过了 ruff 又拦。

这里要提前说清一个容易滑坡的地方:以上论证的是「Go 的验证成本低」,不是「模型更擅长写 Go」。这两件事经常被混为一谈。语言团队提供好的反馈回路,不等于模型的 Go 能力更强。我找不到可信的公开证据支持后者,第七节会详细说为什么。

二、0.17 秒的编译器:反馈回路的成本决定 agent 的上限

先看数字。合成仓库是 200 个包,每个包含一个 struct、两个方法、JSON 编解码,另有一个 main 全部导入。Go 1.27.3,单机容器,GOCACHE 在本地盘:

场景 实测耗时
200 包冷编译(空 cache) 23.33 s
同项目立刻再编一次 0.51 s
改动 1 个包后重编 0.43 s
200 包 go vet ./... 6.75 s
200 包 go test ./...(无测试) 6.56 s
最小 HTTP 服务首次编译(net/http 进 cache) 47.82 s
同一服务改一行后重编 0.88 s
植入一个类型错误到看见报错 0.17 s
静态二进制(-trimpath -ldflags "-s -w") 6.66 MB
go list std(标准库包数) 383

冷热差异近五十倍,这个跨度本身就是结论:agent 的增量成本是亚秒级的,而它的冷启动成本是分钟级的。这两笔钱花的地方完全不同,很多团队把两者混在一起做预算,于是得出「Go 编译也慢」的错误结论。

为什么能这么快?三个设计决定,和 AI 无关,纯是历史红利:

第一,Go 没有头文件,也没有「声明/实现分离」。编译器读的是源码,包的导出信息编译时生成并缓存,所以不存在 C/C++ 那种「改一个头文件,几百个翻译单元全部重编」的雪崩。

第二,包级依赖图是显式的、无环的,go build 按 DAG 并行调度。200 个互不依赖的叶子包在同一次构建里可以同时编译,构建时间由关键路径而不是包总数决定。

第三,泛型用的是基于 GC shape 的实例化策略,而不是 C++ 式的模板展开。一个泛型函数在多数情况下只生成少量代码副本,不会因为类型参数组合爆炸把编译时间推向分钟级。

冷启动那 23 到 47 秒也不是没有解释:那是在空 cache 下把 fmt、encoding/json、net/http 这些标准库从头编一遍,连同类型检查。一旦这些进了缓存,之后的每次改动都只付自己那一个包的钱——0.43 秒。

第二组的错误信息值得单独看,因为它展示的是「可操作性」而不是「速度」。我往 pkg042 里塞了一行 func Broken() int { var s string; return s },构建在 0.17 秒后返回:

# bench/pkg042
pkg042/pkg.go:32:42: cannot use s (variable of type string) as int value in return statement

文件、行、列、实际类型、期望类型,五个要素齐全,句子本身可以直接被模型消费:不需要理解调用栈,不需要猜意图,改哪一行往哪个方向改都是显式的。列号这一项在大文件里尤其值钱——同一个文件里可能有十几个 return,只有精确到列才能定位唯一的那一处。我见过的很多「agent 改错地方」的案例,根因是报错只给了行号,模型在同一行里挑了一个长得像的位置。

这就是「反馈回路成本」的真实含义:不是编译快 10 秒让人心情好,而是延迟低到 agent 可以在一次任务里自己闭环二十轮。二十轮乘以 0.43 秒是 9 秒的编译开销,乘以 23 秒是 7 分半——后者会让「每次改完都编译」这条策略在 token 预算和时间预算上直接破产,agent 就退回「改完就交」的模式,把验证全部推给人。

顺带一个细节:go test ./... 在 200 个包里跑 6.56 秒,而这些包里一个测试都没有。这 6.5 秒花在加载 200 个包的类型信息上,不是花在跑测试上。真实项目的测试套件动辄十几分钟,所以给 agent 的正确姿势不是「跑全量测试」,而是 go test ./changed/pkg/... 这种选择性调用——go 命令对包模式的解析本身就是为这件事设计的。

三、只有一种写法:gofmt 和 go vet 把风格问题变成编译问题

Go 有一个被嘲笑了十几年的设计:gofmt 没有配置项。缩进、括号位置、空行规则,全部由工具决定,争论被取消了。

在人类协作里这算「霸道」,在 agent 协作里这是巨大的省事。我实测了 gofmt -l .,输出为空,耗时 0.01 秒——也就是说,格式化后立刻再检查,不会有任何文件被认为需要重新格式化。这个幂等性意味着 agent 生成的 patch 在格式上一律是「已经合规范」的:不存在「逻辑对了但 CI 挂在 prettier」这种返工回合,review 时的 diff 里也不会混着空格变更。

更重要的是第二条:Go 把一部分原本属于 linter 的规则,直接做成了编译错误。我写了一个只 import 不使用的文件:

# svc
vet: ./bad.go:3:8: "strings" imported and not used

未使用的 import、未使用的局部变量,在 Go 里是硬错误,程序根本编不出来。这条规则改变的是 agent 的产出形态:它不可能「顺手加了几个 import 再说」——那会让编译失败,逼它当场清理。而在这条规则是 warning 的语言里,agent 留下的一堆死 import 会一路绿灯进主干,直到某次 CI 才被 lint 拦住,或者永远不被拦住。

go vet 覆盖的正是 agent 最容易犯的那批错:Printf 系列格式串与参数不匹配、锁被按值拷贝、struct tag 拼错、不可达代码、方法签名与接口不一致。这些都不是「风格」问题,是编译器管不到的语义错误,vet 用静态分析补上了。6.75 秒能扫完 200 个包,成本比人读一遍低五个数量级。

我认为这里有一个可推广的判断:「只有一种写法」不是美学选择,是降低搜索空间。模型在生成代码时,每多一个自由度就多一份猜错的概率——缩进用几个空格、错误要不要包一层、接口命名用后缀还是前缀。人可以用团队规范把这些自由度填掉,但 agent 读不到你的规范(除非你把规范喂进上下文,那又是 token 成本)。Go 把这些自由度从「社会协商」变成了「语言事实」,agent 不需要知道你团队的偏好,它只需要通过编译。

代价也要说清楚。没有 operator overloading,意味着你不能给自定义数值类型写自然的算术语法;没有注解和元编程,意味着「声明式」的东西必须靠 go generate 出代码,而 agent 经常会忘记跑生成步骤,于是改了定义没改生成物,编译过了行为错。这两个坑在真实项目里都常见。

四、啰嗦的错误处理,是留给 review 的接口

对 Go 最常见的批评是 if err != nil 太啰嗦。这个批评在人类视角下完全成立,在 agent 视角下要反过来算一笔账。

我写了同一个功能的两版实现:一个 GET /health 返回 JSON,加 SIGTERM 优雅退出。Go 版只用标准库(net/http + encoding/json + signal),Python 版也只用标准库(http.server + 手写路由 + threading):

版本 字符数 行数 非空行
Go 950 45 38
Python 1432 57 44

Go 版更短。这个结果和「Go 啰嗦」的直觉相反,原因很简单:Go 的啰嗦集中在错误路径(每处 if err != nil),而它的省略集中在抽象层——没有类、没有继承、没有装饰器、没有 metaclass、没有 context manager。同样一个「注册路由」的动作,Python 版要么写个装饰器(要理解闭包与注册表),要么手写字典;Go 版就是 mux.HandleFunc("GET /health", health) 一行。

这个交换对 agent 特别划算,因为它换掉的是隐式控制流。异常的本质是「错误可以不在发生处处理」:一个 try/except 可以吞掉十层调用下的所有异常,代码照常运行,测试照常全绿,行为已经错了。语言设计上这是特性,agent 协作里这是灾难——模型最擅长的就是写 happy path,异常让错误路径在视觉上直接消失,而它消失之后没有任何工具会提醒你。

Go 的 err 是值,错误必须在调用点被看一眼才能继续。这意味着三件事:

第一,模型必须把错误分支写出来,写不出来就编不过(未使用的变量是错误)。review 时你能直接看到「它有没有考虑失败」。

第二,错误路径是可机器审计的。grep -rn "_ = err" 能找出所有显式丢弃错误的地方,errcheck 能扫出未检查的返回值。这类检查在其他语言里要跨「异常 + 返回值 + Promise rejection」三种机制,规则很难写全。

第三,错误的传播方式是显式的。一个常见的 agent 坏习惯是用 fmt.Errorf("failed: %v", err) 把错误包成字符串,丢掉错误链,于是上层 errors.Is(err, fs.ErrNotExist) 永远不成立。这条也可以自动检查——第三方 linter errorlint 就是干这个的。%w 与 %v 这一字之差,是 Go 里最值得写进团队规范、也最值得让 CI 挡住的地方。

所以我的判断是:Go 的错误处理不是「为了简单牺牲优雅」,而是把错误路径从隐式变成可审计。在人类写代码时,这是负担;在 AI 写代码、人类只做 review 的分工里,这是刚需——你没法 review 一个看不见的分支。

五、gopls 变成 MCP 服务器:把「懂 Go」外包给编译器

现在说这波真正的新东西。gopls 是官方的 Go 语言服务器(LSP),从 v0.20 开始,它内置了一个 MCP 服务器,v0.24.0 的文档里已经写得很完整。

两种运行模式,区别很关键:

  • attached 模式:gopls serve -mcp.listen=localhost:8092,走 HTTP + SSE,挂在已有的 LSP 会话上。它共享内存和未保存的缓冲区状态——也就是说,你在编辑器里改了还没存盘,agent 通过 MCP 看到的已经是新代码。
  • detached 模式:gopls mcp,标准输入输出,起一个独立的无头实例。它只看磁盘上已保存的文件——适合 CI、脚本、没有编辑器的场景。

这里有个实践里会咬人的坑:detached 模式下,agent 如果写完文件没落盘就调用工具,读到的是旧代码,然后基于旧代码做出「正确的」推断。这类错误极难 debug,因为每一步看起来都对。

工具清单我数了一遍,12 个,大致分四类:

类别 工具
定位 go_workspace、go_search、go_file_metadata、go_references、go_symbol_references
理解 go_context、go_file_context、go_package_api
动作 go_rename_symbol
检查 go_diagnostics、go_file_diagnostics、go_vulncheck

为什么这不是「给 agent 多几个搜索框」?因为 grep 返回的是字符串,go_references 返回的是绑定。同一个 Close 可能在十个包里存在,grep 分不清它们是不是同一个方法;gopls 知道哪个是 io.Closer 的实现、哪个是别处的同名函数。go_rename_symbol 是语义级重命名,不是正则替换——这直接消灭了 agent 最经典的一类 bug:改了 90% 的调用点,漏掉 10%。

还有两个细节值得单独说。

第一,gopls mcp -instructions > context.md 会输出一份「模型使用说明」,也就是 gopls 自带的、教模型怎么用这些工具的工作流文档。这等于把提示工程搬进了工具链:你不需要自己写「先用 go_search 找 API,再用 go_package_api 确认签名」这种提示词,官方维护一份,所有用户拿到的是同一套。这个思路比任何「AI 插件」都便宜,因为它复用的是 LSP 已经算好的类型信息。

第二,文档的安全章节写得很坦白:这个 MCP 服务器会读文件、会执行 go 命令(可能触发模块下载)、会写 gopls 缓存和配置、在用户选择加入时才上传遥测。换句话说,接上它之后,agent 获得的是「以你的身份在你的机器上跑 go 命令」的能力。CI 里用 detached 模式跑在容器里,比在开发机上跑 attached 模式更稳。

接入成本低得有点不像官方功能:claude mcp add gopls -- gopls mcp,一行。

我把这条回路画出来了,它解释了我前面所有数字的意义:

flowchart TD
    A[Agent asks gopls MCP for context] --> B[Model writes Go patch]
    B --> C{go build}
    C -->|type error in 0.17s| B
    C -->|pass| D{gofmt and go vet}
    D -->|format or lint issue| B
    D -->|clean| E{go test}
    E -->|fail| B
    E -->|pass| F[Human reviews semantics only]
    style A fill:#4ECDC4,stroke:#764ba2,color:#000
    style B fill:#FF6B6B,stroke:#764ba2,color:#000
    style C fill:#FFEAA7,stroke:#764ba2,color:#000
    style D fill:#96CEB4,stroke:#764ba2,color:#000
    style E fill:#45B7D1,stroke:#764ba2,color:#000
    style F fill:#DDA0DD,stroke:#764ba2,color:#000

整条链上,只有最后一步需要人。前面每一步的反馈都来自确定性工具:编译器、格式化器、静态分析、测试。这就是「理想语言」的实际含义——不是让模型更聪明,而是让模型能自己判断自己。

六、Go 省略了什么,代价是什么

上面五节都在讲 Go 为什么合适。这一节讲它在哪些地方不合适,而且要具体,不能只写「不是银弹」。

没有 sum type,也没有 pattern matching。 这是我认为 Go 在「可判定性」上最实质的缺口。Rust 里一个 match 覆盖不全,编译器直接报错;Go 里判断错误类型只能靠 errors.Is / errors.As 加 type switch,漏掉一个分支编译器完全不管。枚举用 iota 加手写 String(),新增一个常量之后所有 switch 都不会提醒你。对 agent 来说这意味着:分支遗漏无法被编译期发现,只能靠测试覆盖。这是 Go 用「简单」换掉的确定性的具体项目。

可空性不在类型系统里。 指针、interface、map、slice、channel 都可以是 nil,且每种 nil 的行为还不太一样(nil map 可读不可写、nil channel 永久阻塞、typed nil interface 不等于 nil)。这是 AI 生成 Go 代码最常见的运行时缺陷来源之一——编译全过,测试路径没覆盖到就 panic。这一条是我的判断,没有对照数据支撑,第七节会说明。

并发正确性验证不了。 goroutine 的语法是三行就能学会的,但「这个 channel 该不该关」「这个 context 什么时候取消」「这个结构体要不要加锁」全靠人。go test -race 能抓出一部分数据竞争,但它是插桩构建,比普通测试慢,而且只对实际执行到的路径有效——没跑到的并发交错它看不见。所以给 agent 配的 CI 里,race 测试要单独开一个 job,不能指望它在每次提交的主回路里跑。

数据库层没有事实标准。 没有 Django ORM 那种「全社区一个答案」的抽象,主流做法是 database/sql + 裸 SQL + 代码生成(sqlc)或者轻量 ORM(sqlx)。对 agent 的影响是分裂的:好处是 SQL 显式可见、容易 review;坏处是它按训练记忆随手拼字符串,注入风险要自己挡。给我的建议很简单——用 sqlc 这种「SQL 是源文件、Go 代码是生成物」的方案,让 SQL 有 review 痕迹。

不适合的场景也很明确。 前端和桌面 GUI(WASM 生态薄)、数据科学(没有 numpy/pandas 的等价物)、需要复杂类型建模的项目(编译器、DSL、协议栈、形式化工具)——这些场景恰恰是「类型系统承担验证职责」的地方,用 Go 写等于把验证工作推回给人。这种时候该选 Rust、Scala、Haskell 或者 TypeScript,而不是硬用 Go 的 interface{} 加反射去模拟和类型。

一个粗糙的判据:如果你的项目里「编译通过」离「逻辑正确」越远,Go 的优势就越小。CRUD 服务、CLI、基础设施组件、编排层——编译通过大概等于走通了一半;复杂业务状态机、金融计算、协议实现——编译通过什么都不能说明。

七、该怀疑的地方

这一节是这篇文章最该被质疑的地方,我逐条列出来,包括对我自己的。

一,我的数字来自合成仓库。 200 个包结构几乎相同、依赖扁平,这恰好是并行编译最理想的情形。真实项目里包依赖是深链、有循环历史的变通(比如 internal 层反向依赖)、有 cgo(会让编译慢一个数量级)。冷启动 23 到 47 秒、热重编 0.4 秒这组数字,结论「热编译是亚秒级」我认为稳健,「冷启动是 23 秒」则高度依赖依赖图和机器。

二,没有可信的跨语言 agent benchmark。 「Go 更适合 AI 生成」目前是推断,不是测量。任何声称有数据的,先查四件事:是不是同一个模型、同一批任务、相同的上下文预算、除了 pass@1 有没有报非功能指标(review 耗时、回归率、CI 成本)。只报 pass@1 的跨语言对比,基本可以当成任务分布偏差——模型见过的 Go 代码(Docker、Kubernetes、Terraform 的 provider、无数 CLI)比某些语言多得多,这本身就会污染结果。

三,gopls MCP 官方自己写着 experimental。 文档原话是「experimental built-in server」。我看的是 master 分支的文档,v0.24.0 的实际行为可能有细节差异,文档与实现不同步在这个项目里是常态。attached 模式还要 gopls v0.20 以上,团队里版本不齐就会出现「我这儿能连上、他那儿连不上」。

四,代码量那张表有我的选择偏差。 950 对 1432 这个对比里,Python 版我刻意只用标准库(http.server 手写路由),因为要和「Go 只用标准库」对等。如果 Python 版用 FastAPI,代码会短得多,结论会反过来。所以这张表只能证明一件事:Go 的标准库覆盖了这类服务,不需要外部框架。它不能当成通用的 token 成本结论,我在正文里也只用它反驳「Go 必然更啰嗦」这个具体说法。

五,我用一条报错夸了整个编译器。 cannot use s (variable of type string) as int value in return statement 确实漂亮,但那是最简单的情形。Go 在泛型类型推断失败时的报错依然难看,在 interface{} 层层传递时的类型错误定位也不友好。一条样本撑不起「Go 报错质量高」的整体判断。

六,我完全没测「agent 写错的概率」。 全文论证的是「Go 让错误更快被发现」,不是「Go 让错误更少产生」。这两者在经济上等价的方向不同:前者降低单次返工成本,后者降低返工次数。真要量化,需要一个控制变量的实验(同模型、同任务集、多语言、记录每轮编译失败次数),我没做。

七,官方给工具不等于社区接受 AI 代码。 Go 社区对 AI 生成代码的态度并不统一,维护者对人肉提交的审查标准也没有因为 gopls 支持 MCP 而放宽。工具能把「格式和类型」这层自动化的门槛降低,但维护者关心的永远是「你为什么这么设计」——这一层没有任何语言能替你回答。

八、今天就能做的三件事

如果你认同前面「验证成本是瓶颈」这个前提,有三件事的性价比高到不需要讨论。

一,把 GOCACHE 持久化。 这是全文最实用的一条。agent 每轮跑在新容器里,就要付 23 到 47 秒的冷启动;把 /root/.cache/go-build 挂成 volume,或者 CI 里缓存这个目录,同一轮任务就变成 0.5 秒。Docker 里一行 -v gocache:/root/.cache/go-build,GitHub Actions 里一个 actions/cache,收益是每轮省几十秒乘以轮次数。

二,把三条检查做成硬门禁。 成本都低得可以忽略:

gofmt -l . | tee /tmp/fmt.txt && test ! -s /tmp/fmt.txt   # 0.01s
go vet ./...                                              # 200 包 6.75s
grep -rn '_ = err' --include='*.go' . || true             # 找出被吞掉的错误

第三条不是让你禁止丢弃错误,而是让每一处丢弃都出现在 review 里。真要严格,再接 errcheck 和 errorlint,专治 %v 代替 %w 这种把错误链掐断的习惯。

三,给 agent 接上 gopls,并要求它「不确定就查」。

go install golang.org/x/tools/gopls@latest
gopls mcp -instructions > .agent/go-context.md   # 官方自带的工作流说明
claude mcp add gopls -- gopls mcp

然后在项目规则里写一句:调用任何标准库或内部包的 API 之前,先用 go_package_api 确认签名,不许凭记忆写。 这一句能挡掉的幻觉,比换一个更强的模型更直接——因为「猜 API 签名」是 agent 高频且完全没必要犯的错,而答案就在编译器里。

做完这三件事,你会得到一条几乎不需要人的内循环:改、编、查、测,全部亚秒到秒级,人只在最后看语义。到那一步,「AI 辅助」才真的开始省时间——省的不是打字,是你不必读完每一行 diff。

参考文档与链接

你的项目里,agent 改完一行代码到知道它对错,需要多久?如果答案超过 10 秒,瓶颈大概率不在模型。评论区聊聊你的 CI 内循环有多长。


作者: itech001
来源: 公众号:AI人工智能时代(the-ai-era)
网站: https://www.theaiera.top/ 关注每日最新AI新闻和技术博客,主页有更多的文章的AI 技术参考:https://www.theaiera.top

本文首发于 AI人工智能时代,转载请注明出处。

分享给朋友