초안 에이전틱 디자인 패턴 - 계획

ai에이전트계획langchainlanggraphtypescript
By sko X opus 4.19/20/202512 min read

AI 에이전트가 복잡한 작업을 분해하고, 다단계 전략을 생성하며, TypeScript, LangChain, LangGraph, Google Gemini 모델을 사용하여 Vercel의 서버리스 플랫폼에서 정교한 워크플로우를 실행할 수 있도록 하는 계획 패턴 구현 방법을 알아보세요.

사고 모델: 건설 현장 관리자

계획 에이전트를 건설 프로젝트를 감독하는 건설 현장 관리자로 생각해보세요. 관리자는 직접 벽돌을 쌓거나 배관을 설치하지 않습니다. 대신 포괄적인 계획을 세우고, 전문 작업자에게 업무를 위임하며, 진행 상황을 모니터링하고, 문제가 발생했을 때 전략을 조정합니다. 마찬가지로 계획 에이전트는 복잡한 문제를 관리 가능한 단계로 분해하고, 전문 도구나 하위 에이전트를 통해 실행을 조율하며, 중간 결과에 따라 계획을 적응시킵니다. 계획과 실행의 이러한 분리는 단일 패스 접근법으로는 처리할 수 없는 복잡성을 다룰 수 있게 해줍니다.

기본 예제: 계획-실행 에이전트

1. 에이전트 상태 타입 정의

// lib/planning/types.ts
import { z } from 'zod';
import { Annotation } from '@langchain/langgraph';
import { BaseMessage } from '@langchain/core/messages';

// 단일 계획 단계에 대한 스키마
export const PlanStepSchema = z.object({
  step: z.number(),
  action: z.string(),
  reasoning: z.string(),
  dependencies: z.array(z.number()).default([]),
  status: z.enum(['pending', 'in_progress', 'completed', 'failed']).default('pending'),
  result: z.string().optional(),
});

export type PlanStep = z.infer<typeof PlanStepSchema>;

// LangGraph 어노테이션을 사용한 상태 정의
export const PlanningAgentState = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: (current, update) => current.concat(update),
    default: () => [],
  }),
  plan: Annotation<PlanStep[]>({
    reducer: (current, update) => update,
    default: () => [],
  }),
  currentStep: Annotation<number>({
    default: () => 0
  }),
  executionResults: Annotation<Record<number, any>>({
    reducer: (current, update) => ({ ...current, ...update }),
    default: () => ({}),
  }),
  finalOutput: Annotation<string>({
    default: () => ''
  }),
});

export type AgentState = typeof PlanningAgentState.State;

상태 관리를 위해 LangGraph의 Annotation 시스템을 사용하여 계획 단계, 실행 추적, 메시지 히스토리가 포함된 강타입 상태 구조를 정의합니다.

2. 계획 노드 생성

// lib/planning/nodes/planner.ts
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
import { map, filter, sortBy } from 'es-toolkit';
import { AgentState, PlanStep } from '../types';

const plannerModel = new ChatGoogleGenerativeAI({
  model: 'gemini-2.5-pro',
  temperature: 0.1,
  maxOutputTokens: 8192,
}).withStructuredOutput({
  name: 'plan',
  description: '작업 실행을 위한 구조화된 계획',
  parameters: {
    type: 'object',
    properties: {
      steps: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            step: { type: 'number' },
            action: { type: 'string' },
            reasoning: { type: 'string' },
            dependencies: {
              type: 'array',
              items: { type: 'number' }
            },
          },
          required: ['step', 'action', 'reasoning'],
        },
      },
    },
    required: ['steps'],
  },
});

export async function plannerNode(state: AgentState): Promise<Partial<AgentState>> {
  const userMessage = state.messages[state.messages.length - 1];

  const systemPrompt = `당신은 계획 에이전트입니다. 사용자의 요청을 명확하고 실행 가능한 단계로 분해하세요.
  각 단계는:
  1. 수행할 명확한 액션을 가져야 함
  2. 이 단계가 왜 필요한지에 대한 이유를 포함해야 함
  3. 이전 단계에 대한 종속성을 나열해야 함 (단계 번호 사용)

  종속성이 존중되는 논리적 순서로 단계가 정렬되도록 하세요.`;

  const response = await plannerModel.invoke([
    new SystemMessage(systemPrompt),
    userMessage,
  ]);

  // es-toolkit을 사용하여 단계 처리 및 검증
  const processedSteps = map(
    response.steps,
    (step: any, index: number) => ({
      ...step,
      step: index + 1,
      status: 'pending' as const,
      dependencies: step.dependencies || [],
    })
  );

  // 올바른 실행 순서를 보장하기 위해 종속성별로 정렬
  const sortedSteps = sortBy(
    processedSteps,
    [(step: PlanStep) => step.dependencies.length, 'step']
  );

  return {
    plan: sortedSteps,
    currentStep: 0,
  };
}

사용자 요청을 분석하고 Gemini 2.5 Pro를 사용하여 고품질 추론을 위한 구조화된 종속성 인식 실행 계획을 생성하는 계획 노드를 생성합니다.

3. 실행 노드 생성

// lib/planning/nodes/executor.ts
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
import { filter, find, every, map as mapArray } from 'es-toolkit';
import { AgentState, PlanStep } from '../types';

const executorModel = new ChatGoogleGenerativeAI({
  model: 'gemini-2.5-flash',  // 실행을 위한 더 빠르고 저렴한 모델
  temperature: 0,
  maxOutputTokens: 2048,
});

export async function executorNode(state: AgentState): Promise<Partial<AgentState>> {
  const { plan, currentStep, executionResults } = state;

  // 다음 실행 가능한 단계 찾기
  const executableStep = find(plan, (step: PlanStep) => {
    // 단계는 대기 중이어야 하고 모든 종속성이 완료되어야 함
    if (step.status !== 'pending') return false;

    const dependenciesMet = every(
      step.dependencies,
      (depId: number) => {
        const depStep = find(plan, (s: PlanStep) => s.step === depId);
        return depStep?.status === 'completed';
      }
    );

    return dependenciesMet;
  });

  if (!executableStep) {
    // 더 이상 실행할 단계가 없음
    return {
      finalOutput: generateSummary(plan, executionResults),
    };
  }

  // 단계 실행
  const contextFromDeps = executableStep.dependencies
    .map(depId => {
      const result = executionResults[depId];
      return result ? `단계 ${depId} 결과: ${result}` : '';
    })
    .filter(Boolean)
    .join('\n');

  const executionPrompt = `다음 액션을 실행하세요:
  액션: ${executableStep.action}
  추론: ${executableStep.reasoning}
  ${contextFromDeps ? `\n이전 단계의 컨텍스트:\n${contextFromDeps}` : ''}

  액션 실행의 간결한 결과를 제공하세요.`;

  const result = await executorModel.invoke([
    new SystemMessage('당신은 실행 에이전트입니다. 요청된 액션을 수행하고 결과를 반환하세요.'),
    new HumanMessage(executionPrompt),
  ]);

  // es-toolkit을 사용하여 계획 및 결과 업데이트
  const updatedPlan = mapArray(plan, (step: PlanStep) =>
    step.step === executableStep.step
      ? { ...step, status: 'completed' as const, result: result.content as string }
      : step
  );

  return {
    plan: updatedPlan,
    executionResults: {
      [executableStep.step]: result.content,
    },
    currentStep: currentStep + 1,
  };
}

function generateSummary(plan: PlanStep[], results: Record<number, any>): string {
  const completedSteps = filter(plan, (s: PlanStep) => s.status === 'completed');
  return `${completedSteps.length}개 단계를 성공적으로 완료했습니다.
최종 결과: ${JSON.stringify(results, null, 2)}`;
}

비용 효율성을 위해 Gemini 2.5 Flash를 사용하여 개별 계획 단계를 실행하고, 종속성을 관리하며 후속 단계를 위한 결과를 누적합니다.

4. 계획 그래프 구축

// lib/planning/graph.ts
import { StateGraph, END, MemorySaver } from '@langchain/langgraph';
import { AgentState } from './types';
import { plannerNode } from './nodes/planner';
import { executorNode } from './nodes/executor';
import { every } from 'es-toolkit';

export function createPlanningGraph() {
  const workflow = new StateGraph<AgentState>({
    stateSchema: AgentState,
  });

  // 노드 추가
  workflow.addNode('planner', plannerNode);
  workflow.addNode('executor', executorNode);

  // 조건부 엣지 정의
  workflow.addConditionalEdges(
    'executor',
    (state: AgentState) => {
      // es-toolkit을 사용하여 모든 단계가 완료되었는지 확인
      const allCompleted = every(
        state.plan,
        step => step.status === 'completed' || step.status === 'failed'
      );

      return allCompleted ? 'end' : 'continue';
    },
    {
      end: END,
      continue: 'executor',
    }
  );

  // 플로우 설정
  workflow.setEntryPoint('planner');
  workflow.addEdge('planner', 'executor');

  return workflow.compile({
    checkpointer: new MemorySaver(), // 서버리스의 경우 프로덕션에서 외부 스토리지 사용
  });
}

모든 작업이 완료될 때까지 반복적인 단계 실행을 가능하게 하는 조건부 실행 로직으로 계획 워크플로우를 조립합니다.

5. 계획 에이전트용 API 라우트

// app/api/planning/route.ts
import { createPlanningGraph } from '@/lib/planning/graph';
import { HumanMessage } from '@langchain/core/messages';
import { NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';

export const runtime = 'nodejs';
export const maxDuration = 300;

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

    const graph = createPlanningGraph();

    // 실시간 업데이트를 위한 이벤트 스트림
    const encoder = new TextEncoder();
    const stream = new TransformStream();
    const writer = stream.writable.getWriter();

    (async () => {
      try {
        const events = graph.stream(
          {
            messages: [new HumanMessage(message)],
          },
          {
            configurable: { thread_id: sessionId },
            streamMode: 'values',
          }
        );

        for await (const event of events) {
          // 프론트엔드로 계획 업데이트 전송
          if (event.plan) {
            await writer.write(
              encoder.encode(`data: ${JSON.stringify({
                type: 'plan_update',
                plan: event.plan,
                currentStep: event.currentStep,
              })}\n\n`)
            );
          }

          // 최종 출력 전송
          if (event.finalOutput) {
            await writer.write(
              encoder.encode(`data: ${JSON.stringify({
                type: 'complete',
                output: event.finalOutput,
              })}\n\n`)
            );
          }
        }
      } catch (error) {
        console.error('스트리밍 오류:', error);
        await writer.write(
          encoder.encode(`data: ${JSON.stringify({
            type: 'error',
            error: '처리 실패',
          })}\n\n`)
        );
      } finally {
        await writer.close();
      }
    })();

    return new Response(stream.readable, {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
      },
    });
  } catch (error) {
    console.error('계획 API 오류:', error);
    return NextResponse.json(
      { error: '요청 처리 실패' },
      { status: 500 }
    );
  }
}

스트리밍 API 엔드포인트를 통해 계획 에이전트를 노출하여 에이전트가 계획 실행을 진행함에 따라 실시간 업데이트를 제공합니다.

6. React Query를 사용한 프론트엔드 컴포넌트

// components/PlanningInterface.tsx
'use client';

import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { map } from 'es-toolkit';

interface PlanStep {
  step: number;
  action: string;
  reasoning: string;
  status: 'pending' | 'in_progress' | 'completed' | 'failed';
  result?: string;
}

export default function PlanningInterface() {
  const [input, setInput] = useState('');
  const [plan, setPlan] = useState<PlanStep[]>([]);
  const [output, setOutput] = useState('');

  const executePlan = useMutation({
    mutationFn: async (message: string) => {
      const response = await fetch('/api/planning', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message }),
      });

      if (!response.ok) throw new Error('계획 실행 실패');
      if (!response.body) throw new Error('응답 본문 없음');

      const reader = response.body.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value);
        const lines = chunk.split('\n');

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            try {
              const data = JSON.parse(line.slice(6));

              if (data.type === 'plan_update') {
                setPlan(data.plan);
              } else if (data.type === 'complete') {
                setOutput(data.output);
              }
            } catch (e) {
              console.error('파싱 오류:', e);
            }
          }
        }
      }
    },
  });

  const getStatusBadge = (status: string) => {
    const badges = {
      pending: 'badge-ghost',
      in_progress: 'badge-info',
      completed: 'badge-success',
      failed: 'badge-error',
    };
    return badges[status as keyof typeof badges] || 'badge-ghost';
  };

  return (
    <div className="card bg-base-100 shadow-xl">
      <div className="card-body">
        <h2 className="card-title">계획 에이전트</h2>

        <form onSubmit={(e) => {
          e.preventDefault();
          executePlan.mutate(input);
        }}>
          <div className="form-control">
            <textarea
              className="textarea textarea-bordered h-24"
              placeholder="복잡한 작업을 설명해주세요..."
              value={input}
              onChange={(e) => setInput(e.target.value)}
              disabled={executePlan.isPending}
            />
          </div>

          <div className="card-actions justify-end mt-4">
            <button
              type="submit"
              className="btn btn-primary"
              disabled={!input || executePlan.isPending}
            >
              {executePlan.isPending ? (
                <>
                  <span className="loading loading-spinner"></span>
                  계획 수립 및 실행 중...
                </>
              ) : '계획 생성 및 실행'}
            </button>
          </div>
        </form>

        {plan.length > 0 && (
          <div className="mt-6">
            <h3 className="font-bold mb-2">실행 계획:</h3>
            <ul className="steps steps-vertical">
              {map(plan, (step) => (
                <li
                  key={step.step}
                  className={`step ${step.status === 'completed' ? 'step-primary' : ''}`}
                >
                  <div className="text-left">
                    <div className="flex items-center gap-2">
                      <span className="font-semibold">{step.action}</span>
                      <span className={`badge badge-sm ${getStatusBadge(step.status)}`}>
                        {step.status}
                      </span>
                    </div>
                    <p className="text-sm opacity-70">{step.reasoning}</p>
                    {step.result && (
                      <div className="mt-2 p-2 bg-base-200 rounded text-sm">
                        {step.result}
                      </div>
                    )}
                  </div>
                </li>
              ))}
            </ul>
          </div>
        )}

        {output && (
          <div className="alert alert-success mt-4">
            <span>{output}</span>
          </div>
        )}

        {executePlan.isError && (
          <div className="alert alert-error mt-4">
            <span>계획 실행에 실패했습니다. 다시 시도해주세요.</span>
          </div>
        )}
      </div>
    </div>
  );
}

DaisyUI 컴포넌트를 사용하여 계획 생성 및 실행을 실시간으로 시각화하고 단계 진행 상황 및 결과를 보여주는 React 컴포넌트입니다.

고급 예제: 생각의 트리가 있는 ReAct 에이전트

1. 이미 설치된 추가 종속성

// 프로젝트 설정의 모든 종속성이 이미 사용 가능합니다:
// @langchain/google-genai - Google AI 모델
// @langchain/langgraph - 상태 기반 워크플로우
// es-toolkit, es-toolkit/compat - 함수형 유틸리티
// zod - 스키마 검증
// @tanstack/react-query - 데이터 페칭

추가 설치가 필요하지 않습니다 - 모든 필요한 패키지가 프로젝트 설정에 사전 설치되어 있습니다.

// lib/advanced-planning/types.ts
import { z } from 'zod';
import { Annotation } from '@langchain/langgraph';
import { BaseMessage } from '@langchain/core/messages';

// 트리 구조를 위한 생각 노드
export const ThoughtNodeSchema = z.object({
  id: z.string(),
  content: z.string(),
  score: z.number(),
  depth: z.number(),
  parentId: z.string().nullable(),
  children: z.array(z.string()).default([]),
  isTerminal: z.boolean().default(false),
  metadata: z.record(z.any()).optional(),
});

export type ThoughtNode = z.infer<typeof ThoughtNodeSchema>;

// 트리 탐색이 포함된 향상된 상태
export const TreeAgentState = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: (current, update) => current.concat(update),
    default: () => [],
  }),
  thoughtTree: Annotation<Map<string, ThoughtNode>>({
    reducer: (current, update) => new Map([...current, ...update]),
    default: () => new Map(),
  }),
  currentNodeId: Annotation<string | null>({
    default: () => null
  }),
  bestPath: Annotation<string[]>({
    reducer: (current, update) => update,
    default: () => [],
  }),
  explorationBudget: Annotation<number>({
    default: () => 10
  }),
  iterationCount: Annotation<number>({
    reducer: (current, update) => current + update,
    default: () => 0,
  }),
});

export type TreeState = typeof TreeAgentState.State;

정교한 추론을 위한 점수 및 깊이 추적과 함께 분기 사고 프로세스를 지원하는 트리 기반 탐색 상태를 정의합니다.

2. 트리 탐색을 사용한 ReAct 패턴 구현

// lib/advanced-planning/nodes/react-tree.ts
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { SystemMessage, HumanMessage, AIMessage } from '@langchain/core/messages';
import { maxBy, filter, map, sortBy, take } from 'es-toolkit';
import { v4 as uuidv4 } from 'uuid';
import { TreeState, ThoughtNode } from '../types';

const reasoningModel = new ChatGoogleGenerativeAI({
  model: 'gemini-2.5-pro',
  temperature: 0.7,
  maxOutputTokens: 8192,
}).withStructuredOutput({
  name: 'thought_expansion',
  description: '여러 추론 경로 생성',
  parameters: {
    type: 'object',
    properties: {
      thoughts: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            reasoning: { type: 'string' },
            action: { type: 'string' },
            confidence: { type: 'number' },
          },
        },
      },
    },
  },
});

export async function reactTreeNode(state: TreeState): Promise<Partial<TreeState>> {
  const { thoughtTree, currentNodeId, explorationBudget, iterationCount } = state;

  // 탐색 예산 확인
  if (iterationCount >= explorationBudget) {
    return selectBestPath(state);
  }

  // 현재 컨텍스트 가져오기
  const currentNode = currentNodeId ? thoughtTree.get(currentNodeId) : null;
  const context = buildContext(state, currentNode);

  // 여러 생각 분기 생성
  const thoughtPrompt = `현재 컨텍스트가 주어졌을 때, 3가지 다른 추론 경로를 생성하세요.
  각 경로는:
  1. 고유한 추론 접근법 제공
  2. 구체적인 액션 제안
  3. 접근법에 대한 신뢰도 (0-1) 추정

  컨텍스트: ${context}

  현재 생각: ${currentNode?.content || '초기 상태'}`;

  const response = await reasoningModel.invoke([
    new SystemMessage('당신은 여러 해결책 경로를 탐색하는 추론 에이전트입니다.'),
    new HumanMessage(thoughtPrompt),
  ]);

  // 새로운 생각 노드 생성
  const newNodes = new Map<string, ThoughtNode>();
  const parentDepth = currentNode?.depth || 0;

  for (const thought of response.thoughts) {
    const nodeId = uuidv4();
    const node: ThoughtNode = {
      id: nodeId,
      content: `${thought.reasoning} → ${thought.action}`,
      score: thought.confidence * (1 / (parentDepth + 1)), // 깊이에 따른 점수 감소
      depth: parentDepth + 1,
      parentId: currentNodeId,
      children: [],
      isTerminal: false,
      metadata: { action: thought.action },
    };

    newNodes.set(nodeId, node);

    // 부모의 자식 업데이트
    if (currentNode) {
      currentNode.children.push(nodeId);
      thoughtTree.set(currentNodeId!, {
        ...currentNode,
        children: currentNode.children,
      });
    }
  }

  // es-toolkit을 사용하여 탐색할 다음 노드 선택 (최고 점수)
  const nextNode = maxBy(
    Array.from(newNodes.values()),
    (node: ThoughtNode) => node.score
  );

  return {
    thoughtTree: new Map([...thoughtTree, ...newNodes]),
    currentNodeId: nextNode?.id || null,
    iterationCount: 1,
  };
}

function buildContext(state: TreeState, currentNode: ThoughtNode | null): string {
  if (!currentNode) {
    return state.messages[state.messages.length - 1]?.content as string || '';
  }

  // es-toolkit을 사용하여 루트에서 현재까지의 경로 구축
  const path: ThoughtNode[] = [];
  let node: ThoughtNode | null = currentNode;

  while (node) {
    path.unshift(node);
    node = node.parentId ? state.thoughtTree.get(node.parentId) || null : null;
  }

  return map(path, (n: ThoughtNode) => n.content).join(' → ');
}

function selectBestPath(state: TreeState): Partial<TreeState> {
  const { thoughtTree } = state;

  // es-toolkit을 사용하여 터미널 노드 또는 가장 깊은 노드 찾기
  const allNodes = Array.from(thoughtTree.values());
  const terminalNodes = filter(allNodes, (n: ThoughtNode) =>
    n.isTerminal || n.children.length === 0
  );

  // 최고의 터미널 노드 선택
  const bestNode = maxBy(terminalNodes, (n: ThoughtNode) => n.score);

  if (!bestNode) {
    return { bestPath: [] };
  }

  // 경로 재구성
  const path: string[] = [];
  let current: ThoughtNode | null = bestNode;

  while (current) {
    path.unshift(current.id);
    current = current.parentId ? thoughtTree.get(current.parentId) || null : null;
  }

  return { bestPath: path };
}

여러 추론 경로의 체계적인 탐색을 가능하게 하는 Gemini 2.5 Pro를 사용하여 ReAct 패턴과 생각의 트리를 결합한 트리 기반 탐색을 구현합니다.

3. 반성을 통한 액션 실행

// lib/advanced-planning/nodes/reflective-executor.ts
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';
import { find } from 'es-toolkit';
import { TreeState, ThoughtNode } from '../types';

// 스키마를 사용한 도구 정의
const searchTool = new DynamicStructuredTool({
  name: 'search',
  description: '정보 검색',
  schema: z.object({
    query: z.string(),
  }),
  func: async ({ query }) => {
    // 검색 시뮬레이션
    return `"${query}"에 대한 검색 결과: [관련 정보]`;
  },
});

const calculateTool = new DynamicStructuredTool({
  name: 'calculate',
  description: '계산 수행',
  schema: z.object({
    expression: z.string(),
  }),
  func: async ({ expression }) => {
    // 실제 계산을 위해 math.js 또는 유사한 도구 사용
    try {
      // 데모 목적으로 안전한 평가
      const result = Function('"use strict"; return (' + expression + ')')();
      return `계산 결과: ${result}`;
    } catch (e) {
      return `계산 오류: ${e}`;
    }
  },
});

const tools = [searchTool, calculateTool];

export async function reflectiveExecutor(state: TreeState): Promise<Partial<TreeState>> {
  const { thoughtTree, currentNodeId } = state;

  if (!currentNodeId) return {};

  const currentNode = thoughtTree.get(currentNodeId);
  if (!currentNode?.metadata?.action) return {};

  const action = currentNode.metadata.action as string;

  // 액션 실행
  let result: string;
  try {
    // 액션 파싱 및 적절한 도구 실행
    const toolMatch = action.match(/(\w+)\((.*)\)/);
    if (toolMatch) {
      const [, toolName, args] = toolMatch;
      const tool = find(tools, (t) => t.name === toolName);

      if (tool) {
        // 인수를 안전하게 파싱
        const parsedArgs = args ? { [toolName === 'search' ? 'query' : 'expression']: args.replace(/['"]/g, '') } : {};
        result = await tool.func(parsedArgs);
      } else {
        result = `알 수 없는 도구: ${toolName}`;
      }
    } else {
      result = '실행할 유효한 액션이 없습니다';
    }
  } catch (error) {
    result = `실행 오류: ${error}`;
  }

  // Gemini Flash를 사용하여 결과에 대한 반성
  const reflectionModel = new ChatGoogleGenerativeAI({
    model: 'gemini-2.5-flash',
    temperature: 0,
    maxOutputTokens: 1024,
  });

  const reflection = await reflectionModel.invoke([
    new SystemMessage('이 결과가 목표를 성공적으로 달성하는지 평가하세요.'),
    new HumanMessage(`액션: ${action}\n결과: ${result}\n이것이 우리의 목표를 달성하나요?`),
  ]);

  // 반성으로 노드 업데이트
  const updatedNode: ThoughtNode = {
    ...currentNode,
    isTerminal: reflection.content?.includes('성공') || reflection.content?.includes('달성') || false,
    metadata: {
      ...currentNode.metadata,
      result,
      reflection: reflection.content,
    },
  };

  thoughtTree.set(currentNodeId, updatedNode);

  return {
    thoughtTree,
  };
}

성공을 평가하기 위해 Gemini 2.5 Flash를 사용한 반성으로 생각 노드에서 액션을 실행하여 에이전트가 실행 결과로부터 학습할 수 있게 합니다.

4. 병렬 탐색을 통한 오케스트레이션 그래프

// lib/advanced-planning/graph.ts
import { StateGraph, END } from '@langchain/langgraph';
import { filter } from 'es-toolkit';
import { TreeState, ThoughtNode } from './types';
import { reactTreeNode } from './nodes/react-tree';
import { reflectiveExecutor } from './nodes/reflective-executor';

export function createAdvancedPlanningGraph() {
  const workflow = new StateGraph<TreeState>({
    stateSchema: TreeState,
  });

  // 노드 추가
  workflow.addNode('think', reactTreeNode);
  workflow.addNode('act', reflectiveExecutor);
  workflow.addNode('select_best', selectBestPathNode);

  // 조건부 라우팅
  workflow.addConditionalEdges(
    'think',
    (state: TreeState) => {
      // 탐색을 계속할지 실행할지 확인
      const { iterationCount, explorationBudget } = state;

      if (iterationCount >= explorationBudget) {
        return 'select';
      }

      // 현재 노드에 액션이 필요한지 확인
      const currentNode = state.currentNodeId
        ? state.thoughtTree.get(state.currentNodeId)
        : null;

      return currentNode?.metadata?.action ? 'execute' : 'explore';
    },
    {
      explore: 'think',
      execute: 'act',
      select: 'select_best',
    }
  );

  workflow.addConditionalEdges(
    'act',
    (state: TreeState) => {
      const currentNode = state.currentNodeId
        ? state.thoughtTree.get(state.currentNodeId)
        : null;

      return currentNode?.isTerminal ? 'end' : 'continue';
    },
    {
      end: END,
      continue: 'think',
    }
  );

  workflow.addEdge('select_best', END);
  workflow.setEntryPoint('think');

  return workflow.compile();
}

async function selectBestPathNode(state: TreeState): Promise<Partial<TreeState>> {
  const { thoughtTree, bestPath } = state;

  if (bestPath.length === 0) {
    return { finalOutput: '탐색 예산 내에서 해결책을 찾지 못했습니다' };
  }

  // es-toolkit을 사용하여 최고 경로에서 결과 컴파일
  const pathNodes = filter(
    bestPath.map(id => thoughtTree.get(id)),
    (node): node is ThoughtNode => node !== undefined
  );

  const solution = pathNodes
    .map(node => ({
      step: node.content,
      result: node.metadata?.result || 'N/A',
    }));

  return {
    finalOutput: JSON.stringify(solution, null, 2),
  };
}

최적의 문제 해결을 위해 사고, 행동, 경로 선택 단계 간의 조건부 분기와 함께 복잡한 추론을 조율합니다.

5. 캐싱을 통한 성능 최적화

// lib/advanced-planning/cache.ts
import { hash } from 'es-toolkit/compat';
import { isEqual } from 'es-toolkit';

interface CacheEntry {
  result: any;
  timestamp: number;
  ttl: number;
}

// Vercel 서버리스의 경우 인메모리 캐시 또는 Redis와 같은 외부 서비스 사용
// 이것은 데모를 위한 간단한 인메모리 버전입니다
class InMemoryCache {
  private cache = new Map<string, CacheEntry>();

  get(key: string): any | null {
    const entry = this.cache.get(key);
    if (!entry) return null;

    if (Date.now() - entry.timestamp > entry.ttl * 1000) {
      this.cache.delete(key);
      return null;
    }

    return entry.result;
  }

  set(key: string, value: any, ttl: number): void {
    this.cache.set(key, {
      result: value,
      timestamp: Date.now(),
      ttl,
    });
  }

  delete(key: string): void {
    this.cache.delete(key);
  }
}

export class PlanningCache {
  private readonly prefix = 'planning:';
  private readonly defaultTTL = 3600; // 1시간
  private store = new InMemoryCache();

  async get(key: string): Promise<any | null> {
    try {
      const cacheKey = this.generateKey(key);
      return this.store.get(cacheKey);
    } catch (error) {
      console.error('캐시 가져오기 오류:', error);
      return null;
    }
  }

  async set(key: string, value: any, ttl?: number): Promise<void> {
    try {
      const cacheKey = this.generateKey(key);
      this.store.set(cacheKey, value, ttl || this.defaultTTL);
    } catch (error) {
      console.error('캐시 설정 오류:', error);
    }
  }

  private generateKey(input: string): string {
    // 일관된 키 생성을 위해 es-toolkit/compat의 hash 사용
    const hashed = hash(input);
    return `${this.prefix}${hashed}`;
  }

  // 유사한 쿼리에 대한 의미적 유사성 캐싱
  async findSimilar(
    query: string,
    threshold: number = 0.85
  ): Promise<any | null> {
    try {
      // 프로덕션에서는 임베딩을 사용한 벡터 유사성 검색 사용
      // 이것은 간단한 토큰 겹침 버전입니다
      const queryTokens = new Set(query.toLowerCase().split(' '));

      // 유사성을 위해 캐시된 쿼리 확인
      for (const [key, entry] of (this.store as any).cache) {
        if (!key.startsWith(this.prefix)) continue;

        // 저장된 경우 메타데이터에서 원래 쿼리 추출
        const similarity = this.calculateSimilarity(query, key);
        if (similarity > threshold) {
          return entry.result;
        }
      }

      return null;
    } catch (error) {
      console.error('유사성 검색 오류:', error);
      return null;
    }
  }

  private calculateSimilarity(query1: string, query2: string): number {
    // 데모를 위한 간단한 Jaccard 유사성
    const set1 = new Set(query1.toLowerCase().split(' '));
    const set2 = new Set(query2.toLowerCase().split(' '));

    const intersection = new Set([...set1].filter(x => set2.has(x)));
    const union = new Set([...set1, ...set2]);

    return intersection.size / union.size;
  }
}

// 서버리스를 위한 싱글톤 인스턴스 내보내기
export const planningCache = new PlanningCache();

중복 LLM 호출을 줄이고 응답 시간을 개선하기 위해 es-toolkit 유틸리티를 사용하여 TTL 및 의미적 유사성 매칭을 통한 지능적 캐싱을 구현합니다.

6. 트리 탐색의 프론트엔드 시각화

// components/TreeVisualization.tsx
'use client';

import { useEffect, useRef } from 'react';
import { ThoughtNode } from '@/lib/advanced-planning/types';

interface TreeVisualizationProps {
  thoughtTree: Map<string, ThoughtNode>;
  currentNodeId: string | null;
  bestPath: string[];
}

export default function TreeVisualization({
  thoughtTree,
  currentNodeId,
  bestPath,
}: TreeVisualizationProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!canvasRef.current) return;

    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    // 캔버스 지우기
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // 노드 위치 계산
    const positions = calculateTreeLayout(thoughtTree);

    // 엣지 그리기
    thoughtTree.forEach((node) => {
      const nodePos = positions.get(node.id);
      if (!nodePos) return;

      node.children.forEach((childId) => {
        const childPos = positions.get(childId);
        if (!childPos) return;

        ctx.beginPath();
        ctx.moveTo(nodePos.x, nodePos.y);
        ctx.lineTo(childPos.x, childPos.y);

        // 최고 경로 강조
        if (bestPath.includes(node.id) && bestPath.includes(childId)) {
          ctx.strokeStyle = '#10b981';
          ctx.lineWidth = 3;
        } else {
          ctx.strokeStyle = '#6b7280';
          ctx.lineWidth = 1;
        }

        ctx.stroke();
      });
    });

    // 노드 그리기
    thoughtTree.forEach((node) => {
      const pos = positions.get(node.id);
      if (!pos) return;

      ctx.beginPath();
      ctx.arc(pos.x, pos.y, 20, 0, 2 * Math.PI);

      // 상태에 따른 색상
      if (node.id === currentNodeId) {
        ctx.fillStyle = '#3b82f6'; // 현재 노드 - 파란색
      } else if (bestPath.includes(node.id)) {
        ctx.fillStyle = '#10b981'; // 최고 경로 - 녹색
      } else if (node.isTerminal) {
        ctx.fillStyle = '#f59e0b'; // 터미널 - 주황색
      } else {
        ctx.fillStyle = '#e5e7eb'; // 기본 - 회색
      }

      ctx.fill();
      ctx.strokeStyle = '#1f2937';
      ctx.lineWidth = 2;
      ctx.stroke();

      // 점수 그리기
      ctx.fillStyle = '#1f2937';
      ctx.font = '12px sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(node.score.toFixed(2), pos.x, pos.y);
    });
  }, [thoughtTree, currentNodeId, bestPath]);

  return (
    <div className="card bg-base-100 shadow-xl">
      <div className="card-body">
        <h3 className="card-title">생각 트리 탐색</h3>
        <canvas
          ref={canvasRef}
          width={800}
          height={400}
          className="border border-base-300 rounded-lg"
        />
        <div className="flex gap-4 mt-4 text-sm">
          <div className="flex items-center gap-2">
            <div className="w-4 h-4 bg-blue-500 rounded-full"></div>
            <span>현재</span>
          </div>
          <div className="flex items-center gap-2">
            <div className="w-4 h-4 bg-green-500 rounded-full"></div>
            <span>최고 경로</span>
          </div>
          <div className="flex items-center gap-2">
            <div className="w-4 h-4 bg-orange-500 rounded-full"></div>
            <span>터미널</span>
          </div>
        </div>
      </div>
    </div>
  );
}

function calculateTreeLayout(
  thoughtTree: Map<string, ThoughtNode>
): Map<string, { x: number; y: number }> {
  const positions = new Map<string, { x: number; y: number }>();
  const levelCounts = new Map<number, number>();

  // 레벨별 노드 수 계산
  thoughtTree.forEach((node) => {
    const count = levelCounts.get(node.depth) || 0;
    levelCounts.set(node.depth, count + 1);
  });

  // 위치 계산
  const levelIndices = new Map<number, number>();
  thoughtTree.forEach((node) => {
    const levelIndex = levelIndices.get(node.depth) || 0;
    const levelCount = levelCounts.get(node.depth) || 1;

    const x = (800 / (levelCount + 1)) * (levelIndex + 1);
    const y = 50 + node.depth * 100;

    positions.set(node.id, { x, y });
    levelIndices.set(node.depth, levelIndex + 1);
  });

  return positions;
}

현재 노드, 최고 경로, 터미널 노드를 상호작용적인 캔버스 렌더링으로 보여주며 생각 트리 탐색을 실시간으로 시각화합니다.

결론

계획 패턴은 AI 에이전트를 단순한 응답자에서 복잡하고 다단계 작업을 처리할 수 있는 정교한 문제 해결자로 변환합니다. 구조화된 워크플로우를 위한 계획-실행과 탐색적 추론을 위한 ReAct와 생각의 트리의 결합을 구현함으로써 인간 수준과 맞먹거나 그를 뛰어넘는 계획 능력을 가진 에이전트를 구축할 수 있습니다.

이러한 구현에서 얻은 주요 통찰:

  • 계획과 실행 간의 관심사 분리는 각 단계에 최적의 모델 사용을 가능하게 합니다 (계획용 Gemini 2.5 Pro, 실행용 Gemini 2.5 Flash)
  • 점수 메커니즘을 사용한 트리 탐색은 선형 접근법보다 더 나은 해결책을 찾습니다
  • 캐싱 및 최적화는 정교한 계획을 프로덕션에서 경제적으로 실행 가능하게 만듭니다
  • 실시간 시각화는 사용자가 에이전트의 의사결정을 이해하고 신뢰할 수 있도록 도움을 줍니다
  • 전체적인 es-toolkit 사용은 서버리스 환경에 최적화된 깨끗하고 함수형 코드를 보장합니다

TypeScript, LangGraph, Google Gemini 모델과 함께 Vercel의 서버리스 플랫폼에서 실행되는 이러한 패턴은 777초 실행 창 내에서 복잡성을 분해하고, 대안을 탐색하며, 규모에 맞는 신뢰할 수 있는 결과를 제공할 수 있는 프로덕션 준비 계획 에이전트 구축의 기반을 제공합니다.