AI 에이전트를 위한 컨텍스트 엔지니어링 실전 가이드: 메모리, RAG, 도구 설계부터 프로덕션까지

프롬프트만으로는 부족합니다. AI 에이전트의 성패를 좌우하는 컨텍스트 엔지니어링의 6가지 핵심 원칙과 Python 구현 코드를 다루는 실전 가이드입니다.

2026년 현재, AI 에이전트를 구축하는 개발자들 사이에서 가장 뜨거운 화두는 더 이상 "프롬프트를 어떻게 잘 쓸까"가 아닙니다. "모델이 추론하기 전에 어떤 정보를 어떤 형태로 제공할 것인가"가 핵심 질문으로 떠올랐죠. 이것이 바로 컨텍스트 엔지니어링(Context Engineering)입니다.

솔직히, 프롬프트 엔지니어링만으로 프로덕션급 AI 에이전트를 만들어본 분이라면 이 한계를 뼈저리게 느끼셨을 겁니다. 테스트할 때는 완벽했던 프롬프트가 실제 사용자 앞에서는 엉뚱한 답을 내놓고, 대화가 좀만 길어지면 에이전트가 아까 한 말도 까먹어 버립니다. 도구 호출도 꼬이고요. 이런 문제의 근본 원인은 프롬프트가 아닙니다. 컨텍스트 설계의 부재입니다.

Andrej Karpathy는 LLM을 새로운 종류의 운영체제에 비유한 바 있는데요. LLM이 CPU라면 컨텍스트 윈도우는 RAM이라는 겁니다. 운영체제가 RAM에 무엇을 올릴지 관리하듯, 컨텍스트 엔지니어링은 LLM의 제한된 작업 메모리에 무엇을, 언제, 어떤 형태로 올릴지를 체계적으로 설계하는 기술이죠.

이 글에서는 컨텍스트 엔지니어링의 핵심 원리부터 시스템 프롬프트, 메모리, RAG, 도구 설계, 그리고 실제 Python 코드까지 다룹니다. 개념 나열에 그치지 않고 바로 프로덕션에 적용할 수 있는 실전 패턴과 코드에 집중했으니, 끝까지 따라와 주세요.

컨텍스트 엔지니어링이란 무엇인가

컨텍스트 엔지니어링은 LLM이 응답을 생성하기 전에 접하는 모든 정보를 설계, 관리, 최적화하는 시스템 수준의 엔지니어링 분야입니다. 단순히 프롬프트 문구를 다듬는 게 아니라, 모델이 보는 전체 정보 환경을 아키텍처 수준에서 설계하는 것이죠.

컨텍스트를 구성하는 핵심 요소는 다음과 같습니다:

  • 시스템 프롬프트: 에이전트의 역할, 규칙, 제약 조건을 정의하는 지침
  • 단기 메모리: 현재 세션의 대화 기록과 상태 정보
  • 장기 메모리: 세션 간 지속되는 사용자 선호도, 과거 상호작용 요약
  • 검색된 지식(RAG): 외부 문서, 데이터베이스에서 실시간으로 가져온 정보
  • 도구 정의와 결과: 에이전트가 사용할 수 있는 도구의 스키마와 실행 결과

프롬프트 엔지니어링과의 핵심 차이

프롬프트 엔지니어링이 "어떻게 질문할 것인가"에 집중한다면, 컨텍스트 엔지니어링은 "질문할 때 모델이 무엇을 알고 있는가"를 설계합니다. 프롬프트 엔지니어링은 컨텍스트 엔지니어링의 하위 요소에 해당합니다.

구분프롬프트 엔지니어링컨텍스트 엔지니어링
초점입력 텍스트의 문구 최적화전체 정보 환경 아키텍처 설계
범위단일 입출력 쌍메모리, RAG, 도구, 상태 포함
확장성엣지 케이스 증가 시 한계규모에 맞게 설계 가능
일관성동일 프롬프트도 결과 변동구조화된 컨텍스트로 일관성 확보
적용 대상단발성 질의, 코드 생성AI 에이전트, 멀티스텝 자동화

핵심을 한 문장으로 정리하면 이렇습니다. 최고의 프롬프트도 잘못된 컨텍스트에서는 실패하지만, 보통 수준의 프롬프트도 잘 설계된 컨텍스트에서는 성공합니다.

원칙 1: 고신호 토큰 최소화 — 주의력 예산 관리

LLM의 컨텍스트 윈도우는 128K~1M+ 토큰을 지원하지만, 많으면 많을수록 좋은 건 아닙니다. 트랜스포머 아키텍처의 어텐션 메커니즘 특성상, 컨텍스트가 길어질수록 각 토큰에 할당되는 주의력이 분산됩니다. Anthropic에서는 이걸 "주의력 예산(Attention Budget)"이라고 부릅니다.

결국, 좋은 컨텍스트 엔지니어링의 핵심은 원하는 결과의 가능성을 최대화하는 가장 작은 고신호 토큰 세트를 찾는 겁니다. 토큰을 잔뜩 넣는 게 답이 아니라는 뜻이죠.

컨텍스트 윈도우의 세 가지 실패 모드

컨텍스트 설계에서 주의해야 할 실패 패턴이 세 가지 있습니다:

  • Context Poisoning(컨텍스트 오염): 환각이나 잘못된 정보가 컨텍스트에 섞여 이후 추론 전체를 오염시키는 현상. 한 번 들어가면 연쇄적으로 영향을 미칩니다
  • Context Distraction(컨텍스트 산만): 관련 없는 정보가 너무 많아서 모델이 정작 중요한 걸 놓치는 현상
  • Context Confusion(컨텍스트 혼란): 서로 모순되는 정보가 동시에 존재해서 모델이 갈팡질팡하는 현상

다음은 토큰 예산을 측정하고 관리하는 실전 코드입니다. 실무에서 바로 가져다 쓸 수 있도록 구성했습니다:

import tiktoken

class ContextBudgetManager:
    """컨텍스트 윈도우의 토큰 예산을 관리하는 유틸리티"""

    def __init__(self, max_tokens: int = 128000, model: str = "gpt-4o"):
        self.max_tokens = max_tokens
        self.encoder = tiktoken.encoding_for_model(model)
        self.allocations = {
            "system_prompt": 0.15,    # 15% - 시스템 프롬프트
            "tools": 0.10,            # 10% - 도구 정의
            "memory": 0.15,           # 15% - 장기 메모리
            "rag_context": 0.25,      # 25% - RAG 검색 결과
            "conversation": 0.25,     # 25% - 대화 기록
            "output_reserve": 0.10,   # 10% - 출력 예약
        }

    def count_tokens(self, text: str) -> int:
        return len(self.encoder.encode(text))

    def get_budget(self, component: str) -> int:
        return int(self.max_tokens * self.allocations[component])

    def check_usage(self, components: dict[str, str]) -> dict:
        """각 컴포넌트의 토큰 사용량을 분석"""
        report = {}
        for name, content in components.items():
            used = self.count_tokens(content)
            budget = self.get_budget(name)
            report[name] = {
                "used": used,
                "budget": budget,
                "utilization": f"{(used / budget) * 100:.1f}%",
                "over_budget": used > budget,
            }
        return report

원칙 2: 시스템 프롬프트 설계 — 적절한 추상화 수준 찾기

시스템 프롬프트는 에이전트의 행동을 정의하는 가장 기본적인 컨텍스트 요소입니다. Anthropic의 연구에 따르면, 시스템 프롬프트 설계에서 가장 흔한 실수는 두 가지 극단에 빠지는 건데요:

  • 과도하게 구체적: 모든 엣지 케이스를 하드코딩하려는 시도 → 취약하고 유지보수 비용이 폭증
  • 과도하게 추상적: 모호한 지침만 제공 → 모델이 너무 많은 걸 추측해야 함

효과적인 시스템 프롬프트는 "적절한 고도(right altitude)"에서 작성해야 합니다. 비행기에 비유하면, 너무 낮으면 나무에 걸리고 너무 높으면 아무것도 안 보이는 거죠:

# 나쁜 예: 과도하게 구체적
SYSTEM_PROMPT_BAD = """
사용자가 "안녕"이라고 하면 "안녕하세요! 무엇을 도와드릴까요?"라고 답하세요.
사용자가 "주문 확인"이라고 하면 order_lookup 함수를 호출하세요.
사용자가 "환불"이라고 하면 먼저 주문번호를 물어보고...
(50개 이상의 if-then 규칙)
"""

# 좋은 예: 적절한 추상화 수준
SYSTEM_PROMPT_GOOD = """
당신은 전자상거래 고객 지원 에이전트입니다.

## 역할과 목표
- 고객의 주문, 배송, 환불 관련 문의를 처리합니다
- 항상 정확한 정보를 제공하며, 확실하지 않은 정보는 추측하지 않습니다
- 고객의 감정에 공감하면서도 효율적으로 문제를 해결합니다

## 도구 사용 원칙
- 고객 정보가 필요한 경우 반드시 도구를 통해 조회합니다
- 주문 변경이나 환불은 고객 확인 후에만 진행합니다
- 권한 밖의 요청은 담당 부서로 에스컬레이션합니다

## 응답 지침
- 한국어로 응답하며, 존댓말을 사용합니다
- 기술 용어는 쉬운 말로 풀어서 설명합니다
"""

Few-Shot 예제의 전략적 활용

시스템 프롬프트에 규칙을 줄줄이 나열하는 것보다 대표적인 예제 2~3개를 포함하는 편이 훨씬 효과적입니다. 다만, 모든 엣지 케이스를 예제로 커버하겠다는 유혹은 참아야 합니다. 핵심은 다양하고 대표성 있는 카노니컬 예제를 고르는 것입니다.

원칙 3: 메모리 시스템 설계 — 단기, 장기, 에이전틱 메모리

메모리는 에이전트를 단순한 챗봇에서 진짜 업무 도구로 탈바꿈시키는 핵심 요소입니다. 메모리 없는 에이전트는 매 턴마다 기억상실에 빠진 것과 같습니다. (실제로 이렇게 동작하는 에이전트를 써보면 정말 답답합니다.)

단기 메모리: 대화 기록 관리

단기 메모리는 현재 세션의 대화 기록입니다. 문제는 대화가 길어질수록 컨텍스트 윈도우를 빠르게 먹어치운다는 점이죠. 실전에서는 보통 두 가지 전략을 조합합니다:

from dataclasses import dataclass, field
from datetime import datetime


@dataclass
class Message:
    role: str
    content: str
    timestamp: datetime = field(default_factory=datetime.utcnow)
    token_count: int = 0


class ConversationMemory:
    """윈도잉 + 요약을 결합한 단기 메모리 관리"""

    def __init__(self, max_recent: int = 20, max_tokens: int = 4000):
        self.messages: list[Message] = []
        self.summary: str = ""
        self.max_recent = max_recent
        self.max_tokens = max_tokens

    def add_message(self, role: str, content: str, token_count: int = 0):
        self.messages.append(
            Message(role=role, content=content, token_count=token_count)
        )
        if len(self.messages) > self.max_recent * 1.5:
            self._compress()

    def _compress(self):
        """오래된 메시지를 요약하고 최근 메시지만 유지"""
        old_messages = self.messages[: -self.max_recent]
        old_text = "
".join(
            f"{m.role}: {m.content}" for m in old_messages
        )
        self.summary = self._summarize(old_text)
        self.messages = self.messages[-self.max_recent :]

    def _summarize(self, text: str) -> str:
        """이전 요약과 새로운 대화를 통합 요약"""
        return f"[이전 대화 요약] {text[:500]}..."

    def get_context(self) -> list[dict]:
        """현재 컨텍스트로 전달할 메시지 목록 구성"""
        context = []
        if self.summary:
            context.append({
                "role": "system",
                "content": f"이전 대화 요약:
{self.summary}"
            })
        for msg in self.messages:
            context.append({"role": msg.role, "content": msg.content})
        return context

장기 메모리: 세션 간 지속성

장기 메모리는 벡터 데이터베이스에 사용자 선호도, 과거 상호작용 패턴, 해결된 문제 등을 저장하고 필요할 때 꺼내 쓰는 방식입니다. 여기서 핵심 원칙 하나만 기억하세요: "저장은 넉넉하게, 검색은 정밀하게"입니다.

from datetime import datetime, timedelta


class LongTermMemory:
    """벡터 DB 기반 장기 메모리 시스템"""

    def __init__(self, vector_store, embedding_model):
        self.vector_store = vector_store
        self.embedding_model = embedding_model

    def store(self, content: str, metadata: dict):
        """경험이나 사실을 장기 메모리에 저장"""
        embedding = self.embedding_model.encode(content)
        self.vector_store.upsert(
            id=metadata.get("id", str(hash(content))),
            vector=embedding,
            metadata={
                **metadata,
                "stored_at": datetime.utcnow().isoformat(),
                "access_count": 0,
            },
        )

    def recall(self, query: str, top_k: int = 5,
               recency_weight: float = 0.3) -> list[dict]:
        """쿼리와 관련된 기억을 검색 (관련성 + 최신성 가중치)"""
        query_embedding = self.embedding_model.encode(query)
        results = self.vector_store.query(
            vector=query_embedding,
            top_k=top_k * 2,
        )
        scored = []
        now = datetime.utcnow()
        for r in results:
            stored = datetime.fromisoformat(r.metadata["stored_at"])
            age_days = (now - stored).days
            recency_score = max(0, 1 - age_days / 365)
            final_score = (
                (1 - recency_weight) * r.score
                + recency_weight * recency_score
            )
            scored.append({**r.metadata, "content": r.content,
                           "score": final_score})

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

에이전틱 메모리: 스크래치패드 패턴

에이전틱 메모리는 좀 독특한 개념입니다. 에이전트가 복잡한 작업을 수행하면서 스스로 메모를 작성하는 기법이에요. 컨텍스트 윈도우 바깥의 파일 시스템이나 데이터베이스에 구조화된 노트를 기록하고, 나중에 필요하면 다시 불러옵니다. Claude Code의 CLAUDE.md 메모리 시스템이 대표적인 예죠.

import json
from pathlib import Path


class ScratchpadMemory:
    """에이전트가 작업 중 자발적으로 메모를 남기는 스크래치패드"""

    def __init__(self, storage_path: str = "./agent_memory"):
        self.path = Path(storage_path)
        self.path.mkdir(exist_ok=True)

    def write_note(self, category: str, content: str,
                   tags: list[str] = None):
        """구조화된 메모 작성"""
        note = {
            "content": content,
            "tags": tags or [],
            "created_at": datetime.utcnow().isoformat(),
        }
        file_path = self.path / f"{category}.jsonl"
        with open(file_path, "a") as f:
            f.write(json.dumps(note, ensure_ascii=False) + "
")

    def read_notes(self, category: str,
                   tag_filter: str = None) -> list[dict]:
        """카테고리별 메모 읽기 (선택적 태그 필터링)"""
        file_path = self.path / f"{category}.jsonl"
        if not file_path.exists():
            return []
        notes = []
        with open(file_path) as f:
            for line in f:
                note = json.loads(line.strip())
                if tag_filter is None or tag_filter in note.get("tags", []):
                    notes.append(note)
        return notes

원칙 4: RAG 통합 — 컨텍스트 품질이 곧 응답 품질

RAG는 컨텍스트 엔지니어링에서 가장 성숙한 구성 요소 중 하나입니다. 하지만 "문서를 벡터로 변환하고 검색하면 끝"이라는 단순한 접근은 프로덕션에서 거의 반드시 실패합니다. 검색 품질이 곧 컨텍스트 품질이고, 컨텍스트 품질이 곧 응답 품질입니다. 이 연쇄 관계를 무시하면 안 됩니다.

청킹 전략: 정밀도와 컨텍스트의 균형

청크 크기는 RAG 성능에 결정적 영향을 미칩니다. 양쪽 다 트레이드오프가 있어요:

  • 큰 청크: 문맥이 풍부하지만 임베딩이 평균화되어 검색 정밀도가 떨어지고, 컨텍스트 윈도우 공간도 낭비됩니다
  • 작은 청크: 검색 정밀도는 높아지지만 문맥이 부족해서 모델이 의미를 제대로 파악하지 못합니다

2026년 현재 실무에서 검증된 접근법은 시맨틱 청킹 + 부모-자식 구조입니다. 작은 청크로 정밀하게 검색하되, 실제 컨텍스트에는 주변까지 포함한 부모 청크를 넣어주는 방식이죠:

class SemanticChunker:
    """의미 단위 청킹 + 부모-자식 구조"""

    def __init__(self, embedding_model, similarity_threshold: float = 0.75):
        self.embedding_model = embedding_model
        self.threshold = similarity_threshold

    def chunk(self, document: str) -> list[dict]:
        """문서를 의미 경계에서 분할"""
        sentences = self._split_sentences(document)
        embeddings = self.embedding_model.encode(sentences)

        chunks = []
        current_chunk = [sentences[0]]

        for i in range(1, len(sentences)):
            similarity = self._cosine_similarity(
                embeddings[i - 1], embeddings[i]
            )
            if similarity < self.threshold:
                chunk_text = " ".join(current_chunk)
                chunks.append(chunk_text)
                current_chunk = [sentences[i]]
            else:
                current_chunk.append(sentences[i])

        if current_chunk:
            chunks.append(" ".join(current_chunk))

        return self._create_hierarchy(chunks)

    def _create_hierarchy(self, chunks: list[str]) -> list[dict]:
        """검색용 자식 청크 + 컨텍스트용 부모 청크 구조"""
        results = []
        for i, chunk in enumerate(chunks):
            parent_start = max(0, i - 1)
            parent_end = min(len(chunks), i + 2)
            parent_text = " ".join(chunks[parent_start:parent_end])

            results.append({
                "child": chunk,
                "parent": parent_text,
                "index": i,
            })
        return results

    def _split_sentences(self, text: str) -> list[str]:
        import re
        return [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]

    def _cosine_similarity(self, a, b) -> float:
        import numpy as np
        return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

검색 결과의 컨텍스트 주입 패턴

검색된 문서를 컨텍스트에 넣을 때는 출처와 관련성 점수를 명시하는 게 환각 방지에 효과적입니다. "어디서 온 정보인지" 모델에게 알려주는 거죠:

def format_rag_context(retrieved_docs: list[dict]) -> str:
    """검색 결과를 LLM 컨텍스트에 주입하기 위한 포매팅"""
    context_parts = [
        "아래는 질문과 관련된 참고 문서입니다. "
        "답변 시 이 문서의 정보를 우선적으로 활용하고, "
        "문서에 없는 정보는 추측하지 마세요.
"
    ]
    for i, doc in enumerate(retrieved_docs, 1):
        context_parts.append(
            f"[문서 {i}] (출처: {doc['source']}, "
            f"관련성: {doc['score']:.2f})
"
            f"{doc['content']}
"
        )
    return "
".join(context_parts)

원칙 5: 도구 설계 — 최소한의 명확한 도구 세트

도구(Tool)는 에이전트를 텍스트 생성기에서 실제 세계에서 행동하는 주체로 바꿔줍니다. 그런데 여기서 흔한 함정이 하나 있습니다. Anthropic의 내부 테스트에 따르면, 58개의 도구 정의가 무려 약 55,000 토큰을 잡아먹을 수 있고, 도구가 많아질수록 모델의 올바른 도구 선택 정확도가 떨어집니다.

도구는 많을수록 좋은 게 아닙니다. 오히려 정반대예요.

효과적인 도구 설계의 원칙

  • 한 번에 20개 미만의 도구만 노출하세요. 자주 쓰지 않는 도구는 Tool Search를 통해 지연 로딩합니다
  • 인간 엔지니어가 판단하기 모호한 도구 경계를 만들지 마세요. 사람도 헷갈리면 LLM은 더 헷갈립니다
  • 도구 이름과 설명을 직관적으로 작성하세요. 이 부분이 도구 선택 정확도에 가장 크게 영향을 미칩니다

좋은 도구 설계와 나쁜 도구 설계의 차이를 코드로 비교해 보겠습니다:

from typing import Any

# 나쁜 도구 설계: 모호한 이름, 빈약한 설명
bad_tools = [
    {
        "name": "process_data",
        "description": "데이터를 처리합니다",
        "parameters": {"data": {"type": "string"}},
    }
]

# 좋은 도구 설계: 명확한 이름, 상세한 설명, 용도 명시
good_tools = [
    {
        "name": "lookup_order_status",
        "description": (
            "주문번호로 주문 상태를 조회합니다. "
            "고객이 주문 상태, 배송 현황, 예상 도착일을 "
            "문의할 때 사용하세요. "
            "주문 변경이나 취소에는 사용하지 마세요."
        ),
        "parameters": {
            "order_id": {
                "type": "string",
                "description": "ORD-로 시작하는 주문번호 (예: ORD-2026-001)",
            }
        },
    },
    {
        "name": "cancel_order",
        "description": (
            "주문을 취소하고 환불을 처리합니다. "
            "반드시 고객에게 취소 의사를 확인받은 후 호출하세요. "
            "이미 배송된 주문은 취소할 수 없으며, "
            "이 경우 반품 절차를 안내해야 합니다."
        ),
        "parameters": {
            "order_id": {
                "type": "string",
                "description": "취소할 주문번호",
            },
            "reason": {
                "type": "string",
                "description": "취소 사유",
                "enum": [
                    "customer_request",
                    "defective_product",
                    "wrong_item",
                    "late_delivery",
                ],
            },
        },
    },
]

원칙 6: JIT(Just-in-Time) 컨텍스트 전략

에이전틱 AI 시스템에서 점점 더 중요해지고 있는 패턴이 JIT 컨텍스트 로딩입니다. 모든 정보를 미리 컨텍스트에 때려넣는 대신, 에이전트가 실제로 필요한 시점에 동적으로 불러오는 방식입니다.

Claude Code의 스킬 시스템이 이 패턴을 잘 보여줍니다. 스킬의 메타데이터만 항상 올려놓고, 실제 지침과 데이터는 스킬이 트리거될 때 비로소 로드합니다. 이걸 프로그레시브 디스클로저(Progressive Disclosure)라고 부르는데, 필요할 때만 정보를 점진적으로 펼쳐 보여주는 것이죠:

class JITContextLoader:
    """필요한 시점에 동적으로 컨텍스트를 로드하는 시스템"""

    def __init__(self):
        self.skill_registry = {
            "database_ops": {
                "trigger_keywords": ["쿼리", "데이터베이스", "SQL", "테이블"],
                "description": "데이터베이스 작업 관련 도구와 지침",
                "loaded": False,
            },
            "api_integration": {
                "trigger_keywords": ["API", "엔드포인트", "REST", "웹훅"],
                "description": "외부 API 연동 관련 도구와 지침",
                "loaded": False,
            },
        }
        self.active_context: dict[str, Any] = {}

    def check_and_load(self, user_message: str) -> list[str]:
        """사용자 메시지를 분석하여 필요한 컨텍스트를 동적 로드"""
        newly_loaded = []
        for skill_name, skill_info in self.skill_registry.items():
            if skill_info["loaded"]:
                continue
            if any(kw in user_message for kw in skill_info["trigger_keywords"]):
                self._load_skill(skill_name)
                newly_loaded.append(skill_name)
        return newly_loaded

    def _load_skill(self, skill_name: str):
        """스킬의 전체 지침과 도구 정의를 로드"""
        skill_path = Path(f"./skills/{skill_name}")
        skill_data = {
            "instructions": (skill_path / "SKILL.md").read_text(),
            "tools": json.loads((skill_path / "tools.json").read_text()),
        }
        self.active_context[skill_name] = skill_data
        self.skill_registry[skill_name]["loaded"] = True

종합: 컨텍스트 어셈블리 파이프라인

자, 이제 지금까지 다룬 모든 원칙을 하나로 엮을 차례입니다. 에이전트가 매 턴마다 실행하는 컨텍스트 어셈블리 파이프라인의 전체 그림을 봅시다:

class ContextAssembler:
    """에이전트의 매 턴마다 최적의 컨텍스트를 조립하는 파이프라인"""

    def __init__(self, system_prompt, budget_manager,
                 conversation_memory, long_term_memory,
                 rag_retriever, tools):
        self.system_prompt = system_prompt
        self.budget = budget_manager
        self.conversation = conversation_memory
        self.ltm = long_term_memory
        self.rag = rag_retriever
        self.tools = tools

    def assemble(self, user_message: str, user_id: str) -> dict:
        """사용자 메시지에 대한 최적의 컨텍스트 조립"""
        # 1단계: 시스템 프롬프트 (항상 포함)
        messages = [{"role": "system", "content": self.system_prompt}]

        # 2단계: 장기 메모리에서 관련 기억 검색
        memories = self.ltm.recall(query=user_message, top_k=3)
        if memories:
            memory_text = "
".join(
                f"- {m['content']}" for m in memories
            )
            messages.append({
                "role": "system",
                "content": f"이 사용자에 대해 알고 있는 정보:
{memory_text}",
            })

        # 3단계: RAG로 관련 문서 검색
        rag_docs = self.rag.search(user_message, top_k=5)
        if rag_docs:
            rag_context = format_rag_context(rag_docs)
            messages.append({
                "role": "system",
                "content": rag_context,
            })

        # 4단계: 대화 기록 추가
        messages.extend(self.conversation.get_context())

        # 5단계: 현재 사용자 메시지
        messages.append({"role": "user", "content": user_message})

        # 6단계: 토큰 예산 검증
        total_content = " ".join(m["content"] for m in messages)
        total_tokens = self.budget.count_tokens(total_content)

        if total_tokens > self.budget.max_tokens * 0.85:
            messages = self._trim_context(messages)

        return {"messages": messages, "tools": self.tools}

    def _trim_context(self, messages: list[dict]) -> list[dict]:
        """예산 초과 시 우선순위 기반으로 컨텍스트 트리밍"""
        return messages

프로덕션 체크리스트

컨텍스트 엔지니어링을 실제 프로덕션에 적용할 때 꼭 확인해야 할 항목들을 정리했습니다. 개인적으로 이 중 로깅 부분을 빠뜨려서 디버깅에 고생한 적이 있으니, 꼼꼼하게 챙기시길 권합니다:

  1. 토큰 예산 모니터링: 각 컨텍스트 구성 요소의 토큰 사용량을 실시간으로 추적하세요. 예산 초과 시 자동으로 축소하는 안전장치는 필수입니다
  2. 컨텍스트 오염 방지: 도구 실행 결과의 에러 메시지나 환각된 내용이 이후 컨텍스트에 누적되지 않도록 검증 레이어를 넣으세요
  3. 메모리 만료 정책: 장기 메모리에 저장된 정보에 유효기간을 설정하세요. 오래된 정보가 최신 정보와 충돌하면 모델이 혼란에 빠집니다
  4. 컨텍스트 로깅: 매 턴마다 조립된 컨텍스트를 로깅하세요. 에이전트 실패의 대부분은 컨텍스트 문제입니다. 로그 없이는 원인을 찾기가 매우 어렵습니다
  5. A/B 테스트: 컨텍스트 구성을 바꿨을 때 효과를 정량적으로 측정하세요. 느낌이 아니라 데이터로 판단해야 합니다
  6. 멀티에이전트 컨텍스트 격리: 여러 에이전트가 협업하는 시스템에서는 각 에이전트의 컨텍스트를 격리하세요. Anthropic 연구에 따르면, 격리된 컨텍스트를 가진 다중 에이전트가 하나의 큰 에이전트보다 성능이 더 좋았습니다

자주 묻는 질문 (FAQ)

컨텍스트 엔지니어링은 프롬프트 엔지니어링을 대체하는 건가요?

아닙니다. 프롬프트 엔지니어링은 컨텍스트 엔지니어링의 핵심 하위 요소로 여전히 중요합니다. 좋은 프롬프트 없이는 좋은 컨텍스트도 의미가 없으니까요. 다만 프로덕션급 AI 시스템에서는 프롬프트만으로 부족하고, 메모리, RAG, 도구, 상태 관리까지 아우르는 컨텍스트 수준의 설계가 필수입니다. 둘은 대체 관계가 아니라 포함 관계입니다.

컨텍스트 윈도우가 무한대로 커지면 컨텍스트 엔지니어링이 불필요해지지 않나요?

오히려 더 중요해집니다. 컨텍스트 윈도우가 커져도 어텐션 희석 문제는 사라지지 않습니다. 1M 토큰을 가득 채운다고 해서 모델이 모든 정보를 균등하게 활용하는 건 아니고, 비용과 레이턴시도 토큰 수에 비례해서 올라갑니다. 중요한 건 더 많은 정보가 아니라 더 적절한 정보를 주는 것입니다.

컨텍스트 엔지니어링을 시작하려면 어떤 기술 스택이 필요한가요?

최소 구성으로는 LLM API(Claude, GPT-4o 등), 벡터 데이터베이스(Pinecone, Weaviate, ChromaDB 중 하나), 그리고 에이전트 프레임워크(LangGraph 또는 직접 구현)가 필요합니다. 처음부터 다 갖추려고 하지 마세요. 시스템 프롬프트 설계와 대화 메모리 관리부터 시작하고, 점진적으로 RAG와 도구 통합을 추가하는 게 현실적입니다.

에이전트의 컨텍스트 품질을 어떻게 측정할 수 있나요?

세 가지 핵심 지표를 추적하세요. 첫째, 도구 선택 정확도 — 에이전트가 올바른 도구를 골랐는지의 비율입니다. 둘째, 작업 완료율 — 멀티스텝 작업에서 최종 목표까지 도달한 비율이죠. 셋째, 컨텍스트 활용률 — 제공된 컨텍스트 중 응답에 실제로 반영된 정보의 비율입니다. 이 지표들이 낮다면, 그건 컨텍스트 설계에 문제가 있다는 확실한 신호입니다.

멀티에이전트 시스템에서 컨텍스트 엔지니어링은 어떻게 달라지나요?

가장 큰 차이는 컨텍스트 격리입니다. 직관적으로는 모든 에이전트에게 전체 컨텍스트를 공유하면 좋을 것 같지만, 실제로는 오히려 성능이 떨어집니다. 각 에이전트는 자기 역할에 필요한 최소한의 컨텍스트만 받아야 합니다. 에이전트 간 정보 전달은 구조화된 메시지를 통해 필요한 정보만 선별적으로 넘기는 게 핵심 패턴입니다.

저자 소개 Editorial Team

Our team of expert writers and editors.