LLM to Agentic 3) 에이전트에게 예외는 없다 — 실패를 관측으로 바꾸기
배경
에이전트는 결국 세 가지의 반복이다. 모델이 도구를 고르고, 우리가 그 도구를 실행하고, 결과를 다시 모델에게 먹여 루프를 돈다. 앞선 단계에서 우리는 이 루프의 뼈대를 손으로 만들었다. 도구의 결과는 언제나 ToolResult라는 값이고, 하나의 레지스트리가 "모델이 보는 스키마"와 "실제 실행되는 handler"를 잇고, 필터드 뷰가 이번 실행에 필요한 도구만 노출한다.
여기까지의 코드는 모두 한 문장 위에 서 있었다. "모든 것이 잘 풀린다면."
- 모델이 부른 도구는 존재하고,
- 인자는 계약에 맞고,
- handler는 성공하고,
- 전달된 JSON은 온전하고,
- 모델은 언젠가 최종 답을 낸다.
문제는 실제 모델이 이 다섯 가정을 전부 어긴다는 것이다. 그리고 각 위반은 지금 구조에서 하나의 결말로 수렴한다 — 잡히지 않은 Python 예외로 실행 전체가 죽는다.
문제 — 에이전트는 "어떻게 실패해야 하는가"
흔히 실패 처리를 "어떻게 실패를 막을까"의 문제로 본다. 하지만 에이전트에서는 질문이 다르다.
막을 수 없는 실패가 일어났을 때, 그 실패는 무엇이 되어야 하는가?
이 질문이 중요한 이유는 에이전트의 본질에 있다. 에이전트는 관측에 반응하는 루프다. 실패가 예외라면 루프는 그 자리에서 끊기고, 모델은 자기가 무엇을 잘못했는지 볼 기회조차 얻지 못한다. 반대로 실패가 모델이 읽을 수 있는 값이라면, 모델은 그것을 관측으로 받아 스스로 다음 수를 고친다.
즉 실패 처리의 목표는 "죽지 않기"가 아니라, **"실패를 모델이 학습 가능한 신호로 만들기"**다.
분석 — 실패를 두 축으로 가른다
일곱 개의 실패 시나리오를 하나씩 방어 코드로 덧붙이는 대신, 먼저 두 개의 축으로 정리하면 처리 방식이 저절로 결정된다.
축 1 — 실패는 어느 층에서 나는가. 원인의 위치를 추적하면 잡을 지점이 정해진다.
| 실패의 근원 | 어디서 터지나 |
|---|---|
| 없는 이름으로 dispatch 조회 | 레지스트리 조회 |
| 계약 위반 인자 | Pydantic 검증 |
| handler 내부 오류 | 도구 실행 |
| 깨진 JSON | 인자 파싱 |
| 빈/무한/반복 응답 | 모델 응답 자체 |
축 2 — 실패는 "계속"인가 "종료"인가. 이게 핵심 통찰이다. 모든 실패가 "다시 시도해"는 아니다.
- 관측형(observation) — 한 도구 호출의 실패다. 값으로 바꿔 돌려주면 루프는 계속되고 모델이 고친다.
- 종료형(terminal) — 실행 자체가 끝나야 하는 실패다. 예외로 흘리지 말고, 타입이 있는 종료 상태로 깔끔히 끝낸다.
이 두 축을 겹치면, 방어 코드를 흩뿌리는 대신 실패마다 정확한 이름과 처리 위치가 나온다.
분석의 하이라이트 — "경계가 관측 가능성을 정한다"
가장 흥미로운 건 깨진 JSON이었다. OpenAI는 인자를 JSON 문자열로 준다. 그런데 우리 adapter가 그걸 루프에 넘기기 전에 너무 일찍 파싱해 버렸다. 그 결과 깨진 JSON은 adapter 안에서 예외로 죽었고, 루프는 그 실패를 관측할 창조차 갖지 못했다.
여기서 해결책은 "adapter에 try/except를 추가한다"가 아니었다. 진짜 문제는 파싱 책임의 위치였다. 파싱을 루프 경계로 옮겨, 정규 호출을 (id, name, arguments_json) 형태로 넘기자 — 깨진 JSON은 비로소 루프가 볼 수 있는 곳에서 실패했다.
실패를 어디서 잡을 수 있느냐는, 파싱(해석)을 어디서 하느냐가 결정한다. 좋은 실패 처리의 절반은 새 코드가 아니라 책임의 이동이었다.
해결 — 실패마다 이름을 붙인다
원칙은 정상 흐름에서 이미 배운 것의 확장이다. "도구의 결과는 값이다"를 **"도구의 실패도 값이다"**로 넓힌다. 값의 모양은 단 하나다.
ToolResult.failure(error_code, message) # 같은 tool_call ID로 모델에게 돌아간다
이 하나의 모양 위에서, 두 축에 따라 일곱 실패를 처리했다.
관측형 — 루프는 계속된다.
unknown_tool(없는 도구), invalid_arguments(계약 위반 인자), tool_error(handler 예외), malformed_arguments(깨진 JSON). 네 실패 모두 근원은 다르지만 결말은 같다 — 예외 대신 이름 붙은 실패 값이 되어 다음 라운드로 흐른다. 특히 malformed_arguments는 위에서 말한 경계 이동 덕분에 비로소 관측 가능해졌다.
종료형 — 여기서 끝이다.
invalid_model_response는 "tool call도 없고 content도 빈" 응답을 거짓 성공으로 인정하지 않고 실패로 종료한다. max_rounds_exceeded는 라운드를 다 써도 답이 없을 때 예외를 던지는 대신 타입 있는 종료 상태를 반환한다. 둘 다 "계속"이 아니라 "정직한 끝"이다.
선제 방어 — 위험이 오기 전에.
repeated_tool_call은 직전과 이름·원문 인자가 완전히 같은 호출을 handler 재실행 없이 막는다. 지금 도구(예: add_numbers)는 부작용이 없어 재실행해도 무해하다. 하지만 곧 들어올 run_shell·파일 수정·외부 API는 같은 호출을 반복하면 비용과 부작용이 중복된다. 아직 무해할 때 세워 두는 방어선이다.
검증 — 각 실패를 테스트로 못박는다
실험 방식은 단순하고 엄격했다. 각 실패 모드마다 먼저 그 실패를 단언하는 테스트를 써서 실패(RED)시키고, 최소 구현으로 통과(GREEN)시켰다. 즉 "없는 도구를 부르면 unknown_tool 관측이 돌아온다", "빈 응답이면 invalid_model_response로 끝난다" 같은 계약이 코드가 아니라 테스트로 고정된다. 덕분에 나중에 어떤 리팩터링을 해도 이 실패 계약이 조용히 깨지지 않는다.
결과 — 실패 지도
일곱 단계를 지나면, 에이전트가 마주치는 모든 실패에 이름과 분류가 생긴다.
| 실패 | 코드 | 분류 |
|---|---|---|
| 없는 도구 | unknown_tool | 관측(계속) |
| 잘못된 인자 | invalid_arguments | 관측(계속) |
| 도구 내부 예외 | tool_error | 관측(계속) |
| 깨진 JSON | malformed_arguments | 관측(계속) |
| 빈 모델 응답 | invalid_model_response | 종료(terminal) |
| 라운드 초과 | max_rounds_exceeded | 종료(terminal) |
| 연속 중복 호출 | repeated_tool_call | 관측(계속) |
에이전트는 이제 잘못된 요청에 무너지지 않는다. 모델은 자기 실수를 읽고 고치고, 호출자는 실패의 종류를 안다.
인사이트
세 가지가 남는다.
-
에이전트에게 예외는 없다 — 오직 값과 상태가 있다. 잡히지 않은 예외로 죽는 대신, 실패는 모델이 반응할 값이거나 호출자가 처리할 상태가 된다. 이건 방어 코드의 집합이 아니라 하나의 설계 원칙이다.
-
모든 실패가 "계속하라"는 아니다. 관측형과 종료형을 구분하는 것이 핵심이었다. 빈 응답을 "성공한 빈 답"으로 넘기거나, 무한 루프를 예외로 방치하는 대신, 정직하게 "여기서 끝"이라고 말하는 것도 좋은 실패 처리다.
-
경계가 관측 가능성을 정한다. 깨진 JSON 사례가 보여줬듯, 무엇을 처리할 수 있느냐는 어디서 해석하느냐에 달려 있다. 실패를 다루기 전에, 먼저 책임의 위치를 물어야 한다.