6. ollama模型缓存
最初使用 ollama 时,我就对它的模型格式有点好奇,类似容器镜像的模型名称、分层存储结构,manifest 保存 layer 列表,blob 存储二进制文件……后来发现他们的模型仓库就是基于 registry 改造的,模型格式上也参考了 OCI。
AI 模型的体积都比较大,我之前一直想开发个离线仓库,不过 ollama 目前还没公开官方 registry 的实现细节,只能从 ollama 的代码中逆向分析。
我最终就使用 caddy 和 ollama 糊了一个可以本地部署的模型仓库,支持从 ollama 官方仓库缓存镜像给内网设备使用:ollama-registry。
下面是之前开发过程里的一些思考,这里做一下记录。
下面以 gemma2:2b 模型为例进行分析。
ollama 就像 GPT 界的 docker(ollama 创始人也是来自 Docker 公司),它的运行模式也是 C/S 架构:
- ollama serve 命令启动服务端,默认监听 localhost:11434,它是模型拉取、删除、推送、运行的实际执行者
- ollama 命令行提供与服务端互通的所有功能,两者之间使用 HTTP 协议通信,比如 ollama pull gemma2:2b 命令实际上是调用 localhost:11434/api/pull 接口
ollama 服务端的全部接口如下:
func (s *Server) GenerateRoutes() http.Handler {
config := cors.DefaultConfig()
config.AllowWildcard = true
config.AllowBrowserExtensions = true
config.AllowHeaders = []string{"Authorization", "Content-Type", "User-Agent", "Accept", "X-Requested-With"}
openAIProperties := []string{"lang", "package-version", "os", "arch", "runtime", "runtime-version", "async"}
for _, prop := range openAIProperties {
config.AllowHeaders = append(config.AllowHeaders, "x-stainless-"+prop)
}
config.AllowOrigins = envconfig.Origins()
r := gin.Default()
r.Use(
cors.New(config),
allowedHostsMiddleware(s.addr),
)
r.POST("/api/pull", s.PullHandler)
r.POST("/api/generate", s.GenerateHandler)
r.POST("/api/chat", s.ChatHandler)
r.POST("/api/embed", s.EmbedHandler)
r.POST("/api/embeddings", s.EmbeddingsHandler)
r.POST("/api/create", s.CreateHandler)
r.POST("/api/push", s.PushHandler)
r.POST("/api/copy", s.CopyHandler)
r.DELETE("/api/delete", s.DeleteHandler)
r.POST("/api/show", s.ShowHandler)
r.POST("/api/blobs/:digest", s.CreateBlobHandler)
r.HEAD("/api/blobs/:digest", s.HeadBlobHandler)
r.GET("/api/ps", s.PsHandler)
// Compatibility endpoints
r.POST("/v1/chat/completions", openai.ChatMiddleware(), s.ChatHandler)
r.POST("/v1/completions", openai.CompletionsMiddleware(), s.GenerateHandler)
r.POST("/v1/embeddings", openai.EmbeddingsMiddleware(), s.EmbedHandler)
r.GET("/v1/models", openai.ListMiddleware(), s.ListHandler)
r.GET("/v1/models/:model", openai.RetrieveMiddleware(), s.ShowHandler)
for _, method := range []string{http.MethodGet, http.MethodHead} {
r.Handle(method, "/", func(c *gin.Context) {
c.String(http.StatusOK, "Ollama is running")
})
r.Handle(method, "/api/tags", s.ListHandler)
r.Handle(method, "/api/version", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"version": version.Version})
})
}
return r
}
顺着 PullHandler,我们可以知道实际执行拉取镜像的方法 PullModel,如下:
func PullModel(ctx context.Context, name string, regOpts *registryOptions, fn func(api.ProgressResponse)) error {
mp := ParseModelPath(name)
// build deleteMap to prune unused layers
deleteMap := make(map[string]struct{})
manifest, _, err := GetManifest(mp)
if errors.Is(err, os.ErrNotExist) {
// noop
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
} else {
for _, l := range manifest.Layers {
deleteMap[l.Digest] = struct{}{}
}
if manifest.Config.Digest != "" {
deleteMap[manifest.Config.Digest] = struct{}{}
}
}
if mp.ProtocolScheme == "http" && !regOpts.Insecure {
return errors.New("insecure protocol http")
}
fn(api.ProgressResponse{Status: "pulling manifest"})
manifest, err = pullModelManifest(ctx, mp, regOpts)
if err != nil {
return fmt.Errorf("pull model manifest: %s", err)
}
var layers []Layer
layers = append(layers, manifest.Layers...)
if manifest.Config.Digest != "" {
layers = append(layers, manifest.Config)
}
skipVerify := make(map[string]bool)
for _, layer := range layers {
cacheHit, err := downloadBlob(ctx, downloadOpts{
mp: mp,
digest: layer.Digest,
regOpts: regOpts,
fn: fn,
})
if err != nil {
return err
}
skipVerify[layer.Digest] = cacheHit
delete(deleteMap, layer.Digest)
}
delete(deleteMap, manifest.Config.Digest)
fn(api.ProgressResponse{Status: "verifying sha256 digest"})
for _, layer := range layers {
if skipVerify[layer.Digest] {
continue
}
if err := verifyBlob(layer.Digest); err != nil {
if errors.Is(err, errDigestMismatch) {
// something went wrong, delete the blob
fp, err := GetBlobsPath(layer.Digest)
if err != nil {
return err
}
if err := os.Remove(fp); err != nil {
// log this, but return the original error
slog.Info(fmt.Sprintf("couldn't remove file with digest mismatch '%s': %v", fp, err))
}
}
return err
}
}
fn(api.ProgressResponse{Status: "writing manifest"})
manifestJSON, err := json.Marshal(manifest)
if err != nil {
return err
}
fp, err := mp.GetManifestPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(fp), 0o755); err != nil {
return err
}
err = os.WriteFile(fp, manifestJSON, 0o644)
if err != nil {
slog.Info(fmt.Sprintf("couldn't write to %s", fp))
return err
}
if !envconfig.NoPrune() && len(deleteMap) > 0 {
fn(api.ProgressResponse{Status: "removing unused layers"})
if err := deleteUnusedLayers(deleteMap); err != nil {
fn(api.ProgressResponse{Status: fmt.Sprintf("couldn't remove unused layers: %v", err)})
}
}
fn(api.ProgressResponse{Status: "success"})
return nil
}
可以看到代码也比较简洁:
- 首先判断是否对 http 链接启用 insecure 选项(这一点与 Docker 类似)
- 拉取 manifest 文件,获取 layers
- 根据 layers 顺序下载 blob
- 校验 blob
- 保存 manifest
manifest 文件示例如下:
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"digest": "sha256:e18ad7af7efbfaecd8525e356861b84c240ece3a3effeb79d2aa7c0f258f71bd",
"size": 487
},
"layers": [
{
"mediaType": "application/vnd.ollama.image.model",
"digest": "sha256:7462734796d67c40ecec2ca98eddf970e171dbb6b370e43fd633ee75b69abe1b",
"size": 1629509152
},
{
"mediaType": "application/vnd.ollama.image.template",
"digest": "sha256:e0a42594d802e5d31cdc786deb4823edb8adff66094d49de8fffe976d753e348",
"size": 358
},
{
"mediaType": "application/vnd.ollama.image.license",
"digest": "sha256:097a36493f718248845233af1d3fefe7a303f864fae13bc31a3a9704229378ca",
"size": 8433
},
{
"mediaType": "application/vnd.ollama.image.params",
"digest": "sha256:2490e7468436707d5156d7959cf3c6341cc46ee323084cfa3fcf30fe76e397dc",
"size": 65
}
]
}
可以看到 manifest 文件的 mediaType 为 application/vnd.docker.distribution.manifest.v2+json,layers 中记录了 blob 的类型、摘要和大小,在 macOS 上,ollama 会把文件存储在 ~/.ollama/models:
➜ ~ tree ~/.ollama/models
/Users/user/.ollama/models
├── blobs
│ ├── sha256-097a36493f718248845233af1d3fefe7a303f864fae13bc31a3a9704229378ca
│ ├── sha256-2490e7468436707d5156d7959cf3c6341cc46ee323084cfa3fcf30fe76e397dc
│ ├── sha256-7462734796d67c40ecec2ca98eddf970e171dbb6b370e43fd633ee75b69abe1b
│ ├── sha256-e0a42594d802e5d31cdc786deb4823edb8adff66094d49de8fffe976d753e348
│ └── sha256-e18ad7af7efbfaecd8525e356861b84c240ece3a3effeb79d2aa7c0f258f71bd
└── manifests
└── registry.ollama.ai
└── library
└── gemma2
└── 2b
默认模型仓库地址为 registry.ollama.ai,官方模型前缀为 library(默认隐藏),最后是模型名和标签。
manifest 和 blob 的完整链接如下:
- https://registry.ollama.ai/v2/library/gemma2/manifests/2b
- https://registry.ollama.ai/v2/library/gemma2/blobs/sha256:7462734796d67c40ecec2ca98eddf970e171dbb6b370e43fd633ee75b69abe1b
- https://registry.ollama.ai/v2/library/gemma2/blobs/sha256:e0a42594d802e5d31cdc786deb4823edb8adff66094d49de8fffe976d753e348
- https://registry.ollama.ai/v2/library/gemma2/blobs/sha256:097a36493f718248845233af1d3fefe7a303f864fae13bc31a3a9704229378ca
- https://registry.ollama.ai/v2/library/gemma2/blobs/sha256:2490e7468436707d5156d7959cf3c6341cc46ee323084cfa3fcf30fe76e397dc
使用 curl 访问 https://registry.ollama.ai/v2/library/gemma2/manifests/2b 可以得到 manifest 文件,而访问 blobs 接口则会有一个重定向:

可以看到这里使用 307 状态码重定向请求,所有模型二进制文件实际托管在 R2,怪不得 ollama 可以托管如此多的大体积模型,原来是背靠 Cloudflare。
为了更好分析请求流程,我使用 caddy 搭建了一个 registry.ollama.ai 反向代理,解析了内部域名 ollama.internal.wbuntu.com,然后执行 ollama pull ollama.internal.wbuntu.com/library/gemma2:2b 测试。
拉取 manifest 的函数为 pullModelManifest,实际上只是调用 makeRequestWithRetry 访问 https://registry.ollama.ai/v2/library/gemma2/manifests/2b,如下:
func makeRequestWithRetry(ctx context.Context, method string, requestURL *url.URL, headers http.Header, body io.ReadSeeker, regOpts *registryOptions) (*http.Response, error) {
anonymous := true // access will default to anonymous if no user is found associated with the public key
for range 2 {
resp, err := makeRequest(ctx, method, requestURL, headers, body, regOpts)
if err != nil {
if !errors.Is(err, context.Canceled) {
slog.Info(fmt.Sprintf("request failed: %v", err))
}
return nil, err
}
switch {
case resp.StatusCode == http.StatusUnauthorized:
// Handle authentication error with one retry
challenge := parseRegistryChallenge(resp.Header.Get("www-authenticate"))
token, err := getAuthorizationToken(ctx, challenge)
if err != nil {
return nil, err
}
anonymous = getTokenSubject(token) == "anonymous"
regOpts.Token = token
if body != nil {
_, err = body.Seek(0, io.SeekStart)
if err != nil {
return nil, err
}
}
case resp.StatusCode == http.StatusNotFound:
return nil, os.ErrNotExist
case resp.StatusCode >= http.StatusBadRequest:
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%d: %s", resp.StatusCode, err)
}
return nil, fmt.Errorf("%d: %s", resp.StatusCode, responseBody)
default:
return resp, nil
}
}
if anonymous {
// no user is associated with the public key, and the request requires non-anonymous access
pubKey, nestedErr := auth.GetPublicKey()
if nestedErr != nil {
slog.Error(fmt.Sprintf("couldn't get public key: %v", nestedErr))
return nil, errUnauthorized
}
return nil, &errtypes.UnknownOllamaKey{Key: pubKey}
}
// user is associated with the public key, but is not authorized to make the request
return nil, errUnauthorized
}
- 第一次请求:服务器响应 401 状态,要求客户端携带认证头
- 第二次请求:客户端使用本地 ed25519 密钥对生成签名写入请求头,从服务端获取到 Token
- 第三次请求:获取到 manifest 对象
这个流程应该是为了兼容匿名访问公开模型和携带认证信息访问私有模型的场景。
下载 blob 的函数为 downloadBlob,逻辑类似 Docker 的镜像分层复用,只会下载本地不存在的 blob 文件,存储 blob 时会把冒号改为横杠以适应文件名格式,如下:
// downloadBlob downloads a blob from the registry and stores it in the blobs directory
func downloadBlob(ctx context.Context, opts downloadOpts) (cacheHit bool, _ error) {
fp, err := GetBlobsPath(opts.digest)
if err != nil {
return false, err
}
fi, err := os.Stat(fp)
switch {
case errors.Is(err, os.ErrNotExist):
case err != nil:
return false, err
default:
opts.fn(api.ProgressResponse{
Status: fmt.Sprintf("pulling %s", opts.digest[7:19]),
Digest: opts.digest,
Total: fi.Size(),
Completed: fi.Size(),
})
return true, nil
}
data, ok := blobDownloadManager.LoadOrStore(opts.digest, &blobDownload{Name: fp, Digest: opts.digest})
download := data.(*blobDownload)
if !ok {
requestURL := opts.mp.BaseURL()
requestURL = requestURL.JoinPath("v2", opts.mp.GetNamespaceRepository(), "blobs", opts.digest)
if err := download.Prepare(ctx, requestURL, opts.regOpts); err != nil {
blobDownloadManager.Delete(opts.digest)
return false, err
}
//nolint:contextcheck
go download.Run(context.Background(), requestURL, opts.regOpts)
}
return false, download.Wait(ctx, opts.fn)
}
这个函数有两个关键操作:
- 准备阶段:通过 HEAD 请求获取到 R2 文件位置并根据 Content-Length 切分 Chunk
- 下载阶段:使用 GET 请求并发下载文件分片,然后合拷贝分片到文件对应位置
文件分片后再并发下载可以提高带宽利用率,如果遇到网络异常,也只需要下载未完成的文件分片,缺点应该是下载期间最大占用两倍存储空间。
在仔细研究了 ollama 代码后,我发现目前还未实现利用 mirror 拉取模型的设计,只能从自定义模型仓库拉取模型,比如我利用 ollama.internal.wbuntu.com 反向代理 registry.ollama.ai 拉取 gemma2:2b 后,本地文件存储如下:
➜ ~ ollama pull ollama.internal.wbuntu.com/library/gemma2:2b
pulling manifest
pulling 7462734796d6... 100% ▕██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▏ 1.6 GB
pulling e0a42594d802... 100% ▕██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▏ 358 B
pulling 097a36493f71... 100% ▕██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▏ 8.4 KB
pulling 2490e7468436... 100% ▕██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▏ 65 B
pulling e18ad7af7efb... 100% ▕██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▏ 487 B
verifying sha256 digest
writing manifest
success
➜ ~ tree ~/.ollama/models
/Users/user/.ollama/models
├── blobs
│ ├── sha256-097a36493f718248845233af1d3fefe7a303f864fae13bc31a3a9704229378ca
│ ├── sha256-2490e7468436707d5156d7959cf3c6341cc46ee323084cfa3fcf30fe76e397dc
│ ├── sha256-7462734796d67c40ecec2ca98eddf970e171dbb6b370e43fd633ee75b69abe1b
│ ├── sha256-e0a42594d802e5d31cdc786deb4823edb8adff66094d49de8fffe976d753e348
│ └── sha256-e18ad7af7efbfaecd8525e356861b84c240ece3a3effeb79d2aa7c0f258f71bd
└── manifests
└── ollama.internal.wbuntu.com
└── library
└── gemma2
└── 2b
6 directories, 6 files
可以看到 manifests 下新增了一个仓库名 ollama.internal.wbuntu.com,若能缓存 manifest 和 blobs 请求,就可以实现一个本地模型缓存。
不过上面也分析过,ollama 与官方模型仓库之间的请求不是简单的 GET,还存在一些鉴权和重定向请求,于是我换一个思路:利用 ollama 下载模型,使用 caddy 重写请求映射到本地文件,这样内网设备就可以访问本地模型缓存。
flowchart LR
subgraph LAN
A0[ollama-registry<br/>192.168.123.3:8080/library/gemma2:2b<br/>192.168.123.3:8080/library/llama3.1:8b]
A1[Linux<br/>192.168.123.x]
A2[Windows<br/>192.168.123.x]
A3[macOS<br/>192.168.123.x]
end
subgraph WAN
roa[registry.ollama.ai<br/>registry.ollama.ai/library/gemma2:2b<br/>registry.ollama.ai/library/llama3.1:8b]
end
A0 -.-> roa
A1 ---> A0
A2 ---> A0
A3 ---> A0
参考之前制作镜像的经验,我使用 supervisor 作为主进程,管理 ollama 和 caddy 子进程,然后对外暴露 caddy 端口,将 HTTP 请求映射到 ollama 的本地文件,也可以暴露 ollama 的 11434 端口,用于远程调用接口缓存模型。
使用文档就不赘述了,参考:ollama-registry。
这次开发主要做了以下工作:
- ollama 源码分析:我认为离线模型仓库是生产环境的必备条件,但现在只有一些 hack 实现,比如 JuiceFS:Ollama + JuiceFS:一次拉取,到处运行,方法是共享存储,与使用 NFS 差不多
- 编译 ollama 纯 CPU 版本:官方的 Linux 版本默认集成显卡驱动,体积 1GB 左右,纯 CPU 版本只有 25~27 MB,能减少容器镜像体积
- 编写 Caddyfile:这回认真学习了一次 Caddyfile 语法,重点在匹配请求与重写请求,最终输出的 Caddyfile 并不复杂