Home [AI 기초] 개발자가 본 LLM의 실체: 마법이 아니라 확률 통계인 이유
Post
Cancel

[AI 기초] 개발자가 본 LLM의 실체: 마법이 아니라 확률 통계인 이유

LLM은 마법이 아니에요, 확률 게임입니다

ChatGPT나 Claude 같은 LLM을 사용하다 보면 “와, 이건 정말 생각하는 거 아니야?”라고 느낄 때가 있어요. 하지만 개발자 관점에서 보면 LLM은 마법이 아니라 아주 정교한 확률 통계 모델입니다.

오늘은 Java/Spring 개발자로서 LLM의 작동 원리를 3가지 핵심 개념으로 파헤쳐보겠습니다:

  1. Token (토큰) - 왜 char 단위가 아닐까?
  2. Next Token Prediction - 문장이 만들어지는 과정
  3. Hallucination (환각) - AI가 거짓말하는 이유

1. Token (토큰) - 왜 Stringchar 단위가 아닐까? 🔤

개발자의 첫 번째 질문

“텍스트 처리라면 Stringchar[]로 쪼개서 처리하면 되는 거 아닌가?”

1
2
3
String text = "안녕하세요";
char[] chars = text.toCharArray();
// 결과: ['안', '녕', '하', '세', '요'] - 5개 문자

하지만 LLM은 문자 단위가 아니라 토큰(Token) 단위로 처리합니다.

1
2
3
4
// LLM의 토큰화
String text = "안녕하세요";
List<String> tokens = tokenizer.encode(text);
// 결과: ["안녕", "하세요"] - 2개 토큰

같은 문장인데 5개 처리 vs 2개 처리. 엄청난 차이죠?


비유: 바이트 코드가 아니라 어셈블리어 수준으로 처리

이걸 프로그래밍 언어의 컴파일 과정에 비유하면:

1
2
3
4
5
// char 단위 = 바이트 코드 수준 (너무 Low-Level)
byte[] bytecode = {0x61, 0x62, 0x63}; // 'a', 'b', 'c'

// Token 단위 = 어셈블리어 수준 (의미 있는 단위)
String[] instructions = {"LOAD", "ADD", "STORE"}; // 명령어 단위
  • 바이트 단위 처리: 0과 1만 봐서는 무슨 의미인지 알 수 없음
  • 어셈블리어 단위 처리: LOAD, ADD 같은 명령어는 의미 있는 동작 단위

LLM도 마찬가지예요. '안', '녕' 각각은 의미가 없지만, "안녕"인사라는 개념을 가진 의미 단위입니다.


왜 토큰 단위로 처리할까?

1) 계산 비용 절감 - DB 인덱싱과 유사

데이터베이스에서 Full Table Scan보다 Index Scan이 빠른 것처럼, 토큰화는 처리 단위를 대폭 줄여줍니다.

1
2
3
4
5
6
7
-- char 단위 = Full Table Scan (비효율)
SELECT * FROM characters WHERE char IN ('J', 'a', 'v', 'a');
-- 4번 조회

-- Token 단위 = Index Scan (효율)
SELECT * FROM tokens WHERE token = 'Java';
-- 1번 조회
처리 방식예시처리 횟수
문자 단위“데이터베이스” → [‘데’,’이’,’터’,’베’,’이’,’스’]6회
토큰 단위“데이터베이스” → [“데이터베이스”]1회

자주 사용되는 단어는 하나의 토큰으로 사전에 등록되어 있기 때문에, 매번 문자를 조합할 필요가 없어요!

2) 의미 단위 처리 - 객체 지향의 캡슐화

1
2
3
4
5
6
7
8
9
10
// 문자 단위 - 의미 없음
char c1 = 'J';
char c2 = 'a';
char c3 = 'v';
char c4 = 'a';
// 각 문자는 독립적, 의미 없음

// 토큰 단위 - 의미 있음
Token token = new Token("Java");
// "Java"는 프로그래밍 언어라는 개념을 캡슐화

객체 지향에서 데이터와 기능을 하나의 클래스로 묶듯이, 토큰은 자주 함께 등장하는 문자들을 하나의 의미 단위로 묶습니다.

3) 메모리 효율 - Enum처럼 정수로 매핑

1
2
3
4
5
6
7
8
9
10
11
// 실제로는 문자열이 아니라 정수로 처리
public enum Token {
    HELLO(1),
    WORLD(2),
    JAVA(3);
    
    private final int id;
}

// 문자열 "Hello World" 대신 정수 배열 [1, 2]로 처리
int[] tokenIds = {1, 2}; // 메모리 절약!

LLM 내부에서는 “Java”를 문자열로 저장하지 않고 42315 같은 정수 ID로 변환해서 처리합니다. 메모리도 절약되고 연산도 빨라지죠!


실제 토큰화 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
String prompt = "ChatGPT는 정말 똑똑해!";

// OpenAI Tokenizer 결과 (대략)
List<String> tokens = [
    "Chat",      // 1개 토큰
    "GPT",       // 1개 토큰
    "는",        // 1개 토큰
    " 정말",     // 공백 포함 1개 토큰
    " 똑",       // 공백 포함 1개 토큰
    "똑",        // 1개 토큰
    "해",        // 1개 토큰
    "!"          // 1개 토큰
];
// 총 8개 토큰

여기서 중요한 점:

  • 공백도 토큰의 일부입니다. “ 정말”처럼 앞에 공백이 포함돼요.
  • 영어 단어는 대부분 1개 토큰, 한글은 음절별로 쪼개질 수 있어요.
  • 특수 기호(!, ?)도 토큰으로 처리됩니다.

토큰 계산 비용이 왜 중요한가?

API 요금이 토큰 단위로 청구되기 때문입니다!

1
2
3
4
5
6
7
8
9
// OpenAI API 예시
String prompt = "이 코드를 리팩토링해줘: " + longCode;

int inputTokens = tokenizer.count(prompt);      // 입력: 500 토큰
int outputTokens = tokenizer.count(response);   // 출력: 1000 토큰

// 요금 계산 (GPT-4 기준)
double cost = (inputTokens * 0.00003) + (outputTokens * 0.00006);
// 입력 $0.015 + 출력 $0.06 = $0.075

실전 팁:

  • 긴 코드를 통째로 넣으면 토큰이 폭증! → 필요한 부분만 잘라서 전송
  • JSON 같은 구조화된 데이터는 토큰이 많이 듦 → 간결하게 요약
  • 이전 대화가 누적되면 토큰이 계속 증가 → 중요한 것만 컨텍스트에 유지

2. Next Token Prediction - SELECT next_word ORDER BY probability DESC LIMIT 1 🔮

LLM의 핵심: 다음 토큰 예측

LLM은 결국 “다음에 올 토큰을 예측하는 함수”입니다. SQL로 비유하면:

1
2
3
4
5
6
-- LLM의 본질
SELECT next_token
FROM vocabulary
WHERE context = '이전 모든 토큰'
ORDER BY probability DESC
LIMIT 1;

매번 이 쿼리를 반복해서 문장을 생성하는 거예요!


Java 코드로 표현한 LLM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class SimpleLLM {
    
    private Vocabulary vocabulary; // 모든 가능한 토큰 사전
    
    /**
     * 다음 토큰을 확률 기반으로 예측
     */
    public Token predictNextToken(List<Token> context) {
        // 1. 이전 모든 토큰을 보고 각 토큰의 확률 계산
        Map<Token, Double> probabilities = new HashMap<>();
        
        for (Token candidate : vocabulary.getAllTokens()) {
            // Transformer가 확률을 계산
            double prob = transformer.calculateProbability(context, candidate);
            probabilities.put(candidate, prob);
        }
        
        // 2. 확률 분포에서 샘플링
        // 예: {"는": 0.35, "을": 0.25, "가": 0.20, ...}
        return sampleFromDistribution(probabilities);
    }
    
    /**
     * 전체 문장 생성 (while 루프!)
     */
    public String generateText(String prompt) {
        List<Token> tokens = tokenizer.encode(prompt);
        
        // 종료 토큰이 나오거나 최대 길이까지 반복
        while (!isEndToken(tokens.getLast()) && tokens.size() < maxLength) {
            Token next = predictNextToken(tokens); // 다음 토큰 예측
            tokens.add(next);                       // 토큰 추가
        }
        
        return tokenizer.decode(tokens);
    }
}

핵심은 while 루프로 한 번에 하나씩 순차적으로 생성한다는 거예요!


실제 작동 과정 - 단계별 시뮬레이션

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
사용자 입력: "Java는"

┌─────────────────────────────────────────────┐
│ Step 1: 첫 번째 토큰 예측                    │
└─────────────────────────────────────────────┘
입력 컨텍스트: ["Java", "는"]

확률 분포:
- " 객체": 0.40       ← 가장 높음!
- " 프로그래밍": 0.25
- " 언어": 0.20
- " 커피": 0.05
- ...

선택: " 객체" (40% 확률로 선택)
현재 문장: "Java는 객체"

┌─────────────────────────────────────────────┐
│ Step 2: 두 번째 토큰 예측                    │
└─────────────────────────────────────────────┘
입력 컨텍스트: ["Java", "는", " 객체"]

확률 분포:
- "지향": 0.60        ← 가장 높음!
- "의": 0.25
- "를": 0.10
- ...

선택: "지향" (60% 확률로 선택)
현재 문장: "Java는 객체지향"

┌─────────────────────────────────────────────┐
│ Step 3: 세 번째 토큰 예측                    │
└─────────────────────────────────────────────┘
입력 컨텍스트: ["Java", "는", " 객체", "지향"]

확률 분포:
- " 프로그래밍": 0.70  ← 가장 높음!
- " 언어": 0.20
- "적": 0.08
- ...

선택: " 프로그래밍" (70% 확률로 선택)
현재 문장: "Java는 객체지향 프로그래밍"

...계속 반복...

마치 데이터베이스 커서(Cursor)처럼 앞으로만 진행하고, 한 번 생성한 토큰은 수정할 수 없어요!

1
2
3
4
5
6
// DB Cursor와 유사
Cursor cursor = database.query("SELECT * FROM tokens");
while (cursor.hasNext()) {
    Token next = cursor.next(); // 앞으로만 진행
    // 이전 토큰은 수정 불가!
}

왜 매번 다른 답변이 나올까? - Temperature 파라미터

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public Token sampleFromDistribution(
    Map<Token, Double> probs, 
    double temperature
) {
    if (temperature == 0.0) {
        // 항상 가장 확률 높은 것만 선택 (결정적)
        return probs.entrySet().stream()
            .max(Map.Entry.comparingByValue())
            .get().getKey();
    } else {
        // 확률 분포에 따라 무작위 선택 (확률적)
        return weightedRandomSample(probs, temperature);
    }
}

Temperature 비교:

Temperature동작예시
0.0항상 가장 확률 높은 토큰 선택“Java는 객체지향 프로그래밍 언어입니다.” (매번 동일)
0.7 (기본)확률 분포에 따라 선택“Java는 객체지향 언어예요.” (다양함)
1.5낮은 확률 토큰도 자주 선택“Java는 커피 이름이기도 해요!” (창의적이지만 이상함)

핵심 포인트 정리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// LLM = while 루프 + 확률 분포 샘플링
String response = "";
List<Token> context = tokenize(prompt);

while (!isDone(context)) {
    // 1. 다음 토큰 확률 계산 (이전 모든 토큰 참고)
    Map<Token, Double> probs = calculateProbabilities(context);
    
    // 2. 확률 분포에서 샘플링
    Token next = sample(probs, temperature);
    
    // 3. 컨텍스트에 추가
    context.add(next);
    response += next.getText();
}

return response;
  • 한 번에 하나씩 순차적으로 생성 (병렬 생성 불가)
  • 이전 모든 토큰을 보고 다음 토큰 결정
  • 확률이 가장 높은 것을 선택하지만, 100% 정확한 것은 아님

3. Hallucination (환각) - AI가 거짓말하는 이유는 “확률적 찐빠” 🌀

환각이란?

LLM이 사실이 아닌 정보를 그럴듯하게 지어내는 현상을 환각(Hallucination)이라고 해요.

1
2
3
4
5
6
사용자: "2024년 출시된 Java 25의 신기능은?"

ChatGPT: "Java 25에는 Pattern Matching for Arrays와 
          Virtual Threads 2.0이 추가되었습니다!"

→ 하지만 Java 25는 아직 출시 안 됨! 😱

개발자의 버그 vs LLM의 환각

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 개발자의 버그 (결정적 Deterministic)
public int divide(int a, int b) {
    return a / b; 
    // b가 0이면 항상 ArithmeticException 발생
    // 재현 가능! 디버깅 가능!
}

// 2. LLM의 환각 (확률적 Probabilistic)
public Token predictNext(List<Token> context) {
    Map<Token, Double> probs = calculateProbabilities(context);
    
    return sampleFromDistribution(probs);
    // "가장 확률 높은" 토큰이 "정확한" 토큰은 아님!
    // 매번 다른 결과 가능!
}
구분개발자의 버그LLM의 환각
발생 원인로직 오류, 코딩 실수확률 기반 추론, 학습 데이터 부족
재현성항상 재현 가능매번 다를 수 있음
예측 가능성디버깅으로 찾을 수 있음확률적으로만 예측 가능
해결법코드 수정프롬프트 개선, 외부 검증
책임 소재개발자 실수모델의 본질적 한계

왜 환각이 발생할까? 3가지 이유

1) 학습 데이터 부족 - Cache Miss와 유사

1
2
3
4
5
6
7
8
9
10
11
// LLM = 거대한 Cache
Cache<String, String> trainingData = new HashMap<>();
// 2023년 10월까지의 인터넷 데이터만 학습

String query = "2024년 Java 신기능은?";

if (!trainingData.containsKey(query)) {
    // Cache Miss!
    // → 비슷한 패턴으로 "추론"해서 답변 생성
    // → 존재하지 않는 기능을 "그럴듯하게" 지어냄
}

데이터베이스에서 SELECT했는데 데이터가 없으면:

  • DB: null 또는 빈 결과 리턴
  • LLM: “비슷한 것”을 찾아서 억지로라도 답변 생성

왜 이럴까? Generative(생성형) 모델의 특성 때문입니다!

1
2
3
4
5
6
7
8
9
// DB: Retrieval (검색) - 없으면 없다고 말함
Optional<String> result = database.findById(id);
if (result.isEmpty()) {
    return "데이터를 찾을 수 없습니다."; // 솔직함
}

// LLM: Generation (생성) - 없어도 만들어냄
String result = llm.generate(prompt);
// "아마도 이럴 것 같은데..." (추측)

2) 확률 기반 선택 - Best Effort Delivery

1
2
3
4
5
6
7
8
9
10
11
Map<Token, Double> probabilities = Map.of(
    "정확한_토큰", 0.30,
    "비슷한_토큰", 0.28,    // ← 이게 선택될 수도!
    "그럴듯한_토큰", 0.22,  // ← 이것도 가능!
    "관련없는_토큰", 0.10,
    "이상한_토큰", 0.10
);

Token selected = sampleFromDistribution(probabilities);
// 30%만 정확한 토큰이 선택됨
// 나머지 70%는 "비슷하지만 틀린" 토큰!

가장 확률이 높은 것도 30%밖에 안 돼요. 70% 확률로 다른 토큰이 선택되는 거죠!

이게 누적되면:

1
2
3
4
5
Step 1: 정확한 토큰 선택 (30%)
Step 2: 정확한 토큰 선택 (30%)
Step 3: 정확한 토큰 선택 (30%)

→ 3단계 모두 정확할 확률 = 0.3 × 0.3 × 0.3 = 2.7%

10개 토큰을 생성하면? 0.3^10 = 0.0006% 😱


3) 컨텍스트 길이 제한 - Memory Overflow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// LLM의 메모리 제한
public class LLMContext {
    private static final int MAX_TOKENS = 8192; // GPT-4 기준
    
    private List<Token> context = new ArrayList<>();
    
    public void addToken(Token token) {
        context.add(token);
        
        if (context.size() > MAX_TOKENS) {
            // 오래된 토큰은 "잊어버림" (FIFO Queue)
            context.remove(0);
            System.out.println("⚠️ 오래된 대화 내용이 삭제됩니다!");
        }
    }
}

긴 대화를 나누다 보면 초반 내용을 잊어버리는 이유가 이거예요!

1
2
3
4
5
6
7
8
사용자: "내 이름은 김개발이야."
ChatGPT: "안녕하세요, 개발님!"

...(8000 토큰 분량의 대화)...

사용자: "내 이름이 뭐였지?"
ChatGPT: "죄송하지만 대화 기록에서 찾을 수 없네요."
← 초반 대화가 메모리에서 삭제됨!

환각 vs Not Found 에러 비교

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 데이터베이스 - 솔직하게 에러 발생
try {
    User user = database.findById(999);
} catch (NotFoundException e) {
    throw new ResponseStatusException(
        HttpStatus.NOT_FOUND, 
        "User not found"
    ); // 명확한 에러!
}

// 2. LLM - 억지로라도 답변 생성
String prompt = "존재하지 않는 Java 25 기능 알려줘";
String response = llm.generate(prompt);
// "Java 25에는 Pattern Matching for Arrays가 있습니다!"
// ← 존재하지 않는 기능을 지어냄 (에러 아님!)
상황데이터베이스LLM
데이터 없음404 Not Found 에러그럴듯한 답변 생성 (환각)
모호한 쿼리400 Bad Request 에러추측해서 답변
잘못된 입력예외 발생최선의 답변 시도

왜 이렇게 설계되었을까?

  • LLM은 대화형 서비스이기 때문에 “모르겠다”보다 “아마 이럴 거야”라고 답하는 게 사용자 경험상 좋다고 판단
  • 하지만 이게 정확성을 해치는 주요 원인

환각을 줄이는 개발자의 해결책

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class LLMWithValidation {
    
    private LLM llm;
    private Database factDatabase; // 실제 사실 DB
    
    public String generateSafe(String prompt) {
        String response = llm.generate(prompt);
        
        // 1. RAG (Retrieval-Augmented Generation)
        // "DB에서 실제 사실 먼저 검색"
        List<String> facts = factDatabase.retrieveRelevantFacts(prompt);
        
        if (!isConsistentWithFacts(response, facts)) {
            // 사실과 다르면 재생성
            String augmentedPrompt = prompt + "\n\n참고 자료:\n" 
                + String.join("\n", facts);
            response = llm.generate(augmentedPrompt);
        }
        
        // 2. Confidence Score 확인
        double confidence = llm.getConfidenceScore();
        if (confidence < 0.7) {
            response += "\n\n⚠️ 이 답변의 신뢰도는 낮습니다. 검증이 필요합니다.";
        }
        
        // 3. 외부 검증 API 호출
        if (requiresFactCheck(response)) {
            boolean isValid = externalFactCheckAPI.verify(response);
            if (!isValid) {
                return "정확한 정보를 제공할 수 없습니다. " +
                       "공식 문서를 참고해주세요: " + getOfficialDocsUrl();
            }
        }
        
        return response;
    }
    
    private boolean isConsistentWithFacts(String response, List<String> facts) {
        // 간단한 키워드 매칭 또는 임베딩 유사도 비교
        for (String fact : facts) {
            if (contradicts(response, fact)) {
                return false;
            }
        }
        return true;
    }
}

실전 적용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Spring에서 LLM API 사용 시
@Service
public class AIAssistantService {
    
    @Autowired
    private OpenAIClient openAI;
    
    @Autowired
    private DocumentRepository documentRepo;
    
    public String answerQuestion(String question) {
        // 1. 먼저 내부 문서에서 검색 (RAG)
        List<String> relevantDocs = documentRepo
            .searchByEmbedding(question, limit = 3);
        
        // 2. 문서와 함께 프롬프트 작성
        String prompt = String.format("""
            다음 문서를 참고해서 질문에 답변해주세요.
            정확한 정보만 답변하고, 모르면 "모르겠습니다"라고 답변하세요.
            
            [참고 문서]
            %s
            
            [질문]
            %s
            """, String.join("\n\n", relevantDocs), question);
        
        // 3. LLM 호출
        String response = openAI.chat(prompt, temperature = 0.3);
        
        // 4. 응답 검증
        if (containsUncertainPhrases(response)) {
            logger.warn("LLM response contains uncertainty: {}", response);
        }
        
        return response;
    }
}

정리: LLM은 “확률 게임”입니다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * LLM의 본질을 한 줄로 표현
 */
public class LLM {
    public String generate(String prompt) {
        List<Token> tokens = tokenize(prompt);
        
        while (!isDone(tokens)) {
            // 1. 확률 계산
            Map<Token, Double> probs = transformer.predict(tokens);
            
            // 2. 확률 기반 샘플링 (100% 정확하지 않음!)
            Token next = sample(probs);
            
            // 3. 토큰 추가
            tokens.add(next);
        }
        
        return detokenize(tokens);
    }
}

핵심 정리

개념개발자 비유핵심 포인트
TokenDB Index, 어셈블리어의미 단위로 처리, 계산 비용 절감
Next Token Predictionwhile 루프 + SQL ORDER BY probability한 번에 하나씩 순차 생성
HallucinationCache Miss + Best Effort없는 정보를 확률적으로 지어냄

개발자가 알아야 할 실전 팁

1. 토큰 비용 최적화

1
2
3
4
5
// Bad: 전체 코드를 다 넣음
String prompt = "이 코드를 리팩토링해줘:\n" + entireCodebase;

// Good: 필요한 부분만 발췌
String prompt = "이 메서드를 리팩토링해줘:\n" + targetMethod;

2. 환각 방지

1
2
3
4
5
6
7
String prompt = """
    다음 질문에 답변해주세요.
    확실하지 않으면 "모르겠습니다"라고 답변하세요.
    추측하지 말고, 사실만 말씀해주세요.
    
    질문: %s
    """.formatted(userQuestion);

3. 확률적 특성 이해

1
2
3
4
5
6
7
8
// 같은 질문도 매번 다른 답변 (temperature > 0)
String answer1 = llm.generate(prompt, temperature = 0.7);
String answer2 = llm.generate(prompt, temperature = 0.7);
// answer1 != answer2 (확률적!)

// 결정적 답변 필요 시
String answer = llm.generate(prompt, temperature = 0.0);
// 항상 동일한 답변

마치며

LLM은 마법이 아닙니다. 확률 통계 기반의 정교한 함수예요.

  • Token: 효율적인 처리를 위한 의미 단위
  • Next Token Prediction: while 루프로 한 토큰씩 순차 생성
  • Hallucination: 확률적 추론의 부작용 (악의가 아니라 “찐빠”)

개발자로서 이 원리를 이해하면:

  1. 더 효과적인 프롬프트를 작성할 수 있고
  2. API 비용을 최적화할 수 있으며
  3. 결과를 적절히 검증하는 로직을 설계할 수 있습니다!

참고 자료

  • OpenAI Tokenizer: https://platform.openai.com/tokenizer
  • Attention Is All You Need (Transformer 논문): https://arxiv.org/abs/1706.03762
  • GPT-3 Paper: https://arxiv.org/abs/2005.14165
This post is licensed under CC BY 4.0 by the author.

[AI 기초] LLM의 메모리 구조: Context Window와 RAG (feat. HTTP Stateless)

[AI 실전] AI에게 일을 제대로 시키는 노하우 - Zero-shot, Few-shot, CoT 완벽 정리