내 에이전트는 도구 호출이 성공했다고 말했다. 하지만 404 오류였다.
요약
AI 에이전트 오케스트레이터에서 모델 호출 실패(404 등)가 성공으로 잘못 처리되는 버그를 디버깅하고 해결하는 과정을 다룹니다. 에러 분류 로직의 비대칭성을 수정하여 실패 상황을 명확히 전달하도록 개선했습니다.
핵심 포인트
- 모델 호출 실패가 '성공'으로 오인되는 조용한 실패(silent failure) 문제 식별
- 에러 분류 로직(terminal branch)에서 isError 플래그 누락 확인
- 단 한 줄의 코드로 에이전트의 오류 감지 능력 개선
- 회귀 테스트를 통한 버그 재현 및 수정 검증의 중요성
나는 한 오픈 소스 오케스트레이터 (orchestrator) 저장소의 GitHub 이슈를 디버깅하고 있었다: "subagent MODEL_CALL_FAILED가 삼켜집니다." 제보자의 재현 방법은 간단했다. 서브에이전트 (subagent)를 해결할 수 없는 모델 슬러그(오타가 난 OpenRouter id)로 지정하고, 워크플로 (workflow)를 실행한 뒤, 부모 오케스트레이터가 서브에이전트가 성공적으로 완료되었다고 보고하는 것을 지켜보는 것이었다. 빈 출력, 초록색 체크 표시, 로그 어디에도 에러는 없었다.
그것이 무서운 부분이다. 모델 호출이 실패했다는 것이 아니라 — 호출은 404, 잘못된 키, 속도 제한 (rate limits) 등 언제나 실패할 수 있다. 무서운 점은 그 실패가 결과를 신뢰할지 여부를 결정하는 시스템 단계에 도달했을 때 _성공_으로 번역되었다는 것이다.
거짓말이 어디서 시작되었는지 찾기
오케스트레이터의 도구 루프 (tool loop)는 모든 모델 호출의 결과를 success (성공), retryable (재시도 가능), terminal (종료) 중 하나로 분류한다. terminal 분류는 "이것은 돌아오지 않으니 재시도를 중단하라"는 의미이다. 수정 전 코드의 대략적인 형태는 다음과 같다:
// tool-loop.ts
if (classification === "terminal") {
return { done: true, output: "" };
...
이 비대칭성이 보이는가? retryable-소진 (exhausted) 분기는 config.mode === "task"를 확인하고, 이것이 단순한 채팅 턴이 아닌 서브에이전트 작업일 때 isError: true를 설정한다. 하지만 401, 403, 404, 잘못된 모델 id에 의해 호출되는 terminal 분기는 그 어느 것도 수행하지 않는다. 어떤 모드에 있든 상관없이 항상 성공 형태의 출력을 반환한다.
하류 (Downstream) 단계에서, workflow-entry.ts의 finalizeDone은 해당 { done: true, output: "" }를 가져와 부모에게 일반적인 subagent-result로 통합한다. 해당 객체의 형태 중 그 무엇도 "이것은 실패했다"라고 말해주지 않는다. 그것은 실행되었으나 아무것도 하지 않고 아무것도 반환하지 않은 서브에이전트와 정확히 똑같이 보인다. 이는 일부 작업에서는 정당한 결과이기도 하다. 오케스트레이터는 "할 일이 없음"과 "모델이 응답하지 않음" 사이의 차이를 구별할 방법이 없다.
수정은 한 줄이었지만, 그것을 찾는 것은 아니었다
if (classification === "terminal") {
if (config.mode === "task") {
return { done: true, output: "", isError: true };
...
동일한 체크를 누락되었던 분기(branch)에도 적용했습니다. 이제 태스크 모드(task mode)에서의 종료 실패(terminal failure)는 isError: true로 나타나며, 상위 오케스트레이터(orchestrator)는 이를 처리하는 방법을 이미 알고 있습니다. 즉, 조용한 빈 성공(silent empty success) 대신 실패한 서브 에이전트(subagent)로 표시됩니다.
실제 패치는 10분밖에 걸리지 않았습니다. 하지만 이것이 진짜 버그였음을 확인하는 데는 더 오랜 시간이 걸렸습니다. 먼저 회귀 테스트(regression test)를 작성한 뒤, 기존 코드에서 테스트가 실패(red)하는 것을 지켜보았습니다(이를 통해 오류가 묻히는 현상이 단순한 착각이 아니라 실제로 발생하며 재현 가능하다는 것을 확인했습니다). 그다음 수정을 적용하고 테스트가 통과(green)되는 것을 확인한 뒤, tool-loop.test.ts 전체 스위트와 더 넓은 범위의 패키지 스위트를 실행하여 다른 부분이 기존 동작에 의존하고 있지는 않은지 확인했습니다. 아무것도 의존하고 있지 않았습니다. 누락된 체크는 종료 경로(terminal path)에 대한 테스트 커버리지가 전혀 없었으며, 바로 그 점 때문에 버그가 스며들 수 있었습니다.
이것이 이 리포지토리 하나를 넘어 일반화되는 이유
"종료(terminal) vs 재시도 가능(retryable)"를 구분하는 모든 오케스트레이터는 정확히 이와 같은 종류의 버그를 가질 가능성이 있습니다. 누군가 재시도 소진(retry-exhaustion)을 처리할 때 isError 플래그를 추가하면서, 형제 관계인 종료 실패 분기에도 이를 추가하는 것을 잊어버리면 시스템은 두 가지 실패 방식을 갖게 됩니다. 하나는 요란하게 알리는 방식이고, 다른 하나는 조용한 방식입니다. 조용한 실패는 크래시(crash)보다 더 나쁩니다. 크래시는 눈에 띄기 때문입니다. 하지만 조용한 성공은 신뢰를 얻고, 다른 결정에 포함되며, 그대로 배포됩니다.
이것이 제가 에이전트가 수행한 작업에 대한 자체 요약(summary)만을 유일한 기록으로 두지 않는 이유이기도 합니다. 제가 태스크를 위해 서브 에이전트를 파견할 때, "마이그레이션을 완료했습니다"라는 말은 _증거(evidence)_가 아니라 _주장(claim)_입니다. 그 주장은 서브 에이전트가 의도한 바를 설명할 뿐, 그 아래에서 이루어진 도구 호출(tool calls)이 실제로 에이전트가 생각하는 대로 결과를 반환했음을 증명하지는 않습니다. 만약 해당 서브 에이전트 내부의 모델 호출이 오류를 묻어버리는 버그가 존재하는 종료 경로에서 404 오류를 냈다면, 에이전트의 최종 요약은 자신 있게 "완료(done)"라고 말했을 것입니다. 에이전트 자신의 관점에서는 그 반대되는 정보를 알려주는 것이 아무것도 없었기 때문입니다.
제가 정착한 실질적인 습관은 다음과 같습니다:
- 오류 전파 (error propagation) 측면에서 "terminal"과 "retry-exhausted"를 동일한 심각도로 취급하십시오. 한 경로가 오류 플래그를 설정한다면, 다른 실패 모드를 처리하는 형제 경로(sibling path)에서도 동일한 확인 절차가 필요합니다. 수정을 유발한 단 하나의 경로만이 아니라, 두 경로 모두에 대해 테스트를 작성하십시오.
- 다단계 에이전트 (multi-step agent)의 결과를 신뢰하기 전에, 에이전트가 그에 대해 무엇이라고 말했는지가 아니라, 기저에 있는 도구 호출 (tool calls)이 무엇을 반환했는지 물으십시오. 무엇이 잘못되었을 법한지에 대한 그럴싸한 이야기 대신, 메커니즘을 실제로 확인해 주는 유일한 방법은 재현 우선 회귀 테스트 (repro-first regression test, 수정 전에는 실패(red), 수정 후에는 성공(green))뿐입니다.
- 시스템에 "실패함"을 나타내는 코드 경로가 두 개 이상 있다면,
isError또는 그에 상응하는 값을 확인하는 모든 곳을 grep으로 검색하고, 각 실패 분류가 해당 지점에 도달하는지 확인하십시오. 한 번 확인하는 것은 비용이 적게 들지만, 운영 환경(production)에서 특정 분기(branch)가 결코 도달하지 않았음을 알게 되는 것은 비용이 많이 듭니다.
침묵하는 성공 (Silent success)은 에이전트가 가질 수 있는 가장 위험한 실패 모드입니다. 왜냐하면 다운스트림 (downstream)의 어떤 요소도 이를 불신해야 한다는 사실을 알 수 없기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기