AI 에이전트 개발 실전 #1 에이전트 루프 단단하게 만들기
LLM 앱 개발 실전 10편에서 에이전트를 처음 만들었습니다. 도구 여러 개와 목표를 주면 Claude가 순서를 스스로 정해 일을 끝내는 루프였습니다. 그 루프는 동작하지만, 실제 서비스에 넣기에는 빈틈이 있습니다. 이 시리즈에서는 그 빈틈을 하나씩 메워서, 긴 작업을 맡겨도 무너지지 않는 에이전트를 만듭니다.
시리즈는 7편입니다. 루프 견고화(1편), 도구 설계(2편), 계획과 자기 수정(3편), 컨텍스트 관리(4편), 서브에이전트(5편), MCP 서버 제작(6편)을 거쳐, 마지막 7편에서 이슈 트리아지(triage, 밀려드는 것들을 종류와 우선순위로 분류하는 일) 에이전트를 완성합니다. 모델은 에이전트처럼 여러 단계를 스스로 판단하는 작업에 가장 강한 claude-opus-4-8을 사용합니다.
최소 루프의 빈틈 #
10편의 루프를 다시 보면, 응답의 stop_reason이 end_turn 아니면 도구 호출이라고 가정하고 있습니다.
if response.stop_reason == "end_turn":
return next(b.text for b in response.content if b.type == "text")
# 아니면 도구를 실행하고 계속실제로는 다른 경우가 더 있습니다. 응답이 max_tokens에 걸려 중간에 잘릴 수 있고, 안전상의 이유로 refusal이 올 수도 있습니다. 또 도구 함수 안에서 예외가 터지면 루프 전체가 죽습니다. 에이전트는 한 번 돌고 마는 코드가 아니라 수십 번 반복하는 코드라서, 한 바퀴에서 1%짜리 사고도 루프 전체로 보면 자주 일어나는 일이 됩니다.
stop_reason 전부 처리하기 #
루프가 만날 수 있는 stop_reason을 정리하면 이렇습니다.
| stop_reason | 의미 | 루프에서 할 일 |
|---|---|---|
tool_use | 도구를 부르고 싶다 | 도구 실행 후 결과를 돌려주고 계속 |
end_turn | 답을 끝냈다 | 최종 텍스트를 반환하고 종료 |
max_tokens | 출력 상한에 걸려 잘렸다 | 상한을 늘려 재시도하거나 에러 처리 |
refusal | 안전상의 이유로 거부했다 | 같은 요청을 반복하지 말고 종료 |
분기를 코드로 옮기면 이렇게 됩니다.
if response.stop_reason == "end_turn":
return final_text(response)
if response.stop_reason == "max_tokens":
raise RuntimeError("응답이 잘렸습니다. max_tokens를 늘려 주세요.")
if response.stop_reason == "refusal":
return "요청을 처리할 수 없습니다."
# 남는 건 tool_use. 도구를 실행하고 루프를 계속한다.max_tokens를 에러로 처리하는 이유가 있습니다. 잘린 응답을 그대로 대화에 쌓고 계속 돌리면, Claude는 잘린 자기 말을 이어받아 점점 이상한 방향으로 갑니다. 잘렸다는 사실을 아는 것이 어설프게 이어 가는 것보다 낫습니다.
도구 에러를 결과로 돌려주기 #
도구 함수에서 예외가 나면 루프가 죽는 것이 기본 동작입니다. 더 좋은 방법은 예외를 잡아서 에러도 도구 결과로 돌려주는 것입니다. tool_result에 is_error 표시를 하면 Claude가 에러를 읽고 다른 방법을 시도합니다.
def execute_tool(block) -> dict:
try:
result = run_tool(block.name, block.input)
return {
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
}
except Exception as e:
return {
"type": "tool_result",
"tool_use_id": block.id,
"content": f"도구 실행 실패: {e}",
"is_error": True,
}예를 들어 파일 읽기 도구가 “파일이 없습니다"라는 에러 결과를 받으면, Claude는 목록 조회 도구로 경로를 다시 확인하는 식으로 스스로 복구합니다. 예외로 죽었다면 일어나지 않았을 일입니다.
일시적인 API 에러는 SDK가 재시도합니다 #
루프가 길어지면 그 사이 네트워크 오류나 일시적인 과부하(429, 5xx)를 만날 확률도 올라갑니다. 이건 직접 구현할 필요가 없습니다. Anthropic SDK가 기본으로 2회까지 지수 백오프(exponential backoff, 실패할 때마다 재시도 간격을 배로 늘리는 방식) 재시도를 하고, 횟수는 max_retries로 조절합니다.
client = anthropic.Anthropic(max_retries=4)완성된 루프 #
지금까지의 내용을 합친 루프입니다. 어떤 도구를 어떤 입력으로 불렀는지 기록하는 로그도 넣었습니다. 에이전트가 이상하게 동작할 때 원인을 찾으려면 이 로그가 사실상 유일한 단서입니다.
import logging
import anthropic
logger = logging.getLogger("agent")
client = anthropic.Anthropic(max_retries=4)
def run_agent(goal: str, max_steps: int = 20) -> str:
messages = [{"role": "user", "content": goal}]
for step in range(max_steps):
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
tools=tools,
messages=messages,
)
if response.stop_reason == "end_turn":
return next(b.text for b in response.content if b.type == "text")
if response.stop_reason == "max_tokens":
raise RuntimeError("응답이 max_tokens에 걸려 잘렸습니다.")
if response.stop_reason == "refusal":
return "요청을 처리할 수 없습니다."
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
logger.info("step=%d tool=%s input=%s", step, block.name, block.input)
tool_results.append(execute_tool(block))
messages.append({"role": "user", "content": tool_results})
raise RuntimeError(f"{max_steps}단계 안에 작업을 끝내지 못했습니다.")10편과 달라진 점을 짚어 보면, stop_reason 네 가지를 모두 처리하고, 도구 예외가 루프를 죽이는 대신 is_error 결과로 돌아가며, 최대 단계 초과를 조용히 넘기지 않고 에러로 알립니다. 구조는 같지만 어떤 상황에서도 “왜 멈췄는지"를 알 수 있는 루프가 됐습니다.
한 가지 더, Claude는 한 응답에서 도구를 여러 개 동시에 부를 수 있습니다. 위 코드처럼 response.content의 tool_use 블록을 전부 순회하고, 블록마다 하나씩 tool_result를 만들어 한 메시지로 돌려줘야 합니다. 하나라도 빠지면 API가 다음 요청을 거부합니다.
흔히 걸려 넘어지는 곳 #
- 잘린 응답을 그대로 잇는다 —
max_tokens로 잘린 응답을 대화에 쌓고 계속 돌리면 출력이 점점 무너집니다. 잘림은 에러로 처리하고 상한을 늘립니다. - tool_result 개수가 안 맞는다 — 병렬 호출에서 일부 도구의 결과만 돌려주면 “tool_use_id에 대응하는 결과가 없다"는 400 에러가 납니다. 에러가 난 도구도
is_error결과로 채워서 개수를 맞춥니다. - 로그 없이 돌린다 — 에이전트가 엉뚱한 결론을 내렸을 때 로그가 없으면 어느 단계에서 틀어졌는지 알 방법이 없습니다. 도구 이름과 입력만이라도 기록합니다.
마무리 #
이번 글에서는 최소 에이전트 루프를 실전 수준으로 끌어올렸습니다.
stop_reason은tool_use와end_turn외에max_tokens,refusal까지 모두 분기합니다.- 도구 예외는 루프를 죽이는 대신
is_error결과로 돌려줘서 Claude가 스스로 복구하게 합니다. - 일시적인 API 에러는 SDK의
max_retries에 맡기고, 도구 호출 로그를 남깁니다.
루프가 단단해졌으니 다음은 그 위에서 도는 도구 차례입니다. 다음 글인 “AI 에이전트 개발 실전 #2 좋은 도구 설계하기"에서는 같은 루프라도 에이전트의 성능을 완전히 바꿔 놓는 도구 설계를 다룹니다.