An Unsuccessful Experiment with Nemotron

Michael Lynch

与 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 领域的进展总是令人兴奋的,所以我希望它们能继续追赶商用模型。

原文由 Michael Lynch 发布

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