초안 에이전틱 디자인 패턴 - 휴먼 인 더 루프

ailangchainlanggraphhitltypescriptvercel
By sko X opus 4.19/21/20258 min read

도움이 필요할 때 인간에게 요청할 수 있는 지능형 에이전트를 구축하세요. 이 가이드는 TypeScript에서 LangGraph와 LangChain을 사용하여 Vercel의 서버리스 플랫폼에서 휴먼 인 더 루프(HITL) 패턴을 구현하는 방법을 보여주며, 에이전트가 중요한 결정을 위해 인간과 원활하게 협업할 수 있도록 합니다.

멘탈 모델: 항공 교통 관제 패턴

HITL을 바쁜 공항의 항공 교통 관제로 생각해보세요. AI 에이전트는 자동 조종 시스템과 같습니다 - 수천 개의 작업을 관리하면서 일상적인 비행을 효율적으로 처리합니다. 하지만 폭풍이 오거나, 비상 상황이 발생하거나, 복잡한 상황이 나타나면 인간 관제사가 개입합니다. 시스템은 멈추지 않고 대신 모든 컨텍스트를 유지하면서 제어를 원활하게 전환합니다. 서버리스 환경에서는 이것이 더욱 중요해집니다 - Vercel의 실행 제한과 같은 플랫폼 제약 내에서 워크플로우를 일시 중지하고, 상태를 저장하고, 인간에게 알리고, 실행을 재개해야 합니다.

기본 예제: 승인 기반 고객 지원 에이전트

1. HITL 종속성 설치

npm install @langchain/langgraph @langchain/core @langchain/google-genai
npm install @upstash/redis @upstash/qstash uuid
npm install zod es-toolkit

워크플로우 오케스트레이션을 위한 LangGraph, 상태 지속성 및 큐 관리를 위한 Upstash, 유틸리티 함수를 위한 es-toolkit을 추가합니다.

2. 인터럽트 기능이 있는 기본 HITL 워크플로우 생성

// lib/hitl-workflow.ts
import { MemorySaver, Annotation, interrupt, Command, StateGraph } from "@langchain/langgraph";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { HumanMessage, AIMessage } from "@langchain/core/messages";
import { Redis } from "@upstash/redis";
import { debounce } from "es-toolkit";

const redis = Redis.fromEnv();

// 워크플로우 상태 정의
const StateAnnotation = Annotation.Root({
  messages: Annotation<Array<HumanMessage | AIMessage>>({
    reducer: (curr, prev) => [...prev, ...curr],
    default: () => []
  }),
  customerQuery: Annotation<string>(),
  agentResponse: Annotation<string>(),
  humanFeedback: Annotation<string>().optional(),
  approved: Annotation<boolean>().optional()
});

// AI 처리 노드
async function processQuery(state: typeof StateAnnotation.State) {
  const model = new ChatGoogleGenerativeAI({
    modelName: "gemini-2.5-flash",
    temperature: 0.3
  });

  const response = await model.invoke(state.messages);

  return {
    agentResponse: response.content as string,
    messages: [response]
  };
}

// 인터럽트가 있는 인간 승인 노드
async function humanApproval(state: typeof StateAnnotation.State): Promise<Command> {
  // 민감한 작업이 포함되어 있는지 확인
  const sensitiveKeywords = ["refund", "cancel", "delete", "payment"];
  const needsApproval = sensitiveKeywords.some(keyword =>
    state.agentResponse.toLowerCase().includes(keyword)
  );

  if (!needsApproval) {
    return new Command({
      goto: "send_response",
      update: { approved: true }
    });
  }

  // 인간 검토를 위해 워크플로우 중단
  const decision = await interrupt({
    question: "이 응답을 승인하시겠습니까?",
    agentResponse: state.agentResponse,
    customerQuery: state.customerQuery,
    requiresApproval: true
  });

  if (decision.approved) {
    return new Command({
      goto: "send_response",
      update: {
        approved: true,
        humanFeedback: decision.feedback
      }
    });
  }

  return new Command({
    goto: "revise_response",
    update: {
      approved: false,
      humanFeedback: decision.feedback
    }
  });
}

// 워크플로우 생성 및 컴파일
export function createHITLWorkflow() {
  const workflow = new StateGraph(StateAnnotation)
    .addNode("process_query", processQuery)
    .addNode("human_approval", humanApproval)
    .addNode("send_response", sendResponse)
    .addNode("revise_response", reviseResponse)
    .addEdge("process_query", "human_approval")
    .addConditionalEdges("human_approval", (state) => {
      return state.approved ? "send_response" : "revise_response";
    })
    .addEdge("revise_response", "human_approval");

  const checkpointer = new MemorySaver();
  return workflow.compile({ checkpointer });
}

민감한 작업을 포함하는 AI 응답이 인간 검토를 트리거하는 워크플로우를 생성하며, 비동기 승인을 위한 상태 지속성을 갖습니다.

3. HITL을 위한 서버리스 API 라우트

// app/api/hitl/chat/route.ts
import { NextRequest } from "next/server";
import { createHITLWorkflow } from "@/lib/hitl-workflow";
import { QStash } from "@upstash/qstash";
import { v4 as uuidv4 } from "uuid";

export const maxDuration = 10; // Hobby 플랜 제한

const qstash = new QStash({ token: process.env.QSTASH_TOKEN! });

export async function POST(req: NextRequest) {
  const { message, sessionId = uuidv4() } = await req.json();

  const workflow = createHITLWorkflow();
  const config = {
    configurable: {
      thread_id: sessionId,
      checkpoint_ns: "hitl"
    }
  };

  // 워크플로우 실행 시작
  const initialState = {
    customerQuery: message,
    messages: [{ role: "human", content: message }]
  };

  // 워크플로우 실행을 큐에 추가
  await qstash.publishJSON({
    url: `${process.env.VERCEL_URL}/api/hitl/process`,
    body: {
      sessionId,
      state: initialState,
      config
    },
    retries: 3
  });

  return Response.json({
    sessionId,
    status: "processing",
    message: "요청을 처리 중입니다"
  });
}

// app/api/hitl/resume/route.ts
export async function POST(req: NextRequest) {
  const { sessionId, decision } = await req.json();

  const workflow = createHITLWorkflow();
  const config = {
    configurable: {
      thread_id: sessionId,
      checkpoint_ns: "hitl"
    }
  };

  // 인터럽트에서 재개
  const result = await workflow.invoke(
    new Command({ resume: decision }),
    config
  );

  return Response.json(result);
}

Vercel의 실행 제한을 처리하기 위한 큐 기반 처리를 구현하며, 워크플로우 시작 및 재개를 위한 별도의 엔드포인트를 제공합니다.

4. SSE를 사용한 실시간 상태 업데이트

// app/api/hitl/status/[sessionId]/route.ts
import { NextRequest } from "next/server";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

export async function GET(
  req: NextRequest,
  { params }: { params: { sessionId: string }}
) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      const sendUpdate = async () => {
        const state = await redis.hgetall(`session:${params.sessionId}`);

        if (state?.interrupt) {
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify({
              type: "approval_needed",
              data: state.interrupt
            })}\n\n`)
          );
        }

        if (state?.status === "completed") {
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify({
              type: "completed",
              response: state.response
            })}\n\n`)
          );
          controller.close();
        } else {
          setTimeout(sendUpdate, 1000);
        }
      };

      await sendUpdate();
    }
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Connection": "keep-alive"
    }
  });
}

Server-Sent Events를 사용하여 워크플로우 상태와 승인 요청에 대한 실시간 업데이트를 클라이언트에 제공합니다.

5. 인간 승인을 위한 React UI

// components/HITLInterface.tsx
"use client";

import { useState, useEffect } from "react";
import { useMutation } from "@tanstack/react-query";

export default function HITLInterface({ sessionId }: { sessionId: string }) {
  const [approvalRequest, setApprovalRequest] = useState(null);
  const [status, setStatus] = useState("waiting");

  // 승인 요청 리스닝
  useEffect(() => {
    const eventSource = new EventSource(`/api/hitl/status/${sessionId}`);

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data);

      if (data.type === "approval_needed") {
        setApprovalRequest(data.data);
        setStatus("pending_approval");
      } else if (data.type === "completed") {
        setStatus("completed");
      }
    };

    return () => eventSource.close();
  }, [sessionId]);

  const approveMutation = useMutation({
    mutationFn: async (decision: any) => {
      const response = await fetch("/api/hitl/resume", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ sessionId, decision })
      });
      return response.json();
    }
  });

  if (status === "pending_approval" && approvalRequest) {
    return (
      <div className="card bg-base-100 shadow-xl">
        <div className="card-body">
          <h2 className="card-title">인간 승인이 필요합니다</h2>

          <div className="divider">고객 문의</div>
          <p className="text-sm">{approvalRequest.customerQuery}</p>

          <div className="divider">에이전트 응답</div>
          <div className="bg-base-200 p-4 rounded">
            <p>{approvalRequest.agentResponse}</p>
          </div>

          <div className="form-control">
            <label className="label">
              <span className="label-text">피드백 (선택사항)</span>
            </label>
            <textarea
              className="textarea textarea-bordered"
              placeholder="수정을 위한 피드백 추가..."
              id="feedback"
            />
          </div>

          <div className="card-actions justify-end mt-4">
            <button
              className="btn btn-error"
              onClick={() => {
                const feedback = document.getElementById("feedback").value;
                approveMutation.mutate({
                  approved: false,
                  feedback
                });
              }}
            >
              거부 및 수정
            </button>
            <button
              className="btn btn-success"
              onClick={() => approveMutation.mutate({ approved: true })}
            >
              승인
            </button>
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="alert alert-info">
      <span>상태: {status}</span>
    </div>
  );
}

인간 검토자가 에이전트 응답을 승인하거나 거부할 수 있는 직관적인 인터페이스를 제공하는 React 컴포넌트입니다.

고급 예제: 에스컬레이션 패턴이 있는 멀티 에이전트 시스템

1. 폴백이 있는 복잡한 HITL 아키텍처

// lib/advanced-hitl-system.ts
import { StateGraph, Annotation, Command, interrupt } from "@langchain/langgraph";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { Redis } from "@upstash/redis";
import { QStash } from "@upstash/qstash";
import { groupBy, sortBy, pick } from "es-toolkit";
import { z } from "zod";

const redis = Redis.fromEnv();
const qstash = new QStash({ token: process.env.QSTASH_TOKEN! });

// 신뢰도 및 에스컬레이션 추적이 포함된 향상된 상태
const EnhancedStateAnnotation = Annotation.Root({
  query: Annotation<string>(),
  context: Annotation<Record<string, any>>(),
  agentResponse: Annotation<string>(),
  confidence: Annotation<number>(),
  escalationLevel: Annotation<number>({ default: () => 0 }),
  reviewHistory: Annotation<Array<{
    level: number;
    reviewer: string;
    decision: string;
    feedback?: string;
    timestamp: Date;
  }>>({ default: () => [] }),
  finalResponse: Annotation<string>().optional()
});

// 신뢰도 평가 노드
async function assessConfidence(state: typeof EnhancedStateAnnotation.State) {
  const model = new ChatGoogleGenerativeAI({
    modelName: "gemini-2.5-pro",
    temperature: 0
  });

  const prompt = `
    이 응답에 대한 신뢰도 수준을 평가하세요.
    쿼리: ${state.query}
    응답: ${state.agentResponse}

    0-1의 신뢰도 점수를 제공하고 위험을 식별하세요.
    형식: { "confidence": 0.X, "risks": ["risk1", "risk2"] }
  `;

  const result = await model.invoke(prompt);
  const assessment = JSON.parse(result.content as string);

  return {
    confidence: assessment.confidence,
    context: { ...state.context, risks: assessment.risks }
  };
}

// 계층적 에스컬레이션 시스템
class EscalationManager {
  private levels = [
    { threshold: 0.8, handler: "auto_approve", timeout: 0 },
    { threshold: 0.6, handler: "peer_review", timeout: 300000 }, // 5분
    { threshold: 0.4, handler: "supervisor_review", timeout: 600000 }, // 10분
    { threshold: 0.0, handler: "expert_panel", timeout: 1800000 } // 30분
  ];

  async determineEscalation(confidence: number, currentLevel: number) {
    const appropriateLevel = sortBy(
      this.levels.filter(l => confidence <= l.threshold),
      l => -l.threshold
    )[0];

    if (!appropriateLevel) return "auto_approve";

    // 추가 에스컬레이션이 필요한지 확인
    const levelIndex = this.levels.findIndex(
      l => l.handler === appropriateLevel.handler
    );

    if (levelIndex > currentLevel) {
      await this.notifyReviewers(appropriateLevel.handler);
      return appropriateLevel.handler;
    }

    return appropriateLevel.handler;
  }

  private async notifyReviewers(level: string) {
    // 긴급도에 따라 다른 채널로 알림 전송
    const notifications = {
      peer_review: { channel: "slack", urgency: "normal" },
      supervisor_review: { channel: "slack", urgency: "high" },
      expert_panel: { channel: "pagerduty", urgency: "critical" }
    };

    const config = notifications[level];
    if (config) {
      await qstash.publishJSON({
        url: `${process.env.NOTIFICATION_WEBHOOK}`,
        body: {
          level,
          channel: config.channel,
          urgency: config.urgency,
          timestamp: new Date()
        }
      });
    }
  }
}

// 타임아웃 및 폴백이 있는 인간 검토
async function humanReviewWithFallback(
  state: typeof EnhancedStateAnnotation.State
): Promise<Command> {
  const escalationManager = new EscalationManager();
  const handler = await escalationManager.determineEscalation(
    state.confidence,
    state.escalationLevel
  );

  if (handler === "auto_approve") {
    return new Command({
      goto: "finalize_response",
      update: { finalResponse: state.agentResponse }
    });
  }

  // 인간 응답을 위한 타임아웃 설정
  const timeoutMs = 300000; // 기본 5분
  const startTime = Date.now();

  try {
    const decision = await Promise.race([
      interrupt({
        level: handler,
        query: state.query,
        response: state.agentResponse,
        confidence: state.confidence,
        risks: state.context.risks
      }),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error("Timeout")), timeoutMs)
      )
    ]);

    // 검토 기록 저장
    const reviewRecord = {
      level: state.escalationLevel,
      reviewer: decision.reviewer || handler,
      decision: decision.approved ? "approved" : "rejected",
      feedback: decision.feedback,
      timestamp: new Date()
    };

    if (decision.approved) {
      return new Command({
        goto: "finalize_response",
        update: {
          finalResponse: decision.editedResponse || state.agentResponse,
          reviewHistory: [...state.reviewHistory, reviewRecord]
        }
      });
    } else {
      return new Command({
        goto: "revise_with_feedback",
        update: {
          reviewHistory: [...state.reviewHistory, reviewRecord],
          escalationLevel: state.escalationLevel + 1
        }
      });
    }
  } catch (error) {
    // 타임아웃 발생 - 폴백 구현
    console.warn(`레벨 ${handler}에서 검토 타임아웃`);

    if (state.escalationLevel < 2) {
      // 다음 레벨로 에스컬레이션
      return new Command({
        goto: "human_review",
        update: { escalationLevel: state.escalationLevel + 1 }
      });
    } else {
      // 최종 폴백 - 안전한 기본값 사용
      return new Command({
        goto: "apply_safe_default",
        update: {
          finalResponse: "정확한 응답을 제공하기 위해 더 많은 시간이 필요합니다. 전문가가 곧 연락드릴 것입니다."
        }
      });
    }
  }
}

// 고급 워크플로우 생성
export function createAdvancedHITLWorkflow() {
  const workflow = new StateGraph(EnhancedStateAnnotation)
    .addNode("generate_response", generateResponse)
    .addNode("assess_confidence", assessConfidence)
    .addNode("human_review", humanReviewWithFallback)
    .addNode("revise_with_feedback", reviseWithFeedback)
    .addNode("apply_safe_default", applySafeDefault)
    .addNode("finalize_response", finalizeResponse)
    .addEdge("generate_response", "assess_confidence")
    .addEdge("assess_confidence", "human_review")
    .addEdge("revise_with_feedback", "assess_confidence")
    .addConditionalEdges("human_review", (state) => {
      if (state.finalResponse) return "finalize_response";
      if (state.escalationLevel > 2) return "apply_safe_default";
      return "human_review";
    });

  return workflow.compile({
    checkpointer: new MemorySaver()
  });
}

신뢰도 점수를 기반으로 타임아웃, 폴백 및 다단계 검토가 포함된 정교한 에스컬레이션 계층을 구현합니다.

2. 확장을 위한 분산 HITL 처리

// lib/distributed-hitl.ts
import { QStash } from "@upstash/qstash";
import { Redis } from "@upstash/redis";
import { chunk, partition, map } from "es-toolkit";

const redis = Redis.fromEnv();
const qstash = new QStash({ token: process.env.QSTASH_TOKEN! });

export class DistributedHITLProcessor {
  private maxBatchSize = 10;
  private maxConcurrent = 5;

  async processLargeWorkload(tasks: Array<any>, workflowId: string) {
    // 우선순위별로 작업 분할
    const [highPriority, normalPriority] = partition(
      tasks,
      t => t.priority === "high"
    );

    // 높은 우선순위 즉시 처리
    await this.processBatch(highPriority, workflowId, "high");

    // 일반 우선순위를 청크로 큐에 추가
    const chunks = chunk(normalPriority, this.maxBatchSize);

    for (let i = 0; i < chunks.length; i++) {
      await qstash.publishJSON({
        url: `${process.env.VERCEL_URL}/api/hitl/batch-process`,
        body: {
          workflowId,
          batchId: i,
          tasks: chunks[i]
        },
        delay: i * 5 // 5초씩 지연
      });
    }

    // 워크플로우 메타데이터 저장
    await redis.hset(`workflow:${workflowId}`, {
      totalTasks: tasks.length,
      totalBatches: chunks.length,
      startTime: Date.now(),
      status: "processing"
    });
  }

  private async processBatch(
    tasks: Array<any>,
    workflowId: string,
    priority: string
  ) {
    const results = await Promise.allSettled(
      tasks.map(task => this.processTask(task, priority))
    );

    // 결과 저장
    await redis.hset(`workflow:${workflowId}:results`, {
      [`batch_${Date.now()}`]: JSON.stringify(results)
    });

    // 진행 상황 업데이트
    await redis.hincrby(`workflow:${workflowId}`, "completed", tasks.length);
  }

  private async processTask(task: any, priority: string) {
    // 우선순위에 따른 적절한 HITL로 작업 처리 구현
    if (priority === "high") {
      // 직접 인간 검토
      return await this.requestImmediateReview(task);
    } else {
      // 선택적 에스컬레이션이 있는 AI
      return await this.processWithOptionalReview(task);
    }
  }
}

// 배치 처리를 위한 API 라우트
// app/api/hitl/batch-process/route.ts
export const maxDuration = 300; // Fluid Compute가 있는 Pro 플랜

export async function POST(req: NextRequest) {
  const { workflowId, batchId, tasks } = await req.json();

  const processor = new DistributedHITLProcessor();

  try {
    await processor.processBatch(tasks, workflowId, "normal");

    // 모든 배치가 완료되었는지 확인
    const workflow = await redis.hgetall(`workflow:${workflowId}`);
    const completed = parseInt(workflow.completed || "0");
    const total = parseInt(workflow.totalTasks || "0");

    if (completed >= total) {
      await redis.hset(`workflow:${workflowId}`, {
        status: "completed",
        endTime: Date.now()
      });

      // 완료 알림
      await notifyCompletion(workflowId);
    }

    return Response.json({ success: true, batchId });
  } catch (error) {
    console.error(`배치 ${batchId} 실패:`, error);

    // 재시도 로직
    await qstash.publishJSON({
      url: `${process.env.VERCEL_URL}/api/hitl/batch-process`,
      body: { workflowId, batchId, tasks },
      delay: 60 // 1분 후 재시도
    });

    return Response.json({ error: "처리 실패, 재시도 중" });
  }
}

우선순위 큐잉 및 배치 관리를 통해 여러 서버리스 함수에 걸쳐 HITL 처리를 확장합니다.

3. HITL 관리를 위한 UI 대시보드

// components/HITLDashboard.tsx
"use client";

import { useQuery, useMutation } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import { groupBy, sortBy } from "es-toolkit";

interface ReviewTask {
  id: string;
  query: string;
  response: string;
  confidence: number;
  level: string;
  timestamp: Date;
  deadline?: Date;
}

export default function HITLDashboard() {
  const [selectedTask, setSelectedTask] = useState<ReviewTask | null>(null);
  const [filter, setFilter] = useState("all");

  // 보류 중인 검토 가져오기
  const { data: tasks = [], refetch } = useQuery({
    queryKey: ["pending-reviews"],
    queryFn: async () => {
      const response = await fetch("/api/hitl/pending");
      return response.json();
    },
    refetchInterval: 5000 // 5초마다 폴링
  });

  // 긴급도별로 작업 그룹화
  const groupedTasks = groupBy(
    sortBy(tasks, t => t.confidence),
    t => {
      if (t.confidence < 0.4) return "critical";
      if (t.confidence < 0.6) return "high";
      if (t.confidence < 0.8) return "medium";
      return "low";
    }
  );

  const reviewMutation = useMutation({
    mutationFn: async (decision: any) => {
      const response = await fetch("/api/hitl/review", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(decision)
      });
      return response.json();
    },
    onSuccess: () => {
      setSelectedTask(null);
      refetch();
    }
  });

  return (
    <div className="container mx-auto p-4">
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
        {/* 작업 목록 */}
        <div className="lg:col-span-1">
          <div className="card bg-base-100 shadow">
            <div className="card-body">
              <h2 className="card-title">보류 중인 검토</h2>

              {/* 필터 탭 */}
              <div className="tabs tabs-boxed mb-4">
                <button
                  className={`tab ${filter === "all" ? "tab-active" : ""}`}
                  onClick={() => setFilter("all")}
                >
                  전체 ({tasks.length})
                </button>
                <button
                  className={`tab ${filter === "critical" ? "tab-active" : ""}`}
                  onClick={() => setFilter("critical")}
                >
                  긴급 ({groupedTasks.critical?.length || 0})
                </button>
                <button
                  className={`tab ${filter === "high" ? "tab-active" : ""}`}
                  onClick={() => setFilter("high")}
                >
                  높음 ({groupedTasks.high?.length || 0})
                </button>
              </div>

              {/* 작업 항목 */}
              <div className="space-y-2">
                {(filter === "all" ? tasks : groupedTasks[filter] || [])
                  .map(task => (
                    <div
                      key={task.id}
                      className={`card card-compact bg-base-200 cursor-pointer hover:bg-base-300 ${
                        selectedTask?.id === task.id ? "ring-2 ring-primary" : ""
                      }`}
                      onClick={() => setSelectedTask(task)}
                    >
                      <div className="card-body">
                        <div className="flex justify-between items-start">
                          <div className="flex-1">
                            <p className="text-sm truncate">{task.query}</p>
                            <div className="flex gap-2 mt-1">
                              <span className={`badge badge-sm ${
                                task.confidence < 0.4 ? "badge-error" :
                                task.confidence < 0.6 ? "badge-warning" :
                                "badge-success"
                              }`}>
                                {(task.confidence * 100).toFixed(0)}%
                              </span>
                              <span className="badge badge-sm badge-outline">
                                {task.level}
                              </span>
                            </div>
                          </div>
                          {task.deadline && (
                            <span className="text-xs text-warning">
                              {new Date(task.deadline).toLocaleTimeString()}
                            </span>
                          )}
                        </div>
                      </div>
                    </div>
                  ))}
              </div>
            </div>
          </div>
        </div>

        {/* 검토 패널 */}
        <div className="lg:col-span-2">
          {selectedTask ? (
            <div className="card bg-base-100 shadow">
              <div className="card-body">
                <h2 className="card-title">작업 검토</h2>

                {/* 작업 세부 정보 */}
                <div className="space-y-4">
                  <div>
                    <label className="label">
                      <span className="label-text font-semibold">고객 문의</span>
                    </label>
                    <div className="bg-base-200 p-3 rounded">
                      {selectedTask.query}
                    </div>
                  </div>

                  <div>
                    <label className="label">
                      <span className="label-text font-semibold">AI 응답</span>
                      <span className="label-text-alt">
                        신뢰도: {(selectedTask.confidence * 100).toFixed(1)}%
                      </span>
                    </label>
                    <div className="bg-base-200 p-3 rounded">
                      <textarea
                        className="textarea w-full h-32"
                        defaultValue={selectedTask.response}
                        id="edited-response"
                      />
                    </div>
                  </div>

                  <div>
                    <label className="label">
                      <span className="label-text font-semibold">검토 피드백</span>
                    </label>
                    <textarea
                      className="textarea textarea-bordered w-full"
                      placeholder="에이전트를 위한 피드백 제공..."
                      id="feedback"
                    />
                  </div>

                  {/* 빠른 작업 */}
                  <div className="flex gap-2">
                    <button className="btn btn-sm btn-outline">
                      더 많은 컨텍스트 요청
                    </button>
                    <button className="btn btn-sm btn-outline">
                      기록 보기
                    </button>
                    <button className="btn btn-sm btn-outline">
                      에스컬레이션
                    </button>
                  </div>
                </div>

                {/* 작업 버튼 */}
                <div className="card-actions justify-end mt-6">
                  <button
                    className="btn btn-error"
                    onClick={() => {
                      reviewMutation.mutate({
                        taskId: selectedTask.id,
                        approved: false,
                        feedback: document.getElementById("feedback").value
                      });
                    }}
                  >
                    거부
                  </button>
                  <button
                    className="btn btn-warning"
                    onClick={() => {
                      reviewMutation.mutate({
                        taskId: selectedTask.id,
                        approved: true,
                        editedResponse: document.getElementById("edited-response").value,
                        feedback: document.getElementById("feedback").value
                      });
                    }}
                  >
                    수정 후 승인
                  </button>
                  <button
                    className="btn btn-success"
                    onClick={() => {
                      reviewMutation.mutate({
                        taskId: selectedTask.id,
                        approved: true
                      });
                    }}
                  >
                    승인
                  </button>
                </div>
              </div>
            </div>
          ) : (
            <div className="card bg-base-100 shadow">
              <div className="card-body">
                <div className="text-center py-8">
                  <p className="text-base-content/60">
                    검토할 작업을 선택하세요
                  </p>
                </div>
              </div>
            </div>
          )}
        </div>
      </div>

      {/* 통계 */}
      <div className="stats shadow mt-6">
        <div className="stat">
          <div className="stat-title">총 보류 중</div>
          <div className="stat-value">{tasks.length}</div>
        </div>
        <div className="stat">
          <div className="stat-title">긴급</div>
          <div className="stat-value text-error">
            {groupedTasks.critical?.length || 0}
          </div>
        </div>
        <div className="stat">
          <div className="stat-title">평균 응답 시간</div>
          <div className="stat-value">2.3분</div>
        </div>
        <div className="stat">
          <div className="stat-title">승인율</div>
          <div className="stat-value">87%</div>
        </div>
      </div>
    </div>
  );
}

우선순위 필터링, 인라인 편집 및 성능 메트릭을 갖춘 HITL 작업 관리를 위한 포괄적인 대시보드입니다.

결론

휴먼 인 더 루프 패턴은 자율 에이전트를 AI 효율성과 인간 판단을 모두 활용하는 협업 시스템으로 변환합니다. 서버리스 환경에서 성공적인 HITL 구현의 핵심은 상태 지속성 관리, 비동기 워크플로우 처리 및 인간 검토자를 위한 직관적인 인터페이스 제공에 있습니다. LangGraph의 인터럽트 기능을 Vercel의 서버리스 인프라 및 최신 UI 패턴과 결합하여 강력하고 신뢰할 수 있는 시스템을 만듭니다.

여기에 표시된 패턴(기본 승인 워크플로우부터 정교한 다단계 에스컬레이션 시스템까지)은 HITL이 제한이 아니라 향상임을 보여줍니다. 이는 순수한 자동화가 무책임할 수 있는 중요한 도메인에서 AI 에이전트를 배포할 수 있게 하며, 서버리스 아키텍처의 확장성과 비용 효율성을 유지합니다. AI 기능이 계속 발전함에 따라 HITL은 시스템이 윤리적 경계와 조직 정책 내에서 작동하도록 보장하는 데 필수적으로 남아 있습니다.