An Unsuccessful Experiment with Nemotron

Michael Lynch

Nemotronでの失敗に終わった実験

数週間前、NVIDIAがMetaのLlama 3.1 70Bをベースにした大規模言語モデル、Nemotronをリリースしました。

NVIDIAはリリース時、Nemotronが特定のベンチマークでGPT-4oやClaude 3.5 Sonnetを上回ったと主張しました。これは興味深いニュースでした。セルフホスト可能なAIモデルを使ってきた経験からすると、正確性や品質の面で商用モデルに約1年遅れているという印象があるからです。

そこで、Claude 3.5 Sonnetのような商用モデルと比べてどうなのかを確かめるため、Nemotronにいくつか簡単なコーディング作業をさせてみることにしました。

GPU搭載クラウドサーバーの用意

最初は手元のワークステーションでNemotronを動かそうとしました。しかし、9年前のGTX 970 GPUは「ははは、面白い冗談だね!」とでも言うような反応でした。OllamaはNemotronモデルのインストールすら拒否しました。

そこで、Scalewayに次のサーバーを用意しました。

  • サーバーインスタンスタイプ: H100-1-80G
  • OS: Debian 12
  • ディスク容量: 200 GB(モデルのサイズが大きいため必要)

SSH接続には、次のコマンドをポートフォワーディング付きで実行しました。サーバーのlocalhostインターフェースで動くWebインターフェースにアクセスする必要があるためです。

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のインストール

次に、Open-WebUIのWebインターフェースからOllamaを実行できるよう、Dockerをインストールしました。

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を試したときと比べて、インストール手順はさらに簡単になりました。

今では、OllamaとOpen-WebUIを1つのDockerコンテナにまとめてインストールできます。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に頼むコーディング作業として私が特に気に入っているものの1つです。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が生成したものほど読みやすくはありません。不要な配列を宣言する代わりに、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は私の意図を理解し、実装方法についてもある程度は正しい方向性を示していました。

テスト2: Go関数のユニットテストを作成する

2つ目のテストでは、PicoShareのある関数に対するユニットテストをNemotronに書かせました。

プロンプト

次のコードのユニットテストを作成してください。

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パッケージへのパスは.../parseではなく.../handlers/parseだと判断できるはずでした。
  • ときどきdescriptionフィールドの前にタブ文字を出力する代わりに、奇妙にも(を出力して、目立つ構文エラーをいくつも入れました。
  • 先頭にドットが付くファイル名の扱いを誤解していました。
    • .hiddenのように先頭にドットが1つ付くファイル名は拒否すべきだというテストを作成しました。しかし実装を見ると、先頭にドットが1つだけ付くファイル名は許可されます。

それ以外については、挙動を十分にカバーした有効なテストを作成できていました。claude-3-5-sonnet-20241022とも比較しましたが、Claudeも似たようなテストを作成し、Nemotronのようなミスはありませんでした。

最後に

今回Nemotronに与えたいくつかのコーディング作業では、期待外れの結果になりました。これらは簡単から中程度の難易度の課題だと考えているので、Nemotronが失敗したのは残念です。

どちらのケースでも、Claude 3.5 Sonnetの出力はNemotronの出力を明確に上回っていました。

それでも、セルフホスト可能なLLMに動きがあるのを見るのはいつも刺激的です。商用モデルに追いつき続けてくれることを願っています。

原文は Michael Lynch により に公開されました。

この記事は「gpt-5.6-terra」を使用して翻訳されました。