Nemotron 的一次失敗實驗
原文由 Michael Lynch 于 發布,訂閱此部落格
幾週前,NVIDIA 發布了 Nemotron,這是一個衍生自 Meta 的 Llama 3.1 70B 的大型語言模型。
NVIDIA 在發布時宣稱 Nemotron 在某些基準測試中表現超越了 GPT-4o 與 Claude 3.5 Sonnet。這是個令人振奮的消息,因為以我的經驗,可自行架設的 AI 模型在準確度與品質上,大約落後商用模型一年左右。
我決定用幾個簡單的程式設計任務來試試 Nemotron,看看它與 Claude 3.5 Sonnet 這類商用模型相比表現如何。
租用一台搭載 GPU 的雲端伺服器
一開始我試著在自己的本機工作站上執行 Nemotron,但我那張用了 9 年的 GTX 970 顯示卡彷彿在說:「哈哈,別鬧了!」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 領域持續有新動態,總是令人興奮,也希望它們能繼續拉近與商用模型的差距。
隨機一篇部落格





留言
登入後參與討論