[AI 기초] LLM 작동 원리 - Token, Next Token Prediction, Hallucination
1. Token (토큰)
1.1. 개념
- LLM은 텍스트를
char[]단위가 아닌 Token 단위로 처리함 - 의미 있는 단위로 묶어서 처리함으로써 계산 비용 절감
1
2
3
4
5
6
7
8
// 문자 단위 처리 (비효율)
String text = "안녕하세요";
char[] chars = text.toCharArray();
// ['안', '녕', '하', '세', '요'] - 5개 문자
// Token 단위 처리 (효율)
List<String> tokens = tokenizer.encode(text);
// ["안녕", "하세요"] - 2개 토큰
1.2. 비유: JVM 바이트코드 vs 어셈블리어
char단위: 바이트코드 수준 (Low-Level, 의미 파악 불가)- Token 단위: 어셈블리어 수준 (의미 있는 명령어 단위)
1
2
3
4
5
// char 단위 = 바이트코드
byte[] bytecode = {0x61, 0x62, 0x63}; // 의미 없음
// Token 단위 = 어셈블리어
String[] instructions = {"LOAD", "ADD", "STORE"}; // 의미 있음
1.3. Token 단위 처리의 이점
1.3.1. 계산 비용 절감
- DB 인덱싱과 유사:
Full Table ScanvsIndex Scan - 자주 사용되는 단어는 사전에 등록되어 있어 조합 연산 불필요
| 처리 방식 | 예시 | 처리 횟수 |
|---|---|---|
| 문자 단위 | “데이터베이스” → [‘데’,’이’,’터’,’베’,’이’,’스’] | 6회 |
| 토큰 단위 | “데이터베이스” → [“데이터베이스”] | 1회 |
1
2
3
4
5
-- 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번 조회
1.3.2. 의미 단위 처리
- 객체 지향의 캡슐화 원리와 유사
- 자주 함께 등장하는 문자들을 하나의 의미 단위로 묶음
1
2
3
4
5
// 문자 단위 - 의미 없음
char c1 = 'J', c2 = 'a', c3 = 'v', c4 = 'a';
// 토큰 단위 - 의미 있음
Token token = new Token("Java"); // 프로그래밍 언어 개념 캡슐화
1.3.3. 메모리 효율
- 문자열 대신 정수 ID로 매핑 (Enum 패턴)
- 메모리 절약 및 연산 속도 향상
1
2
3
4
5
6
7
8
// 실제 처리: 문자열 → 정수 ID
public enum Token {
HELLO(1), WORLD(2), JAVA(3);
private final int id;
}
// "Hello World" → [1, 2] (정수 배열)
int[] tokenIds = {1, 2};
1.4. 실제 토큰화 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
String prompt = "ChatGPT는 정말 똑똑해!";
// OpenAI Tokenizer 결과
List<String> tokens = Arrays.asList(
"Chat", // 1개 토큰
"GPT", // 1개 토큰
"는", // 1개 토큰
" 정말", // 공백 포함 1개 토큰
" 똑", // 공백 포함 1개 토큰
"똑", // 1개 토큰
"해", // 1개 토큰
"!" // 1개 토큰
);
// 총 8개 토큰
특징:
- 공백도 토큰의 일부 (예:
" 정말") - 영어 단어는 대부분 1개 토큰
- 한글은 음절별로 분리 가능
- 특수 기호(
!,?)도 토큰으로 처리
1.5. 토큰 비용 최적화
- API 요금이 토큰 단위로 청구됨
- 입력/출력 토큰 수에 따라 비용 결정
1
2
3
4
5
6
7
// OpenAI API 요금 계산 (GPT-4 기준)
int inputTokens = tokenizer.count(prompt); // 입력: 500 토큰
int outputTokens = tokenizer.count(response); // 출력: 1000 토큰
double inputCost = inputTokens * 0.00003; // $0.015
double outputCost = outputTokens * 0.00006; // $0.06
double totalCost = inputCost + outputCost; // $0.075
Note
- 전체 코드베이스 통째 전송 시 토큰 폭증 → 필요한 메서드만 발췌
- JSON 등 구조화 데이터는 토큰 과다 → 간결하게 요약
- 이전 대화 누적 시 토큰 증가 → 중요한 것만 컨텍스트 유지
2. Next Token Prediction
2.1. 개념
- LLM의 핵심: 다음 토큰을 예측하는 함수
while루프로 한 토큰씩 순차 생성
1
2
3
4
5
6
-- LLM의 본질 (SQL 비유)
SELECT next_token
FROM vocabulary
WHERE context = '이전 모든 토큰'
ORDER BY probability DESC
LIMIT 1;
2.2. Java 코드로 표현
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
public class SimpleLLM {
private Vocabulary vocabulary; // 모든 가능한 토큰 사전
/**
* 다음 토큰을 확률 기반으로 예측
*/
public Token predictNextToken(List<Token> context) {
// 1. 이전 모든 토큰을 보고 각 토큰의 확률 계산
Map<Token, Double> probabilities = new HashMap<>();
for (Token candidate : vocabulary.getAllTokens()) {
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);
}
}
2.3. 작동 과정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
입력: "Java는"
Step 1: ["Java", "는"] → 확률 분포 계산
- " 객체": 0.40 (선택)
- " 프로그래밍": 0.25
- " 언어": 0.20
현재: "Java는 객체"
Step 2: ["Java", "는", " 객체"] → 확률 분포 계산
- "지향": 0.60 (선택)
- "의": 0.25
현재: "Java는 객체지향"
Step 3: ["Java", "는", " 객체", "지향"] → 확률 분포 계산
- " 프로그래밍": 0.70 (선택)
- " 언어": 0.20
현재: "Java는 객체지향 프로그래밍"
...반복...
특징:
- DB Cursor와 유사: 앞으로만 진행, 이전 토큰 수정 불가
- 한 번에 하나씩 순차 생성 (병렬 생성 불가)
1
2
3
4
5
6
// DB Cursor와 유사
Cursor cursor = database.query("SELECT * FROM tokens");
while (cursor.hasNext()) {
Token next = cursor.next(); // 앞으로만 진행
// 이전 토큰은 수정 불가
}
2.4. Temperature 파라미터
- 확률 분포에서 샘플링 방식 제어
- 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 | 동작 | 예시 |
|---|---|---|
| 0.0 | 항상 가장 확률 높은 토큰 선택 | “Java는 객체지향 프로그래밍 언어입니다.” (매번 동일) |
| 0.7 (기본) | 확률 분포에 따라 선택 | “Java는 객체지향 언어예요.” (다양함) |
| 1.5 | 낮은 확률 토큰도 자주 선택 | “Java는 커피 이름이기도 해요!” (창의적이지만 이상함) |
2.5. 핵심 포인트
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;
3가지 핵심:
- 한 번에 하나씩 순차 생성 (병렬 생성 불가)
- 이전 모든 토큰을 보고 다음 토큰 결정
- 확률이 가장 높은 것을 선택하지만, 100% 정확하지 않음
3. Hallucination (환각)
3.1. 개념
- LLM이 사실이 아닌 정보를 그럴듯하게 지어내는 현상
- 확률 기반 추론의 부작용
1
2
3
4
5
6
사용자: "2024년 출시된 Java 25의 신기능은?"
ChatGPT: "Java 25에는 Pattern Matching for Arrays와
Virtual Threads 2.0이 추가되었습니다!"
→ 하지만 Java 25는 아직 출시 안 됨
3.2. 개발자의 버그 vs LLM의 환각
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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.3. 환각 발생 원인
3.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!
// → 비슷한 패턴으로 "추론"해서 답변 생성
// → 존재하지 않는 기능을 "그럴듯하게" 지어냄
}
DB vs LLM:
- DB (Retrieval): 데이터 없으면
null반환 (솔직함) - LLM (Generation): 데이터 없어도 생성 (추측)
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);
// "아마도 이럴 것 같은데..." (추측)
3.3.2. 확률 기반 선택
- Best Effort Delivery: 가장 확률 높은 토큰도 30% 수준
- 70% 확률로 다른 토큰이 선택됨
1
2
3
4
5
6
7
8
9
10
Map<Token, Double> probabilities = Map.of(
"정확한_토큰", 0.30,
"비슷한_토큰", 0.28, // ← 선택될 수도
"그럴듯한_토큰", 0.22, // ← 선택될 수도
"관련없는_토큰", 0.10,
"이상한_토큰", 0.10
);
Token selected = sampleFromDistribution(probabilities);
// 30%만 정확한 토큰이 선택됨
누적 확률:
1
2
3
4
5
6
Step 1: 정확한 토큰 선택 (30%)
Step 2: 정확한 토큰 선택 (30%)
Step 3: 정확한 토큰 선택 (30%)
→ 3단계 모두 정확할 확률 = 0.3³ = 2.7%
→ 10개 토큰: 0.3¹⁰ = 0.0006%
Note
확률적으로는 맞을 수도 있지만, 실제로는 틀릴 수 있음
3.3.3. 컨텍스트 길이 제한
- JVM Heap 메모리와 유사: 용량 초과 시 오래된 데이터 삭제
1
2
3
4
5
6
7
8
9
10
11
12
13
// 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);
}
}
}
예시:
1
2
3
4
5
6
7
8
사용자: "내 이름은 김개발이야."
ChatGPT: "안녕하세요, 개발님!"
...(8000 토큰 분량의 대화)...
사용자: "내 이름이 뭐였지?"
ChatGPT: "죄송하지만 대화 기록에서 찾을 수 없네요."
← 초반 대화가 메모리에서 삭제됨
3.4. 환각 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 에러 | 추측해서 답변 |
| 잘못된 입력 | 예외 발생 | 최선의 답변 시도 |
설계 이유:
- 대화형 서비스 특성상 “모르겠다”보다 “아마 이럴 거야”가 사용자 경험상 유리
- 하지만 정확성을 해치는 주요 원인
3.5. 환각 완화 전략
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
@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. Temperature 낮춰서 결정적으로
String response = openAI.chat(prompt, temperature = 0.3);
// 4. 응답 검증
if (containsUncertainPhrases(response)) {
logger.warn("LLM response contains uncertainty: {}", response);
}
return response;
}
}
핵심 전략:
- RAG (Retrieval-Augmented Generation): DB에서 실제 사실 먼저 검색
- Temperature 낮추기: 확률적 선택을 줄여서 결정적으로
- 명확한 지시: “모르면 모르겠다고 말하세요” 명시
- 외부 검증: 응답을 받은 후 실제 DB나 API로 검증
4. 정리
4.1. 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);
}
}
4.2. 핵심 개념 정리
| 개념 | 개발자 비유 | 핵심 포인트 |
|---|---|---|
| Token | DB Index, 어셈블리어 | 의미 단위로 처리, 계산 비용 절감 |
| Next Token Prediction | while 루프 + SQL ORDER BY probability | 한 번에 하나씩 순차 생성 |
| Hallucination | Cache Miss + Best Effort | 없는 정보를 확률적으로 지어냄 |
4.3. 실전 팁
4.3.1. 토큰 비용 최적화
1
2
3
4
5
// Bad: 전체 코드를 다 넣음
String prompt = "이 코드를 리팩토링해줘:\n" + entireCodebase;
// Good: 필요한 부분만 발췌
String prompt = "이 메서드를 리팩토링해줘:\n" + targetMethod;
4.3.2. 환각 방지
1
2
3
4
5
6
7
String prompt = """
다음 질문에 답변해주세요.
확실하지 않으면 "모르겠습니다"라고 답변하세요.
추측하지 말고, 사실만 말씀해주세요.
질문: %s
""".formatted(userQuestion);
4.3.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);
// 항상 동일한 답변
참고 자료
- 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