一次失败的 Nemotron 试验
原文由 Michael Lynch 于 发布,订阅该博客
几周前,NVIDIA 发布了 Nemotron,这是一个基于 Meta 的 Llama 3.1 70B 衍生而来的大语言模型。
发布时,NVIDIA 宣称 Nemotron 在部分基准测试中超过了 GPT-4o 和 Claude 3.5 Sonnet。这是个令人振奋的消息,因为就我的经验而言,可自托管的 AI 模型在准确性和质量上往往要落后商用模型大约一年。
为了看看它与 Claude 3.5 Sonnet 这类商用模型的差距,我决定用几个简单的编程任务来试试 Nemotron。
准备一台带 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),
)这是我最喜欢拿来考验大语言模型的编程任务之一,因为它对模型来说很容易描述,却让人工手动修改非常繁琐。
为了测试 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 对同一提示词的输出。Claude 没有声明多余的数组,而是直接内联创建 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字段前莫名其妙地用(代替制表符。 - Nemotron 对以点开头的文件名的规则理解有误。
- 它写了一个测试,认为像
.hidden这样以点开头的文件名应该被拒绝,但实际实现中,单个前导点是允许的。
- 它写了一个测试,认为像
除此之外,它生成的测试基本有效,对功能的覆盖也还不错。我把它和 claude-3-5-sonnet-20241022 的结果对比了一下,Claude 生成了类似的测试,但没有这些错误。
总结
总的来说,Nemotron 在我给出的这几个编程任务上表现令人失望。在我看来,这些都属于简单到中等难度的挑战,看到它失败不免有些遗憾。
在这两项测试中,Claude 3.5 Sonnet 的输出都明显优于 Nemotron。
不过,看到可自托管的大语言模型领域依然活跃,总是一件令人兴奋的事,希望它们能继续追赶商用模型的步伐。
随机一篇博客





评论
登录后参与讨论