與 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 領域有所進展總是令人振奮,因此我希望它們能持續追趕上商業模型。
隨機一篇部落格




