An Unsuccessful Experiment with Nemotron

Michael Lynch

與 Nemotron 的一次失敗實驗

幾週前,NVIDIA 發布了 Nemotron,這是一個衍生自 Meta Llama 3.1 70B 的 large language model(大型語言模型)。

NVIDIA 在發布時宣稱 Nemotron 在某些基準測試中的表現優於 GPT-4o 與 Claude 3.5 Sonnet。那是令人振奮的消息,因為以我對可自行託管的 AI 模型的經驗來說,它們在準確度與品質上大約落後商業模型一年。

我決定用幾個簡單的程式設計任務來測試 Nemotron,看看它與 Claude 3.5 Sonnet 這類商業模型相比表現如何。

佈建搭載 GPU 的雲端伺服器

我一開始嘗試在本地工作站上執行 Nemotron,但我那張用了 9 年的 GTX 970 GPU 彷彿在說:「哈哈,真好笑!」Ollama 甚至拒絕安裝 Nemotron 模型。

改為在 Scaleway 上佈建了以下伺服器:

  • 伺服器執行個體類型:H100-1-80G
  • 作業系統:Debian 12
  • 磁碟大小:200 GB(因為模型很大,所以需要)

要透過 SSH 連線,我執行了以下帶有連接埠轉送的指令,因為我需要存取將在伺服器的 localhost 介面上執行的網頁介面。

TARGET_IP='51.159.150.3' # Change to your server's IP.
REMOTE_PORT='8080'
LOCAL_PORT='8080'

# SSH in and port-forward a port to access the Open-WebUI web interface.
ssh "${TARGET_IP}" -L "${REMOTE_PORT}:localhost:${LOCAL_PORT}"

安裝 Docker

接著,我安裝了 Docker,以便能在 Open-WebUI 網頁介面下執行 Ollama:

sudo apt-get update && \
   sudo apt-get install ca-certificates curl && \
   sudo install -m 0755 -d /etc/apt/keyrings && \
  sudo curl -fsSL https://download.docker.com/linux/debian/gpg \
    -o /etc/apt/keyrings/docker.asc && \
  sudo chmod a+r /etc/apt/keyrings/docker.asc &&\
  echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null && \
  sudo apt-get update && \
  sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && \
  sudo usermod -aG docker "${USER}" && \
  newgrp docker

要測試一切是否正常運作,請執行以下指令:

docker run hello-world

啟動 Ollama 與 Open-WebUI

自從我上次的 Ollama 實驗以來,安裝過程變得更加簡單。

現在你可以在單一 Docker 容器中安裝 Ollama 與 Open-WebUI,這比處理 Docker Compose 要簡單得多:

docker run \
  -d \
  -p 8080:8080 \
  --gpus=all \
  -v ollama:/root/.ollama \
  -v open-webui:/app/backend/data \
  --name open-webui \
  --restart always \
  ghcr.io/open-webui/open-webui:ollama

伺服器啟動並執行後,請在瀏覽器中前往以下網址:

你會先看到一個要求登入的頁面。請點選「Sign up」。

然後輸入任意資訊。就我所知,你其實不需要提供有效的電子郵件。

接下來,你需要下載一個模型來使用。請點選設定按鈕:

請點選顯示 Pull a model from Ollama.com 的地方:

在模型欄位中輸入 nemotron,然後按下下載按鈕:

這是個大檔案,所以下載會需要幾分鐘。下載進度會在 100% 停留一段時間,直到看到彈出視窗宣布模型已完整下載才算完成。

測試一:重構程式碼以使用 sql.Named

我最近在 Go 標準函式庫中發現了sql.Named 函式。因此,與其像這樣使用 ? 佔位符來撰寫 SQL 查詢:

db.Exec(`
    INSERT INTO
        downloads
    (
        entry_id,
        download_timestamp,
        client_ip,
        user_agent
    )
    VALUES(?,?,?,?)`, // Ugly placeholders!
        id.String(),
        formatTime(r.Time),
        r.ClientIP,
        r.UserAgent,
    )

這樣寫很醜,因為讀者必須在腦中記住大量上下文,才能記得每個 ? 代表什麼,而且在重寫查詢時很容易出錯。

相反地,你可以像這樣使用具名參數來撰寫更易讀的查詢:

db.Exec(`
    INSERT INTO
        downloads
    (
        entry_id,
        download_timestamp,
        client_ip,
        user_agent
    )
    VALUES(@entry_id, @download_timestamp, @client_ip, @user_agent)`,
        sql.Named("entry_id", id.String()),
        sql.Named("download_timestamp", formatTime(r.Time)),
        sql.Named("client_ip", r.ClientIP),
        sql.Named("user_agent", r.UserAgent),
    )

這是我最喜歡交給 LLM 的程式設計任務之一,因為它很容易向 LLM 描述,卻讓人類手動修正時非常繁瑣。

為了測試 Nemotron,我讓它嘗試重做我已經在 Picoshare 中完成的一次重構? 佔位符替換為 sql.Named

提示詞

將此程式碼重構,把 SQL 查詢中的 ? 佔位符替換為 sql.Named 參數:

package sqlite
import (
    "database/sql"
    "log"
    "github.com/mtlynch/picoshare/v2/picoshare"
)
func (d DB) InsertEntryDownload(id picoshare.EntryID, r picoshare.DownloadRecord) error {
    log.Printf("recording download of file ID %s from client %s", id.String(), r.ClientIP)
    if _, err := d.ctx.Exec(`
    INSERT INTO
        downloads
    (
        entry_id,
        download_timestamp,
        client_ip,
        user_agent
    )
    VALUES(?,?,?,?)`,
        id.String(),
        formatTime(r.Time),
        r.ClientIP,
        r.UserAgent,
    ); err != nil {
        log.Printf("insert into downloads table failed: %v", err)
        return err
    }
    return nil
}

func (d DB) GetEntryDownloads(id picoshare.EntryID) ([]picoshare.DownloadRecord, error) {
    rows, err := d.ctx.Query(`
    SELECT
        download_timestamp,
        client_ip,
        user_agent
    FROM
        downloads
    WHERE
        entry_id=?
    ORDER BY
        download_timestamp DESC`, id)
    if err == sql.ErrNoRows {
        return []picoshare.DownloadRecord{}, nil
    } else if err != nil {
        return []picoshare.DownloadRecord{}, err
    }
    downloads := []picoshare.DownloadRecord{}
    for rows.Next() {
        var downloadTimeRaw string
        var clientIP string
        var userAgent string
        if err := rows.Scan(&downloadTimeRaw, &clientIP, &userAgent); err != nil {
            return []picoshare.DownloadRecord{}, err
        }
        dt, err := parseDatetime(downloadTimeRaw)
        if err != nil {
            return []picoshare.DownloadRecord{}, err
        }
        downloads = append(downloads, picoshare.DownloadRecord{
            Time:      dt,
            ClientIP:  clientIP,
            UserAgent: userAgent,
        })
    }
    return downloads, nil
}

輸出

package sqlite

import (
    "database/sql"
    "log"
    "github.com/mtlynch/picoshare/v2/picoshare"
)

func (d DB) InsertEntryDownload(id picoshare.EntryID, r picoshare.DownloadRecord) error {
    log.Printf("recording download of file ID %s from client %s", id.String(), r.ClientIP)
    params := []sql.NamedArg{
        {Name: "entry_id", Value: id.String()},
        {Name: "download_timestamp", Value: formatTime(r.Time)},
        {Name: "client_ip", Value: r.ClientIP},
        {Name: "user_agent", Value: r.UserAgent},
    }
    if _, err := d.ctx.Exec(`
    INSERT INTO
        downloads
    (
        entry_id,
        download_timestamp,
        client_ip,
        user_agent
    )
    VALUES(:entry_id, :download_timestamp, :client_ip, :user_agent)`,
        params...,
    ); err != nil {
        log.Printf("insert into downloads table failed: %v", err)
        return err
    }
    return nil
}

func (d DB) GetEntryDownloads(id picoshare.EntryID) ([]picoshare.DownloadRecord, error) {
    params := []sql.NamedArg{
        {Name: "entry_id", Value: id},
    }
    rows, err := d.ctx.Query(`
    SELECT
        download_timestamp,
        client_ip,
        user_agent
    FROM
        downloads
    WHERE
        entry_id=:entry_id
    ORDER BY
        download_timestamp DESC`,
        params...,
    )
    if err == sql.ErrNoRows {
        return []picoshare.DownloadRecord{}, nil
    } else if err != nil {
        return []picoshare.DownloadRecord{}, err
    }
    downloads := []picoshare.DownloadRecord{}
    for rows.Next() {
        var downloadTimeRaw string
        var clientIP string
        var userAgent string
        if err := rows.Scan(&downloadTimeRaw, &clientIP, &userAgent); err != nil {
            return []picoshare.DownloadRecord{}, err
        }
        dt, err := parseDatetime(downloadTimeRaw)
        if err != nil {
            return []picoshare.DownloadRecord{}, err
        }
        downloads = append(downloads, picoshare.DownloadRecord{
            Time:      dt,
            ClientIP:  clientIP,
            UserAgent: userAgent,
        })
    }
    return downloads, nil
}

評註

所以,Nemotron 一開始表現就不佳。這段程式碼無法編譯,因為 sql API 不接受 sql.NamedArg 作為參數:

store/sqlite/downloads.go:28:3: cannot use params (variable of type []sql.NamedArg) as []any value in argument to d.ctx.Exec
store/sqlite/downloads.go:51:3: cannot use params (variable of type []sql.NamedArg) as []any value in argument to d.ctx.Query

但即使可以,這樣的寫法也不如 Claude 3.5 Sonnet 針對相同提示詞產生的結果易讀。它沒有宣告不必要的陣列,而是直接內聯建立 sql.Named 參數,這樣要容易理解得多:

// Excerpt of output from claude-3-5-sonnet-20241022

    if _, err := d.ctx.Exec(`
    INSERT INTO
        downloads
    (
        entry_id,
        download_timestamp,
        client_ip,
        user_agent
    )
    VALUES(@entryID, @timestamp, @clientIP, @userAgent)`,
        sql.Named("entryID", id.String()),
        sql.Named("timestamp", formatTime(r.Time)),
        sql.Named("clientIP", r.ClientIP),
        sql.Named("userAgent", r.UserAgent),
    ); err != nil {
//      ...

儘管如此,Nemotron 還是理解了我的需求,並且大致掌握了正確的實作方向。

測試二:為 Go 函式建立單元測試

在第二項測試中,我請 Nemotron 為 PicoShare 中的一個函式撰寫單元測試:

提示詞

為此程式碼建立單元測試:

package parse

import (
    "errors"
    "strings"

    "github.com/mtlynch/picoshare/v2/picoshare"
)

// MaxFilenameBytes is the maximum number of bytes allowed for uploaded files
// There's no technical reason on PicoShare's side for this limitation, but it's
// useful to have some upper bound to limit malicious inputs, and 255 is a
// common filename limit (in single-byte characters) across most filesystems.
const MaxFilenameBytes = 255

var ErrFilenameEmpty = errors.New("filename must be non-empty")
var ErrFilenameTooLong = errors.New("filename too long")
var ErrFilenameHasDotPrefix = errors.New("filename cannot begin with dots")
var ErrFilenameIllegalCharacters = errors.New("illegal characters in filename")

func Filename(s string) (picoshare.Filename, error) {
    if s == "" {
        return picoshare.Filename(""), ErrFilenameEmpty
    }
    if len(s) > MaxFilenameBytes {
        return picoshare.Filename(""), ErrFilenameTooLong
    }
    if s == "." || strings.HasPrefix(s, "..") {
        return picoshare.Filename(""), ErrFilenameHasDotPrefix
    }
    if strings.ContainsAny(s, "\\/\a\b\t\n\v\f\r\n") {
        return picoshare.Filename(""), ErrFilenameIllegalCharacters
    }
    return picoshare.Filename(s), nil
}

請符合此檔案的風格慣例:

package parse_test

import (
    "fmt"
    "strings"
    "testing"

    "github.com/mtlynch/picoshare/v2/handlers/parse"
    "github.com/mtlynch/picoshare/v2/picoshare"
)

func TestGuestLinkLabel(t *testing.T) {
    for _, tt := range []struct {
        description string
        input       string
        output      picoshare.GuestLinkLabel
        err         error
    }{
        {
            description: "accept valid label",
            input:       "For my good pals",
            output:      picoshare.GuestLinkLabel("For my good pals"),
            err:         nil,
        },
        {
            description: "allow empty label",
            input:       "",
            output:      picoshare.GuestLinkLabel(""),
            err:         nil,
        },
        {
            description: "reject labels that are too long",
            input:       strings.Repeat("A", parse.MaxGuestLinkLabelLength+1),
            output:      picoshare.GuestLinkLabel(""),
            err:         parse.ErrGuestLinkLabelTooLong,
        },
    } {
        t.Run(fmt.Sprintf("%s [%s]", tt.description, tt.input), func(t *testing.T) {
            label, err := parse.GuestLinkLabel(tt.input)
            if got, want := err, tt.err; got != want {
                t.Fatalf("err=%v, want=%v", err, want)
            }
            if got, want := label, tt.output; got != want {
                t.Errorf("label=%v, want=%v", label, want)
            }
        })
    }
}

輸出

package parse_test
import (
    "fmt"
    "strings"
    "testing"
    "github.com/mtlynch/picoshare/v2/picoshare"
    "github.com/mtlynch/picoshare/v2/parse"
)

func TestFilename(t *testing.T) {
    for _, tt := range []struct {
        description string
        input       string
        output      picoshare.Filename
        err         error
    }{
        {
            description: "accept valid filename",
            input:       "example.txt",
            output:      picoshare.Filename("example.txt"),
            err:         nil,
        },
        {
        (description: "reject empty filename",
            input:       "",
            output:      picoshare.Filename(""),
            err:         parse.ErrFilenameEmpty,
        },
        {
            description: "reject filename too long",
            input:       strings.Repeat("A", parse.MaxFilenameBytes+1),
            output:      picoshare.Filename(""),
            err:         parse.ErrFilenameTooLong,
        },
        {
            description: "reject filename with dot prefix (single dot)",
            input:       ".hidden",
            output:      picoshare.Filename(""),
            err:         parse.ErrFilenameHasDotPrefix,
        },
        {
            description: "reject filename with dot prefix (double dots)",
            input:       "..parentdir",
            output:      picoshare.Filename(""),
            err:         parse.ErrFilenameHasDotPrefix,
        },
        {
        (description: "reject filename with illegal characters (slash)",
            input:       "example/file.txt",
            output:      picoshare.Filename(""),
            err:         parse.ErrFilenameIllegalCharacters,
        },
        {
            description: "reject filename with illegal characters (backspace)",
            input:       "example\x08.txt", // \x08 represents a backspace character
            output:      picoshare.Filename(""),
            err:         parse.ErrFilenameIllegalCharacters,
        },
    } {
        t.Run(fmt.Sprintf("%s [%s]", tt.description, tt.input), func(t *testing.T) {
            filename, err := parse.Filename(tt.input)
            if got, want := err, tt.err; got != want {
                t.Fatalf("err=%v, want=%v", err, want)
            }
            if got, want := filename, tt.output; got != want {
                t.Errorf("filename=%v, want=%v", filename, want)
            }
        })
    }
}

評註

Nemotron 在這裡表現尚可,但犯了一些明顯的錯誤:

  • Nemotron 為匯入 parse 套件建立了錯誤的路徑。
    • 它應該要從範例測試中推斷出 parse 套件的路徑是 .../handlers/parse,而非 .../parse
  • Nemotron 引入了明顯的語法錯誤,有時會在 description 欄位前莫名其妙地輸出 (,而不是 Tab 字元。
  • Nemotron 誤解了檔名以點開頭的規則。
    • 它建立了一個測試,聲稱像 .hidden 這樣以點開頭的檔名應該被拒絕,但實際的實作卻表示單一前導點是可以接受的。

除此之外,它建立的測試大致有效,對行為有不錯的涵蓋率。我將其與 claude-3-5-sonnet-20241022 進行比較,Claude 建立了類似的測試,但沒有 Nemotron 的那些錯誤。

結語

我覺得 Nemotron 在我提供的幾個程式設計任務上表現令人失望。我認為這些是簡單到中等難度的挑戰,因此看到 Nemotron 失敗令人沮喪。

在這兩種情況下,Claude 3.5 Sonnet 的輸出都明顯優於 Nemotron 的輸出。

儘管如此,看到可自行託管的 LLM 領域有所進展總是令人振奮,因此我希望它們能持續追趕上商業模型。

原文由 Michael Lynch 發布

本文章由 muse-spark-1.2-contributor 進行翻譯