从readme.md 开始

readme.md中,说明了ollama是一个能够帮助你拉取、本地部署大模型,或者接入大模型服务提供商的服务,除此之外,他还能够作为网关,帮助你对外在网络上开放大模型的RESTful API。

llama.cpp

readme中还介绍了llama.cpp,这是一个后端项目,意味着你可以只把ollama作为前端网关使用,后端替换成llama.cpp。

client, server 和 model

ollama可以大致分为三个部分:前端的client,后端的server和model。

client

client的代码:这部分的代码在api/clent.go中。从代码可以看到,client有着Generate、Chat、Pull、Push等丰富的方法,以generate为例:

func (c *Client) Generate(ctx context.Context, req *GenerateRequest, fn GenerateResponseFunc) error {
    return c.stream(ctx, http.MethodPost, "/api/generate", req, func(bts []byte) error {
        var resp GenerateResponse
        if err := json.Unmarshal(bts, &resp); err != nil {
            return err
        }

        return fn(resp)
    })
}

从上面的代码可以看到,generate最终会调用私有的stream函数,而这个函数实际上是通过rest请求去调用server的命令。实际上,仔细观察你会发现,client大部分的方法都会调用dostream函数,这两个函数都会去发送rest请求到server侧,只不过是是否为流式返回响应的差别。这种许多函数的调用链最终汇聚到一两个函数的调用的“调用收敛”模式很常见,而其中的核心函数被称为”窄腰”(narrow waist)。

而当我们在命令行输入ollama run llama3时,这个命令本身就会启动一个client。做这个命令分发的代码在cmd/cmd.go中,它使用了corba来做命令分发。

cmd.go的树状组织

cmd.go里面,你可以看到rootCmd,然后在这个根的基础上,发展出许多二级的cmd:

    rootCmd.AddCommand(
        serveCmd,
        createCmd,
        showCmd,
        runCmd,
        stopCmd,
        pullCmd,
        pushCmd,
        signinCmd,
        loginCmd,
        signoutCmd,
        logoutCmd,
        listCmd,
        psCmd,
        copyCmd,
        deleteCmd,
        runnerCmd,
        launch.LaunchCmd(checkServerHeartbeat, runInteractiveTUI),
    )

我没有细看下去,但是也许会有三级的cmd——至少有三级的flag。因此可以用树状的方式去展开这些命令的匹配模式。

corba的“一鱼两吃”

corba的命令是这样子注册的,拿runCmd举例:

    runCmd := &cobra.Command{
        Use:     "run MODEL [PROMPT]",
        Short:   "Run a model",
        Args:    cobra.MinimumNArgs(1),
        PreRunE: checkServerHeartbeat,
        RunE:    RunHandler,
    }

这里对应的是ollama run这条命令,我初看的时候,纳闷是在哪个参数注册的run,后面才发现,原来在Use这里——Use: “run MODEL [PROMPT]”;corba对use参数做了一鱼两吃,既能用它输出帮助文档,又对这个use做模式提取,把里面的第一个词提取出来注册为子命令。。。真的不得不佩服很有想法。

server

今天不深究server的代码,但是我们可以先看看server是如何启动的。

cmd/start.go

//go:build darwin || windows

package cmd

import (
    "context"
    "errors"
    "time"

    "github.com/ollama/ollama/api"
)

func waitForServer(ctx context.Context, client *api.Client) error {
    // wait for the server to start
    timeout := time.After(5 * time.Second)
    tick := time.Tick(500 * time.Millisecond)
    for {
        select {
        case <-timeout:
            return errors.New("timed out waiting for server to start")
        case <-tick:
            if err := client.Heartbeat(ctx); err == nil {
                return nil // server has started
            }
        }
    }
}

start.go的代码如上,可以看到start.go有一个神奇的顶部注释:

//go:build darwin || windows

这是什么意思呢?这个是Go 的 build tag,意味着这个文件只会在macOS和Windows上编译。虽然这不是我们想看的linux下如何启动background server的代码,这里是指定平台会用到的公共工具,但是我们还是可以看看waitForServer这个函数,用了两个定时器和一个select,总体的意思是每500ms通过调用客户端的心跳函数,检查其对应服务端是否健康,5s后超时退出。

话说回来,那我们想看的linux相关代码在哪呢?原来还在cmd目录下。

多平台

我们可以在cmd目录下看到一系列的文件:

cmd/start.go            ← darwin || windows
cmd/start_darwin.go     ← 只在 macOS
cmd/start_default.go    ← 其他平台
cmd/start_windows.go    ← 只在 Windows
cmd/background_unix.go  ← unix 类

我们可以查看linux系统相关的start_default.go和background_unix.go文件:

aoverb@BA:~/mygo/ollama$ cat cmd/start_default.go cmd/background_unix.go
//go:build !windows && !darwin
package cmd
import (
        "context"
        "errors"
        "github.com/ollama/ollama/api"
)
func startApp(ctx context.Context, client *api.Client) error {
        return errors.New("could not connect to ollama server, run 'ollama serve' to start it")
}
//go:build !windows
package cmd
import "syscall"
// backgroundServerSysProcAttr returns SysProcAttr for running the server in the background on Unix.
// Setpgid prevents the server from being killed when the parent process exits.
func backgroundServerSysProcAttr() *syscall.SysProcAttr {
        return &syscall.SysProcAttr{
                Setpgid: true,
        } 

可以看到linux不自动启动background server,需要我们用ollama server去手动拉起。

而第二个Unix下对于background server启动的配置,可以看到代码调用了setpgid这个系统调用,意思是把它从当前进程组摘取出来,自己作为一个新进程组的组长。这样在原进程组结束的时候自己也不会被波及而退出了。

与client的串联

看着上面的start.go和background.go,可能有点摸不着头脑,尤其,我们看的还是linux相关的代码,实际上却什么都没干,这更让人迷惑了,因此这里有必要把上面的知识都串联起来,从ollama run ollama3这句命令的执行去看上面这些文件的作用:

执行后,cobra 分发到 RunHandler(上面看的cmd.go),它的 PreRunE: checkServerHeartbeat 先 ping 一下 server 在不在。
不在 → 调 startApp(第一组):

Mac/Win:唤起 GUI app 自动起 server,然后用 waitForServer(start.go 里那个,第一组的辅助)轮询等它 ready。
Linux:startApp 直接返回 error,流程中断,提示你 ollama serve。

而无论谁去真正启动 server 进程,执行 exec 时都会用 backgroundServerSysProcAttr()(第二组)给那个进程套上”后台独立存活”的系统属性。

model

整合层级:

aoverb@BA:~/mygo/ollama$ grep -rn "exec.Command\|exec.Cmd\|os/exec" llm/
llm/server.go:17:       "os/exec"
llm/server.go:89:       cmd       *exec.Cmd
llm/server.go:334:func StartRunner(ollamaEngine bool, modelPath string, gpuLibs []string, out io.Writer, extraEnvs map[string]string) (cmd *exec.Cmd, port int, err error) {
llm/server.go:383:      cmd = exec.Command(exe, params...)
llm/server.go:388:              // os/exec serializes Write calls when shared
llm/status.go:14:       // os/exec serializes Write calls in that case. 

从这里可以判断ollama是将后端的推理引擎(前面介绍的llama.cpp也好,ollama自己的go engine也好)当作子进程去启动,然后通过端口来进行通信。

遗留问题

今天对ollama的代码是匆匆一瞥,还遗留了一些问题,比如,ollama与它的model子进程通信的细节,模型拉起和并发请求对于模型进程的调度策略,或者更深层的go engine本身的代码,都还需要我们进一步探索…