`useChat`이 툴 결과를 다음 턴에 이어붙이는 방식 — Vercel AI SDK 4.x 멀티스텝 루프 파헤치기
LLM이 텍스트 대신 툴을 호출하고, 그 결과를 컨텍스트에 쌓아가며 최종 답변을 만드는 에이전트 루프. 구현 방법은 알겠는데, 내부적으로 어떻게 돌아가는지 정확히 이해하는 개발자는 생각보다 많지 않습니다. 저도 처음엔 "클라이언트에서 addToolResult 호출하면 뭔가 되는구나" 정도로만 쓰다가, 대화가 길어질수록 이상한 페이로드 문제나 maxSteps 소진 이슈에 부딪혀서 비로소 제대로 파고들었습니다.
이 글에서는 Vercel AI SDK 4.x의 useChat이 클라이언트에서 툴 실행 결과를 수집해 다음 LLM 턴의 컨텍스트로 이어붙이는 메커니즘을 구조적으로 분석합니다. 단순 사용법이 아니라, 왜 이런 구조인지 · 어디서 비용이 발생하는지 · 어떤 실수를 조심해야 하는지까지 다룹니다. 버전 언급은 2026년 8월 시점을 기준으로 합니다.
이 구조가 왜 필요한가 — 단순 채팅과의 차이
텍스트 응답은 단순하다, 툴 호출은 다르다
일반 채팅은 단순합니다. 사용자 메시지 → 서버에서 LLM 호출 → 텍스트 스트리밍 → 끝. useChat이 알아서 처리해줍니다.
그런데 LLM이 "이 질문에 답하려면 날씨 API를 호출해야겠다"고 판단하는 순간, 흐름이 바뀝니다. 텍스트 대신 툴 호출 지시를 반환하고, 그 툴이 실행되어 결과를 가져오면, 그 결과를 다시 컨텍스트에 담아 LLM에 넘겨야 최종 텍스트 응답이 나옵니다.
문제는 이 툴이 서버에서 실행될 수도 있고, 클라이언트에서만 접근 가능한 경우도 있다는 점입니다. 브라우저의 navigator.geolocation, 로컬 스토리지, DOM 상태 — 이런 건 서버에서 접근할 방법이 없습니다. 그래서 AI SDK는 서버 사이드 툴과 클라이언트 사이드 툴을 모두 지원하는 협력 구조를 설계했습니다.
멀티스텝 루프의 전체 흐름
말로 설명하면 복잡해 보이지만, 다이어그램으로 보면 패턴이 명확해집니다.
핵심은 두 갈래로 나뉜다는 점입니다. execute가 있는 서버 사이드 툴은 단일 HTTP 요청 안에서 streamText의 maxSteps가 루프를 처리하고 최종 텍스트까지 한 스트림에 실어 보냅니다. 반면 execute가 없는 클라이언트 사이드 툴은 스트림이 툴 호출 지시에서 끊기고, 클라이언트가 결과를 채운 뒤 전체 메시지 배열을 서버에 재전송해야 다음 스텝이 시작됩니다. 이 자동 재전송이 클라이언트 툴에서의 "멀티스텝" 동작입니다.
maxSteps는 이 재전송 루프의 한도를 제어합니다. 기본값은 1이라 재전송이 일어나지 않고, 2 이상으로 설정해야 클라이언트 재전송이 활성화됩니다.
메시지 구조가 바뀐 이유 — parts 배열 이해하기
4.2 이전: toolInvocations 배열의 한계
4.2 이전에는 어시스턴트 메시지에 content 문자열 하나와 toolInvocations 배열이 별도로 존재했습니다. 텍스트와 툴 호출이 섞인 멀티스텝 응답에서 순서 정보가 유실되는 문제가 있었습니다. "텍스트를 먼저 말하고 툴을 호출했는지, 툴 결과를 보고 나서 텍스트를 생성했는지"를 UI에서 정확히 재현하기 어려웠습니다.
4.2+: parts 배열로 통합
AI SDK 4.2 릴리즈에서 message.parts 배열이 도입되었습니다. 텍스트, 툴 호출, 스텝 구분자가 모두 하나의 배열에 발생 순서 그대로 담깁니다.
// useChat이 반환하는 메시지 구조 (AI SDK 4.2+)
{
role: "assistant",
parts: [
{ type: "step-start" },
{ type: "text", text: "날씨를 확인해볼게요." },
{
type: "tool-invocation",
toolInvocation: {
toolCallId: "call_abc123",
toolName: "getWeather",
args: { city: "서울" },
// state 변화: "partial-call" (인수 스트리밍 중)
// → "call" (인수 완성, 실행 대기)
// → "result" (실행 완료)
state: "result",
result: { temp: 22, condition: "맑음" }
}
},
{ type: "step-start" },
{ type: "text", text: "서울 현재 기온은 22도입니다." }
]
}state는 인수가 부분적으로 스트리밍되는 "partial-call"부터 시작해서, 인수가 완성되면 "call", 실제 결과가 채워지면 "result"로 전이됩니다. 이 순서를 반대로 이해하면 로딩 UI가 완전히 거꾸로 동작하니 주의가 필요합니다. step-start 파트는 멀티스텝 루프에서 각 스텝의 경계를 표시해줍니다.
구현 패턴 세 가지
패턴 1: 자동 실행 클라이언트 툴
브라우저에서만 접근 가능한 데이터를 툴로 노출하는 가장 간단한 형태입니다. onToolCall 콜백에서 값을 반환하면 useChat이 알아서 addToolResult를 호출하고 다음 스텝을 진행합니다.
// app/chat/page.tsx
const { messages, input, handleSubmit, handleInputChange } = useChat({
maxSteps: 5, // 클라이언트 재전송 왕복 한도
onToolCall: async ({ toolCall }) => {
if (toolCall.toolName === "getUserLocation") {
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(pos) => resolve({
lat: pos.coords.latitude,
lng: pos.coords.longitude
}),
() => resolve({ error: "위치 접근 거부됨" })
);
});
}
},
});// app/api/chat/route.ts
import { streamText, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
// 이 시나리오에서는 execute 없는 툴만 있으므로
// streamText의 maxSteps는 실질적으로 의미가 없다.
// 재전송 한도는 useChat 쪽 maxSteps로 제어된다.
tools: {
getUserLocation: tool({
description: "사용자의 현재 위치를 가져옵니다",
parameters: z.object({}),
// execute 생략 = 클라이언트 사이드 툴
}),
},
});
return result.toDataStreamResponse();
}서버 측 tool 정의에서 execute 함수를 생략하면 클라이언트 사이드 툴로 처리됩니다. 서버는 툴 호출 지시를 스트림에 실어 보내고 응답을 종료하므로, 이 시나리오에서 서버의 streamText에 maxSteps를 걸어봐도 서버 내부 루프가 돌지 않습니다. 루프 한도는 useChat 쪽에서 관리됩니다.
패턴 2: Human-in-the-loop — 사용자 승인이 필요한 툴
파일 삭제, 이메일 발송, 결제처럼 민감한 작업은 LLM이 호출을 지시하더라도 사람이 확인해야 합니다. state: "call" 상태를 감지해 승인 UI를 띄우고, 사용자 응답에 따라 addToolResult를 호출합니다.
여기서 자주 실수하는 지점이 있습니다. MessageList 같은 하위 컴포넌트에서 useChat을 새로 호출하면 상위와 완전히 분리된 인스턴스가 생성되어, addToolResult가 화면에 보이는 메시지와 아무 관계없는 상태를 건드립니다. 반드시 상위에서 훅을 한 번만 호출하고 messages와 addToolResult를 prop이나 Context로 내려야 합니다.
// app/chat/page.tsx — 훅은 상위에서 한 번만 호출
"use client";
import { useChat } from "@ai-sdk/react";
import { MessageList } from "@/components/MessageList";
export default function ChatPage() {
const chat = useChat({ maxSteps: 3 });
return (
<>
<MessageList
messages={chat.messages}
addToolResult={chat.addToolResult}
/>
<form onSubmit={chat.handleSubmit}>
<input value={chat.input} onChange={chat.handleInputChange} />
</form>
</>
);
}// components/MessageList.tsx — prop으로 전달받은 상태만 사용
import type { UseChatHelpers } from "@ai-sdk/react";
type Props = Pick<UseChatHelpers, "messages" | "addToolResult">;
export function MessageList({ messages, addToolResult }: Props) {
return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.role === "assistant" &&
message.parts?.map((part, idx) => {
// 같은 메시지 안에서 text 파트가 여러 개일 수 있으므로
// 인덱스를 포함한 고유 키를 만들어야 한다.
const baseKey = `${message.id}:${idx}`;
if (part.type === "text") {
return <p key={baseKey}>{part.text}</p>;
}
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "call" &&
part.toolInvocation.toolName === "deleteFile"
) {
const { toolCallId, args } = part.toolInvocation;
return (
<ConfirmCard
key={toolCallId}
message={`${args.path} 파일을 삭제할까요?`}
onApprove={() =>
addToolResult({
toolCallId,
result: { approved: true, deletedPath: args.path },
})
}
onReject={() =>
addToolResult({
toolCallId,
result: { approved: false, reason: "사용자 거부" },
})
}
/>
);
}
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
return (
<ToolResultBadge
key={part.toolInvocation.toolCallId}
name={part.toolInvocation.toolName}
result={part.toolInvocation.result}
/>
);
}
})}
</div>
))}
</div>
);
}승인 대기 → 승인/거부 → 결과 배지로 이어지는 흐름은 결국 state 필드 하나로 분기됩니다. 이 패턴은 이후 Tool Approvals 문서에서 별도 개념으로 정리되었지만, 4.x 위에서도 동일 원리로 직접 구현할 수 있습니다.
패턴 3: 멀티스텝 에이전트 — 서버 사이드 툴 체이닝
LLM이 여러 툴을 순차적으로 호출하며 최종 답변을 조합하는 흐름입니다. 서버 사이드 툴만 사용한다면, 클라이언트 왕복 없이 단일 HTTP 요청 안에서 스텝이 반복됩니다.
// app/api/chat/route.ts — 서버 사이드 멀티스텝
import { streamText, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: anthropic("claude-3-5-sonnet-20241022"),
messages,
maxSteps: 5, // 서버 내부 루프 한도
tools: {
getWeather: tool({
description: "도시의 현재 날씨를 가져옵니다",
parameters: z.object({ city: z.string() }),
execute: async ({ city }) => {
return { temp: 22, condition: "맑음", city };
},
}),
searchRestaurants: tool({
description: "날씨와 위치에 맞는 맛집을 검색합니다",
parameters: z.object({
city: z.string(),
weather: z.string(),
}),
execute: async ({ city, weather }) => {
return { restaurants: ["식당A", "식당B"] };
},
}),
},
});
return result.toDataStreamResponse();
}execute가 있는 툴은 서버에서 바로 실행되고 결과가 스트림에 포함됩니다. 클라이언트 재전송이 필요 없기 때문에 응답 지연이 훨씬 짧고, 왕복 오버헤드도 없습니다. 반면 클라이언트 툴은 UI가 개입해야 하므로 매 스텝마다 네트워크 왕복이 추가됩니다. 어떤 툴을 어느 쪽에 둘지가 사실상 아키텍처 결정입니다.
트레이드오프 — 편리함 뒤에 숨은 비용들
useChat의 자동 컨텍스트 관리는 편리합니다. 메시지 배열을 수동으로 조작할 필요가 없고, 툴 결과 주입도 addToolResult 하나로 끝납니다. 하지만 프로덕션에서 쓰다 보면 몇 가지 함정이 있습니다.
| 항목 | 내용 | 심각도 |
|---|---|---|
| 클라이언트 툴 재전송 비용 | 클라이언트 툴 결과 반환마다 전체 대화 히스토리가 서버로 재전송 | 중 |
| 컨텍스트 토큰 누적 | 대화가 길어질수록 매 요청 토큰 수 증가 | 중~고 |
maxSteps 소진 |
툴을 자주 호출하는 모델에서 한도를 소진하면 최종 텍스트 없이 루프 종료 | 고 |
서버·클라이언트 maxSteps 이원화 |
서버 streamText의 maxSteps와 useChat의 maxSteps가 서로 다른 축을 관리 |
중 |
| API 변경 빈도 | 4.x → 5.x 사이에 훅 API와 루프 종료 조건 표현이 여러 차례 갱신 | 중 |
전역 maxSteps만 가능 |
특정 툴에만 다른 한도를 적용하는 API 미지원 (Discussion #3815) | 저~중 |
stopWhen 혼용 충돌 |
v5의 stopWhen과 useChat 재전송 로직 혼용 시 예상치 못한 추가 왕복 가능 (Issue #7502) |
중 |
자주 하는 실수 두 가지
실수 1: 모델의 툴 호출 패턴을 파악하지 않고 maxSteps를 임의 설정
maxSteps: 2가 무조건 나쁜 값은 아닙니다. 툴을 한 번만 호출하면 되는 단순 케이스에는 적절합니다. 문제는 어떤 모델은 답변 하나에 여러 툴을 연쇄 호출한다는 점입니다. 툴을 공격적으로 호출하는 모델에서 낮은 한도를 두면 마지막 스텝이 툴 호출로 끝나버려 최종 텍스트가 나오지 않습니다. 모델별로 실제 호출 횟수를 로깅해서 결정하는 편이 안전합니다.
실수 2: 서버 maxSteps 소진을 클라이언트가 감지하지 못함
서버 사이드 툴로 멀티스텝을 돌리다가 서버의 maxSteps 한도에 걸려 루프가 종료되면, 클라이언트 입장에서는 그냥 "짧은 응답" 또는 "툴 호출 후 텍스트 없음"으로 보입니다. 별도의 finishReason이나 스텝 로그를 파싱해서 UI에 알리지 않으면, 사용자에게는 그저 어시스턴트가 말을 하다 만 것처럼 보이게 됩니다.
v4.x → v5+ 마이그레이션 포인트
버전 변화 속도가 빠릅니다. 2026년 8월 기준으로 4.x와 5.x는 사용 감각이 꽤 다릅니다. 정확한 심볼 이름과 시그니처는 공식 마이그레이션 문서와 AI SDK 5 릴리즈 블로그를 반드시 확인하고 사용하는 버전에 맞춰 조정하세요.
| 관심사 | v4.x | v5+ |
|---|---|---|
| 클라이언트 툴 결과 주입 | addToolResult({ toolCallId, result }) |
훅에서 노출되는 결과 주입 API (이름·시그니처 확인 필요) |
| 루프 한도 표현 | maxSteps: 숫자 |
스텝 카운트나 종료 조건을 서술하는 stopWhen 계열 옵션 |
| 자동 재전송 조건 | 훅 내부에서 암묵 처리 | 재전송 조건을 명시적으로 선언하는 옵션 제공 |
| 메시지 파트 구조 | 4.2에서 parts 배열 도입 |
parts 기반 유지, 파트 타입 확장 |
v4.x에서는 훅 내부에서 암묵적으로 처리되던 "모든 툴 결과가 채워지면 재전송"이라는 동작이 v5에서는 조건 옵션으로 겉으로 드러난 셈입니다. 4.x 코드를 5.x로 옮길 때 가장 크게 부딪히는 지점도 여기입니다.
마무리
useChat의 멀티스텝 루프는 결국 하나의 패턴입니다. LLM이 툴 호출 지시를 내리면 → 클라이언트나 서버에서 실행하고 → 결과를 포함한 컨텍스트를 다음 스텝의 LLM 호출에 넘기는 것. useChat은 이 반복을 개발자 대신 자동으로 처리해줄 뿐입니다. 다만 그 자동화의 경계선 — 서버 안에서 도는 루프와 클라이언트 왕복이 필요한 루프 — 을 정확히 구분해야 예상 밖 왕복이나 조기 종료 이슈를 잡을 수 있습니다.
다음에 코드로 옮길 때 짚어볼 만한 것들을 정리하면:
- 사용 중인 모델이 답변 하나에 툴을 평균 몇 번 호출하는지 로깅해서,
maxSteps값을 감이 아니라 데이터로 정한다. - 대화가 일정 길이를 넘으면 이전 툴 결과를 요약하거나 잘라내는 컨텍스트 압축 전략을 붙인다. 자동 재전송은 매번 전체 히스토리를 다시 보낸다는 사실을 잊기 쉽다.
- 서버 사이드로 옮길 수 있는 툴은 최대한 서버로 옮긴다. 왕복이 줄고, 인증·비밀키 노출 리스크도 함께 사라진다.
- 승인이 필요한 툴은 훅을 상위에서 한 번만 호출하고 상태를 내려주는 구조를 먼저 잡는다. 나중에 컴포넌트가 늘어난 뒤에 고치기는 훨씬 번거롭다.
참고 자료
- AI SDK UI: Chatbot Tool Usage — 공식 문서
- AI SDK UI: useChat API 레퍼런스 (v4)
- AI SDK Core: Tools and Tool Calling
- Tool Approvals - Agents
- Migration Guide: AI SDK 4.1 → 4.2 (message parts 도입)
- AI SDK 4.2 릴리즈 블로그
- AI SDK 5 릴리즈 블로그
- GitHub: maxSteps 개별 툴 적용 요청 Discussion #3815
- GitHub: stopWhen과 useChat 재전송 충돌 Issue #7502
- GitHub: client-side tool calling 버그 Issue #4283
- Next.js: Call Tools — 공식 쿡북