본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research8 min read

Hugging Face 생태계: 내가 만든 generate와 transformers의 generate를 나란히 놓고 비교하기

Hugging Face는 사전학습된 모델·토크나이저·데이터를 불러다 쓰는 표준 도구 모음이다. 오늘은 Qwen 0.5B 모델을 로드해 텍스트를 생성하고, 지난 주에 직접 만든 generate와 출력·속도를 비교한다. 그리고 temperature, top_p, repetition_penalty를 바꿔 가며 생성 결과가 어떻게 달라지는지 관찰한다.

By 송민성

오늘의 목표

Hugging Face 라이브러리로 사전학습 모델(pretrained model, 이미 대량의 텍스트로 학습이 끝난 모델)을 내려받아 문장을 생성해 본다. 그리고 Tiny GPT 프로젝트에서 직접 짠 generate 함수와 model.generate()가 내부에서 같은 일을 한다는 것을 출력과 속도로 확인한다.

개념과 직관

Hugging Face(HF)는 "AI 모델의 GitHub + 공구함"이라고 생각하면 된다. 모델 파일은 Hub라는 저장소에 올라 있고, 그것을 다루는 파이썬 라이브러리가 여러 개 있다. 오늘은 이름과 역할만 정확히 구분해 두자.

도구역할비유
transformers모델 구조와 가중치 로드, 생성(generate)완성된 가전제품과 리모컨
tokenizers문자열 ↔ 토큰 ID 변환을 빠르게번역 사전
datasets데이터셋 다운로드·가공·스트리밍도서관 대출 시스템
accelerateGPU 여러 장·혼합 정밀도 등 실행 환경 추상화전원 어댑터
evaluate정확도 같은 평가 지표 계산채점표

AutoModel / AutoTokenizer: 모델 이름(예: Qwen/Qwen2.5-0.5B)만 주면 설정 파일(config)을 읽고 알맞은 클래스를 골라 준다. 생성용 모델은 AutoModelForCausalLM을 쓴다. Causal LM은 "앞의 토큰들을 보고 다음 토큰을 예측하는 언어 모델"이라는 뜻으로, 지난 주에 만든 Tiny GPT와 같은 종류다.

safetensors: 가중치를 저장하는 파일 형식이다. 예전의 pickle 기반 .bin은 불러올 때 임의의 코드가 실행될 위험이 있었다. safetensors는 순수한 숫자 배열과 메타데이터만 담아서 더 안전하고, 필요한 부분만 빠르게 읽을 수 있다.

device_map: 모델의 각 층을 어느 장치(GPU, CPU)에 올릴지 정하는 옵션이다. device_map="auto"라고 쓰면 라이브러리가 메모리를 보고 알아서 배치한다. 이 옵션을 쓰려면 accelerate가 설치되어 있어야 한다. 0.5B 모델은 작아서 사실상 한 장치에 다 올라가지만, 큰 모델에서 왜 필요한지 맛보는 용도다.

generate의 정체: 다음 토큰을 하나 고르고, 입력 뒤에 붙이고, 다시 모델에 넣는 반복이다.

text
토큰들 → 모델 → 다음 토큰의 점수(logits) → 하나 선택 → 뒤에 붙임 → 반복

"하나 선택"하는 방법이 바로 샘플링 설정이다.

  • temperature: 점수를 나누는 값. softmax(logits / T)로 확률을 만든다. T가 작으면 1등 확률이 더 커져서 안정적이고, T가 크면 확률이 평평해져서 다양하지만 엉뚱해질 수 있다.
  • top_p (nucleus sampling, 확률 높은 순으로 더해 p에 도달할 때까지의 후보만 남기는 방법): top_p=0.9면 누적 확률 90%를 채우는 상위 후보 안에서만 뽑는다.
  • repetition_penalty: 이미 나온 토큰의 점수를 깎아 같은 말의 반복을 줄인다. 1.0이면 효과 없음, 클수록 강하다.

코드로 직접 해보기

실행 환경은 pip install torch transformers accelerate로 준비한다. Qwen 0.5B는 CPU에서도 돌아가지만 느리다. 이 노트에서 모델 이름은 Qwen/Qwen2.5-0.5B로 쓰는데, Hub에서 실제 이름이 맞는지 먼저 확인하자.

1단계: 로드하고 HF generate로 생성

python
import torch from transformers import AutoModelForCausalLM, AutoTokenizer name = "Qwen/Qwen2.5-0.5B" tok = AutoTokenizer.from_pretrained(name) model = AutoModelForCausalLM.from_pretrained(name, torch_dtype=torch.float32, device_map="auto") model.eval() prompt = "인공지능이란" inputs = tok(prompt, return_tensors="pt").to(model.device) print(inputs["input_ids"]) # 토큰 ID 확인 out = model.generate(**inputs, max_new_tokens=40, do_sample=False) print(tok.decode(out[0], skip_special_tokens=True))

do_sample=False는 매번 가장 높은 점수의 토큰을 고르는 탐욕적(greedy) 방식이다. 비교 실험은 반드시 이 설정에서 시작한다. 무작위가 없어야 두 구현을 정확히 맞춰 볼 수 있다.

2단계: 원리대로 직접 구현 (내 generate)

라이브러리가 해 주는 일을 먼저 손으로 해 본다.

python
import time @torch.no_grad() def my_generate(model, input_ids, max_new_tokens): for _ in range(max_new_tokens): logits = model(input_ids).logits # (배치, 길이, 어휘크기) next_id = logits[:, -1, :].argmax(dim=-1, keepdim=True) input_ids = torch.cat([input_ids, next_id], dim=1) return input_ids t0 = time.time() mine = my_generate(model, inputs["input_ids"], 40) t_mine = time.time() - t0 print(tok.decode(mine[0], skip_special_tokens=True))

핵심은 logits[:, -1, :]다. 각 위치마다 "그다음 토큰" 점수가 나오는데, 마지막 위치의 것만 필요하다.

3단계: 출력과 속도 비교

python
t0 = time.time() hf = model.generate(**inputs, max_new_tokens=40, do_sample=False) t_hf = time.time() - t0 print("같은 토큰?", torch.equal(mine[0], hf[0])) print(f"내 구현 {t_mine:.2f}초 / HF {t_hf:.2f}초")

GPU라면 시간을 잴 때 torch.cuda.synchronize()를 측정 전후에 넣어야 정확하다. 첫 호출은 준비 비용이 들어가니 한 번 워밍업한 뒤 재는 것이 좋다.

기대할 수 있는 것: 토큰이 같다면 원리 이해가 맞은 것이다. 속도는 보통 HF가 더 빠른 쪽이다. 이유는 KV 캐시(이전 토큰의 중간 계산 결과를 저장해 두고 재사용하는 기법) 때문이다. 내 구현은 매 단계 전체 문장을 처음부터 다시 계산한다. KV 캐시의 구체적 원리는 Attention을 배운 뒤 다시 다루니, 지금은 "HF는 이미 계산한 걸 재활용한다" 정도로만 알아 두자. 속도 수치는 환경마다 다르니 내 기기에서 나온 값을 그대로 기록한다.

토큰이 조금 다르게 나올 수도 있다. 부동소수점 연산 순서 차이로 점수가 거의 같은 두 후보 중 다른 쪽이 뽑힐 수 있기 때문이다. 앞부분이 같다가 중간에서 갈라지면 이 경우를 의심하자.

오늘의 실험

같은 프롬프트로 설정만 바꿔 생성 결과를 비교한다. 한 번에 하나만 바꾸는 것이 원칙이다.

python
import random prompt = "인공지능이란" inputs = tok(prompt, return_tensors="pt").to(model.device) def run(**kw): torch.manual_seed(0) out = model.generate(**inputs, max_new_tokens=50, do_sample=True, **kw) return tok.decode(out[0], skip_special_tokens=True) configs = [ dict(temperature=0.2), dict(temperature=0.7), dict(temperature=1.5), dict(temperature=0.8, top_p=0.5), dict(temperature=0.8, top_p=0.95), dict(temperature=0.8, repetition_penalty=1.0), dict(temperature=0.8, repetition_penalty=1.3), ] for c in configs: print(c, "\n", run(**c), "\n" + "-" * 40)

관찰할 것

  1. temperature 0.2 / 0.7 / 1.5: 문장이 얼마나 단조로운지, 어느 지점부터 말이 깨지는지.
  2. top_p 0.5 vs 0.95: 같은 temperature에서 다양성이 어떻게 달라지는지.
  3. repetition_penalty 1.0 vs 1.3: 반복 표현이 줄어드는지, 너무 높으면 어색한 단어로 도망가는지.
  4. 시드를 바꿔(manual_seed(1), (2)) 같은 설정이 매번 어떻게 다르게 나오는지. 설정 효과와 운의 차이를 구분하기 위해서다.

기록 방법: 표로 정리한다.

설정시드출력 앞 50자느낌(단조/자연/엉뚱)반복 여부

숫자로 비교하고 싶다면 "출력에서 중복되는 단어 비율" 같은 간단한 지표를 직접 만들어 같이 적어 보자. 이것이 평가 지표의 출발점이다.

왜 이걸 배우나 (LLM / Post-training 연결)

LLM Research Engineer의 일상은 공개된 사전학습 모델을 불러와 추가로 학습시키고 평가하는 것이다. 이때 transformers(모델), datasets(데이터), accelerate(분산 실행), evaluate(평가)가 거의 그대로 쓰인다. Post-training(사전학습 이후에 모델을 원하는 방향으로 다듬는 단계)에서는 학습한 모델의 출력을 샘플링해서 품질을 살피는 일이 계속 반복되므로, temperature·top_p 같은 생성 설정이 결과에 미치는 영향을 몸으로 아는 것이 중요하다. 직접 만든 generate와 비교해 본 경험은 라이브러리 안에서 무슨 일이 일어나는지 추적할 수 있는 힘이 된다.

초보자가 막히는 지점

  • 모델 다운로드가 느리거나 실패한다: 첫 실행에는 수백 MB를 받는다. 네트워크 오류면 다시 실행하면 되고, 캐시는 보통 ~/.cache/huggingface에 저장된다. 디스크 용량도 확인하자.
  • `device_map` 에러: accelerate가 없을 때 난다. pip install accelerate로 해결한다.
  • 입력과 모델의 장치가 다르다: "Expected all tensors to be on the same device" 에러. .to(model.device)를 빠뜨렸는지 확인한다.
  • do_sample=False인데 temperature를 줬다: 샘플링을 끈 상태에서는 temperature와 top_p가 무시되고 경고가 뜬다. 샘플링 실험에서는 do_sample=True를 꼭 켠다.
  • 출력에 프롬프트도 같이 나온다: 정상이다. generate는 입력 토큰을 포함한 전체를 돌려준다. 새로 생성된 부분만 보려면 out[0][inputs["input_ids"].shape[1]:]로 자른다.
  • 내 구현이 매우 느리다: 정상이다. 위에서 설명한 대로 캐시 없이 매번 전체를 다시 계산하기 때문이다. 버그가 아니다.
  • 토큰 수와 글자 수를 헷갈린다: max_new_tokens는 글자 수가 아니라 토큰 수다. 한국어는 한 글자가 여러 토큰으로 쪼개질 수도 있다.
  • Base 모델이 질문에 답하지 않고 문장을 이어 쓴다: 사전학습만 된 모델은 "답변"이 아니라 "이어 쓰기"를 한다. 이것이 Post-training이 필요한 이유 중 하나다. 오늘은 이상한 게 아니니 그대로 관찰하자.

알고리즘 (병행 1시간)

LeetCode Medium 트리 문제 2문제를 푼다. 추천 후보는 다음과 같다.

  • Binary Tree Level Order Traversal (너비 우선 탐색, 큐 사용)
  • Validate Binary Search Tree (범위를 내려보내는 재귀)

문제를 고를 때 어려우면 Lowest Common Ancestor of a Binary Tree 같은 다른 트리 Medium으로 바꿔도 된다.

풀이 후 반드시 기록할 것

  1. 접근: 어떤 순회(전위·중위·후위·레벨)를 왜 골랐는지 두세 문장으로.
  2. 시간복잡도·공간복잡도: 노드 수 N 기준으로 쓰고, 재귀 깊이나 큐 크기가 공간에 어떻게 영향을 주는지 적는다.
  3. 틀린 이유: 처음 제출이 틀렸다면 원인을 기록한다. 예를 들어 빈 트리 처리 누락, BST 검증에서 부모만 비교하고 조상 범위는 놓침 같은 것들이다. 틀리지 않았다면 헷갈렸던 지점을 적는다.