iBetter Books
수정

에스컬레이션 로직 설계

에스컬레이션은 에이전트가 자기 권한 밖의 결정을 사람 또는 상위 시스템으로 넘기는 메커니즘이다. 아키텍트 시험은 "에이전트가 더 똑똑하게 일하도록"이 아니라 "어떤 행동을 에이전트가 스스로 실행하게 두면 안 되는가"를 묻는다. 정답을 가르는 기준은 거의 항상 하나로 수렴한다. 가역성(reversibility)이다.

왜 가역성이 설계의 1차 기준인가

행동을 두 부류로 나눈다. 되돌릴 수 있는 행동(파일 읽기, 검색, 로컬 계산)과 되돌릴 수 없는 행동(외부 API로 POST, 이메일 발송, 데이터 삭제, 결제, 프로덕션 배포)이다. 되돌릴 수 있는 행동은 에이전트가 자율로 실행해도 된다. 실수해도 다시 시도하거나 롤백하면 그만이기 때문이다. 되돌릴 수 없는 행동은 사람 승인 게이트 뒤에 둔다. 이것이 에스컬레이션 설계의 출발점이다.

시험 함정: "에이전트의 신뢰도(confidence)가 낮을 때 에스컬레이션하라"는 선택지가 자주 매력적으로 제시된다. confidence는 보조 신호일 뿐 1차 기준이 아니다. 신뢰도가 높아도 되돌릴 수 없는 행동이면 게이트를 거쳐야 하고, 신뢰도가 낮아도 가역적이면 굳이 사람을 부를 필요가 없다. "행동의 결과가 얼마나 위험한가(blast radius)"가 "모델이 얼마나 확신하는가"보다 우선한다.

%% 가역성 기반 에스컬레이션 flowchart TB A["에이전트가 행동을 시도"] Q{"되돌릴 수 있는가? (blast radius)"} AUTO["가역·읽기 전용\n자동 실행"] GATE["비가역·외부 영향\n결제·삭제·발송·배포"] H["사람 승인 게이트\npermission_policy: always_ask"] A --> Q Q -->|"예"| AUTO Q -->|"아니오"| GATE GATE --> H

도구 설계로 게이트를 만든다

에스컬레이션 게이트는 프롬프트가 아니라 도구 표면에서 강제해야 한다. bash 같은 만능 도구는 하네스에게 불투명한 명령 문자열만 넘기므로 curl -X POST를 막을 수 없다. 되돌릴 수 없는 행동은 전용 도구(send_email, delete_record)로 승격해 타입이 있는 인자를 받게 하면, 하네스가 그 호출을 가로채 승인을 요구할 수 있다. 가역적이고 읽기 전용인 도구만 자동 실행하고, 위험 도구는 게이트로 보내는 구조가 정석이다.

Managed Agents에서는 이 패턴이 권한 정책(permission policy)으로 1급 기능화되어 있다. 도구별로 permission_policyalways_ask로 설정하면, 에이전트가 그 도구를 호출하는 순간 세션이 session.status_idle로 멈추고 stop_reason.typerequires_action이 된다. 클라이언트는 user.tool_confirmation 이벤트로 result: "allow" 또는 "deny"를 회신한다. 거부 시 deny_message로 이유를 전달하면 에이전트가 다른 접근을 시도한다. 자동 실행 도구는 always_allow(기본값)로 둔다.

# 위험 도구만 승인 게이트 뒤에 두는 에이전트 설정agent = client.beta.agents.create(    name="Ops Agent",    model="claude-opus-4-8",    tools=[        {            "type": "agent_toolset_20260401",            "default_config": {                "enabled": True,                "permission_policy": {"type": "always_allow"},            },            "configs": [                {"name": "bash", "permission_policy": {"type": "always_ask"}},            ],        },    ],)

정지 신호를 올바르게 분기한다

에스컬레이션 판단의 입력은 모델이 왜 멈췄는가다. 직접 Messages API를 쓰는 워크플로에서 stop_reason을 분기하지 않고 response.content[0]을 무조건 읽으면, 거부나 일시정지 상황에서 인덱스 오류가 난다. 반드시 stop_reason을 먼저 검사한다.

stop_reason: "refusal"은 안전상 거부다. HTTP 200으로 돌아오며 stop_details.category에 사유가 담긴다. 같은 프롬프트로 재시도하지 말고 사람에게 올린다. pause_turn은 서버 측 도구 루프가 반복 한도에 도달한 상태로, 사람 개입이 아니라 직전 응답을 그대로 다시 보내면 서버가 이어서 처리한다. 이 둘을 혼동해 pause_turn을 에스컬레이션으로 처리하면 정상 진행을 끊고, refusal을 자동 재시도로 처리하면 무한 루프에 빠진다. max_tokens는 출력 잘림이므로 한도를 늘리거나 스트리밍으로 전환한다.

resp = client.messages.create(model="claude-opus-4-8", max_tokens=16000, messages=msgs)if resp.stop_reason == "refusal":    escalate_to_human(resp.stop_details)elif resp.stop_reason == "pause_turn":    msgs = [*msgs, {"role": "assistant", "content": resp.content}]    resp = client.messages.create(model="claude-opus-4-8", max_tokens=16000, messages=msgs)elif resp.stop_reason == "max_tokens":    handle_truncation()else:    deliver(resp.content)

human-in-the-loop의 위치와 SSE 함정

승인 게이트를 둘 때 가장 흔한 운영 사고는 SSE 스트림이 끊긴 사이에 도구 승인 요청을 놓치는 것이다. Managed Agents의 이벤트 스트림은 재생(replay)을 제공하지 않는다. agent.tool_use가 승인 대기 중일 때 클라이언트 연결이 끊기면, 세션은 idle로 멈춰 있고 재접속해도 그 이벤트는 다시 오지 않아 교착에 빠진다. 재접속 시 항상 events.list()로 과거 이력을 먼저 가져와 이벤트 ID로 중복 제거한 뒤 라이브 스트림을 이어 붙여야 한다. 또한 session.status_idle 하나만으로 종료를 판정하면 안 된다. stop_reason.typerequires_action이면 사람의 응답을 기다리는 중이므로 계속 진행하고, end_turn이나 retries_exhausted일 때만 루프를 빠져나간다.

정리

  • 에스컬레이션의 1차 기준은 가역성이다. 되돌릴 수 없는 행동은 신뢰도와 무관하게 사람 승인 게이트 뒤에 둔다.
  • 게이트는 프롬프트가 아니라 전용 도구로 강제한다. Managed Agents에서는 permission_policy: always_ask + user.tool_confirmation이 표준이다.
  • stop_reason을 먼저 분기한다. refusal은 사람에게, pause_turn은 응답을 그대로 재전송, max_tokens는 출력 잘림 처리로 구분한다.
  • SSE는 재생이 없다. 재접속 시 events.list()로 이력을 합치고, idle은 stop_reason.type을 확인해 requires_action이면 종료하지 않는다.
  • 시험은 confidence보다 blast radius, 프롬프트 지시보다 도구 표면 강제, idle 단독 판정의 위험을 묻는다.