다중 턴 구조화된 채팅
사용자가 여러 턴에 걸쳐 구조화된 객체를 반복해서 수정하도록 하려 합니다. "15달러 이하의 파스타 레시피를 알려줘" → 레시피 카드가 표시됩니다. "이제 비건으로 만들어줘" → 새 레시피 카드가 표시되고 이전 카드는 기록에 계속 보입니다. "샐러드를 추가하고 글루텐 프리로 만들어줘" → 세 번째 카드가 표시되며, 비교할 수 있도록 처음 두 카드도 그대로 남아 있습니다.
이것이 구조화된 출력 채팅의 형태입니다. 성공적으로 완료된 각 structured-output 실행은 structured-output 어시스턴트 메시지를 추가하고, 이전 응답은 모두 계속 렌더링할 수 있으며, messages[i].parts.find(p => p.type === 'structured-output').data의 타입은 unknown이 아니라 스키마에서 추론된 타입입니다.
이 가이드를 마치면 messages를 직접 순회하고, 성공적으로 완료된 각 structured-output 실행마다 타입이 지정된 카드 하나를 렌더링하며, sendMessage() 호출 간에 기록을 유지하는 채팅 UI를 갖게 됩니다.
참고: 단일 왕복(프롬프트 하나 → 객체 하나)만 필요하다면 One-Shot Extraction을 사용합니다. 기록 없이 한 턴을 점진적으로 스트리밍하려면 Streaming UIs를 사용합니다. 해당 문서의
partial/final편의 기능이 적합합니다. 이 페이지는 기록이 중요한 경우를 다룹니다.
React Native 레시피 앱: Expo 예제는 XHR 전송 선택기를 사용해 동일한 다중 턴 레시피 패턴을 네이티브 카드로 스트리밍합니다. Expo Go에서 실행하려면 Quick Start: React Native를 참고합니다.
메시지에 추가되는 방식
useChat({ outputSchema })가 서버의 structured-output.complete 이벤트를 받으면 런타임은 구조화된 응답을 담은 어시스턴트 UIMessage에 타입이 지정된 structured-output MessagePart를 연결합니다. 파트는 다음과 같습니다.
import type { DeepPartial } from "@tanstack/ai";
type StructuredOutputPart<TData> = {
type: "structured-output";
status: "streaming" | "complete" | "error";
/** Progressive parse of `raw` — populated while streaming and after complete. */
partial?: DeepPartial<TData>;
/** Completed typed object — set when `status === "complete"`. */
data?: TData;
/** Accumulating JSON text. Round-trip source of truth for the next turn. */
raw: string;
/** Optional reasoning tokens surfaced by thinking models. */
reasoning?: string;
/** Set when `status === "error"`. */
errorMessage?: string;
};
TData는 useChat({ outputSchema })에서 프레임워크 패키지의 메시지 타입을 거쳐(@tanstack/ai-client의 UIMessage<TTools, TData> 및 MessagePart<TTools, TData>는 React / Vue / Solid / Svelte 훅이 다시 내보냅니다) structured-output 변형까지 전달됩니다. 따라서 useChat({ outputSchema: RecipeSchema })를 호출하면 messages[i].parts.find(p => p.type === "structured-output")가 StructuredOutputPart<Recipe>를 반환하며, data에는 Recipe, partial에는 DeepPartial<Recipe> 타입이 지정됩니다. 수동 캐스팅은 필요하지 않습니다.
참고: 핵심
@tanstack/ai패키지는 단일 제네릭(TTools없음)을 사용하는MessagePart<TData>및UIMessage<TData>를 정의합니다. 도구 제네릭은@tanstack/ai-client와 프레임워크 훅 패키지에 있습니다. UI를 빌드한다면 거의 항상 프레임워크 패키지(@tanstack/ai-react/-vue/-solid/-svelte) 또는@tanstack/ai-client에서 가져와야 합니다. 이러한 패키지는 두 제네릭을 모두 포함합니다. 클라이언트 아래의 어댑터 계층에서 작업하는 경우에만 핵심 타입을 사용합니다.
새 구조화된 응답은 각각 고유한 structured-output 파트를 포함하는 새 어시스턴트 메시지에 추가됩니다. 이전 응답은 변경되지 않습니다. 따라서 "기록 표시"가 간단해집니다.
실행에는 structured-output 파트가 없는 어시스턴트 메시지도 포함될 수 있습니다. 네이티브 결합 출력에서는 구조화된 JSON과 해당 파트를 하나의 어시스턴트 메시지에 유지합니다. 별도 완료 경로에서는 일반 텍스트 어시스턴트 메시지가 structured-output 어시스턴트 메시지보다 먼저 올 수 있습니다. 모든 어시스턴트 메시지에 파트가 있다고 가정하지 말고 타입으로 파트를 찾습니다.
서버 엔드포인트
서버 측은 단일 턴 스트리밍 엔드포인트와 동일합니다. 서버가 모든 요청에서 전체 대화 기록을 확인하고 요청마다 하나의 structured-output 실행을 내보내므로 chat({ outputSchema, stream: true })는 기본적으로 다중 턴에서도 안전하게 작동합니다.
// app/api/structured-chat/route.ts
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
export const RecipeSchema = z.object({
title: z.string(),
cuisine: z.string(),
servings: z.number(),
estimatedCostUsd: z.number(),
ingredients: z.array(
z.object({ item: z.string(), amount: z.string() }),
),
steps: z.array(z.string()),
tips: z.array(z.string()),
});
export type Recipe = z.infer<typeof RecipeSchema>;
const SYSTEM_PROMPT = `You are a chef assistant. Always respond with a single recipe matching the JSON schema. When the user asks for modifications, produce a new recipe in the same shape that reflects the change.`;
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
systemPrompts: [SYSTEM_PROMPT],
outputSchema: RecipeSchema,
stream: true,
});
return toServerSentEventsResponse(stream);
}
내부적으로 클라이언트가 N번째 턴을 보낼 때 요청에는 이전 UIMessage.parts가 그대로 유지됩니다. 클라이언트는 완료된 각 structured-output 파트의 raw JSON도 어시스턴트 콘텐츠에 반영합니다. 서버 변환은 structured-output 마커를 보존하고, 어댑터는 공급자용 콘텐츠를 사용합니다. 별도의 작업 없이 다중 턴의 일관성이 유지됩니다.
클라이언트: 메시지 순회
원하는 형태는 다음과 같습니다. useChat은 타입과 스키마를 인식하는 messages, sendMessage, 그리고 최신 턴을 위한 훅 수준의 partial / final 편의 기능을 제공합니다. 기록을 렌더링하려면 messages를 직접 순회합니다.
import { useState } from "react";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import type { StructuredOutputPart } from "@tanstack/ai-client";
import { RecipeSchema, type Recipe } from "./api/structured-chat";
import { UserBubble } from "./components";
// The schema-typed structured-output part. Pulled out so the find()
// predicate below stays readable.
type RecipePart = StructuredOutputPart<Recipe>;
function StructuredChatPage() {
const [input, setInput] = useState("");
const { messages, sendMessage, isLoading } = useChat({
outputSchema: RecipeSchema,
connection: fetchServerSentEvents("/api/structured-chat"),
});
return (
<div>
{messages.map((m) => {
if (m.role === "user") {
const text = m.parts
.filter((p) => p.type === "text")
.map((p) => p.content)
.join("");
return <UserBubble key={m.id} text={text} />;
}
if (m.role === "assistant") {
// `data` is typed as `Recipe` because the schema generic flows
// all the way from useChat through messages[i].parts.
const recipePart = m.parts.find(
(p): p is RecipePart => p.type === "structured-output",
);
if (!recipePart) return null;
return <RecipeCard key={m.id} part={recipePart} />;
}
return null;
})}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isLoading}
/>
<button
onClick={() => {
sendMessage(input);
setInput("");
}}
disabled={isLoading}
>
Send
</button>
</div>
);
}
function RecipeCard({ part }: { part: RecipePart }) {
// `data` is `Recipe` once status === 'complete'. `partial` is
// DeepPartial<Recipe> while the model is still streaming the JSON.
// Read whichever is freshest — they converge on complete.
const recipe = part.data ?? part.partial;
return (
<article>
<h3>{recipe?.title ?? "Plating up…"}</h3>
{recipe?.cuisine && <p>{recipe?.cuisine}</p>}
{recipe?.ingredients?.map((ing, i) => (
<li key={i}>
{ing?.amount} {ing?.item}
</li>
))}
{part.status === "error" && (
<p>Failed: {part.errorMessage ?? "Stream failed"}</p>
)}
</article>
);
}
이것으로 끝입니다. 위의 렌더링 루프는 구조화된 응답마다 카드 하나를 만듭니다. 사용자가 후속 메시지를 보내면 새 structured-output 어시스턴트 메시지가 도착하며, 이전 카드는 원래 상태 그대로 유지됩니다.
전체 패턴은 코드에서 확인합니다:
examples/ts-react-chat/src/routes/generations.structured-chat.tsx의 예제 앱에는 동일한 레시피 빌더 UI의 완성도 높은 버전이 포함되어 있습니다. 빈 상태, 스트리밍 자리 표시자, 요리에 맞춘 히어로 배너, 재료 그리드, 번호가 매겨진 조리법, 셰프 팁 블록을 제공합니다. 시각적 레이아웃의 참고 자료로 사용하며, 데이터 연결은 위에 표시된 내용과 일치합니다.
최신 턴 스트리밍
structured-output 파트는 일반적으로 streaming → complete(또는 streaming → error) 순서로 진행됩니다. 이전 스트리밍 델타 없이 종료 전용 complete 이벤트가 도착할 수도 있습니다. data 필드는 complete일 때만 채워집니다. 모델이 JSON을 계속 생성하는 동안에는 partial과 raw만 채워집니다. part.data ?? part.partial을 기준으로 렌더링하면 바이트가 도착할 때마다 UI의 필드가 채워지고, 종료 이벤트에서 완료된 타입 객체로 전환됩니다.
훅 수준의 partial과 final도 계속 사용할 수 있습니다. 이는 최신 사용자 메시지 이후 가장 최근의 structured-output 파트에서 파생되며, 위의 렌더링 루프가 이미 찾는 파트와 동일합니다. sendMessage()와 첫 번째 청크 사이에는 파생할 structured-output 파트가 아직 없으므로 partial이 {}를 반환하고, 최신 턴의 complete 이벤트가 도착할 때까지 final은 null을 반환합니다. 고정 요약 위젯("최신 레시피 제목: …")에는 이를 사용하고, 전체 기록 보기에는 messages 순회를 사용합니다.
이름이 지정된 별칭 없이 타입 안전하게 접근하기
위 예제에서는 가독성을 위해 type RecipePart = StructuredOutputPart<Recipe>를 별도로 분리했습니다. 이름을 지정하지 않으려면 Extract를 사용해 인라인으로 범위를 좁힐 수 있습니다.
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { RecipeSchema, type Recipe } from "./api/structured-chat";
const { messages } = useChat({
outputSchema: RecipeSchema,
connection: fetchServerSentEvents("/api/structured-chat"),
});
for (const m of messages) {
const recipePart = m.parts.find(
(p): p is Extract<typeof p, { type: "structured-output" }> =>
p.type === "structured-output",
);
// `recipePart` is `StructuredOutputPart<Recipe> | undefined`.
// `recipePart.data` is `Recipe | undefined`.
}
두 형식 모두 동일한 타입 결과를 만듭니다. 더 읽기 쉬운 형식을 선택합니다.
왕복은 어떻게 처리되나요?
N+1번째 턴이 실행되면 완료된 structured-output 파트는 UI 메시지에 남고 part.raw를 사용해 공급자용 어시스턴트 콘텐츠에 반영됩니다. 스트리밍 중인 파트와 오류가 발생한 파트는 UI 상태로 남지만 모델 입력에서는 제외됩니다.
raw가 비어 있으면(드문 경우로, 델타가 하나도 오기 전에 종료 전용 complete 이벤트가 도착했고 런타임도 data를 직렬화하지 못한 경우) 파트는 UI 상태에 남지만 공급자용 콘텐츠에서는 제외됩니다. 이렇게 하면 빈 어시스턴트 턴이 모델로 전송되지 않습니다.
도구와 함께 사용하나요? 다중 턴 구조화된 채팅은 단일 턴 스트림과 동일한 방식으로 에이전트 루프와 결합됩니다. 각 턴은 먼저 도구를 실행한 다음 structured-output 메시지를 생성합니다. 구조화된 채팅 실행에서 도구 승인 게이팅과 클라이언트 도구 호출을 사용하려면 With Tools를 참고합니다.