Nemotron으로 실패한 실험
원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기
몇 주 전, 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가 “Haha, funny joke!”라고 답하는 듯했다. Ollama는 Nemotron 모델 설치조차 거부했다.
대신 Scaleway에서 다음과 같은 사양의 서버를 프로비저닝했다:
- 서버 인스턴스 유형: H100-1-80G
- OS: Debian 12
- 디스크 크기: 200 GB (모델 용량이 커서 필요함)
서버의 localhost 인터페이스에서 실행될 웹 인터페이스에 접속해야 하므로, SSH 접속을 위해 포트 포워딩과 함께 다음 명령을 실행했다.
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-worldOllama와 Open-WebUI 시작하기
지난 Ollama 실험 이후 설치 과정이 한층 더 쉬워졌다.
이제 Docker Compose를 따로 다룰 필요 없이 단일 Docker 컨테이너에 Ollama와 Open-WebUI를 함께 설치할 수 있다:
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은 이번에 그런대로 해냈지만, 몇 가지 명백한 실수를 했다:
- Nemotron은
parse패키지를 import하는 경로를 잘못 만들었다.- 예제 테스트를 통해
parse패키지의 경로가.../parse가 아니라.../handlers/parse임을 파악했어야 했다.
- 예제 테스트를 통해
- Nemotron은 때때로
description필드 앞의 탭 문자 대신 기묘하게(를 출력하면서 눈에 띄는 문법 오류를 만들었다. - Nemotron은 선행 점이 있는 파일 이름에 대한 규칙을 잘못 이해했다.
.hidden처럼 선행 점이 있는 파일 이름은 거부되어야 한다는 테스트를 만들었지만, 실제 구현에서는 점 하나만 있는 경우는 허용된다.
그 외에는 동작을 꽤 잘 커버하는 유효한 테스트를 만들었다. claude-3-5-sonnet-20241022와 비교해 보니 Claude도 비슷한 테스트를 만들었지만 Nemotron 같은 오류는 없었다.
마무리
내가 제시한 몇 가지 코딩 작업에서 Nemotron은 실망스러웠다. 이 정도 과제는 쉽거나 중간 정도 난이도라고 보는데, Nemotron이 실패하는 모습은 아쉽다.
두 경우 모두 Claude 3.5 Sonnet의 출력이 Nemotron의 출력보다 명백히 더 우수했다.
그래도 자가 호스팅 가능한 LLM 분야에서 움직임이 있다는 것 자체는 언제나 반가운 일이니, 앞으로도 상용 모델을 계속 따라잡아 나가길 기대한다.
글을 무작위로 읽기





댓글
로그인하고 댓글 남기기