AI 编程时代,我愿意用 Go 做后端

AI 编程工具越来越会写代码,但真正把一个 AI 应用跑到线上,难点从来不只是"能不能生成一个接口"。请求超时怎么处理,模型服务失败后怎么重试,流式响应如何关闭,服务占多少内存,镜像怎样发布,出了问题能不能快速回滚,这些才是后端每天要面对的事情。
我越来越愿意把 Go 放在 AI 应用的后端位置:让 AI 负责加快实现,让 Go 负责把边界、资源和部署约束固定下来。这里说的不是把模型训练和推理强行改写成 Go,而是模型外围的 API、网关、任务和编排服务。蓝色文字表示语言和工程特性,绿色文字表示我的实践判断,红色文字表示容易被忽略的风险,黄色底色表示可以直接采用的做法。
一、Go 不会让 AI 更聪明,但会让错误更快暴露
我用 AI 写后端时,最常见的情况不是代码完全不能运行,而是它给出了一条顺利路径,却把失败路径一笔带过。比如调用大模型时,AI 很容易生成下面这种代码:
func AskModel(prompt string) string {
resp, _ := http.Get("http://model:8080/v1/chat")
body, _ := io.ReadAll(resp.Body)
return string(body)
}
这段代码的问题很多:忽略了网络错误,没有关闭响应体,没有设置超时,也没有检查状态码。它可能在本地演示中返回结果,但一旦模型服务变慢,连接就可能长期占用;一旦服务返回 500,调用方拿到的可能仍然是一段错误页面。
Go 的显式语法会把这些问题推到台面上。error 必须被接收,类型转换必须写明,defer、context 和 goroutine 的生命周期也不会凭空消失。 这对 AI 生成代码形成了一种很有价值的约束:编译器先拦住低级问题,人再检查业务决策。
例如,我会要求模型先生成一个"责任完整的版本":
var ErrModelUnavailable = errors.New("model service unavailable")
func AskModel(ctx context.Context, client *http.Client, endpoint string, prompt string) ([]byte, error) {
payload, err := json.Marshal(map[string]string{"prompt": prompt})
if err != nil {
return nil, fmt.Errorf("encode model request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("create model request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("call model: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("model returned status %d: %w", resp.StatusCode, ErrModelUnavailable)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, fmt.Errorf("read model response: %w", err)
}
return body, nil
}
这里不是说 Go 写起来没有重复代码,而是每个关键动作都落到具体的代码位置:编码、请求、响应、释放、超时、错误,全都看得见也改得动。
我认为这正是 Go 在 AI 编程里的第一个优势:生成结果不必完美,但必须容易被编译、检查和追责。AI 写出的代码越多,这种可见性越重要。
我自己的小经验:每次让 AI 写一个会发外部请求的 Go 函数,我都会在 prompt 里补一句"必须返回一个不会 panic 的版本",再要求它把每个分支的返回值显式列出来。这一步能把多数"演示能跑、生产会爆"的代码拦下来;剩下的那一两处兜底,再人工补,比一开始就盯着 AI 改错要省事得多。
二、显式语法让 AI 更容易被审查,而不是更容易偷懒
Go 有一个经常被低估的优势:代码生成之后,审查成本比较稳定。
这并不代表 Go 的生态比所有语言都大,而是它的后端"默认路径"比较短:一个 HTTP 服务可以从标准库开始,一个命令行工具可以编译成单个二进制,一个接口可以用很少的约定完成。
我尤其在意接口和依赖注入的写法。与其让 AI 在业务函数里直接创建数据库、HTTP 客户端和模型 SDK,不如先把依赖写成接口:
type ChatModel interface {
Complete(ctx context.Context, prompt string) (string, error)
}
type AnswerService struct {
model ChatModel
}
func (s *AnswerService) Answer(ctx context.Context, question string) (string, error) {
if strings.TrimSpace(question) == "" {
return "", errors.New("question is empty")
}
return s.model.Complete(ctx, question)
}
这样做的意义不只是"方便测试":
type FakeModel struct{}
func (FakeModel) Complete(context.Context, string) (string, error) {
return "这是测试回答", nil
}
不过,Go 的显式性也不是自动免疫。AI 仍然可能生成"形式正确、业务错误"的代码,例如每一个错误都直接用 _ 接住、每一个 ctx 都形同虚设、每一个 goroutine 都没有退出条件。
所以我的使用方式是:让 AI 先写代码,再让它根据清单做一次反向审查。
- 每个外部调用是否有超时和取消机制?
- 每个打开的资源是否有明确的关闭点?
- 每个错误是否带有足够上下文,调用方是否真的能处理?
- 每个 goroutine 是否有退出条件,是否可能无限堆积?
- 失败重试是否有次数、退避和幂等性约束?
我自己的小经验:AI 写完一整段之后,我喜欢让它再做一次"如果这段代码会出事故,最可能的三个点是什么"的自我审查。Go 的显式语法让这种自审也能落到代码里——错误要往上抛、defer 在外层兜底、ctx 一路透传,而不是一句"上线再观察"。让 AI 自己先审一遍,比我从零挑刺更快。
三、Go 把更多机器资源留给真正昂贵的模型工作
AI 应用里最耗资源的通常是模型推理、向量检索或数据处理,不是负责鉴权、路由、限流、会话和任务编排的那一层。后端的工作经常是连接多个外部系统:接收请求、拼接上下文、调用模型、转发流式结果、记录用量,再把结果返回给前端。
这类服务的特点是网络等待多、请求并发高、单次计算不一定重。Go 的 goroutine 比传统线程更轻量,运行时也自带调度器;标准库里的 HTTP、JSON、context 和并发原语足以覆盖大量 API 服务。
一个简单的并发限制器,就能防止 AI 服务在高峰时无限向模型供应商发请求:
type Limiter struct {
sem chan struct{}
}
func NewLimiter(maxConcurrent int) *Limiter {
return &Limiter{sem: make(chan struct{}, maxConcurrent)}
}
func (l *Limiter) Do(ctx context.Context, fn func() error) error {
select {
case l.sem <- struct{}{}:
defer func() { <-l.sem }()
return fn()
case <-ctx.Done():
return ctx.Err()
}
}
这个例子很小,却体现了我希望 AI 遵守的后端原则:并发不是越多越好,资源必须有上限,取消必须能传递。
当然,不能把"Go 占用少"理解成"部署后不需要监控"。连接池、响应缓冲、日志、指标和模型返回内容仍然会消耗内存;无限制读取响应体,照样可以把一个小服务打崩。资源开销低的前提,是代码真的有边界。
我自己的小经验:曾经有一次 AI 给我生成了一个"用 channel 把请求均匀分发给三个模型"的代码,看上去很优雅,跑起来 CPU 也不高,但
go tool pprof一查,goroutine 数一直在涨。原因就是 AI 写成了for { select { ... default: 继续循环 } },没有退出条件。一旦加上ctx.Done()兜底,goroutine 数立刻就稳了。这件事比任何语言层面的争论都更让我确信:Go 的资源感不是语言白送的,是要靠人主动写出来。
四、独立二进制让部署、扩容和回滚都更直接
AI 项目通常变化很快:模型供应商会换,提示词会调,配置会增加,前后端会频繁发布。后端如果需要复杂运行时、巨大的依赖目录和一长串启动脚本,迭代速度很快会被部署流程拖住。
Go 可以编译成独立二进制,配合多阶段构建,把编译环境和运行环境分开:
FROM golang:1.24 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
运行镜像里不需要编译器,也不需要源码和模块缓存;容器以 nonroot 用户启动,攻击面和排查范围都更容易控制。
我通常会把配置放到环境变量或配置文件里,把模型密钥、数据库地址和限流参数与二进制分离。上线时重点保证三件事:
- 构建产物已通过测试与 CI 流水线,并打上可回滚的版本标签;
- 收到停止信号后立即停止接收新请求,等待已有请求在期限内结束;
- 日志输出请求 ID、耗时、状态和错误阶段,但绝不记录用户提示词中的敏感信息。
这部分特别适合让 AI 辅助生成,但不能把运维责任完全交给默认模板。
服务退出时也要给正在处理的请求一个收尾机会。Go 的 http.Server 已经把这些约束写进了 API:
server := &http.Server{
Addr: ":8080",
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 120 * time.Second, // 给流式模型响应留出时间
IdleTimeout: 60 * time.Second,
}
go func() {
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
shutdownCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-shutdownCtx.Done()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
}
这段代码的重点不是写法本身,而是把线上行为变成了可读的规则:请求头不能无限等待,普通请求有时限,流式响应有更长的写超时,收到停止信号后最多等待 30 秒。
我自己的小经验:让 AI 帮我写
Dockerfile、k8s manifest和 GitHub Actions 流水线时,我会同时要求它输出"上线检查清单"——不是写得越花越好,而是只有当健康检查、超时、回滚、灰度这些步骤都被显式写明,才认为它合格。把责任写进配置,本身就是一种自我保护。
五、Go 适合做 AI 系统的骨架,但不必包办所有事情
我不会因为喜欢 Go,就把模型训练、数据分析和所有实验脚本都改写成 Go。Python 在机器学习、数据处理和模型生态上的优势依然明显。更合理的组合是:模型和实验使用最合适的工具,Go 承担长期运行的服务骨架。
一个常见边界可以这样划分:
- Python 负责模型实验、离线处理和数据科学工作;
- Go 负责 API 网关、鉴权、会话、限流、任务队列和流式转发;
- 数据库、消息队列和模型服务通过稳定的 HTTP、RPC 或消息协议连接;
- 每个服务都定义超时、错误格式、健康检查和优雅退出规则。
这样做的核心不是语言崇拜,而是把高变化和高稳定的部分解耦。
我自己的小经验:在做 RAG 项目时,我用 Python 跑离线评测和向量入库脚本,用 Go 写对外网关和流式接口。两个仓库、两套 CI、两套发布节奏。Go 这一头一旦稳定下来,可以连续几周不动;Python 那一头可以放心改模型、改提示词,不必担心牵连线上服务。这种"动和不动"的拆开,比任何框架都更能撑住一个长期迭代的 AI 项目。
六、我与 AI 协作写 Go 的真实流程
如果只能给一条建议,我会告诉刚开始在 Go 项目里重度使用 AI 的人:让 AI 先写骨架,再让它补血肉;自己守住接口和边界。具体来说,我每天会按下面这五步走。
一、写清单再写代码。 在让 AI 动手之前,我会先用一段简短的话把约束列出来:函数会从哪里被调用、必须接受哪些参数、必须返回什么、错误如何向上抛、超时和取消从哪里来。清单不写,AI 几乎一定会乐观。
二、先要"完整责任"的版本,再要"短小直白"的版本。 第一遍我会让 AI 写一个"绝不在生产里 panic"的版本——所有外部依赖都注入、所有错误都带上下文、所有取消点都连到 ctx。第二遍才让 AI 抽取小函数、合并判断、补充注释。颠倒顺序,常常先得到一份"漂亮但脆弱"的代码。
三、让 AI 自我审查,而不是由我从零挑刺。 仓库里放一份 checklist(参见第二节那五条),每次提交前让 AI 对照清单再写一遍"自审报告"。这个习惯能省掉一半的 review 回合。
四、CI 上把 Go 自己的工具放在最前面。 go vet、go test -race ./...、staticcheck、golangci-lint 的检查结果,是给 AI 反馈最直接的语言——它看见红字,下次就能少犯。把这一组检查固定到 PR 机器人上,比反复在 prompt 里强调"记得加 ctx"更管用。
五、长跑任务必有观测点。 不管 AI 写出多少 go func(),我都会要求每个 goroutine 在退出路径里打 defer 日志,把"为什么停、停了多久、最后一帧在做什么"留下来。一两个月后回头看,这点投入的回报非常高。
我自己的小经验:有一个阶段我以为"AI 写得多 = 进度快",后来发现,真正决定上线速度的,不是 AI 一小时能生成多少函数,而是它一次生成里能被原样编译、一次跑通、一次部署的比例。Go 的类型系统、显式错误处理和静态工具链,恰好把这三个比例同时抬高。
给读者的启示
- **如果 AI 应用的后端主要负责网络调用、并发编排和服务治理,Go 值得优先考虑。**它的类型系统、标准工具和运行时能力,能让生成代码更容易被编译器和审查流程约束。
- **不要只让 AI 生成主路径,要先要求它写清楚错误、超时、取消、关闭和重试。**Go 的显式语法给了我们一个很好的审查入口。
- **资源少不代表可以忽略资源边界。**并发数、响应大小、队列长度和请求时限,都应该在代码或配置里明确表达。
- **把 Go 当成 AI 系统的工程骨架,而不是万能语言。**模型实验用合适的工具,稳定的服务边界用 Go 固定下来,往往比强行统一技术栈更可靠。
- **AI 写 Go 的关键不是"信它写得多快",而是"信它写得多稳"。**一个能被
go vet和go test -race同时通过的实现,比一段看起来优雅的代码更有价值。
对我来说,AI 编程时代选择 Go 的理由,最后并不是"它写得更快"或者"它一定更省资源",而是它会持续提醒我:**每个请求都有失败的可能,每个资源都需要释放,每个后台任务都必须知道什么时候结束。**这正是 AI 最容易遗漏、而线上系统最不能遗漏的部分。