오늘은 에이전트 이야기를 해보려고 합니다. 모두가 항상 말하듯 개발자의 역할은 '소프트웨어로 문제를 해결하는 것'입니다. 소프트웨어가 빠르게 발전하고, 산업도 커지며 많은 문제가 해결됐지만 여전히 해결해야 할 문제는 많습니다.

최근에는 AI가 등장하며 소프트웨어 패러다임이 변화하고 있습니다. 소프트웨어가 해결하는 문제도, 개발하는 방법도 달라지는 혼돈의 시기지만 AI가 아직까지 해결하지 못한 문제를 해결해줄 수 있는 키가 될 것은 확실해 보입니다.

그런 관점에서 더이상 AI를 무시할 수는 없게 됐습니다. 따라서 오늘은 AI 에이전트가 무엇인지, 어떤 문제를 해결할 수 있는지 알아보고 어떻게 만들 수 있는지 알아보겠습니다.

비결정론적 문제

지금까지 소프트웨어는 결정론적인 문제를 해결하는 데 집중해왔습니다. 결정론적 문제란 문제에 대해 정답을 예측할 수 있는 문제를 의미합니다. 대표적으로, 1 + 1 = 2와 같은 수학 문제는 정답을 예측할 수 있기에 결정론적입니다. 이러한 문제는 알고리즘을 통해 해결할 수 있으며, 소프트웨어 개발자들은 이를 해결하기 위해 다양한 알고리즘과 데이터 구조를 사용해왔습니다.

하지만 현실 세계의 문제는 비결정론적입니다. 결정론적 문제와는 반대로 비결정론적 문제는 불규칙하기에 정답을 예측하기 어렵습니다. 예를 들어, 고객의 CS 응대를 하거나 소프트웨어의 UX를 개선하는 것은 비결정론적 문제입니다. 이러한 문제는 특정 알고리즘으로 해결하기 어렵고, 많은 경우 경험과 직관이 필요합니다.

지금까지의 소프트웨어는 다음과 같은 비결정론적 문제의 특징으로 인해 해결하기 어려웠습니다.

  • 모호성: 입력이나 요구사항이 명확하게 정의되지 않음
  • 불확실성: 정답이 여러 개 존재하거나 최적의 답이 상황에 따라 달라질 수 있음
  • 복잡성: 변수가 너무 많거나 서로 복잡하게 얽혀 있음
  • 유연성 요구: 기존 패턴에서 벗어난 사고가 필요함

앞서 비결정론적 문제를 해결하기 위해선 경험과 직관이 필요하다고 언급했습니다. 경험과 직관이란 결국 대량의 데이터와 패턴이라 할 수 있습니다. 즉, 비결정론적 문제는 정답을 예측하기 어렵지만, 과거의 데이터를 기반으로 패턴을 찾아내어 해결할 수 있습니다. 많은 사람들이 이런 방법을 통해 비결정론적 문제를 해결하고자 했고 결국 머신러닝 모델이 등장하게 됐습니다.

소프트웨어의 진화

소프트웨어는 처음 등장했을 때부터 지금까지 많은 발전을 해왔습니다. 초기의 소프트웨어는 결정론적 문제를 해결하는 데 집중했습니다. 이러한 결정론적 문제는 논리적인 사고와 명시적인 규칙, 제한된 입력을 통해 해결할 수 있었습니다. 예를 들어, 계산기 소프트웨어는 사용자가 규칙에 따라 입력한 수식을 기반으로 결과를 계산하는 결정론적 문제를 해결합니다. 이렇게 결정론적 문제를 해결하는 소프트웨어를 소프트웨어 1.0이라고 부릅니다.

소프트웨어 1.0은 정해진 입력을 프로그램으로 처리한다

하지만 앞서 언급했 듯이 현실 세계의 많은 문제는 비결정론적입니다. 비결정론적 문제를 해결하기 위해 많은 데이터를 수집했고 이를 기반으로 패턴을 찾아내는 방법이 필요했습니다. 이 방법을 통해 나온 것이 머신러닝 모델입니다.

머신러닝 모델은 대량의 데이터를 기반으로 학습하여 패턴을 찾아내고, 이를 바탕으로 예측이나 분류 등의 작업을 수행할 수 있습니다. 예를 들어, 추천 시스템은 사용자의 행동 데이터를 기반으로 패턴을 찾아내어 개인화된 추천을 제공합니다. 이러한 머신러닝 모델을 활용하여 비결정론적 문제를 해결하는 소프트웨어를 소프트웨어 2.0이라고 부릅니다.

소프트웨어 2.0은 입력을 머신러닝 모델로 처리한다

그리고 이제 LLM이 등장했습니다. LLM은 대량의 데이터를 기반으로 학습하여 자연어를 이해하고 생성할 수 있는 머신러닝 모델입니다.

이를 통해 LLM은 사용자가 자연어로 입력한 프롬프트를 이해하고, 이를 기반으로 적절한 응답을 생성합니다. 예를 들어, 사용자가 "오늘 날씨 어때?"라고 물으면 LLM은 날씨 정보를 검색하여 적절한 응답을 생성합니다. 이러한 LLM을 활용하여 비결정론적 문제를 해결하는 소프트웨어를 소프트웨어 3.0이라고 부릅니다.

소프트웨어 3.0은 프롬프트를 통해 문제를 해결한다

이렇게 세 가지 방식으로 소프트웨어를 분류했습니다. 이 분류 방법이 공식적인 것은 아닙니다. 다만, 패러다임이 바뀜에 따라 여러 아티클이나 도서에서 이러한 분류 방법을 따르고 있습니다.

중요한 점은 각 분류에 우위가 있는게 아니라, 각 분류가 서로 보완적이라는 점입니다. 결정론적인 문제는 알고리즘을 통해 해결하는 것이 가장 빠르고 효율적입니다. 반면, 비결정론적 문제는 머신러닝 모델을 통해 해결하는 것이 더 효과적입니다. 그리고 LLM은 비결정론적 문제를 해결하는 데 있어 프롬프트라는 자연어로 문제를 해결할 수 있는 방법을 제공합니다. 어떻게보면 점차 사람과 가까워지는 방향으로 추상화되고 있다고 볼 수 있습니다.

AI 에이전트란?

LLM은 자연어를 이해하고 생성할 수 있는 머신러닝 모델입니다. 하지만 LLM은 단순히 입력된 프롬프트에 대해 응답을 생성하는 것에 그칩니다. 즉, LLM은 사용자가 원하는 작업을 수행하기 위해 필요한 정보를 수집하거나, 여러 단계를 거쳐 작업을 수행하는 등의 복잡한 작업을 수행할 수 없습니다. 이러한 한계를 넘어 실행 가능한 지능(Acting Intelligence)을 만들기 위해 등장한 개념이 AI 에이전트(AI Agent)입니다.

조금 더 자세히 설명하자면, AI 에이전트는 LLM이 단순히 텍스트를 생성하는 것을 넘어서, 도구를 사용하고 환경과 상호작용함으로써 문제를 해결하는 시스템을 말합니다. 즉, LLM이 계획을 세워 API를 호출하거나 데이터베이스에 접근하여 정보를 수집하고, 이를 바탕으로 작업을 수행하는 것입니다.

그리고 이런 AI 에이전트를 만들기 위한 일반적인 아키텍처는 다음과 같습니다.

일반적인 AI 에이전트 아키텍처

AI 에이전트 아키텍처는 크게 세 레이어로 구분됩니다.

  1. Model
    LLM을 포함한 모델 레이어입니다. 사용자의 프롬프트를 이해하고, 이를 바탕으로 작업을 수행합니다.
  2. Orchestration
    AI 에이전트의 핵심 레이어입니다. 사용자의 프롬프트를 분석하고, 작업을 계획하고, 이를 수행하는 모듈로 구성됩니다. 이 레이어는 LLM과 도구를 연결하여 AI 에이전트가 실제로 작업을 수행할 수 있도록 합니다.
  3. Tool
    AI 에이전트가 사용할 수 있는 도구 레이어입니다. API 호출, 데이터베이스 접근 등 다양한 작업을 수행할 수 있는 도구를 제공합니다.

위와 같은 아키텍처에서 만약 "오늘 뉴스를 요약하여 Notion에 기록하고, 관련 키워드를 Slack으로 공유해줘"라는 프롬프트가 입력으로 들어왔다면, AI 에이전트는 다음과 같이 작업을 수행할 수 있습니다.

  1. LLM
    뉴스 요약 → 기록 → 공유라는 자연어 계획 생성
  2. Planner
    → 이를 Tool 호출 목록으로 세분화, 실패/성공 시나리오 생성
  3. Executor
    news_api.get()llm.summarize()notion.create()llm.keywords()slack.send() 순차 실행
  4. Memory
    → 요약/키워드/사용자 요청 내용 저장
  5. 최종 응답
    → "요약문이 Notion에 기록되었고, 키워드는 Slack에 공유되었습니다."라는 최종 응답 생성

이렇게 AI 에이전트는 사용자의 요청을 이해하고, 이를 바탕으로 여러 단계를 거쳐 작업을 수행할 수 있습니다. 즉, AI 에이전트는 LLM의 능력을 확장하여 더 복잡한 작업을 수행할 수 있습니다.

직접 AI 에이전트 만들어보기

그럼 이제 실제로 AI 에이전트를 만드는 방법에 대해서 알아보겠습니다. 여기서는 간단하게 대화할 수 있는 에이전트부터, 복잡한 오케스트레이션을 사용하는 에이전트까지 만들어보겠습니다.

참고로 최종적으로 만들어진 코드는 GitHub 저장소에서 확인할 수 있습니다.

대화 가능한 에이전트 구현

먼저 단순히 LLM과 대화할 수 있는 에이전트를 만들어보겠습니다. 여기서는 간단한 TypeScript 코드와 OpenAI API를 활용하여 대화할 수 있는 에이전트를 만들어보겠습니다.

시작하기전 필요한 준비물은 다음과 같습니다.

  1. OpenAI API 키
  2. Node.js 환경
  3. TypeScript 환경

OpenAI API 키 발급과 크레딧 충전은 OpenAI 공식 문서에서 확인할 수 있습니다.

mkdir simple-agent
cd simple-agent
npm init -y # package.json 생성
npm install typescript ts-node @types/node --save-dev # 타입스크립트 설치
npx tsc --init # tsconfig.json 생성
npm install openai dotenv # 라이브러리 설치

이제 src 디렉토리를 만들고, 필요한 코드를 작성해보겠습니다. 먼저 OpenAI API를 사용하여 대화하는 코드를 작성해보겠습니다.

// src/chatAgent.ts
import OpenAI from 'openai';
import readline from 'readline';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function startChatAgent() {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  console.log('💬 AI 에이전트와의 대화를 시작합니다. 종료하려면 Ctrl+C를 누르세요.\n');

  while (true) {
    const userInput = await new Promise<string>((resolve) => {
      rl.question('👤 사용자: ', resolve);
    });

    const res = await openai.chat.completions.create({
      model: 'gpt-4-0613',
      messages: [{ role: 'user', content: userInput }],
    });

    const reply = res.choices[0].message?.content;
    console.log(`🤖 에이전트: ${reply}\n`);
  }
}

이어서 index.ts 파일을 만들어 에이전트를 실행하는 코드를 작성해보겠습니다.

// src/index.ts
import dotenv from 'dotenv';
dotenv.config();

import { startChatAgent } from './chatAgent';

startChatAgent();

마지막으로 .env 파일을 만들어 OpenAI API 키를 설정합니다.

# .env
OPENAI_API_KEY=... # OpenAI API 키

이제 모든 준비가 끝났습니다. 다음과 같이 실행하면 AI 에이전트와 대화할 수 있습니다.

npx ts-node src/index.ts
💬 AI 에이전트와의 대화를 시작합니다. 종료하려면 Ctrl+C를 누르세요.

👤 사용자: 안녕하세요. 잘 지내시나요?
🤖 에이전트: 안녕하세요! 저는 잘 지내고 있습니다. 당신은 어떻게 지내시나요? 도움이 필요하시면 언제든지 말씀해 주세요!

👤 사용자: 

툴 사용하기

이제 LLM과 대화할 수 있는 에이전트를 만들었습니다. 하지만 이 에이전트는 단순히 LLM과 대화하는 것에 그칩니다. 이번에는 툴을 사용하여 더 복잡한 작업을 수행할 수 있는 에이전트를 만들어보겠습니다.

먼저 GeekNews에서 최신 글 목록을 가져오는 툴을 만들어보겠습니다. GeekNews는 RSS 피드를 제공하므로, 이를 활용하여 최신 글 목록을 가져올 수 있습니다.

이를 위해 rss-parser 라이브러리를 사용하여 RSS 피드를 파싱하는 코드를 작성해보겠습니다. 먼저 rss-parser 라이브러리를 설치합니다.

npm install rss-parser

이제 tools.ts 파일을 만들어 GeekNews RSS 피드를 가져오는 코드를 작성해보겠습니다.

// src/tools.ts
import Parser from 'rss-parser';

const parser = new Parser();

export async function fetchGeekNewsFeed(): Promise<string[]> {
  const feed = await parser.parseURL('https://feeds.feedburner.com/geeknews-feed');
  return feed.items.slice(0, 20).map(item => `${item.title}\n${item.link}`);
}

다음으로 toolSchema.ts 파일을 만들어 툴의 스키마를 정의합니다. 여기서는 방금 만든 GeekNews RSS 피드를 가져오는 툴을 정의합니다.

// src/toolSchema.ts
import { ChatCompletionTool } from "openai/resources/chat";

export const tools: ChatCompletionTool[] = [
  {
    type: "function",
    function: {
      name: "fetchGeekNewsFeed",
      description: "긱뉴스에서 최근 뉴스 항목을 가져옵니다",
      parameters: {
        type: "object",
        properties: {},
        required: [],
      },
    },
  },
];

마지막으로 앞서 만든 chatAgent.ts 파일을 수정하여 툴을 사용할 수 있도록 합니다. 여기서는 사용자가 "긱뉴스"라고 입력하면 GeekNews RSS 피드를 가져오는 툴을 호출하도록 구현합니다.

import OpenAI from "openai";
import readline from "readline";
import { tools } from "./toolSchema";
import { fetchGeekNewsFeed } from "./tools";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function startChatAgent() {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  console.log(
    "💬 AI 에이전트와의 대화를 시작합니다. 종료하려면 Ctrl+C를 누르세요.\n"
  );

  while (true) {
    const userInput = await new Promise<string>((resolve) => {
      rl.question("👤 사용자: ", resolve);
    });

    const res = await openai.chat.completions.create({
      model: "gpt-4o-mini",
      messages: [{ role: "user", content: userInput }],
      tools,
      tool_choice: "auto",
    });

    const choice = res.choices[0];
    const msg = choice.message;

    if (msg.tool_calls?.[0]?.function.name === "fetchGeekNewsFeed") {
      const data = await fetchGeekNewsFeed();
      console.log("📡 긱뉴스 결과:\n", data.join("\n\n"));
    } else {
      console.log(`🤖 에이전트: ${msg.content ?? "(응답 없음)"}`);
    }
  }
}

아래쪽 코드를 보면 msg.tool_calls?.[0]?.function.name을 통해 툴을 호출했는지 확인하는 부분이 있습니다. 만약 fetchGeekNewsFeed 툴을 호출했다면 앞서 만든 fetchGeekNewsFeed 함수를 호출하여 긱뉴스 RSS 피드를 가져옵니다. 그리고 가져온 데이터를 출력하도록 만들 수 있습니다.

실행하면 다음과 같이 긱뉴스 RSS 피드를 가져올 수 있습니다.

 npx ts-node src/index.ts
💬 AI 에이전트와의 대화를 시작합니다. 종료하려면 Ctrl+C를 누르세요.

👤 사용자: 안녕하세요
🤖 에이전트: 안녕하세요! 무엇을 도와드릴까요?
👤 사용자: 최근 긱뉴스 글 알려주세요
📡 긱뉴스 결과:
Ask GN: LLM으로 생산성이 증가한거 같은데요. 왜 저는 여전히 바쁠까요?
https://news.hada.io/topic?id=20632

샤오미 MiMo 추론 모델
https://news.hada.io/topic?id=20631

AI 이미지 생성 서비스 Civit이 검열을 강화하는 진짜 이유 - "Visa"
https://news.hada.io/topic?id=20630

...

계획과 실행

만약 다음과 같이 단계적으로 작업을 수행해야 한다면 어떻게 해야 할까요?

"긱뉴스에서 최근 글을 가져와서 인공지능(AI) 관련 글만 필터링하고 이메일로 보내줘"

이런 경우엔 프롬프트를 기반으로 계획을 나눈 후, 각 단계를 실행해야 합니다. 즉, 사용자의 요청을 분석하고, 이를 바탕으로 작업을 계획하고, 이를 수행하는 모듈들이 필요합니다. 이러한 모듈들을 모아 Orchestration이라고 부릅니다.

먼저 계획을 수립하는 Planner 에이전트를 만들어보겠습니다. OpenAI API를 사용하여 사용자의 요청을 분석하고, 이를 바탕으로 작업을 계획할 수 있습니다.

Planner 에이전트가 수립한 계획을 동적으로 실행하기 위해선 계획에 대한 구조를 정의해야 합니다. JSON 구조를 다음과 같이 정의하겠습니다.

[
  { "action": "fetchGeekNewsFeed" },
  { "action": "filter", "args": { "keyword": "AI" } },
  { "action": "summarize" },
  { "action": "sendEmail", "args": { "subject": "오늘의 뉴스" } }
]

이제 plannerAgent.ts 파일을 만들어 Planner 에이전트를 구현합니다.

// src/plannerAgent.ts
import OpenAI from "openai";
import { tools } from "./toolSchema";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

function getToolDescriptions(): string {
  return tools
    .map((t) => {
      const name = t.function.name;
      const desc = t.function.description;
      return `- ${name}: ${desc}`;
    })
    .join("\n");
}

export async function createStructuredPlan(
  userRequest: string
): Promise<any[]> {
  const prompt = `
당신은 사용자의 자연어 요청을 계획으로 분해하는 시스템입니다.

다음은 사용할 수 있는 도구 목록입니다:
${getToolDescriptions()}

**주의:**
- 요청에 명시된 작업만 계획하세요.
- 추론을 통해 추가 도구를 사용하지 마세요.
- 코드 블록을 사용하지 마세요.
- 각 작업은 JSON 객체로 표현되어야 하며, "action" 필드와 선택적 "args" 필드를 포함해야 합니다.

요청: "${userRequest}"

출력 예시:
[
  { "action": "fetchGeekNewsFeed" },
  { "action": "filter", "args": { "keyword": "AI" } },
  { "action": "sendEmail", "args": { "subject": "오늘의 뉴스" } }
]
`;

  const res = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [{ role: "user", content: prompt }],
  });

  const planText = res.choices[0].message?.content ?? "[]";
  const clean = planText
    .replace(/^```json/, "")
    .replace(/^```/, "")
    .replace(/```$/, "")
    .trim();
  return JSON.parse(clean);
}

이제 계획을 실행하는 Executor를 만들어보겠습니다. Executor 에이전트는 Planner 에이전트가 수립한 계획을 기반으로 각 단계를 실행합니다.

import * as tools from "./tools";

type PlanStep = {
  action: string;
  args?: Record<string, any>;
};

export async function executeStructuredPlan(plan: PlanStep[]) {
  let context = ""; // 초기 context는 빈 문자열

  for (let i = 0; i < plan.length; i++) {
    const { action, args } = plan[i];
    const tool = (tools as any)[action];

    if (!tool) {
      console.warn(`⚠️ 등록되지 않은 도구: ${action}`);
      continue;
    }

    console.log(`\n🚀 [${i}] 실행: ${action}`);
    if (args) console.log(`🔧 args: ${JSON.stringify(args)}`);

    try {
      context = await tool(context, args);
    } catch (err) {
      console.error(`${action} 실행 중 오류 발생`, err);
    }
  }

  console.log("\n✅ 최종 결과:");
  console.log(context);
}

이어서 필요한 툴을 만들어보겠습니다. 여기서 메일을 보내기 위해 nodemailer 라이브러리를 사용하겠습니다.

npm install nodemailer
npm install @types/nodemailer --save-dev # 타입 정의 설치

이제 tools.ts 파일을 수정하여 글 필터링 툴, 글 요약 툴, 이메일 전송 툴을 추가합니다. 참고로 툴을 만들 때는 context를 첫 번째 인자로 받고, 두 번째 인자로 args를 받도록 합니다. context는 이전 단계에서 생성된 결과를 전달받고, args는 툴에 필요한 추가 인자를 전달받습니다.

import Parser from "rss-parser";
import nodemailer from "nodemailer";
import OpenAI from "openai";

const parser = new Parser();
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function fetchGeekNewsFeed(): Promise<string> {
  const feed = await parser.parseURL(
    "https://feeds.feedburner.com/geeknews-feed"
  );
  return feed.items
    .slice(0, 20)
    .map((item, index) => `${index + 1}. ${item.title} / ${item.link}`)
    .join("\n\n");
}

export async function filter(
  context: string,
  args: { keyword: string }
): Promise<string> {
  const prompt = `다음 항목들 중 '${args.keyword}'와 관련된 항목만 골라주세요.`;

  const res = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [{ role: "user", content: `${prompt}\n\n${context}` }],
  });

  const text = res.choices[0].message?.content ?? "";
  return text;
}

export async function sendEmail(
  context: string,
  args: { subject: string }
): Promise<string> {
  const transporter = nodemailer.createTransport({
    service: "gmail",
    auth: {
      user: process.env.EMAIL_FROM,
      pass: process.env.EMAIL_PASS,
    },
  });

  await transporter.sendMail({
    from: process.env.EMAIL_FROM,
    to: process.env.EMAIL_TO,
    subject: args.subject,
    text: context,
  });

  console.log("📧 이메일 전송 완료");
  return "이메일 전송 완료";
}

export async function print(
  context: string,
  args: { subject: string }
): Promise<string> {
  console.log("📜 출력:", context);
  return "출력 완료";
}

이제 toolSchema.ts 파일을 수정하여 툴을 추가합니다.

// src/toolSchema.ts
import { ChatCompletionTool } from "openai/resources/chat";

export const tools: ChatCompletionTool[] = [
  {
    type: "function",
    function: {
      name: "fetchGeekNewsFeed",
      description: "긱뉴스에서 최근 뉴스 항목을 가져옵니다",
      parameters: {
        type: "object",
        properties: {},
        required: [],
      },
    },
  },
  {
    type: "function",
    function: {
      name: "filter",
      description: "문자열 항목을 키워드와 관련 있는 것으로 필터링합니다",
      parameters: {
        type: "object",
        properties: {
          keyword: {
            type: "string",
            description: "필터링할 키워드",
          },
        },
        required: ["keyword"],
      },
    },
  },
  {
    type: "function",
    function: {
      name: "sendEmail",
      description: "이메일을 보냅니다",
      parameters: {
        type: "object",
        properties: {
          subject: {
            type: "string",
            description: "이메일 제목",
          },
          body: {
            type: "string",
            description: "이메일 본문",
          },
        },
        required: ["subject", "body"],
      },
    },
  },
];

마지막으로 index.ts 파일을 수정하여 Planner와 Executor를 연결합니다. 이제 다른 프롬프트는 무시하고, 등록한 툴과 관련한 프롬프트만 처리하도록 만들겠습니다.

import readline from "readline";
import { createStructuredPlan } from "./plannerAgent";
import { executeStructuredPlan } from "./executor";

export async function startChatAgent() {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  console.log(
    "💬 AI 에이전트와의 대화를 시작합니다. 종료하려면 Ctrl+C를 누르세요.\n"
  );

  while (true) {
    const userInput = await new Promise<string>((resolve) => {
      rl.question("👤 사용자: ", resolve);
    });

    try {
      const plan = await createStructuredPlan(userInput);

      const isValidPlan =
        Array.isArray(plan) && plan.every((p) => typeof p.action === "string");
      if (isValidPlan) {
        console.log("🗺 계획:", plan);
        await executeStructuredPlan(plan);
      }
    } catch (e) {
      console.error("⚠️ 계획 생성 중 오류 발생:", e);
    }
  }
}

위와 같이 만든 후 실행하면 다음과 같이 긱뉴스에서 AI 관련 글을 필터링하고 요약하여 이메일로 전송할 수 있습니다.

npx ts-node src/index.ts
💬 AI 에이전트와의 대화를 시작합니다. 종료하려면 Ctrl+C를 누르세요.

👤 사용자: 긱뉴스에서 최근 글을 가져와서 인공지능(AI) 관련 글만 필터링하고 이메일로 보내줘
🗺 계획: [
  { action: 'fetchGeekNewsFeed' },
  { action: 'filter', args: { keyword: 'AI' } },
  { action: 'sendEmail', args: { subject: '오늘의 AI 관련 뉴스' } }
]
🚀 실행 중: fetchGeekNewsFeed
🚀 실행 중: filter
🚀 실행 중: sendEmail
📧 이메일 전송 완료
이메일로 전송된 긱뉴스 요약

메모리

프롬프트에 따라 분기 처리를 해야하거나 이전 요청을 기반으로 작업을 수행해야 하는 경우가 있습니다. 이럴 때는 메모리를 사용하여 이전 요청이나 상태를 저장할 수 있습니다. 메모리는 사용자의 요청과 응답을 저장하고, 이를 바탕으로 다음 요청을 처리하는 데 사용됩니다.

"긱뉴스에서 AI 관련 뉴스는 이메일로, 프론트엔드 관련 뉴스는 콘솔에 출력해줘"
“긱뉴스 정보 가져와서 보여줘, “아까 가져온 것 중 AI 관련만 메일로 보내줘”

위와 같은 프롬프트를 처리하는 것을 목표로 구현해보겠습니다. 사실 데이터를 외부로 저장하지 않는다면 단순히 힙 메모리에 저장하면 됩니다.

// src/memory.ts
type MemoryStore = {
  [step: string]: any;
};

const memory: MemoryStore = {};

위와 같이 메모리를 저장할 수 있는 객체를 만들고, 이를 사용하여 메모리를 저장하고 불러오도록 executeStructuredPlan 함수를 수정하겠습니다.

// src/executor.ts
import * as tools from "./tools";
import { memory } from "./memory";

type PlanStep = {
  action: string;
  args?: Record<string, any>;
  saveAs?: string;
  inputFrom?: string;
};

export async function executeStructuredPlan(plan: PlanStep[]) {
  let context = "";

  for (let i = 0; i < plan.length; i++) {
    const { action, args = {}, saveAs, inputFrom } = plan[i];
    const tool = (tools as any)[action];

    if (!tool) {
      console.warn(`⚠️ 등록되지 않은 도구: ${action}`);
      continue;
    }

    const input = inputFrom ? memory[inputFrom] ?? "" : context;

    // args 내의 메모리 참조 해석
    const resolvedArgs: Record<string, any> = {};
    for (const [k, v] of Object.entries(args)) {
      if (typeof v === "string" && memory[v]) {
        resolvedArgs[k] = memory[v];
      } else {
        resolvedArgs[k] = v;
      }
    }

    console.log(`\n🚀 [${i}] 실행: ${action}`);
    if (inputFrom) console.log(`↪️ inputFrom: '${inputFrom}'`);
    if (saveAs) console.log(`💾 saveAs: '${saveAs}'`);
    if (Object.keys(args).length > 0) {
      console.log(`🔧 args: ${JSON.stringify(resolvedArgs)}`);
    }

    try {
      const result = await tool(input, resolvedArgs);
      context = result;
      if (saveAs) {
        memory[saveAs] = result;
      }
    } catch (err) {
      console.error(`${action} 실행 중 오류 발생`, err);
    }
  }

  console.log("\n🧠 메모리 상태:");
  for (const [key, value] of Object.entries(memory)) {
    const lines = value.split("\n").length;
    const preview = value.slice(0, 100).replace(/\n/g, " ");
    console.log(
      `- ${key} (${lines}줄): ${preview}${value.length > 100 ? "..." : ""}`
    );
  }
}

각 계획에 saveAs, inputFrom 필드가 추가되었습니다. saveAs는 툴의 결과를 메모리에 저장할 때 사용하고, inputFrom은 메모리에서 가져온 값을 툴의 입력으로 사용할 때 사용합니다. 이를 활용하기 위해 executeStructuredPlan 함수도 수정해야 합니다.

// src/plannerAgent.ts
import OpenAI from "openai";
import { tools } from "./toolSchema";
import { memory } from "./memory";
import { ChatCompletionMessageParam } from "openai/resources/chat";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

function getToolDescriptions(): string {
  return tools
    .map((t) => {
      const name = t.function.name;
      const desc = t.function.description;
      return `- ${name}: ${desc}`;
    })
    .join("\n");
}

function getMemoryDescription(): string {
  const keys = Object.keys(memory);
  if (keys.length === 0) return "현재 저장된 메모리는 없습니다.";

  return `현재 사용 가능한 메모리 키: ${keys
    .map((k) => `"${k}"`)
    .join(", ")}\n\ninputFrom 또는 args에서 사용할 수 있습니다.`;
}

export async function createStructuredPlan(
  userRequest: string
): Promise<any[]> {
  const messages: ChatCompletionMessageParam[] = [
    {
      role: "system",
      content: `
당신은 사용자의 자연어 요청을 계획으로 분해하는 시스템입니다.

다음은 사용할 수 있는 도구 목록입니다:
${getToolDescriptions()}

다음은 현재까지의 메모리 상태입니다:
${getMemoryDescription()}

**주의:**
- 요청에 명시된 작업만 계획하세요.
- 추론을 통해 추가 도구를 사용하지 마세요.
- 코드 블록을 사용하지 마세요.
- 각 작업은 JSON 객체로 표현되어야 하며, "action" 필드와 선택적 "args", "saveAs", "inputFrom" 필드를 포함해야 합니다.
- 도구 결과를 재사용하려면 'saveAs'로 저장하고, 'inputFrom'으로 참조하세요.

출력 예시:
[
  { "action": "fetchGeekNewsFeed", "args": {}, "saveAs": "news" },
  { "action": "filter", "args": { "keyword": "AI" }, "inputFrom": "news", "saveAs": "filteredNews" },
  { "action": "sendEmail", "args": { "subject": "AI 관련 뉴스" }, "inputFrom": "filteredNews" }
]
`,
    },
    {
      role: "user",
      content: userRequest,
    },
  ];

  const res = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages,
  });

  const planText = res.choices[0].message?.content ?? "[]";
  const clean = planText
    .replace(/^```json/, "")
    .replace(/^```/, "")
    .replace(/```$/, "")
    .trim();
  return JSON.parse(clean);
}

위와 같이 구현하면 메모리를 활용하여 이전 요청을 기반으로 작업을 수행할 수 있습니다. 실행하면 다음과 같이 메모리를 활용할 수 있습니다.

npx ts-node src/index.ts
💬 AI 에이전트와의 대화를 시작합니다. 종료하려면 Ctrl+C를 누르세요.

👤 사용자: 긱뉴스에서 AI 관련 뉴스는 이메일로, 프론트엔드 관련 뉴스는 콘솔에 출력해줘
🗺 계획: [
  { action: 'fetchGeekNewsFeed', args: {}, saveAs: 'news' },
  {
    action: 'filter',
    args: { keyword: 'AI' },
    inputFrom: 'news',
    saveAs: 'aiNews'
  },
  {
    action: 'filter',
    args: { keyword: '프론트엔드' },
    inputFrom: 'news',
    saveAs: 'frontendNews'
  },
  {
    action: 'sendEmail',
    args: { subject: 'AI 관련 뉴스' },
    inputFrom: 'aiNews'
  },
  {
    action: 'print',
    args: { message: '프론트엔드 관련 뉴스: ' },
    inputFrom: 'frontendNews'
  }
]

🚀 [0] 실행: fetchGeekNewsFeed
💾 saveAs: 'news'

🚀 [1] 실행: filter
↪️ inputFrom: 'news'
💾 saveAs: 'aiNews'
🔧 args: {"keyword":"AI"}

🚀 [2] 실행: filter
↪️ inputFrom: 'news'
💾 saveAs: 'frontendNews'
🔧 args: {"keyword":"프론트엔드"}

🚀 [3] 실행: sendEmail
↪️ inputFrom: 'aiNews'
🔧 args: {"subject":"AI 관련 뉴스"}
📧 이메일 전송 완료

🚀 [4] 실행: print
↪️ inputFrom: 'frontendNews'
🔧 args: {"message":"프론트엔드 관련 뉴스: "}
📜 출력: '프론트엔드'와 관련된 항목은 다음과 같습니다:

10. 왜 우리는 RGB와 HSL에서 OKLCH로 전환했을까요? (번역) / https://news.hada.io/topic?id=20623

이 항목은 색상 모델에 대한 내용으로, 프론트엔드 개발에서 스타일링과 UI 디자인에 관련된 중요한 요소입니다. 나머지 항목들은 주로 경제, AI, 비즈니스 관련 내용으로 보입니다.

🧠 메모리 상태:
- news (19): 1. Ask GN: LLM으로 생산성이 증가한거 같은데요. 왜 저는 여전히 바쁠까요? / https://news.hada.io/topic?id=20632  2. 샤오미 MiMo 추...
- aiNews (11): 'AI'와 관련된 항목은 다음과 같습니다.  1. Ask GN: LLM으로 생산성이 증가한거 같은데요. 왜 저는 여전히 바쁠까요? / https://news.hada.io/topi...
- frontendNews (5): '프론트엔드'와 관련된 항목은 다음과 같습니다:  10. 왜 우리는 RGB와 HSL에서 OKLCH로 전환했을까요? (번역) / https://news.hada.io/topic?id...
👤 사용자: 아까 봤던 프론트엔드 뉴스도 이메일로 보내줘
🗺 계획: [
  {
    action: 'sendEmail',
    args: { subject: '프론트엔드 뉴스' },
    inputFrom: 'frontendNews'
  }
]

🚀 [0] 실행: sendEmail
↪️ inputFrom: 'frontendNews'
🔧 args: {"subject":"프론트엔드 뉴스"}
📧 이메일 전송 완료

🧠 메모리 상태:
- news (19): 1. Ask GN: LLM으로 생산성이 증가한거 같은데요. 왜 저는 여전히 바쁠까요? / https://news.hada.io/topic?id=20632  2. 샤오미 MiMo 추...
- aiNews (11): 'AI'와 관련된 항목은 다음과 같습니다.  1. Ask GN: LLM으로 생산성이 증가한거 같은데요. 왜 저는 여전히 바쁠까요? / https://news.hada.io/topi...
- frontendNews (5): '프론트엔드'와 관련된 항목은 다음과 같습니다:  10. 왜 우리는 RGB와 HSL에서 OKLCH로 전환했을까요? (번역) / https://news.hada.io/topic?id...
👤 사용자: 

더 쉬운 길은 없을까?

지금까지는 LLM과 도구를 직접 연결하고, 계획과 실행을 수작업으로 처리하는 방식으로 에이전트를 구현해보았습니다. 하지만 이러한 방식은 간단한 실험에는 적합하지만, 복잡한 흐름 제어와 대규모 구성에서는 유지보수가 어렵고 확장성이 떨어질 수 있습니다.

또한, 정확도 측면에서 불안정합니다. 이 글에서 만든 에이전트는 단순한 예제일 뿐이며, 실제로는 더 많은 예외 처리와 오류 처리가 필요합니다. LLM은 여전히 불확실성이 존재하기 때문에, 잘못된 결과를 반환할 수 있습니다.

AI 에이전트를 제대로 만들기 위해서는 LangChain, LangGraph, AutoGen과 같은 프레임워크를 이용하는 것이 좋습니다. 이러한 프레임워크는 더 복잡한 에이전트 시스템을 구축할 수 있도록 도와줍니다.

이 글에서는 범위를 벗어나므로 자세히 다루지는 않겠지만, 추후 기회가 된다면 한 번 써보시길 권장합니다.

마치며

이번 글에서는 LLM과 도구를 연결하여 직접 에이전트를 만드는 방법을 알아보았습니다. LLM과 도구를 연결하는 것은 매우 강력한 기능이며, 이를 활용하여 다양한 작업을 자동화할 수 있습니다.

앞서 이야기했듯, LLM의 등장으로 앞으로는 비결정론적인 문제를 해결하는 사례가 더 많아질 것으로 보입니다. 요즘 들리는 이야기로는 툴 자동화를 넘어 특정 업무를 수행하는 AI 직원에 대한 이야기도 나오고 있습니다. 이건 조금 무서운 이야기네요.

아무튼 AI의 등장으로 혼돈의 시대가 됐지만 난세에 영웅이 탄생하듯 개발자들에게 새로운 기회가 열린 것일지도 모릅니다. 여러분도 LLM과 도구를 활용하여 더 많은 기회를 만들어보시길 바랍니다.