본문으로 건너뛰기

도구 아키텍처

TanStack AI 도구 시스템은 AI 에이전트가 외부 시스템과 상호작용할 수 있도록 강력하고 유연한 아키텍처를 제공합니다.

  • 서버 도구는 자동 처리와 함께 백엔드에서 안전하게 실행됩니다.

  • 클라이언트 도구는 UI 업데이트와 로컬 작업을 위해 브라우저에서 실행됩니다.

  • 에이전트 루프는 다단계 추론과 복잡한 워크플로를 지원합니다.

  • 도구 상태는 실시간 피드백을 제공하고 견고한 UI를 구현할 수 있게 합니다.

  • 승인 흐름은 민감한 작업을 사용자가 제어할 수 있게 합니다. 이 아키텍처를 사용하면 다음과 같은 정교한 AI 애플리케이션을 구축할 수 있습니다.

  • API와 데이터베이스에서 데이터를 가져옵니다.

  • 계산과 변환을 수행합니다.

  • UI를 업데이트하고 상태를 관리합니다.

  • 다단계 워크플로를 실행합니다.

  • 민감한 작업에 사용자 승인을 요구합니다.

호출 흐름: 클라이언트에서 LLM 서비스까지

사용자가 도구 사용이 필요한 메시지를 보내면 다음 흐름이 발생합니다.

sequenceDiagram
participant User
participant Browser
participant Server
participant LLM Service

User->>Browser: Types message
Browser->>Server: POST /api/chat<br/>{messages, ...}
Server->>Server: Build tool definitions<br/>from tool array
Server->>LLM Service: Send request with:<br/>- messages<br/>- tool definitions<br/>- model config

Note over LLM Service: Model analyzes tools<br/>and decides to use one

LLM Service-->>Server: Stream chunks:<br/>tool_call, content, done
Server-->>Browser: Forward chunks via SSE/HTTP
Browser->>Browser: Parse chunks &<br/>update UI
Browser->>User: Show response

단계별 분석

  1. 사용자 입력: 사용자는 채팅 인터페이스에서 메시지를 입력합니다
  2. 클라이언트 요청: 브라우저는 다음을 포함하여 서버에 POST 요청을 보냅니다:
    • 현재 대화 이력 (messages)
    • 선택적 데이터 페이로드 (body)
  3. 서버 처리: 서버는
    • 요청을 받습니다
    • 요청 본문에서 메시지를 추출합니다
    • 도구 정의를 LLM 의 예상 형식으로 변환합니다
    • LLM 서비스 (OpenAI, Anthropic 등) 에 요청을 보냅니다
  4. LLM 결정: LLM 서비스는
    • 대화와 사용 가능한 도구를 분석합니다
    • 사용자의 요청에 따라 도구를 호출할지 여부를 결정합니다
    • 인수를 포함하여 도구 호출을 생성합니다
  5. 스트리밍 응답: LLM 은 다음을 포함하여 청크를 스트리밍합니다:
    • tool_call 도구 이름과 인수를 포함하는 청크
    • content 텍스트 응답을 포함하는 청크
    • 완료 시 done 청크
  6. 클라이언트 업데이트: 브라우저는 청크를 받고 실시간으로 UI 를 업데이트합니다

코드 예시

서버 (API 라우트):

import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { getWeather, sendEmail } from "./tools";

export async function POST(request: Request) {
const { messages } = await request.json();

// Create streaming chat with tools
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [getWeather, sendEmail], // Tool definitions passed here
});

return toServerSentEventsResponse(stream);
}

클라이언트 (React 컴포넌트):

import { useState } from "react";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";

function ChatComponent() {
const [input, setInput] = useState("");
const { messages, sendMessage, isLoading } = useChat({
connection: fetchServerSentEvents("/api/chat"),
});

return (
<div>
{messages.map((message) => (
<div key={message.id}>{/* Render message */}</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(input);
setInput("");
}}
>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit" disabled={isLoading}>
Send
</button>
</form>
</div>
);
}

상태 및 수명 주기

도구는 수명 주기 동안 서로 다른 상태를 거칩니다. 이러한 상태를 이해하면 견고한 UI를 구축하고 도구 실행을 디버깅하는 데 도움이 됩니다.

두 부분, 두 상태 집합 — 이 페이지가 정식 참조입니다. 호출 상태 (awaiting-input, input-streaming, input-complete, approval-requested, approval-responded)는 tool-call 부분에 part.state로 존재합니다. 호출 부분에는 complete/error/executing/cancelled 값이 없습니다. 결과는 별도의 형제 tool-result 부분에 있으며 자체 statestreaming, complete 또는 error입니다. 확인된 값은 호출 부분의 part.output에도 복제됩니다.

아래 다이어그램은 개념도입니다. approval-responded 이후의 노드(executing, success, error, cancelled)는 ToolCallState 값이 아닙니다. 이는 형제 tool-result 부분의 상태(complete / error)와 호출 부분의 output 필드에 해당합니다.

stateDiagram-v2
state "tool-call part (ToolCallState)" as Call {
[*] --> AwaitingInput: tool_call received
AwaitingInput --> InputStreaming: partial arguments
InputStreaming --> InputComplete: all arguments received
InputComplete --> ApprovalRequested: needsApproval=true
ApprovalRequested --> ApprovalResponded: user approves / denies
}
InputComplete --> ResultComplete: needsApproval=false, success
InputComplete --> ResultError: parsing, validation, or execution error
ApprovalResponded --> ResultComplete: approved + success (output set)
ApprovalResponded --> ResultError: approved + error
ApprovalResponded --> Denied: user denied (no execution)
state "tool-result part" as Results {
ResultComplete: complete
ResultError: error
}
ResultComplete --> [*]
ResultError --> [*]
Denied --> [*]

호출 상태

상태설명클라이언트 작업
awaiting-input도구 호출을 수신했으며 아직 인수가 없습니다.로딩을 표시합니다.
input-streaming일부 인수를 수신하는 중입니다.진행 상태를 표시합니다.
input-complete모든 인수를 수신했습니다.실행할 준비가 되었습니다.
approval-requested사용자 승인을 기다리는 중입니다.승인 UI를 표시합니다.
approval-responded사용자가 승인하거나 거부했습니다.실행하거나 취소합니다.

결과 상태

상태설명클라이언트 작업
streaming결과를 스트리밍하는 중입니다(향후 기능).진행 상태를 표시합니다.
complete결과가 완료되었습니다.결과를 표시합니다.
error실행 전 또는 실행 중 오류가 발생했습니다.오류 메시지를 표시합니다.

React에서 도구 상태 모니터링

import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { createChatClientOptions } from "@tanstack/ai-client";
import { getWeather, sendEmail } from "./tools";
import { ApprovalUI } from "./approval-ui";

// Wiring `tools` is what lets `part.name` / `part.input` / `part.output`
// narrow to each tool's types below.
const chatOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
tools: [getWeather, sendEmail],
});

function ChatComponent() {
const { messages } = useChat(chatOptions);

return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part) => {
if (part.type === "tool-call") {
return (
<div key={part.id} className="tool-status">
{/* Show state-specific UI */}
{part.state === "awaiting-input" && (
<div>🔄 Calling {part.name}...</div>
)}
{part.state === "input-streaming" && (
<div>📥 Receiving arguments...</div>
)}
{part.state === "input-complete" && (
<div>✓ Arguments ready</div>
)}
{part.state === "approval-requested" && (
<ApprovalUI part={part} />
)}
</div>
);
}
if (part.type === "tool-result") {
return (
<div key={part.toolCallId}>
{part.state === "complete" && (
<div>✓ Tool completed</div>
)}
{part.state === "error" && (
<div>❌ Error: {part.error}</div>
)}
</div>
);
}
return null;
})}
</div>
))}
</div>
);
}

승인 흐름

민감한 작업의 경우 도구가 실행 전에 사용자 승인을 요구할 수 있습니다.

sequenceDiagram
participant User
participant Client
participant Server
participant LLM
participant Tool

LLM->>Server: tool_call: send_email
Server->>Server: Check needsApproval
Server->>Client: approval-requested chunk
Client->>Client: Show approval UI
User->>Client: Clicks "Approve"
Client->>Server: POST approval response
Server->>Tool: execute(args)
Tool-->>Server: result
Server->>LLM: tool_result
LLM-->>Client: Generate response

승인이 필요한 도구 정의:

import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
import { emailService } from "./email-service";

const sendEmailDef = toolDefinition({
name: "send_email",
description: "Send an email",
inputSchema: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
needsApproval: true, // Requires user approval
});

const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => {
await emailService.send({ to, subject, body });
return { success: true };
});

클라이언트에서 승인 처리:

const { messages, addToolApprovalResponse } = useChat({
connection: fetchServerSentEvents("/api/chat"),
});

// In your render (guard `type` and `approval` so `part.approval.id` is safe):
{part.type === "tool-call" &&
part.state === "approval-requested" &&
part.approval && (
<div>
<p>Approve sending email to {part.input.to}?</p>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval.id,
approved: true,
})
}
>
Approve
</button>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval.id,
approved: false,
})
}
>
Deny
</button>
</div>
)}

하이브리드 도구 (서버 + 클라이언트)

일부 도구는 두 환경 모두에서 실행해야 합니다.

import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
import { db } from "./db";
import { i18n } from "./i18n";

// Server: Fetch data from database
const fetchUserPrefsDef = toolDefinition({
name: "fetch_user_preferences",
description: "Get user preferences from server",
inputSchema: z.object({
userId: z.string(),
}),
});

const fetchUserPreferences = fetchUserPrefsDef.server(async ({ userId }) => {
const prefs = await db.userPreferences.findUnique({ where: { userId } });
return prefs;
});

// Client: Apply preferences to UI
const applyPrefsDef = toolDefinition({
name: "apply_preferences",
description: "Apply user preferences to the UI",
inputSchema: z.object({
theme: z.string(),
language: z.string(),
}),
});

// On client, create client implementation
const applyPreferences = applyPrefsDef.client(async ({ theme, language }) => {
// Update UI state with preferences
document.body.className = theme;
i18n.changeLanguage(language);
return { applied: true };
});

// Usage: LLM can chain these together
// 1. Call fetchUserPreferences (server)
// 2. Call applyPreferences with the result (client)

병렬 도구 실행

LLM은 효율성을 위해 여러 도구를 병렬로 호출할 수 있습니다.

graph TD
A[LLM decides to call 3 tools] --> B[tool_call index: 0]
A --> C[tool_call index: 1]
A --> D[tool_call index: 2]
B --> E[Execute in parallel]
C --> E
D --> E
E --> F[Collect all results]
F --> G[Continue with results]

Example:

User: "Compare the weather in NYC, SF, and LA"

LLM calls:
- get_weather({city: "NYC"}) [index: 0]
- get_weather({city: "SF"}) [index: 1]
- get_weather({city: "LA"}) [index: 2]

All execute simultaneously, then LLM generates comparison.

모범 사례

도구 설계

  • 단일 책임: 각 도구가 한 가지 작업을 잘 수행하도록 합니다.
  • 명확한 설명: LLM이 도구를 언제 사용할지 이해하도록 돕습니다.
  • 타입 안전성: 입력/출력 검증에 Zod 스키마를 사용합니다.
  • 오류 처리: 의미 있는 오류 메시지를 반환하고 예외를 발생시키지 않습니다.
  • 멱등성: 도구를 여러 번 호출해도 안전해야 합니다.

보안

  • 서버 대 클라이언트: 민감한 작업은 서버에 배치합니다
  • 승인 흐름: 파괴적 작업에는 needsApproval 를 사용합니다
  • 입력 유효성 검사: 항상 도구 입력을 유효성 검사합니다
  • 속도 제한: 비싼 도구에는 속도 제한을 구현합니다
  • 감사 로그: 디버깅 및 보안을 위해 도구 실행을 기록합니다

성능

  • 캐싱: 적절할 때 도구 결과를 캐싱합니다
  • 병렬 실행: 가능한 경우 병렬 도구 호출을 활성화합니다
  • 스트리밍: 긴 작업에는 스트리밍을 사용합니다
  • 타임아웃: 외부 API 호출에는 타임아웃을 설정합니다
  • 지연 로딩: 필요할 때만 도구를 로드합니다

다음 단계