4. 하네스 실행 루프

한 줄 정의. 실행 루프는 모델 호출→도구 실행→결과 관찰을 과업이 끝날 때까지 반복하는 한 바퀴이고, 운영 관점의 핵심은 언제 멈추고, 언제 다시 시도하고, 언제 사람에게 넘기느냐를 모델 바깥에서 정해 두는 것이다.
왜 중요한가
섹션 제목: “왜 중요한가”0. 에이전트 지도에서 본 계획·행동·관찰 루프는 “정상 동작”만 그린 그림입니다. 실제로 루프를 돌려 보면 도구가 타임아웃을 내고, 모델이 같은 실수를 반복하고, 끝나야 할 시점에도 안 끝나는 일이 생깁니다. 이 장은 그 루프에 분기를 달아 운영 가능한 상태로 만드는 작업을 다룹니다.
Anthropic은 에이전트가 매 단계에서 환경으로부터 “ground truth”(도구 호출 결과나 코드 실행 같은 실측 신호)를 얻어 진척을 평가하는 것이 핵심이라고 말합니다 — 원문은 “gain ground truth from the environment at each step (such as tool call results or code execution) to assess its progress”입니다.1 동시에 같은 글은 “The task often terminates upon completion, but it’s also common to include stopping conditions (such as a maximum number of iterations) to maintain control”이라고 못 박습니다.1
즉 루프의 자율성과 정지 조건은 한 쌍이며, 정지 조건이 없는 루프는 폭주합니다. 폭주의 대가도 같은 글이 분명히 합니다.
“The autonomous nature of agents means higher costs, and the potential for compounding errors” — 자율성은 곧 비용 상승과 오류 누적의 위험이라는 뜻입니다.1 이 한 문장이 이 장 전체의 설계 동기입니다.
무한 재시도, 상한 없는 루프, 무인 승인은 모두 비용과 오류를 복리로 키웁니다.
한 바퀴의 구조와 종료 판정
섹션 제목: “한 바퀴의 구조와 종료 판정”OpenAI Agents SDK의 Runner.run(), Runner.run_sync(), Runner.run_streamed()는 모두 같은 루프를 돕니다. 한 바퀴는 네 단계입니다.
(1) 현재 에이전트로 LLM을 호출하고, (2) 출력을 평가하고, (3) 도구 실행·핸드오프·종료 중 하나로 분기하고, (4) max_turns를 체크한 뒤 반복합니다.2 세 진입점의 차이는 루프 본체가 아니라 실행 방식에 있습니다.
run_sync()는 내부에서 .run()을 돌리는 동기 래퍼라 이미 이벤트 루프 안(예: 다른 async 함수 내부)이면 쓰면 안 됩니다. run_streamed()는 async로 실행하면서 스트리밍 이벤트를 RunResultStreaming으로 돌려줍니다.2
종료 규칙은 정확히 말하면 이렇습니다 — 원하는 output_type의 텍스트 출력이 나오고, 동시에 보류 중인 도구 호출이 없을 때 그 출력이 최종 출력(final output)이 됩니다.2 두 조건이 모두 충족돼야 합니다.
output_type을 명시했다면 그 타입의 구조화 출력이 나와야 final로 인정되고, 기본값(str)이면 도구 호출이 딸리지 않은 평문 텍스트가 곧바로 final입니다. 분기 둘은 종료가 아니라 루프 재시작입니다.
핸드오프가 나오면 current agent와 input을 교체하고 맥락을 갱신해 루프를 처음부터 다시 돕니다. 도구 호출이 나오면 도구를 실행하고 결과를 히스토리에 append한 뒤 루프를 재시작합니다.2
종료가 모델의 “선언”에 달려 있다는 점도 중요합니다. 모델이 위 조건을 만족하는 출력을 내야 끝나므로, 모델이 끝낼 줄 모르면 루프는 계속 돕니다. 그래서 종료 판정만으로는 부족하고, 바깥에서 거는 상한이 필요합니다.
최대 반복: 폭주를 막는 하드 스톱
섹션 제목: “최대 반복: 폭주를 막는 하드 스톱”상한은 Runner.run / run_sync / run_streamed에 넘기는 정수 매개변수 max_turns입니다. 초과하면 MaxTurnsExceeded 예외가 납니다.2 여기서 운영상 두 가지를 짚어야 합니다.
첫째, max_turns는 RunConfig 레벨 설정이 아니라 호출마다 거는 per-run 인자입니다. 그리고 한 번 핸드오프돼 에이전트가 바뀌어도 turn 카운트는 리셋되지 않고 누적됩니다.2
즉 여러 에이전트로 넘기는 워크플로에서 max_turns는 전체 루프의 총예산이지 에이전트별 예산이 아닙니다. 이걸 놓치면 핸드오프 두세 번 만에 예상보다 빨리 상한에 부딪힙니다.
둘째, 상한에 걸렸을 때 손에 쥔 게 예외만은 아닙니다. MaxTurnsExceeded는 AgentsException을 베이스로 하고 message: str 속성을 가지는데, 베이스 클래스가 run_data: RunErrorDetails | None을 들고 있습니다.3
덕분에 상한에 걸려 중단됐어도 그때까지의 input·new_items·raw_responses·last_agent·context_wrapper·input_guardrail_results·output_guardrail_results를 회수해서 부분 진척을 복구하거나 로깅할 수 있습니다. 20바퀴 내내 한 일이 통째로 날아가는 게 아니라, 중단 직전 상태를 꺼내 이어 붙이거나 사후 분석할 수 있다는 뜻입니다.
상한에 걸렸을 때 예외를 그냥 위로 던지는 대신, 뒤에서 다룰 error_handlers에 "max_turns" 키를 등록해 통제된 폴백 응답으로 바꿀 수도 있습니다. 폭주를 막는 안전판과, 막혔을 때 사용자에게 보여 줄 메시지는 별개로 설계해 두는 편이 좋습니다.
승인 게이트: 위험한 행동 앞에서 멈추기
섹션 제목: “승인 게이트: 위험한 행동 앞에서 멈추기”자율 루프가 파일을 지우거나 결제를 일으키는 도구를 쥐고 있다면, 실행 전에 사람이 한 번 보는 관문이 필요합니다. Anthropic은 에이전트가 “pause for human feedback at checkpoints or when encountering blockers”할 수 있다고 설명하고, 그 상위 맥락으로 “extensive testing in sandboxed environments, along with the appropriate guardrails”를 권합니다.1
승인 게이트는 그 가드레일의 한 형태입니다.
SDK에서 게이트는 함수 도구의 needs_approval로 켭니다. 기본값은 False(승인 불필요)이고, 데코레이터에 직접 겁니다.4
from agents import function_tool
@function_tool(needs_approval=True)def delete_file(path: str) -> str: ...승인이 필요하면 run이 멈추고, 보류 항목이 result.interruptions에 ToolApprovalItem으로 담겨 돌아옵니다. 문서는 각 항목이 “agent.name, tool_name, arguments” 같은 세부를 담는다고 설명하니, 사람에게 “어떤 에이전트가 어떤 도구를 어떤 인자로 부르려 하는지”를 그대로 보여 줄 수 있습니다.5
Agent.as_tool(..., needs_approval=...)과 ShellTool도 같은 게이트를 지원합니다. 다만 두 가지 제약을 기억해야 합니다.
호스티드 도구(WebSearchTool 등)는 승인 게이트를 노출하지 않습니다. 그리고 호스티드 컨테이너 환경의 ShellTool에는 executor/needs_approval/on_approval을 설정하지 말아야 합니다 — 이건 자주 틀리는 제약입니다.4
승인 게이트는 정지 조건과 달리 루프를 끝내는 게 아니라 잠시 일시정지시키는 장치라는 점이 핵심입니다.
일시정지·재개와 상태 직렬화
섹션 제목: “일시정지·재개와 상태 직렬화”멈춘 뒤에는 result.to_state()로 RunState를 만든 다음 결정을 내립니다. 결정 메서드에는 본문 예제가 흔히 생략하는 옵션 인자가 있습니다.5
state.approve(interruption, always_approve=False)— 해당 도구 호출을 승인합니다.state.reject(interruption, rejection_message=...)— 거부합니다.
always_approve=True(또는 거부 쪽의 always_reject=True)는 그 도구에 대한 결정을 이번 run 나머지 동안 캐시합니다.5 같은 도구를 루프 안에서 여러 번 부르는 경우, 매번 똑같은 승인 프롬프트가 뜨는 걸 없애는 운영 노브입니다.
rejection_message는 그 호출 한 건에 한해 run 전체의 tool_error_formatter를 덮어써, 모델에게 보일 거부 사유를 그 자리에서 커스터마이즈합니다.5
재개는 Runner.run(agent, state) — 원래 최상위 에이전트와 state를 함께 넘깁니다.5 한 프로세스 안에서라면 while result.interruptions: 루프로 보류 항목을 차례로 처리하면 되고, 프로세스를 건너야 한다면 위 직렬화로 state를 옮긴 뒤 같은 호출로 재개합니다.
import jsonfrom agents import Runner, RunState
result = await Runner.run(agent, input="이 폴더 정리해 줘", max_turns=20)
while result.interruptions: state = result.to_state() for item in result.interruptions: if human_says_yes(item.tool_name, item.arguments): state.approve(item, always_approve=True) # 이 도구는 이후 자동 승인 else: state.reject(item, rejection_message="이 파일은 건드리지 마세요")
# 다른 프로세스에서 재개해야 한다면 직렬화해서 넘긴다 blob = state.to_json() # ... 저장/전송 ... state = await RunState.from_json(agent, json.loads(blob)) # 복원 result = await Runner.run(agent, state, max_turns=20)오류 처리: 예외 분류와 carrying data
섹션 제목: “오류 처리: 예외 분류와 carrying data”도구는 실패합니다. SDK는 상황별 예외를 계층으로 정리해 두는데, 정점은 AgentsException(Exception 상속)입니다.
핵심은 이 베이스가 run_data: RunErrorDetails | None을 들고 있어, 어떤 예외든 부분 결과를 함께 실어 나른다는 공통 메커니즘입니다.3 RunErrorDetails는 데이터클래스로 input·new_items·raw_responses·last_agent·context_wrapper·input_guardrail_results·output_guardrail_results 필드를 가집니다.3
즉 예외를 잡으면 거기서 디버깅·복구 컨텍스트를 그대로 꺼낼 수 있습니다. 개별 예외만 보고 “실패했으니 버린다”가 아니라, 예외가 운반한 데이터로 이어 붙이거나 분석하는 것이 운영의 기본기입니다.
개별 예외의 의미와 속성은 다음과 같습니다.3
ModelBehaviorError— 존재하지 않는 도구를 부르거나 malformed JSON을 내는 등 모델의 예기치 못한 행동.message: str.ModelRefusalError— 모델이 출력을 거부. 거부 사유는message가 아니라refusal: str속성에 담깁니다.ToolTimeoutError—tool_name·timeout_seconds속성 보유.InputGuardrailTripwireTriggered/OutputGuardrailTripwireTriggered— 각각guardrail_result(InputGuardrailResult/OutputGuardrailResult)를 들고 있습니다. 이 밖에ToolInputGuardrailTripwireTriggered·ToolOutputGuardrailTripwireTriggered·MCPToolCancellationError, 그리고 SDK 오용을 알리는UserError도 있습니다.
예외와 별개로, 도구 자체의 실패를 도구 레벨에서 처리하는 훅도 있습니다. failure_error_function은 def f(context: RunContextWrapper[Any], error: Exception) -> str 시그니처로, 도구가 던진 예외를 받아 모델에게 보일 문자열로 변환합니다.
기본은 default_tool_error_function이라 LLM에게 “오류가 났다”고 통지하고 루프를 이어 갑니다. 반대로 failure_error_function=None을 주면 오류를 변환하지 않고 그대로 re-raise합니다.4
도구 하나의 사소한 실패는 모델이 보고 스스로 우회하게 두고, 치명적 실패만 위로 던지고 싶다면 이 훅으로 도구별 정책을 가릅니다.
재시도와 SDK 차원의 회복 노브
섹션 제목: “재시도와 SDK 차원의 회복 노브”재시도는 만능이 아닙니다. 네트워크 타임아웃처럼 일시적 오류는 같은 호출을 다시 거는 게 합리적이지만, 입력이 잘못돼 나는 오류는 몇 번을 다시 걸어도 같은 결과만 반복합니다.
무의미한 재시도는 앞서 인용한 “compounding errors”와 비용 상승을 그대로 키웁니다.1 그래서 재시도는 횟수 상한과 함께 걸고, 그래도 안 되면 폴백이나 사람 인계로 빠지게 설계합니다.
위 도식에서 “오류?” 분기가 관찰 단계로 되돌아가는 화살표가 이 재시도 경로입니다.
이 재시도 경로는 손으로만 짤 필요가 없습니다. RunConfig 차원에 회복 노브가 있어 코드로 구체화됩니다.
모델이 없는 도구를 부를 때의 동작을 정하는 tool_not_found_behavior="return_error_to_model"은 예외를 던지는 대신 오류를 모델에게 돌려줘 모델이 스스로 고쳐 부르게 합니다. 모델에게 보일 도구 오류 메시지의 포맷을 통제하는 tool_error_formatter도 같은 자리에 있습니다.2
이 둘은 “실패를 예외로 끝내지 않고 모델의 다음 한 바퀴 입력으로 되먹이는” 재시도 경로를 SDK 레벨에서 구현합니다.
에러 핸들러 폴백
섹션 제목: “에러 핸들러 폴백”예외를 위로 던지는 것과 대비되는 길이 error_handlers입니다. Runner 메서드에 dict를 넘기고 "max_turns"·"model_refusal" 같은 키로 통제된 폴백을 등록합니다.2
핸들러는 RunErrorHandlerInput[T]를 받아 RunErrorHandlerResult를 돌려주는데, 이 결과의 두 필드가 핵심입니다.2
final_output— 폴백으로 사용자에게 내보낼 응답.include_in_history— 그 폴백을 대화 히스토리에 넣을지 여부(bool).
from agents import Runner, RunErrorHandlerResult
def on_max_turns(inp): return RunErrorHandlerResult( final_output="여기까지 진행했고 더는 자동으로 끝내지 못했습니다. 검토가 필요합니다.", include_in_history=True, )
result = await Runner.run( agent, input="...", max_turns=20, error_handlers={"max_turns": on_max_turns},)이 폴백은 max_turns 초과나 모델 거부를 사용자에게 보일 메시지로 변환하는 자리, 즉 “우아한 실패” 경로입니다. 폭주 방지(상한)와 막혔을 때의 메시지를 분리해 두라는 앞 절의 원칙이 여기서 코드가 됩니다.
루프 간 상태 전달: 대화 스레딩
섹션 제목: “루프 간 상태 전달: 대화 스레딩”지금까지는 한 바퀴 안의 분기였습니다. 멀티턴 대화처럼 루프를 여러 번 이으려면, 한 run의 결과를 다음 run 입력으로 넘기는 방식을 골라야 합니다. SDK는 세 가지를 둡니다.2
result.to_input_list()— 앱이 메모리에서 직접 관리하는 방식. 결과를 입력 리스트로 펼쳐 다음 호출에 그대로 넘깁니다.session(SQLite 등) — 외부 저장소가 히스토리를 자동 관리합니다.- 서버 관리 —
conversation_id또는previous_response_id(result.last_response_id체이닝)로 서버 쪽에 대화를 맡깁니다.
| 분기 | 트리거 | 하네스의 처리 |
|---|---|---|
| 종료 | output_type 텍스트 + 보류 도구 호출 없음 | 결과 반환, 루프 종료 |
| 핸드오프 | 모델이 핸드오프를 냄 | current agent·input 교체 후 루프 재시작 |
| 도구 호출 | 모델이 도구를 부름 | 실행→결과 append→루프 재시작 |
| 최대 반복 | 누적 turn 수 > max_turns | MaxTurnsExceeded(+run_data) 또는 핸들러 폴백 |
| 승인 | 도구 needs_approval | 일시정지 → interruptions → approve/reject |
| 오류 | 도구 실패·타임아웃·거부 | 예외 분류(+run_data) → 재시도/폴백/핸들러 |
요약 · 체크리스트
섹션 제목: “요약 · 체크리스트”- 한 바퀴의 4단계와 종료 규칙(“원하는
output_type텍스트 + 보류 도구 호출 없음”)을 정확히 말할 수 있고, 1 turn 안에서 LLM 호출이 여러 번 일어남을 안다. -
max_turns가 per-run·누적 카운트임을 알고,MaxTurnsExceeded의run_data로 부분 진척을 회수할 수 있다. -
needs_approval을 bool뿐 아니라 콜러블로 조건부 승인할 수 있고,RunState직렬화로 프로세스를 건너 재개할 수 있다. - 도구 타임아웃 기본이
error_as_result(예외 아님)임을 알고,failure_error_function·tool_not_found_behavior로 도구 레벨 회복을 설계할 수 있다. -
error_handlers의final_output·include_in_history로 우아한 실패를 구성할 수 있다.