공유 상태 (Shared State)¶
CrewAI 에이전트의 상태와 앱의 UI를 양방향으로 동기화해서, 어느 한쪽에서 수정하면 그 변경이 다른 쪽에도 전달되도록 해요.
하나의 상태, 두 방향¶
공유 상태는 에이전트와 UI가 함께 읽고 쓰는 단일 상태 객체예요. 에이전트는 작업하면서 상태를 갱신하고, React 컴포넌트는 그 상태를 실시간으로 렌더링하죠. 그리고 사용자가 UI에서 같은 상태를 수정하면, 그 변경이 다시 흘러가서 다음 턴에 에이전트가 그 내용을 보게 됩니다.
전형적인 예가 레시피예요. 에이전트가 레시피를 작성하면 사용자가 재료나 조리법 하나를 수정하고, 에이전트는 수정된 버전부터 이어서 작업하죠. 어느 한쪽이 상태를 "소유"하는 게 아니라, 양쪽이 상태를 공유하는 겁니다.
공유 상태는 커스텀 상태를 가진 Flow에서 동작해요. CopilotKitState를 상속하는 AgentState를 정의하고, Flow를 Flow[AgentState]로 타입 지정하면 됩니다. Crew는 커스텀 상태를 가지지 않기 때문에 이 패턴은 Flow에서만 쓸 수 있어요.
동작 방식¶
1단계: Flow에 공유 상태 정의하기¶
CopilotKitState를 상속하면 에이전트가 CopilotKit의 메시지 파이프라인을 그대로 유지하고, 거기에 필요한 필드를 추가해요. 여기서 공유되는 필드는 recipe입니다.
# recipe_flow.py
import json
from typing import List, Optional
from pydantic import BaseModel, Field
from crewai.flow.flow import Flow, start, router, listen
from litellm import acompletion
from ag_ui_crewai.sdk import copilotkit_stream, CopilotKitState
class Ingredient(BaseModel):
name: str
amount: str
class Recipe(BaseModel):
title: str
ingredients: List[Ingredient] = Field(default_factory=list)
instructions: List[str] = Field(default_factory=list)
class AgentState(CopilotKitState):
recipe: Optional[Recipe] = None
2단계: 에이전트가 상태 읽고 쓰기¶
에이전트는 현재 상태를 시스템 프롬프트에 덤프해서 읽어오고, self.state.recipe에 할당해서 다시 씁니다. generate_recipe 도구는 모델이 갱신된 레시피를 구조화된 인자로 반환할 수 있게 해줘요.
GENERATE_RECIPE_TOOL = {
"type": "function",
"function": {
"name": "generate_recipe",
"description": "Generate or modify the recipe.",
"parameters": {
"type": "object",
"properties": {"recipe": {"type": "object"}},
"required": ["recipe"],
},
},
}
class SharedStateFlow(Flow[AgentState]):
@start()
@listen("route_follow_up")
async def start_flow(self):
pass
@router(start_flow)
async def chat(self):
# The current shared state is visible to the model.
system_prompt = f"""You help the user build a recipe.
Current recipe: {self.state.model_dump_json(indent=2)}
Modify it by calling generate_recipe."""
response = await copilotkit_stream(
await acompletion(
model="openai/gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
*self.state.messages,
],
tools=[*self.state.copilotkit.actions, GENERATE_RECIPE_TOOL],
parallel_tool_calls=False,
stream=True,
)
)
message = response.choices[0].message
self.state.messages.append(message)
if message.tool_calls:
call = message.tool_calls[0]
if call.function.name == "generate_recipe":
args = json.loads(call.function.arguments)
self.state.recipe = Recipe(**args["recipe"]) # write to shared state
self.state.messages.append({
"role": "tool",
"content": "Recipe updated.",
"tool_call_id": call.id,
})
return "route_follow_up"
return "route_end"
@listen("route_end")
async def end(self):
pass
이걸 단방향이 아니라 공유로 만들어 주는 건 두 가지예요. self.state를 프롬프트에 덤프한다는 건 에이전트가 항상 최신 레시피(UI에서 사용자가 수정한 내용 포함)에서 작업한다는 뜻이고, self.state.recipe에 할당하면 그 새 값이 스텝이 끝날 때 연결된 클라이언트로 보내지는 상태 스냅샷에 담기게 됩니다. 긴 스텝이 진행되는 동안에도 갱신을 보내려면 copilotkit_emit_state로 명시적으로 내보내면 돼요(Agentic Generative UI 참고).
3단계: Flow를 AG-UI로 서빙하기¶
FastAPI 앱에서 add_crewai_flow_fastapi_endpoint로 Flow를 노출하고, CopilotKit 런타임에 등록합니다. 전체 서버·런타임 설정은 Frontend Overview에서 확인할 수 있어요.
# server.py
from fastapi import FastAPI
from ag_ui_crewai.endpoint import add_crewai_flow_fastapi_endpoint
from recipe_flow import SharedStateFlow
app = FastAPI(title="CrewAI Agent Server")
add_crewai_flow_fastapi_endpoint(
app=app,
flow=SharedStateFlow(),
path="/shared_state",
)
4단계: UI에서 상태 읽고 쓰기¶
useAgent 훅 하나로 두 방향을 모두 다룹니다. 공유 상태는 agent.state에서 읽고, agent.setState(...)로 다시 씁니다. OnStateChanged를 구독하면 에이전트가 상태를 갱신할 때마다 컴포넌트가 다시 렌더링돼요.
"use client";
import { useAgent, UseAgentUpdate } from "@copilotkit/react-core/v2";
function RecipeEditor() {
const { agent } = useAgent({
agentId: "shared_state",
updates: [UseAgentUpdate.OnStateChanged],
});
const state = agent?.state as { recipe?: Recipe } | undefined;
const isLoading = agent?.isRunning;
const recipe = state?.recipe;
// setState replaces the whole state object, so spread the current
// state and override only the field you changed. Passing just
// `{ recipe }` would drop messages and other runtime fields.
const updateRecipe = (patch: Partial<Recipe>) =>
agent?.setState({ ...(agent.state ?? {}), recipe: { ...(recipe ?? {}), ...patch } });
return (
<div>
<input
value={recipe?.title ?? ""}
disabled={isLoading}
onChange={(e) => updateRecipe({ title: e.target.value })}
/>
{/* render inputs for ingredients and instructions the same way */}
</div>
);
}
agent.state는 공유 상태를 읽고, agent.setState(...)는 다시 써서 다음 턴에 에이전트가 그 변경을 보게 만들며, agent.isRunning은 에이전트가 현재 작업 중인지 알려줘요.
setState는 상태 객체 전체를 교체하지 병합하지 않습니다. 항상 현재 상태를 펼친({ ...agent.state, ... }) 다음 바꿀 필드만 덮어써야 해요. 그렇지 않으면 에이전트가 의존하는 대화 기록 같은 런타임 필드들이 사라집니다.
양방향 루프¶
여러 조각을 합치면, 레시피 객체 하나가 양방향으로 동기화돼요.
- 에이전트가 수정하면 UI가 갱신돼요. Flow가
self.state.recipe에 값을 할당하면, 그 새 값이 스텝의 상태 스냅샷에 실려 나가고OnStateChanged가 입력 필드를 다시 렌더링합니다. - 사용자가 수정하면 에이전트가 보게 돼요. UI의 변경이
agent.setState(...)를 호출하고, Flow가self.state를 프롬프트에 덤프하기 때문에 에이전트는 다음 턴에 수정된 레시피에서 작업하게 되죠.
관련 자료¶
- Agentic Generative UI — 에이전트의 상태가 바뀌는 대로 실시간으로 렌더링합니다.
- Predictive State — 에이전트가 작업하는 동안 진행 중인 상태를 UI로 스트리밍합니다.
- Human-in-the-Loop — 실행 중간에 실행을 멈추고 사용자 승인이나 입력을 받습니다.