도구 호출과 MCP에서 모델이 함수를 부르는 구조를 다뤘다. 개발 환경에서 몇 번 돌려 보면 잘 동작한다. 문제는 그 뒤다. 실제 트래픽을 태우면 백 번 중 몇 번은 이상하게 끝나고, 로그를 열어 보면 매번 다른 이유로 틀려 있다. 이때 가장 흔한 대응이 시스템 프롬프트에 "반드시 도구를 사용하세요" 같은 문장을 한 줄씩 늘리는 것인데, 대개 효과가 없다. 실패가 한 종류가 아니기 때문이다.
다섯 지점을 먼저 나눈다
"도구 호출이 실패한다"는 문장은 서로 다른 다섯 가지 사건을 뭉뚱그린 것이다. 나눠 놓고 보면 고쳐야 할 곳이 각각 다르다는 게 드러난다.
| 지점 | 증상 | 실제 원인 | 고치는 곳 |
|---|---|---|---|
| 도구 선택 | 도구를 안 쓰고 답을 지어낸다 | 설명문이 언제 쓰는지를 안 말한다 | 스키마 |
| 인자 구성 | user_id에 이름이 들어온다 |
형식·출처가 스키마에 없다 | 스키마 + 검증 |
| 실행 | 간헐적 500, 타임아웃 | 우리 쪽 인프라 | 재시도·백오프 |
| 결과 해석 | 빈 배열을 "찾았습니다"로 답한다 | 결과 표현이 모호하다 | 반환 포맷 |
| 종료 판단 | 같은 도구를 여섯 번 부른다 | 루프에 종료 조건이 없다 | 루프 코드 |
이 표에서 중요한 것은 마지막 열이다. 모델을 더 좋은 것으로 바꿔서 나아지는 것은 위의 두 줄뿐이고, 아래 세 줄은 우리가 짠 코드의 문제라 모델을 바꿔도 그대로 남는다. 그런데 실무에서 실패를 세어 보면 아래 세 줄이 절반을 넘는 경우가 흔하다.
그러니 첫 작업은 프롬프트 수정이 아니라 분류해서 세는 것이다. 호출 하나마다 어느 지점에서 어긋났는지를 태그로 남겨 두면, 일주일 뒤에는 무엇을 고쳐야 하는지가 표 한 장으로 나온다.
인자 구성 — 가장 흔하고 가장 잘 고쳐진다
모델은 인자를 채울 때 두 가지를 한다. 대화에서 값을 찾아 옮기거나, 없으면 그럴듯한 값을 만든다. 후자가 문제다. 그리고 후자가 일어나는 조건은 대체로 하나다 — 그 인자를 비워 둘 방법이 스키마에 없을 때다.
필수 필드로만 이루어진 스키마는 모델에게 "모른다"고 말할 수단을 주지 않는다. 그러면 모델은 답을 지어낸다. 반대로 이렇게 열어 두면 지어내는 빈도가 눈에 띄게 준다.
{
"name": "lookup_order",
"description": "주문번호로 주문 1건을 조회한다. 사용자가 주문번호를 말하지 않았다면 호출하지 말고 먼저 물어볼 것.",
"input_schema": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"pattern": "^ORD-[0-9]{8}$",
"description": "대화에 그대로 등장한 주문번호만 사용한다. 추론하거나 만들지 않는다."
}
},
"required": ["order_id"]
}
}
세 가지가 들어 있다. pattern은 형식을 못 박고, description은 값의 출처를 지정하며, 도구 설명문은 값이 없을 때 무엇을 해야 하는지를 알려 준다. 셋 중 실무에서 가장 자주 빠지는 것이 두 번째다. 형식만 적어 두면 모델은 형식에 맞는 가짜 값을 만들어 낸다.
실행과 결과 표현 — 모델이 보는 것은 반환값뿐이다
도구가 예외를 던졌을 때 그걸 그대로 문자열로 만들어 모델에게 돌려주는 코드를 자주 본다. 스택 트레이스를 받은 모델은 두 가지 중 하나를 한다. 사용자에게 그대로 옮기거나, 무시하고 답을 지어낸다. 둘 다 나쁘다.
모델에게 돌아가는 값은 모델이 다음에 무엇을 할지 정할 수 있는 형태여야 한다. 이 관점으로 보면 결과는 세 갈래로 정리된다.
from dataclasses import dataclass
@dataclass
class ToolResult:
status: str # "ok" | "empty" | "error"
data: dict | None
hint: str # 모델이 다음 행동을 정하는 데 쓸 한 문장
def lookup_order(order_id: str) -> ToolResult:
try:
row = db.find_order(order_id)
except TimeoutError:
# 재시도 여부는 코드가 정한다. 모델에게 넘기지 않는다.
return ToolResult("error", None, "조회 시스템이 응답하지 않는다. 사용자에게 잠시 뒤 다시 시도해 달라고 안내할 것.")
if row is None:
return ToolResult("empty", None, f"{order_id}에 해당하는 주문이 없다. 번호를 다시 확인해 달라고 물어볼 것.")
return ToolResult("ok", row, "조회 성공. 이 값으로 답변할 것.")
empty와 error를 나눈 것이 핵심이다. 둘을 합쳐 놓으면 모델은 "없음"과 "못 봄"을 구별하지 못하고, 없는 주문을 시스템 장애로 안내하거나 그 반대를 한다. hint는 사족처럼 보이지만, 이 한 줄이 결과 해석 단계의 실패를 상당히 줄인다.
재시도는 무엇이 틀렸는지를 알려 줄 때만 듣는다
인자가 검증을 통과하지 못했을 때 그냥 다시 호출하면 모델은 거의 같은 값을 다시 낸다. 온도를 올려도 마찬가지다. 재시도가 의미를 가지려면 무엇이 왜 틀렸는지가 다음 요청에 들어가야 한다.
def call_with_repair(model, messages, tools, max_repair=2):
for attempt in range(max_repair + 1):
reply = model.run(messages, tools=tools)
if not reply.tool_calls:
return reply
call = reply.tool_calls[0]
problems = validate_arguments(call.name, call.arguments)
if not problems:
return reply
if attempt == max_repair:
# 더 시도하지 않는다. 사람이 볼 수 있게 남기고 안전한 답으로 빠진다.
log.warning("argument repair exhausted", extra={"tool": call.name, "problems": problems})
return fallback_reply(reply, problems)
messages.append(reply.as_message())
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": "입력이 거부되었다: " + "; ".join(problems),
})
return reply
두 가지를 같이 봐야 한다. 하나는 오류 문자열을 대화에 되먹인다는 것, 다른 하나는 상한이 있다는 것이다. 상한 없는 재시도 루프는 실패한 요청 하나가 토큰을 스무 배로 쓰게 만든다. 두 번이면 충분하다 — 두 번 안에 못 고치면 세 번째에도 못 고친다.
종료 판단 — 루프는 모델이 아니라 코드가 끝낸다
에이전트 루프에서 가장 비싼 사고는 잘못된 호출이 아니라 끝나지 않는 호출이다. 모델은 도구 결과가 만족스럽지 않으면 조건을 조금 바꿔 같은 도구를 다시 부르는 경향이 있고, 그 조금이 계속 조금씩만 바뀐다.
코드가 걸어야 할 제동은 세 가지다.
- 총 호출 수 상한 — 한 요청에서 도구를 몇 번까지 부를 수 있는가. 대개 8~12번이면 정상 작업은 다 끝난다.
- 같은 호출 반복 차단 — 도구 이름과 인자를 합쳐 해시로 만들고, 이미 나온 조합이면 실행하지 않고 "같은 조회를 이미 했다. 결과는 위에 있다"를 돌려준다.
- 시간 상한 — 벽시계 기준. 사용자가 기다리는 화면이라면 호출 수보다 이쪽이 먼저 걸린다.
세 가지 모두 모델에게 부탁할 일이 아니다. 프롬프트에 "같은 도구를 반복해서 호출하지 마세요"라고 적는 것과 코드에서 중복 호출을 막는 것은 신뢰도가 다르다. 앞은 경향이고 뒤는 보장이다.
무엇부터 볼 것인가
정리하면 순서는 이렇다. 먼저 실패를 다섯 갈래로 태그해 분포를 본다. 인자 구성이 많으면 스키마의 pattern과 출처 설명을 손본다. 결과 해석이 많으면 반환 포맷을 ok/empty/error로 나눈다. 종료 판단이 많으면 루프에 상한 세 개를 건다. 프롬프트 문장을 늘리는 것은 이 셋을 다 하고 나서도 남는 것이 있을 때 할 일이다.
도구 호출의 신뢰성은 모델의 성질이 아니라 우리가 설계한 인터페이스의 성질에 훨씬 가깝다.
읽어주셔서 감사합니다. 😊

