LLM 에이전트가 도구 결과를 무시하고 할루시네이션할 때 생기는 일

LLM 에이전트가 도구

LLM 에이전트가 검색이나 API 호출 같은 도구를 쓰고도 엉뚱한 답을 내놓으면, 흔히 “모델이 부족해서” 혹은 “더 똑똑한 모델로 바꿔야 해서”라고 생각하기 쉽습니다. 하지만 실무에서 이런 문제를 뜯어보면 원인이 모델 자체가 아니라 도구 결과를 프롬프트 어디에, 어떤 role로 넣었는지에 있는 경우가 많습니다. 즉 LLM 에이전트가 도구 결과를 무시하고 할루시네이션할 때는 모델 교체보다 메시지 구조 점검이 먼저입니다.

이 글은 OpenAI Chat Completions API와 LangChain·LangGraph 기준으로, 도구 결과가 프롬프트 안에서 어떻게 배치되는지, 그리고 그 배치가 왜 모델의 판단을 흔드는지를 구조적으로 짚어 보겠습니다.

도구 결과를 무시하는 원인은 메시지 role 구조에 있습니다

챗 기반 LLM API는 메시지를 system, user, assistant, tool(또는 function) 같은 role로 나눠서 받습니다. 모델은 학습 과정에서 role마다 다른 의미를 부여하도록 훈련되는데, system은 “변하지 않는 배경 지시”로, tool 결과는 “이번 턴에만 참고할 사실 근거”로 취급되도록 설계돼 있습니다.

문제는 개발자가 검색 결과나 API 응답을 별도 role 없이 system 프롬프트 문자열 안에 그냥 이어 붙이는 경우입니다. 이렇게 되면 모델 입장에서는 그 텍스트가 “지켜야 할 규칙”인지 “참고할 사실”인지 구분할 근거가 사라집니다. 결과적으로 도구 결과를 규칙처럼 흘려 읽고, 정작 답변은 학습 당시의 사전지식으로 채우는 형태가 나타납니다.

OpenAI Chat Completions API는 2023년 11월 DevDay에서 tools 파라미터를 공개하면서, 모델이 tool_calls를 반환하면 개발자가 role: tool 메시지에 tool_call_id를 맞춰 실행 결과를 넣도록 정리했습니다. 이 구조를 따르지 않고 tool 결과를 system 메시지에 욱여넣는 방식은 함수호출 공식 가이드가 권장하는 흐름과도 어긋납니다.

LangChain·LangGraph 에이전트에서 자주 보이는 배치 실수

LangChain의 AgentExecutor는 도구 호출과 결과 쌍(intermediate_steps)을 agent_scratchpad라는 이름으로 프롬프트에 순서대로 끼워 넣습니다. 커스텀 프롬프트를 짤 때는 MessagesPlaceholder(variable_name="agent_scratchpad")를 대화 흐름의 마지막, 즉 가장 최근 user 메시지 다음에 두는 게 원칙입니다.

AI 에이전트가 도구 결과를 혼동하는 모습을 표현한 일러스트

실무에서 자주 보이는 실수는 이 placeholder를 system 메시지보다 앞쪽에 두거나, 커스텀 프롬프트를 작성하면서 아예 빼먹는 경우입니다. 이렇게 되면 모델이 도구를 호출은 하는데, 그 결과가 다음 턴 프롬프트에 제대로 반영되지 않아 같은 도구를 반복 호출하거나 결과 없이 답을 지어내는 패턴으로 이어집니다.

LangGraph에서는 tools_condition이라는 사전 구축된 조건 함수가 마지막 AIMessage에 tool_calls가 있는지를 보고 도구 노드로 라우팅할지를 정합니다. 이 흐름 자체는 안정적이지만, 커스텀 노드에서 도구 실행 결과를 ToolMessage 객체로 만들지 않고 일반 문자열로 state에 합쳐버리면 그래프 실행은 정상인데 모델이 볼 때는 “근거 없는 텍스트”로 들어가는 상황이 생깁니다. 그래프가 도는 것과 모델이 그 결과를 근거로 인식하는 것은 별개 문제라는 점을 기억할 필요가 있습니다.

system 프롬프트 뒤에 도구 결과를 욱여넣으면 왜 위험할까요?

Liu 등이 2023년에 발표한 “Lost in the Middle” 연구는, 긴 컨텍스트에서 모델이 맨 앞이나 맨 뒤에 있는 정보는 잘 반영하지만 중간에 묻힌 정보는 상대적으로 덜 반영하는 경향이 있다는 점을 보여줬습니다. system 프롬프트가 수십 줄에 달하고 그 안쪽 어딘가에 도구 결과가 섞여 있다면, 바로 이 ‘중간’ 위치에 걸려 모델이 그 내용을 충분히 반영하지 않을 가능성이 커집니다.

여기에 더해 system 메시지는 역할상 “매 턴 바뀌지 않는 규칙”으로 학습된 경향이 있어서, 그 안에 들어간 가변적인 사실 정보(오늘 날짜, 검색된 가격, API 응답 값)를 고정 규칙처럼 취급하거나, 반대로 규칙이 아니라고 판단해 아예 걸러내는 경우도 관찰됩니다. LLM 에이전트가 도구 결과를 무시하고 할루시네이션할 때 이 두 가지가 함께 작용하는 경우가 많습니다.

해결 방향은 단순합니다. system 메시지는 역할·형식 지시만 짧게 유지하고, 도구 결과는 반드시 role: tool(또는 프레임워크가 제공하는 ToolMessage)로 최신 user 턴 근처에 배치하는 것입니다.

코드로 비교해보는 문제 구조와 개선 구조

아래는 OpenAI Chat Completions API 기준으로 자주 보이는 문제 구조와 개선 구조를 비교한 예시입니다.

## 문제가 되는 구조: tool 결과를 system 프롬프트에 섞어 넣음
messages = [
    {"role": "system", "content": (
        "너는 여행 일정 비서야. 항상 친절하게 답해. "
        "참고: 오늘 검색된 항공권 최저가는 342,000원, "
        "출발 시각은 09시 20분이야. "
        "말투는 존댓말로 유지해."
    )},
    {"role": "user", "content": "제일 싼 항공권 가격이랑 출발 시각 알려줘"}
]
## 개선된 구조: tool 결과를 role: tool로 분리해서 최신 턴 근처에 배치
messages = [
    {"role": "system", "content": "너는 여행 일정 비서야. 존댓말로 답해."},
    {"role": "user", "content": "제일 싼 항공권 가격이랑 출발 시각 알려줘"},
    {"role": "assistant", "content": None,
     "tool_calls": [{"id": "call_1", "type": "function",
                      "function": {"name": "search_flight", "arguments": "{}"}}]},
    {"role": "tool", "tool_call_id": "call_1",
     "content": "{\"price\": 342000, \"currency\": \"KRW\", \"depart_time\": \"09:20\"}"}
]

개발자가 LLM 에이전트 메시지 구조를 디버깅하는 장면

두 구조의 차이를 표로 정리하면 다음과 같습니다.

항목 문제 구조 개선 구조
도구 결과 위치 system 프롬프트 내부 텍스트 role: tool 메시지, tool_call_id로 매칭
모델이 인식하는 성격 고정 규칙에 가깝게 처리될 위험 이번 턴의 사실 근거로 처리
반복 호출 위험 근거를 못 찾아 도구를 재호출하거나 추측 tool_call_id가 명확해 재사용 용이
디버깅 난이도 어느 부분이 근거인지 로그로 구분 어려움 메시지 role만 봐도 근거 구간 파악 가능

Anthropic Messages API는 구조가 조금 다릅니다. 별도의 tool role 없이, user role 메시지 안에 {"type": "tool_result", "tool_use_id": "...", "content": "..."} 형태의 콘텐츠 블록을 넣는 방식입니다. 이 글의 코드 예시는 OpenAI API 기준이므로, Anthropic 기반 에이전트를 쓴다면 role 이름이 아니라 콘텐츠 블록 타입을 기준으로 같은 원칙(짧은 system, 최신 턴 근처 배치)을 적용해야 합니다.

프롬프트 구조를 고쳐도 안 통하는 경우가 있습니다

메시지 구조를 바로잡는다고 모든 환각이 사라지는 건 아닙니다. 도구 결과 자체가 모델의 컨텍스트 윈도우보다 긴 경우, 예를 들어 수만 토큰짜리 API 응답을 그대로 role: tool 메시지에 넣으면 앞쪽 일부만 반영되고 뒤쪽은 잘려서 사라집니다. 이때는 role 배치를 바꿔도 소용이 없고, 결과를 요약하거나 필요한 필드만 추출해서 넣는 전처리가 따로 필요합니다.

반대 방향의 부작용도 있습니다. “도구 결과에 없는 내용은 절대 답하지 마”처럼 grounding 지시를 지나치게 강하게 넣으면, 모델이 필요한 추론까지 포기하고 “모르겠습니다”만 반복하는 경우가 생깁니다. 예를 들어 도구 결과에 “342,000원”만 있는데 “부가세 포함 가격이냐”고 물으면, 적당한 추론은 필요한데 과도한 금지 지시 때문에 아예 답을 회피하는 식입니다. 그래서 grounding 지시는 “결과에 없으면 없다고 명시하고, 추정이 필요하면 추정이라고 표시해라” 정도로 완화하는 편이 실용적입니다.

지금 점검해볼 수 있는 체크리스트

정리하면, LLM 에이전트가 도구 결과를 무시하고 할루시네이션할 때 가장 먼저 볼 곳은 모델 성능이 아니라 메시지 role 배치입니다. system 프롬프트 안에 도구 결과가 텍스트로 섞여 있지 않은지, tool_call_id가 요청과 응답에서 정확히 매칭되는지, LangChain을 쓴다면 agent_scratchpad placeholder가 최신 user 메시지 근처에 있는지부터 확인해 보시기 바랍니다.

바로 실행할 수 있는 다음 단계로는, 기존 에이전트 로그에서 실제 API에 전송된 messages 배열 전체를 한 번 출력해 role 순서를 눈으로 확인하는 작업을 추천합니다. 프롬프트 템플릿 코드만 봐서는 놓치기 쉬운 배치 문제가, 실제 전송된 메시지 로그에서는 바로 드러나는 경우가 많습니다.

LLM 응답을 임베딩과 함께 저장할 때 자주 나오는 스키마 실수

Leave a Comment