Go 语言原本 under the hood
Go 语言原本
第 20 章 · AI 推理与服务

20.1 推理运行时与 FFI

第 18、19 章反复出现的那道 FFI 边界,到了大模型这里有了它最当下的一副面孔。训练大模型几乎是 Python 与 CUDA 的天下,可当一个模型训好、要被部署去服务千万次请求时,舞台换了主角, Go 在这一层站得很稳。这一章讲 Go 如何承担 AI 的推理与服务,而第一节要先解决最底层的问题: Go 自己并不做矩阵乘,它得接入一个本地推理运行时,而这次接入,又是第 18 章那道边界。

20.1.1 训练在 Python,推理与服务在哪

先把分工看清。训练是一件研究性的事:试不同的结构、调超参、看损失曲线,要的是表达的灵活与 生态的丰富,Python 加 PyTorch、再加 CUDA,是它无可争议的家园。Go 在训练这一侧没有位置, 也不必有。

推理与服务是另一种事。模型已经冻结,权重不再变,剩下的问题清一色是系统问题:怎么用 高吞吐、低延迟地服务大量并发请求;怎么把一个几 GB 的模型稳定地装进内存、喂给设备;怎么做成一个 能一键部署、抗住生产流量、好监控好运维的服务。把这串问题念一遍,会发现它正是 Go 被设计出来要 解决的那一类:静态编译的单文件部署、天生的并发、可控的内存、成熟的网络栈。所以「训练归 Python、 推理与服务归 Go」不是偶然的站队,而是两种工作的性质把两门语言各自吸到了它擅长的那一层。 当下最流行的本地大模型服务 Ollama,正是用 Go 写就的。

20.1.2 接入本地推理运行时:又是那道边界

Go 站在服务层,可矩阵乘、注意力、量化这些真正吃算力的活儿,Go 并不亲自做,它把这些交给一个 本地推理运行时:llama.cpp/ggml(C/C++)、ONNX Runtime、或厂商的运行时。这些运行时是 用 C/C++ 写的高度优化的张量计算库,能调度 CPU 的 SIMD、也能驱动 GPU。Go 要用它们, 靠的还是 cgo。

于是第 18 章的整套机制原样回来了。Ollama 的文档说得很直白:它「包含用 CGO 编译的原生代码」, 原生推理引擎用 CMake 构建,按需编译出 CUDA、ROCm、Vulkan 等后端。这意味着 15.6 与第 18 章讲过的 代价一并继承:构建期需要 C/C++ 工具链、失去纯 Go 的轻便(15.6.4), 运行期每次跨界要付那笔状态转换的税。Ollama 文档甚至点出了一个 15.6 没细说的脆弱处:Go 与 C 两侧共享的数据结构「可能不同步,导致意外崩溃」,这正是 cgo 把两个世界缝在一起时,缝合处最阴险的 一类 bug。

关键的设计纪律也和 18.1 一致:粗粒度。你绝不会为每一个算子跨一次界,而是一次 cgo 调用就让 运行时「把这一批 token 前向算完」,把成百上千个算子的执行整个留在 C 那侧的一次调用里。 18.1 说 GPU 负载天生是细粒度命令的洪流,而推理运行时的可贵之处,正在于它替你把这股洪流挡在了 边界的 C 一侧,只在 Go 面前留下一个粗粒度的接口。

20.1.3 张量的所有权穿过边界

接入之后,最要紧的工程问题是内存,因为大模型的内存以 GB 计,一次跨界拷贝的代价是实打实的。 把 18.3 那张内存地图套到推理上,所有权的划分一目了然:

flowchart TB
    subgraph go["Go 堆(GC 管理)"]
        req["请求、调度、HTTP/gRPC 状态"]
        tok["输入 token、输出文本"]
    end
    subgraph native["原生运行时内存(GC 之外)"]
        weights["模型权重 数 GB<br/>mmap 加载,进程生命周期常驻"]
        kv["KV cache 每请求<br/>随上下文增长"]
        act["激活值、中间张量"]
    end
    tok -. "输入张量:小,拷过去" .-> act
    act -. "输出 logits:小,拷回来" .-> tok

要点有三。其一,模型权重不在 Go 堆里。它们由运行时分配,通常用 mmap 把 GGUF 这类权重 文件直接映射进地址空间,在整个进程生命周期常驻。这是一块几 GB 的、GC 完全不该插手的内存, Go 的回收器既不扫描它、也不回收它,正是 18.3「设备/原生指针不是 Go 指针」的直接体现。 若错把这么大一块东西塞进 Go 堆,会给 GC 带来灾难性的扫描负担。

其二,KV cache 是每请求的大块状态。自回归生成时,运行时为每个序列维护一份键值缓存, 随上下文长度增长,它同样活在原生内存里,由运行时管理生命周期。Go 这侧持有的,只是一个指向它的 句柄。

其三,只有小东西才值得跨界拷贝。输入的 token、输出的 logits 与文本,相对权重是小量, 拷过边界无妨。真正的大块,权重、KV cache、激活值,全程留在原生一侧,Go 绝不把它们搬进自己的堆。 这就是推理里的「零拷贝」精神:让 GB 级的数据待在原地,Go 只递句柄、只搬小数据, 绝不让一次请求触发一次 GB 级的跨界搬运。把这条守住,FFI 边界的内存成本才不会失控。

20.1.4 进程内还是进程外

最后是 18.1.4 那个选择,在推理部署里再次浮现,而且尤为关键:把运行时 嵌进同一个进程,还是让它另起一个进程?

进程内(cgo 嵌入)。ggml/llama.cpp 直接 cgo 链进 Go 服务,一个二进制跑天下, Ollama 就是这条路。优点是没有进程间通信、没有序列化,输入输出在同一地址空间里直接传句柄, 延迟最低、部署最简。代价是全盘继承 cgo:构建要 C 工具链、失去交叉编译的轻便,而且, 一个崩在 C 里的推理运行时会把整个 Go 服务一起带走,18.2 说过运行时管不住 C 的崩溃, 一次原生段错误就是整个进程的终结。

进程外(IPC/RPC)。 把推理运行时跑成一个独立服务(如 llama.cpp 自带的 server、 vLLM、Triton),Go 进程通过 gRPC 或 HTTP 与它通信。Go 这侧于是一行 cgo 都没有,纯 Go 的工具链 性质全数保住,推理进程崩了也只是一个可重启的依赖,不会拖垮 Go 服务。代价是多了一道序列化与 跨进程通信,延迟略增,部署多了一个组件要编排。

没有放之四海的答案。要极致延迟、要单文件部署、且愿意背上 cgo,选进程内;要隔离、要纯 Go、 要把推理当作一个可独立伸缩与重启的后端,选进程外。这恰是 18.1.4 说的那句话:FFI 边界不是 铁律,而是一个可以挪动的设计选择,在推理部署里,它直接决定了你的系统拓扑。

小结

模型训好之后,服务它是一个系统问题,而系统问题正是 Go 的主场,这是 Go 站上 AI 推理与服务层的 根由。但 Go 不亲自算张量,它经 cgo 接入 ggml、ONNX Runtime 这类原生运行时,于是第 18 章那道 边界连同它的全部代价一并回归:粗粒度调用的纪律、构建期的 C 工具链负担、运行期的跨界税, 以及 Ollama 点名的「两侧数据结构不同步」这类缝合处的崩溃。内存上,把 18.3 的地图套过来, GB 级的权重(常 mmap 常驻)、每请求的 KV cache、激活值全留在原生一侧,Go 只递句柄、只搬 token 与 logits 这些小数据,守住推理的「零拷贝」。而把运行时嵌进进程还是另起一个进程, 是 18.1.4 那个可挪动边界的又一次抉择,直接决定系统拓扑。

权重与张量的家安顿好了,下一节走进数据本身:20.2 看一段文本如何被切成 token、 又如何变回文本,以及为什么第 5 章关于字符串与字节的那套机制,在这里关系到正确与否。

延伸阅读的文献

  1. Ollama. Development / CGO and native runtime. https://github.com/ollama/ollama (用 CGO 嵌入原生推理引擎、CMake 构建多后端、两侧数据结构同步的脆弱性)
  2. Georgi Gerganov 等. llama.cpp 与 ggml. https://github.com/ggml-org/llama.cpp ,https://github.com/ggml-org/ggml (C/C++ 张量运行时、GGUF 权重格式与 mmap 加载、KV cache)
  3. Microsoft. ONNX Runtime C API. https://onnxruntime.ai/docs/api/c/ (以 C API 暴露的推理运行时,可经 cgo 接入 Go)
  4. vLLM. vLLM: Easy, Fast, and Cheap LLM Serving. https://docs.vllm.ai/ (进程外推理服务的代表,Go 经 RPC 接入的对端)
  5. 本书 15.6 cgo18.1 跨越 FFI 边界18.3 显存与垃圾回收的分界20.2 分词与张量20.3 服务、批处理与流式