본문 바로가기

RAG

컨텍스트 엔지니어링 — 프롬프트 엔지니어링의 다음 단계

반응형

2025년 6월, Andrej Karpathy(전 OpenAI, Tesla AI 디렉터)가 X에 짧은 글 하나를 올렸어요.

"프롬프트 엔지니어링이라는 말은 우리가 실제로 하는 일을 너무 사소하게 만든다. 더 정확한 표현은 컨텍스트 엔지니어링이다."

그리고 Shopify CEO 토비 뤼트케가 동의하며 이렇게 정의했어요.

"LLM이 그럴듯하게 문제를 풀 수 있도록 모든 컨텍스트를 제공하는 기술."

이 두 발언 이후 AI 개발 커뮤니티에서 컨텍스트 엔지니어링이 2026년 가장 중요한 개념으로 자리 잡았어요.


프롬프트 엔지니어링과 뭐가 다른가

먼저 LLM을 컴퓨터로 비유해볼게요.

LLM = CPU
컨텍스트 윈도우 = RAM
컨텍스트 엔지니어링 = 운영체제

운영체제는 CPU가 작업할 때 RAM에 딱 필요한 데이터만 올려요. 너무 많아도 안 되고, 너무 적어도 안 돼요.

프롬프트 엔지니어링은 "무슨 말을 어떻게 할까"예요. 배우한테 건네는 대본이에요.

컨텍스트 엔지니어링은 "그 말을 할 때 모델이 무엇을 알고 있어야 하는가"예요. 배우가 연기하기 전에 무대 배경, 소품, 상대 배우의 캐릭터 전부를 세팅하는 것이에요.

프롬프트 엔지니어링:
"당신은 전문 여행 에이전트입니다. 서울에서 도쿄 여행을 도와주세요."

컨텍스트 엔지니어링:
- 시스템 프롬프트: 역할과 규칙 정의
- 사용자 프로필: 과거 여행 기록, 선호 항공사
- 검색 결과: 오늘의 항공권 가격
- 대화 기록: 지난 3번의 대화
- 툴 목록: 예약 API, 날씨 API
- 출력 형식: JSON 구조
→ 이 모든 것을 최적으로 조합해서 컨텍스트 윈도우에 넣는 것

컨텍스트 윈도우 안에 들어가는 6가지

LLM이 응답을 생성할 때 보는 것들이에요. 컨텍스트 엔지니어링은 이 6가지를 어떻게 관리하느냐예요.


컨텍스트의 4가지 실패 모드

컨텍스트가 잘못되면 네 가지 방식으로 망가져요.

1. Context Poisoning (오염)
   → 잘못된 정보가 컨텍스트에 들어감
   → LLM이 잘못된 정보를 사실로 받아들여 답변

2. Context Distraction (산만함)
   → 관련 없는 정보가 너무 많음
   → LLM의 주의가 분산돼서 핵심을 놓침

3. Context Confusion (혼란)
   → 모순되는 정보가 동시에 존재
   → LLM이 어느 정보를 따를지 몰라서 일관성 없는 답변

4. Context Clash (충돌)
   → 시스템 프롬프트와 사용자 입력이 충돌
   → LLM이 규칙을 어기거나 이상한 답변 생성

그리고 중요한 연구 결과가 있어요. 많은 컨텍스트 = 더 좋은 답변이 아니에요.

2025년 Chroma 연구에서 GPT-4.1, Claude, Gemini 포함 18개 최신 모델을 테스트했는데, 모든 모델이 입력 길이가 늘어날수록 성능이 떨어졌어요. 어떤 모델은 95% 정확도에서 60%로 급락했어요.

그리고 "잃어버린 중간(Lost in the Middle)" 문제도 실재해요. 관련 정보를 컨텍스트 앞뒤에 놓으면 정확도가 높고, 중간에 넣으면 30% 이상 정확도가 떨어져요.

컨텍스트 내 위치별 LLM 주의도:
시작 부분 ████████████ (높음)
중간 부분 ████         (낮음)
끝 부분   ████████████ (높음)

→ 중요한 정보는 앞이나 뒤에 배치해야 함

컨텍스트 엔지니어링 4가지 핵심 전략

LangChain이 정리한 4가지 전략이에요.

전략 1: Write (외부 저장)

컨텍스트 윈도우가 꽉 차기 전에 중요한 정보를 외부에 저장해요.

from langchain_core.messages import AIMessage, HumanMessage
from supabase import create_client

supabase = create_client(SUPABASE_URL, SUPABASE_KEY)

class ConversationManager:
    def save_to_external(self, conversation_id: str, messages: list):
        """대화가 길어지면 외부 DB에 저장"""
        for msg in messages:
            supabase.table("messages").insert({
                "conversation_id": conversation_id,
                "role": msg["role"],
                "content": msg["content"]
            }).execute()

    def load_recent(self, conversation_id: str, limit: int = 10) -> list:
        """최근 N개 메시지만 컨텍스트에 로드"""
        result = supabase.table("messages") \
            .select("role, content") \
            .eq("conversation_id", conversation_id) \
            .order("created_at", desc=True) \
            .limit(limit) \
            .execute()

        return list(reversed(result.data))

전략 2: Select (관련 정보만 검색)

모든 정보를 넣지 말고, 지금 질문과 관련된 것만 가져와요. RAG가 여기에 해당해요.

from langchain_community.vectorstores import Qdrant
from langchain_openai import OpenAIEmbeddings

class ContextSelector:
    def __init__(self, vectorstore: Qdrant):
        self.vectorstore = vectorstore

    def select_relevant(
        self,
        query: str,
        k: int = 3,
        score_threshold: float = 0.7
    ) -> list[str]:
        """쿼리와 관련된 문서만 선택"""
        results = self.vectorstore.similarity_search_with_score(
            query, k=k
        )

        # 유사도 낮은 건 제외 (관련 없는 정보가 컨텍스트 오염시키는 걸 방지)
        relevant = [
            doc.page_content
            for doc, score in results
            if score >= score_threshold
        ]

        return relevant

    def build_context(self, query: str, history: list) -> str:
        """최적 컨텍스트 조합"""
        relevant_docs = self.select_relevant(query)

        context_parts = []

        # 중요한 정보를 앞에 배치 (Lost in the Middle 방지)
        if relevant_docs:
            context_parts.append("### 관련 문서\n" + "\n\n".join(relevant_docs))

        # 대화 기록은 마지막 몇 개만
        if history:
            recent = history[-4:]  # 최근 4개만
            history_text = "\n".join([
                f"{msg['role']}: {msg['content']}" for msg in recent
            ])
            context_parts.append("### 대화 기록\n" + history_text)

        return "\n\n".join(context_parts)

전략 3: Compress (요약 압축)

대화가 길어지면 오래된 내용을 요약해서 토큰을 줄여요.

from langchain_anthropic import ChatAnthropic

llm = ChatAnthropic(model="claude-sonnet-4-6")

class ContextCompressor:
    MAX_MESSAGES = 20  # 이 이상이면 요약

    def compress_history(self, messages: list) -> list:
        if len(messages) <= self.MAX_MESSAGES:
            return messages

        # 오래된 메시지 요약
        old_messages = messages[:-10]  # 최근 10개 제외
        recent_messages = messages[-10:]

        summary_prompt = f"""
        다음 대화 내용을 핵심 정보만 남겨서 3~5문장으로 요약하세요.
        결정된 사항, 중요한 정보, 사용자 선호도를 포함하세요.

        대화:
        {self._format_messages(old_messages)}
        """

        summary = llm.invoke(summary_prompt).content

        # 요약을 시스템 메시지로 앞에 붙이고, 최근 대화는 유지
        compressed = [
            {"role": "system", "content": f"[이전 대화 요약]\n{summary}"}
        ] + recent_messages

        return compressed

    def _format_messages(self, messages: list) -> str:
        return "\n".join([
            f"{msg['role']}: {msg['content']}" for msg in messages
        ])

전략 4: Isolate (컨텍스트 격리)

멀티 에이전트 시스템에서 에이전트마다 독립적인 컨텍스트를 가져요. 서로의 컨텍스트가 오염되지 않게요.

class IsolatedAgentContext:
    """각 에이전트가 독립적인 컨텍스트를 가짐"""

    def __init__(self):
        self.contexts = {}

    def create_agent_context(
        self,
        agent_id: str,
        role: str,
        tools: list
    ) -> dict:
        """에이전트별 격리된 컨텍스트 생성"""
        self.contexts[agent_id] = {
            "system_prompt": role,
            "tools": tools,
            "messages": [],
            "memory": {}
        }
        return self.contexts[agent_id]

    def get_context(self, agent_id: str) -> dict:
        return self.contexts.get(agent_id, {})

    def update_context(self, agent_id: str, key: str, value):
        if agent_id in self.contexts:
            self.contexts[agent_id][key] = value

# 사용 예시
context_manager = IsolatedAgentContext()

# 리서처 에이전트 — 검색 툴만 가짐
researcher_ctx = context_manager.create_agent_context(
    "researcher",
    role="당신은 정보 수집 전문가입니다.",
    tools=["web_search", "document_search"]
)

# 작성자 에이전트 — 리서처 결과만 받음 (다른 히스토리 없음)
writer_ctx = context_manager.create_agent_context(
    "writer",
    role="당신은 글쓰기 전문가입니다.",
    tools=["text_editor"]
)

컨텍스트 조립 파이프라인 — 실전 구현

매 LLM 호출 전에 실행되는 동적 컨텍스트 조립 시스템이에요.

from dataclasses import dataclass
from typing import Optional

@dataclass
class ContextConfig:
    max_tokens: int = 4000
    max_history_messages: int = 10
    max_retrieved_docs: int = 3
    critical_info_at_front: bool = True  # 중요 정보 앞에 배치

class ContextEngine:
    """매 LLM 호출 전에 컨텍스트를 동적으로 조립"""

    def __init__(self, config: ContextConfig = ContextConfig()):
        self.config = config
        self.selector = ContextSelector(vectorstore)
        self.compressor = ContextCompressor()
        self.token_counter = TokenCounter()

    def assemble(
        self,
        query: str,
        conversation_id: str,
        user_profile: Optional[dict] = None,
        system_role: str = "당신은 도움이 되는 어시스턴트입니다."
    ) -> list[dict]:

        messages = []

        # 1. 시스템 프롬프트 구성 (항상 앞에)
        system_content = self._build_system_prompt(system_role, user_profile)
        messages.append({"role": "system", "content": system_content})

        # 2. 관련 문서 검색 및 추가
        relevant_docs = self.selector.select_relevant(query, k=self.config.max_retrieved_docs)
        if relevant_docs:
            doc_context = "다음 정보를 참고해서 답변하세요:\n\n"
            doc_context += "\n\n---\n\n".join(relevant_docs)
            # 중요 정보 → 앞에 배치 (Lost in the Middle 방지)
            messages.append({"role": "system", "content": doc_context})

        # 3. 대화 기록 추가 (필요시 압축)
        history = self._load_history(conversation_id)
        compressed_history = self.compressor.compress_history(history)

        # 토큰 예산 체크
        remaining_tokens = self.config.max_tokens - self.token_counter.count(messages)
        trimmed_history = self._trim_to_budget(compressed_history, remaining_tokens)
        messages.extend(trimmed_history)

        # 4. 현재 사용자 메시지 추가 (항상 마지막)
        messages.append({"role": "user", "content": query})

        return messages

    def _build_system_prompt(self, role: str, user_profile: Optional[dict]) -> str:
        prompt = role

        if user_profile:
            prompt += f"\n\n사용자 정보:\n"
            prompt += f"- 이름: {user_profile.get('name', '알 수 없음')}\n"
            prompt += f"- 선호도: {user_profile.get('preferences', '없음')}\n"
            prompt += f"- 플랜: {user_profile.get('plan', 'free')}\n"

        prompt += "\n\n중요:\n"
        prompt += "- 제공된 정보 밖의 내용은 '모른다'고 답하세요.\n"
        prompt += "- 불확실할 때는 확실한 것과 불확실한 것을 구분하세요.\n"

        return prompt

    def _trim_to_budget(self, messages: list, token_budget: int) -> list:
        """토큰 예산에 맞게 메시지 트리밍"""
        result = []
        used = 0

        # 최근 메시지부터 역순으로 추가
        for msg in reversed(messages):
            msg_tokens = self.token_counter.count([msg])
            if used + msg_tokens > token_budget:
                break
            result.insert(0, msg)
            used += msg_tokens

        return result

실전 팁 — 모델별 최적 컨텍스트 전략

모델마다 컨텍스트를 다루는 방식이 달라요.

Claude (Anthropic):
- XML 태그로 섹션 구분 효과적
- <context>, <instructions>, <examples> 구조 권장
- Few-shot 예시는 <example> 태그로 감싸기
- 긴 컨텍스트 처리 능력 우수

GPT-4o (OpenAI):
- 시스템 프롬프트 간결하게 유지
- "Step by step" 지시 효과적
- JSON 출력 요청 시 examples 포함
- 중간 길이 컨텍스트에서 최고 성능

Gemini (Google):
- Few-shot 예시 항상 포함 (zero-shot 비선호)
- 특정 질문은 데이터 컨텍스트 뒤에 배치
- 짧고 직접적인 프롬프트 선호
- 2M 토큰 활용 시 위치 전략 중요
# Claude 최적화 컨텍스트 구조
claude_system_prompt = """
<role>
당신은 전문 개발자 어시스턴트입니다.
</role>

<instructions>
- 코드 예시는 항상 포함하세요
- 알 수 없는 내용은 명시하세요
- 간결하고 실용적으로 답하세요
</instructions>

<examples>
<example>
Q: 파이썬에서 리스트 정렬 방법은?
A: sorted() 함수나 .sort() 메서드를 사용합니다.
   sorted(list)는 새 리스트를 반환하고, list.sort()는 원본을 수정합니다.
</example>
</examples>
"""

컨텍스트 품질 측정

컨텍스트도 측정해야 개선할 수 있어요.

class ContextQualityMetrics:
    def evaluate(self, context: list[dict], response: str) -> dict:
        total_tokens = self.count_tokens(context)
        system_tokens = self.count_tokens([m for m in context if m["role"] == "system"])
        history_tokens = self.count_tokens([m for m in context if m["role"] != "system"])

        return {
            # 기본 지표
            "total_tokens": total_tokens,
            "token_utilization": total_tokens / 4096,  # 컨텍스트 윈도우 대비

            # 컨텍스트 구성 비율
            "system_ratio": system_tokens / total_tokens,
            "history_ratio": history_tokens / total_tokens,

            # 품질 지표
            "has_relevant_docs": any("참고" in m["content"] for m in context),
            "response_length": len(response),

            # 경고 플래그
            "context_too_long": total_tokens > 3500,  # 85% 이상 사용
            "no_relevant_docs": not any("참고" in m["content"] for m in context),
        }

# CI/CD에서 컨텍스트 품질 모니터링
def monitor_context_health(metrics: dict):
    if metrics["context_too_long"]:
        alert("컨텍스트 90% 이상 사용 중 — 압축 필요")
    if metrics["no_relevant_docs"]:
        alert("관련 문서 없음 — 검색 파이프라인 확인")

프롬프트 엔지니어링 vs 컨텍스트 엔지니어링 비교

항목 프롬프트 엔지니어링 컨텍스트 엔지니어링

초점 무슨 말을 어떻게 할까 모델이 무엇을 알고 있어야 하나
범위 단일 호출 최적화 전체 시스템 설계
성격 카피라이팅에 가까움 소프트웨어 아키텍처에 가까움
적합한 경우 단순 요약, 번역, 생성 에이전트, 멀티턴, 프로덕션 시스템
한계 컨텍스트 부족 시 해결 불가 복잡하고 유지보수 필요
프롬프트 엔지니어링의 관계 - 프롬프트 엔지니어링을 포함하는 상위 개념

마무리

컨텍스트 엔지니어링을 세 줄로 정리하면 이래요.

첫째, 더 많은 컨텍스트가 항상 좋은 게 아니다. 관련 있는 정보만, 적절한 위치에, 적절한 양으로 넣어야 해요.

둘째, 중요한 정보는 컨텍스트 앞이나 뒤에 배치해야 한다. 중간에 넣으면 30% 이상 성능이 떨어져요.

셋째, 프롬프트는 컨텍스트 엔지니어링의 일부일 뿐이다. 메모리 관리, 검색, 압축, 격리까지 설계해야 프로덕션에서 살아남아요.

"에이전트 전쟁의 승자는 가장 큰 컨텍스트 윈도우를 가진 팀이 아니라, 가장 신중하게 컨텍스트를 설계한 팀이다." 😄

 

반응형