에스컬레이션 로직 설계
에스컬레이션은 에이전트가 자기 권한 밖의 결정을 사람 또는 상위 시스템으로 넘기는 메커니즘이다. 아키텍트 시험은 "에이전트가 더 똑똑하게 일하도록"이 아니라 "어떤 행동을 에이전트가 스스로 실행하게 두면 안 되는가"를 묻는다. 정답을 가르는 기준은 거의 항상 하나로 수렴한다. 가역성(reversibility)이다.
왜 가역성이 설계의 1차 기준인가
행동을 두 부류로 나눈다. 되돌릴 수 있는 행동(파일 읽기, 검색, 로컬 계산)과 되돌릴 수 없는 행동(외부 API로 POST, 이메일 발송, 데이터 삭제, 결제, 프로덕션 배포)이다. 되돌릴 수 있는 행동은 에이전트가 자율로 실행해도 된다. 실수해도 다시 시도하거나 롤백하면 그만이기 때문이다. 되돌릴 수 없는 행동은 사람 승인 게이트 뒤에 둔다. 이것이 에스컬레이션 설계의 출발점이다.
시험 함정: "에이전트의 신뢰도(confidence)가 낮을 때 에스컬레이션하라"는 선택지가 자주 매력적으로 제시된다. confidence는 보조 신호일 뿐 1차 기준이 아니다. 신뢰도가 높아도 되돌릴 수 없는 행동이면 게이트를 거쳐야 하고, 신뢰도가 낮아도 가역적이면 굳이 사람을 부를 필요가 없다. "행동의 결과가 얼마나 위험한가(blast radius)"가 "모델이 얼마나 확신하는가"보다 우선한다.
도구 설계로 게이트를 만든다
에스컬레이션 게이트는 프롬프트가 아니라 도구 표면에서 강제해야 한다. bash 같은 만능 도구는 하네스에게 불투명한 명령 문자열만 넘기므로 curl -X POST를 막을 수 없다. 되돌릴 수 없는 행동은 전용 도구(send_email, delete_record)로 승격해 타입이 있는 인자를 받게 하면, 하네스가 그 호출을 가로채 승인을 요구할 수 있다. 가역적이고 읽기 전용인 도구만 자동 실행하고, 위험 도구는 게이트로 보내는 구조가 정석이다.
Managed Agents에서는 이 패턴이 권한 정책(permission policy)으로 1급 기능화되어 있다. 도구별로 permission_policy를 always_ask로 설정하면, 에이전트가 그 도구를 호출하는 순간 세션이 session.status_idle로 멈추고 stop_reason.type이 requires_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.type이 requires_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 단독 판정의 위험을 묻는다.