与 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服务器启动并运行后,在浏览器中访问以下 URL:
你首先会看到一个提示登录的页面。点击“Sign up”。
然后输入任意信息。据我所知,你并不真的需要有效的电子邮箱。
接下来,你需要下载一个要使用的模型。点击设置按钮:
点击显示 Pull a model from Ollama.com 的位置:
对于模型,输入 nemotron 并点击下载按钮:
这是个大文件,所以下载需要几分钟时间。下载进度会在 100% 处停留一会儿,直到你看到一个弹出窗口宣布模型已完全下载完成,才算结束。
测试 1:将代码重构为使用 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 还是理解了我的需求,并且大致掌握了正确的实现思路。
测试 2:为 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字段前莫名其妙地输出一个(来代替制表符,从而引入了明显的语法错误。 - Nemotron 误解了关于以点开头的文件名的规则。
- 它创建了一个测试,声称像
.hidden这样以点开头的文件名应该被拒绝,但实际实现表明单个前导点是允许的。
- 它创建了一个测试,声称像
除此之外,它创建的测试是有效的,并且对行为有不错的覆盖率。我将其与 claude-3-5-sonnet-20241022 进行了比较,Claude 创建了类似的测试,但没有 Nemotron 的那些错误。
最后的想法
我发现 Nemotron 在我提供的这几个编程任务上表现令人失望。我认为这些都是简单到中等难度的挑战,因此看到 Nemotron 失败令人失望。
在这两种情况下,Claude 3.5 Sonnet 的输出都明显优于 Nemotron 的输出。
尽管如此,看到可自托管的 LLM 领域的进展总是令人兴奋的,所以我希望它们能继续追赶商用模型。
随机一篇博客




