콘텐츠로 이동

휴먼 인 더 루프 (Human in the Loop)

사용자를 루프 안에 넣기

모든 단계를 사람의 확인 없이 자동으로 처리하면 안 되는 일이 있어요. 휴먼 인 더 루프(Human-in-the-Loop)는 에이전트 실행 중간에 멈춰서 프론트엔드에 인터랙티브 컴포넌트를 띄우고 사용자의 선택을 기다려요. 사용자가 결정을 내리면 에이전트는 그 선택을 가지고 실행을 이어가요.

동작 원리는 프론트엔드가 등록하는 도구(tool) 하나로 설명돼요. 모델이 그 도구를 호출하는 순간, 그 지점에서 실행이 멈추고 사용자가 응답할 때까지 대기해요. 어떤 것도 자동으로 진행되지 않아요 — respond()가 제어권을 돌려주기 전까지 에이전트는 그 자리에 멈춰 있어요.

아래 예시에서는 에이전트가 작업 단계 목록을 제안해요. 사용자는 각 단계를 켜거나 끈 다음 확인하고, 에이전트는 사용자가 승인한 내용 그대로 존중하며 계속 진행해요.

이 패턴은 Flows에서 동작해요. respond() 이후 Flow의 채팅 루프가 다시 진입하는 방식에 의존하죠 — 반환된 값이 도구 결과(tool result)로 돌아오고, 에이전트의 다음 턴이 그 값을 받아서 동작해요.

만들기

1. 프론트엔드 액션을 모델의 도구에 바인딩하기

Flow에서 프론트엔드가 등록한 액션을 모델의 도구 목록에 *self.state.copilotkit.actions로 추가해요. 이 액션들이 바로 프론트엔드가 useHumanInTheLoop로 등록한 도구예요. 이렇게 바인딩하면 모델이 그 도구를 호출할 수 있고, 그 도구 호출 지점에서 사용자가 응답할 때까지 실행이 멈춰요.

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

class HumanInTheLoopFlow(Flow[CopilotKitState]):
    @start()
    @listen("route_follow_up")
    async def start_flow(self):
        pass

    @router(start_flow)
    async def chat(self):
        system_prompt = (
            "You perform tasks for the user. When asked to do a task, call the "
            "tool the frontend provides so the user can approve or adjust the steps "
            "before you continue."
        )

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

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

    @listen("route_end")
    async def end(self):
        pass

CopilotKitState는 프론트엔드가 등록한 액션을 self.state.copilotkit.actions에 담고 있어요. 모델이 그중 하나를 호출하면 그 지점에서 실행이 멈추죠. 사용자가 응답하면 반환된 값이 도구 결과로 self.state.messages에 들어오고, Flow가 chat으로 다시 돌아가서 모델이 그 결정을 바탕으로 동작해요.

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

다른 모든 에이전트와 마찬가지로, FastAPI 서버에서 add_crewai_flow_fastapi_endpoint로 Flow를 노출해요. 전체 서버·런타임·프로바이더 설정은 Frontend Overview 문서를 참고하세요.

# server.py
from fastapi import FastAPI
from ag_ui_crewai.endpoint import add_crewai_flow_fastapi_endpoint
from my_agents.human_in_the_loop_flow import HumanInTheLoopFlow

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

add_crewai_flow_fastapi_endpoint(
    app=app,
    flow=HumanInTheLoopFlow(),
    path="/human_in_the_loop",
)

3. 프론트엔드에 인터랙티브 도구 등록하기

useHumanInTheLoop는 에이전트가 멈추는 데 쓰는 도구를 등록하고, 인터랙티브 UI를 그릴 render 함수를 반환해요. 에이전트가 그 도구를 호출하면 컴포넌트가 나타나고, 사용자가 행동하면 respond()를 호출해서 에이전트를 다시 진행시켜요.

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

useHumanInTheLoop({
  agentId: "human_in_the_loop",
  name: "generate_task_steps",
  parameters: z.object({
    steps: z.array(
      z.object({
        description: z.string(),
        status: z.enum(["enabled", "disabled", "executing"]),
      })
    ),
  }),
  render: ({ args, respond, status }) => (
    <StepReview
      steps={args.steps ?? []}
      // `status === "executing"` means the agent is waiting for the user
      waiting={status === "executing"}
      onConfirm={(chosen) => respond?.(chosen)}
    />
  ),
});

render 함수가 받는 값 세 가지를 짚어볼게요.

  • args — 모델이 만들어낸 도구 인자예요 (여기서는 제안된 steps). 모델이 생성하는 대로 스트리밍돼요.
  • status — 도구 호출의 생명주기예요. "executing"인 동안 에이전트는 멈춰서 사용자를 기다리고 있어요.
  • respond(value) — 사용자의 결정과 함께 에이전트를 재개해요. 에이전트의 다음 턴이 반환된 값을 보고 그에 맞춰 동작해요.

4. 사용자가 결정하고 응답하기

컴포넌트는 args.steps를 읽어서 사용자가 각 단계를 토글하게 하고, 최종 선택과 함께 respond()를 호출해요. 그 값이 에이전트가 이어서 사용하는 내용이에요.

function StepReview({ steps, waiting, onConfirm }) {
  const [choices, setChoices] = useState(steps);

  const toggle = (i) =>
    setChoices((prev) =>
      prev.map((s, idx) =>
        idx === i
          ? { ...s, status: s.status === "enabled" ? "disabled" : "enabled" }
          : s
      )
    );

  return (
    <div>
      {choices.map((step, i) => (
        <label key={i}>
          <input
            type="checkbox"
            checked={step.status === "enabled"}
            onChange={() => toggle(i)}
            disabled={!waiting}
          />
          {step.description}
        </label>
      ))}
      <button disabled={!waiting} onClick={() => onConfirm(choices)}>
        Confirm
      </button>
    </div>
  );
}

사용자가 Confirm을 누르면 respond()가 실행되고, 실행이 재개되면서 Flow의 chat 단계가 사용자의 선택을 메시지 히스토리에 담아 다시 돌아가요.

관련 문서

  • Frontend Actions — 에이전트가 브라우저에서 실행되는 함수를 호출하게 하기
  • Shared State — 에이전트 상태와 앱 UI를 양방향으로 동기화하기
  • Agentic Generative UI — 실시간 에이전트 상태를 커스텀 컴포넌트로 렌더링하기