핵심 요약
- 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_count | tool result 이후 모델을 몇 번 다시 호출했는지 |
| tool_context | 현재 turn에서 사용 가능한 도구와 권한 정보 |
| usage | streaming 중 누적되는 token/cost 정보 |
| compaction | context가 길어질 때 요약 또는 압축 상태 |
| 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 start | assistant_started |
| text delta | assistant_text_delta |
| tool call delta/done | tool_request_ready |
| usage update | usage_updated |
| message stop | assistant_message_finished |
| error | model_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는 불필요하게 커지고, 중요한 결과는 누락됩니다.
| Event | Model history | UI stream | Ledger |
|---|---|---|---|
| assistant token | yes | yes | yes |
| tool request | yes | yes | yes |
| tool progress | no | yes | optional |
| tool result | yes | yes | yes |
| usage update | no | yes | yes |
체크리스트 적용 결과
실제 구현을 리뷰할 때는 "모델을 몇 번 호출하는가"보다 "반복 조건이 어디에 있는가"를 봅니다. 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:
| situation | explanation |
|---|---|
| messages | Conversation snapshot to send to current model |
| turn_count | How many times was the model re-called after the tool result? |
| tool_context | Information on tools and permissions available in the current turn |
| usage | Token/cost information accumulated during streaming |
| compaction | Summary or compressed state when context becomes long |
| cancellation | User interruption or timeout condition |
| pending_tool_results | Result 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 personality | Runtime event example |
|---|---|
| message start | assistant_started |
| text delta | assistant_text_delta |
| tool call delta/done | tool_request_ready |
| usage update | usage_updated |
| message stop | assistant_message_finished |
| error | model_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.
| condition | treatment |
|---|---|
| No tool request | End with final answer |
| There is a tool request | After executing the tool runtime, result is injected into messages |
| max turn exceeded | Create turn limit final event |
| context exceeded | Continue after compaction or inform user |
| cancel request | End after recording the result of the interruption |
| budget exceeded | Request 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.
| Event | Model history | UI stream | Ledger |
|---|---|---|---|
| assistant token | yes | yes | yes |
| tool request | yes | yes | yes |
| tool progress | no | yes | optional |
| tool result | yes | yes | yes |
| usage update | no | yes | yes |
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.
| item | simple loop | Operational loop controller |
|---|---|---|
| interruption | Depends on provider exception | Separate cancel signal and final event |
| expense | Count only the last usage | Budget reassessed every turn |
| tool failure | exit with exception | Inject failure result into history |
| context | Failure if exceeded | compaction or user guidance |
| thanks | Save only the final answer | Records 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를 통해 운영됩니다.