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

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 必须被接收,类型转换必须写明,defercontext 和 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 先写代码,再让它根据清单做一次反向审查。

  1. 每个外部调用是否有超时和取消机制?
  2. 每个打开的资源是否有明确的关闭点?
  3. 每个错误是否带有足够上下文,调用方是否真的能处理?
  4. 每个 goroutine 是否有退出条件,是否可能无限堆积?
  5. 失败重试是否有次数、退避和幂等性约束?

我自己的小经验: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 用户启动,攻击面和排查范围都更容易控制。

我通常会把配置放到环境变量或配置文件里,把模型密钥、数据库地址和限流参数与二进制分离。上线时重点保证三件事

  1. 构建产物已通过测试与 CI 流水线,并打上可回滚的版本标签;
  2. 收到停止信号后立即停止接收新请求,等待已有请求在期限内结束;
  3. 日志输出请求 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 帮我写 Dockerfilek8s manifest 和 GitHub Actions 流水线时,我会同时要求它输出"上线检查清单"——不是写得越花越好,而是只有当健康检查、超时、回滚、灰度这些步骤都被显式写明,才认为它合格。把责任写进配置,本身就是一种自我保护

五、Go 适合做 AI 系统的骨架,但不必包办所有事情

我不会因为喜欢 Go,就把模型训练、数据分析和所有实验脚本都改写成 Go。Python 在机器学习、数据处理和模型生态上的优势依然明显。更合理的组合是:模型和实验使用最合适的工具,Go 承担长期运行的服务骨架

一个常见边界可以这样划分:

  1. Python 负责模型实验、离线处理和数据科学工作;
  2. Go 负责 API 网关、鉴权、会话、限流、任务队列和流式转发;
  3. 数据库、消息队列和模型服务通过稳定的 HTTP、RPC 或消息协议连接;
  4. 每个服务都定义超时、错误格式、健康检查和优雅退出规则。

这样做的核心不是语言崇拜,而是把高变化和高稳定的部分解耦

我自己的小经验:在做 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 vetgo test -race ./...staticcheckgolangci-lint 的检查结果,是给 AI 反馈最直接的语言——它看见红字,下次就能少犯。把这一组检查固定到 PR 机器人上,比反复在 prompt 里强调"记得加 ctx"更管用

五、长跑任务必有观测点。 不管 AI 写出多少 go func(),我都会要求每个 goroutine 在退出路径里打 defer 日志,把"为什么停、停了多久、最后一帧在做什么"留下来。一两个月后回头看,这点投入的回报非常高

我自己的小经验:有一个阶段我以为"AI 写得多 = 进度快",后来发现,真正决定上线速度的,不是 AI 一小时能生成多少函数,而是它一次生成里能被原样编译、一次跑通、一次部署的比例。Go 的类型系统、显式错误处理和静态工具链,恰好把这三个比例同时抬高。

给读者的启示

  1. **如果 AI 应用的后端主要负责网络调用、并发编排和服务治理,Go 值得优先考虑。**它的类型系统、标准工具和运行时能力,能让生成代码更容易被编译器和审查流程约束。
  2. **不要只让 AI 生成主路径,要先要求它写清楚错误、超时、取消、关闭和重试。**Go 的显式语法给了我们一个很好的审查入口。
  3. **资源少不代表可以忽略资源边界。**并发数、响应大小、队列长度和请求时限,都应该在代码或配置里明确表达。
  4. **把 Go 当成 AI 系统的工程骨架,而不是万能语言。**模型实验用合适的工具,稳定的服务边界用 Go 固定下来,往往比强行统一技术栈更可靠。
  5. **AI 写 Go 的关键不是"信它写得多快",而是"信它写得多稳"。**一个能被 go vetgo test -race 同时通过的实现,比一段看起来优雅的代码更有价值。

对我来说,AI 编程时代选择 Go 的理由,最后并不是"它写得更快"或者"它一定更省资源",而是它会持续提醒我:**每个请求都有失败的可能,每个资源都需要释放,每个后台任务都必须知道什么时候结束。**这正是 AI 最容易遗漏、而线上系统最不能遗漏的部分。