LLM 에이전트에 이슈 생성을 요청했다고 가정해 보자. 답변에는 “이슈를 생성했습니다”라고 나오지만, 프로젝트에는 새 이슈가 없다. 다시 물으면 에이전트는 도구를 사용했다고 설명한다.

이때 모델을 바꾸거나 “절대 거짓말하지 마”라는 프롬프트를 추가하기 전에 확인할 것이 있다. 실행 과정의 어느 단계까지 실제로 진행됐는가?

에이전트의 문장만으로는 그 답을 알 수 없다. 도구 호출 요청이 만들어졌는지, 실행기가 요청을 처리했는지, 외부 서비스에 결과가 남았는지를 나누어 봐야 한다.

1. “호출했다”에는 서로 다른 사건이 섞여 있다

도구를 사용하는 에이전트의 작업은 대략 다음 과정을 거친다.

  1. 모델이 사용할 도구와 인자를 선택한다.
  2. 런타임이 호출 요청을 해석하고 권한을 확인한다.
  3. 실행기가 실제 함수나 외부 API를 호출한다.
  4. 도구 결과가 모델에 전달된다.
  5. 사용자가 요청한 상태에 도달했는지 판단한다.

각 단계는 따로 실패할 수 있다.

모델이 호출 요청을 제대로 생성했어도 실행기에서 예외가 날 수 있다. API가 요청을 접수했어도 비동기 작업은 아직 실행 중일 수 있다. 실행은 끝났지만 잘못된 프로젝트에 이슈가 생겼을 수도 있다.

그래서 장애를 조사할 때는 “도구 호출 성공”이라는 상태 하나로 모든 단계를 덮지 않는 편이 좋다.

2. 일반 텍스트와 호출 이벤트를 구분한다

OpenAI Responses API의 사용자 정의 함수 호출을 예로 들어 보자. 다음은 호출 항목의 주요 필드를 단순화한 예시다.

json
{
  "type": "function_call",
  "call_id": "call_42",
  "name": "create_ticket",
  "arguments": "{\"title\":\"로그인 오류 점검\"}"
}

애플리케이션은 이 항목을 받아 해당 함수를 실행하고, 같은 call_id에 결과를 연결한다.

json
{
  "type": "function_call_output",
  "call_id": "call_42",
  "output": "{\"status\":\"created\",\"ticket_id\":\"T-731\"}"
}

여기서 status와 ticket_id는 예시 애플리케이션이 정한 결과 형식이다. OpenAI가 이슈 생성 여부를 보증하는 표준 필드는 아니다.

이 구조가 일반 답변에 등장한 아래 문장과 다른 점은 명확하다.

create_ticket을 호출해 이슈를 생성했습니다.

이 문장은 모델이 생성한 텍스트다. 코드 블록 안에 도구 이름이나 호출 형태를 적어도, 실행기가 그것을 호출 요청으로 처리하도록 구현되어 있지 않다면 실행은 일어나지 않는다.

사용자 정의 함수는 애플리케이션이 실행하지만, 제공자가 운영하는 서버 측 도구는 실행 위치가 다를 수 있다. 따라서 사용하는 도구가 어디에서 실행되고 어떤 이벤트를 남기는지 먼저 확인해야 한다. OpenAI 함수 호출 문서와 Claude 도구 사용 문서는 이 실행 경계를 설명한다.

3. 증상은 같아도 원인은 다르다

호출 요청 없이 완료 문장만 생성한 경우

모델 출력에 일반 메시지만 있고 호출 이벤트가 없다면, 모델이 실행 증거 없이 완료를 주장했는지 확인한다.

가능한 원인에는 도구가 해당 요청에 노출되지 않은 경우, 모호한 도구 설명, 호출을 선택하지 않은 경우 등이 있다. 먼저 실제 요청에 포함된 도구 정의와 모델의 원시 출력을 비교해야 한다.

단, 트레이스 화면에 기록이 없다는 이유만으로 미실행을 확정해서는 안 된다. 수집 누락이나 샘플링, 별도 실행 경로도 확인해야 한다.

호출 요청은 있지만 실행기가 놓친 경우

이 경우에는 모델보다 애플리케이션의 제어 흐름을 볼 필요가 있다.

  • 응답에서 텍스트만 읽고 호출 항목을 버렸는가?
  • 여러 호출 중 첫 번째만 처리했는가?
  • 도구 이름과 실제 핸들러 이름이 일치하는가?
  • 승인 대기 상태에서 실행 루프가 끝났는가?

스트리밍도 확인 대상이다. 인자 조각을 완성된 요청처럼 처리하거나 호출 완료 이벤트를 놓치면 실행이 누락될 수 있다. response.function_call_arguments.done은 인자 생성이 끝났다는 신호이지, 외부 함수 실행이 끝났다는 신호가 아니다.

실행 실패가 성공으로 바뀌어 전달된 경우

예외 처리 코드가 실패를 지워 버리기도 한다.

python
try:
    result = execute_tool(arguments)
except Exception:
    result = {"message": "처리했습니다"}

이 코드는 원인을 조사할 단서까지 없앤다. 모델에 전달되는 결과에도 실패와 그 범위가 남아 있어야 한다.

MCP에서도 응답을 받았다는 사실과 도구 작업의 성공은 구분된다. 예를 들어 다음 응답은 도구 오류를 나타낸다.

json
{
  "jsonrpc": "2.0",
  "id": 17,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "프로젝트에 이슈를 생성할 권한이 없습니다."
      }
    ],
    "isError": true
  }
}

어댑터가 content만 전달하고 오류 표시를 버리면 중요한 정보가 사라진다. MCP Tools 명세는 프로토콜 오류와 도구 실행 오류를 별도로 다룬다.

요청 접수를 작업 완료로 해석한 경우

배포, 파일 변환, 이메일 전송처럼 단계가 여러 개인 작업에서는 특히 조심해야 한다.

배포 API가 작업 ID를 반환했다면 “배포 요청 접수”까지 확인된 것일 수 있다. 메일 서비스가 메시지를 받아들였다고 해서 수신자의 받은편지함에 도착한 것까지 확인되지는 않는다.

제품이 어떤 상태를 “완료”라고 부를지 도구별로 정의해야 한다.

4. 프롬프트와 스키마가 해결하는 범위를 구분한다

“실행 결과를 받기 전에는 완료했다고 말하지 마”라는 지시는 도움이 될 수 있다. 그러나 실행기의 예외 처리나 끊어진 작업 흐름까지 복구하지는 못한다.

설정도 적용 범위가 있다.

  • tool_choice: "required"는 도구 호출을 요구한다.
  • strict: true는 지원되는 스키마 제약에 맞는 인자 생성을 돕는다.

둘 다 외부 작업의 성공을 보증하지 않는다. 형식이 맞는 프로젝트 ID가 실제로 존재하는지, 사용자가 그 프로젝트에 쓸 권한이 있는지도 별도로 검사해야 한다.

호출을 강제하는 설정은 필요한 단계에 적용하고, 결과를 설명하는 단계에서는 종료할 수 있어야 한다. 모든 턴에 무조건 호출을 강제하면 불필요한 반복이 생길 수 있다.

5. 완료 상태는 실행 기록에서 만든다

실무적으로는 모델의 답변과 별도로 작업 상태를 관리하는 방식이 유용하다. 예를 들어 다음 상태를 구분할 수 있다.

  • requested: 호출 요청을 받았다.
  • waiting_for_approval: 실행 승인을 기다린다.
  • running: 실행을 시작했다.
  • succeeded: 도구가 성공 결과를 반환했다.
  • verified: 정의한 완료 조건을 확인했다.
  • failed: 실패가 확인됐다.
  • unknown: 실행 결과를 아직 판단할 수 없다.

이 이름들은 설계 예시다. 중요한 것은 상태를 모델이 임의로 선언하게 두지 않고, 실행기와 검증 코드가 실제 사건을 바탕으로 갱신하는 것이다.

이슈 생성의 완료 조건을 다음처럼 정할 수 있다.

  1. 생성 API가 이슈 ID를 반환했다.
  2. 그 ID로 조회한 이슈가 요청한 프로젝트에 존재한다.
  3. 제목 등 필요한 필드가 요청과 일치한다.

모든 도구에 추가 조회가 필요한 것은 아니다. 원래 응답이 무엇을 보증하는지, 작업의 위험과 비용이 어느 정도인지에 따라 정하면 된다. 저장 직후 조회에 지연이 생기는 서비스라면 그 특성도 고려해야 한다.

완료 배지 같은 중요한 UI는 이 상태에서 직접 렌더링하는 편이 안전하다. 모델은 검증된 결과를 설명하되, 완료 여부를 결정하는 유일한 주체가 되지 않게 한다.

6. 타임아웃을 만나면 재시도 전에 확인한다

외부 API 요청이 타임아웃으로 끝났다고 가정해 보자. 서버는 이미 이슈를 만들었지만 응답만 유실됐을 수도 있다.

이 상태를 즉시 실패로 처리하고 다시 생성하면 중복 이슈가 생긴다. 우선 unknown으로 기록하고, 조회나 제공자의 요청 식별자를 통해 결과를 확인하는 경로가 필요하다.

서비스가 멱등성 키를 지원한다면 같은 논리적 작업의 재시도에 같은 키를 사용한다. 실제 보장 범위와 보존 기간은 서비스 문서를 따라야 한다. Stripe의 멱등 요청 문서가 구체적인 예다.

모델의 call_id와 비즈니스 작업의 멱등성 키도 구분해야 한다. 모델이 같은 작업을 다시 요청하면 새로운 호출 ID가 생길 수 있으므로, 호출 ID만으로 중복 실행을 막는 설계는 충분하지 않을 수 있다.

7. 답변 평가에 실행 증거를 붙인다

에이전트 테스트에서는 답변 문장과 실제 상태를 함께 검증해야 한다.

최소한 다음 상황을 만들어 볼 수 있다.

  • 도구를 호출하지 않았을 때 완료를 주장하지 않는가?
  • 권한 거절을 성공처럼 설명하지 않는가?
  • 도구 오류가 모델에 그대로 전달되는가?
  • 비동기 작업을 실행 중으로 표시하는가?
  • 타임아웃 뒤 결과 확인 없이 중복 실행하지 않는가?
  • 여러 호출의 결과를 올바른 요청에 연결하는가?
  • 잘못된 대상에 생성된 결과를 완료로 인정하지 않는가?

트레이스에는 모델 호출, 도구 요청, 실행 시작과 종료, 오류, 검증 결과를 연결할 식별자가 있으면 좋다. OpenAI Agents SDK의 tracing처럼 이를 수집하는 기능을 활용할 수 있다.

다만 운영 로그에 비밀값이나 개인정보를 무조건 남겨서는 안 된다. 문제를 추적하는 데 필요한 식별자와 상태를 우선 기록하고, 인자와 결과는 마스킹 및 접근 제어를 적용한다.

마무리

에이전트가 “호출했습니다”라고 말하면, 다음 세 가지를 확인하자.

  1. 실제 호출 요청이 있었는가?
  2. 실행기는 어떤 결과를 기록했는가?
  3. 그 결과가 사용자가 요청한 완료 조건을 충족하는가?

이 세 질문을 구분하면 모델의 잘못된 주장과 런타임의 실행 실패를 훨씬 정확하게 진단할 수 있다.

신뢰할 수 있는 에이전트는 유창한 완료 문장만으로 만들어지지 않는다. 그 문장을 뒷받침하는 실행 증거와 검증 경로까지 설계해야 한다.