콘텐츠로 이동

도구 기반 생성형 UI (Tool-Based Generative UI)

도구 호출을 컴포넌트로 렌더링하기

Crew나 Flow가 도구를 호출할 때, 그 원본 인수(arguments)가 통째로 채팅에 쏟아지는 걸 원하는 경우는 드물어요. 도구 기반 생성형 UI는 에이전트가 호출하는 각 도구를 여러분이 소유한 React 컴포넌트에 매핑합니다. 에이전트는 도구를 언제 호출할지 정하고, 사용자에게 무엇을 보여줄지는 여러분이 정해요. CopilotKit은 모델이 생성하는 대로 도구 호출을 프론트엔드로 스트리밍하기 때문에, 인수는 점점 채워져요. 첫 번째 필드가 도착하는 순간 컴포넌트가 그려지고, 나머지가 스트리밍되는 동안 계속 갱신될 수 있어요.

이 가이드에서는 하이쿠(haiku) 생성기를 만들어요. 에이전트는 generate_haiku 도구를 호출하고, 프론트엔드는 하이쿠 하나를 카드로 렌더링하죠. Next.js 앱에 이미 연결된 Crew나 Flow가 있다고 가정할게요. 아직 없다면 프론트엔드 개요부터 시작해서 서버·런타임·프로바이더 설정을 갖추면 돼요.

도구 렌더링은 Crew와 Flow 모두에서 동작해요. 아래 예시는 Flow를 쓰지만, 프론트엔드 연결 방식은 어느 쪽이든 똑같아요.

워크스루 (Walkthrough)

1. 백엔드에서 도구 정의하기

JSON 스키마로 도구를 선언하고 모델에 전달해요. copilotkit_stream 래퍼와 함께 stream=True를 쓰는 게, 도구 호출을 생성되는 대로 한 인수 덩어리씩 프론트엔드로 스트리밍하는 핵심이에요.

# haiku_flow.py
from crewai.flow.flow import Flow, start
from litellm import acompletion
from ag_ui_crewai.sdk import copilotkit_stream, CopilotKitState

GENERATE_HAIKU_TOOL = {
    "type": "function",
    "function": {
        "name": "generate_haiku",
        "description": "Generate a haiku in Japanese and its English translation",
        "parameters": {
            "type": "object",
            "properties": {
                "japanese": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Three lines in Japanese",
                },
                "english": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Three lines in English",
                },
            },
            "required": ["japanese", "english"],
        },
    },
}


class HaikuFlow(Flow[CopilotKitState]):
    @start()
    async def chat(self):
        system_prompt = "You help the user write haikus. Use the generate_haiku tool."

        response = await copilotkit_stream(
            await acompletion(
                model="openai/gpt-4o",
                messages=[
                    {"role": "system", "content": system_prompt},
                    *self.state.messages,
                ],
                tools=[GENERATE_HAIKU_TOOL],
                parallel_tool_calls=False,
                stream=True,
            )
        )

        message = response.choices[0].message
        self.state.messages.append(message)

        if message.tool_calls:
            self.state.messages.append({
                "tool_call_id": message.tool_calls[0].id,
                "role": "tool",
                "content": "Haiku generated.",
            })

이 도구에는 Python 구현이 없어요. 프론트엔드가 렌더링할 구조화된 호출을 모델이 내보내도록 하는 역할만 있어요. 호출 뒤에는 짧은 도구 결과를 붙여서, 다음 턴에도 대화가 잘 유지되게 해요.

2. Flow를 AG-UI로 서빙하기

FastAPI 앱에서 Flow를 자기 경로에 노출해요:

# server.py
from fastapi import FastAPI
from ag_ui_crewai.endpoint import add_crewai_flow_fastapi_endpoint
from haiku_flow import HaikuFlow

app = FastAPI(title="CrewAI Agent Server")

add_crewai_flow_fastapi_endpoint(
    app=app,
    flow=HaikuFlow(),
    path="/haiku",
)

프론트엔드 개요에서 보여준 대로 에이전트를 CopilotKit 런타임에 등록하고 <CopilotKit>이 그걸 가리키게 하면 돼요. 이 가이드의 나머지 부분은 에이전트가 haiku라는 id로 등록되어 있다고 가정해요.

3. 렌더링 컴포넌트 등록하기

프론트엔드에서 백엔드가 선언한 것과 같은 name으로 useRenderTool을 호출해요. useRenderTool은 도구 호출을 렌더링하는 훅이에요. render 함수만 받고 실행할 건 없어요. 이 도구는 순수하게 표시용이거든요.

도구가 UI만 그릴 때 useRenderTool을 쓰세요. 브라우저에서 무언가를 실행해야 한다면, handler와 선택적 render를 짝으로 쓰는 useFrontendTool를 대신 쓰세요.

"use client";
import { useRenderTool } from "@copilotkit/react-core/v2";
import { z } from "zod";

useRenderTool({
  name: "generate_haiku",
  parameters: z.object({
    japanese: z.array(z.string()),
    english: z.array(z.string()),
  }),
  render: ({ args, status }) => {
    if (!args.japanese) return <></>; // still streaming
    return <HaikuCard japanese={args.japanese} english={args.english} />;
  },
});

도구는 <CopilotKit agent="haiku"> 프로바이더에 의해 활성 에이전트로 한정되므로, 여기선 agentId가 필요 없어요. 몇 가지 짚고 넘어갈 점이 있어요.

  • name은 백엔드 도구 이름과 정확히 일치해야 해요(generate_haiku). 이 일치가 CopilotKit이 호출을 이 컴포넌트로 라우팅하는 방식이에요.
  • render{ args, status }를 받아요. args는 모델이 호출을 스트리밍하면서 점점 채워져요. 초기엔 비어 있거나 일부만 있을 수 있어요. status"inProgress" / "executing"을 거쳐 "complete"로 넘어가는데, 인수가 스트리밍되는 동안 로딩 상태를 보여주고 싶을 때 쓰면 돼요.
  • 부분 인수에 대비하세요. 필요한 필드가 생길 때까지 빈 프래그먼트를 반환해요. 여기선 카드를 그리기 전에 args.japanese를 기다리죠.

4. 하이쿠 렌더링하기

render 함수는 평범한 React 컴포넌트에 위임해요. 이 안엔 CopilotKit 특유의 것이 하나도 없어요. props를 받아 마크업을 반환할 뿐이에요.

function HaikuCard({
  japanese,
  english,
}: {
  japanese: string[];
  english: string[];
}) {
  return (
    <div className="haiku-card">
      {japanese.map((line, i) => (
        <div key={i} className="haiku-line">
          <span className="jp">{line}</span>
          <span className="en">{english?.[i]}</span>
        </div>
      ))}
    </div>
  );
}

englishjapanese와 함께 스트리밍되어 오기 때문에, 옵셔널 접근(english?.[i])을 써서 번역이 아직 도착하는 동안에도 카드가 깔끔하게 그려지게 해요.

5. 실행하기

두 프로세스를 시작하고 어시스턴트에게 하이쿠를 요청하면 돼요. 인수가 스트리밍되는 대로 카드가 그려지면서 줄 단위로 채워져요.

uvicorn server:app --port 8000   # terminal 1
npm run dev                       # terminal 2

점진적 렌더링이 동작하는 방식

모델은 도구 호출을 한 번에 내보내지 않아요. 토큰을 스트리밍하면서, CopilotKit은 인수의 새 덩어리가 도착할 때마다 여러분의 render 함수를 다시 호출해요.

  1. 호출이 시작돼요. args는 비어 있으므로, 가드가 빈 프래그먼트를 반환해요.
  2. args.japanese가 줄 단위로 채워져요. 카드가 나타나고 자라나요.
  3. args.english가 채워져요. 번역이 제자리에 들어와요.
  4. 호출이 완료돼요. args에는 최종적으로 완전히 검증된 객체가 들어 있어요.

그래서 부분 인수 가드가 중요한 거예요. render는 설계상 불완전한 데이터를 상대로 실행되거든요. 있는 필드만 읽고, 나머지는 도착하는 대로 그려지게 두면 돼요.

백엔드 도구 (Backend tools)

generate_haiku 도구에는 Python 구현이 없어요. 프론트엔드가 렌더링할 구조화된 호출을 모델이 내보내도록 하는 역할만 있죠. 하지만 Crew나 Flow가 서버 쪽에서 실제로 실행하는 도구도 같은 방식으로 렌더링돼요. 에이전트나 크루가 실행 중에 도구를 실행하면, 브리지가 그 도구 호출을 결과와 함께 표면화해요. 도구 이름으로 useRenderTool을 등록하고 render 안에서 result를 읽으면 돼요:

useRenderTool({
  name: "get_weather",
  parameters: z.object({ location: z.string() }),
  render: ({ args, result, status }) => {
    if (status !== "complete") return <WeatherSkeleton location={args.location} />;
    return <WeatherCard data={JSON.parse(result)} />;
  },
});

백엔드 도구는 Python dict가 아니라 JSON 문자열을 반환해야 해요. 브리지가 도구 출력을 문자열화하므로, 그냥 dict를 돌려주면 브라우저가 JSON.parse할 수 없는 Python repr로 도착해요. 도구에서는 json.dumps(...)를 반환하세요.