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년 정도 뒤처져 있기 때문에 꽤 흥미로운 소식이었습니다.

Nemotron이 Claude 3.5 Sonnet 같은 상용 모델과 비교해 어느 정도 수준인지 확인하기 위해, 간단한 코딩 작업 몇 가지로 직접 테스트해 보기로 했습니다.

GPU가 탑재된 클라우드 서버 준비하기

처음에는 로컬 워크스테이션에서 Nemotron을 실행해 보려 했지만, 9년 된 GTX 970 GPU가 “하하, 재미있는 농담이네!”라고 말하는 듯했습니다. Ollama는 Nemotron 모델 설치 자체를 거부했습니다.

그래서 대신 Scaleway에서 다음과 같은 사양의 서버를 준비했습니다:

  • 서버 인스턴스 유형: H100-1-80G
  • OS: 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 설치하기

다음으로 Open-WebUI 웹 인터페이스에서 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를 단일 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에게는 설명하기 쉽지만 사람이 직접 수정하기에는 번거롭기 때문에, 제가 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 함수에 대한 단위 테스트 작성하기

두 번째 테스트에서는 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은 여기서는 그럭저럭 해냈지만, 몇 가지 명백한 실수를 했습니다:

  • parse 패키지를 임포트하는 경로를 잘못 만들었습니다.
    • 예제 테스트를 보면 parse 패키지의 경로가 .../parse가 아니라 .../handlers/parse임을 파악했어야 합니다.
  • 때때로 description 필드 앞의 탭 문자 대신 기이하게도 (를 출력하면서 눈에 띄는 문법 오류를 만들었습니다.
  • 점으로 시작하는 파일 이름에 대한 규칙을 잘못 이해했습니다.
    • .hidden처럼 점으로 시작하는 파일 이름은 거부되어야 한다는 테스트를 만들었지만, 실제 구현에서는 점 하나로 시작하는 파일명은 허용됩니다.

그 외에는 동작을 꽤 잘 다루는 유효한 테스트를 만들었습니다. claude-3-5-sonnet-20241022와 비교해 보니, Claude는 Nemotron의 오류 없이 비슷한 테스트를 생성했습니다.

마무리 생각

제가 제시한 몇 가지 코딩 작업에서 Nemotron은 실망스러웠습니다. 이 작업들을 쉽거나 중간 정도의 난이도라고 생각하기 때문에, Nemotron이 실패하는 모습을 보는 것은 더욱 아쉽습니다.

두 경우 모두 Claude 3.5 Sonnet의 출력이 Nemotron의 출력보다 확실히 더 우수했습니다.

그럼에도 셀프 호스팅이 가능한 LLM 분야에서 활발한 움직임이 있는 것을 보는 것은 언제나 흥미로운 일이므로, 앞으로도 상용 모델을 계속 따라잡아가길 바랍니다.

원문은 Michael Lynch님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.