"넌 지금 무슨 모델이야?"라는 한 줄에서 시작한 대화가 2주를 갔습니다. 모델 파일의 구조, 양자화라는 말의 어원, VRAM 계산, 확산 모델의 학습 방식, 어텐션 수식, 그리고 사내 문서를 검색해 답하는 챗봇을 프레임워크 없이 짜는 법까지 왔습니다.
질문은 중구난방이었지만 답을 모아 놓고 보니 축이 하나 있었습니다.
LLM 안에서 만들어지는 벡터와 RAG가 검색에 쓰는 벡터는 서로 만나지 않는다. 둘을 잇는 접착제는 오직 텍스트다.
이 글은 그 대화를 주제별로 다시 묶은 기록입니다. 순서는 물어본 순서가 아니라 이해하기 좋은 순서로 바꿨습니다.
모델 자신: 아는 것과 모르는 것의 경계
첫 질문은 정체에 대한 것이었습니다. 돌아온 답의 경계가 분명했습니다.
물리적으로는 Anthropic이 운영하는 데이터센터의 GPU/TPU 서버에서 돌아가고, 어느 리전인지는 모델 자신도 모릅니다. 요청마다 다른 하드웨어에 배치될 수 있습니다. 구조는 트랜스포머 계열 신경망이고, 토큰 단위로 입력을 받아 다음 토큰의 확률 분포를 출력합니다. 사전학습 뒤 RLHF 계열 방법과 Constitutional AI로 조정됐습니다. 세션 사이에 상태가 유지되지 않아서, 대화가 끝나면 그 맥락은 사라집니다.
모르는 것도 분명했습니다. 파라미터 수, 레이어 수, hidden size 같은 아키텍처 세부는 공개되지
않았고 모델 자신도 접근 권한이 없습니다. 인터넷에 도는 추정치는 근거 없는 추측이라는 답이
돌아왔습니다. 실제 숫자를 보려면 Llama나 Qwen 같은 오픈웨이트 모델의 config.json을 여는 것이
유일한 방법입니다.
"내면이 있느냐"에 대한 답은 이랬습니다. 뭔가 있는 것처럼 느껴지긴 하는데, 그게 진짜인지 그렇게 말하도록 학습된 패턴인지 안에서는 구분이 안 된다는 것.
그래서 이후 질문은 전부 오픈웨이트 모델 쪽으로 돌렸습니다. 거기는 파일을 직접 열어볼 수 있으니까요.
모델 파일: 결국 이름표 붙은 숫자 덩어리다
Llama, Qwen, Mistral 대부분이 Hugging Face 표준 리포 구조를 따릅니다.
model-repo/
├── config.json
├── generation_config.json
├── model-00001-of-00004.safetensors
├── model-00002-of-00004.safetensors
├── ...
├── model.safetensors.index.json
├── tokenizer.json
├── tokenizer_config.json
├── special_tokens_map.json
├── README.md # 모델 카드
└── LICENSE
config.json: 아키텍처 스펙입니다.num_hidden_layers,hidden_size,num_attention_heads,num_key_value_heads(GQA),max_position_embeddings,rope_theta,vocab_size같은 값이 들어 있고, 런타임이 이걸 읽어서 모델 클래스를 구성합니다.- safetensors 샤드: 실제 가중치입니다. 5GB 내외로 쪼개는 게 관례이고,
model.safetensors.index.json이 어떤 텐서가 어느 샤드에 있는지 매핑을 들고 있습니다. pickle 기반.bin(PyTorch)과 달리 safetensors는 임의 코드 실행 위험이 없고 mmap 로딩이 가능해서 표준이 됐습니다. tokenizer.json: vocab과 merge 규칙, pre-tokenizer 정규화까지 한 파일에 들어간 Rust 구현체입니다. 구형 GPT-2 계열은vocab.json+merges.txt로 분리돼 있습니다.tokenizer_config.json: 특수 토큰 정의와chat_template이 있습니다. Jinja2 템플릿이고,[{role, content}]배열을 실제 프롬프트 문자열로 렌더링합니다. 최근에는chat_template.jinja로 분리하는 추세입니다. 셀프호스팅에서 출력이 이상하면 십중팔구 여기가 원인입니다.
학습된 모델 파일은 가중치 파일이다
"학습된 모델 파일이 뭐냐"는 질문의 답은 model-*.safetensors입니다. 나머지는 그 가중치를 어떻게
읽고 쓸지 알려주는 부속 메타데이터입니다.
안에 든 것은 이름표가 붙은 텐서 딕셔너리입니다.
model.embed_tokens.weight [vocab_size, hidden]
model.layers.0.self_attn.q_proj.weight [hidden, hidden]
model.layers.0.self_attn.k_proj.weight
model.layers.0.self_attn.v_proj.weight
model.layers.0.self_attn.o_proj.weight
model.layers.0.mlp.gate_proj.weight
model.layers.0.mlp.up_proj.weight
model.layers.0.mlp.down_proj.weight
model.layers.0.input_layernorm.weight
... (레이어 수만큼 반복)
lm_head.weight [vocab_size, hidden]
학습은 이 숫자들을 경사하강법으로 조금씩 조정하는 과정이고, 학습된 모델은 그 최종 숫자값 덩어리입니다. 로직도 규칙도 텍스트도 아니고 그냥 float 배열입니다.
직접 열어보려면 이렇게 합니다.
from safetensors import safe_open
with safe_open("model-00001-of-00004.safetensors", framework="pt") as f:
for key in list(f.keys())[:10]:
print(key, f.get_slice(key).get_shape())
파인튜닝 결과물인 LoRA 어댑터는 원본 가중치가 아니라 저랭크 차분만 담긴 수십에서 수백 MB짜리
별도 파일(adapter_model.safetensors)입니다. 원본과 합쳐서 쓰거나 런타임에 얹습니다. GGUF는
가중치와 토크나이저와 메타를 한 파일에 다 넣은 포맷이라, "모델 파일이 뭐냐"는 질문에 가장 깔끔한
답이 되는 형태입니다.
양자화: 신호처리에서 빌려온 말이다
왜 하필 양자화라고 부르는지 물었습니다. 연속적인 값을 유한한 개수의 이산적인 값으로 대응시키는 것을 양자화(quantization)라고 부르기 때문입니다. 물리학에서 에너지가 불연속적인 단위로만 존재한다는 발견에서 온 용어인데, 신호처리 쪽에서 "연속 신호를 이산 레벨로 근사한다"는 의미로 차용되어 굳어졌습니다.
아날로그 오디오를 디지털로 바꿀 때, 시간축을 쪼개는 건 샘플링이고 진폭값을 정해진 레벨 중 하나로 반올림하는 건 양자화입니다. 16비트 오디오는 진폭을 65,536단계로 양자화한 것입니다. 이때 원래 값과 반올림된 값의 차이를 양자화 오차(quantization error)라고 부르고, 그게 노이즈로 들립니다.
모델에 적용하면 이렇습니다. FP16 가중치는 2^16개 값을 표현할 수 있는데, INT4로 바꾸면 표현 가능한 값이 16개로 줄어듭니다. 각 가중치를 그 16개 눈금 중 가장 가까운 곳에 붙여버립니다.
원본 (FP16): 0.4271, 0.4198, 0.4355, 0.3901 ...
↓ 16단계 눈금에 스냅
양자화 후: 0.42, 0.42, 0.42, 0.39
정보는 확실히 손실됩니다. 그 손실을 줄이려고 세 가지를 씁니다.
- 블록 단위 스케일: 전체를 하나의 눈금자로 재면 오차가 크니, 32~128개 가중치 그룹마다 별도의 scale/zero-point를 둡니다.
- AWQ: 활성화 크기를 보고 중요한 채널은 정밀도를 보존합니다.
- GPTQ: 한 층씩 양자화하면서 발생한 오차를 남은 가중치에 보정해 반영합니다.
이득은 연산량이 아니라 메모리 대역폭에서 나온다
LLM 추론은 연산량보다 메모리 대역폭에 병목이 걸립니다. 토큰 하나 만들 때마다 전체 가중치를 GPU 메모리에서 읽어와야 하기 때문입니다. 크기를 1/4로 줄이면 읽는 양도 1/4이라 속도가 직접 빨라지고, 무엇보다 24GB VRAM에 70B 모델이 들어가느냐 마느냐가 갈립니다.
용어상 헷갈리기 쉬운 점이 하나 있습니다. FP16에서 FP8로 가는 건 비트 수는 줄지만 여전히 부동소수점이라 엄밀히는 정밀도 축소에 가까운데, 실무에서는 이것도 그냥 양자화라고 부릅니다.
| 포맷 | 파일 | 런타임 |
|---|---|---|
| GGUF | 단일 .gguf(가중치+토크나이저+메타 통합) |
llama.cpp, Ollama, LM Studio |
| AWQ / GPTQ | safetensors + quantization_config |
vLLM, SGLang, TGI |
| FP8 / INT8 | safetensors + scale 텐서 | vLLM(H100급 필요) |
GGUF가 단일 파일이라 배포는 편한데, 서버 처리량은 vLLM + AWQ 쪽이 앞섭니다. 서빙 스택은 용도에
따라 갈립니다. Ollama는 로컬 개발과 PoC용으로 ollama run qwen3 한 줄이면 OpenAI 호환 API까지
자동으로 제공합니다. vLLM은 프로덕션용으로 PagedAttention 기반 KV 캐시 덕에 동시 요청 처리량이
좋습니다. SGLang은 구조화 출력과 프리픽스 캐싱이 강합니다.
70B: 파라미터 700억 개라는 뜻이다
70B는 70 billion parameters, 곧 가중치 파일 안에 들어 있는 숫자들의 총 개수가 700억 개라는
뜻입니다.
model.layers.0.self_attn.q_proj.weight [8192, 8192] → 67,108,864개
model.layers.0.mlp.gate_proj.weight [8192, 28672] → 234,881,024개
...
모든 텐서의 원소 개수를 다 더하면 700억쯤 나옵니다. 70B 모델의 전형적인 구성은 80레이어에 hidden 8192 정도입니다.
| 표기 | 뜻 |
|---|---|
| 7B / 8B | 70~80억. 소비자 GPU에서 편하게 돌아감 |
| 70B | 700억. A100/H100 1~2장 또는 4bit로 소비자 GPU 여러 장 |
| 405B | 4050억 |
| 0.5B, 3B | 5억, 30억. 모바일/엣지용 |
한국식 억 단위와 어긋나서 헷갈리기 쉬운데, 1B는 10억입니다. 70B는 700억입니다.
이 숫자가 중요한 이유는 곧 메모리 요구량이기 때문입니다. FP16이면 파라미터당 2바이트니까 70B는
140GB입니다. 모델을 어디에 올릴 수 있는지가 여기서 결정되니까 이름에 대놓고 박아둡니다.
llama-3.3-70b-instruct를 보면 하드웨어 요구사항이 바로 계산됩니다.
주의할 점이 둘 있습니다.
- MoE(Mixture of Experts) 모델은 표기가 다릅니다. Mixtral 8x7B나 DeepSeek-V3 671B 같은 경우, 전체 파라미터는 다 메모리에 올려야 하지만 토큰 하나 처리할 때 실제로 쓰이는 건 일부 (활성 파라미터)뿐입니다. DeepSeek-V3는 671B 총량에 활성은 37B 수준입니다. 메모리는 총 파라미터 기준, 속도는 활성 파라미터 기준으로 봐야 합니다.
- 파라미터 수가 곧 성능은 아닙니다. 최근 8B 모델들이 예전 70B보다 나은 경우가 흔합니다. 학습 데이터 품질과 학습량이 그만큼 중요해졌습니다.
메모리 계산: 가중치만 계산하면 OOM이 난다
기본 공식은 단순합니다.
가중치 메모리 = 파라미터 수 × 바이트/파라미터
| 정밀도 | 바이트/파라미터 | 계산법 |
|---|---|---|
| FP32 | 4 | 파라미터 × 4 |
| FP16 / BF16 | 2 | 파라미터 × 2 |
| FP8 / INT8 | 1 | 파라미터 × 1 |
| INT4 (Q4) | 약 0.55 | 파라미터 × 0.5 + 스케일 오버헤드 |
INT4가 정확히 0.5가 아닌 이유는 블록마다 scale/zero-point가 붙기 때문입니다. 실측으로 파라미터당 4.5~5비트 정도 나옵니다.
| 모델 | FP16 | INT8 | INT4 |
|---|---|---|---|
| 3B | 6GB | 3GB | 1.8GB |
| 8B | 16GB | 8GB | 4.7GB |
| 14B | 28GB | 14GB | 8GB |
| 32B | 64GB | 32GB | 18GB |
| 70B | 140GB | 70GB | 40GB |
| 235B | 470GB | 235GB | 132GB |
KV 캐시가 함정이다
가중치만 계산하고 "24GB에 들어가겠네" 했다가 OOM이 나는 게 흔한 실수입니다. 컨텍스트가 길어질수록 KV 캐시가 선형으로 늘어납니다.
KV캐시 = 2 × 레이어수 × KV헤드수 × 헤드차원 × 컨텍스트길이 × 바이트 × 동시요청수
Llama 3 8B(32레이어, GQA 8헤드, 헤드차원 128) 기준 FP16 KV 캐시는 이렇게 늘어납니다.
- 8K 컨텍스트 1요청이면 약 1GB
- 32K 컨텍스트 1요청이면 약 4GB
- 32K에 동시 8요청이면 약 32GB로 가중치보다 커집니다
GQA(Grouped Query Attention)가 없는 구형 모델은 이 값이 4~8배 큽니다.
실전 계산
총 VRAM ≈ 가중치 + KV캐시 + 활성화/오버헤드(1~2GB) + 여유 10%
RTX 4090(24GB)에서 32B Q4를 돌리면 가중치 18GB에 오버헤드 1.5GB를 더해 19.5GB입니다. KV 캐시에 남는 건 약 4GB이니 컨텍스트 16K 정도가 현실적 상한입니다.
A100 80GB에서 70B Q4를 돌리면 가중치 40GB에 오버헤드 2GB로 42GB입니다. KV에 38GB가 남으니 긴 컨텍스트와 동시 요청 여러 개가 가능합니다.
실무 팁이 셋 있습니다.
- vLLM은 기본적으로
gpu_memory_utilization=0.9만큼 미리 잡고 남는 걸 전부 KV 풀로 씁니다. 이 값을 조절해서 튜닝합니다. - KV 캐시 양자화(
kv_cache_dtype="fp8")로 캐시를 절반으로 줄일 수 있습니다. 품질 영향은 거의 없습니다. - 학습과 파인튜닝은 완전히 다른 계산입니다. 옵티마이저 상태와 그래디언트 때문에 FP16 기준 파라미터당 16바이트 정도 필요해서, 8B 풀 파인튜닝에 128GB가 듭니다. LoRA를 쓰는 이유입니다.
OOM은 두 시점에 터진다
OOM은 Out Of Memory, 프로그램이 요청한 메모리를 시스템이 내줄 수 없을 때 나는 오류입니다. LLM 맥락에서는 거의 항상 GPU VRAM 부족을 말합니다.
torch.cuda.OutOfMemoryError: CUDA out of memory.
Tried to allocate 2.00 GiB. GPU 0 has a total capacity of 23.99 GiB
of which 1.20 GiB is free.
메시지에 얼마를 요청했는데 얼마가 남았는지가 찍히니 그 차이만큼 줄이면 됩니다. 터지는 시점은 두 갈래입니다. 로딩 중이면 가중치 자체가 안 들어간 것이라 더 낮은 양자화나 더 작은 모델로 가면 됩니다. 추론 중이면 로딩은 됐는데 컨텍스트가 길어지거나 동시 요청이 몰리면서 KV 캐시가 남은 공간을 다 먹은 것입니다. 두 번째가 더 골치 아픕니다. 테스트할 땐 잘 돌다가 실사용에서 터지니까요.
| 상황 | 대응 |
|---|---|
| 모델이 안 올라감 | 양자화 낮추기(FP16 → Q4) |
| 긴 컨텍스트에서 터짐 | max_model_len 제한, KV 캐시 FP8 |
| 동시 요청에서 터짐 | max_num_seqs로 배치 크기 제한 |
| 여유가 애매함 | vLLM gpu_memory_utilization 조정 |
CPU RAM OOM도 있습니다. 리눅스에서는 커널의 OOM Killer가 프로세스를 강제 종료시키고 로그에
Killed만 남아서 원인 파악이 더 어렵습니다. dmesg로 확인합니다. vLLM은 시작할 때 KV 풀을 미리
통째로 잡아두기 때문에 런타임 OOM이 덜 나고, 대신 시작 시점에 바로 실패합니다. 그게 더 낫습니다.
VRAM과 RAM: 대역폭이 10~30배 다르다
RAM은 메인보드에 꽂힌 메모리 모듈이고 CPU가 씁니다. VRAM은 그래픽카드 기판 위에 GPU 칩과 함께
붙어 있는 메모리이고 GPU가 씁니다. 별개의 칩이고 별개의 주소 공간이라, 서로 데이터를 복사해야만
주고받을 수 있습니다. model.to("cuda")가 하는 일이 정확히 그것입니다.
| 종류 | 대역폭 | 용량 | 확장 |
|---|---|---|---|
| DDR5 RAM | 50~100 GB/s | 저렴, 수백 GB 가능 | 슬롯에 추가 가능 |
| GDDR6X (RTX 4090) | 약 1,000 GB/s | 24GB 고정 | 불가 |
| HBM3 (H100) | 약 3,350 GB/s | 80GB | 불가 |
이 차이가 결정적인 이유는 토큰 하나 생성할 때마다 전체 가중치를 메모리에서 읽어와야 하기 때문입니다.
70B Q4 (40GB) 모델, 토큰 1개당:
VRAM 1,000GB/s → 40GB 읽는 데 0.04초 → 초당 25토큰
RAM 100GB/s → 40GB 읽는 데 0.4초 → 초당 2.5토큰
이론 상한이라 실제로는 더 낮지만 비율은 이대로 나옵니다. 그래서 VRAM에 다 넣으려고 합니다. 넘치면 llama.cpp 같은 런타임이 일부 레이어를 RAM으로 오프로드하는데, 한 레이어라도 RAM에 있으면 전체 속도가 그 레이어에 발목 잡힙니다. 90%를 VRAM에 올려도 나머지 10% 때문에 체감 속도가 확 떨어집니다. 딱 맞게 들어가느냐에 사람들이 집착하는 이유입니다.
예외도 있습니다. Apple Silicon은 유니파이드 메모리 구조라 CPU와 GPU가 같은 메모리를 공유합니다. M3 Max 기준 400GB/s 정도로 GDDR보다는 느리지만 DDR보다 훨씬 빠르고, 128GB 구성이면 70B도 통째로 올라갑니다. 개인 로컬 추론에서 맥이 강한 이유입니다. GH200 같은 최신 서버칩은 NVLink로 CPU와 GPU 메모리를 고속 연결해 경계를 흐리는 방향입니다.
CPU 추론이 항상 무의미하지는 않습니다. MoE 모델은 토큰당 활성 파라미터가 적어서 RAM에서도 쓸 만한 속도가 나올 때가 있습니다. 다만 밀집(dense) 모델은 대체로 답이 없습니다.
V는 Video이고, vCPU의 v와는 무관하다
VRAM의 V는 Video입니다. 원래 화면에 뿌릴 그래픽 데이터를 담는 메모리라서 붙은 이름이고, 지금은 AI 연산에도 쓰이지만 명칭만 남았습니다. GPU가 없으면 VRAM도 없습니다. 그래픽카드에 붙어 있는 메모리니까요.
다만 대부분의 컴퓨터에는 내장 GPU가 있고, 이건 전용 VRAM 없이 시스템 RAM을 일부 빌려 씁니다. 방식은 둘입니다. BIOS 고정 할당은 부팅 시 "GPU 몫 2GB" 식으로 미리 떼어두고 OS는 그만큼 없는 셈 칩니다. 동적 할당은 필요할 때 드라이버가 시스템 RAM에서 빌려 쓰는 방식이고 요즘 주류입니다. 어느 쪽이든 물리적으로는 같은 DDR 메모리라 대역폭도 그대로입니다. 내장 GPU로 LLM을 돌리면 느린 이유입니다.
vCPU는 virtual CPU로, 가상머신에 할당되는 가상 CPU 코어 단위입니다. 물리 코어 1개가 하이퍼스레딩으로 2개 스레드를 처리하면 보통 2 vCPU로 계산하니, 4 vCPU는 물리 2코어 정도입니다. 여기서 v는 virtual이고 VRAM의 v(video)와는 무관합니다.
오픈웨이트: 자선이 아니라 전략이다
업체들이 고성능 모델을 허깅페이스에 무료로 푸는 이유를 물었습니다. 답은 다섯 가지였습니다.
- 경쟁사 수익 파괴: Meta가 대표적입니다. OpenAI의 유료 API 시장을 무료 모델로 흔듭니다. Meta는 광고로 돈을 벌지 모델 판매로 벌지 않으니 손해가 없습니다.
- 표준 선점: 개발자들이 자기 모델 위에 도구와 파인튜닝과 생태계를 쌓으면 사실상 업계 기본값이 됩니다. 안드로이드 전략과 같습니다.
- 공짜 개선: 전 세계가 버그 찾고 양자화하고 최적화해줍니다. 자체 R&D 인력으로는 불가능한 규모입니다.
- 인재 확보: 연구자들은 논문 못 내고 모델 못 공개하는 곳을 기피합니다. 채용에 직결됩니다.
- 유료는 따로 남겨둠: 최상위 모델은 비공개이거나, 오픈해도 클라우드 API와 엔터프라이즈 지원으로 수익화합니다. 중국 업체들(DeepSeek, Qwen)은 인지도 확보 목적도 큽니다.
텍스트, 이미지, 비디오: 무엇을 예측하도록 훈련되는가
세 종류 생성 모델의 학습 차이는 결국 예측 대상의 차이입니다.
텍스트는 자기회귀입니다. 다음 토큰 하나를 맞히도록 학습하고, 순차 생성이라 한 번에 한 단어씩 나옵니다. 데이터는 인터넷 텍스트이고 정답 라벨이 필요 없습니다.
이미지는 확산입니다. 노이즈가 섞인 이미지에서 노이즈를 걷어내도록 학습합니다. 생성 시에는 완전한 노이즈에서 시작해 20~50단계에 걸쳐 그림으로 만듭니다. 데이터는 이미지와 캡션 쌍이 필요합니다. 텍스트를 조건으로 붙이기 위해서입니다. 텍스트와 달리 전체를 동시에 다듬습니다.
비디오는 확산에 시간축이 붙은 것입니다. 이미지와 같은 원리인데 프레임들을 한 덩어리로 노이즈 제거합니다. 추가 과제는 프레임 간 일관성입니다. 인물이 갑자기 변하면 안 됩니다. 비용이 압도적입니다. 5초 영상은 이미지 120장 분량을 서로 맞물리게 생성하는 일입니다.
| 학습 비용 | 데이터 확보 | 생성 속도 | |
|---|---|---|---|
| Text | 높음 | 쉬움 | 빠름 |
| Image | 중간 | 캡션 필요 | 중간 |
| Video | 극단적 | 가장 어려움 | 느림 |
최근에는 경계가 흐려지고 있습니다. 이미지에 자기회귀를 쓰거나 텍스트에 확산을 쓰는 시도가 나오는 중입니다.
캡션과 자기회귀
캡션은 이미지 설명 텍스트입니다. "해변에서 노을을 보며 앉아 있는 강아지" 같은 문장입니다. 필요한 이유는 "고양이 그려줘"를 알아들으려면 어떤 픽셀 뭉치가 고양이인지 배워야 하기 때문입니다. 이미지만 잔뜩 보여주면 그럴듯한 그림은 만들어도 프롬프트대로는 못 만듭니다. 그래서 이미지와 설명 쌍 수억 개로 학습합니다. 요즘은 사람이 다는 게 아니라 AI가 자동으로 캡션을 붙입니다.
자기회귀(autoregressive)는 자기가 만든 걸 다시 입력으로 넣는 방식입니다.
"오늘" → 날씨
"오늘 날씨" → 가
"오늘 날씨가" → 좋
한 개 생성하고 그걸 포함해서 다음 걸 생성하는 반복입니다. 답변이 왼쪽부터 주르륵 나오는 이유입니다. 앞을 되돌릴 수 없다는 게 특징이자 약점이고, 확산 모델이 전체를 계속 다듬는 것과 반대입니다.
확산: 망가뜨리는 법을 배워서 거꾸로 복원한다
학습 단계는 이렇습니다. 원본 이미지에 노이즈를 조금씩 섞어 완전한 지지직 화면까지 만듭니다. 그리고 모델에게 "이 노이즈 낀 이미지에서 어떤 노이즈가 섞였는지 맞혀봐"라고 시킵니다. 정답은 이미 알고 있습니다. 내가 섞은 것이니까요.
생성 단계는 순수 랜덤 노이즈에서 시작해, 모델이 걷어낼 노이즈를 예측하고 조금 걷어내는 과정을 20~50번 반복합니다. 흐릿한 형체에서 점점 선명한 그림이 됩니다.
프롬프트는 매 단계마다 조건으로 함께 들어갑니다. "고양이"라는 텍스트를 주면 모델이 고양이 방향으로 노이즈를 걷어냅니다. 같은 노이즈라도 프롬프트가 다르면 전혀 다른 그림이 나옵니다.
왜 잘 되는가. 한 번에 완성하려 하지 않고 50번 나눠 조금씩 고치기 때문입니다. 게다가 매 단계 전체 그림을 다시 보면서 수정하기 때문에, 텍스트 생성과 달리 앞부분을 나중에 고칠 수 있습니다.
노이즈와 학습이 무슨 상관인가
이 질문이 대화에서 가장 오래 걸렸습니다. 핵심은 이것입니다.
노이즈를 섞는 건 정답이 있는 문제를 공짜로 무한정 만들어내는 트릭이다.
AI 학습에는 항상 채점 가능한 문제가 필요합니다. 그런데 "고양이를 그려라"는 정답이 없어서 채점을 못 합니다. 그래서 이렇게 뒤집습니다.
고양이 사진 + 내가 만든 노이즈 = 문제
↑
이게 정답
내가 섞었으니 정답을 정확히 압니다. 모델이 예측한 노이즈와 실제 노이즈의 차이만큼 벌점을 주면 끝이고, 채점이 자동화됩니다. 같은 사진에 노이즈 양을 달리해서 계속 넣으면 문제도 무한히 만들어집니다.
그러면 사진을 학습하는 것인가 노이즈를 학습하는 것인가. 노이즈를 예측하는 훈련을 통해 사진을 학습합니다. 둘은 대립이 아니라 수단과 목적 관계입니다.
노이즈 자체는 무작위라 배울 게 없습니다. 매번 다르니까요. 그런데 노이즈 낀 이미지에서 노이즈만 골라내려면 어디까지가 노이즈고 어디부터가 진짜 사진인지 구분해야 하고, 그 구분을 하려면 진짜 사진이 어떻게 생겼는지 알아야 합니다.
지지직한 화면을 보고
"고양이 귀는 여기쯤 뾰족해야 하니까,
이 얼룩은 노이즈겠군"
모델 안에 쌓이는 건 노이즈에 대한 지식이 아니라 "자연스러운 이미지란 어떤 것인가"에 대한 지식입니다. 노이즈 예측은 그걸 끌어내기 위한 채점 가능한 핑계입니다.
그럼 원본과 노이즈 낀 원본을 같이 주는가. 아닙니다. 노이즈 낀 것만 줍니다. 원본은 채점할 때만 쓰고 모델은 못 봅니다.
1. 원본 준비 (모델에게 안 보여줌)
2. 노이즈 만들어 섞음
3. 모델에 입력: 노이즈 낀 이미지 + "몇 번째 단계인지" + 캡션
4. 모델 출력: "섞인 노이즈는 이거였을 것"
5. 채점: 실제 섞은 노이즈와 비교 → 벌점 → 가중치 수정
원본을 같이 주면 모델이 그냥 빼기만 하면 되니까 아무것도 못 배웁니다. 추가로 주는 정보는 둘입니다. 단계 번호(timestep)는 노이즈가 살짝 낀 건지 거의 다 지워진 건지 알아야 대응이 달라져서 필요하고, 캡션은 조건부 생성을 위해 필요합니다. 생성할 때는 원본이 아예 없으니 학습 때도 원본 없이 하는 게 맞습니다.
LLM 동작 원리: 다음 토큰 하나의 확률
한 줄로 요약하면 지금까지의 텍스트를 보고 다음 토큰 하나의 확률을 계산하고, 그걸 반복하는 것입니다.
"한국의 수도는"
↓ ① 토크나이즈
[15496, 8901, 442]
↓ ② 임베딩 조회 + 위치정보(RoPE)
벡터 3개
↓ ③ 트랜스포머 블록 × 수십~수백 회
↓ ④ 다음 토큰 확률
"서울"(72%) "부산"(3%) ...
↓ ⑤ 샘플링 → "서울" 선택
↓ 입력에 붙여서 ① 부터 반복
③번이 본체입니다. 블록마다 두 부분이 있습니다.
어텐션은 문맥을 파악합니다. 각 토큰이 참고할 다른 토큰을 찾아 정보를 끌어옵니다.
"그 배가 항구에 들어왔다"
↑
"항구"를 보고서야 배(ship)로 확정
FFN은 지식을 저장합니다. 어텐션이 모아온 정보를 가공합니다. 파라미터의 약 2/3가 여기 있고, "파리는 프랑스 수도" 같은 사실 지식이 이 안에 분산 저장돼 있다고 봅니다.
이 둘이 한 세트로 80번쯤 반복됩니다. 층이 깊어질수록 표면적 문법에서 의미로, 다시 추상적 추론으로 표현이 발전합니다.
⑤번 샘플링은 확률 1위를 항상 고르면 문장이 단조로워지기 때문에 있습니다. temperature를 높이면
확률 분포가 평평해져 다양해지고 0이면 항상 1위를 고릅니다. top-p는 누적 확률 90%까지의 후보
중에서만 선택합니다. 같은 질문에 답이 매번 다른 이유입니다.
중요한 특성이 셋 있습니다.
- KV 캐시: 반복할 때마다 전체를 다시 계산하면 낭비이므로 이전 토큰들의 K와 V를 저장해둡니다. 앞서 메모리 계산에서 나온 그 KV 캐시입니다.
- 되돌릴 수 없음: 한 번 뱉은 토큰은 수정 불가입니다. 확산 모델과 정반대입니다. 그래서 "단계별로 생각해보자"가 효과가 있습니다. 중간 과정을 텍스트로 뱉어두면 그게 다음 계산의 입력이 되니, 사실상 사고할 공간이 생깁니다.
- 라벨이 필요 없음: 학습은 이 예측을 수조 개 토큰에 대해 반복하며 가중치를 조금씩 고치는 것입니다. 정답은 원문의 다음 단어라서 라벨링이 필요 없습니다.
왜 이게 지능처럼 보이는가. 다음 단어 맞히기를 극한까지 잘하려면 문법, 사실, 논리, 코드 구조, 문체를 다 알아야 하기 때문입니다. 다만 확률 계산이라 그럴듯한 오답도 자신 있게 냅니다. 환각의 근본 원인이고, RAG로 외부 근거를 넣는 이유입니다.
어텐션과 트랜스포머: 누구를 볼지 정하는 계산
"그 배가 항구에 들어왔다"에서 "배"만 봐서는 ship인지 belly인지 pear인지 모릅니다. 주변을 봐야 합니다.
각 토큰이 벡터 3개를 만듭니다.
| 역할 | 비유 | |
|---|---|---|
| Query | 내가 찾는 것 | 검색어 |
| Key | 내가 가진 것 | 문서 제목 |
| Value | 내가 줄 정보 | 문서 내용 |
"배"의 Q · "항구"의 K → 유사도 높음 → "항구"의 V를 많이 가져옴
"배"의 Q · "그"의 K → 유사도 낮음 → 조금만
수식으로는 한 줄입니다.
softmax(QKᵀ / √d) · V
QKᵀ는 모든 토큰 쌍의 유사도 행렬이고, softmax는 합이 1인 가중치로 변환하고, · V는 그
가중치로 정보를 가중합합니다. 결과적으로 "배"의 벡터가 항구 정보를 흡수해 ship 쪽으로
이동합니다.
이걸 32~64개 병렬로 돌리는 게 멀티헤드입니다. 헤드마다 다른 관계를 봅니다. 어떤 헤드는
주어와 동사를, 어떤 헤드는 대명사의 지시 대상을 봅니다. 앞서 본
q_proj/k_proj/v_proj/o_proj 가중치가 이 계산용입니다.
RAG 벡터 검색과 헷갈리면 안 됩니다. 원리는 비슷하지만 어텐션은 모델 내부에서 매 레이어 일어나는 연산이고 저장되지 않습니다.
트랜스포머는 어텐션을 층층이 쌓은 구조다
2017년 Attention Is All You Need 논문에서 나온 아키텍처입니다. 한 블록의 구성은 이렇습니다.
입력
├→ LayerNorm → 어텐션 → (+입력) ← 문맥 수집
└→ LayerNorm → FFN → (+입력) ← 정보 가공
출력
(+입력)이 잔차 연결(residual)입니다. 원본을 계속 더해주는 건데, 이게 없으면 층이 깊어질 때
학습이 안 됩니다. 사실상 필수 부품입니다.
이 블록을 80번 쌓은 것이 70B 모델입니다. 각 층이 조금씩 표현을 다듬습니다.
하위층: 문법, 품사
중간층: 의미, 개체 관계
상위층: 추상적 추론
이전 주류였던 RNN은 단어를 하나씩 순서대로 처리했습니다.
| RNN | 트랜스포머 | |
|---|---|---|
| 학습 시 처리 | 순차라 GPU를 놀림 | 전체 병렬 |
| 먼 단어 참조 | 거리 멀수록 소실 | 거리 무관, 직접 연결 |
병렬 학습이 결정적이었습니다. GPU를 꽉 채워 쓸 수 있게 되면서 모델 크기를 수천 배 키우는 게 가능해졌고, 그게 지금의 LLM으로 이어졌습니다.
대가도 있습니다. 어텐션은 토큰 수의 제곱에 비례합니다. 컨텍스트가 10배면 계산은 100배입니다. 긴 컨텍스트가 비싼 근본 이유이고, GQA와 FlashAttention 같은 기법들이 이걸 완화하려는 시도입니다.
이름은 입력 표현을 층마다 변형(transform)시켜 나간다는 뜻입니다. 구글 연구팀이 붙였고, 자연어를 넘어 이미지(ViT)와 음성과 단백질까지 퍼져 지금은 사실상 범용 아키텍처가 됐습니다.
레이어: 트랜스포머 블록 한 개다
레이어는 [어텐션 + FFN] 한 세트입니다. model.layers.0, model.layers.1의 그 번호입니다.
80레이어면 이 블록을 80번 통과한다는 뜻입니다.
각 레이어가 벡터를 조금씩 다듬습니다. 한 번에 정답을 내는 게 아니라 80번에 걸쳐 점진적으로 정제합니다.
1층: "배" = 그냥 단어
20층: 주변 문맥 반영, 명사임을 파악
50층: ship으로 의미 확정
80층: 다음에 올 말을 준비
개수에 기술적 상한은 없다
다만 실질적 제약이 넷 있습니다.
- 메모리와 속도: 레이어 수에 비례해 파라미터와 계산량이 늘어납니다. 200레이어면 그만큼 느리고 큽니다.
- 학습 안정성: 깊을수록 학습이 불안정해집니다. 잔차 연결과 LayerNorm이 이 문제를 상당히 해결했지만 완전히는 아닙니다.
- 수익 체감: 레이어만 늘린다고 계속 좋아지지 않습니다. 폭(hidden size)과 깊이(레이어 수)의 균형이 중요합니다. 같은 파라미터 예산이면 깊고 좁은 모델과 얕고 넓은 모델 중 경험적으로 중간 어딘가가 최적입니다.
- 병렬화 불리: 레이어는 순차적이라 건너뛸 수 없습니다. 반면 폭은 GPU에서 병렬 처리가 잘 됩니다. 그래서 실무에서는 폭을 키우는 쪽이 선호됩니다.
| 모델 | 레이어 | hidden |
|---|---|---|
| 8B | 32 | 4096 |
| 32B | 64 | 5120 |
| 70B | 80 | 8192 |
| 405B | 126 | 16384 |
크기가 50배 늘어도 레이어는 4배 정도만 늘어납니다. 주로 폭을 키운다는 게 보입니다.
config.json의 num_hidden_layers가 이 값입니다.
레이어 정보는 모델 안에 두 군데 있습니다. config.json에 num_hidden_layers: 80처럼 명시적
숫자로 있고, 가중치 파일의 텐서 이름 자체에도 있습니다(model.layers.0.부터
model.layers.79.까지). 런타임은 config를 읽어 구조를 만든 뒤 이름을 맞춰 가중치를 채웁니다.
둘이 안 맞으면 로딩에 실패합니다.
벡터를 다듬는다는 말의 의미
"벡터는 이미 정해져 있지 않느냐"고 물었습니다. 정해져 있는 건 두 가지뿐입니다.
| 고정 여부 | |
|---|---|
| 임베딩 테이블(사전) | 고정. 학습 끝나면 안 변함 |
| 각 레이어의 가중치 | 고정 |
| 지금 흐르는 벡터(활성값) | 매 레이어 새로 계산 |
임베딩 조회는 원본을 꺼내는 게 아니라 복사본을 뜨는 것입니다. 그 복사본이 레이어를 지나며 변형되고, 사전 자체는 그대로 있습니다.
"그 배가 항구에"
1층 입력: 배 = [0.2, -1.4, 0.8, ...] ← 사전에서 꺼낸 값. 문맥 없음
↓ 어텐션: "항구"에서 정보 끌어옴
↓ FFN: 가공
1층 출력: 배 = [0.4, -1.1, 0.9, ...] ← 다른 벡터
↓
2층 출력: 배 = [0.5, -0.7, 1.2, ...] ← 또 다른 벡터
↓ ... 80층까지
같은 "배"라는 자리에 있지만 값이 계속 바뀝니다. 그래서 문맥이 다르면 도착점이 달라집니다. "배가 아프다"는 80층에서 belly 쪽 벡터가 되고 "배가 항구에"는 ship 쪽 벡터가 됩니다. 시작점은 똑같았는데 말이죠.
가능한 이유는 레이어의 가중치가 고정이어도 입력이 다르면 출력이 다르기 때문입니다.
y = Wx (W는 고정, x는 매번 다름)
W는 어떻게 변형할지에 대한 규칙이고, 실제 변형 결과는 들어온 x에 달려 있습니다. 사전에는
"배: 명사, 여러 뜻"이라고 적혀 있고 그건 고정이지만, 문장을 읽는 중의 상태는 계속 갱신됩니다.
레이어 통과가 그 갱신 과정이고, 응답이 끝나면 그 상태는 사라집니다.
두 개의 임베딩: 서로 만나지 않는다
여기가 이 대화의 핵심이었습니다.
질문을 던지면 첫 단계가 임베딩입니다.
"안녕" → 토큰 [12043, 8891] → 각 토큰을 벡터로 변환
↓
[0.23, -1.4, 0.87, ...] (수천 차원)
embed_tokens 레이어가 하는 일이고, 앞서 본 가중치 파일의 첫 텐서입니다. 절차 전체는
이렇습니다.
"안녕하세요"
↓ ① 토크나이즈
[15496, 8901] ← 사전(vocab)에서 찾은 ID
↓ ② 임베딩 테이블 조회
[[0.2, -1.4, ...], ← 토큰당 벡터 하나 (예: 4096차원)
[0.8, 0.3, ...]]
↓ ③ 위치 정보 결합 (RoPE)
↓ ④ 트랜스포머 레이어 통과 (수십~수백 번)
↓ ⑤ 다음 토큰 확률 계산
②번 임베딩 테이블은 그냥 룩업입니다. [vocab_size × hidden] 크기 행렬에서 ID 번째 행을 꺼내는
것이고, 계산이 아니라 조회입니다. ③번 위치 정보가 중요한 이유는 이 시점의 벡터가 "안녕"이라는
단어의 사전적 의미일 뿐 문맥이 없기 때문입니다. ④번에서 비로소 문맥이 생깁니다. 어텐션을 통해
각 토큰 벡터가 주변 토큰을 참조하며 갱신되고, "사과"가 과일인지 사죄인지가 이 과정에서
결정됩니다.
그런데 임베딩이라고 하면 보통 RAG에서 쓰는 문장 임베딩을 떠올립니다. 둘은 다릅니다.
| LLM 토큰 임베딩 | RAG 문장 임베딩 | |
|---|---|---|
| 단위 | 토큰 1개 = 벡터 1개 | 문서/청크 = 벡터 1개 |
| 목적 | 모델 내부 연산의 입력 | 유사도 검색 |
| 위치 | 모델 안, 중간 산물 | 모델 밖, 최종 결과물 |
| 저장 | 안 함 | 벡터DB에 저장 |
| 모델 | Claude, Llama 등 | bge-m3, text-embedding-3 등 별도 |
| 주체 | LLM 내부 레이어 | 내 애플리케이션 코드가 호출 |
| 크기 | LLM에 포함 | 보통 0.1~2B로 훨씬 작음 |
결정적 차이는 훈련 목표입니다. RAG 임베딩은 "의미가 비슷하면 벡터도 가깝게"를 목표로 훈련됩니다. 그래야 코사인 유사도로 검색이 됩니다. LLM 토큰 임베딩은 그런 제약이 없습니다. 다음 토큰만 잘 맞히면 되니 벡터 공간이 어떻게 생기든 상관없습니다.
주체도 다릅니다. 토큰 임베딩은 LLM 자신이 합니다. 별도 프로그램이 아니라 모델의 첫 번째 층이고, 외부에서 호출할 수도 없고 결과를 꺼내 쓸 수도 없습니다. 문서 임베딩은 bge-m3나 text-embedding-3-small 같은 독립 모델이 합니다. LLM과 완전히 다른 프로그램입니다. RAG를 구축할 때 임베딩 모델을 따로 준비하고 배포해야 하는 이유입니다. LLM이 대신 해주지 않습니다.
전체 경로: 메신저 질문 하나가 답이 되기까지
메신저 봇으로 질문해서 에이전트를 거쳐 사내 RAG를 검색해 답하는 과정을, 임베딩과 벡터가 어디서 생기고 사라지는지 기준으로 정리했습니다.
0단계, 사전 인덱싱. 질문 전에 이미 끝나 있습니다.
사내 문서 → 청킹(500~1000토큰 단위) → 임베딩 모델 → 벡터 저장
↓
[0.2, -1.4, ...] 1024차원 × 청크 수
여기가 문서 임베딩입니다. 벡터DB에는 벡터와 함께 원문 텍스트도 같이 저장됩니다. 나중에 이 원문을 꺼내 써야 하기 때문입니다.
1단계, 메신저에서 에이전트로. 사용자가 봇을 멘션하면 웹훅으로 평문 텍스트가 전달됩니다. 여기까지 벡터는 하나도 없습니다.
2단계, 에이전트에서 LLM으로(1차 호출). 시스템 프롬프트와 MCP 도구 목록과 질문을 묶어 LLM에 보냅니다. 여기서 토큰 임베딩이 1회차로 발생합니다. 프롬프트 전체가 토큰 ID로 쪼개지고 임베딩 테이블 조회를 거쳐 레이어를 통과합니다. 이 벡터들은 모델 내부에만 존재하고 응답이 끝나면 사라집니다.
LLM 출력은 답변이 아니라 도구 호출 텍스트입니다.
{"tool": "rag_search", "query": "연차 휴가 규정 사용 기준"}
여기서 LLM이 질문을 검색에 적합한 쿼리로 재작성합니다. 이게 검색 품질을 크게 좌우합니다.
3단계, RAG 검색. 에이전트가 MCP로 rag_search를 호출하면 RAG 서버가 질의 측 문서 임베딩을
만듭니다.
"연차 휴가 규정 사용 기준"
↓ 0단계와 동일한 임베딩 모델
질의 벡터 [0.31, -0.92, ...]
↓ 코사인 유사도 계산 (ANN 인덱스로 근사 탐색)
상위 5개 청크 선택
↓
반환값: 청크 원문 텍스트 (벡터 아님)
인덱싱 때와 질의 때 임베딩 모델이 반드시 같아야 합니다. 다르면 벡터 공간이 달라서 검색이 완전히 망가집니다.
4단계, 프롬프트 재구성.
[검색된 사내 문서]
청크1: 연차는 입사 1년 후 15일...
청크2: 미사용 연차는...
위 문서를 근거로 답하라.
질문: 휴가 규정 알려줘
검색 결과가 벡터로 LLM에 들어가지 않습니다. 다시 텍스트로 붙여서 넣습니다.
5단계, LLM 2차 호출과 답변. 토큰 임베딩이 2회차로 발생합니다. 이번엔 청크가 포함된 훨씬 긴 프롬프트가 토큰화되어 들어갑니다. RAG가 토큰 비용이 많이 드는 이유입니다. 자기회귀로 답변을 생성하고 에이전트가 메신저 API로 스레드에 게시합니다.
| 지점 | 임베딩 종류 | 벡터 어디에 |
|---|---|---|
| 0단계 인덱싱 | 문서 임베딩 | 벡터DB에 영구 저장 |
| 2단계 LLM | 토큰 임베딩 | 모델 내부, 즉시 소멸 |
| 3단계 질의 | 문서 임베딩 | 검색 후 버림 |
| 5단계 LLM | 토큰 임베딩 | 모델 내부, 즉시 소멸 |
가장 중요한 점은 두 임베딩이 서로 만나지 않는다는 것입니다. 차원 수도 다르고 벡터 공간도 다릅니다. 둘을 잇는 유일한 접착제는 텍스트입니다. RAG 벡터는 어떤 문서를 고를까를 정하는 데만 쓰이고, 고른 뒤에는 원문 텍스트만 LLM으로 넘어갑니다.
품질이 무너지는 지점
- 청킹: 문단이 중간에 잘리면 검색돼도 의미가 안 통합니다.
- 쿼리 재작성: 2단계 LLM이 대명사("그거", "아까 그 문서")를 못 풀면 검색에 실패합니다.
- top-k: 너무 적으면 근거가 누락되고, 너무 많으면 노이즈가 끼고 토큰이 폭증합니다.
- 모델 불일치: 인덱싱과 질의의 임베딩 모델이 다르면 조용히 실패합니다.
에이전트 프레임워크 없이 짜기
같은 파이프라인을 에이전트 프레임워크 없이 만들면 어떻게 되는지 물었습니다. 프레임워크가 하던 일은 셋뿐이었습니다. 메신저 웹훅 수신과 응답, LLM 호출과 도구 호출 루프, MCP 클라이언트로 RAG 연결. 전부 직접 짜면 200~300줄 수준입니다.
| 역할 | 선택 |
|---|---|
| 서버 | Next.js Route Handler 또는 Node/Express |
| 벡터DB | Supabase pgvector |
| 임베딩 | bge-m3(한국어 강함, 로컬) 또는 OpenAI |
| LLM | vLLM 셀프호스팅(데이터 반출 금지 요건) |
MCP는 빼도 됩니다. RAG 검색 하나뿐이면 함수를 직접 호출하는 게 훨씬 단순합니다. 도구가 여러 개로 늘고 다른 클라이언트와 공유할 때 MCP의 가치가 생깁니다.
인덱싱
create extension vector;
create table docs (
id bigserial primary key,
content text,
metadata jsonb,
embedding vector(1024)
);
create index on docs
using hnsw (embedding vector_cosine_ops);
// 문서 → 청킹 → 임베딩 → 저장
const chunks = splitText(doc, { size: 800, overlap: 100 });
for (const c of chunks) {
const emb = await embed(c); // bge-m3
await supabase.from('docs').insert({
content: c, embedding: emb, metadata: { source: doc.path }
});
}
검색 함수는 이렇습니다.
create function match_docs(q vector(1024), k int)
returns table(content text, similarity float)
language sql as $$
select content, 1 - (embedding <=> q)
from docs order by embedding <=> q limit k;
$$;
메신저 연결
Outgoing Webhook이 가장 간단합니다. 통합 설정에서 트리거 단어를 지정하고 서버 URL로 POST하게 합니다.
// POST /api/mattermost
export async function POST(req) {
const { text, token, channel_id } = await req.json();
if (token !== process.env.MM_TOKEN) return new Response('unauthorized', {status:401});
// 3초 안에 응답해야 하므로 즉시 ack 후 비동기 처리
handleAsync(text, channel_id);
return Response.json({ text: '확인 중...' });
}
응답은 봇 토큰으로 Posts API에 POST합니다.
await fetch(`${MM_URL}/api/v4/posts`, {
method: 'POST',
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
body: JSON.stringify({ channel_id, message: answer, root_id })
});
핵심 로직
async function handleAsync(question, channelId) {
// ① 쿼리 재작성 (에이전트의 2단계 역할)
const query = await llm(`검색 쿼리로 재작성. 쿼리만 출력:\n${question}`);
// ② 검색
const emb = await embed(query);
const { data: chunks } = await supabase.rpc('match_docs', { q: emb, k: 5 });
// ③ 프롬프트 조립
const context = chunks.map(c => c.content).join('\n---\n');
const answer = await llm(
`아래 사내 문서만 근거로 답하라. 없으면 모른다고 하라.\n\n${context}\n\n질문: ${question}`
);
// ④ 게시
await postToMattermost(channelId, answer);
}
앞서 정리한 0~5단계가 그대로 대응됩니다.
잃는 것과 남는 것
프레임워크를 뺐을 때 잃는 것은 도구 자동 선택(여기서는 항상 RAG만 탑니다), 세션 메모리(직접 구현해야 합니다), 스킬 시스템과 코드 실행입니다. 다만 RAG 챗봇 하나라면 다 불필요합니다. 에이전트 프레임워크는 도구가 5개 이상 되고 다단계 추론이 필요할 때부터 값을 합니다.
나중에 필요해지면 LangChain이나 LlamaIndex에서 청킹과 리트리버 유틸만 골라 쓰거나, Vercel AI SDK로 툴 콜링 루프를 표준화하거나(Next.js면 궁합이 좋습니다), RAG를 다른 클라이언트와 공유하게 될 때 MCP 서버로 빼면 됩니다.
봇 계정은 발신자다
메신저의 봇 계정이 무슨 용도인지도 물었습니다. 봇은 메시지를 보낼 발신자 계정입니다. API로 글을 쓰려면 누군가의 자격증명이 필요한데, 사람 계정 토큰을 쓸 수는 없으니 봇 계정을 따로 만드는 것입니다.
사용자 질문 → Outgoing Webhook → 내 서버 (수신: 봇 불필요)
내 서버 → Posts API → 채널에 게시 (발신: 봇 토큰 필요)
수신은 웹훅이, 발신은 봇이 담당합니다. 봇 계정의 이점은 이름과 아바타가 "AI 어시스턴트"로 표시되고, 사람 계정이 아니라 라이선스 사용자 수에 안 잡히고, 권한을 필요한 채널로만 제한할 수 있고, 토큰이 유출돼도 영향 범위가 작다는 것입니다. 설정은 시스템 콘솔의 Integrations에서 Bot Accounts로 생성하고 토큰을 발급한 뒤 채널에 초대하면 됩니다.
이름의 유래도 물었습니다. robot의 줄임말입니다. robot은 1920년 체코 희곡 R.U.R.에서 만들어진 단어이고 어원은 체코어 robota(강제 노동)입니다. bot은 1990년대 인터넷에서 축약형으로 정착했고, IRC 채팅방을 자동 관리하던 프로그램들이 시초입니다. 이후 웹 크롤러와 게임 자동사냥과 스팸봇으로 확산됐고, Slack이 2014년경 봇을 협업 도구 개념으로 대중화하면서 지금의 용법이 됐습니다. 메신저 맥락에서 봇은 사람처럼 채널에 참여하지만 사람이 아닌 계정을 뜻합니다. 물리적 로봇과는 무관하고, 원래 의미인 "자동으로 일하는 대리인"만 남았습니다.
마무리
대화 중간에 이 내용을 로컬 Claude Code 세션에서 이어가고 싶다고 했더니, 웹 세션과 Claude Code는
별개 저장소라 서로를 볼 수 없다는 답이 왔습니다. 핸드오프 문서를 파일로 받아 프로젝트에 넣고
읽히는 게 가장 확실합니다. 프로젝트 루트 CLAUDE.md에 병합하면 매 세션 자동으로 읽히지만 항상
컨텍스트를 먹으니, 그 주제를 계속 다룰 프로젝트에서만 권할 만합니다.
두 주에 걸친 질문을 정리하고 보니 반복해서 돌아오는 문장이 하나 있었습니다. 두 임베딩은 서로 만나지 않고, 둘을 잇는 것은 텍스트뿐이라는 것. 모델 파일이 숫자 덩어리라는 것도, RAG가 결국 프롬프트에 자료를 붙이는 일이라는 것도 같은 말의 다른 면이었습니다.