Agent Loop는 모델 호출이 아니라 상태 기계다
메뉴

AI Agent

Agent Loop는 모델 호출이 아니라 상태 기계다

Claude Code CLI 분석을 기반으로 agent query loop를 streaming model call, tool request, result injection이 반복되는 상태 기계로 설명합니다.

Agent Loop는 모델 호출이 아니라 상태 기계다 hero image
Markdown약 2042 tokens

핵심 요약

  • Agent loop는 단발성 모델 API 호출이 아니라 반복 가능한 상태 기계다.
  • 모델은 텍스트를 생성하다가 tool request를 만들 수 있고, runtime은 결과를 다시 message로 주입한다.
  • stream event, usage update, stop reason, tool call은 모두 runtime event로 처리해야 한다.
  • continuation condition이 명확해야 무한 루프, 누락된 tool result, 비용 폭주를 막을 수 있다.

이번 글에서는 agent runtime의 심장인 query loop를 다룹니다.

많은 LLM 앱은 모델 호출을 “요청 하나, 응답 하나”로 생각합니다. 하지만 agent는 다릅니다. 모델이 응답 중간에 도구 사용을 요청할 수 있고, runtime은 도구를 실행한 뒤 그 결과를 다시 메시지로 넣어 모델에게 이어서 추론하게 해야 합니다.

즉 agent loop는 모델 호출 함수가 아니라 streaming event와 tool result injection이 반복되는 상태 기계입니다.

1. 모델 호출 하나로 agent가 되지 않는 이유

모델이 도구를 요청하면 응답은 끝난 것이 아닙니다. 오히려 그때부터 agent 실행이 시작됩니다.

# 읽는 법: 아래 항목은 동작 흐름을 빠르게 확인하기 위한 요약 예시입니다.messages→ model stream→ assistant text 일부 출력→ tool request 발생→ tool runtime 실행→ tool result message 생성→ messages에 재주입→ model stream 재개→ final answer

이 반복을 처리하지 못하면 도구 호출은 단발성 함수 실행에 머뭅니다. 모델은 외부 세계를 관찰한 결과를 보고 다음 판단을 할 수 없습니다.

2. Query loop의 상태

agent loop는 다음 상태를 관리합니다.

상태설명
messages현재 모델에게 보낼 conversation snapshot
turn_counttool result 이후 모델을 몇 번 다시 호출했는지
tool_context현재 turn에서 사용 가능한 도구와 권한 정보
usagestreaming 중 누적되는 token/cost 정보
compactioncontext가 길어질 때 요약 또는 압축 상태
cancellation사용자 중단 또는 timeout 상태
pending_tool_results실행 완료 후 재주입할 result messages

이 상태들은 provider 호출 하나에서 끝나지 않습니다. tool result를 넣고 다시 모델을 부를 때 이어져야 합니다.

3. Stream event를 runtime event로 바꾸기

provider API가 주는 event 이름을 앱 전체에 그대로 퍼뜨리면 provider를 바꾸기 어렵습니다. 따라서 query loop는 provider stream을 내부 runtime event로 변환해야 합니다.

Provider event 성격Runtime event 예시
message startassistant_started
text deltaassistant_text_delta
tool call delta/donetool_request_ready
usage updateusage_updated
message stopassistant_message_finished
errormodel_stream_failed

이렇게 하면 session shell, ledger, cost tracker는 provider SDK 타입을 몰라도 됩니다.

4. Tool result 재주입

도구 결과는 단순 출력 문자열이 아닙니다. 다음 모델 호출에 들어갈 관찰값입니다.

도구가 실패해도 result message를 만들어야 합니다. 모델이 요청한 tool call에 대응되는 결과가 없으면 다음 추론이 불안정해질 수 있습니다.

# 읽는 법: 아래 항목은 동작 흐름을 빠르게 확인하기 위한 요약 예시입니다.tool request id: T-17→ schema validation failed→ permission denied→ execution failed→ success result

위 네 경우 모두 “결과”입니다. 성공만 결과로 보는 것이 아니라, 실패도 모델이 읽을 수 있는 관찰값으로 만들어야 합니다.

실무 팁
tool error를 Python exception으로만 처리하지 말고 model-visible error result로 바꿔보세요. agent가 스스로 다른 전략을 선택할 수 있습니다.

5. Continuation condition 설계

agent loop가 언제 계속 돌고 언제 멈추는지 명확해야 합니다.

조건처리
tool request 없음final answer로 종료
tool request 있음tool runtime 실행 후 messages에 result 주입
max turn 초과turn limit final event 생성
context 초과compaction 후 계속 또는 사용자에게 안내
cancel 요청중단 result 기록 후 종료
budget 초과정책에 따라 중단 또는 승인 요청

이 조건이 흩어져 있으면 agent가 무한히 돌거나, tool result가 빠지거나, 비용이 예상보다 커질 수 있습니다.

6. 개념 코드로 보는 agent loop

아래 코드는 설명용으로 새로 작성한 코드입니다.

# 읽는 법: 실제 구현 복제가 아니라 runtime 경계를 설명하는 개념 코드입니다.class LoopState:    # 객체가 이후 단계에서 참조할 runtime 의존성과 상태 저장소를 초기화합니다.    def __init__(self, messages, tool_context):        self.messages = list(messages)        self.tool_context = tool_context        self.turn_count = 0        self.usage = UsageSnapshot()        self.cancelled = False # 모델 스트림을 읽고 tool request를 실행한 뒤 결과를 다시 model message로 주입합니다.async def run_agent_loop(initial_messages, runtime):    state = LoopState(initial_messages, runtime.tool_context)     while True:        if state.turn_count >= runtime.limits.max_model_rounds:            await runtime.ledger.write("turn_limit_reached", state.turn_count)            return FinalAnswer(reason="turn_limit")         if runtime.abort_signal.requested:            return FinalAnswer(reason="cancelled")         request = runtime.provider_adapter.build_request(state.messages, state.tool_context)        tool_requests = []         async for event in runtime.provider_adapter.stream(request):            if event.type == "assistant_text_delta":                runtime.shell.publish(event)             elif event.type == "tool_request_ready":                tool_requests.append(event.tool_request)             elif event.type == "usage_updated":                state.usage.merge(event.usage)                runtime.accounting.update(state.usage)         if not tool_requests:            return FinalAnswer(reason="assistant_done", usage=state.usage)         async for result in runtime.tools.execute_batch(tool_requests, state.tool_context):            state.messages.append(result.to_model_message())            runtime.shell.publish(result.to_screen_event())            await runtime.ledger.write("tool_result", result.summary())         state.turn_count += 1

이 구조에서 provider adapter와 tool runtime은 서로의 내부 구현을 모릅니다. query loop가 둘 사이를 연결합니다.

7. AI 활용 개발자 관점

AI coding agent를 사용할 때 긴 작업이 어떻게 진행되는지 관찰해보면 좋습니다.

  • 모델이 도구를 요청했는지 화면에 표시되는가?
  • 도구 실행 결과가 요약되어 보이는가?
  • 실패한 도구에 대해 agent가 다른 전략을 시도하는가?
  • 반복 실행이 너무 길어질 때 제한이 있는가?
  • 비용/토큰이 중간에 갱신되는가?

이 정보가 보이지 않는 agent는 편해 보일 수 있지만, 팀 환경에서는 감사와 디버깅이 어렵습니다.

8. Agent 개발자 체크리스트

# 읽는 법: 아래 항목은 동작 흐름을 빠르게 확인하기 위한 요약 예시입니다.Query Loop 체크리스트 [ ] 모델 호출은 반복 가능한 loop로 설계되어 있다.[ ] provider stream은 내부 runtime event로 변환된다.[ ] tool request는 수집된 뒤 tool runtime으로 넘어간다.[ ] tool result는 model-visible message로 재주입된다.[ ] 실패한 tool request도 result message를 만든다.[ ] max turn, cancel, budget, context compaction 조건이 명확하다.[ ] usage/cost는 stream 중간 event에서도 갱신된다.

실패 사례: tool result를 넣지 않고 다음 호출로 넘어간 경우

Agent loop에서 가장 위험한 버그 중 하나는 모델이 tool request를 냈는데 runtime이 그 결과를 message history에 정확히 다시 넣지 않는 경우입니다. 화면에는 도구가 실행된 것처럼 보이지만 다음 모델 호출에는 결과가 빠져 있습니다. 그러면 모델은 같은 도구를 다시 요청하거나, 결과를 상상해서 답변하거나, 이미 실패한 작업을 성공한 것처럼 이어갑니다.

이 문제는 streaming UI에서 더 자주 발생합니다. 화면은 token stream을 기준으로 움직이고, backend는 tool event를 기준으로 움직이며, ledger는 최종 message만 저장할 수 있습니다. 세 계층이 같은 turn id와 tool call id를 공유하지 않으면 재현하기 어려운 ghost state가 생깁니다. 사용자는 "방금 파일을 읽었다"고 봤는데 모델은 읽지 않은 상태로 답하는 식입니다.

상태 전이 예시

Query loop를 상태 기계로 두면 이 실패를 줄일 수 있습니다.

idle  -> streaming_model  -> collecting_tool_requests  -> executing_tools  -> injecting_tool_results  -> streaming_model  -> done

각 상태에는 들어갈 수 있는 event와 나갈 수 있는 event가 정해져야 합니다. executing_tools 상태에서 새 model stream을 시작하려면 모든 required tool result가 history에 들어갔는지 확인해야 합니다. 반대로 optional telemetry event는 model history에 넣지 않아도 됩니다. 이 구분이 없으면 context는 불필요하게 커지고, 중요한 결과는 누락됩니다.

EventModel historyUI streamLedger
assistant tokenyesyesyes
tool requestyesyesyes
tool progressnoyesoptional
tool resultyesyesyes
usage updatenoyesyes

체크리스트 적용 결과

실제 구현을 리뷰할 때는 "모델을 몇 번 호출하는가"보다 "반복 조건이 어디에 있는가"를 봅니다. max turn, max tool calls, cancel signal, budget exceeded, provider stop reason이 모두 같은 loop controller에서 판단되어야 합니다. 각각의 adapter가 임의로 재시도하면 agent는 멈추기 어려워집니다. 특히 비용이 걸린 제품에서는 usage update가 늦게 도착해도 다음 turn 전에 예산을 다시 계산해야 합니다.

구현 비교: 단순 while loop와 운영형 loop controller

Agent loop를 처음 만들 때는 while tool_calls: 형태로 충분해 보입니다. 하지만 제품에서는 중단, 비용, tool 실패, context 압축, 사용자 승인 같은 조건이 들어옵니다. 단순 while loop는 이 조건들을 각 handler에 흩뿌리기 쉽습니다.

항목단순 loop운영형 loop controller
중단provider exception에 의존cancel signal과 final event 분리
비용마지막 usage만 계산turn마다 budget 재평가
tool 실패예외로 종료실패 result를 history에 주입
context초과하면 실패compaction 또는 사용자 안내
감사최종 답변만 저장turn, tool request, result를 모두 기록

예를 들어 파일 검색 tool이 실패했다고 해봅시다. 운영형 loop에서는 tool_result에 실패 이유를 넣고 모델에게 다시 전달합니다. 모델은 다른 경로를 시도하거나 사용자에게 "현재 파일을 읽을 수 없다"고 답할 수 있습니다. 단순 loop에서는 exception이 밖으로 터지고 화면에는 generic error만 남을 수 있습니다.

경계 사례도 있습니다. tool request가 두 개 이상이고 하나는 성공, 하나는 권한 거부라면 어떻게 해야 할까요? 모든 결과를 모은 뒤 같은 turn의 관찰값으로 넣어야 합니다. 성공한 결과만 넣으면 모델은 거부된 작업을 성공했다고 착각할 수 있고, 거부만 넣으면 이미 얻은 정보를 버립니다. loop controller는 이처럼 "다음 모델 호출 전에 history가 완전한가"를 확인하는 계층이어야 합니다.

마무리

Agent loop를 이해하면 AI agent의 본질이 보입니다. 모델은 혼자 실행하지 않습니다. runtime이 stream을 읽고, 도구를 실행하고, 결과를 다시 넣고, 언제 멈출지 결정합니다.

다음 글에서는 provider API boundary를 보겠습니다. 제품 로직이 provider SDK 타입에 끌려가지 않게 하려면 모델 호출 경계를 어떻게 설계해야 할까요?

Key takeaways

  • Agent loop is a repeatable state machine rather than a one-time model API call.
  • The model can create a tool request while generating text, and the runtime injects the result back into a message.
  • Stream events, usage updates, stop reasons, and tool calls must all be handled as runtime events.
  • The continuation condition must be clear to prevent infinite loops, missing tool results, and runaway costs.

In this article, we cover the query loop, the heart of agent runtime.

Many LLM apps think of model calls as “one request, one response.” But agents are different. The model may request the use of a tool in the middle of a response, and the runtime must execute the tool and send the result back to the message to allow the model to continue making inferences.

In other words, the agent loop is not a model call function but a state machine that repeats streaming events and tool result injection.

1. Why an agent cannot be created with just one model call

Once the model requests a tool, the response is not over. Rather, agent execution begins from then on.

# How to read: The items below are summarized examples to quickly check the action flow.messages→ model streamto assistant text Some outputto tool request occursrun to tool runtimeCreate to tool result messageReinject to messagesto model stream resume→ final answer

If you fail to handle this iteration, your tool calls will just be one-off function executions. The model cannot make its next decision based on observations of the external world.

2. Query loop status

The agent loop manages the following states:

situationexplanation
messagesConversation snapshot to send to current model
turn_countHow many times was the model re-called after the tool result?
tool_contextInformation on tools and permissions available in the current turn
usageToken/cost information accumulated during streaming
compactionSummary or compressed state when context becomes long
cancellationUser interruption or timeout condition
pending_tool_resultsResult messages to be reinjected after execution is complete

These states do not end with a single provider call. It must be continued when inserting the tool result and calling the model again.

3. Convert stream event to runtime event

If the event name given by the provider API is spread throughout the app, it is difficult to change the provider. Therefore, the query loop must convert the provider stream into an internal runtime event.

Provider event personalityRuntime event example
message startassistant_started
text deltaassistant_text_delta
tool call delta/donetool_request_ready
usage updateusage_updated
message stopassistant_message_finished
errormodel_stream_failed

This way, the session shell, ledger, and cost tracker do not need to know the provider SDK type.

4. Tool result reinjection

The tool result is not a simple output string. Observations that will be used in the next model call.

Even if the tool fails, you must create a result message. If the model does not have a corresponding result for the requested tool call, the next inference may become unstable.

# How to read: The items below are summarized examples to quickly check the action flow.tool request id: T-17→ schema validation failed→ permission denied→ execution failed→ success result

In all four cases above, it is a “result”. In addition to viewing success as an outcome, failure must also be viewed as an observation that the model can read.

Practical Tips Instead of treating tool errors only as Python exceptions, try changing them to model-visible error results. The agent can choose different strategies on its own.

5. Continuation condition design

It must be clear when the agent loop continues running and when it stops.

conditiontreatment
No tool requestEnd with final answer
There is a tool requestAfter executing the tool runtime, result is injected into messages
max turn exceededCreate turn limit final event
context exceededContinue after compaction or inform user
cancel requestEnd after recording the result of the interruption
budget exceededRequest suspension or approval according to policy

If these conditions are scattered, the agent may run infinitely, tool results may be missing, or costs may be greater than expected.

6. Agent loop viewed through concept code

The code below is newly written code for explanation purposes.

# How to read: Conceptual code that describes runtime boundaries, not a clone of the actual implementation.class LoopState:    # Initializes runtime dependencies and state storage that the object will reference in later steps.    def __init__(self, messages, tool_context):        self.messages = list(messages)        self.tool_context = tool_context        self.turn_count = 0        self.usage = UsageSnapshot()        self.cancelled = False # It reads the model stream, executes a tool request, and injects the results back into a model message.async def run_agent_loop(initial_messages, runtime):    state = LoopState(initial_messages, runtime.tool_context)     while True:        if state.turn_count >= runtime.limits.max_model_rounds:            await runtime.ledger.write("turn_limit_reached", state.turn_count)            return FinalAnswer(reason="turn_limit")         if runtime.abort_signal.requested:            return FinalAnswer(reason="cancelled")         request = runtime.provider_adapter.build_request(state.messages, state.tool_context)        tool_requests = []         async for event in runtime.provider_adapter.stream(request):            if event.type == "assistant_text_delta":                runtime.shell.publish(event)             elif event.type == "tool_request_ready":                tool_requests.append(event.tool_request)             elif event.type == "usage_updated":                state.usage.merge(event.usage)                runtime.accounting.update(state.usage)         if not tool_requests:            return FinalAnswer(reason="assistant_done", usage=state.usage)         async for result in runtime.tools.execute_batch(tool_requests, state.tool_context):            state.messages.append(result.to_model_message())            runtime.shell.publish(result.to_screen_event())            await runtime.ledger.write("tool_result", result.summary())         state.turn_count += 1

In this architecture, the provider adapter and tool runtime do not know each other's internal implementation. A query loop connects the two.

7. AI utilization developer perspective

When using an AI coding agent, it is good to observe how long tasks progress.

  • Does the screen indicate that the model has requested the tool?
  • Do you see a summary of the results of running the tool?
  • Does the agent try a different strategy for a failed tool?
  • Are there limits when iterative execution becomes too long?
  • Are costs/tokens updated mid-term?

An agent without this information being visible may seem convenient, but it makes auditing and debugging difficult in a team environment.

8. Agent developer checklist

# How to read: The items below are summarized examples to quickly check the action flow.Query Loop Checklist [ ] The model call is designed as a repeatable loop.[ ] The provider stream is converted to an internal runtime event.[ ] Tool requests are collected and then passed to the tool runtime.[ ] The tool result is reinjected as a model-visible message.[ ] A failed tool request also creates a result message.[ ] The conditions for max turn, cancel, budget, and context compaction are clear.[ ] usage/cost is also updated in mid-stream events.

Failure case: Moving to the next call without entering the tool result

One of the most dangerous bugs in the agent loop is when the model issues a tool request, but the runtime does not correctly put the result back into the message history. On the screen it looks like the tool ran, but the next model call is missing the results. The model then requests the same tool again, responds by imagining the outcome, or continues an operation that has already failed as if it had succeeded.

This issue occurs more often with streaming UI. The screen moves based on the token stream, the backend moves based on tool events, and the ledger can store only the final message. If the three layers do not share the same turn ID and tool call ID, a ghost state will occur that is difficult to reproduce. The user says “I just read the file,” but the model replies that it hasn’t read it, and so on.

State transition example

This failure can be reduced by having the query loop as a state machine.

idle-> streaming_model-> collecting_tool_requests-> executing_tools-> injecting_tool_results-> streaming_model-> done

For each state, the events that can enter and the events that can exit must be determined. To start a new model stream inexecuting_toolsstate, you must check whether all required tool results have been entered into history. Conversely, optional telemetry events do not need to be included in model history. Without this distinction, the context becomes unnecessarily large and important results are missed.

EventModel historyUI streamLedger
assistant tokenyesyesyes
tool requestyesyesyes
tool progressnoyesoptional
tool resultyesyesyes
usage updatenoyesyes

Checklist application results

When reviewing an actual implementation, look at “where the repetition conditions are” rather than “how many times do you call the model?” max turn, max tool calls, cancel signal, budget exceeded, and provider stop reason must all be determined in the same loop controller. If each adapter retries randomly, the agent becomes difficult to stop. Especially for products with high costs, even if the usage update arrives late, the budget must be recalculated before the next turn.

Implementation comparison: simple while loop and operational loop controller

When first creating an Agent loop, thewhile tool_calls:format seems sufficient. However, in production, conditions such as outage, cost, tool failure, context compression, and user approval are introduced. A simple while loop would easily spread these conditions to each handler.

itemsimple loopOperational loop controller
interruptionDepends on provider exceptionSeparate cancel signal and final event
expenseCount only the last usageBudget reassessed every turn
tool failureexit with exceptionInject failure result into history
contextFailure if exceededcompaction or user guidance
thanksSave only the final answerRecords all turns, tool requests, and results

For example, let's say a file search tool fails. In the operational loop, the failure reason is put intool_resultand passed back to the model. The model may try a different path, or it may reply to the user that "current file cannot be read". In a simple loop, an exception may pop out and only a generic error may remain on the screen.

There are also borderline cases. What should I do if there are more than two tool requests, one is successful and one is permission denied? All results must be collected and put into observations of the same turn. If only successful results are entered, the model may mistakenly think that a rejected operation is successful, and if only rejections are entered, the information already obtained is discarded. The loop controller should be a layer that checks whether the history is complete before calling the next model.

finish

Understanding the agent loop reveals the essence of an AI agent. The model does not run alone. The runtime reads the stream, executes the tool, puts the results back, and decides when to stop.

In the next article, we will look at the provider API boundary. How should we design model call boundaries so that our product logic is not tied to the provider SDK type?

댓글

GitHub 계정으로 로그인하면 댓글을 남길 수 있습니다. 댓글은 GitHub Discussions를 통해 운영됩니다.

TOP