에이전트 루프를 프레임워크 없이 직접 짠다
3장과 4장에서 본 왕복을 반복 가능하게 만들면 그게 에이전트 루프입니다. 거창하게 들리지만 실제로는 while 문 하나입니다. 여기서 "Agent SDK"라고 부르는 것들 대부분이 결국 이 루프에 재시도, 로깅, 타입 검증을 덧붙인 것에 지나지 않는다는 걸 알아두면, 나중에 프레임워크 코드를 읽을 때도 겁먹지 않습니다.
최소 루프
def run_agent(user_input: str, tools: list, tool_executors: dict, max_turns: int = 8) -> str:
messages = [{"role": "user", "content": user_input}]
for turn in range(max_turns):
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
tools=tools,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
text_blocks = [b.text for b in response.content if b.type == "text"]
return "".join(text_blocks)
tool_results = []
for block in response.content:
if block.type != "tool_use":
continue
executor = tool_executors.get(block.name)
output = executor(block.input) if executor else f"오류: 도구 {block.name} 없음"
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": tool_results})
return "최대 턴 수를 초과했습니다."이게 전부입니다. 도구를 몇 개를 등록하든, 모델이 몇 번을 호출하든 이 루프 하나로 다 돕니다. tool_executors에 이름과 함수를 매핑해 두기만 하면 되고, 도구를 늘릴 때 루프 코드는 손댈 필요가 없습니다.
max_turns는 장식이 아니다
for turn in range(max_turns)를 while True로 바꾸고 싶은 유혹이 들 겁니다. 그러지 마세요. 도구 실행 결과가 애매하거나 모델이 같은 도구를 계속 다른 인자로 재시도하는 패턴에 빠지면, while True는 그대로 무한 루프가 되어 API 비용이 카드값 청구서에 찍힐 때까지 멈추지 않습니다. 저는 이걸 실제로 겪은 뒤로 모든 에이전트 코드에 턴 상한을 강제로 넣습니다. 8장에서 이 실패 패턴을 더 자세히 다룹니다.
응답 하나에 텍스트와 도구 호출이 같이 올 수 있다
response.content는 리스트이고, 그 안에 text 블록과 tool_use 블록이 섞여 있을 수 있습니다. 모델이 "먼저 서울 날씨를 확인해 볼게요"라는 텍스트를 내놓은 다음 같은 응답 안에서 도구를 호출하는 경우가 흔합니다. 위 코드에서 text_blocks를 모으는 부분과 tool_use 블록을 순회하는 부분을 따로 처리하는 이유가 이것입니다. 하나만 처리하고 나머지를 버리면, 모델이 실제로 한 말의 절반이 사라집니다.
도구가 여러 개면 순서를 지켜야 한다
sequenceDiagram participant U as 사용자 participant M as 모델 participant L as 루프 코드 participant T as 도구 U->>L: "서울이랑 부산 중 어디가 따뜻해?" L->>M: 메시지 + tools M-->>L: tool_use(get_weather, 서울) + tool_use(get_weather, 부산) L->>T: 두 도구 병렬 실행 T-->>L: 결과 2개 L->>M: tool_result 2개 (tool_use_id로 매칭) M-->>L: stop_reason=end_turn, "부산이 더 따뜻합니다" L-->>U: 최종 답변
tool_results 배열에 결과를 담을 때, 모델이 보낸 tool_use_id와 정확히 일치시켜야 합니다. 응답 순서를 바꿔서 넣어도 API는 ID로 매칭하기 때문에 크래시는 안 나지만, ID 자체를 빠뜨리면 다음 호출에서 API가 400 에러를 반환합니다 — 모든 tool_use에는 대응하는 tool_result가 있어야 한다는 게 Anthropic Messages API의 제약입니다.
이 루프가 준비되면, 다음 장에서는 모델이 도구를 부르기 전에 왜 그 도구를 부르는지 스스로 말하게 만드는 ReAct 패턴을 얹어 봅니다. 지금 루프도 잘 동작하지만, 모델의 추론 과정이 안 보이면 뭐가 잘못됐는지 디버깅하기가 훨씬 어렵습니다.