Go 基础体系 · 第 110/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go 接入 Ollama 与本地模型:生成、流式、模型管理和资源限制

本文以 Go 1.26.4、Ollama v0.11.x 和 JSON API 为稳定基线。生产必须锁定镜像、模型 digest、Modelfile、推理参数与驱动,并按实际版本重跑 API 契约和质量评测。文中模型名仅作示例。

Ollama 通过 HTTP 提供本地模型加载、聊天、生成和 Embedding,适合开发、离线与数据驻留。“本地”不等于免费或安全:权重、KV cache 和推理消耗磁盘与内存,网络、权限、内容和供应链仍需治理。

1. 先理解 Ollama 的系统边界

Go 服务是认证、租户、预算和业务协议的拥有者;Ollama 是受控推理后端。浏览器和不可信客户端不应直连 Ollama。模型名称、参数和 endpoint 由服务端配置,而不是从请求原样透传。

Client -> Go API (auth, quota, prompt, audit)
              -> bounded queue/semaphore
              -> Ollama HTTP API
                   -> model weights + CPU/GPU + KV cache

Ollama 不替代会话、RAG 权限、工具确认和内容审核;自有 Model 接口用于隔离后端类型。

2. 固定版本、模型 digest 和配置

浮动 latest 会让同一发布悄悄得到不同权重或模板。拉取后记录 ollama version、模型清单中的 digest、size、quantization、parameter size、context capability 和 Modelfile hash。镜像、GPU runtime 与驱动同样进入发布清单。

ollama:
  base_url: http://127.0.0.1:11434
  chat_model: registry.internal/llama-local:2026-08-q4
  chat_digest: sha256:0123456789abcdef
  embedding_model: registry.internal/embed-zh:2026-08-f16
  request_timeout: 90s
  response_header_timeout: 10s
  max_request_bytes: 1048576
  max_stream_bytes: 8388608
  max_concurrent_chat: 2
  max_concurrent_embed: 4
  keep_alive: 10m

启动时用 show/tags API 核对模型和 digest,不在首个用户请求中 pull 权重。Base URL 只接受管理员配置的受信地址,禁止用户 URL 和自动重定向。

3. 模型大小、量化与硬件资源

权重内存可粗估为参数量乘每参数位数再除 8,加上量化元数据;7B 的 Q4 权重约数 GB,实际加载还有运行时缓冲。KV cache 随层数、上下文 token、并发序列和精度增长,长上下文常比权重更快耗尽显存。模型部分卸载到 CPU 会显著降低 tokens/s。

量化 Q4/Q5/Q8 在容量、速度和质量间取舍,不同量化要分别评测。VRAM 不够会退到系统内存或加载失败;swap 会让延迟失控。容量测试记录冷加载、TTFT、tokens/s、峰值内存、功耗与并发退化。

为 KV cache 和上下文峰值留余量;容量报告标明硬件与驱动。

4. API 不等于 OpenAI 兼容层

Ollama 原生 API 常见 /api/generate/api/chat/api/embed/api/tags/api/show/api/pull。即使部署还提供 OpenAI-compatible 路由,字段、流格式、usage 和工具支持也可能不同;适配器应明确选择一种协议并做契约测试。

type ChatRequest struct {
	Model     string         `json:"model"`
	Messages  []ChatMessage  `json:"messages"`
	Stream    bool           `json:"stream"`
	Options   map[string]any `json:"options,omitempty"`
	KeepAlive string         `json:"keep_alive,omitempty"`
}

type ChatMessage struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

wire 类型留在 internal/ollama,适配器归一化完成原因、token 和耗时。未知字段可兼容读取,关键字段或完成标志损坏则返回协议错误。

5. 构造非流式聊天请求

对短结构化任务可使用 stream:false,整体响应更易重试和校验,但服务端需要在内存中保存完整输出。请求先校验消息数、每条字节、角色顺序、模型 allowlist、上下文和输出上限。

func (c *Client) Chat(ctx context.Context, input ChatRequest) (ChatResponse, error) {
	body, err := json.Marshal(input)
	if err != nil {
		return ChatResponse{}, fmt.Errorf("encode chat request: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		c.baseURL+"/api/chat", bytes.NewReader(body))
	if err != nil {
		return ChatResponse{}, fmt.Errorf("build chat request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return ChatResponse{}, fmt.Errorf("call ollama chat: %w", err)
	}
	defer resp.Body.Close()
	return decodeChatResponse(resp, c.maxResponseBytes)
}

非 2xx body 有界读取并按 status 分类;所有路径关闭 body。长期复用 Transport 并限制各阶段,整体生成由 context deadline 控制。

6. Ollama 流是 NDJSON,不是 SSE

原生流通常返回多个 JSON 对象,每个对象以换行分隔;它没有 SSE 的 data: 字段、空行事件和 [DONE] 约定。TCP read 可能拆开一个 JSON 或合并多行,必须使用 json.Decoder 连续解码,不能把一次 Read 当一个 token。

{"model":"local-chat","message":{"role":"assistant","content":"Go "},"done":false}
{"model":"local-chat","message":{"role":"assistant","content":"并发"},"done":false}
{"model":"local-chat","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","prompt_eval_count":42,"eval_count":18}

Chunk 不是 token,也不保证是句子。聊天读取 message.content,generate 读取 response;工具调用、thinking 和图片字段按锁定版本显式处理。只有收到 done:true 的最终对象才算完整,普通 EOF 且没有 done 是 unexpected_eof,结果标为 partial。

7. 有界流式解码与下游背压

json.Decoder 能跨网络分片解析对象,但仍需限制单对象和全流大小。可在响应 body 外包累计 reader,超过上限主动取消;每个 chunk 解码后验证模型名、字段和输出累计长度。

func decodeStream(ctx context.Context, body io.Reader, sink func(ChatChunk) error) error {
	decoder := json.NewDecoder(io.LimitReader(body, 8<<20))
	completed := false
	for decoder.More() {
		if err := ctx.Err(); err != nil {
			return context.Cause(ctx)
		}
		var chunk ChatChunk
		if err := decoder.Decode(&chunk); err != nil {
			return fmt.Errorf("decode ollama stream: %w", err)
		}
		if err := sink(chunk); err != nil {
			return fmt.Errorf("send chat chunk: %w", err)
		}
		if chunk.Done {
			completed = true
			break
		}
	}
	if !completed {
		return io.ErrUnexpectedEOF
	}
	return nil
}

生产用 counting reader 区分上限和 EOF,并拒绝 done 后尾随对象。同一 goroutine 读写可自然背压;channel 必须有界且不能丢文本,满载就取消。浏览器使用自有 SSE/NDJSON 协议。

8. 完成统计与性能计算

最终对象可能包含 total_durationload_durationprompt_eval_count/durationeval_count/duration。duration 通常是纳秒量纲,按锁定 API 文档确认。生成速度可用 eval_count / eval_duration.Seconds(),预填充和生成需分开看。

分别记录 queue wait、header、首 chunk 与完成时间,避免 tokens/s 掩盖长队列。usage 缺失时标未知。

完成原因如 stop、length、unload 或版本特定值要映射到领域枚举。达到 context/output 上限可能给出语法未闭合的结构化输出,应先检查 done reason,再解析 JSON。客户端取消通常没有最终 usage,费用虽非云账单,GPU 时间仍已消耗。

9. Context、停止生成和资源清理

每次请求用 NewRequestWithContext。用户停止、浏览器断开、队列超时、服务关停都会 cancel;Transport 随后中止 body read,Ollama 应停止对应推理并释放调度资源,但释放时延需实测。错误分类先检查 context.Cause

ctx, cancel := context.WithTimeout(parent, 90*time.Second)
defer cancel()

stream, err := client.StartChat(ctx, request)
if err != nil {
	return fmt.Errorf("start local generation: %w", err)
}
defer stream.Close()
if err := relay(ctx, stream, sink); err != nil {
	return fmt.Errorf("relay local generation: %w", err)
}

Close 应幂等,所有路径关闭 response body;如启动辅助 goroutine必须等待。首个可见 chunk 后不能透明重试:新生成会重复或分叉。保存部分文本时带 partial=true、终止原因和 generation ID,不把它当完整 assistant message 自动进入下一轮。

10. 并发、排队和模型驻留

本地 GPU 容量是硬边界。并发会增加 KV cache,可能降低每路 tokens/s、触发 CPU offload 或 OOM。为 chat、embedding 和模型管理分别设置 semaphore;队列有长度、等待 deadline 和按租户公平性。满载时尽早返回 429/503,不能无界堆 goroutine。

keep_alive 控制模型在内存中停留的意图,精确行为以版本为准。太短导致反复冷加载,太长使多个大模型争内存。生产通常每节点只承载少量模型,通过路由把同模型请求送到已热实例。预热用固定小请求,但 readiness 不应因每次模型短暂生成失败而重启进程。

用到达率乘平均持续时间估算在途量,并按 context 长度分桶。压测覆盖冷启动、长 prompt、慢客户端、取消和混部,记录排队与 OOM。

11. 上下文窗口和提示预算

num_ctx 或对应选项影响可用上下文与 KV cache,不能仅因模型声称支持很长窗口就配置最大值。输入 token 包括 system、历史、RAG 文档、工具定义和当前消息;还要预留输出。超限时应用先裁剪,不依赖后端静默截断。

固定 system 规则、当前任务和未完成 tool call/result 对优先保留;旧闲聊做可追溯摘要;RAG chunk 去重并按证据预算选择。不同模型 tokenizer 不同,预估器用于请求前保护,最终 prompt eval count 用于观测校准。字符数只能作保守兜底。

推理参数只在批准范围可调;固定 seed 不保证跨版本、驱动和并发确定,回归应断言结构与质量区间。

12. 结构化 JSON 与工具调用

若锁定模型与 API 支持 format/schema 或 tools,可请求结构化结果;模型能力和模板决定实际稳定性。返回仍是不可信输入:完成后限制总字节,严格 JSON 解码,拒绝未知字段和尾随值,再做业务校验。

{
  "model":"registry.internal/local-chat:2026-08-q4",
  "messages":[{"role":"user","content":"判断这段 Go 代码风险"}],
  "stream":false,
  "format":{
    "type":"object",
    "additionalProperties":false,
    "required":["risk","reason"],
    "properties":{
      "risk":{"enum":["low","medium","high"]},
      "reason":{"type":"string","maxLength":400}
    }
  }
}

工具调用只是模型提议。Go 服务按白名单查名称,校验 arguments,使用真实 Actor 授权;写工具需用户确认、幂等键和审计。流式工具参数完整结束前不能执行。模型模板升级可能改变 tool call 格式,必须有协议和行为回归。

13. Embedding API 与本地 RAG

Embedding 模型与聊天模型用途不同。批量 /api/embed 前限制文本条数、每条 token 和总字节;返回验证数量、顺序、维度、NaN/Inf。模型 digest、维度、归一化和距离函数构成索引版本。

type EmbedRequest struct {
	Model string   `json:"model"`
	Input []string `json:"input"`
}

type EmbedResponse struct {
	Model      string      `json:"model"`
	Embeddings [][]float32 `json:"embeddings"`
}

按规范化文本 hash+模型 digest 缓存向量。更换量化或模型即使维度相同,也不能假设空间兼容,应新建索引、回填、评测再切换。Embedding 与生成混部会争抢 GPU,分别限并发;批处理节省开销却增加队首等待,设置最大 batch age。

本地化减少文档发往外部供应商,但向量库、日志和备份仍含敏感派生数据。RAG 检索继续执行 tenant/ACL filter,不能因为推理在本机就取消权限控制。

14. 模型拉取、创建和供应链

pull/create/delete/copy 是管理面,不暴露给普通请求或模型工具。拉取由发布流程执行,验证 registry、digest、许可证、来源与扫描结果。

ollama pull registry.internal/local-chat:2026-08-q4
ollama show registry.internal/local-chat:2026-08-q4
ollama list

Modelfile 可能包含 base model、template、system、adapter 和参数,是可执行发布配置,要代码审查和版本化。模板控制角色格式,错误模板会让同权重质量骤降或泄漏 system 内容。Adapter 与 base 必须兼容。删除模型前检查没有 active routing 和在途加载,保留可回滚 digest。

磁盘设水位,只删除未引用 digest;下载恢复后仍校验最终 digest。

15. 网络暴露、认证和多租户隔离

默认优先只监听 loopback,由 Go API 或带认证的反向代理访问。需要远程时使用 TLS/mTLS、网络策略、Host/Origin 验证、body/连接/速率限制和独立服务身份。不要把未认证的 11434 端口暴露公网;模型 API 可被滥用耗尽 GPU,也可能泄漏已安装模型和生成内容。

Ollama 不是完整多租户边界。Go 层隔离 prompt、历史、RAG、缓存、流和日志,按租户限并发与 token;用户数据不写 Modelfile 或模型目录。

容器非 root、只读根文件系统(模型卷单独只读/受控写)、最小设备权限、限制 capabilities 和网络出口。GPU device 授权只给推理容器。管理员 API 走独立网络与凭据,不与生成入口共用公开路由。

16. 失败分类、重试和恢复

分类包括模型不存在/加载失败、输入无效、资源不足、排队超时、连接、协议损坏、取消、deadline 和异常 EOF。错误消息可能含路径或内部信息,对用户只映射稳定 code。非 2xx body 有界读取;不要把 HTML 代理错误交给模型。

尚未输出时,连接失败或明确 5xx 可在剩余 deadline 内有限重试;模型不存在、4xx、OOM、取消不盲重试。OOM 应降低调度并告警,而不是立即并发重放。首 chunk 后失败返回 partial。重试由 Go 适配器一层负责,反向代理不要再自动重试生成 POST。

实例崩溃后在途生成无法精确续传,客户端用 generation ID 创建新轮;不要宣称从相同随机状态恢复。异步任务持久保存请求引用、模型 digest、预算和状态,worker lease 过期后重做仍需业务幂等。已执行工具副作用由工具记录恢复,与文本生成分开。

17. 测试与评测本地模型

HTTP 单测用 httptest.Server 发送 NDJSON:一个对象拆成多次 Write、多对象一次 Write、done 缺失、done 后尾随、超大对象、非 2xx、慢 header 和中途断开。测试取消后 handler/read goroutine 退出,首输出后 attempt 不增加。

func TestStreamRequiresDone(t *testing.T) {
	body := strings.NewReader("{\"message\":{\"content\":\"partial\"},\"done\":false}\n")
	err := decodeStream(context.Background(), body, func(ChatChunk) error { return nil })
	if !errors.Is(err, io.ErrUnexpectedEOF) {
		t.Fatalf("expected unexpected EOF, got %v", err)
	}
}

真实模型评测集覆盖中文、长上下文、结构化 JSON、工具选择、RAG 引用、拒答、提示注入和领域术语。比较不同量化的任务成功率,而不只看困惑度或速度;记录硬件、冷/热状态、prompt/model digest、tokens/s、TTFT、峰值内存和功耗。模型或模板任何变化都跑同一集。

CI 的解析和客户端测试不依赖 GPU;受控硬件 job 才运行在线模型测试,设置时间与资源上限。概率输出断言 schema、关键事实和评分区间,不逐字 golden。go test -race 覆盖并发流、取消、队列与统计聚合。

18. 诊断与可观测性

指标包括请求结果、排队、在途、模型加载、TTFT、总延迟、prompt/eval token、tokens/s、取消、partial、HTTP/protocol error、RAM/VRAM、GPU 利用率、温度和磁盘。模型 digest、endpoint pool 和结果码是受控低基数标签;user、prompt 和输出不作标签。

没有输出时依次检查:请求是否还在队列、模型是否存在/digest 是否匹配、load 是否 OOM、response header 是否到达、NDJSON 是否被代理缓冲、context 是否提前取消。速度骤降时看 CPU offload、context 长度、并发、热降频、swap 和其他 workload。只看 Ollama 进程 CPU 会漏掉 GPU 瓶颈。

Trace 分解 API validation、queue、Ollama header、first chunk、stream completion。默认不记录 prompt/response;质量采样需用户授权、脱敏、加密和短保留。日志将 Ollama request 关联到自有 generation ID,不信任上游自由文本 ID。

19. 成本和容量治理

本地成本包含硬件、空闲、功耗、磁盘、运维和机会成本。除每百万 token 摊销,还要计算 SLA 冗余与峰值利用率;本地或云端的选择应由测量决定。

每请求限制输入/输出 token、上下文、队列等待和总 deadline;每租户限制并发、每分钟 token 和日资源预算。大模型与小模型按已评测路由,简单分类可走小模型,但不能临时切未经验证模型。缓存只用于允许的确定任务,key 含模型/模板/参数版本与权限,敏感结果不跨用户。

容量报告给出短/长 prompt 混合下 P50/P95/P99、TTFT、tokens/s、OOM 和功耗。预留一台故障或滚动升级容量。队列积压超过可接受等待时拒绝或转异步,不能让请求超时后仍在后台占 GPU。

20. 部署、升级与优雅关闭

构建固定 Go 1.26.4、Ollama 镜像和模型 digest;目标节点预拉、验证并预热后才加入路由。readiness 检查磁盘、模型与容量,liveness 只判断进程卡死。

滚动升级先新增热实例,再稳定迁移流量,旧实例停止接收新请求并等待在途生成至 grace deadline;之后取消流、关闭 HTTP idle connection 和进程。模型、template 与二进制独立版本化但作为一个 release manifest 灰度。回滚路由到保留的旧 digest,不现场重新下载浮动 tag。

上线清单:端口不裸露;管理面隔离;模型/digest/Modelfile 可复现;资源余量经过长上下文并发压测;队列和并发有界;NDJSON 按对象解析并要求 done;全流和错误体限大小;context 可取消且无 goroutine 泄漏;首输出后不重试;partial 明确;Embedding 与索引版本一致;提示和遥测按租户隔离;质量、速度、功耗和故障回归通过。满足这些条件,本地模型才是可运维的推理服务,而不是开发机上偶然可用的进程。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。