본문으로 건너뛰기

도구를 사용한 구조화된 출력

에이전트가 도구를 사용해 정보를 수집한 다음, 찾은 내용을 요약하는 구조화된 객체를 반환하도록 할 수 있습니다. "개발자를 위한 제품을 추천해 주세요" → 루프가 getProductPrice를 호출하고 재고 API에 접근한 다음, 스키마에 따라 검증된 { productName, currentPrice, reason }을 반환합니다. 모든 도구가 해결된 후에만 구조화된 응답이 생성됩니다.

이 페이지에서는 outputSchema + tools를 결합하는 형태를 다룹니다. 구조화된 객체가 도착하기 전에 실행 중간에 발생할 수 있는 일시 중지/재개 지점(서버 도구 승인 프롬프트, 클라이언트 도구 호출)도 포함합니다.

네이티브 결합 모드를 지원하는 어댑터(modern OpenAI, Claude 4.5+, Gemini 3.x, Grok 4.x 및 동일한 업스트림 모델을 사용하는 OpenRouter)에서는 chat({ tools, outputSchema, stream: true })가 하나의 스트리밍 요청입니다. 추가 최종화 호출은 실행되지 않습니다.

React 채팅 예제의 실제 동작하는 OpenRouter 페이지는 /generations/openrouter-combined에 있습니다.

참고: TanStack AI에서 도구가 작동하는 방식이 아직 익숙하지 않다면 먼저 도구 아키텍처서버 도구를 읽어 보세요. 여기서 사용하는 패턴은 일반적인 에이전트 루프 흐름을 기반으로 하며, outputSchema는 마지막 종료 이벤트만 추가합니다.

비스트리밍: 먼저 도구를 사용한 다음 구조화된 객체 반환

가장 간단한 형태는 await chat({ tools, outputSchema })입니다. 에이전트 루프가 완료될 때까지 실행된 후(모든 도구가 해결되고 모든 승인에 응답한 후) 모델이 구조화된 객체를 생성합니다. 프로미스는 검증된 타입 결과로 해결됩니다.

import { chat, toolDefinition } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";

const getProductPrice = toolDefinition({
name: "get_product_price",
description: "Get the current price of a product",
inputSchema: z.object({ productId: z.string() }),
}).server(async ({ productId }) => {
return { price: 29.99, currency: "USD" };
});

const RecommendationSchema = z.object({
productName: z.string(),
currentPrice: z.number(),
reason: z.string(),
});

const recommendation = await chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Recommend a product for a developer" }],
tools: [getProductPrice],
outputSchema: RecommendationSchema,
});

recommendation.productName; // string — fully typed
recommendation.currentPrice; // number
recommendation.reason; // string

에이전트는 get_product_price를 호출할 시점을 결정하고, 도구를 실행하며, 그 결과를 추론에 반영한 후에 최종 구조화된 응답을 생성합니다. 검증된 객체만 표시되고 도구 호출은 내부적으로 처리됩니다.

참고: 샌드박스의 코딩 에이전트(Claude Code, Codex, OpenCode, Grok Build)는 같은 턴에 네이티브 도구와 스키마를 실행합니다. TanStack 도구 승인이나 클라이언트 도구를 위해 일시 중지하지 않습니다. 하네스 에이전트를 참고하세요.

스트리밍: 구조화된 페이로드 전의 수명 주기 이벤트

stream: true를 전달하면 와이어 형식이 변경됩니다. 클라이언트는 도구 호출 이벤트를 발생하는 즉시 확인하고, 그런 다음 구조화된 출력 스트림이 종료 이벤트를 내보냅니다. 수명 주기 순서는 다음과 같습니다.

  1. RUN_STARTED
  2. (에이전트 루프) TOOL_CALL_STARTTOOL_CALL_ARGSTOOL_CALL_ENDTOOL_CALL_RESULT, 여러 도구 호출이나 반복을 위해 가능하게 반복
  3. structured-output.start (모델이 JSON 응답을 방출하기 시작할 때)
  4. TEXT_MESSAGE_CONTENT 델타 (JSON 자체)
  5. structured-output.complete (완료된 페이로드)
  6. RUN_FINISHED

2단계가 실행되는 동안 useChatpartial{}, finalnull로 유지됩니다. 아직 구조화된 스트림이 시작되지 않았기 때문입니다. 3단계가 발생하면 partial이 채워지기 시작하고, 5단계에서 final이 완성됩니다.

별도 최종화 경로에서는 에이전트 루프가 3단계 전에 일반 텍스트 어시스턴트 메시지를 완료할 수도 있습니다. 해당 메시지와 구조화된 출력 어시스턴트 메시지는 서로 분리된 상태로 유지됩니다. 네이티브 결합 출력은 구조화된 JSON과 그 파트를 하나의 어시스턴트 메시지에 유지합니다.

도구 호출 파트는 일반 스트리밍 채팅에서와 동일하게 어시스턴트 메시지에 추가됩니다. 구조화된 출력 실행 외부에서 도구 호출을 렌더링하는 방식으로 렌더링하면 됩니다.

승인이 필요한 서버 도구

needsApproval: true로 등록된 서버 도구는 자동으로 실행되지 않습니다. 에이전트 루프가 일시 중지되고, 대기 중인 도구 호출이 state === "approval-requested"ToolCallPart로 어시스턴트 메시지에 추가되며, 루프는 훅 반환값에서 addToolApprovalResponse({ id, approved })를 호출할 때까지 기다립니다. 승인이 허용된 후(또는 거부되어 루프가 재개된 후)에만 구조화된 출력 스트림이 이어집니다.

import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { RecommendationSchema } from "./api/recommend";
import { sendEmail } from "./tools";

const { messages, sendMessage, partial, final, addToolApprovalResponse } =
useChat({
connection: fetchServerSentEvents("/api/recommend"),
outputSchema: RecommendationSchema,
tools: [sendEmail], // server tool with needsApproval: true
});

const last = messages.at(-1);

return (
<>
{last?.parts.map((part, i) => {
// Surface approval prompts inline.
if (
part.type === "tool-call" &&
part.state === "approval-requested" &&
part.approval
) {
return (
<ApprovalPrompt
key={i}
part={part}
onApprove={() =>
addToolApprovalResponse({ id: part.approval!.id, approved: true })
}
onDeny={() =>
addToolApprovalResponse({ id: part.approval!.id, approved: false })
}
/>
);
}
if (part.type === "thinking") return <ReasoningView key={i} text={part.content} />;
if (part.type === "tool-call") return <ToolCallView key={i} part={part} />;
return null;
})}

{/* The structured payload — fills in once tools resolve. */}
<StructuredView data={final ?? partial} />
</>
);

승인이 대기 중인 동안 partial은 마지막 값(첫 실행에서는 {})으로 유지되고 finalnull로 유지됩니다. 사용자가 승인하는 즉시(또는 거부하여 루프가 계속되는 즉시) 에이전트 루프가 재개되고 구조화된 스트림이 실행되며 partial / final이 채워집니다.

전체 서버 도구 승인 패턴은 도구 승인 흐름에 있습니다. 구조화된 출력과 관련해 주의할 점은 승인이 구조화된 스트림이 시작되기 전에 발생할 수 있다는 것입니다. 승인 프롬프트를 표시하는 동안 partial{}인 이유는 아직 JSON이 없기 때문입니다.

실행 중간의 클라이언트 도구

도구 정의에서 .client((input) => ...)으로 정의한 클라이언트 도구는 모델이 호출하면 자동으로 실행됩니다. 서버는 공개 interrupts 배열에 나타나지 않는 내부 client-tool-execution 인터럽트로 현재 실행을 종료합니다. 클라이언트는 등록된 .client() 구현을 실행하고 재개 배치에서 그 출력을 제출합니다. 모든 클라이언트 도구가 해결되면 에이전트 루프가 구조화된 출력 스트림으로 계속 진행됩니다. 훅 측에서 연결할 onToolCall 옵션은 없습니다.

import { toolDefinition } from "@tanstack/ai";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { z } from "zod";
import { runLookupOnClient } from "./client-utils";
import { RecommendationSchema } from "./api/recommend";

const lookupContactDef = toolDefinition({
name: "lookup_contact",
description: "Find a contact by name",
inputSchema: z.object({ name: z.string() }),
outputSchema: z.object({ email: z.string(), phone: z.string() }),
});

// `.client()` registers the browser-side implementation. Calls land here
// automatically when the model invokes the tool.
const lookupContact = lookupContactDef.client((input) => {
// Look up in local state, IndexedDB, an in-process address book, etc.
return runLookupOnClient(input);
});

const { messages, sendMessage, partial, final } = useChat({
outputSchema: RecommendationSchema,
tools: [lookupContact],
connection: fetchServerSentEvents("/api/recommend"),
});

클라이언트 도구가 실행되는 동안 에이전트 루프는 일시 중지되고 구조화된 출력 스트림은 시작되지 않습니다. partial{}, finalnull로 유지됩니다. .client() 구현이 반환되는 즉시 루프가 재개되고 구조화된 스트림이 이어지며 partial / final이 채워집니다.

전체 패턴(타입이 지정된 입력 / 출력, 여러 클라이언트 도구, 서버 도구와의 혼합, 메시지 렌더러에 도구 호출 표시)은 클라이언트 도구를 참고하세요.

멀티턴 + 도구 + 구조화된 출력

자연스럽게 조합할 수 있습니다. 매 턴마다 에이전트 루프가 실행되고(모든 도구 게이트 포함), 실행이 성공적으로 완료되면 구조화된 출력 어시스턴트 메시지가 생성됩니다. 다음 턴에서는 이전 레시피(또는 추천이나 보고서)를 어시스턴트 콘텐츠로 확인하고 이를 반복해서 개선할 수 있습니다. 별도 최종화 경로에서는 구조화된 응답 전에 에이전트 루프가 생성한 일반 텍스트 어시스턴트 메시지를 유지할 수도 있습니다.

주의할 점은 sendMessage()와 첫 구조화된 출력 이벤트 사이에는 최신 턴에 아직 structured-output 파트가 없다는 것입니다. 따라서 렌더 루프의 m.parts.find(p => p.type === "structured-output")undefined를 반환합니다. 이 공백을 처리하려면 isLoading && messages[last]?.role === "user"일 때 "스트리밍 중…" 플레이스홀더를 렌더링하세요. 전체 패턴은 멀티턴 채팅을 참고하세요.