에이전트 루프
에이전트 루프는 LLM이 도구를 반복해서 호출하고 결과를 받은 뒤 최종 답변을 제공할 수 있을 때까지 추론을 계속하는 패턴입니다. 이를 통해 복잡한 다단계 작업을 수행할 수 있습니다.
팁: Code Mode를 사용하면 LLM이 한 번의 실행에서 여러 도구를 호출하는 프로그램을 작성할 수 있으므로 에이전트 루프 반복 횟수를 줄일 수 있습니다. Code Mode를 참조하세요.
graph TD
A[User sends message] --> B[LLM analyzes request]
B --> C{Does task need tools?}
C -->|No| D[Generate text response]
C -->|Yes| E[Call appropriate tool]
E --> F{Where does<br/>tool execute?}
F -->|Server| G[Execute on server]
F -->|Client| H[Execute on client]
G --> I[Tool returns result]
H --> I
I --> J[Add result to conversation]
J --> K[LLM analyzes result]
K --> L{Task complete?}
L -->|No| E
L -->|Yes| D
D --> M[Stream response to user]
M --> N[Done]
style E fill:#e1f5ff
style G fill:#ffe1e1
style H fill:#ffe1e1
style L fill:#fff4e1
에이전트 흐름 상세
sequenceDiagram
participant User
participant Client
participant Server
participant LLM
participant Tools
User->>Client: "What's the weather in SF and LA?"
Client->>Server: Send message
Server->>LLM: Message + tool definitions
Note over LLM: Cycle 1: Call first tool
LLM->>Server: tool_call: get_weather(SF)
Server->>Tools: Execute get_weather
Tools-->>Server: {temp: 65, conditions: "sunny"}
Server->>LLM: tool_result
Note over LLM: Cycle 2: Call second tool
LLM->>Server: tool_call: get_weather(LA)
Server->>Tools: Execute get_weather
Tools-->>Server: {temp: 75, conditions: "clear"}
Server->>LLM: tool_result
Note over LLM: Cycle 3: Generate answer
LLM-->>Server: content: "SF is 65°F..."
Server-->>Client: Stream response
Client->>User: Display answer
다단계 예제
다음은 에이전트 루프의 실제 예입니다.
사용자: "파리행 항공편을 500달러 미만으로 찾아 가장 저렴한 항공편을 예약해 주세요"
주기 1: LLM이 searchFlights({destination: "Paris", maxPrice: 500})을 호출합니다.
- 도구가 반환:
[{id: "F1", price: 450}, {id: "F2", price: 480}]
주기 2: LLM이 결과를 분석하고 bookFlight({flightId: "F1"})을 호출합니다.
- 도구에 승인이 필요합니다(민감한 작업). 도구 승인을 참조하세요.
- 사용자가 승인합니다.
- 도구가 반환:
{bookingId: "B123", confirmed: true}
주기 3: LLM이 최종 답변을 생성합니다.
- "500달러 미만인 항공편 2개를 찾았습니다. 가장 저렴한 항공편(Flight F1)을 450달러에 예약했습니다. 예약 ID는 B123입니다."
코드 예제: 에이전트 날씨 도우미
import { chat, toolDefinition, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
// Tool definitions
const getWeatherDef = toolDefinition({
name: "get_weather",
description: "Get current weather for a city",
inputSchema: z.object({
city: z.string(),
}),
});
const getClothingAdviceDef = toolDefinition({
name: "get_clothing_advice",
description: "Get clothing recommendations based on weather",
inputSchema: z.object({
temperature: z.number(),
conditions: z.string(),
}),
});
// Server implementations
const getWeather = getWeatherDef.server(async ({ city }) => {
const response = await fetch(`https://api.weather.com/v1/${city}`);
return await response.json();
});
const getClothingAdvice = getClothingAdviceDef.server(async ({ temperature, conditions }) => {
// Business logic for clothing recommendations
if (temperature < 50) {
return { recommendation: "Wear a warm jacket" };
}
return { recommendation: "Light clothing is fine" };
});
// Server route
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [getWeather, getClothingAdvice],
});
return toServerSentEventsResponse(stream);
}
사용자: "오늘 샌프란시스코에서는 무엇을 입어야 하나요?"
에이전트 루프:
- LLM이
get_weather({city: "San Francisco"})을 호출합니다. →{temp: 62, conditions: "cloudy"}를 반환합니다. - LLM이
get_clothing_advice({temperature: 62, conditions: "cloudy"})를 호출합니다. →{recommendation: "Light jacket recommended"}를 반환합니다. - LLM이 다음을 생성합니다. "샌프란시스코의 날씨는 62°F이고 흐립니다. 얇은 재킷을 입는 것이 좋습니다."
루프는 모델의 종료 이유가 tool_calls(대기 중인 도구 호출 포함)이고 동시에 에이전트 루프 전략이 다음 반복을 허용하는 동안에만 계속됩니다. 모델이 일반적인 stop 종료 이유를 반환하면 즉시 종료됩니다.
루프 제어
기본적으로 루프는 maxIterations(5)로 제한됩니다. 모델이 도구를 계속 호출하려 해도 모델 턴 5회 후 중지됩니다. agentLoopStrategy 옵션으로 재정의할 수 있습니다.
기타 기본 제공 전략은 다음과 같습니다.
untilFinishReason([...])— 모델이 지정된 종료 이유 중 하나를 반환할 때까지 계속합니다(예:untilFinishReason(["stop", "length"])).combineStrategies([...])— 여러 전략을 AND 논리로 결합합니다. 모든 전략이 동의하는 동안에만 루프가 계속됩니다.
전략은 { iterationCount, finishReason, messages, toolCallCount, lastTurnToolCallCount }를 받아 다음 반복을 허용하려면 true, 중지하려면 false를 반환하는 함수입니다. 따라서 직접 작성할 수도 있습니다.
import { chat, combineStrategies, maxIterations, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import type { AgentLoopState } from "@tanstack/ai";
import { getWeather, getClothingAdvice } from "./tools";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [getWeather, getClothingAdvice],
agentLoopStrategy: combineStrategies([
maxIterations(10),
({ messages }: AgentLoopState) => messages.length < 100,
]),
});
return toServerSentEventsResponse(stream);
}
도구 호출 예산(미들웨어 레시피)
반복 ≠ 도구 호출입니다. 한 모델 턴에서 여러 병렬 도구 호출을 내보낼 수 있습니다.
maxIterations는 모델 턴만 제한합니다. 전략은 턴 사이에서 실행되므로 턴별 상한이 없으면 단일 폭주 턴이 여전히 제한 없이 확장될 수 있습니다.
기본 제공 maxToolCalls 전략은 없습니다. 미들웨어로 도구 수를 제한합니다.
onBeforeToolCall— 한 턴 안에서 초과 호출을 건너뜁니다(maxPerTurn).onShouldContinue— 누적 내보낸 도구가 예산(max)에 도달하면 이후 턴을 중지합니다. 건너뛴 호출도toolCallCount에 포함됩니다.
import {
chat,
maxIterations,
toServerSentEventsResponse,
type ChatMiddleware,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { getWeather, getClothingAdvice } from "./tools";
/** App-owned policy — not a library export. */
function toolCallBudget(options: {
max?: number;
maxPerTurn?: number;
}): ChatMiddleware {
const { max, maxPerTurn } = options;
let perTurn = 0;
return {
name: "tool-call-budget",
onIteration() {
perTurn = 0;
},
// Fresh per-turn budget for pending/resume batches (no onIteration).
onToolPhaseComplete() {
perTurn = 0;
},
onBeforeToolCall() {
if (maxPerTurn == null) return undefined;
perTurn += 1;
if (perTurn > maxPerTurn) {
return {
type: "skip",
result: {
error: `Skipped: exceeded maxToolCallsPerTurn (${maxPerTurn})`,
},
};
}
return undefined;
},
onShouldContinue(_ctx, state) {
if (max != null && state.toolCallCount >= max) return false;
return undefined;
},
};
}
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [getWeather, getClothingAdvice],
agentLoopStrategy: maxIterations(20), // model turns
middleware: [
toolCallBudget({
maxPerTurn: 10, // cap parallel fan-out inside one turn
max: 20, // stop further turns once cumulative emitted tools hit 20
}),
],
});
return toServerSentEventsResponse(stream);
}
초과 예산 건너뛰기가 캐시 적중보다 우선하도록 이를 toolCacheMiddleware 앞에 배치합니다. 훅 계약은 onShouldContinue를 참조하세요.