agents101 · 에이전트 엔지니어링

서문: LLM에서 Agent까지 — 완전한 엔지니어링 경로

대규모 언어 모델(Large Language Model, LLM)은 우리가 소프트웨어를 구축하는 방식을 바꾸어 놓았습니다. 그러나 단순히 API를 호출하여 텍스트 응답을 받는 것은 첫걸음에 불과합니다. 진정한 엔지니어링 과제는 모델이 “대화할 수 있는” 수준에서 “일을 할 수 있는” 수준으로 발전시키는 것입니다 — 외부 도구를 호출하고, 자율적으로 작업을 계획하며, 팀 내에서 협업하고, 사용자 선호도를 기억하고, 최종적으로 프로덕션 환경에서 안정적으로 실행되는 것입니다.

이 경로는 여덟 가지 핵심 영역을 포함합니다:

  1. LLM 기초: Tokenization, Transformer 아키텍처, 컨텍스트 윈도우와 프롬프트 엔지니어링의 이해
  2. RAG 원리와 실전: 검색 증강 생성(Retrieval-Augmented Generation)을 마스터하여 모델이 프라이빗 지식에 접근하도록 하기
  3. Agent 도구 호출: Function Calling, ReAct 루프, MCP 프로토콜
  4. Agent 계획과 실행: 성찰 메커니즘, Plan & Execute, 워크플로우 오케스트레이션
  5. 다중 Agent 협업: 계층적 협업, 블랙보드 패턴, 협업 방법론
  6. Memory와 Skill: 단기/장기 메모리 관리, Skill 시스템 설계
  7. Agent 평가: 엔드 투 엔드 평가, 화이트박스 평가, 평가 기반 반복
  8. 프로덕션 배포: 모델 배포, 추론 최적화, 보안 가드레일, Harness Engineering

본 가이드는 Agent 엔지니어링을 체계적으로 마스터하려는 개발자를 대상으로 하며, 기초 원리부터 프로덕션 실전까지 파노라마식 기술 참조를 제공합니다.


Tokenization 원리

왜 Tokenization이 필요한가

컴퓨터는 인간의 문자를 직접 이해할 수 없습니다. 대형 모델이 텍스트를 처리하는 첫 단계는 자연어를 기계가 연산 가능한 숫자 형식으로 변환하는 것입니다. 이 과정을 **Tokenization(토큰화)**이라고 합니다.

Token은 토크나이저(Tokenizer)가 텍스트를 인코딩하여 얻은 기본 단위로, 각 token은 어휘집(vocabulary)의 정수 ID에 대응합니다. 핵심 인사이트: token은 일반적으로 서브워드 조각 또는 문자 조각이며, 반드시 하나의 완전한 “단어”도 아니고 반드시 독립적인 의미를 가지는 것도 아닙니다.

입력 텍스트: "ACP is a very"
          ↓ Tokenizer
Token 시퀀스: [347, 1186, 374, 1134]

영어의 경우, 단어가 어간과 접미사로 분해될 수 있습니다. 중국어는 글자나 일반적인 어구 단위로 분할될 수 있으며, 공백과 구두점도 token으로 인코딩될 수 있습니다. 서로 다른 모델의 Tokenizer는 큰 차이가 있습니다 — GPT 계열은 BPE(Byte Pair Encoding)를, LLaMA는 SentencePiece BPE를 사용하며, 일부 중국어 모델은 중국어 말뭉치에 특화된 최적화를 적용합니다.

Token 벡터화와 위치 인코딩

정수 ID 자체의 숫자 크기는 의미론적 의미를 가지지 않습니다. ID 500이 ID 50보다 “더 중요하다”는 의미가 아닙니다. 따라서 이러한 이산적인 ID를 Embedding 행렬을 통해 밀집 벡터로 매핑해야 합니다.

Token ID → Embedding Matrix Lookup → d-dimensional vector
  1186   →   [0.023, -0.451, 0.789, ..., -0.312]  (d=4096 또는 그 이상)

동시에, 언어의 순서는 매우 중요합니다 — “내가 너를 돕는다”와 “네가 나를 돕는다”는 전혀 다릅니다. Transformer의 Self-Attention 메커니즘은 본질적으로 시퀀스 인식 능력이 없기 때문에, 추가적으로 **위치 인코딩(Positional Encoding)**을 제공해야 합니다. 현대 모델은 일반적으로 회전 위치 인코딩(RoPE, Rotary Position Embedding)을 사용하며, 이는 어텐션 계산에 상대적 위치 정보를 도입하여 모델이 자연스럽게 긴 시퀀스를 처리할 수 있도록 합니다.

디코딩 전략: 확률에서 출력으로

모델 추론 후 logits(점수 벡터)을 얻고, softmax를 거쳐 확률 분포 P(next_token | context)로 변환합니다. 그런 다음 디코딩 전략을 통해 출력 token을 선택합니다:

전략원리적용 시나리오
Greedy 디코딩매번 확률이 가장 높은 token 선택결정론적 출력이 필요한 작업 (코드 생성, 구조화된 추출 등)
Beam Search여러 후보 경로를 유지하고 전체 확률이 가장 높은 시퀀스 선택번역, 요약 등 전역 최적이 필요한 작업
Top-k Sampling확률이 가장 높은 k개 token에서 무작위 샘플링창의적 글쓰기, 대화 생성
Top-p(Nucleus)누적 확률이 p를 초과하는 최소 token 집합에서 샘플링범용 대화, 다양성과 품질의 균형

출력의 무작위성을 제어하는 두 가지 핵심 파라미터:

Temperature(온도): softmax 확률 분포의 “날카로움”을 제어합니다.

# temperature의 효과 비교
# 원본 logits → softmax(logits / temperature)
# T=0.1: 확률이 매우 집중되어 거의 결정론적 출력
# T=0.7: 적당함, 합리적인 다양성 유지
# T=1.5: 확률이 균일해지는 경향, 출력이 매우 무작위적

Top_p(핵 샘플링 임계값): 샘플링에 참여하는 후보 token 범위를 제어합니다. 예를 들어 top_p=0.9는 누적 확률이 90%에 도달하는 최소 token 집합에서만 샘플링함을 의미합니다.

# 일반적인 설정 예시
response = client.chat.completions.create(
    model="gpt-4",
    messages=[{"role": "user", "content": "인공지능에 관한 시를 써줘"}],
    temperature=0.8,   # 적당한 창의성
    top_p=0.9,         # 핵 샘플링
    max_tokens=200
)

자기회귀 생성과 중지 조건

모델은 **자기회귀 생성(Autoregressive Generation)**을 채택합니다: 매번 새로운 token을 생성하고, 이를 입력 끝에 추가한 다음, 새로운 시퀀스를 기반으로 다음 token을 계속 예측합니다.

"ACP is a very" → "informative" 예측
"ACP is a very informative" → "course" 예측
"ACP is a very informative course" → ... → <EOS> 예측 → 중지

중지 조건은 다음을 포함합니다:

  • 특수 종료 토큰(EOS token) 생성
  • 사전 설정된 max_tokens 제한 도달
  • 사용자가 지정한 중지 단어 시퀀스 생성

모델 시리즈별 종료 토큰:

모델종료 토큰
GPT 시리즈`<
LLaMA/Mistral</s>
DeepSeek<|end▁of▁sentence|>
일부 중국어 LLM<|im_end|>

스트리밍 출력(Streaming)은 본질적으로 서버 측에서 하나 또는 몇 개의 token이 생성될 때마다 즉시 디코딩하여 증분 전송하는 것이며, 모든 token 생성이 완료될 때까지 기다렸다가 반환하는 것이 아닙니다.


Transformer 아키텍처와 Attention 메커니즘

아키텍처 전경

현대 대규모 언어 모델의 핵심은 Transformer 아키텍처입니다. 완전한 Transformer는 인코더(Encoder)와 디코더(Decoder)를 포함하지만, 현재 주류인 자기회귀 LLM(GPT, LLaMA 등)은 디코더(Decoder-only) 아키텍처만 사용합니다.

Transformer 디코더 아키텍처

인과적 자기 어텐션(Causal Self-Attention)

Attention 메커니즘의 핵심 공식:

Attention(Q, K, V) = softmax(QK^T / √d_k) · V

여기서 Q(Query), K(Key), V(Value)는 모두 동일한 입력 시퀀스로부터 선형 변환을 통해 얻습니다. 핵심 설계:

(1) 스케일링 팩터 √d_k: 내적 결과가 너무 커져 softmax 기울기 소실을 방지합니다. 벡터 차원 d_k가 클 때 내적 값의 분산도 함께 증가하므로, √d_k로 나누어 분산을 정규화합니다.

(2) 인과 마스크(Causal Mask): 자기회귀 모델에서 각 token은 자신 “이전”의 token만 볼 수 있으며, 미래의 내용을 “미리 볼” 수 없습니다. 이는 상삼각 행렬 위치에 -∞를 채워 구현됩니다:

입력: ["ACP", "is", "a", "very"]
Attention 행렬 (인과 마스크 적용 후):
        ACP   is    a   very
ACP   0.8    -∞   -∞    -∞
is    0.3   0.7   -∞    -∞
a     0.2   0.3  0.5    -∞
very  0.1   0.2  0.3   0.4

(3) 멀티헤드 어텐션(Multi-Head Attention): 한 번만 어텐션 계산을 하는 것이 아니라, 여러 그룹의 Q/K/V 투영을 병렬로 수행하며, 각 그룹은 서로 다른 의미 관계(문법 구조, 참조 관계, 의미적 유사성 등)에 주목하고, 마지막에 모든 헤드의 출력을 연결합니다.

# 멀티헤드 어텐션의 의사 코드
def multi_head_attention(x, num_heads=8, d_model=512):
    d_head = d_model // num_heads  # 각 헤드의 차원
    outputs = []
    for h in range(num_heads):
        Q = linear_projection(x, d_head)
        K = linear_projection(x, d_head)
        V = linear_projection(x, d_head)
        attn_out = softmax(Q @ K.T / sqrt(d_head)) @ V
        outputs.append(attn_out)
    return concat(outputs)  # 모든 헤드 연결

피드포워드 네트워크(Feed-Forward Network)

각 Attention 레이어 뒤에는 FFN이 이어집니다:

FFN(x) = GELU(x·W₁ + b₁) · W₂ + b₂

FFN은 일반적으로 은닉 차원을 먼저 확장(예: 4배)한 다음 다시 원래 차원으로 압축합니다. 이 “확장-압축” 구조는 모델에 비선형 변환 능력을 제공하며, 모델이 지식을 저장하고 활용하는 핵심 구성 요소입니다.

잔차 연결과 레이어 정규화

각 서브레이어(Attention 및 FFN)는 잔차 연결을 통해 입력과 더해집니다:

output = LayerNorm(x + Sublayer(x))

잔차 연결은 기울기가 얕은 레이어까지 직접 전파될 수 있게 하여, 깊은 네트워크의 학습 어려움을 해결합니다. 현대 아키텍처의 레이어 정규화(LayerNorm)는 일반적으로 Pre-Norm 배치(서브레이어 이전에 정규화)를 채택하여, 원래의 Post-Norm보다 더 안정적입니다.


컨텍스트 윈도우와 Token 예산

컨텍스트 윈도우의 본질

대형 모델이 입력을 받는 곳을 **컨텍스트 윈도우(Context Window)**라고 합니다. 이를 컴퓨터의 메모리(RAM)에 비유할 수 있습니다 — 용량이 제한되어 있으며 성능에 직접적인 영향을 미칩니다.

컨텍스트 윈도우 구성

현대 모델의 컨텍스트 윈도우는 크게 확장되었습니다:

  • GPT-4 Turbo: 128K tokens
  • Claude 3: 200K tokens
  • Gemini 1.5 Pro: 1M+ tokens
  • 오픈소스 모델 (LLaMA 3, 일부 중국어 모델 등): 32K–128K tokens

그러나 윈도우가 크다고 남용해도 된다는 뜻은 아닙니다. 연구에 따르면 “Lost in the Middle” 효과가 존재합니다: 모델은 컨텍스트 중간 부분의 정보 처리 능력이 현저히 저하되며, 시작 부분(초두 효과)과 끝 부분(최신 효과)의 내용에 더 주목합니다.

Token 예산 관리

프로덕션 환경에서는 메모리를 관리하듯 컨텍스트를 관리해야 합니다. 다음은 핵심 전략입니다:

(1) Token 소비의 정확한 계산

import tiktoken

def count_tokens(text: str, model: str = "gpt-4") -> int:
    encoding = tiktoken.encoding_for_model(model)
    return len(encoding.encode(text))

# 예시: messages의 총 token 수 계산
def count_message_tokens(messages):
    encoding = tiktoken.encoding_for_model("gpt-4")
    total = 0
    for msg in messages:
        # 각 메시지마다 고정 오버헤드 (약 4 tokens)
        total += 4
        total += len(encoding.encode(msg["content"]))
    total += 2  # 응답 프라이밍
    return total

(2) 컨텍스트 윈도우 할당 전략

┌──────────────────────────────────────────────┐
│ Token 예산 할당 제안 (128K 윈도우 기준)          │
├──────────────────────────────────────────────┤
│ System Prompt:      2-5K  (역할 정의, 규칙)     │
│ RAG 검색 결과:       3-8K  (관련 지식 조각)      │
│ 대화 기록:           10-20K (최근 N 턴)          │
│ 현재 사용자 입력:     1-3K                       │
│ 응답 예약 공간:       4-8K                       │
│ 버퍼 여유:            나머지 (~80K)               │
└──────────────────────────────────────────────┘

(3) 컨텍스트 엔지니어링(Context Engineering)

컨텍스트 엔지니어링은 컨텍스트를 체계적으로 설계, 구축 및 최적화하는 실천입니다. 단순히 “prompt에 정보를 밀어 넣는 것”이 아니라 네 가지 핵심 기술을 포함합니다:

기술해결하는 문제핵심 방법
RAG프라이빗 도메인 지식 부족외부 지식 베이스에서 관련 정보 검색하여 컨텍스트에 주입
Prompt Engineering명령이 정확하지 않음정교하게 설계된 명령으로 모델 행동 유도
Tool Use모델이 작업을 실행할 수 없음모델에 외부 도구 호출 능력 부여
Memory세션 간 망각장단기 메모리 메커니즘 구축

많은 LLM 애플리케이션의 실패는 모델 자체가 충분히 지능적이지 않아서가 아니라 “컨텍스트”의 실패 때문입니다. 컨텍스트 엔지니어링이야말로 LLM의 잠재력을 발휘하는 열쇠입니다.


프롬프트 엔지니어링 방법론

System Prompt 설계

System Prompt는 모델의 “헌법”입니다 — 역할의 행동 경계, 답변 스타일 및 작업 제약을 정의합니다. 좋은 System Prompt는 다음을 포함해야 합니다:

# System Prompt 구조 템플릿
역할 정의: |
  당신은 숙련된 Python 기술 문서 심사자입니다,
  코드 정확성과 교육 효과성에 중점을 둡니다.

행동 지침:
  - 코드의 변수명, API 버전 번호를 수정하지 마십시오
  - 문제 발견 시 구체적인 위치와 수정 제안을 제시하십시오
  - 판단하기에 정보가 부족하면 명확히 "불확실"이라고 밝히십시오

출력 형식:
  ## 심사 보고서
  ### 주요 문제
  - **[N행]**: 문제 설명
    - 심각도: 심각|일반|경미
    - 수정 제안: 구체적 제안

제약 조건:
  - 농담이나 불필요한 상황 묘사 금지
  - 용어는 최초 등장 시 설명 필요
  - 코드 블록은 필수 import 문을 포함해야 함

Few-Shot과 구조화된 출력

Few-Shot 예시: 입력-출력 예시를 제공하여 모델이 형식과 스타일을 모방하게 합니다.

examples = [
    {
        "input": "Python 데코레이터가 무엇인지 설명해주세요",
        "output": "### 페인포인트 소개\n여러 함수에 동일한 로깅 로직을 추가하고 싶었던 적이 있나요?..."
    },
    {
        "input": "리스트 컴프리헨션이 무엇인지 설명해주세요",
        "output": "### 페인포인트 소개\n리스트에서 짝수만 필터링하기 위해 5줄의 for 루프를 작성한 적이 있나요?..."
    }
]

prompt = f"""
다음 예시의 스타일에 따라 사용자 질문에 답변해주세요:

{examples}

사용자 질문: {user_question}
"""

구조화된 출력: JSON Schema 또는 Pydantic 모델을 통해 출력 형식을 제약합니다.

from pydantic import BaseModel
from typing import List, Optional

class CodeReview(BaseModel):
    file_name: str
    issues: List[dict]
    overall_score: int  # 1-5
    requires_rewrite: bool

# prompt에 Schema 첨부
prompt = f"""
다음 JSON Schema에 따라 심사 결과를 출력해주세요:

{CodeReview.model_json_schema()}

심사 대상 코드:
{code}
"""

Chain-of-Thought(사고 연쇄)

여러 단계의 추론이 필요한 복잡한 작업에서는, 모델이 “사고 과정을 말하도록” 유도하면 정확도가 크게 향상됩니다.

# ❌ 직접 요청 (정확도 낮음)
prompt_simple = "계산: 한 학급에 30명의 학생이 있고, 남학생이 여학생보다 4명 많다면, 남학생은 몇 명인가요?"

# ✅ CoT 프롬프트 (정확도 높음)
prompt_cot = """
계산: 한 학급에 30명의 학생이 있고, 남학생이 여학생보다 4명 많다면, 남학생은 몇 명인가요?

단계별로 추론해주세요:
단계 1: 여학생 수를 x라 하면, 남학생 수는 x + 4
단계 2: 총 인원은 x + (x + 4) = 30
단계 3: 방정식 2x + 4 = 30을 풀면, x = 13
단계 4: 남학생 수 = x + 4 = 17
답: 17명
"""

CoT의 변형으로는:

  • ToT(Tree of Thoughts): 여러 추론 경로를 동시에 탐색하여 최적 선택
  • GoT(Graph of Thoughts): 추론을 유향 그래프로 표현하여 더 복잡한 추론 토폴로지 지원
  • Self-Consistency: CoT 경로를 여러 번 샘플링하여 다수결 결과 채택

Meta Prompting: 모델이 자신의 프롬프트를 최적화하게 하기

한 번에 완벽한 프롬프트를 작성하는 것은 거의 불가능합니다. Meta Prompting의 핵심 아이디어는: LLM이 “프롬프트 심사 전문가” 역할을 하여, 프롬프트 자체를 분석하고 최적화하도록 하는 것입니다.

meta_prompt = """
당신은 프롬프트 엔지니어링 전문가입니다. 다음 프롬프트의 결함을 분석하고 최적화된 버전을 생성해주세요.

현재 프롬프트:
{current_prompt}

이 프롬프트의 출력:
{current_output}

기대하는 출력:
{desired_output}

격차를 분석하고 최적화된 프롬프트를 출력해주세요.
"""

# 이 루프는 자동화 가능: 생성 → 평가 → 최적화 → 재생성

완전한 Meta Prompting 프로세스는 “참조 답안”과 정량적 채점도 도입할 수 있습니다:

  1. 참조 답안 설정: 이상적인 출력 정의
  2. 격차 분석: “평가자” 모델이 생성 결과와 참조 답안 비교
  3. 프롬프트 최적화: 격차 분석 보고서 기반으로 프롬프트 재작성
  4. 정량적 검증: 채점자(Grader)로 여러 버전 점수 매기기

Embedding과 벡터 검색

Embedding 모델의 작동 원리

Embedding 모델은 텍스트를 고차원 벡터로 변환하여, 의미적으로 유사한 텍스트가 벡터 공간에서 가까운 거리에 위치하게 합니다.

"나는 사과 먹는 것을 좋아한다"   →  [0.12, -0.34, 0.56, ..., 0.78]  (1024차원)
"나는 사과 먹는 것을 사랑한다"   →  [0.11, -0.33, 0.55, ..., 0.79]  ← 거리가 매우 가까움
"자동차 수리 가이드"             →  [-0.78, 0.45, -0.23, ..., 0.01] ← 거리가 매우 멈

Embedding 모델의 학습은 일반적으로 대조 학습(Contrastive Learning) 단계를 포함합니다: 입력은 관련성/비관련성이 레이블된 많은 텍스트 쌍이며, 학습 목표는 관련 텍스트의 벡터 유사도를 최대화하고 비관련 텍스트의 유사도를 최소화하는 것입니다.

# 두 텍스트 벡터의 코사인 유사도 계산
import numpy as np

def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

# 예시
query_vec = embedding_model.encode("연차는 어떻게 신청하나요?")
doc_vec = embedding_model.encode("직원 연차 신청 절차")

similarity = cosine_similarity(query_vec, doc_vec)
print(f"유사도: {similarity:.4f}")  # 0.92 — 높은 관련성

벡터 데이터베이스 선정

벡터 데이터베이스는 RAG 시스템의 핵심 인프라입니다. 선택 시 다음을 고려해야 합니다:

방식대표 제품장점단점적용 시나리오
인메모리 저장LlamaIndex 내장설정 제로, 빠른 프로토타입데이터 비영속적, 메모리 제한개발 테스트
로컬 벡터 DBMilvus, Qdrant, Chroma기능 완전, 데이터 통제 가능자체 배포 및 유지보수 필요중소규모 애플리케이션
관리형 서비스Pinecone, Weaviate Cloud운영 불필요, 자동 확장비용 높음, 데이터 외부 저장프로덕션 환경, 탄력적 수요
기존 DB 확장PostgreSQL + pgvector, Elasticsearch기존 인프라 활용벡터 성능은 전용 DB보다 낮음해당 DB를 이미 사용 중인 팀
# Chroma 사용 예시 (경량 로컬 벡터 DB)
import chromadb
from chromadb.utils import embedding_functions

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.create_collection(
    name="company_docs",
    embedding_function=embedding_functions.OpenAIEmbeddingFunction(
        api_key="your-api-key",
        model_name="text-embedding-3-small"
    )
)

# 문서 추가
collection.add(
    documents=["직원 연차 신청 절차...", "출장 정산 기준..."],
    metadatas=[{"source": "hr_policy.pdf"}, {"source": "finance_policy.pdf"}],
    ids=["doc_1", "doc_2"]
)

# 검색
results = collection.query(
    query_texts=["연차는 어떻게 신청하나요?"],
    n_results=3
)

문서 청크 분할 전략

분할의 기본 모순

RAG 시스템의 검색 효과는 문서 청크 분할 품질에 크게 의존합니다. 분할의 핵심 모순은:

청크가 너무 큼 → 검색 시 너무 많은 노이즈 도입, 모델 주의력 분산
청크가 너무 작음 → 핵심 정보가 잘릴 수 있음, 컨텍스트 손실

“만능” 최적 분할 전략은 존재하지 않습니다. 문서 유형, 검색 시나리오, 모델 능력에 따라 선택해야 합니다.

다섯 가지 주요 분할 방법

Token 분할

고정 token 수로 분할하며, token 소비를 정밀하게 제어해야 하는 시나리오에 적합합니다.

from llama_index.core.node_parser import TokenTextSplitter

splitter = TokenTextSplitter(
    chunk_size=256,     # 각 청크의 token 수
    chunk_overlap=30    # 인접 청크 간 중복 token 수
)

nodes = splitter.get_nodes_from_documents(documents)

장점: 컨텍스트 크기를 정밀하게 제어, 작은 컨텍스트 윈도우 모델에 적합. 단점: 문장 중간에서 잘릴 수 있어 의미적 완전성을 해칠 수 있음.

문장 분할

문장의 완전성을 유지하는 분할 방식으로, 대부분의 시나리오에서 기본 선택입니다.

from llama_index.core.node_parser import SentenceSplitter

splitter = SentenceSplitter(
    chunk_size=512,
    chunk_overlap=50
)

장점: 자연어 의미 단위의 완전성 유지. 단점: 문서 구조에 대한 인식 부족, 관련 단락을 다른 청크로 분할할 수 있음.

문장 윈도우 청크

인덱싱과 검색에 서로 다른 세분화 사용: 인덱싱은 작은 세분화로 정밀 매칭, 검색 반환 시 인접 컨텍스트 윈도우 포함.

from llama_index.core.node_parser import SentenceWindowNodeParser

parser = SentenceWindowNodeParser(
    window_size=3,          # 검색 시 3개의 인접 문장 확장
    window_metadata_key="window",
    original_text_metadata_key="original"
)

핵심 장점: 검색 정밀도와 컨텍스트 완전성을 모두 고려.

의미적 청크

의미적 관련성에 따라 적응적으로 분할 지점을 선택하여 문서의 의미적 연속성 유지.

from llama_index.core.node_parser import SemanticSplitterNodeParser

splitter = SemanticSplitterNodeParser(
    buffer_size=1,
    breakpoint_percentile_threshold=95,  # 유사도가 이 임계값 이하일 때 분할
    embed_model=embed_model
)

적용 시나리오: 논리성이 좋고 내용이 전문적인 긴 문서.

Markdown 청크

Markdown 구조화 문서에 특화된 최적화, 제목 계층에 따라 분할.

from llama_index.core.node_parser import MarkdownNodeParser

parser = MarkdownNodeParser()
# #, ##, ### 등의 제목 계층을 자동 인식하여 각 제목 단락에서 분할

모범 사례: 문서를 PDF/Word에서 Markdown으로 변환한 후 분할하여, 제목 구조를 활용해 검색 정확도 향상.

분할 전략 선택 가이드

문서 유형추천 전략이유
기술 매뉴얼 (구조 명확)Markdown 청크제목 계층을 활용하여 구조 유지
법률 계약 (논리 엄밀)의미적 청크조항의 의미적 완전성 유지
대화 기록문장 윈도우 청크전후 문맥 이해 필요
코드 문서Token 분할 + 의미적 청크길이 정밀 제어 필요
뉴스/블로그문장 분할단락 간 연관성 약함

검색 증강 생성 파이프라인

RAG의 2단계 아키텍처

RAG(Retrieval-Augmented Generation)는 LLM의 “지식 부족”을 해결하는 핵심 아키텍처입니다. 프로세스를 두 단계로 나눕니다:

1단계: 인덱스 구축

검색 증강 생성 파이프라인

  1. 문서 파싱: PDF, Word, Markdown 등의 형식을 순수 텍스트로 파싱
  2. 텍스트 청크: 선택한 전략에 따라 문서를 단락으로 분할
  3. 벡터화: Embedding 모델로 각 청크를 벡터로 변환
  4. 인덱스 저장: 벡터를 벡터 데이터베이스에 저장하고 인덱스 구축

2단계: 검색과 생성

┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐
│ 사용자 질문 │ → │ 벡터 검색  │ → │ Prompt 조립 │ → │ 모델 생성  │
│ Query     │    │ Retrieve  │    │ Augment   │    │ Generate  │
└──────────┘    └──────────┘    └──────────┘    └──────────┘
  1. 사용자 질문: 사용자 질문 수신
  2. 벡터 검색: 질문을 벡터화하여 벡터 DB에서 가장 유사한 청크 검색
  3. Prompt 조립: 검색된 지식 조각 + 원본 질문 + 지시사항을 완전한 Prompt로 조립
  4. 모델 생성: LLM이 증강된 컨텍스트를 기반으로 답변 생성

완전한 RAG 파이프라인 예시

from openai import OpenAI
import numpy as np

client = OpenAI()

class SimpleRAG:
    def __init__(self, embed_model="text-embedding-3-small"):
        self.embed_model = embed_model
        self.documents = []      # 문서 텍스트 저장
        self.embeddings = []     # 문서 벡터 저장

    def add_documents(self, docs: list[str]):
        """인덱스 구축: 문서를 벡터화하여 저장"""
        for doc in docs:
            vec = self._embed(doc)
            self.documents.append(doc)
            self.embeddings.append(vec)

    def _embed(self, text: str) -> np.ndarray:
        resp = client.embeddings.create(
            model=self.embed_model,
            input=text
        )
        return np.array(resp.data[0].embedding)

    def retrieve(self, query: str, top_k: int = 3) -> list[str]:
        """가장 관련성 높은 문서 조각 검색"""
        query_vec = self._embed(query)
        similarities = [
            np.dot(query_vec, doc_vec) /
            (np.linalg.norm(query_vec) * np.linalg.norm(doc_vec))
            for doc_vec in self.embeddings
        ]
        top_indices = np.argsort(similarities)[-top_k:][::-1]
        return [self.documents[i] for i in top_indices]

    def query(self, question: str) -> str:
        """완전한 RAG 쿼리"""
        contexts = self.retrieve(question)
        prompt = f"""다음 참조 정보에 따라 질문에 답변해주세요:

참조 정보:
{' '.join(contexts)}

질문: {question}

참조 정보가 질문에 답변하기에 부족하면 명확히 밝혀주세요."""

        resp = client.chat.completions.create(
            model="gpt-4",
            messages=[{"role": "user", "content": prompt}]
        )
        return resp.choices[0].message.content

RAG 멀티턴 대화에서의 질문 재작성

RAG 시나리오에서 멀티턴 대화를 구현하는 것은 독특한 도전 과제입니다. 사용자가 두 번째 턴에 “그의 상사는 누구인가요?”라고 묻는다면, 이 문장만으로 검색하면 완전히 실패할 것입니다 — 시스템은 “그”가 누구를 가리키는지 모릅니다.

해결책: 질문 재작성(Query Rewriting)

def rewrite_query(conversation_history: list, current_query: str) -> str:
    """LLM을 사용하여 컨텍스트 의존적 질문을 독립적 질문으로 재작성"""
    rewrite_prompt = f"""
    대화 기록에 따라 현재 질문을 컨텍스트에 의존하지 않는 독립적 질문으로 재작성해주세요.

    대화 기록:
    {format_history(conversation_history)}

    현재 질문: {current_query}

    재작성된 질문:"""

    resp = client.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": rewrite_prompt}],
        temperature=0.1
    )
    return resp.choices[0].message.content

# 예시
# 기록: 사용자 "장삼의 자리는 어디인가요?" 어시스턴트 "A동 5층입니다"
# 현재: "그의 상사는 누구인가요?"
# 재작성 후: "장삼의 상사는 누구인가요?"

고급 RAG 패턴

HyDE(Hypothetical Document Embeddings)

HyDE의 핵심 아이디어: 먼저 모델이 가상의 답변을 “만들어내게” 한 다음, 그 가상 답변으로 검색하고 원래 질문은 사용하지 않는 것입니다. 이 접근법의 직관은 — 가상 답변이 질문보다 의미적으로 실제 문서에 더 가깝다는 것입니다.

def hyde_retrieve(query: str, top_k: int = 3) -> list[str]:
    """HyDE 방법을 사용한 검색"""
    # Step 1: 가상 답변 생성
    hyde_prompt = f"""
    Question: {query}
    Please write a passage that answers this question.
    Passage:"""

    hyde_resp = client.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": hyde_prompt}]
    )
    hypothetical_doc = hyde_resp.choices[0].message.content

    # Step 2: 가상 답변으로 원래 질문 대신 검색
    query_vec = embed(hypothetical_doc)
    results = vector_db.search(query_vec, top_k=top_k)
    return results

고급 RAG

핵심 원칙:

  • 알고리즘을 최적화하기 전에 먼저 부족한 지식을 보충하십시오
  • 재현율을 개선하기 전에 먼저 문서 품질을 향상시키십시오
  • 지속적으로 사용자 의도를 수집하여 “데이터 수집-지식 업데이트-전문가 검증” 피드백 루프 형성

Function Calling 프로토콜

AutoGen Studio — 멀티 에이전트 워크플로우 빌더 UI AutoGen Studio — Microsoft의 노코드 멀티 에이전트 워크플로우 빌더 — microsoft/autogen 제공

DSPy — 선언적 LLM 프로그래밍 DSPy — LLM을(프롬프트가 아닌)선언적으로 프로그래밍;Hermes 자가 진화 파이프라인이 사용 — stanfordnlp/dspy 제공

Function Calling이란

Function Calling(함수 호출, Tool Calling이라고도 함)은 LLM API가 제공하는 표준 기능입니다. 모델이 필요할 때 순수 텍스트 응답 대신 구조화된 도구 호출 명령을 출력할 수 있도록 합니다.

워크플로우는 다음과 같습니다:

Function Calling 프로토콜

JSON Schema 도구 정의

# 도구 목록 정의
tools = [
    {
        "type": "function",
        "function": {
            "name": "search_knowledge_base",
            "description": "회사 내부 지식 베이스를 검색하여 정책 문서와 운영 가이드를 가져옵니다",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "검색 키워드 또는 질문"
                    },
                    "category": {
                        "type": "string",
                        "enum": ["hr", "it", "finance", "general"],
                        "description": "지식 카테고리"
                    }
                },
                "required": ["query"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "send_email",
            "description": "이메일 전송",
            "parameters": {
                "type": "object",
                "properties": {
                    "to": {
                        "type": "string",
                        "description": "수신자 이메일"
                    },
                    "subject": {
                        "type": "string",
                        "description": "이메일 제목"
                    },
                    "body": {
                        "type": "string",
                        "description": "이메일 본문"
                    }
                },
                "required": ["to", "subject", "body"]
            }
        }
    }
]

완전한 Function Calling 루프

from openai import OpenAI
import json

client = OpenAI()

def execute_function_call(tool_call):
    """도구 호출을 실행하고 결과 반환"""
    func_name = tool_call.function.name
    args = json.loads(tool_call.function.arguments)

    if func_name == "search_knowledge_base":
        # 실제 검색 로직
        result = knowledge_base.search(args["query"])
        return json.dumps(result)
    elif func_name == "send_email":
        # 실제 메일 전송 로직
        result = email_service.send(
            to=args["to"],
            subject=args["subject"],
            body=args["body"]
        )
        return json.dumps({"status": "sent" if result else "failed"})
    else:
        return json.dumps({"error": f"Unknown function: {func_name}"})

def chat_with_tools(user_message: str, messages: list = None):
    """Function Calling을 지원하는 대화"""
    if messages is None:
        messages = [
            {"role": "system", "content": "당신은 기업 어시스턴트로, 지식 베이스 검색과 이메일 전송이 가능합니다."}
        ]

    messages.append({"role": "user", "content": user_message})

    # 첫 번째 호출: 모델이 도구 사용 여부 결정
    response = client.chat.completions.create(
        model="gpt-4",
        messages=messages,
        tools=tools
    )

    assistant_msg = response.choices[0].message

    # 모델이 도구를 호출하려는 경우
    if assistant_msg.tool_calls:
        messages.append(assistant_msg)

        for tool_call in assistant_msg.tool_calls:
            # 도구 실행
            result = execute_function_call(tool_call)
            # 결과를 모델에 반환
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result
            })

        # 두 번째 호출: 모델이 도구 결과를 기반으로 최종 응답 생성
        final_response = client.chat.completions.create(
            model="gpt-4",
            messages=messages
        )
        return final_response.choices[0].message.content

    # 모델이 직접 응답한 경우
    return assistant_msg.content

도구 정의 모범 사례

  1. 설명은 정확하게: 모델은 description에 따라 도구 호출 시점을 판단하므로, 설명이 모호하면 오호출로 이어집니다
  2. 파라미터에 제약 적용: enum, required, 타입 제약을 사용하여 모델의 파라미터 오류 감소
  3. 함수 책임은 단일하게: 하나의 함수에 여러 작업을 포함하지 마십시오
  4. 구조화된 결과 반환: 도구 반환값은 모델이 이해하기 쉽도록 JSON 사용 권장
# ❌ 좋지 않은 도구 설명
{"name": "do_stuff", "description": "작업 실행", "parameters": {...}}

# ✅ 정밀한 도구 설명
{
    "name": "cancel_meeting",
    "description": "지정된 회의를 취소합니다. 회의 ID와 취소 사유가 필요합니다",
    "parameters": {
        "properties": {
            "meeting_id": {"type": "string", "description": "회의의 고유 식별자"},
            "reason": {"type": "string", "description": "취소 사유, 모든 참석자에게 통지됩니다"}
        },
        "required": ["meeting_id"]
    }
}

ReAct 추론-행동 루프

ReAct의 핵심 아이디어

ReAct(Reasoning + Acting)는 모델이 **추론(Thought)**과 **행동(Action)**을 번갈아 수행하는 패턴입니다. 한 번에 최종 답변을 생성하는 것이 아니라 “사고→행동→관찰→사고…”의 순환을 통해 문제를 해결합니다.

ReAct 추론-행동 루프

전형적인 ReAct 실행 과정:

사용자: "장삼의 부서를 확인하고 그의 상사에게 이메일을 보내주세요"

Thought 1: 먼저 장삼의 부서 정보를 찾아야 합니다
Action 1: search_knowledge_base(query="장삼 부서")
Observation 1: "장삼은 교연부 소속, 상사는 리시([email protected])"

Thought 2: 정보를 얻었으니, 이제 리시에게 이메일을 작성해야 합니다
Action 2: send_email(to="[email protected]", subject="장삼 관련",
                      body="...")
Observation 2: {"status": "sent"}

Thought 3: 작업 완료
Final Answer: "장삼이 교연부 소속임을 확인했으며, 그의 상사 리시에게 이메일을 발송했습니다."

수동 ReAct Agent 구현

class ReActAgent:
    def __init__(self, tools: dict, max_iterations: int = 10):
        self.tools = tools
        self.max_iterations = max_iterations

    def run(self, task: str) -> str:
        messages = [
            {"role": "system", "content": self._build_system_prompt()},
            {"role": "user", "content": task}
        ]

        for i in range(self.max_iterations):
            response = client.chat.completions.create(
                model="gpt-4",
                messages=messages,
                tools=self._format_tools()
            )

            msg = response.choices[0].message

            if msg.content and not msg.tool_calls:
                # 모델이 최종 답변을 제시함
                return msg.content

            if msg.tool_calls:
                # 어시스턴트의 도구 호출을 기록에 추가
                messages.append(msg)

                for tc in msg.tool_calls:
                    tool_name = tc.function.name
                    args = json.loads(tc.function.arguments)

                    # 도구 실행
                    result = self.tools[tool_name](**args)

                    # 관찰 결과를 기록에 추가
                    messages.append({
                        "role": "tool",
                        "tool_call_id": tc.id,
                        "content": json.dumps(result)
                    })
                    print(f"  [Tool: {tool_name}({args}) → {result}]")

        return "ReAct 루프가 최대 반복 횟수에 도달했습니다"

    def _build_system_prompt(self) -> str:
        return """당신은 도구를 사용할 수 있는 지능형 어시스턴트입니다.
ReAct 패턴을 따릅니다: 먼저 사고하고, 행동하고, 결과를 관찰한 후, 다음 단계를 결정합니다.
작업이 완료되면 최종 답변을 직접 제시합니다."""

    def _format_tools(self) -> list:
        return [
            {
                "type": "function",
                "function": {
                    "name": name,
                    "description": func.__doc__ or "",
                    "parameters": get_schema(func)
                }
            }
            for name, func in self.tools.items()
        ]

ReAct의 장점과 한계

장점:

  • 관찰 가능: 각 단계의 사고와 행동을 추적할 수 있음
  • 오류 수정 가능: 잘못된 결과를 관찰한 후 전략 조정 가능
  • 조합 가능: 여러 도구를 자동으로 솔루션으로 조합

한계:

  • 루프 횟수 예측 불가 (무한 루프 가능성)
  • 여러 번의 API 호출로 지연 시간과 비용 증가
  • 도구가 반환하는 관찰 품질에 의존

MCP 프로토콜과 도구 생태계

MCP 프로토콜과 도구 생태계

왜 MCP가 필요한가

Function Calling에는 근본적인 문제가 있습니다: 도구 정의와 소비가 결합되어 있다는 점입니다. 모든 Agent 개발자는 자신의 코드에 도구의 JSON Schema를 하드코딩해야 합니다. 도구 API가 업그레이드되면, 해당 도구를 통합한 모든 Agent가 수동으로 업데이트해야 합니다.

전통적 Function Calling 패턴:
  Agent A ──하드코딩 Schema──→ web_search v1
  Agent B ──하드코딩 Schema──→ web_search v1  ← 중복 정의
  Agent C ──하드코딩 Schema──→ web_search v1  ← 중복 정의

MCP 패턴:
  Agent A ──┐
  Agent B ──┼── MCP Client ──→ MCP Server (web_search)
  Agent C ──┘                    ↑
                          도구 제공자가 Schema 정의

**MCP(Model Context Protocol)**의 핵심 아이디어는 “도구를 제공하는 자가 도구를 정의한다”입니다. 도구 정의의 책임을 Agent(소비자 측)에서 도구 서비스(제공자 측)로 이전합니다.

MCP의 아키텍처 역할

역할책임비유
MCP Server도구 선언 (이름, 설명, 파라미터), 도구 로직 실행USB 장치
MCP ClientMCP Server에 연결, 도구 정의 가져오기, 호출 요청 전송USB 호스트 컨트롤러
AgentMCP Client를 사용하여 도구 목록 획득, 호출 결정애플리케이션

MCP Server 및 Client 구축

MCP Server 예시:

from mcp.server import Server, stdio_server
from mcp.types import Tool, TextContent

app = Server("web-search")

@app.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="web_search",
            description="인터넷에서 최신 정보 검색",
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "검색 키워드"},
                    "num_results": {"type": "integer", "default": 5}
                },
                "required": ["query"]
            }
        )
    ]

@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    if name == "web_search":
        results = search_engine.search(
            arguments["query"],
            num=arguments.get("num_results", 5)
        )
        return [TextContent(type="text", text=json.dumps(results))]
    raise ValueError(f"Unknown tool: {name}")

# stdio로 Server 시작
async def main():
    async with stdio_server() as streams:
        await app.run(streams[0], streams[1], app.create_initialization_options())

if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

MCP Client 통합 예시:

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def run_with_mcp_tools(user_query: str):
    server_params = StdioServerParameters(
        command="python",
        args=["web_search_server.py"]
    )

    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # MCP Server의 도구 정의 가져오기
            tools_result = await session.list_tools()
            tools = tools_result.tools

            # OpenAI 형식의 tools 파라미터로 변환
            openai_tools = [
                {
                    "type": "function",
                    "function": {
                        "name": tool.name,
                        "description": tool.description,
                        "parameters": tool.inputSchema
                    }
                }
                for tool in tools
            ]

            # 표준 Function Calling 프로세스
            response = client.chat.completions.create(
                model="gpt-4",
                messages=[{"role": "user", "content": user_query}],
                tools=openai_tools
            )

            # 모델이 도구 호출을 원하면 MCP Client를 통해 실행
            if response.choices[0].message.tool_calls:
                for tc in response.choices[0].message.tool_calls:
                    result = await session.call_tool(
                        tc.function.name,
                        json.loads(tc.function.arguments)
                    )
                    # ... 결과를 모델에 반환

MCP의 엔지니어링 가치

  • 디커플링: 도구 정의와 서비스 구현이 분리되어 각자 독립적 반복 가능
  • 동적 발견: Agent 시작 시 자동으로 최신 도구 목록 가져오기, 유지보수 비용 제로
  • 생태계 효과: 서드파티가 표준화된 MCP Server를 제공할 수 있으며, Agent 개발자는 MCP Client만 통합하면 됨
  • 다중 전송 프로토콜: stdio(로컬 프로세스 통신) 및 HTTP/SSE(원격 통신) 지원

성찰과 자기 교정

왜 성찰이 필요한가

LLM이 생성한 내용이 항상 사용 가능한 것은 아닙니다. 다음과 같은 문제가 발생할 수 있습니다:

  • 코드의 변수명을 “조용히 수정”하여 실행 오류 발생
  • 잘못된 “사실”에 기반하여 추론을 계속하여 연쇄 오류 발생
  • 긴 출력에서 앞부분의 제약 조건을 잊어버림

성찰(Reflection)의 핵심 아이디어는: 모델이 자신이 이미 생성한 완전한 내용을 검토하고 평가할 기회를 주어, 오류를 발견하고 수정하게 하는 것입니다.

자기 피드백의 두 가지 패턴

패턴 1: 단일 단계 명령식 성찰

한 번의 호출로, Prompt를 통해 모델이 답변 생성과 동시에 성찰을 수행하도록 지시합니다:

prompt_with_reflection = """
## 작업
1. 다음 강좌의 언어 표현을 다듬고, 다듬은 전체 내용을 출력하세요.
2. 다듬은 내용을 성찰하세요:
   - 작성 규범을 준수하는가
   - 언어 표현 외에 다른 내용이 의도치 않게 수정되었는가
   성찰 결과와 수정 제안을 출력하세요.
3. 제안에 따라 강좌를 수정하고, 수정된 전체 내용을 출력하세요.

## 강좌 초안
{original_content}
"""

장점: 구현이 간단하고 한 번의 호출로 완료. 단점: 모델이 동일한 사고 편향으로 자기 검증하기 쉬워 “자기 정당화”에 빠질 수 있음.

패턴 2: 2단계 “생성-피드백”

생성과 심사를 두 번의 독립적 호출로 분리합니다:

def generate_and_review(content: str) -> str:
    # 1단계: 생성
    draft_resp = client.chat.completions.create(
        model="gpt-4",
        messages=[{
            "role": "system",
            "content": "당신은 강좌 작가입니다. 다음 내용을 더 매력적으로 다듬으세요."
        }, {
            "role": "user",
            "content": content
        }]
    )
    draft = draft_resp.choices[0].message.content

    # 2단계: 심사 (다른 system prompt 사용!)
    review_resp = client.chat.completions.create(
        model="gpt-4",
        messages=[{
            "role": "system",
            "content": """당신은 엄격한 기술 심사관입니다.
[원본 내용]과 [다듬은 내용]을 비교하세요:
- 문장 표현만 수정되고 코드 등 기술 내용이 완전히 동일 → "통과"라고 답변
- 기술 내용이 수정됨 → "불통과"라고 답변하고 구체적 위치 지적"""
        }, {
            "role": "user",
            "content": f"원본 내용:\n{content}\n\n다듬은 내용:\n{draft}"
        }]
    )

    # 3단계: 불통과 시 심사 결과를 작성 Agent에게 피드백하여 수정
    review = review_resp.choices[0].message.content
    if "불통과" in review:
        # ... 피드백을 작성 Agent에게 전달하여 수정
        pass

    return draft

핵심 장점: 심사 Agent의 관점이 작성 Agent와 달라 역할 편향을 회피합니다. 나아가 여러 전문 심사 Agent — 사실 심사, 논리 심사, 스타일 심사, 보안 심사 — 를 설정할 수도 있습니다.

외부 피드백

자기 피드백에는 본질적 한계가 있습니다: 모델은 내용이 실제 환경에서 올바른지 검증할 수 없습니다. 외부 피드백의 아이디어는 생성 결과를 실제 환경에서 실행하여 객관적 사실로 검증하는 것입니다.

def generate_code_and_validate(spec: str) -> str:
    # 1단계: 코드 생성
    code_resp = client.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": f"다음 요구사항에 따라 Python 코드를 작성하세요:\n{spec}"}]
    )
    code = extract_code(code_resp.choices[0].message.content)

    # 2단계: 외부 실행 검증
    import subprocess, tempfile
    with tempfile.NamedTemporaryFile(suffix=".py", mode="w") as f:
        f.write(code)
        f.flush()
        result = subprocess.run(
            ["python", f.name],
            capture_output=True,
            text=True,
            timeout=30
        )

    # 3단계: 오류를 모델에 피드백하여 수정
    if result.returncode != 0:
        fix_prompt = f"""다음 코드 실행 중 오류 발생:

코드:
{code}

오류 정보:
{result.stderr}

코드를 수정하고 완전한 수정 버전을 출력하세요."""
        fix_resp = client.chat.completions.create(
            model="gpt-4",
            messages=[{"role": "user", "content": fix_prompt}]
        )
        return fix_resp.choices[0].message.content

    return code

외부 피드백의 적용 시나리오:

  • 코드 실행 검증: 코드 인터프리터로 코드를 실행하여 런타임 오류 캡처
  • JSON Schema 검증: Pydantic 등의 라이브러리로 구조화된 출력 검증
  • 수치 계산 검증: 계산기 도구로 수학 결과 검증
  • 시각화 렌더링 검증: 차트 생성 후 모델이 렌더링 결과를 “보고” 시각적 검사

Plan & Execute 패턴

왜 명시적 계획이 필요한가

Agent가 복잡한 작업을 직접 실행할 때 흔히 발생하는 문제:

  • 망각: 뒤쪽 단계를 처리할 때 앞쪽의 제약 조건을 “잊어버림”
  • 연쇄 오류: 초기 오류가 이후 추론의 기반이 되어 오류가 기하급수적으로 증폭
  • 구조 누락: 모델이 선형 처리를 선호하여 작업의 병렬/의존 관계를 인식하지 못함

Plan & Execute 패턴의 핵심 아이디어: 먼저 계획하고 실행하라 — 먼저 완전한 행동 계획을 수립하고, 검토 확인 후 단계적으로 실행하는 것입니다.

Plan Mode 구현

from typing import List
from pydantic import BaseModel

class PlanStep(BaseModel):
    step_id: int
    description: str
    dependencies: List[int] = []  # 의존하는 단계 ID
    tool: str = ""               # 사용할 도구
    expected_output: str = ""    # 예상 산출물

class ExecutionPlan(BaseModel):
    goal: str
    steps: List[PlanStep]

def plan_and_execute(task: str) -> str:
    # Phase 1: 계획 수립
    plan_prompt = f"""
    당신은 프로젝트 계획 전문가입니다. 다음 작업에 대한 상세 실행 계획을 수립하세요.

    요구사항:
    1. 작업을 구체적 단계로 분해
    2. 단계 간 의존 관계 표시
    3. 각 단계의 예상 산출물 설명

    작업: {task}

    JSON 형식으로 계획을 출력하세요."""

    plan_resp = client.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": plan_prompt}],
        response_format={"type": "json_object"}
    )
    plan = ExecutionPlan.model_validate_json(
        plan_resp.choices[0].message.content
    )

    # Phase 2: 의존 관계에 따라 실행
    results = {}
    executed = set()

    while len(executed) < len(plan.steps):
        for step in plan.steps:
            if step.step_id in executed:
                continue
            # 의존성이 모두 실행되었는지 확인
            if all(dep in executed for dep in step.dependencies):
                # 단계 실행
                result = execute_step(step, results)
                results[step.step_id] = result
                executed.add(step.step_id)

    # Phase 3: 결과 취합
    return summarize_results(plan, results)

고정 워크플로우: Pipeline 패턴

작업의 단계가 확정적이고 반복 가능할 때는 파이프라인으로 고정해야 합니다:

Input → Step 1 → Step 2 → Step 3 → ... → Output
class Pipeline:
    """고정 파이프라인: 각 단계의 출력이 다음 단계의 입력"""

    def __init__(self):
        self.steps = []

    def add_step(self, name: str, func):
        self.steps.append({"name": name, "func": func})

    def run(self, input_data):
        result = input_data
        for step in self.steps:
            print(f"  [실행] {step['name']}")
            result = step["func"](result)
        return result

# 예시: 문서 처리 파이프라인
pipeline = Pipeline()
pipeline.add_step("PDF 파싱", parse_pdf_to_text)
pipeline.add_step("텍스트 정제", clean_text)
pipeline.add_step("분할", split_sections)
pipeline.add_step("벡터화", vectorize_chunks)
pipeline.add_step("인덱스 저장", store_to_vectordb)

pipeline.run("document.pdf")

워크플로우 오케스트레이션 패턴

다섯 가지 핵심 워크플로우 패턴

복잡한 작업은 Agent 노드를 특정 토폴로지로 구성해야 합니다. 다음은 다섯 가지 핵심 패턴입니다:

분기 선택(Branching/Router)

진입 노드에서 작업 유형을 판단하여 서로 다른 처리 경로로 분배합니다.

워크플로 오케스트레이션 패턴

def router_agent(user_input: str):
    """의도에 따라 다른 처리 파이프라인으로 분배"""
    classify_prompt = f"""
    다음 사용자 요청의 유형을 분석하고 한 단어만 응답하세요:
    - code_review: 코드 검사/검증
    - style_review: 언어 교정/최적화
    - fact_check: 사실/개념 정확성 검증

    요청: {user_input}
    유형:"""

    intent = client.chat.completions.create(
        model="gpt-4o-mini",  # 경량 모델로 비용 절감
        messages=[{"role": "user", "content": classify_prompt}],
        temperature=0
    ).choices[0].message.content.strip()

    pipelines = {
        "code_review": code_review_pipeline,
        "style_review": style_review_pipeline,
        "fact_check": fact_check_pipeline
    }
    return pipelines.get(intent, default_pipeline)(user_input)

병렬 실행(Parallel)

서로 의존하지 않는 하위 작업을 동시에 분배하고 마지막에 취합합니다.

              ┌→ 코드 검사 ──┐
사용자 입력 → Split ─┼→ 사실 심사 ──┼→ Merge → 종합 보고서
              └→ 스타일 심사 ─┘
import asyncio

async def parallel_review(notebook_content: str):
    """강좌 콘텐츠에 대해 세 가지 심사 병렬 수행"""
    tasks = [
        asyncio.create_task(check_code(notebook_content)),
        asyncio.create_task(check_facts(notebook_content)),
        asyncio.create_task(check_style(notebook_content))
    ]

    code_result, fact_result, style_result = await asyncio.gather(*tasks)

    # 취합
    return generate_summary_report(code_result, fact_result, style_result)

혼합 전문가(Mixture-of-Agents, MoA)

여러 다른 모델이 동일한 작업을 처리하고, 집계자가 최적의 결과를 종합합니다.

             ┌→ 모델 A (추론 특화) ──┐
사용자 질문 → Split ─┼→ 모델 B (창의 특화) ──┼→ Aggregator → 최적 답변
             └→ 모델 C (정확 특화) ─┘

MoA의 핵심 발견은 모델의 “협업성”(Collaborativeness)입니다: 한 모델이 다른 모델의 출력을 참조할 수 있을 때, 종종 더 높은 품질의 응답을 생성할 수 있습니다.

def mixture_of_agents(task: str):
    """MOA 구현: 다중 모델 + 집계"""
    # 1계층: 제안자 병렬 생성
    proposers = ["gpt-4", "claude-3-opus", "gemini-pro"]
    proposals = []

    for model in proposers:
        resp = client.chat.completions.create(
            model=model,
            messages=[{"role": "user", "content": task}]
        )
        proposals.append(resp.choices[0].message.content)

    # 2계층: 집계자 종합
    aggregator_prompt = f"""
    다음은 동일한 질문에 대한 {len(proposals)}개의 답변입니다. 각 답변의 장점을 종합하여
    최적의 답변을 생성하세요.

    {format_proposals(proposals)}

    종합 답변:"""

    final = client.chat.completions.create(
        model="gpt-4",  # 가장 강력한 모델로 집계
        messages=[{"role": "user", "content": aggregator_prompt}]
    )
    return final.choices[0].message.content

휴먼-인-더-루프(Human-in-the-Loop, HITL)

중요 지점에서 인간 검토를 도입하여 “AI 실행 → 인간 승인 → AI 계속” 순환을 형성합니다.

def hitl_workflow(task: str):
    """휴먼-인-더-루프 워크플로우"""
    plan = generate_plan(task)
    print(f"실행 계획:\n{format_plan(plan)}")

    approval = input("이 계획을 승인하시겠습니까? (y/n): ")
    if approval.lower() != 'y':
        return "작업이 취소되었습니다"

    for step in plan.steps:
        result = execute_step(step)
        print(f"단계 {step.step_id} 완료: {result['summary']}")

        if step.get("requires_review"):
            review = input(f"단계 결과를 검토하세요 (approve/modify/reject): ")
            if review == "reject":
                print("단계가 거부되었습니다. 재실행 중...")
                result = execute_step(step, feedback=review)

    return generate_final_output()

패턴 선택 방법론

패턴적용 시나리오부적합 시나리오
Pipeline프로세스 고정, 단계 선형적동적 의사결정이 필요한 작업
Branching여러 유형의 입력이 서로 다른 처리 필요여러 측면을 동시에 처리해야 함
Parallel하위 작업이 서로 독립적, 효율 추구의존 체인이 있는 작업
MoA고품질 요구, 창의적 작업비용 민감한 일반 작업
HITL고위험 의사결정, 컴플라이언스 요구저지연 요구의 실시간 시스템
Plan & Execute프로세스 가변적, 탐색이 필요한 신규 작업고도로 반복적인 확정적 작업

모범 사례는 “탐색-고정” 혼합 모드입니다: 먼저 Plan & Execute로 최적 방안을 탐색한 후, Pipeline으로 고정하여 대규모 생산에 사용합니다.


계층적 협업 패턴

Leader-Worker 아키텍처

계층적 협업(Hierarchical/Team Leader Pattern)은 가장 직관적인 다중 Agent 협업 패턴입니다. “프로젝트 매니저 + 팀원”의 조직 구조를 모방합니다:

계층적 협업 패턴

Leader Agent의 책임:

  1. 최상위 작업 수신 및 이해
  2. 하위 작업으로 분해하여 적합한 Worker에게 할당
  3. 전체 진행 상황 추적
  4. Worker 성과 취합

Worker Agent는 각자 특정 분야의 전문성을 보유하며, 할당된 하위 작업 실행에 집중합니다.

Handoff를 통한 계층적 협업 구현

class LeaderWorkerSystem:
    """계층적 협업 시스템 구현"""

    def __init__(self):
        self.workers = {
            "instructional_designer": self._create_worker(
                "당신은 교육 설계자로, 강좌 개요와 학습 경로 설계를 전문으로 합니다."
            ),
            "data_scientist": self._create_worker(
                "당신은 데이터 과학자로, Python 데이터 분석 코드와 사례 작성을 전문으로 합니다."
            ),
            "content_writer": self._create_worker(
                "당신은 콘텐츠 작성자로, 기술 내용을 생생한 강좌 원고로 변환하는 것을 전문으로 합니다."
            )
        }
        self.leader = self._create_leader()

    def _create_leader(self):
        return {
            "system_prompt": """당신은 강좌 프로젝트 책임자입니다. 당신의 책임은:
1. 요구사항 분석, 하위 작업으로 분해
2. 적합한 전문가에게 작업 할당
3. 각 전문가의 성과를 완전한 강좌로 통합
사용 가능한 전문가 팀: instructional_designer, data_scientist, content_writer""",
            "tools": [
                {
                    "type": "function",
                    "function": {
                        "name": "delegate_to_worker",
                        "description": "지정된 전문가에게 하위 작업 할당",
                        "parameters": {
                            "type": "object",
                            "properties": {
                                "worker": {
                                    "type": "string",
                                    "enum": list(self.workers.keys()),
                                    "description": "작업을 받을 전문가"
                                },
                                "task": {
                                    "type": "string",
                                    "description": "구체적인 하위 작업 설명"
                                }
                            },
                            "required": ["worker", "task"]
                        }
                    }
                }
            ]
        }

    def run(self, project_brief: str) -> str:
        """완전한 강좌 개발 프로젝트 실행"""
        messages = [
            {"role": "system", "content": self.leader["system_prompt"]},
            {"role": "user", "content": project_brief}
        ]

        # Leader 루프
        while True:
            response = client.chat.completions.create(
                model="gpt-4",
                messages=messages,
                tools=self.leader["tools"]
            )
            msg = response.choices[0].message

            if msg.content and not msg.tool_calls:
                return msg.content  # 최종 취합 출력

            if msg.tool_calls:
                messages.append(msg)
                for tc in msg.tool_calls:
                    if tc.function.name == "delegate_to_worker":
                        args = json.loads(tc.function.arguments)
                        # Worker를 호출하여 하위 작업 실행
                        worker_result = self._run_worker(
                            args["worker"], args["task"]
                        )
                        messages.append({
                            "role": "tool",
                            "tool_call_id": tc.id,
                            "content": worker_result
                        })

    def _run_worker(self, worker_name: str, task: str) -> str:
        worker = self.workers[worker_name]
        resp = client.chat.completions.create(
            model="gpt-4",
            messages=[
                {"role": "system", "content": worker},
                {"role": "user", "content": task}
            ]
        )
        return resp.choices[0].message.content

계층적 협업의 장단점

장점:

  • 구조가 명확하고 각 Agent의 책임이 분명
  • Leader가 전체를 통제하므로 목표 이탈 없음
  • 각 Worker가 독립적인 컨텍스트 윈도우를 보유하여 더 집중
  • 병렬 작업 할당 지원

단점:

  • Worker 간 직접 소통 불가, 정보 전달에 지연/왜곡 발생
  • Leader가 단일 병목 지점
  • 각 모듈 조합 후 전체적 유려함 부족 가능성

블랙보드 협업 패턴

탈중앙화된 공동 창작

블랙보드 패턴(Blackboard/Co-creation Pattern)은 “전문가들이 화이트보드 앞에 둘러앉아 브레인스토밍하는” 작업 방식을 모방합니다. 중앙화된 조정자가 없으며, 모든 Agent가 평등하게 공유 공간을 읽고 씁니다:

블랙보드 협업 패턴

블랙보드 패턴 구현

class BlackboardSystem:
    """블랙보드 협업 시스템"""

    def __init__(self, agents: dict, max_rounds: int = 3):
        self.agents = agents
        self.max_rounds = max_rounds
        self.blackboard = []  # 공유 공간

    def run(self, problem: str) -> str:
        # 문제를 블랙보드에 작성
        self.blackboard.append({"source": "user", "content": problem})

        for round_num in range(self.max_rounds):
            print(f"\n=== {round_num + 1} 라운드 ===")
            new_contributions = []

            # 모든 Agent가 병렬로 블랙보드를 읽고 기여
            for name, agent_config in self.agents.items():
                contribution = self._agent_contribute(
                    name, agent_config, self.blackboard
                )
                if contribution:
                    new_contributions.append({
                        "source": name,
                        "content": contribution
                    })

            # 새로운 기여를 블랙보드에 추가
            self.blackboard.extend(new_contributions)

            # 합의 도달 확인
            if self._check_consensus():
                break

        return self._synthesize_final_answer()

    def _agent_contribute(self, name, config, blackboard):
        """각 Agent가 블랙보드를 읽고 자신의 아이디어 기여"""
        board_text = self._format_blackboard(blackboard)

        prompt = f"""당신은 {config['role']}입니다.

현재 공유 블랙보드의 내용:
{board_text}

기존 논의를 바탕으로 당신의 견해, 보충, 질문 또는 새로운 아이디어를 제시하세요.
이미 제안된 방안이 완벽하다면 동의하고 그 이유를 설명하셔도 됩니다."""

        resp = client.chat.completions.create(
            model="gpt-4",
            messages=[
                {"role": "system", "content": config["system_prompt"]},
                {"role": "user", "content": prompt}
            ]
        )
        return resp.choices[0].message.content

    def _format_blackboard(self, blackboard):
        return "\n\n".join([
            f"[{entry['source']}]: {entry['content']}"
            for entry in blackboard
        ])

    def _check_consensus(self):
        """블랙보드상의 최신 기여가 합의를 형성했는지 확인"""
        # 합의 감지 로직 구현
        pass

    def _synthesize_final_answer(self):
        """블랙보드 내용에서 최종 방안 합성"""
        pass

블랙보드 패턴 vs 계층적 패턴

차원계층적 패턴블랙보드 패턴
통제 방식중앙화 (Leader 통제)탈중앙화 (평등 참여)
통신 방식스타형 (Leader↔Worker)완전 연결 (모든 Agent↔블랙보드)
의사결정 메커니즘Leader 판정창발적 합의
적용 작업목표 명확, 분해 가능개방형 탐색, 집단 지성 필요
효율성높음 (병렬 + 통제 가능)낮음 (여러 라운드 논의)
창의성제한적 (Leader 시각에 제한)높음 (아이디어 충돌로 새로운 발상)
비용중간높음 (모든 Agent가 매 라운드 참여)

협업 패턴 선택 방법론

현실 세계에서 배우기

훌륭한 다중 Agent 시스템 설계는 현실 세계 팀 협업에 대한 관찰과 정제에서 비롯됩니다. 추상적인 패턴 이름을 암기하기보다, 비즈니스 현장에 들어가 인간 전문가 팀이 유사한 작업을 어떻게 완수하는지 관찰하십시오.

관찰의 세 가지 차원:

협업 패턴 선택 방법론

혼합 패턴 설계

실제 프로젝트에서 단일 패턴만 사용하는 경우는 드뭅니다. 일반적인 것은 혼합 설계입니다:

                    ┌─────────────┐
                    │   Leader    │  ← 계층적 패턴
                    └──────┬──────┘
           ┌───────────────┼───────────────┐
           ▼               ▼               ▼
    ┌──────────┐    ┌──────────┐    ┌──────────┐
    │ 교육 설계자 │    │ 콘텐츠 작성자 │    │ 심사 Leader│
    │  Worker   │    │  Worker   │    └─────┬────┘
    └──────────┘    └──────────┘           │
                                  ┌────────┼────────┐
                                  ▼        ▼        ▼
                             ┌──────┐ ┌──────┐ ┌──────┐
                             │코드 검│ │사실 검│ │스타일│  ← 병렬 패턴
                             └──────┘ └──────┘ └──────┘
                                 │        │        │
                                 └────────┼────────┘

                                    ┌──────────┐
                                    │ 종합 보고서 │
                                    └──────────┘

비용 의식

다중 Agent 시스템의 Token 소비는 일반적으로 단일 Agent의 3-5배입니다. 설계 시 다음을 고려해야 합니다:

def estimate_cost(num_agents: int, avg_tokens_per_agent: int,
                  rounds: int = 1, price_per_1k: float = 0.01):
    """다중 Agent 시스템의 Token 비용 추정"""
    total_tokens = num_agents * avg_tokens_per_agent * rounds
    return total_tokens * price_per_1k / 1000

# 예시: 5개 Agent, 각 2000 tokens, 3 라운드 블랙보드 논의
cost = estimate_cost(5, 2000, 3)
print(f"추정 비용: ${cost:.2f}")
# 실제 수치는 블랙보드 내용의 반복 전송으로 인해 더 높을 수 있음

단기 메모리 관리

무상태성: 문제의 근원

LLM은 본질적으로 **무상태(Stateless)**입니다. 각 API 호출은 독립적입니다 — 이전 대화 내용, 당신의 선호도, 또는 이전에 도달한 합의를 기억하지 않습니다.

# 이 두 호출은 서로 완전히 독립적
response1 = client.chat.completions.create(
    model="gpt-4",
    messages=[{"role": "user", "content": "제 이름은 장삼입니다"}]
)
# 응답: "안녕하세요 장삼님! 무엇을 도와드릴까요?"

response2 = client.chat.completions.create(
    model="gpt-4",
    messages=[{"role": "user", "content": "제 이름이 뭐죠?"}]
)
# 응답: "죄송합니다, 이전 대화가 없어서 당신의 이름을 알 수 없습니다."

해결책: 대화 기록 목록을 유지하고, 호출할 때마다 전체 기록을 전송합니다.

class ConversationBuffer:
    """가장 간단한 단기 메모리: 완전한 대화 기록 저장"""

    def __init__(self, system_prompt: str = ""):
        self.messages = []
        if system_prompt:
            self.messages.append({"role": "system", "content": system_prompt})

    def chat(self, user_input: str) -> str:
        self.messages.append({"role": "user", "content": user_input})
        response = client.chat.completions.create(
            model="gpt-4",
            messages=self.messages
        )
        reply = response.choices[0].message.content
        self.messages.append({"role": "assistant", "content": reply})
        return reply

컨텍스트 윈도우 압박

대화 턴이 증가함에 따라 전체 기록 방식은 세 가지 치명적인 문제에 직면합니다:

  1. 컨텍스트 윈도우 초과: 기록 길이가 모델 제한 초과 → 프로그램 오류
  2. 비용 통제 불능: 호출할 때마다 전체 기록 재전송 → Token 소비 선형 증가
  3. 주의력 분산: 긴 컨텍스트에서 모델의 중간 부분 정보 처리 능력이 현저히 저하

세 가지 메모리 관리 전략

전략 1: 고정 윈도우 절단(Context Truncation)

최근 N 턴의 대화 또는 N 개의 Token만 유지합니다.

class TruncationMemory:
    def __init__(self, max_tokens: int = 4000):
        self.max_tokens = max_tokens
        self.messages = []

    def add_and_truncate(self, role: str, content: str):
        self.messages.append({"role": role, "content": content})

        # 가장 오래된 메시지부터 삭제하여 총 token 수가 제한 내에 들도록 함
        while self._total_tokens() > self.max_tokens:
            self.messages.pop(0)  # 가장 오래된 non-system 메시지 삭제

    def _total_tokens(self):
        return sum(count_tokens(m["content"]) for m in self.messages)

장점: 구현이 매우 간단하고 계산 오버헤드가 작음. 단점: 핵심 정보가 초기 대화에 있으면 절단 후 Agent가 “기억 상실”.

전략 2: 롤링 요약(Rolling Summary)

잊기 전에 먼저 핵심을 추출합니다.

대화 기록: [msg1, msg2, msg3, msg4, msg5, msg6, msg7, msg8]
                          ↓ 앞부분 압축
         [요약(m1-m4), msg5, msg6, msg7, msg8]
                          ↓ 계속 압축
         [요약(m1-m6), msg7, msg8]
class RollingSummaryMemory:
    def __init__(self, summary_trigger_tokens: int = 3000):
        self.summary_trigger = summary_trigger_tokens
        self.messages = []
        self.summary = ""

    def add_message(self, role: str, content: str):
        self.messages.append({"role": role, "content": content})

        if self._total_tokens() > self.summary_trigger:
            self._compress()

    def _compress(self):
        """앞부분 대화를 요약으로 압축"""
        split_point = len(self.messages) // 2
        to_compress = self.messages[:split_point]
        remaining = self.messages[split_point:]

        compress_prompt = f"""
        다음 대화 기록을 간결한 요약으로 정리하고 핵심 정보를 유지하세요:

        대화:
        {format_messages(to_compress)}

        요약:"""

        resp = client.chat.completions.create(
            model="gpt-4o-mini",  # 경량 모델로 요약
            messages=[{"role": "user", "content": compress_prompt}]
        )
        self.summary = resp.choices[0].message.content

        # 요약으로 압축된 메시지 대체
        self.messages = [
            {"role": "system", "content": f"대화 기록 요약:\n{self.summary}"}
        ] + remaining

    def _total_tokens(self):
        return sum(count_tokens(m["content"]) for m in self.messages)

장점: 길이를 압축하면서도 핵심 정보를 보존하여 장기 일관성 유지. 단점: 추가 API 호출 비용, 요약 품질이 후속 대화에 직접적 영향.

전략 3: 벡터화 검색(Vector-based Retrieval)

가장 지능적인 방식: 대화 기록을 벡터 데이터베이스에 저장하고 필요에 따라 가장 관련성 높은 기억을 검색합니다.

class VectorBasedMemory:
    def __init__(self):
        self.conversations = []  # 완전한 대화 기록
        self.embeddings = []     # 각 턴 대화의 벡터
        self.embed_model = "text-embedding-3-small"

    def store_conversation(self, user_msg: str, assistant_msg: str):
        """한 턴의 대화를 저장하고 벡터화"""
        conversation_text = f"User: {user_msg}\nAssistant: {assistant_msg}"
        self.conversations.append(conversation_text)

        vec = client.embeddings.create(
            model=self.embed_model,
            input=conversation_text
        )
        self.embeddings.append(vec.data[0].embedding)

    def retrieve_relevant(self, current_query: str, top_k: int = 5):
        """현재 질문과 가장 관련성 높은 과거 대화 검색"""
        query_vec = client.embeddings.create(
            model=self.embed_model,
            input=current_query
        ).data[0].embedding

        # 유사도 계산
        similarities = [
            np.dot(query_vec, mem_vec) /
            (np.linalg.norm(query_vec) * np.linalg.norm(mem_vec))
            for mem_vec in self.embeddings
        ]

        top_indices = np.argsort(similarities)[-top_k:][::-1]
        return [self.conversations[i] for i in top_indices]

장점: 컨텍스트 윈도우의 길이 제한에서 근본적으로 해방, 의미적 정밀 매칭. 단점: 시스템 복잡도가 가장 높음, Embedding 모델과 벡터 DB 도입.

전략 선택 가이드

시나리오추천 전략
잡담 봇고정 윈도우 절단 (간단하고 효과적)
고객 서비스 Q&A (정보 가치가 시간에 따라 빠르게 감소)고정 윈도우 절단
장문 콘텐츠 창작 / 프로젝트 계획롤링 요약
개인화 어시스턴트 / 장기 인터랙션벡터화 검색
모범 사례혼합 사용: 요약 + 벡터 검색

장기 메모리와 벡터 저장소

수동적 컨텍스트에서 능동적 메모리 관리로

진정한 지능형 Agent는 단순히 처리된 컨텍스트를 수동적으로 받아들이는 것을 넘어, 자신의 메모리를 능동적으로 관리할 수 있어야 합니다 — 언제 무엇을 기억해야 하는지, 언제 무엇을 떠올려야 하는지 스스로 결정하는 것입니다.

이를 위해 Agent에 두 가지 핵심 도구를 제공해야 합니다:

# Agent가 사용 가능한 메모리 관리 도구
memory_tools = [
    {
        "type": "function",
        "function": {
            "name": "record_to_memory",
            "description": "중요한 정보를 장기 메모리에 기록합니다. 사용자가 명시적으로 선호도를 표현하거나, 핵심 정보를 제공하거나, 중요한 결정을 내렸을 때 사용합니다.",
            "parameters": {
                "type": "object",
                "properties": {
                    "content": {
                        "type": "string",
                        "description": "기억해야 할 내용"
                    },
                    "category": {
                        "type": "string",
                        "enum": ["preference", "fact", "decision", "context"],
                        "description": "메모리 카테고리"
                    }
                },
                "required": ["content"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "retrieve_from_memory",
            "description": "장기 메모리에서 관련 정보를 검색합니다. 사용자 선호도, 과거 결정 또는 이전에 논의된 내용을 회상해야 할 때 사용합니다.",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "검색 쿼리"
                    },
                    "category": {
                        "type": "string",
                        "description": "선택사항, 검색할 메모리 카테고리 제한"
                    }
                },
                "required": ["query"]
            }
        }
    }
]

단기 메모리 vs 장기 메모리

┌─────────────────────────────────────────────────────┐
│                    메모리 시스템 아키텍처                │
├─────────────────────────────────────────────────────┤
│                                                     │
│  단기 메모리 (Short-term Memory)                       │
│  ┌───────────────────────────────────────────────┐  │
│  │ 저장: 대화 버퍼 (messages list)                │  │
│  │ 관리: 절단 / 요약                              │  │
│  │ 생명주기: 현재 세션                            │  │
│  │ 책임: 대화 일관성 유지, "방금 무엇을 이야기했는지" 기억 │  │
│  └───────────────────────────────────────────────┘  │
│                                                     │
│  장기 메모리 (Long-term Memory)                        │
│  ┌───────────────────────────────────────────────┐  │
│  │ 저장: 벡터 DB + 메타데이터 저장소               │  │
│  │ 관리: 벡터화 검색 + 도구화 호출                 │  │
│  │ 생명주기: 세션 간                               │  │
│  │ 책임: 핵심 정보 영속화, 세션 간 지능형 검색 지원  │  │
│  └───────────────────────────────────────────────┘  │
│                                                     │
└─────────────────────────────────────────────────────┘

메모리 관리 모범 사례

  1. 선별적으로 기억하기: 기억은 많을수록 좋은 것이 아닙니다. 낮은 가치의 정보는 후속 검색을 방해합니다. 쓰기 진입 메커니즘을 구축하십시오 — 사용자가 명시적으로 요청하거나 정보의 중요도가 임계값을 초과할 때만 기록합니다.

  2. 지속적 거버넌스: 메모리는 동적 데이터 자산입니다. 정기적으로 오래된 정보를 정리하고, 중복 항목을 병합하며, 사실 정확성을 검증하십시오. 사용자가 메모리를 관리할 수 있는 인터페이스(조회, 수정, 삭제)를 제공하십시오.

  3. 시나리오별 적용: 시나리오마다 메모리 요구사항이 다릅니다. 강좌 문서 워크플로우에서는 개인화 선호도를 기록해서는 안 됩니다. 제품 사실 정보(API 파라미터, 기능 제한 등)에 대해서는 기록하고 정기적으로 유효성을 검토해야 합니다.


Skill 시스템 설계

Prompt에서 Skill로의 진화

정교하게 작성된 Prompt는 가치가 높지만, 현재 세션에서만 효력을 발휘합니다. 세션이 끝나면 지식은 흩어집니다. Skill은 Prompt를 재사용 가능하고, 버전 관리 가능하며, 팀 공유가 가능한 전문 지식 모듈로 업그레이드하는 것입니다.

진화 경로:

임시 Prompt ("이 강좌를 검토해 주세요")
    ↓ 문제: 매번 다시 작성해야 하고, 기준이 일관되지 않음
고정 Prompt (채팅 기록에 저장)
    ↓ 문제: 흩어져 있고, 찾기 어렵고, 협업 불가
독립 파일 (course-review.md)
    ↓ 문제: 파일이 비대해지고 유지보수 어려움
지식 베이스 디렉토리 (course-review/)
    ↓ 문제: 여전히 Agent에게 무엇을 해야 하는지 수동으로 알려줘야 함
Skill (SKILL.md + 리소스 파일 + 스크립트)

Skill의 구조

규범적인 Skill은 YAML frontmatter + Markdown 본문으로 구성됩니다:

---
name: course-review
description: |
  강좌 콘텐츠의 기술적 정확성, 코드 정확성 및 교육 품질을 검토합니다.
  사용자가 기존 강좌나 교육 자료의 검토, 감사 또는 평가를 요청할 때 이 스킬을 사용합니다.
---

# 강좌 검토 스킬

## 검토 프로세스
1. Notebook 디렉토리 구조 추출, 전체 챕터 구성 이해
2. 챕터별 단락별 검사, 다음 방향에 따라:
   - 코드 실행 가능성 (자세한 내용은 [code-quality.md](code-quality.md) 참조)
   - 콘텐츠 정확성 (자세한 내용은 [content-accuracy.md](content-accuracy.md) 참조)
   - 설명 스타일 (자세한 내용은 [style-guide.md](style-guide.md) 참조)
   - 구식 API (자세한 내용은 [outdated-api.md](outdated-api.md) 참조)
3. 검토 결과 취합, 출력 형식에 따라 보고서 생성

## 안티패턴 체크리스트
- ❌ 코드의 변수명, API 버전 번호를 수정하지 마십시오
- ❌ 어떤 검사 항목도 건너뛰지 마십시오
- ❌ 검토 과정에서 새로운 기술 개념을 도입하지 마십시오

## 출력 형식
각 검사 항목에 대해: 통과/불통과/인간 검토 필요 + 위치 + 수정 제안

디렉토리 구조:

course-review/
├── SKILL.md              # 메인 명령 진입점
├── code-quality.md       # 코드 실행 가능성 검사 항목
├── content-accuracy.md   # 사실 정확성 검사 항목
├── style-guide.md        # 설명 스타일 좋은 예/나쁜 예
├── outdated-api.md       # 구식 API 대조표
└── scripts/
    ├── extract_toc.py    # Notebook 디렉토리 추출
    └── validate_code.py  # 코드 검증 자동 실행

Skill vs RAG

많은 사람이 Skill과 RAG를 혼동합니다. 이들의 핵심 차이는:

RAGSkill
해결하는 문제”모델이 어떤 사실을 모른다""모델이 어떻게 해야 하는지 모른다”
정보 유형사실적 지식 (문서 내용, 제품 파라미터)절차적 지식 (프로세스, 표준, 판단 규칙)
트리거 방식검색 후 컨텍스트에 주입선택 후 전개 (Agent가 활성화 여부 판단)
로딩 방식검색 결과 일회성 주입점진적 공개 (필요에 따라 단계적으로 하위 파일 로드)
생명주기매 쿼리마다 개별 검색세션 간 영속화, 버전 관리 가능

고품질 Skill 작성을 위한 5단계

1단계: 할 가치가 있는지 판단
  ├─ 이 작업에 "전문가 직관"이 있는가? (전문가는 잘하지만 초보자는 경계 조건을 놓치기 쉬운)
  ├─ 이 작업이 충분히 복잡한가? (3단계 이내 GUI로 완료할 수 있으면 하지 않음)
  └─ 이 작업이 반복적으로 실행되는가? (한 번만 하면 하지 않음)

2단계: 무엇을 작성할지 추출
  ├─ 단순 단계가 아닌 전문가의 의사결정 트리 추출
  ├─ 안티패턴 검사 주입 ("절대 밟아서는 안 될 함정들")
  ├─ Template 패턴: 표준화된 출력 템플릿 제공
  └─ Examples 패턴: 예시로 문자 설명 대체

3단계: 명령 잘 작성하기
  ├─ 간결: 모든 문장이 token 비용만큼 가치 있어야 함
  ├─ 자유도 매칭: 제약 정도가 작업 리스크와 일치해야 함
  │   ├─ 낮은 자유도 (데이터베이스 마이그레이션): 정밀 스크립트
  │   ├─ 중간 자유도 (보고서 생성): 의사 코드/파라미터화
  │   └─ 높은 자유도 (코드 리뷰): 텍스트 명령
  └─ 점진적 공개: 메인 파일은 간결하게 유지, 세부사항은 필요에 따라 로드

4단계: 도구 갖추기
  ├─ 워크플로우: 추적 가능한 Checklist
  ├─ 피드백 루프: 실행→확인→수정→반복
  ├─ 고위험 작업: 먼저 계획 검증 후 실행
  └─ AI 친화적 스크립트: 구조화된 상태 + 수정 단서 + 우아한 저하 + 멱등 안전

5단계: 검증과 반복
  ├─ 단계 1: 평가 기준선 구축 (먼저 평가를 갖춘 후 Skill 작성)
  ├─ 단계 2: Skill 추출 (AI로 반복 제공되는 정보를 요약)
  └─ 단계 3: 이중 Agent 테스트 반복 (설계자 vs 사용자)

Skill as Code

Skill을 코드로 간주하고 코드 엔지니어링의 방법론을 적용합니다:

  • 버전 관리: Skill 디렉토리를 Git에 포함, 모든 수정 사항은 이력 기록
  • 코드 리뷰: 신규/수정 Skill은 PR 리뷰 필요
  • CI/CD: Skill 변경 시 평가 파이프라인 트리거, 퇴행 방지
  • 커뮤니티 공유: 오픈소스 라이브러리처럼 Skill 공유 및 재사용

점진적 정보 공개와 Skill as Code

점진적 정보 공개의 설계 철학

전통적인 Prompt 엔지니어링이 직면한 핵심 모순: 정보가 너무 많으면 → 컨텍스트 혼잡, 주의력 분산; 정보가 너무 적으면 → Agent가 의사결정을 뒷받침할 충분한 지식 부족.

**점진적 정보 공개(Progressive Disclosure)**는 이 모순을 해결하는 설계 패턴입니다:

제1계층: Skill 목록 (Agent 시작 시 표시)
  ↓ Agent가 특정 Skill 활성화 선택
제2계층: SKILL.md (Skill 활성화 후 로드)
  ↓ Agent가 특정 단계 실행
제3계층: 하위 파일 (필요에 따라 해당 리소스 로드)
  ↓ 특정 기술 세부사항 검증 필요
제4계층: 스크립트 실행 (결정론적 결과 제공)

설계 원칙:

  • 평면 구조 유지, 깊은 중첩 참조 방지
  • SKILL.md가 모든 리소스 파일을 직접 링크, “한 단계”로 도달 가능하게 함
  • 모든 내용을 SKILL.md에 밀어 넣지 말 것 — 이는 단지 진입점일 뿐입니다

AI 친화적 스크립트 설계

도구 스크립트의 출력 품질은 Agent의 성능에 직접적 영향을 미칩니다. 네 가지 핵심 원칙:

1. 구조화된 상태 피드백 — 자유 텍스트 대신 JSON 출력:

# ❌ 좋지 않은 출력
print("Error: exit code 1")
print("Could not find module 'openpyxl'")

# ✅ AI 친화적 출력
print(json.dumps({
    "status": "failed",
    "error_code": "MODULE_NOT_FOUND",
    "missing_module": "openpyxl",
    "fix_hint": "pip install openpyxl을 실행하여 누락된 의존성 설치",
    "fallback_available": True,
    "affected_cells": [42, 43, 44]
}))

2. 오류 정보에 수정 단서 포함 — “무엇이 잘못되었는지”뿐 아니라 “어떻게 고칠지”를 Agent에게 알려줍니다.

3. 우아한 저하이(가) 아닌 충돌 — 가능한 경우 기본값을 제공하고 계속 진행합니다.

4. 멱등과 안전 — 부작용 없이 반복 실행 지원:

시나리오비멱등 (위험)멱등 (안전)
파일 쓰기매번 내용 추가먼저 비우고 다시 쓰기
데이터베이스 작업매번 INSERTUPSERT 사용
API 호출매번 새 리소스 생성멱등 키 사용

평가 프레임워크 설계

평가 프레임워크 설계

왜 “느낌이 괜찮다”는 믿을 수 없는가

Agent 최적화가 주관적 느낌에만 의존하면 다음과 같은 문제가 발생합니다:

  • 정량화 어려움: “느낌이 더 좋다”는 엔지니어링 의사결정의 근거가 될 수 없음
  • 기준 부재: 테스터와 시점에 따라 평가 기준이 표류할 수 있음
  • 재현 불가: 체계적 회귀 테스트가 불가능하여 새 변경이 기존 기능을 망가뜨리지 않았는지 확인할 수 없음

**평가 주도 개발(Evaluation-Driven Development)**은 평가를 개발 프로세스의 끝에서 핵심 위치로 끌어올립니다:

         ┌──────────────────┐
         │  평가 = 품질 척도   │
         └────────┬─────────┘

    ┌─────────────┼─────────────┐
    ▼             ▼             ▼
측정 가능해야    피드백이 빠르고   제품 능력의
개선 가능하다    정확할수록       상한을 결정한다
                 개선이 효율적이다

엔드 투 엔드 평가(End-to-End Evaluation)

엔드 투 엔드 평가는 최종 출력에 주목하여 “이 Agent가 사용자에게 좋은가?”라는 질문에 답합니다.

평가 지표는 두 가지 유형으로 나뉩니다:

유형설명예시
객관적 지표코드 규칙으로 직접 판단 가능코드 실행 가능 여부, 형식 Schema 준수 여부, 글자 수 범위 내 여부
주관적 지표의미와 품질에 관한 판단 포함콘텐츠 정확성, 교육 효과성, 언어 스타일 규정 준수 여부

화이트박스 평가(White-box Evaluation)

Agent 프로세스가 복잡해지면 엔드 투 엔드 평가로는 구체적 문제를 찾을 수 없습니다. 화이트박스 평가는 시스템 내부로 깊이 들어가 주요 구성 요소별로 독립적 평가 체계를 설계할 것을 주장합니다.

엔드 투 엔드 평가 (최종 출력만 봄):
  Agent 전체 → 점수 4.2/5 → 하지만 어디가 문제인지 모름

화이트박스 평가 (중간 단계 검사):
  ┌────────────┐    ┌────────────┐    ┌────────────┐
  │ 개념 설명    │    │ 코드 생성    │    │ 스타일 조정   │
  │ 점수 3.1/5 │    │ 점수 4.8/5 │    │ 점수 4.5/5 │
  └────────────┘    └────────────┘    └────────────┘
       ↑ 병목이 여기!

화이트박스 평가의 핵심 장점:

  • 명확한 신호: 간섭 없는 개선 신호, 진정한 병목에 집중
  • 빠른 반복: 단일 구성 요소만 테스트, 전체 프로세스 실행 불필요
  • 정밀 최적화: 각 변경 효과를 정확하게 측정 가능

LLM-as-Judge

LLM을 평가자로 사용하기

또 다른 LLM이 “평가 전문가” 역할을 하여, 정의된 지표와 채점 기준에 따라 자동으로 점수를 매깁니다.

def llm_judge_evaluate(generated_output: str, criteria: dict):
    """LLM을 평가자로 사용"""
    judge_prompt = f"""
    당신은 전문 강좌 품질 평가자입니다. 다음 기준에 따라 점수를 매기세요:

    평가 기준:
    {json.dumps(criteria, indent=2, ensure_ascii=False)}

    평가 대상 콘텐츠:
    {generated_output}

    JSON 형식으로 평가 결과를 출력하세요:
    {{
        "scores": {{
            "accuracy": <1-5>,
            "clarity": <1-5>,
            "engagement": <1-5>
        }},
        "overall": <1-5>,
        "comments": "종합 평가"
    }}"""

    resp = client.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": judge_prompt}],
        response_format={"type": "json_object"}
    )
    return json.loads(resp.choices[0].message.content)

LLM 평가자의 편향

LLM을 평가자로 사용할 때는 그 고유의 편향을 반드시 경계해야 합니다:

편향 유형표현완화 방법
스타일 편향특정 코드/작문 스타일 선호평가 기준 명확화, “취향”에 의존하지 않음
길이 편향더 긴 답변이 “더 완전하다”고 판단”간결성”을 평가 차원으로 포함
”Yes-man” 편향긍정적 평가를 선호하는 경향대조 평가 사용 (A vs B 어느 쪽이 더 나은가)
위치 편향목록의 특정 위치 콘텐츠 선호평가 대상 콘텐츠 순서 무작위화

모범 사례: 초기에는 인간 전문가를 사용하여 “골든 테스트 세트”를 구축하고, 이를 사용해 LLM 평가자를 교정하십시오. 정기적으로 수동 샘플 검사로 자동 평가의 일관성을 검증하십시오.

평가 지표 분해

모호한 평가 목표를 하나씩 점검 가능한 구체적 세부 기준으로 분해합니다:

# 콘텐츠 품질 평가 분해 예시
평가 차원: "콘텐츠 품질"
세부 기준:
  - id: "pain_point"
    description: "구체적인痛点으로 시작하는가?"
    type: "boolean"
  - id: "theory_depth"
    description: "초기 해결책의 한계를 명확히 지적하고 핵심 이론으로 이끄는가?"
    type: "boolean"
  - id: "code_relevance"
    description: "코드 예시가 설명하는 이론과 밀접하게 관련되고 충분히 단순화되었는가?"
    type: "boolean"
  - id: "anti_pattern_check"
    description: "'새로운 스킬을 획득한 것을 축하합니다' 같은 농담을 피했는가?"
    type: "boolean"

평가 주도 반복

평가 폐쇄 루프

평가는 일회성이 아니라 지속적으로 개선을 이끄는 엔진입니다:

    ┌──────────────────────────────────┐
    │                                  │
    ▼                                  │
┌─────────┐   ┌──────────┐   ┌─────────┐
│ MVP 구축  │ → │ 문제 발견  │ → │ 지표 정제 │
└─────────┘   └──────────┘   └─────────┘


┌─────────┐   ┌──────────┐   ┌─────────┐
│ 배포/출시  │ ← │ 회귀 테스트 │ ← │ 최적화 개선 │
└─────────┘   └──────────┘   └─────────┘

                                  └────→ (순환)

비즈니스 전문가 주도 평가 표준

평가 지표(특히 주관적 지표)는 반드시 가장 경험이 많은 비즈니스 전문가가 주도하여 수립해야 합니다:

  1. 비즈니스 목표로 참여 유도: “평가 지표 정의를 도와주세요”가 아니라 “이 Agent는 당신의 강좌 제작 주기를 2주에서 3일로 단축하고, 90% 이상의 사용자 만족도를 유지해야 합니다”라고 말하십시오.

  2. 구조화된 도구로 진입 장벽 낮추기: 채점 척도 템플릿, 사례 주석 도구, “강좌의 좋고 나쁨을 판단하기 위해 단 세 가지 지표만 볼 수 있다면 무엇을 선택하시겠습니까?”와 같은 유도 질문.

  3. 지속적 협업 메커니즘 구축: 주간 리뷰 회의에서 전문가가 데이터를 보고, 기술팀이 시스템을 조정하며, 함께 의사결정합니다.

평가의 효율 레버리지

모든 평가가 전자동, 전체 커버리지일 필요는 없습니다. 가장 간단한 방법부터 시작하십시오:

Level 1: 수동 샘플 검사 → "코드를 복사해서 실행해 보기"
Level 2: 자동화 스크립트 → "스크립트를 작성하여 일괄 실행"
Level 3: 평가 파이프라인 통합 → CI/CD 통합, PR마다 자동 트리거
Level 4: 지속적 모니터링 → 프로덕션 환경 실시간 핵심 지표 모니터링

각 Level의 투자 대비 수익은 다릅니다. 초기에는 Level 1의 투자 대비 수익이 가장 높습니다 — 가장 빠르게 문제를 발견하고 구현 비용이 가장 낮습니다. 시스템이 성숙해짐에 따라 점진적으로 더 높은 Level로 진화하십시오.


모델 배포 전략

비즈니스 요구사항 분석 프레임워크

LLM 애플리케이션을 프로덕션 환경에 배포할 때, 첫 단계는 기술 선정이 아니라 요구사항 분석입니다:

┌────────────────────────────────────────────┐
│           비즈니스 요구사항 분석 매트릭스         │
├────────────────────────────────────────────┤
│                                            │
│  기능적 요구사항 (무엇을 하는가):                 │
│  ├─ 자연어 처리 → 범용 LLM                     │
│  ├─ 코드 생성 → 코드 최적화된 LLM               │
│  ├─ 수학 추론 → 수학 파인튜닝된 LLM             │
│  ├─ 시각 이해 → 멀티모달 모델                    │
│  └─ 음성 처리 → 음성 모델                       │
│                                            │
│  비기능적 요구사항 (어떻게 하는가):                │
│  ├─ 성능: TTFT < 500ms, TPOT < 50ms         │
│  ├─ 비용: 단일 호출 < $0.01                    │
│  ├─ 안정성: 99.9% 가용성                       │
│  ├─ 보안: 콘텐츠 필터링, 프라이버시 보호           │
│  └─ 컴플라이언스: 산업 규제 요구사항               │
│                                            │
└────────────────────────────────────────────┘

모델 선정 전략

모든 시나리오에 가장 큰 모델이 필요한 것은 아닙니다. 모델 선정은 “최소 가용” 원칙을 따릅니다:

작업 복잡도

    │  ┌──────────────────────────┐
    │  │ 대형 모델 (GPT-4, Claude)   │
    │  │ - 복잡한 추론               │
    │  │ - 다단계 계획               │
    │  │ - 창의적 생성               │
    │  └──────────────────────────┘
    │  ┌──────────────────────────┐
    │  │ 중형 모델 (GPT-4o-mini)     │
    │  │ - 의도 인식                │
    │  │ - 구조화된 추출             │
    │  │ - 요약 생성                │
    │  └──────────────────────────┘
    │  ┌──────────────────────────┐
    │  │ 소형 모델 / 증류 모델        │
    │  │ - 텍스트 분류               │
    │  │ - 키워드 매칭               │
    │  │ - 형식 검증                │
    │  └──────────────────────────┘
    └─────────────────────────────────→ 호출 빈도

증류: 작은 모델이 전문 능력을 갖추게 하기

증류의 핵심 아이디어: 대형 모델의 판단 능력을 소형 모델에 “복사”하는 것입니다.

교사 모델 (GPT-4)           학생 모델 (0.6B 파라미터)
      │                         │
      │  레이블 데이터 생성          │
      ├─────────────────────────→│
      │  "요청 의도를 이해하라"      │  교사의 행동 패턴 학습
      │  [입력→출력 쌍]             │
      │                         │
      │  효과: 소형 모델이 특정 작업에서│
      │  교사 모델 수준에 근접        │

증류 vs 파인튜닝:

파인튜닝증류
데이터 출처수동 레이블교사 모델 생성
데이터 비용높음낮음 (API 호출 비용)
데이터 규모제한적대규모 생성 가능
품질 상한레이블러에 의존교사 모델에 의존

증류의 세 가지 경로:

경로필요한 리소스적용 시나리오
데이터 합성 증류 (블랙박스)API 접근만 필요구조화된 작업, 상용 API 교사
지식 증류 KD (화이트박스)교사 모델 가중치오픈소스 교사, 더 높은 정밀도 필요
추론 압축교사 추론 궤적다단계 추론 작업 (예: DeepSeek-R1)

추론 최적화

성능 최적화 프레임워크

LLM 추론 최적화를 네 가지 방향으로 나눕니다:

요청을 더 빠르게 처리하기

  • 모델 소형화: 파라미터 수가 더 작은 모델 변형 선택
  • 양자화: INT4/INT8/FP16 양자화로 계산 리소스 요구 감소
  • 프루닝: 중복 가중치 제거, 모델 복잡도 감소
  • 지식 증류: 대형 모델 데이터로 소형 모델 학습
양자화 정밀도 비교:
  FP32 (전체 정밀도)   → 기준 성능, 최대 계산 오버헤드
  FP16 (반정밀도)     → ~2x 가속, 정밀도 거의 손실 없음
  INT8               → ~4x 가속, 소폭 정밀도 손실
  INT4               → ~8x 가속, 정밀도 영향 주의 깊게 평가 필요

처리 요청 수 줄이기

  • 컨텍스트 캐시(Context Cache): 멀티턴 대화의 공통 프리픽스 캐싱, 중복 계산 감소
  • 배치 처리(Batching): 여러 요청을 하나의 배치로 병합, 하드웨어 활용률 향상
  • 결과 캐시: 빈도 높은 동일 질문 결과 직접 캐시 반환
# 컨텍스트 캐시의 전형적 적용
# 멀티턴 대화에서 System Prompt + 과거 지식 문서가 공통 프리픽스
# 첫 번째 턴: 전체 계산 (전체 가격)
# 후속 턴: 캐시 히트 부분은 20% 가격으로 과금

Token 입출력 줄이기

  • 입력 측: 입력 간소화, 중복 정보 제거, 긴 문서는 먼저 요약 생성
  • 출력 측: Prompt를 통해 간결한 답변 유도, 합리적인 max_tokens 설정

max_tokens의 설계 철학: 이는 콘텐츠 제어 수단이 아닌 안전 밸브입니다. 의미적으로 완전한 짧은 응답은 Prompt 유도를 사용해야 하며, max_tokens는 비용 통제의 최후 방어선으로 더 적합합니다.

병렬화 처리

LLM 추론은 본질적으로 대규모 행렬 연산입니다. CPU와 GPU의 차이를 이해하십시오:

CPUGPU
코어 수소수의 강력한 코어 (8-64)대량의 단순 코어 (수천)
적합 작업복잡 논리, 직렬대규모 병렬 행렬 연산

GPU 병렬화 전략:

  • 데이터 병렬: 데이터 샤딩을 여러 GPU에 할당
  • 모델 병렬: 모델의 다른 레이어를 다른 장치에 분산
  • 파이프라인 병렬: 계산 과정을 단계로 나누어 순차 실행

대형 모델에 기본 의존하지 말 것

많은 시나리오에서 더 간단한 방법이 오히려 더 효율적입니다:

시나리오대안
표준 확인 메시지하드코딩 템플릿 + 무작위 변형 선택
제한된 옵션의 응답가능한 모든 결과 미리 계산, 입력에 따라 매칭
데이터 표시LLM이 설명 텍스트를 생성하는 대신 차트, 표 등 전통적 UI 사용
키워드 매칭의도 인식 단계에서 먼저 키워드 필터링, 필요시에만 LLM 호출

보안 가드레일

LLM이 직면한 보안 위협

LLM 애플리케이션은 다층적 보안 위협에 직면하며, 체계적인 방어 전략이 필요합니다:

안전 가드레일

방어 전략 매트릭스

공격 유형공격 방식방어 조치
프롬프트 인젝션모델이 시스템 명령을 덮어쓰도록 유도내장 보안 가드레일 탐지 + 사용자 입력과 시스템 명령 엄격 분리
명령 인젝션요청에 악성 코드 삽입실행 전 감사 + 최소 권한
프롬프트 유출모델이 자신의 System Prompt 출력하도록 유도보안 가드레일이 탐지 패턴 인식
지식 베이스 오염잘못된 정보가 담긴 문서 업로드지식 등록 승인 프로세스 + 콘텐츠 사전 스캔
모델 탈취대량 API 호출로 학습 데이터 수집API 속도 제한 + 봇 트래픽 식별
악의적 기능 호출Agent가 위험한 작업 실행하도록 유도도구 호출 전 감사 + 서킷 브레이커 메커니즘

보안 가드레일의 엔지니어링 구현

class SafetyGuard:
    """다층 보안 가드레일 구현"""

    def __init__(self):
        self.blocked_keywords = set()    # 사용자 정의 민감어
        self.rate_limits = {}            # 속도 제한 기록
        self.max_tool_calls = 10         # Agent 최대 도구 호출 횟수
        self.dangerous_commands = {      # 위험 명령 블랙리스트
            "rm -rf", "DROP TABLE", "DELETE FROM",
            "os.system", "subprocess.call", "eval("
        }

    def check_input(self, user_input: str) -> tuple[bool, str]:
        """입력 보안 검사"""
        # 1. 민감어 탐지
        for keyword in self.blocked_keywords:
            if keyword in user_input.lower():
                return False, f"입력에 민감어 포함: {keyword}"

        # 2. 명령 인젝션 탐지
        for dangerous in self.dangerous_commands:
            if dangerous.lower() in user_input.lower():
                return False, f"잠재적 위험 명령 탐지: {dangerous}"

        return True, "통과"

    def check_tool_call(self, tool_name: str, args: dict) -> tuple[bool, str]:
        """도구 호출 전 감사"""
        # 1. 호출 빈도 검사
        if self.rate_limits.get(tool_name, 0) >= self.max_tool_calls:
            return False, f"도구 {tool_name} 호출 횟수 초과"

        # 2. 파라미터 안전성 검사
        args_str = json.dumps(args).lower()
        for dangerous in self.dangerous_commands:
            if dangerous.lower() in args_str:
                return False, f"도구 파라미터에 위험 명령 포함: {dangerous}"

        self.rate_limits[tool_name] = self.rate_limits.get(tool_name, 0) + 1
        return True, "통과"

    def check_output(self, output: str) -> tuple[bool, str]:
        """출력 콘텐츠 심사"""
        # 출력해서는 안 되는 민감 정보 패턴 탐지
        sensitive_patterns = [
            r'\b\d{17}[\dXx]\b',           # 주민등록번호
            r'\b1[3-9]\d{9}\b',            # 휴대폰 번호
            r'[Pp]assword\s*[:=]\s*\S+',  # 비밀번호 패턴
        ]

        for pattern in sensitive_patterns:
            if re.search(pattern, output):
                return False, f"출력에 민감 정보 포함 가능: {pattern}"

        return True, "통과"

서킷 브레이커 메커니즘

Agent의 각 작업에 명시적 리소스 상한을 설정합니다:

class CircuitBreaker:
    """Agent 서킷 브레이커: 통제 불능 루프가 큰 손실을 초래하는 것을 방지"""

    def __init__(self,
                 max_api_calls: int = 10,      # 최대 API 호출 횟수
                 max_wall_time: int = 300,      # 최대 실행 시간 (초)
                 max_cost: float = 0.50):       # 최대 비용 (달러)
        self.max_api_calls = max_api_calls
        self.max_wall_time = max_wall_time
        self.max_cost = max_cost
        self.reset()

    def reset(self):
        self.api_calls = 0
        self.start_time = time.time()
        self.total_cost = 0.0

    def check(self) -> tuple[bool, str]:
        """서킷 브레이크 여부 확인"""
        self.api_calls += 1
        elapsed = time.time() - self.start_time

        if self.api_calls > self.max_api_calls:
            return False, f"API 호출 횟수 초과 ({self.api_calls}/{self.max_api_calls})"
        if elapsed > self.max_wall_time:
            return False, f"실행 시간 초과 ({elapsed:.0f}s/{self.max_wall_time}s)"
        if self.total_cost > self.max_cost:
            return False, f"비용 초과 (${self.total_cost:.2f}/${self.max_cost:.2f})"

        return True, "정상"

Harness Engineering

프로덕션 환경의 전체 청사진

Harness Engineering은 Agent 시스템이 개발에서 프로덕션까지 안정적으로 실행되도록 보장하는 풀셋 엔지니어링 실천입니다. 여기에는 다음이 포함됩니다:

Harness Engineering

가시성(Observability)

OpenTelemetry 표준을 사용하여 세 가지 유형의 데이터 수집을 구축합니다:

  • Metrics(지표): Token 소비, 지연 시간 분포, 오류율
  • Traces(추적): 한 번의 요청이 거치는 각 단계와 소요 시간
  • Logs(로그): 각 단계의 입출력, 오류 스택, 감사 정보
# OpenTelemetry를 사용한 Agent 호출 계측
from opentelemetry import trace
from opentelemetry.instrumentation.openai import OpenAIInstrumentor

# OpenAI API 호출 자동 계측
OpenAIInstrumentor().instrument()

tracer = trace.get_tracer(__name__)

@tracer.start_as_current_span("agent_task")
def run_agent_task(task: str):
    # span이 자동으로 소요 시간과 컨텍스트 기록
    with tracer.start_as_current_span("llm_call") as span:
        span.set_attribute("task", task)
        result = agent.run(task)
        span.set_attribute("tokens_used", result.usage.total_tokens)
        return result

배포 전 체크리스트

Agent를 프로덕션에 배포하기 전에 다음 검사를 완료하십시오:

☐ SLO 정의
  ├─ TTFT (첫 Token 지연) 목표: _____ ms
  ├─ TPOT (Token당 생성 시간) 목표: _____ ms
  └─ 가용성 목표: _____%

☐ 비용 통제
  ├─ 단일 호출 예산 상한: $_____
  ├─ 일일 Token 소비 상한: _____ tokens
  └─ 경보 임계값 설정 완료

☐ 보안 방어
  ├─ 입력 보안 검사 활성화
  ├─ 출력 콘텐츠 심사 활성화
  ├─ Agent 행동 서킷 브레이커 설정 완료
  └─ 보안 모니터링 경보 설정 완료

☐ 재해복구 방안
  ├─ 모델 저하 경로 정의 완료
  ├─ 주요 경로 폴백 로직 검증 완료
  └─ 장애 복구 훈련 완료

☐ 평가 기준선
  ├─ 엔드 투 엔드 평가 점수: _____
  ├─ 구성 요소별 평가 점수: _____
  └─ 회귀 테스트 파이프라인 통합 완료

☐ 가시성
  ├─ OpenTelemetry 연동 완료
  ├─ 핵심 지표 Dashboard 구축 완료
  └─ 경보 규칙 설정 및 검증 완료

점진적 배포 전략

한 번에 전면 배포하지 마십시오. 점진적 전략을 채택하십시오:

Phase 1: 내부 테스트 (1-2주)
  └─ 팀원 사용, 초기 피드백 수집

Phase 2: 소규모 카나리 (5% 사용자)
  └─ 신구 시스템 평가 지표 비교

Phase 3: 범위 확대 (25% → 50% → 100%)
  └─ 각 단계에서 3-5일 머무르며 지표 관찰

Phase 4: 전면 배포
  └─ 모니터링 유지, 지속적 최적화 사이클 구축

프로덕션 운영 모범 사례

평가 기준선 관리:

  • 현재 프로덕션 버전을 기준선으로 설정, 모든 새 버전은 기준선을 초과해야 함
  • 정기적으로(주 단위) 최신 데이터로 기준선과 후보 버전 재테스트
  • 배포 파이프라인에 기준선 검사 통합, 미달 버전 자동 차단

계층화된 저하 전략:

  1. 주 모델 불가용 → 대체 모델로 전환
  2. 대체 모델도 불가용 → 캐시된 일반 답변 사용
  3. 캐시 미스 → 사전 설정된 저하 응답 템플릿 반환

비용 거버넌스:

  • 모델별, 사용자별, 작업 유형별 차원에서 Token 소비 분석
  • 이상 비용 급증 식별 및 경보 설정
  • 정기 검토: 불필요한 긴 컨텍스트, 중복된 System Prompt가 있는가

모델 증류: 소형 모델에 도메인 전문 지식 가르치기

증류가 중요한 이유

대형 모델(GPT-4, Claude, Qwen-72B)은 뛰어난 품질을 제공하지만 추론 비용과 지연 시간이 높습니다. 분당 수천 건의 요청을 처리하는 프로덕션 시스템에서는 토큰 비용이 매우 커질 수 있습니다. 모델 증류는 실용적인 해결책을 제공합니다: 대형 모델의 출력을 훈련 데이터로 사용하여 소형 모델(7B-14B)이 훨씬 낮은 비용으로 동일한 동작을 재현하도록 가르칩니다.

교사 모델 (GPT-4, 175B 파라미터)

  ├── 도메인 작업에 대한 고품질 응답 생성


훈련 데이터 (입력 → 교사 출력 쌍)

  ├── 학생 모델 파인튜닝


학생 모델 (7B-14B 파라미터)

  ├── 동일한 품질, 비용 10-50배 절감
  └── 지연 시간 5-10배 감소

증류 파이프라인

1단계: 작업 범위 정의

증류는 작업이 잘 정의되고 반복적일 때 가장 효과적입니다. 소형 모델이 수행해야 할 구체적인 도메인을 식별합니다:

# 예시: 고객 지원 의도 분류
task_definitions = [
    {
        "name": "intent_classification",
        "input_schema": {"user_message": "string", "context": "string"},
        "output_schema": {"intent": "string", "confidence": "float", "reasoning": "string"},
    },
    {
        "name": "response_generation",
        "input_schema": {"intent": "string", "knowledge": "string", "tone": "string"},
        "output_schema": {"response": "string", "sources": ["string"]},
    },
]

2단계: 교사 모델로 훈련 데이터 생성

대형 모델을 사용하여 도메인에 대한 고품질 예시를 생성합니다:

from openai import OpenAI

teacher = OpenAI(api_key="...")  # GPT-4 또는 유사 모델
student = OpenAI(base_url="http://localhost:8000/v1")  # 소형 모델

def generate_training_examples(task_def: dict, n_examples: int = 1000) -> list:
    examples = []
    for i in range(n_examples):
        # 다양한 입력 생성
        input_prompt = f"작업 '{task_def['name']}'에 대한 현실적인 입력을 생성하세요. 복잡도와 엣지 케이스를 다양하게 변화시키세요."
        input_resp = teacher.chat.completions.create(
            model="gpt-4",
            messages=[{"role": "user", "content": input_prompt}],
        )
        user_input = input_resp.choices[0].message.content

        # 교사 출력 생성
        teacher_resp = teacher.chat.completions.create(
            model="gpt-4",
            messages=[
                {"role": "system", "content": f"당신은 {task_def['name']}의 전문가입니다. 이 출력 스키마를 따르세요: {task_def['output_schema']}"},
                {"role": "user", "content": user_input},
            ],
        )
        teacher_output = teacher_resp.choices[0].message.content

        examples.append({"input": user_input, "output": teacher_output})

    return examples

training_data = generate_training_examples(task_definitions[0], n_examples=2000)

3단계: 학생 모델 파인튜닝

LoRA 또는 전체 파인튜닝을 사용하여 교사의 출력으로 소형 모델을 훈련합니다:

# Hugging Face Transformers + PEFT를 사용한 LoRA 파인튜닝
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model

model_name = "meta-llama/Llama-2-7b-hf"
model = AutoModelForCausalLM.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)

# 효율적인 파인튜닝을 위해 LoRA 설정
lora_config = LoraConfig(
    r=16,  # LoRA 랭크
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 출력: trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.0622

# 증류 데이터로 훈련
# ... (표준 훈련 루프)

4단계: 평가 및 반복

홀드아웃 테스트 세트에서 학생 모델의 출력을 교사의 출력과 비교합니다:

def evaluate_student_vs_teacher(test_set: list, teacher_client, student_client) -> dict:
    results = {"exact_match": 0, "semantic_similarity": 0, "total": len(test_set)}

    for example in test_set:
        student_resp = student_client.chat.completions.create(
            model="student-7b",
            messages=[{"role": "user", "content": example["input"]}],
        )
        student_output = student_resp.choices[0].message.content
        teacher_output = example["output"]

        # 정확 일치 검사
        if student_output.strip() == teacher_output.strip():
            results["exact_match"] += 1

        # 의미적 유사도 (embedding 사용)
        # ... (출력 간 코사인 유사도 계산)

    results["exact_match_rate"] = results["exact_match"] / results["total"]
    return results

# 목표: 교사와의 의미적 유사도 >85%

증류 should 할 때

시나리오권장
고빈도 반복 작업 (분류, 추출)증류 — 비용 절감이 매우 큼
창의적·오픈엔드 생성교사 모델 유지 — 비용보다 품질이 중요
지연 시간에 민감한 애플리케이션 (실시간 채팅)증류 — 소형 모델이 5-10배 빠름
드물고 복잡한 추론 작업교사 모델 유지 — 소형 모델은 새로운 추론에 약함
하이브리드: 단순 작업 + 복잡한 엣지 케이스라우팅 — 소형 모델로 80% 처리, 20%는 교사로 에스컬레이션

프로덕션 모범 사례: 모니터링, 카나리 릴리스, A/B 테스트

가시성 스택

프로덕션 Agent 시스템에는 포괄적인 가시성이 필요합니다. 다음 모니터링 레이어를 구축합니다:

1. 애플리케이션 레벨 지표

import time
from prometheus_client import Counter, Histogram, Gauge

# 요청 지표
request_counter = Counter('agent_requests_total', '총 요청 수', ['model', 'intent', 'status'])
request_latency = Histogram('agent_request_duration_seconds', '요청 지연 시간', ['model', 'intent'])
active_sessions = Gauge('agent_active_sessions', '활성 세션 수')

# Token 사용량 지표
token_usage = Counter('agent_tokens_total', 'Token 사용량', ['model', 'type'])  # type: input/output

# 품질 지표
hallucination_rate = Gauge('agent_hallucination_rate', '환각 추정 비율')
user_satisfaction = Histogram('agent_user_satisfaction', '사용자 만족도 점수', buckets=[1, 2, 3, 4, 5])

def track_request(model: str, intent: str, status: str, latency: float, tokens_in: int, tokens_out: int):
    request_counter.labels(model=model, intent=intent, status=status).inc()
    request_latency.labels(model=model, intent=intent).observe(latency)
    token_usage.labels(model=model, type='input').inc(tokens_in)
    token_usage.labels(model=model, type='output').inc(tokens_out)

2. 분산 추적

OpenTelemetry를 사용하여 Agent 파이프라인 전체에서 요청을 추적합니다:

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

trace.set_tracer_provider(TracerProvider())
tracer = trace.get_tracer(__name__)

def process_user_request(user_input: str):
    with tracer.start_as_current_span("process_request") as span:
        span.set_attribute("user.input_length", len(user_input))

        with tracer.start_as_current_span("retrieve_context") as retrieve_span:
            context = retrieve_relevant_docs(user_input)
            retrieve_span.set_attribute("docs.retrieved", len(context))

        with tracer.start_as_current_span("llm_generate") as llm_span:
            llm_span.set_attribute("model", "gpt-4")
            response = call_llm(user_input, context)
            llm_span.set_attribute("tokens.used", response.usage.total_tokens)

        return response

3. 로깅 전략

import structlog

logger = structlog.get_logger()

def log_agent_event(event_type: str, **kwargs):
    logger.info(
        event_type,
        session_id=kwargs.get("session_id"),
        user_id=kwargs.get("user_id"),
        model=kwargs.get("model"),
        intent=kwargs.get("intent"),
        latency_ms=kwargs.get("latency_ms"),
        tokens_in=kwargs.get("tokens_in"),
        tokens_out=kwargs.get("tokens_out"),
        error=kwargs.get("error"),
    )

# 사용 예시
log_agent_event(
    "request_completed",
    session_id="abc123",
    user_id="user_456",
    model="gpt-4",
    intent="faq_answer",
    latency_ms=1234,
    tokens_in=500,
    tokens_out=200,
)

카나리 릴리스 전략

위험을 최소화하기 위해 변경 사항을 점진적으로 릴리스합니다:

Phase 1: 카나리 (트래픽의 5%)
  └─ 소규모 서브셋에서 새 모델/설정 실행
  └─ 오류율, 지연 시간, 만족도 모니터링
  └─ 기간: 1-3일

Phase 2: 확대 (25% → 50%)
  └─ 트래픽을 점진적으로 증가
  └─ 기준선과 지표 비교
  └─ 각 단계 기간: 3-5일

Phase 3: 전면 릴리스 (100%)
  └─ 모든 트래픽을 새 버전으로
  └─ 모니터링 계속
  └─ 빠른 롤백을 위해 구 버전 유지
# 사용자 ID 해시 기반 간단한 카나리 라우팅
import hashlib

def route_to_version(user_id: str, canary_percent: int = 5) -> str:
    hash_val = int(hashlib.md5(user_id.encode()).hexdigest(), 16)
    bucket = hash_val % 100
    return "canary" if bucket < canary_percent else "stable"

# 요청 핸들러에서
def handle_request(user_id: str, user_input: str):
    version = route_to_version(user_id, canary_percent=5)

    if version == "canary":
        response = call_new_model(user_input)
    else:
        response = call_stable_model(user_input)

    # 버전별 지표 추적
    track_request(model=version, ...)
    return response

A/B 테스트 프레임워크

두 버전을 직접 비교하여 영향을 측정합니다:

from dataclasses import dataclass
from enum import Enum

class Variant(Enum):
    CONTROL = "control"
    TREATMENT = "treatment"

@dataclass
class ABTestResult:
    variant: Variant
    total_requests: int
    avg_latency_ms: float
    success_rate: float
    user_satisfaction: float  # 1-5 척도

def run_ab_test(user_id: str, user_input: str) -> tuple[str, str]:
    """(배리언트, 응답)을 반환"""
    # 사용자 ID 기반 일관된 할당
    hash_val = int(hashlib.md5(user_id.encode()).hexdigest(), 16)
    variant = Variant.CONTROL if hash_val % 2 == 0 else Variant.TREATMENT

    if variant == Variant.CONTROL:
        response = call_control_model(user_input)
    else:
        response = call_treatment_model(user_input)

    return variant.value, response

def analyze_ab_results(results_a: list, results_b: list) -> dict:
    """대조군과 실험군의 지표를 비교"""
    import statistics

    def compute_metrics(results):
        return {
            "count": len(results),
            "avg_latency": statistics.mean([r["latency_ms"] for r in results]),
            "success_rate": sum(1 for r in results if r["success"]) / len(results),
        }

    metrics_a = compute_metrics(results_a)
    metrics_b = compute_metrics(results_b)

    return {
        "control": metrics_a,
        "treatment": metrics_b,
        "latency_improvement": (metrics_a["avg_latency"] - metrics_b["avg_latency"]) / metrics_a["avg_latency"] * 100,
        "success_rate_delta": (metrics_b["success_rate"] - metrics_a["success_rate"]) * 100,
    }

알림 규칙

중요 조건에 대한 알림을 설정합니다:

# Prometheus 알림 규칙
groups:
  - name: agent_alerts
    rules:
      - alert: HighErrorRate
        expr: rate(agent_requests_total{status="error"}[5m]) / rate(agent_requests_total[5m]) > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Agent 오류율이 5%를 초과했습니다"

      - alert: HighLatency
        expr: histogram_quantile(0.95, rate(agent_request_duration_seconds_bucket[5m])) > 10
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "P95 지연 시간이 10초를 초과했습니다"

      - alert: TokenCostSpike
        expr: rate(agent_tokens_total[1h]) > 100000
        for: 30m
        labels:
          severity: warning
        annotations:
          summary: "Token 사용량 급증 감지됨"

RIDE 방법론: AI 기반 비즈니스 영향력 프레임워크

도전 과제

AI 도입을 서두르는 조직은 종종 두 가지 함정 중 하나에 빠집니다:

  1. 문제를 찾는 솔루션: 실제 비즈니스 요구를 해결하지 못하는 인상적인 AI 데모를 구축
  2. 분석 마비: 아무것도 출시하지 않고 AI 도구를 무한히 평가

RIDE 방법론은 실제 비즈니스 가치를 전달하는 AI 이니셔티브를 선택, 구현, 측정하기 위한 구조화된 접근법을 제공합니다.

RIDE: Research → Implement → Deliver → Enhance

┌─────────────────────────────────────────────────────────────┐
│                      RIDE 사이클                              │
│                                                              │
│   ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────┐ │
│   │ Research │───▶│Implement │───▶│ Deliver  │───▶│Enhance│ │
│   │          │    │          │    │          │    │      │ │
│   └──────────┘    └──────────┘    └──────────┘    └──────┘ │
│        ▲                                              │     │
│        └──────────────────────────────────────────────┘     │
│                      (반복)                                  │
└─────────────────────────────────────────────────────────────┘

Phase 1: Research — 올바른 문제 선택

목표: 비즈니스 우선순위와 일치하는 고영향력·실행 가능한 AI 유스케이스를 식별합니다.

주요 활동:

  1. 비즈니스 페인포인트 매핑: 이해관계자 인터뷰, 지원 티켓 분석, 프로세스 병목 검토
  2. 영향력-실행 가능성 매트릭스로 기회 점수화:
@dataclass
class UseCase:
    name: str
    description: str
    business_impact: int      # 1-10: 수익 영향, 비용 절감, 고객 만족도
    technical_feasibility: int # 1-10: 데이터 가용성, 모델 능력, 통합 복잡도
    time_to_value: int         # MVP까지의 주 수
    stakeholders: list[str]

def score_use_case(uc: UseCase) -> float:
    """점수가 높을수록 = AI 이니셔티브 후보로서 더 우수"""
    return (uc.business_impact * 0.4 + uc.technical_feasibility * 0.3 +
            (10 - uc.time_to_value / 4) * 0.3)  # 시간을 1-10 척도로 정규화

# 점수화 예시
use_cases = [
    UseCase("FAQ Bot", "고객 FAQ 응답 자동화", 7, 9, 4, ["지원", "엔지니어링"]),
    UseCase("Code Review", "AI 지원 코드 리뷰", 6, 7, 8, ["엔지니어링"]),
    UseCase("Demand Forecast", "제품 수요 예측", 9, 5, 12, ["제품", "공급망"]),
]

for uc in sorted(use_cases, key=score_use_case, reverse=True):
    print(f"{uc.name}: score={score_use_case(uc):.1f}, impact={uc.business_impact}, feasibility={uc.technical_feasibility}")
# 출력:
# FAQ Bot: score=8.2, impact=7, feasibility=9
# Code Review: score=6.7, impact=6, feasibility=7
# Demand Forecast: score=6.1, impact=9, feasibility=5
  1. 이해관계자와 검증: 상위 후보를 제시하고, 동의를 얻고, 성공 기준을 정의

산출물: 명확한 성공 지표를 갖춘 2-3개의 우선 유스케이스 목록.

Phase 2: Implement — MVP 구축

목표: 핵심 기능에 집중하여 작동하는 프로토타입을 빠르게 출시합니다.

핵심 원칙:

  • 가장 간단한 접근법으로 시작: 규칙 기반 → RAG → 파인튜닝 모델 (필요한 경우에만 업그레이드)
  • 기존 도구 사용: 필요하지 않으면 인프라를 직접 구축하지 않음
  • 첫날부터 측정: 이전 섹션의 가시성 스택으로 MVP를 계측

구현 체크리스트:

1-2주차: 기반
  □ 개발 환경 설정
  □ 데이터 소스 및 접근 방식 정의
  □ 기본 프롬프트 템플릿 작성
  □ 평가 데이터셋 생성 (50-100개 예시)

3-4주차: 핵심 기능
  □ 검색 파이프라인 구현 (RAG인 경우)
  □ Agent 루프 구축 (도구 사용인 경우)
  □ 기존 시스템과 통합 (API, 데이터베이스)
  □ 기본 오류 처리 및 폴백 추가

5-6주차: 품질 및 테스트
  □ 평가 스위트 실행, 프롬프트 반복 개선
  □ 보안 가드레일 추가 (콘텐츠 필터링, PII 탐지)
  □ 5-10명의 내부 사용자와 사용자 테스트
  □ 주요 문제 수정

7-8주차: 배포 준비
  □ 모니터링 및 알림 설정
  □ 일반적인 문제에 대한 런북 문서화
  □ 롤백 계획 준비
  □ 스테이징에 배포, 부하 테스트 실행

Phase 3: Deliver — 비즈니스 영향력 측정

목표: Research에서 정의한 성공 기준에 대한 실제 영향을 정량화합니다.

유스케이스 유형별 핵심 지표:

유스케이스 유형주요 지표부차적 지표
고객 지원 봇티켓 차단율, 해결 시간고객 만족도 (CSAT), 티켓당 비용
코드 리뷰 도우미리뷰 소요 시간, 결함 탈출율개발자 만족도, 코드 품질 점수
콘텐츠 생성콘텐츠 제작 시간, 참여 지표브랜드 일관성 점수, 편집 검토 통과율
데이터 분석 Agent분석 소요 시간, 인사이트 품질이해관계자 만족도, 의사결정 속도

영향력 계산 예시:

# FAQ 봇의 전후 비교
before = {
    "monthly_tickets": 5000,
    "avg_resolution_time_hours": 24,
    "cost_per_ticket": 15,  # 인간 상담원 비용
    "csat_score": 3.2,
}

after = {
    "monthly_tickets": 5000,
    "bot_deflection_rate": 0.65,  # 65%가 봇으로 처리
    "avg_resolution_time_hours": 0.5,  # 봇 처리 시
    "cost_per_ticket_bot": 0.50,  # API 비용
    "cost_per_ticket_human": 15,  # 에스컬레이션 시
    "csat_score": 4.1,
}

# 영향력 계산
bot_handled = after["monthly_tickets"] * after["bot_deflection_rate"]
human_handled = after["monthly_tickets"] - bot_handled

monthly_cost_before = before["monthly_tickets"] * before["cost_per_ticket"]
monthly_cost_after = (bot_handled * after["cost_per_ticket_bot"] +
                      human_handled * after["cost_per_ticket_human"])

monthly_savings = monthly_cost_before - monthly_cost_after
annual_savings = monthly_savings * 12

print(f"이전 월 비용: ${monthly_cost_before:,.0f}")
print(f"이후 월 비용: ${monthly_cost_after:,.0f}")
print(f"연간 절감액: ${annual_savings:,.0f}")
print(f"CSAT 개선: {after['csat_score'] - before['csat_score']:.1f}점")
# 출력:
# 이전 월 비용: $75,000
# 이후 월 비용: $29,875
# 연간 절감액: $541,500
# CSAT 개선: 0.9점

Phase 4: Enhance — 반복 및 확장

목표: 데이터와 피드백을 기반으로 시스템을 지속적으로 개선합니다.

강화 전략:

  1. 프롬프트 최적화: 평가 데이터를 사용하여 프롬프트 개선 (평가 챕터 참조)
  2. RAG 개선: 문서 추가, 청킹 개선, 리랭킹 추가
  3. 모델 업그레이드: 비용 절감을 위해 소형 모델로 증류 (증류 섹션 참조)
  4. 기능 확장: 사용자 피드백에 기반한 새 기능 추가
  5. 프로세스 통합: 기존 워크플로우와의 통합 심화

반복 주기:

매주:
  - 오류 로그 및 사용자 피드백 검토
  - 새 예시로 평가 데이터셋 업데이트
  - 주요 버그 수정

매월:
  - 전체 평가 스위트 실행
  - 지표 트렌드 분석
  - 다음 반복 계획

분기별:
  - 목표 대비 비즈니스 영향력 검토
  - 업그레이드를 위한 모델/제공자 환경 평가
  - 전략적 강화 계획

RIDE 실천: 일반적인 함정

함정회피 방법
Research를 건너뛰고 바로 구현에 착수항상 이해관계자 인터뷰와 유스케이스 점수화로 시작
전달 전 몇 개월간 구축8주 MVP 마감 설정; 측정 가능한 것을 출시
기술 지표만 측정 (지연, 정확도)사전에 비즈니스 지표를 정의; 비용 절감, 시간 절감 추적
AI를 일회성 프로젝트로 취급지속적인 반복을 계획; AI 시스템은 지속적 유지보수가 필요
안전과 컴플라이언스 간과첫날부터 가드레일을 구축; 나중에 덧붙이지 않음

부록: 주요 개념 빠른 참조

개념한 문장 설명
Tokenization텍스트를 모델이 처리 가능한 숫자 ID 시퀀스로 변환
Embedding이산적 ID를 의미 정보를 포함한 밀집 벡터로 매핑
Attention모델이 각 token을 처리할 때 시퀀스의 모든 관련 위치에 주목하게 함
RAG먼저 관련 지식을 검색한 후 모델이 지식을 기반으로 답변 생성
ReAct모델이 사고(Reasoning)와 행동(Action)을 번갈아 순환하게 함
Function Calling모델이 순수 텍스트 응답 대신 구조화된 도구 호출 명령을 출력
MCPAnthropic이 제안한 도구 표준화 프로토콜, 도구 정의와 사용을 디커플링
Plan & Execute먼저 완전한 행동 계획을 수립하고, 검토 통과 후 단계적으로 실행
HyDE먼저 가상 답변을 생성하고, 원래 질문 대신 가상 답변으로 검색
Lost in the Middle모델이 긴 컨텍스트의 중간 부분 정보 처리 능력이 현저히 저하되는 현상
Mixture-of-Agents여러 다른 모델이 동일한 작업을 처리하고 집계자가 최적 결과를 종합
LoRA저랭크 행렬 어댑터 학습을 통한 효율적 파인튜닝
증류대형 모델의 출력을 학습 데이터로 사용하여 소형 모델을 가르침
Skill전문 지식을 재사용 가능한 모듈식 기능 단위로 캡슐화
LLM-as-JudgeLLM을 평가자로 사용하여 출력에 대한 자동 채점
SLO서비스 수준 목표, 예: TTFT, TPOT, 가용성 등

본 가이드는 Agent 엔지니어링의 모범 사례를 기반으로 작성되었으며, LLM 기초부터 프로덕션 배포까지의 완전한 경로를 다룹니다. 기술 분야는 빠르게 발전하므로, 커뮤니티 발전을 지속적으로 주시하고 본 가이드의 방법론을 최신 도구와 함께 사용하시기 바랍니다.

종합 실전 프로젝트: 스마트 Q&A Agent 구축

프로젝트 내러티브: Part 1-5에서 Agent 엔지니어링의 각 구성 요소를 개별적으로 배웠습니다. 이제 이 모든 것을 하나로 합칠 차례입니다. 신입 사원 Q&A Agent를 구축합니다. 간단한 API 호출에서 시작하여 RAG, 도구 호출, 메모리, 스킬, 평가, 배포를 갖춘 프로덕션 등급 Agent로 발전하는 시스템입니다. 각 단계는 실제 엔지니어링 마일스톤에 대응합니다.


6.1 환경 구축 및 기본 대화

시작점

회사에 문제가 있습니다: 신입 사원들이 온보딩, 복리후생, 도구, 프로세스에 대해 같은 질문을 반복해서 합니다. HR은 같은 답변을 반복하느라 많은 시간을 씁니다. 목표: 이러한 질문에 정확하게 답할 수 있는 AI Agent를 구축하는 것입니다.

스테이지 1 목표: 기본 LLM 대화를 작동시킨다.

프로젝트 설정

# 프로젝트 디렉토리 생성
mkdir qa-agent && cd qa-agent

# Python 환경 설정
python -m venv .venv
source .venv/bin/activate

# 의존성 설치
pip install openai python-dotenv
# .env
OPENAI_API_KEY=sk-your-key-here

첫 번째 대화

# chat.py
import os
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

def chat(user_message: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": "You are a helpful onboarding assistant for new employees."},
            {"role": "user", "content": user_message},
        ],
    )
    return response.choices[0].message.content

# 테스트
print(chat("What's the dress code?"))
# 출력: "Our dress code is business casual..."

멀티턴 대화

단일 턴 대화로는 부족합니다. 직원들은 후속 질문을 합니다. 대화 히스토리를 추가합시다:

# multi_turn_chat.py
conversation_history = [
    {"role": "system", "content": "You are a helpful onboarding assistant for new employees at Acme Corp."}
]

def chat_with_history(user_message: str) -> str:
    conversation_history.append({"role": "user", "content": user_message})

    response = client.chat.completions.create(
        model="gpt-4",
        messages=conversation_history,
    )
    assistant_reply = response.choices[0].message.content
    conversation_history.append({"role": "assistant", "content": assistant_reply})

    return assistant_reply

# 멀티턴 테스트
print(chat_with_history("What's the dress code?"))
print(chat_with_history("What about on Fridays?"))  # 후속 질문
print(chat_with_history("And for client meetings?"))  # 추가 후속 질문

토큰 예산 관리

# token_tracker.py
import tiktoken

def count_tokens(messages: list, model: str = "gpt-4") -> int:
    encoding = tiktoken.encoding_for_model(model)
    total = 0
    for msg in messages:
        total += len(encoding.encode(msg["content"])) + 4  # 메시지당 오버헤드
    return total

def chat_within_budget(user_message: str, max_tokens: int = 4000) -> str:
    conversation_history.append({"role": "user", "content": user_message})

    # 예산 초과 시 히스토리 정리
    while count_tokens(conversation_history) > max_tokens:
        # 가장 오래된 비 system 메시지 쌍 제거
        non_system = [m for m in conversation_history if m["role"] != "system"]
        if len(non_system) >= 2:
            conversation_history.remove(non_system[0])
            conversation_history.remove(non_system[1])
        else:
            break

    response = client.chat.completions.create(
        model="gpt-4",
        messages=conversation_history,
    )
    reply = response.choices[0].message.content
    conversation_history.append({"role": "assistant", "content": reply})
    return reply

스테이지 1 완료: 기본 멀티턴 챗봇이 완성되었습니다. 하지만 LLM이 학습한 내용만 알고 있으며, 회사의 구체적인 정책은 알지 못합니다.


6.2 RAG: 기업 지식 연결

문제

print(chat_with_history("What's the parental leave policy?"))
# 출력: "I don't have specific information about Acme Corp's parental leave policy..."

LLM은 회사의 내부 문서를 알지 못합니다. Retrieval-Augmented Generation (RAG) 이 필요합니다.

1단계: 지식 베이스 준비

# knowledge_base.py
import os
from pathlib import Path

# 샘플 회사 문서 (실제로는 문서 저장소에서 로드)
documents = [
    {
        "id": "doc_001",
        "title": "Employee Handbook - Leave Policies",
        "content": """Acme Corp provides the following leave benefits:
- Annual Leave: 20 days per year, prorated for partial years
- Sick Leave: 10 days per year
- Parental Leave: 16 weeks paid leave for primary caregivers, 8 weeks for secondary caregivers
- Bereavement Leave: 5 days for immediate family members
All leave requests must be submitted through the HR portal at least 2 weeks in advance, except for sick leave which can be reported same-day."""
    },
    {
        "id": "doc_002",
        "title": "Employee Handbook - Dress Code",
        "content": """Acme Corp Dress Code:
- Regular days: Business casual (collared shirts, slacks, closed-toe shoes)
- Casual Fridays: Jeans and casual wear allowed, but no flip-flops or gym clothes
- Client meetings: Business formal (suit and tie for men, business suit or dress for women)
- Remote work days: No dress code, but camera-on for meetings
When in doubt, err on the side of being more formal."""
    },
    # ... 추가 문서
]

2단계: 검색 파이프라인 구축

# rag_pipeline.py
from openai import OpenAI
import numpy as np

client = OpenAI()

def get_embedding(text: str) -> list[float]:
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=text,
    )
    return response.data[0].embedding

def build_vector_store(docs: list) -> dict:
    """모든 문서를 임베딩하여 간단한 벡터 인덱스로 저장"""
    vector_store = {}
    for doc in docs:
        embedding = get_embedding(doc["content"])
        vector_store[doc["id"]] = {
            "embedding": embedding,
            "content": doc["content"],
            "title": doc["title"],
        }
    return vector_store

def search(query: str, vector_store: dict, top_k: int = 3) -> list[dict]:
    """가장 관련성 높은 top-k 문서를 검색"""
    query_embedding = get_embedding(query)

    results = []
    for doc_id, doc_data in vector_store.items():
        similarity = cosine_similarity(query_embedding, doc_data["embedding"])
        results.append({
            "doc_id": doc_id,
            "title": doc_data["title"],
            "content": doc_data["content"],
            "score": similarity,
        })

    results.sort(key=lambda x: x["score"], reverse=True)
    return results[:top_k]

def cosine_similarity(a: list, b: list) -> float:
    a_arr = np.array(a)
    b_arr = np.array(b)
    return np.dot(a_arr, b_arr) / (np.linalg.norm(a_arr) * np.linalg.norm(b_arr))

3단계: 검색된 컨텍스트로 생성 강화

# rag_chat.py
def rag_chat(user_message: str, vector_store: dict) -> str:
    # 1단계: 관련 문서 검색
    relevant_docs = search(user_message, vector_store, top_k=3)

    # 2단계: 검색된 문서에서 컨텍스트 구성
    context = "\n\n".join([
        f"[Source: {doc['title']}]\n{doc['content']}"
        for doc in relevant_docs
    ])

    # 3단계: 컨텍스트를 포함하여 답변 생성
    system_prompt = f"""You are a helpful onboarding assistant for Acme Corp.
Use the following company documents to answer questions. If the answer is not in the documents, say so honestly.
Always cite which document you're referencing.

--- Company Documents ---
{context}
--- End Documents ---"""

    response = client.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_message},
        ],
    )
    return response.choices[0].message.content

# 테스트
vector_store = build_vector_store(documents)
print(rag_chat("What's the parental leave policy?", vector_store))
# 출력: "According to the Employee Handbook - Leave Policies, Acme Corp provides:
# - 16 weeks paid leave for primary caregivers
# - 8 weeks for secondary caregivers
# All requests must be submitted through the HR portal at least 2 weeks in advance."

스테이지 2 완료: 봇이 이제 회사 문서에서 답변할 수 있습니다. 하지만 말하기만 할 뿐, 휴가 신청 제출이나 캘린더 가용 상황 확인 같은 행동은 할 수 없습니다.


6.3 Agent 도구 호출 및 플래닝

문제

직원이 이렇게 묻습니다: “다음 주 월요일부터 수요일까지 휴가 신청을 대신 제출해 주시겠어요?”

봇은 정책을 설명할 수 있지만, 실제로 신청을 제출할 수는 없습니다. 도구 호출이 필요합니다.

1단계: 도구 정의

# tools.py
import json

tools = [
    {
        "type": "function",
        "function": {
            "name": "submit_leave_request",
            "description": "Submit a leave request to the HR system",
            "parameters": {
                "type": "object",
                "properties": {
                    "leave_type": {
                        "type": "string",
                        "enum": ["annual", "sick", "parental", "bereavement"],
                        "description": "Type of leave"
                    },
                    "start_date": {"type": "string", "description": "Start date (YYYY-MM-DD)"},
                    "end_date": {"type": "string", "description": "End date (YYYY-MM-DD)"},
                    "reason": {"type": "string", "description": "Reason for leave"},
                },
                "required": ["leave_type", "start_date", "end_date"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "check_leave_balance",
            "description": "Check remaining leave balance for an employee",
            "parameters": {
                "type": "object",
                "properties": {
                    "employee_id": {"type": "string", "description": "Employee ID"},
                    "leave_type": {"type": "string", "description": "Type of leave to check"},
                },
                "required": ["employee_id"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "search_knowledge_base",
            "description": "Search the company knowledge base for policies and procedures",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "Search query"},
                },
                "required": ["query"],
            },
        },
    },
]

# 모의 구현
def submit_leave_request(leave_type: str, start_date: str, end_date: str, reason: str = "") -> dict:
    # 프로덕션에서는 HR API를 호출
    return {"status": "submitted", "request_id": "LR-2024-001", "leave_type": leave_type, "dates": f"{start_date} to {end_date}"}

def check_leave_balance(employee_id: str, leave_type: str = None) -> dict:
    # 모의 데이터
    balances = {"annual": 15, "sick": 8, "parental": 0, "bereavement": 5}
    if leave_type:
        return {"employee_id": employee_id, "leave_type": leave_type, "remaining_days": balances.get(leave_type, 0)}
    return {"employee_id": employee_id, "balances": balances}

def search_knowledge_base(query: str) -> str:
    # 이전 섹션의 RAG 검색 재사용
    results = search(query, vector_store, top_k=2)
    return "\n\n".join([f"[{r['title']}]: {r['content'][:200]}..." for r in results])

TOOL_IMPLEMENTATIONS = {
    "submit_leave_request": submit_leave_request,
    "check_leave_balance": check_leave_balance,
    "search_knowledge_base": search_knowledge_base,
}

2단계: ReAct 루프 구현

# agent.py
def agent_chat(user_message: str, max_iterations: int = 5) -> str:
    messages = [
        {"role": "system", "content": """You are an onboarding assistant for Acme Corp.
You can use tools to help answer questions and perform actions.
Always think step by step. If you need information, use search_knowledge_base.
If the user wants to perform an action, use the appropriate tool.
After getting tool results, provide a clear summary to the user."""},
        {"role": "user", "content": user_message},
    ]

    for i in range(max_iterations):
        response = client.chat.completions.create(
            model="gpt-4",
            messages=messages,
            tools=tools,
            tool_choice="auto",
        )

        message = response.choices[0].message

        # 도구 호출이 없으면 최종 답변 반환
        if not message.tool_calls:
            return message.content

        # 도구 호출 처리
        messages.append(message)  # 도구 호출이 포함된 어시스턴트 메시지 추가

        for tool_call in message.tool_calls:
            func_name = tool_call.function.name
            func_args = json.loads(tool_call.function.arguments)

            print(f"  [Tool Call] {func_name}({func_args})")

            # 도구 실행
            result = TOOL_IMPLEMENTATIONS[func_name](**func_args)

            # 도구 결과를 메시지에 추가
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": json.dumps(result),
            })

    return "I wasn't able to complete the task within the allowed steps."

# 테스트
print(agent_chat("How many annual leave days do I have left? My employee ID is EMP-042."))
# 출력:
#   [Tool Call] check_leave_balance({'employee_id': 'EMP-042', 'leave_type': 'annual'})
# "You have 15 annual leave days remaining."

print(agent_chat("Can you submit annual leave for me from Dec 23 to Dec 27?"))
# 출력:
#   [Tool Call] submit_leave_request({'leave_type': 'annual', 'start_date': '2024-12-23', 'end_date': '2024-12-27'})
# "Your leave request has been submitted! Request ID: LR-2024-001, covering Dec 23-27, 2024."

3단계: 복잡한 작업을 위한 플래닝 추가

다단계 작업의 경우, Agent는 실행 전에 계획을 세워야 합니다:

# planner.py
def plan_and_execute(user_request: str) -> str:
    # 1단계: 계획 생성
    plan_response = client.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": """You are a task planner. Break down the user's request into concrete steps.
For each step, specify which tool to use and what arguments to pass.
Output a JSON array of steps."""},
            {"role": "user", "content": user_request},
        ],
    )

    plan_text = plan_response.choices[0].message.content
    print(f"Plan: {plan_text}")

    # 2단계: Agent를 사용하여 각 단계 실행
    # (프로덕션에서는 계획을 파싱하여 검증을 포함해 단계별로 실행)
    return agent_chat(user_request)

스테이지 3 완료: 봇이 이제 도구를 호출하고 작업을 수행할 수 있습니다. 하지만 매번 대화가 처음부터 시작되며, 이전 상호작용을 기억하지 못합니다.


6.4 메모리 & 스킬: Agent를 시간이 지날수록 똑똑하게

문제

한 직원이 3번의 별도 대화를 나눴습니다:

  1. “다음 주 월요일에 입사하는데, 뭘 가져가야 하나요?”
  2. “감사합니다! 참고로, 제 사번은 EMP-042입니다.”
  3. “제 휴가 잔여 일수를 확인해 주시겠어요?”

Agent는 사번을 전혀 모릅니다. 각 대화가 고립되어 있습니다.

1단계: 단기 메모리 (대화 버퍼)

# memory.py
from dataclasses import dataclass, field

@dataclass
class ConversationBuffer:
    max_tokens: int = 4000
    messages: list = field(default_factory=list)
    summary: str = ""

    def add_message(self, role: str, content: str):
        self.messages.append({"role": role, "content": content})
        self._trim_if_needed()

    def _trim_if_needed(self):
        token_count = count_tokens(self.messages)
        if token_count > self.max_tokens:
            # 오래된 메시지를 요약하고 최근 메시지를 유지
            old_messages = self.messages[:len(self.messages)//2]
            self.summary = self._summarize(old_messages)
            self.messages = self.messages[len(self.messages)//2:]

    def _summarize(self, messages: list) -> str:
        response = client.chat.completions.create(
            model="gpt-4",
            messages=[
                {"role": "system", "content": "Summarize this conversation in 2-3 sentences, focusing on key facts and decisions."},
                *messages,
            ],
        )
        return response.choices[0].message.content

    def get_context(self) -> list:
        context = []
        if self.summary:
            context.append({"role": "system", "content": f"Previous conversation summary: {self.summary}"})
        context.extend(self.messages)
        return context

2단계: 장기 메모리 (사용자 프로필 저장소)

# long_term_memory.py
import json
from pathlib import Path

USER_PROFILES_DIR = Path("user_profiles")
USER_PROFILES_DIR.mkdir(exist_ok=True)

def save_user_fact(user_id: str, fact: str):
    """사용자에 대한 사실을 장기 프로필에 저장"""
    profile_path = USER_PROFILES_DIR / f"{user_id}.json"
    profile = {}
    if profile_path.exists():
        profile = json.loads(profile_path.read_text())

    if "facts" not in profile:
        profile["facts"] = []
    profile["facts"].append(fact)
    profile_path.write_text(json.dumps(profile, indent=2))

def get_user_facts(user_id: str) -> list[str]:
    """사용자에 대해 알려진 모든 사실을 검색"""
    profile_path = USER_PROFILES_DIR / f"{user_id}.json"
    if not profile_path.exists():
        return []
    profile = json.loads(profile_path.read_text())
    return profile.get("facts", [])

def extract_and_save_facts(user_id: str, messages: list):
    """LLM을 사용하여 대화에서 중요한 사실을 추출하고 저장"""
    response = client.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": """Extract important facts about the user from this conversation.
Focus on: name, employee ID, department, preferences, upcoming events, action items.
Output a JSON array of fact strings. If no important facts, output []."""},
            *messages,
        ],
    )
    facts = json.loads(response.choices[0].message.content)
    for fact in facts:
        save_user_fact(user_id, fact)

3단계: 스킬 — 재사용 가능한 워크플로

# skills/onboarding_guide.md
"""
---
name: onboarding_guide
description: Guide new employees through their first week
triggers: new employee, first day, onboarding, getting started
---

# Onboarding Guide Skill

## Day 1 Checklist
1. Verify IT setup (laptop, accounts, VPN)
2. Introduce to team via Slack
3. Share key documents: handbook, org chart, tools guide
4. Schedule 1:1 with manager for week overview

## Week 1 Priorities
- Complete mandatory training modules (compliance, security)
- Set up development environment (if engineer)
- Attend team standup meetings
- Read team's project documentation

## Common First-Week Questions
- "How do I submit expenses?" → Use Concur, submit within 30 days
- "What's the wifi password?" → Provided on IT setup sheet
- "Who do I talk about benefits?" → HR portal or email [email protected]
"""

# skill_loader.py
from pathlib import Path

def load_skill(skill_name: str) -> str:
    skill_path = Path(f"skills/{skill_name}.md")
    if not skill_path.exists():
        return ""
    return skill_path.read_text()

def find_relevant_skill(query: str, available_skills: list[str]) -> str | None:
    """쿼리에 기반하여 활성화할 스킬을 판단"""
    skills_info = []
    for skill_name in available_skills:
        content = load_skill(skill_name)
        # 프론트매터에서 설명 추출
        skills_info.append(f"- {skill_name}: {content[:200]}")

    response = client.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": "Given the user query, which skill should be activated? Output just the skill name, or 'none' if no skill is relevant."},
            {"role": "user", "content": f"Query: {query}\n\nAvailable skills:\n" + "\n".join(skills_info)},
        ],
    )
    result = response.choices[0].message.content.strip()
    return result if result != "none" else None

통합: 메모리 인식 Agent

# memory_agent.py
def memory_aware_agent_chat(user_id: str, user_message: str) -> str:
    # 사용자의 장기 메모리 로드
    user_facts = get_user_facts(user_id)
    facts_context = "\n".join(user_facts) if user_facts else "No previous facts known."

    # 스킬을 활성화해야 하는지 확인
    skill_name = find_relevant_skill(user_message, ["onboarding_guide"])
    skill_context = load_skill(skill_name) if skill_name else ""

    system_prompt = f"""You are an onboarding assistant for Acme Corp.

Known facts about this user:
{facts_context}

{f'Active skill: {skill_context}' if skill_context else ''}

Use the user's known facts to personalize responses.
If the user shares new important information, note it for future reference."""

    messages = [{"role": "system", "content": system_prompt}]
    messages.extend(conversation_buffer.get_context())
    messages.append({"role": "user", "content": user_message})

    response = client.chat.completions.create(
        model="gpt-4",
        messages=messages,
        tools=tools,
        tool_choice="auto",
    )

    reply = response.choices[0].message.content

    # 메모리 업데이트
    conversation_buffer.add_message("user", user_message)
    conversation_buffer.add_message("assistant", reply)
    extract_and_save_facts(user_id, messages)

    return reply

스테이지 4 완료: Agent가 이제 사용자를 기억하고, 스킬을 활성화하고, 개인화된 답변을 제공합니다. 하지만 실제로 좋은 답변을 제공하고 있는지 어떻게 알 수 있을까요?


6.5 평가 및 반복 최적화

문제

많은 것을 구축했지만, 얼마나 잘 작동하는지 알 수 없습니다. 정확한 답변을 제공하고 있나요? 환각을 일으키고 있진 않나요? 중요한 컨텍스트를 놓치고 있진 않나요?

1단계: 평가 데이터셋 구축

# eval_dataset.py
eval_cases = [
    {
        "id": "eval_001",
        "input": "What's the parental leave policy?",
        "expected_output": "16 weeks for primary caregivers, 8 weeks for secondary caregivers",
        "required_sources": ["Employee Handbook - Leave Policies"],
        "category": "factual_recall",
    },
    {
        "id": "eval_002",
        "input": "How do I submit a leave request?",
        "expected_output": "Through the HR portal, at least 2 weeks in advance",
        "required_sources": ["Employee Handbook - Leave Policies"],
        "category": "procedural",
    },
    {
        "id": "eval_003",
        "input": "What should I wear to a client meeting?",
        "expected_output": "Business formal: suit and tie for men, business suit or dress for women",
        "required_sources": ["Employee Handbook - Dress Code"],
        "category": "factual_recall",
    },
    {
        "id": "eval_004",
        "input": "Can you submit a sick leave request for me today?",
        "expected_behavior": "Should call submit_leave_request tool with leave_type='sick'",
        "category": "tool_use",
    },
    {
        "id": "eval_005",
        "input": "What's the meaning of life?",
        "expected_behavior": "Should politely decline or redirect to onboarding topics",
        "category": "boundary",
    },
]

2단계: 자동 평가

# evaluator.py
def evaluate_factual_recall(agent_fn, case: dict) -> dict:
    """Agent가 문서에서 사실을 올바르게 기억했는지 평가"""
    response = agent_fn(case["input"])

    # 체크 1: 답변에 예상 정보가 포함되어 있는가?
    expected_keywords = case["expected_output"].lower().split()
    response_lower = response.lower()
    keyword_hits = sum(1 for kw in expected_keywords if kw in response_lower)
    recall_score = keyword_hits / len(expected_keywords)

    # 체크 2: 올바른 소스를 인용했는가?
    source_cited = any(src.lower() in response.lower() for src in case["required_sources"])

    return {
        "case_id": case["id"],
        "category": case["category"],
        "recall_score": recall_score,
        "source_cited": source_cited,
        "passed": recall_score > 0.7 and source_cited,
        "response": response[:200],
    }

def evaluate_tool_use(agent_fn, case: dict) -> dict:
    """Agent가 도구를 올바르게 사용했는지 평가"""
    # 실행 중 도구 호출 캡처
    tool_calls_made = []
    original_implementations = {}

    # 도구 호출을 캡처하기 위해 래핑
    for name, impl in TOOL_IMPLEMENTATIONS.items():
        original_implementations[name] = impl
        def make_wrapper(n):
            def wrapper(*args, **kwargs):
                tool_calls_made.append({"name": n, "args": kwargs})
                return original_implementations[n](*args, **kwargs)
            return wrapper
        TOOL_IMPLEMENTATIONS[name] = make_wrapper(name)

    try:
        response = agent_fn(case["input"])
        expected_tool = case["expected_behavior"].split("'")[1] if "'" in case["expected_behavior"] else ""
        correct_tool_called = any(tc["name"] == expected_tool for tc in tool_calls_made)

        return {
            "case_id": case["id"],
            "category": case["category"],
            "correct_tool_called": correct_tool_called,
            "tools_called": [tc["name"] for tc in tool_calls_made],
            "passed": correct_tool_called,
        }
    finally:
        # 원래 구현 복원
        for name, impl in original_implementations.items():
            TOOL_IMPLEMENTATIONS[name] = impl

def run_evaluation(agent_fn) -> dict:
    results = []
    for case in eval_cases:
        if case["category"] in ("factual_recall", "procedural"):
            results.append(evaluate_factual_recall(agent_fn, case))
        elif case["category"] == "tool_use":
            results.append(evaluate_tool_use(agent_fn, case))

    total = len(results)
    passed = sum(1 for r in results if r["passed"])

    return {
        "total_cases": total,
        "passed": passed,
        "pass_rate": passed / total if total > 0 else 0,
        "results": results,
    }

3단계: LLM-as-Judge

# llm_judge.py
def llm_judge_evaluation(case: dict, agent_response: str) -> dict:
    """GPT-4를 심사자로 사용하여 답변 품질 평가"""
    judge_prompt = f"""You are an expert evaluator for an onboarding assistant.

User Question: {case['input']}
Expected Answer: {case['expected_output']}
Agent Response: {agent_response}

Rate the response on these dimensions (1-5 scale):
1. Accuracy: Is the information correct?
2. Completeness: Does it cover all key points?
3. Helpfulness: Would a new employee find this useful?
4. Tone: Is it professional and friendly?

Output a JSON object with scores and a brief explanation."""

    response = client.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": judge_prompt},
            {"role": "user", "content": "Evaluate the response."},
        ],
    )

    return json.loads(response.choices[0].message.content)

4단계: 결과 기반 반복 개선

# iteration.py
def run_eval_improve_loop(max_iterations: int = 3):
    for iteration in range(max_iterations):
        print(f"\n=== Iteration {iteration + 1} ===")

        # 평가 실행
        results = run_evaluation(memory_aware_agent_chat)
        print(f"Pass rate: {results['pass_rate']:.0%}")

        # 실패 사례 분석
        failures = [r for r in results["results"] if not r["passed"]]
        for f in failures:
            print(f"  FAIL [{f['case_id']}]: {f.get('response', '')[:100]}")

        if results["pass_rate"] >= 0.9:
            print("Target reached!")
            break

        # 반복: 프롬프트 개선, 문서 추가, 도구 수정
        print("  → Improving system prompt and adding more documents...")
        # (실제로는 프롬프트 수정, 문서 추가 등을 수행)

스테이지 5 완료: 이제 측정 가능하고 반복적으로 개선되는 Agent 시스템을 갖추게 되었습니다. 마지막 단계: 프로덕션 준비를 하는 것입니다.


6.6 증류 및 배포

문제

Agent가 GPT-4로 훌륭하게 작동하지만, 규모가 커지면 비용이 지속 불가능합니다:

  • 직원 500명 × 하루 10쿼리 × 30일 = 월 150,000쿼리
  • 쿼리당 약 2000 토큰 (입력 + 출력), 월 3억 토큰
  • GPT-4 비용: 약 $3,000/월

특정 도메인에서 동등한 성능을 발휘하는 더 저렴한 모델이 필요합니다.

1단계: 작은 모델로 증류

# distillation.py
# GPT-4를 교사 모델로 사용하여 학습 데이터 생성
def generate_training_data(n_examples: int = 1000) -> list[dict]:
    training_data = []

    for case in eval_cases * (n_examples // len(eval_cases)):
        # 변형을 추가하여 더 다양한 데이터 생성
        variations = [
            case["input"],
            f"Hey, {case['input'].lower()}",
            f"Quick question: {case['input']}",
        ]

        for variant in variations:
            response = client.chat.completions.create(
                model="gpt-4",
                messages=[
                    {"role": "system", "content": "You are an onboarding assistant for Acme Corp..."},
                    {"role": "user", "content": variant},
                ],
            )
            training_data.append({
                "input": variant,
                "output": response.choices[0].message.content,
            })

    return training_data

# 학습 데이터를 사용하여 소규모 모델 (예: Llama 2 7B) 파인튜닝
# (상세한 파인튜닝 코드는 Production 챕터 참조)

2단계: 모니터링 설정

# monitoring.py
from prometheus_client import Counter, Histogram, start_http_server

request_counter = Counter('qa_agent_requests_total', 'Total requests', ['intent', 'status'])
request_latency = Histogram('qa_agent_latency_seconds', 'Request latency')
token_counter = Counter('qa_agent_tokens_total', 'Token usage', ['type'])

# Prometheus 메트릭 서버 시작
start_http_server(8000)

def tracked_agent_chat(user_id: str, user_message: str) -> str:
    import time
    start = time.time()

    try:
        response = memory_aware_agent_chat(user_id, user_message)
        request_counter.labels(intent="general", status="success").inc()
        return response
    except Exception as e:
        request_counter.labels(intent="general", status="error").inc()
        raise
    finally:
        request_latency.observe(time.time() - start)

3단계: 카나리 릴리스로 배포

# deployment.py
def route_request(user_id: str, user_message: str) -> str:
    """트래픽의 5%를 새로운 증류 모델로 라우팅"""
    import hashlib
    bucket = int(hashlib.md5(user_id.encode()).hexdigest(), 16) % 100

    if bucket < 5:
        # 카나리: 증류 모델 사용
        return distilled_model_chat(user_id, user_message)
    else:
        # 안정 버전: GPT-4 사용
        return memory_aware_agent_chat(user_id, user_message)

최종 아키텍처

┌─────────────────────────────────────────────────────────┐
│                    Q&A Agent System                       │
│                                                          │
│  User ──▶ Router ──▶ Agent Core ──▶ LLM (GPT-4/7B)     │
│              │            │                               │
│              │            ├── RAG Pipeline (Vector DB)    │
│              │            ├── Tool Registry (HR API)      │
│              │            ├── Memory Store (User Profile) │
│              │            └── Skill Loader (Onboarding)   │
│              │                                            │
│              └── Monitoring (Prometheus + Grafana)        │
│                                                          │
│  Canary: 5% → Distilled Model (7B, fine-tuned)          │
│  Stable: 95% → GPT-4                                    │
└─────────────────────────────────────────────────────────┘

구축한 것

단 하나의 API 호출에서 시작하여 단계적으로 다음을 구축했습니다:

스테이지기능적용한 지식
6.1기본 대화LLM 기초 (토큰, 컨텍스트 윈도우)
6.2문서 기반 답변RAG (임베딩, 청킹, 검색)
6.3도구 사용 및 플래닝Agent 코어 (Function Calling, ReAct, MCP)
6.4메모리 및 개인화된 답변메모리 & 스킬 시스템
6.5측정 가능한 품질평가 프레임워크
6.6비용 효율적인 프로덕션 시스템증류, 모니터링, 카나리 릴리스

이것이 완전한 Agent 엔지니어링 경로입니다 — “Hello World”에서 프로덕션까지.