도구 아키텍처
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
단계별 분석
- 사용자 입력: 사용자는 채팅 인터페이스에서 메시지를 입력합니다
- 클라이언트 요청: 브라우저는 다음을 포함하여 서버에 POST 요청을 보냅니다:
- 현재 대화 이력 (
messages) - 선택적 데이터 페이로드 (
body)
- 현재 대화 이력 (
- 서버 처리: 서버는
- 요청을 받습니다
- 요청 본문에서 메시지를 추출합니다
- 도구 정의를 LLM 의 예상 형식으로 변환합니다
- LLM 서비스 (OpenAI, Anthropic 등) 에 요청을 보냅니다
- LLM 결정: LLM 서비스는
- 대화와 사용 가능한 도구를 분석합니다
- 사용자의 요청에 따라 도구를 호출할지 여부를 결정합니다
- 인수를 포함하여 도구 호출을 생성합니다
- 스트리밍 응답: LLM 은 다음을 포함하여 청크를 스트리밍합니다:
tool_call도구 이름과 인수를 포함하는 청크content텍스트 응답을 포함하는 청크- 완료 시
done청크
- 클라이언트 업데이트: 브라우저는 청크를 받고 실시간으로 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부분에 있으며 자체state는streaming,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 호출에는 타임아웃을 설정합니다
- 지연 로딩: 필요할 때만 도구를 로드합니다
다음 단계
- 도구 개요 - 기본 도구 개념 및 예제
- 서버 도구 - 서버 측 도구에 대한 심층 분석
- 클라이언트 도구 - 클라이언트 측 도구에 대한 심층 분석
- 도구 승인 흐름 - 승인 워크플로우 구현
- AG-UI 프로토콜 - 스트리밍 프로토콜 이해