스트리밍 구조화된 출력 UI
기존 채팅 스타일 엔드포인트가 있고 모델이 생성하는 동안 구조화된 응답으로 UI를 채우려 한다고 가정합니다. 필드별로 채워지는 폼, JSON이 스트리밍될수록 재료 목록이 늘어나는 카드, JSON 타입 보고서의 타자기식 미리보기 등이 해당합니다. await chat({ outputSchema })를 기다리면 전체 객체가 준비될 때까지 UI가 표시되지 않으므로, 이 가이드에서는 대안을 설명합니다.
이 가이드를 마치면 구조화된 JSON을 Server-Sent Events로 스트리밍하는 서버 엔드포인트와, useChat에서 타입이 지정된 partial(점진적 객체) 및 final(완료된 최종 객체)을 읽는 클라이언트를 갖게 됩니다.
참고: 이는 One-Shot Extraction의 스트리밍 버전입니다. 점진적인 UI 업데이트가 필요하지 않다면 단일 실행 경로가 더 간단합니다. 여러 턴에 걸쳐 사용자가 객체를 수정하고 기록을 유지하려면 Multi-Turn Chat을 참고하세요.
서버 엔드포인트
// app/api/extract-person/route.ts (or your framework's equivalent)
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const PersonSchema = z.object({
name: z.string().meta({ description: "The person's full name" }),
age: z.number().meta({ description: "The person's age in years" }),
email: z.string().email(),
});
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
outputSchema: PersonSchema,
stream: true,
});
return toServerSentEventsResponse(stream);
}
서버 측 구현은 이것으로 끝입니다. chat({ outputSchema, stream: true })는 완료된 객체를 담은 최종 structured-output.complete 이벤트와 표준 스트리밍 이벤트로 이루어진 AsyncIterable인 StructuredOutputStream<InferSchemaType<typeof PersonSchema>>를 반환합니다. toServerSentEventsResponse가 이를 처리합니다.
useChat을 사용하는 클라이언트
같은 스키마를 useChat에 전달합니다. 이 훅은 점진적으로 파싱되는 partial과 타입이 지정된 final을 제공합니다.
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { z } from "zod";
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});
function PersonExtractor() {
const { sendMessage, isLoading, partial, final } = useChat({
connection: fetchServerSentEvents("/api/extract-person"),
outputSchema: PersonSchema,
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage("Extract: John Doe, 30, john@example.com");
}}
>
<button disabled={isLoading}>Extract</button>
{/* `partial` fills in field by field as JSON streams in. */}
<p>Name: {partial.name ?? "…"}</p>
<p>Age: {partial.age ?? "…"}</p>
<p>Email: {partial.email ?? "…"}</p>
{final && <pre>Completed: {JSON.stringify(final, null, 2)}</pre>}
</form>
);
}
훅이 수행하는 작업은 다음과 같습니다.
- **
partial**은DeepPartial<z.infer<typeof PersonSchema>>입니다. 모든 속성과 중첩 배열 요소가 선택 사항입니다. 런타임의 부분 JSON 파서를 통해TEXT_MESSAGE_CONTENT델타에서 업데이트됩니다. 훅은 최신 assistant 메시지의structured-output부분에서 이를 도출하므로(이 구분이 중요한 이유는 Multi-Turn Chat 참고), 추가 초기화 상태 없이sendMessage()와 첫 번째 청크 사이에서{}를 읽습니다. - **
final**은z.infer<typeof PersonSchema> | null입니다.structured-output.complete이벤트의 완료된 최종 페이로드이며, 실행이 성공적으로 완료될 때까지null입니다. - **
outputSchema**는 클라이언트 측 TypeScript 타입 추론에만 사용됩니다. 스트리밍 경로에서는 Standard Schema 검증을 실행하지 않으므로, 필요한 경우 소비자에서 완료된 객체를 검증해야 합니다. - 비스트리밍 어댑터에서도 같은 형태가 동작합니다. 어댑터(Anthropic, Gemini, Ollama)가 증분 델타 없이 단일
structured-output.complete이벤트를 반환하면partial은{}로 유지되고 이벤트가 도착할 때final이 채워집니다. 소비자 코드는 동일합니다. Claude Code와 Codex는 하네스 이벤트에서structured-output.complete를 내보냅니다. OpenCode, Grok Build,acpCompatible는 마지막에 마지막 assistant 텍스트를 파싱합니다. 두 경우 모두final이 설정될 때까지partial은 비어 있습니다. Harness Agents를 참고하세요.
outputSchema는 선택 사항입니다. 생략하면 useChat은 partial / final이 없는 표준 형태를 반환합니다.
추론과 도구 호출 렌더링
partial / final은 구조화된 페이로드를 처리합니다. 추론 토큰과 도구 호출은 다른 채팅과 마찬가지로 messages[…].parts에 들어갑니다.
| Chunk type | messages[i].parts 에 어디가 위치하는지 |
|---|---|
REASONING_MESSAGE_CONTENT | 어시스턴트 메시지의 ThinkingPart |
TOOL_CALL_START / _ARGS / _END | 어시스턴트 메시지의 ToolCallPart |
TOOL_CALL_RESULT | 도구 메시지의 ToolResultPart |
TEXT_MESSAGE_CONTENT (outputSchema 설정) | assistant 메시지의 StructuredOutputPart — JSON 델타가 part.raw에 누적되고 점진적 파싱 결과가 part.partial에 채워짐 |
TEXT_MESSAGE_CONTENT (outputSchema 없음) | assistant 메시지의 TextPart |
따라서 messages[].parts에서 추론, 도구 호출, 타입이 지정된 객체를 렌더링합니다. final과 partial은 최신 structured-output 부분만을 위한 바로 가기입니다.
return (
<>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, i) => {
if (part.type === "thinking") {
return <ReasoningView key={i} text={part.content} />;
}
if (part.type === "tool-call") {
return <ToolCallView key={i} part={part} />;
}
if (part.type === "text") {
return <p key={i}>{part.content}</p>;
}
if (part.type === "structured-output") {
return (
<StructuredView key={i} data={part.data ?? part.partial} />
);
}
return null;
})}
</div>
))}
</>
);
structured-output 부분의 필드는 다음과 같습니다.
data:structured-output.complete이후 검증된 객체partial:raw을 JSON 스트림으로 진행해 파싱하는 과정raw: 소스 JSON 문자열status:streaming,complete, 또는error
최신 assistant 턴에서 final은 data와 같습니다. 기록, 도구 호출 또는 추론이 필요할 때는 parts를 순회합니다.
마이그레이션 참고: 이전 버전의 TanStack AI는 구조화된 JSON 델타를
TextPart로 전달했으며 렌더러에서 해당 부분을 필터링해야 했습니다. 이제 이 우회 방식은 없어졌습니다. 구조화된 출력 실행의TEXT_MESSAGE_CONTENT는 전용StructuredOutputPart(raw,partial,data,status, 선택적errorMessage포함)로 전달됩니다. 렌더링 루프에 구조화된 JSON을 숨기기 위한if (part.type === "text") return null;줄이 아직 명시적으로 있다면 제거할 수 있습니다.
더 낮은 수준에서 작업하나요? 관리되는
partial/final상태와 함께 개별 청크를 관찰하려면(예: 사용자 지정 진행 UI를 구동하려면)useChat이 여전히onChunk를 노출합니다. 내부 partial/final 추적이 먼저 실행된 다음 같은 청크로onChunk콜백이 호출되므로 두 경로를 함께 사용할 수 있습니다.
useChat(React, Vue, Solid)과 createChat(Svelte)은 모두 같은 outputSchema 옵션을 받고 동일한 의미의 partial / final을 노출합니다. 반응성 프리미티브만 다릅니다(React state, Vue shallowRef, Solid Accessor, Svelte reactive getter). 해당 프레임워크의 빠른 시작 문서에서 각 환경의 관용적인 사용법을 확인하세요.
스트림에 포함되는 내용
chat({ outputSchema, stream: true })는 StructuredOutputStream<T>를 반환합니다. 스트림은 표준 StreamChunk 생명주기에 structured-output.complete라는 최종 CUSTOM 이벤트가 추가된 형태입니다. RUN_FINISHED로 통합되지 않습니다.
{
type: "CUSTOM",
name: "structured-output.complete",
value: {
object: T; // completed, parsed, typed
raw: string; // full accumulated JSON text
reasoning?: string; // present only for thinking/reasoning models
},
// ...standard event fields (timestamp, …)
// model lives in metadata.tanstack when present
}
실행 시작 시 { messageId }를 담은 structured-output.start 이벤트가 한 번 발생합니다. 이 이벤트는 클라이언트에 "다음 TEXT_MESSAGE_CONTENT 델타 묶음이 이 ID의 assistant 메시지에 속하므로 자유 형식 TextPart를 만들지 말고 StructuredOutputPart로 전달하라"고 알립니다. 런타임은 종료 시 클라이언트가 올바른 assistant 메시지 부분을 찾을 수 있도록 최종 structured-output.complete 이벤트의 value에도 같은 messageId를 추가합니다. 이 추가 필드는 공개 StructuredOutputCompleteEvent<T> 형태에는 없지만(일반적으로 소비자 코드에 필요하지 않고 start 이벤트에 이미 포함되기 때문), 필요하면 런타임에 value에서 읽을 수 있습니다.
어댑터 지원 범위
스트리밍 구조화된 출력은 모든 어댑터에서 동작하지만, 실제 단일 요청 스트리밍 와이어 형식을 지원하는 어댑터는 일부뿐입니다.
| 어댑터 | outputSchema + stream: true 동작 |
|---|---|
@tanstack/ai-openai | 네이티브 단일 요청 스트림 (Responses API, text.format: json_schema) |
@tanstack/ai-openrouter | 네이티브 단일 요청 스트림 (response_format: json_schema) |
@tanstack/ai-grok | 네이티브 단일 요청 스트림 (Chat Completions, response_format: json_schema) |
@tanstack/ai-groq | 네이티브 단일 요청 스트림 (Chat Completions, response_format: json_schema) |
@tanstack/ai-bedrock | Converse 또는 OpenAI 호환 API를 통한 네이티브 스트림 |
@tanstack/ai-byteplus | 지원 모델에서는 네이티브 단일 요청 스트림, 미지원 모델에서는 RUN_ERROR 발생 |
@tanstack/ai-llmgateway | 네이티브 단일 요청 스트림 (Chat Completions, response_format: json_schema) |
@tanstack/ai-lovable | 네이티브 단일 요청 스트림 (Responses 또는 Chat Completions) |
| 기타 어댑터(anthropic, gemini, ollama, …) | 대체 경로: 비스트리밍 structuredOutput을 실행하고 최종 객체를 하나의 structured-output.complete 이벤트로 발생시킴 |
대체 경로를 사용하면 제공자와 관계없이 소비자 코드는 동일하게 유지됩니다. 항상 structured-output.complete에서 최종 객체를 읽지만, 어댑터가 structuredOutputStream을 네이티브로 구현하지 않았다면 증분 델타는 확인할 수 없습니다.
고급: 스트림 직접 순회
SSE-over-HTTP 경계가 필요하지 않은 경우(Node 스크립트, CLI, 스트림 대신 최종 JSON 객체로 응답하는 서버 엔드포인트 또는 테스트)에는 chat({ outputSchema, stream: true })를 일반 async iterable로 소비합니다.
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Extract: John Doe is 30, john@example.com" }],
outputSchema: PersonSchema,
stream: true,
});
for await (const chunk of stream) {
if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
// Typed against PersonSchema. Validate here when required.
console.log(chunk.value.object.name);
console.log(chunk.value.object.age);
}
}
이는 위의 서버 엔드포인트가 toServerSentEventsResponse에 전달하는 것과 같은 StructuredOutputStream<T>입니다. 처음부터 끝까지 단일 프로세스라면 이 형태를 선택하고, 중간에 네트워크가 있다면 서버 엔드포인트와 useChat을 결합한 형태를 사용합니다.
도구와 결합하나요?
outputSchema,stream: true,tools가 모두 설정되면 먼저 에이전트 루프가 실행되고 모든 도구가 완료된 후에만 구조화된 스트림이 최종 이벤트를 발생시킵니다. 도구 승인 게이트와 클라이언트 도구 호출은 일반 채팅과 동일하게 동작합니다. 전체 일시 중지/재개 패턴은 With Tools를 참고하세요.