LangGraph로 생성적 사용자 인터페이스 구현하기
LangGraph로 생성적 사용자 인터페이스 구현하기
참고: 사전 요구사항
생성적 사용자 인터페이스(Generative UI)는 에이전트가 텍스트를 넘어 풍부한 사용자 인터페이스를 생성할 수 있게 해줘요. 이를 통해 대화 흐름과 AI 응답에 따라 UI가 적응하는 더 상호작용적이고 컨텍스트를 인지하는 애플리케이션을 만들 수 있어요.
LangSmith는 React 컴포넌트를 그래프 코드와 함께 배치(co-locate)하는 것을 지원해요. 이렇게 하면 특정 UI 컴포넌트를 그래프용으로 만드는 데 집중하면서 Agent Chat 같은 기존 채팅 인터페이스에 쉽게 연결하고, 실제로 필요할 때만 코드를 로드할 수 있어요.
출처: 문서
본문
튜토리얼
1. UI 컴포넌트 정의 및 구성
먼저 첫 UI 컴포넌트를 만들어요. 각 컴포넌트에는 그래프 코드에서 컴포넌트를 참조하는 데 사용될 고유 식별자를 제공해야 해요.
const WeatherComponent = (props: { city: string }) => {
return <div>Weather for {props.city}</div>;
};
export default {
weather: WeatherComponent,
};
다음으로 langgraph.json 구성에서 UI 컴포넌트를 정의해요:
{
"node_version": "20",
"graphs": {
"agent": "./src/agent/index.ts:graph"
},
"ui": {
"agent": "./src/agent/ui.tsx"
}
}
ui 섹션은 그래프가 사용할 UI 컴포넌트를 가리켜요. 기본적으로 그래프 이름과 같은 키를 사용할 것을 권장하지만, 원하는 대로 컴포넌트를 분할할 수 있어요. 자세한 내용은 UI 컴포넌트의 네임스페이스 커스터마이징을 참조해요.
LangSmith는 UI 컴포넌트 코드와 스타일을 자동으로 번들링해 LoadExternalComponent 컴포넌트가 로드할 수 있는 외부 에셋으로 제공해요. react와 react-dom 같은 일부 의존성은 번들에서 자동으로 제외돼요.
CSS와 Tailwind 4.x도 기본 지원되므로 UI 컴포넌트에서 Tailwind 클래스와 shadcn/ui를 자유롭게 사용할 수 있어요.
const WeatherComponent = (props: { city: string }) => {
return <div className="bg-red-500">Weather for {props.city}</div>;
};
export default {
weather: WeatherComponent,
};
```
2. 그래프에서 UI 컴포넌트 보내기
from langchain.messages import AIMessage
from langchain_core.messages import BaseMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
from langgraph.graph.ui import AnyUIMessage, ui_message_reducer, push_ui_message
class AgentState(TypedDict): # noqa: D101
messages: Annotated[Sequence[BaseMessage], add_messages]
ui: Annotated[Sequence[AnyUIMessage], ui_message_reducer]
async def weather(state: AgentState):
class WeatherOutput(TypedDict):
city: str
weather: WeatherOutput = (
await ChatOpenAI(model="gpt-5.4-mini")
.with_structured_output(WeatherOutput)
.with_config({"tags": ["nostream"]})
.ainvoke(state["messages"])
)
message = AIMessage(
id=str(uuid.uuid4()),
content=f"Here's the weather for {weather['city']}",
)
# Emit UI elements associated with the message
push_ui_message("weather", weather, message=message)
return {"messages": [message]}
workflow = StateGraph(AgentState)
workflow.add_node(weather)
workflow.add_edge("__start__", "weather")
graph = workflow.compile()
```
```typescript title="src/agent/index.ts"
import {
typedUi,
uiMessageReducer,
} from "@langchain/langgraph-sdk/react-ui/server";
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";
import type ComponentMap from "./ui.js";
import {
Annotation,
MessagesAnnotation,
StateGraph,
type LangGraphRunnableConfig,
} from "@langchain/langgraph";
const AgentState = Annotation.Root({
...MessagesAnnotation.spec,
ui: Annotation({ reducer: uiMessageReducer, default: () => [] }),
});
export const graph = new StateGraph(AgentState)
.addNode("weather", async (state, config) => {
// Provide the type of the component map to ensure
// type safety of `ui.push()` calls as well as
// pushing the messages to the `ui` and sending a custom event as well.
const ui = typedUi<typeof ComponentMap>(config);
const weather = await new ChatOpenAI({ model: "gpt-5.4-mini" })
.withStructuredOutput(z.object({ city: z.string() }))
.withConfig({ tags: ["nostream"] })
.invoke(state.messages);
const response = {
id: crypto.randomUUID(),
type: "ai",
content: `Here's the weather for ${weather.city}`,
};
// Emit UI elements associated with the AI message
ui.push({ name: "weather", props: weather }, { message: response });
return { messages: [response] };
})
.addEdge("__start__", "weather")
.compile();
```
3. React 애플리케이션에서 UI 요소 처리
클라이언트 측에서 useStream()과 LoadExternalComponent를 사용해 UI 요소를 표시할 수 있어요.
"use client";
import { useStream } from "@langchain/langgraph-sdk/react";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
export default function Page() {
const { thread, values } = useStream({
apiUrl: "http://localhost:2024",
assistantId: "agent",
});
return (
<div>
{thread.messages.map((message) => (
<div key={message.id}>
{message.content}
{values.ui
?.filter((ui) => ui.metadata?.message_id === message.id)
.map((ui) => (
<LoadExternalComponent key={ui.id} stream={thread} message={ui} />
))}
</div>
))}
</div>
);
}
내부적으로 LoadExternalComponent는 UI 컴포넌트의 JS와 CSS를 LangSmith에서 가져와 섀도 DOM에서 렌더링하므로, 애플리케이션의 나머지와 스타일이 분리되도록 보장해요.
How-to 가이드
클라이언트 측에서 커스텀 컴포넌트 제공
이미 클라이언트 애플리케이션에 컴포넌트를 로드해 두었다면, LangSmith에서 UI 코드를 가져오지 않고 직접 렌더링할 수 있도록 그러한 컴포넌트의 맵을 제공할 수 있어요.
const clientComponents = {
weather: WeatherComponent,
};
<LoadExternalComponent
stream={thread}
message={ui}
components={clientComponents}
/>;
컴포넌트 로딩 중 로딩 UI 표시
컴포넌트를 로드하는 동안 렌더링할 폴백 UI를 제공할 수 있어요.
<LoadExternalComponent
stream={thread}
message={ui}
fallback={<div>Loading...</div>}
/>
UI 컴포넌트의 네임스페이스 커스터마이징
기본적으로 LoadExternalComponent는 useStream() 훅의 assistantId를 사용해 UI 컴포넌트 코드를 가져와요. LoadExternalComponent 컴포넌트에 namespace prop을 제공해 커스터마이즈할 수 있어요.
UI 컴포넌트에서 스레드 상태에 접근 및 상호작용
useStreamContext 훅을 사용해 UI 컴포넌트 내부에서 스레드 상태에 접근할 수 있어요.
import { useStreamContext } from "@langchain/langgraph-sdk/react-ui";
const WeatherComponent = (props: { city: string }) => {
const { thread, submit } = useStreamContext();
return (
<>
<div>Weather for {props.city}</div>
<button
onClick={() => {
const newMessage = {
type: "human",
content: `What's the weather in ${props.city}?`,
};
submit({ messages: [newMessage] });
}}
>
Retry
</button>
</>
);
};
클라이언트 컴포넌트에 추가 컨텍스트 전달
LoadExternalComponent 컴포넌트에 meta prop을 제공해 클라이언트 컴포넌트에 추가 컨텍스트를 전달할 수 있어요.
<LoadExternalComponent stream={thread} message={ui} meta={{ userId: "123" }} />
그런 다음 useStreamContext 훅을 사용해 UI 컴포넌트에서 meta prop에 접근할 수 있어요.
import { useStreamContext } from "@langchain/langgraph-sdk/react-ui";
const WeatherComponent = (props: { city: string }) => {
const { meta } = useStreamContext<
{ city: string },
{ MetaType: { userId?: string } }
>();
return (
<div>
Weather for {props.city} (user: {meta?.userId})
</div>
);
};
서버에서 UI 메시지 스트리밍
useStream() 훅의 onCustomEvent 콜백을 사용해 노드 실행이 끝나기 전에 UI 메시지를 스트리밍할 수 있어요. 특히 LLM이 응답을 생성하는 동안 UI 컴포넌트를 업데이트할 때 유용해요.
import { uiMessageReducer } from "@langchain/langgraph-sdk/react-ui";
const { thread, submit } = useStream({
apiUrl: "http://localhost:2024",
assistantId: "agent",
onCustomEvent: (event, options) => {
options.mutate((prev) => {
const ui = uiMessageReducer(prev.ui ?? [], event);
return { ...prev, ui };
});
},
});
그런 다음 업데이트하려는 UI 메시지와 같은 ID로 ui.push() / push_ui_message()를 호출해 UI 컴포넌트에 업데이트를 푸시할 수 있어요.
from langchain_anthropic import ChatAnthropic
from langchain.messages import AIMessage, AIMessageChunk, BaseMessage
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
from langgraph.graph.ui import AnyUIMessage, push_ui_message, ui_message_reducer
class AgentState(TypedDict): # noqa: D101
messages: Annotated[Sequence[BaseMessage], add_messages]
ui: Annotated[Sequence[AnyUIMessage], ui_message_reducer]
class CreateTextDocument(TypedDict):
"""Prepare a document heading for the user."""
title: str
async def writer_node(state: AgentState):
model = ChatAnthropic(model="claude-sonnet-4-6")
message: AIMessage = await model.bind_tools(
tools=[CreateTextDocument],
tool_choice={"type": "tool", "name": "CreateTextDocument"},
).ainvoke(state["messages"])
tool_call = next(
(x["args"] for x in message.tool_calls if x["name"] == "CreateTextDocument"),
None,
)
if tool_call:
ui_message = push_ui_message("writer", tool_call, message=message)
ui_message_id = ui_message["id"]
# We're already streaming the LLM response to the client through UI messages
# so we don't need to stream it again to the `messages` stream mode.
content_stream = model.with_config({"tags": ["nostream"]}).astream(
f"Create a document with the title: {tool_call['title']}"
)
content: AIMessageChunk | None = None
async for chunk in content_stream:
content = content + chunk if content else chunk
push_ui_message(
"writer",
{"content": content.text()},
id=ui_message_id,
message=message,
# Use `merge=True` to merge props with the existing UI message
merge=True,
)
return {"messages": [message]}
```
import type ComponentMap from "./ui";
const AgentState = Annotation.Root({
...MessagesAnnotation.spec,
ui: Annotation({ reducer: uiMessageReducer, default: () => [] }),
});
async function writerNode(
state: typeof AgentState.State,
config: LangGraphRunnableConfig
): Promise<typeof AgentState.Update> {
const ui = typedUi<typeof ComponentMap>(config);
const model = new ChatAnthropic({ model: "claude-sonnet-4-6" });
const message = await model
.bindTools(
[
{
name: "create_text_document",
description: "Prepare a document heading for the user.",
schema: z.object({ title: z.string() }),
},
],
{ tool_choice: { type: "tool", name: "create_text_document" } }
)
.invoke(state.messages);
type ToolCall = { name: "create_text_document"; args: { title: string } };
const toolCall = message.tool_calls?.find(
(tool): tool is ToolCall => tool.name === "create_text_document"
);
if (toolCall) {
const { id, name } = ui.push(
{ name: "writer", props: { title: toolCall.args.title } },
{ message }
);
const contentStream = await model
// We're already streaming the LLM response to the client through UI messages
// so we don't need to stream it again to the `messages` stream mode.
.withConfig({ tags: ["nostream"] })
.stream(`Create a short poem with the topic: ${message.text}`);
let content: AIMessageChunk | undefined;
for await (const chunk of contentStream) {
content = content?.concat(chunk) ?? chunk;
ui.push(
{ id, name, props: { content: content?.text } },
// Use `merge: true` to merge props with the existing UI message
{ message, merge: true }
);
}
}
return { messages: [message] };
}
```
{props.title}
{props.content}
export default {
weather: WriterComponent,
};
```
상태에서 UI 메시지 제거
메시지가 RemoveMessage를 추가하여 상태에서 제거될 수 있는 것과 유사하게, UI 메시지의 ID로 remove_ui_message / ui.delete를 호출해 상태에서 UI 메시지를 제거할 수 있어요.
# push message
message = push_ui_message("weather", {"city": "London"})
# remove said message
delete_ui_message(message["id"])
```
// remove said message
ui.delete(message.id);
```