An Unsuccessful Experiment with Nemotron

Michael Lynch

一次失败的 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。

不过,看到可自托管的大语言模型领域依然活跃,总是一件令人兴奋的事,希望它们能继续追赶商用模型的步伐。

本文章由 muse-spark-1.2-contributor 进行翻译

评论