아톨러브

XIV. 작은 AI Agent 프로그램(가짜 LLM Model-script 사용) 본문

AI, 클라우드, 문서, 자동화/AI_AGENT

XIV. 작은 AI Agent 프로그램(가짜 LLM Model-script 사용)

아톨 2026. 8. 26. 17:25
반응형

 

    #-------- 1. 설계 주석

    # 설계

    # 목표:       사용자로부터 입력받은 문장

    # 도구:       계산기, 파일 읽기, 조회

    # 반복:       최대 6단계

    # 메모리:     메시지 목록 (완료 디스크에 저장)

    # 종료:       모델이 '완료' 도구를 반환하거나 단계 제한에 도달함

 

    # (아래는 영문으로 위와 동일한 내용)

    # The design

    # goal        a sentence from the user

    # tools       calculator, read_file, lookup

    # loop        at most 6 steps

    # memory      a list of messages, saved to disk at the end

    # exit        the model returns tool 'finish', or the step cap is hit

 

    #-------- 2. Tools

    from pathlib import Path

 

    def calculator(expr:str)->str:

        """(20*4)+15 같은 간단한 산술 표현식을 계산합니다."""

        allowed = set("0123456789+-*/().")  

        expr = expr.replace(" ", "")

        if not expr or not set(expr) <=allowed:

            return "오류: 숫자와 +-*/(). 허용됩니다."

        try:

            return str(eval(expr))

        except (SyntaxError, ZeroDivisionError) as e:

            return f"ERROR: {type(e).__name__}"

 

    def read_file(path:str, max_chars:int=800)->str:

        """로컬 텍스트 파일을 읽습니다. URL에는 사용하지 마십시오."""

        p=Path(path)

        if not p.exists():

            return f"오류: {path}라는 이름의 파일이 없습니다."

        text=p.read_text(encoding="utf-8")

        return text[:max_chars]

 

    def lookup(topic:str)->str:

        """내부 지식 기반에서 사실을 조회합니다."""

        facts={

            "환불 정책":"배송후 30 이내",

            "단가":"개당 $24",

            "배송":"$500 이상 무료",

        }

        return facts.get(topic.lower(), f"오류: '{topic}' 대해 알려진 정보가 없습니다.")

 

    TOOLS = {"calculator":calculator, "read_file":read_file, "lookup":lookup}

 

    # print(calculator("(25*4)+15"))

    # print(lookup("단가"))

    # print(read_file("missing.txt"))

 

    #-------- 3. tool_description()

    import inspect

 

    def tool_description(tools:dict)->str:

        lines=[]

        for name, func in tools.items():

            params=",".join(inspect.signature(func).parameters)

            summary=(inspect.getdoc(func) or "").splitlines()[0]

            lines.append(f"- {name}({params}):{summary}")

        return "\n".join(lines)

 

    def build_system_prompt(tools:dict)->str:

        return f"""

    당신은 단계적으로 작업하는 신중한 조력자입니다.(You are a careful assistant that works in steps.)

 

    사용 가능한 도구(Tools available):

    {tool_description(tools)}

 

    JSON 객체 하나만 응답으로 보내주세요. 다른 내용은 포함하지 마세요.(Reply with ONE JSON object and nothing else):

    {{"tool":"<tool name or finish>", "args": {{...}}}}

 

    최종 답변을 얻으면 다음을 사용하세요.(When you have the final answer, use):

    {{"tool":"finish", "args":{{"answer":"..."}}}}

    """

    # print(build_system_prompt(TOOLS))

 

    #-------- 3. Agent

    import inspect

    import json

    import logging

    import os

    from datetime import datetime

    from pathlib import Path

 

    logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")

    log = logging.getLogger("agent")

 

    MAX_STEPS = int(os.environ.get("MAX_STEPS", "6"))

    DATA_DIR = Path("agent_data")

 

    # scripted stand-in for a real model

    SCRIPT = [

        '{"tool":"lookup", "args":{"topic":"단가"}}',

        '```json\n{"tool":"calculator", "args":{"expr":"24*7"}}\n```',

        '{"tool":"lookup", "args":{"topic":"discount"}}',

        '{"tool":"finish", "args":{"answer":"Seven units cost 168."}}',

    ]

 

    def call_model(messages:list[dict])->dict:

        turn = len([m for m in messages if m["role"]=="assistant"])

        if turn < len(SCRIPT):

            return {"ok":True, "text":SCRIPT[turn], "error":None}

        return {"ok":False, "text":None, "error":"no reply"}

 

    # parsing

    def parse_reply(text:str)->dict:

        if not text or not text.strip():

            return {"ok":False, "tool":None, "args":{}, "error":"empty reply"}

 

        start, end = text.find("{"), text.rfind("}")

        if start == -1 or end == -1:

            return {"ok":False, "tool":None, "args":{}, "error":"no JSON found"}

 

        try:

            data = json.loads(text[start:end+1])

        except json.JSONDecodeError as e:

            return {"ok":False, "tool":None, "args":{}, "error":f"bad JSON:{e.msg}"}

 

        return {

            "ok":True,

            "tool":data.get("tool"),

            "args":data.get("args",{}),

            "error":None,

        }

 

    # the agent

    class Agent:

        def __init__(self, tools:dict, max_steps:int=MAX_STEPS):

            self.tools=tools

            self.max_steps=max_steps

            self.steps=0

            self.history=[]

            self.answer=None

 

        def __repr__(self):

            return f"Agent(steps={self.steps}/{self.max_steps}, msgs={len(self.history)})"

 

        def add(self, role:str, content:str):

            self.history.append({"role":role, "content":content})

 

        def dispatch(self, name:str, args:dict)->str:

            if name not in self.tools:

                return f"ERROR:unknown tool '{name}'. Available:{','.join(self.tools)}"

            if not isinstance(args, dict):

                return "ERROR: args must be an object"

 

            func = self.tools[name]

            expected = set(inspect.signature(func).parameters)

            unknown = set(args) - expected

            if unknown:

                return f"ERROR:unexpected argument(s): {sorted(unknown)}"  

            required = {name for name, args in inspect.signature(func).parameters.items() if args.default is inspect.Parameter.empty}

            missing = required-set(args)

            if missing:

                return f"ERROR:missing required argement(s): {sorted(missing)}"

           

 

            try:

                return str(func(**args))

            except TypeError as e:

                return f"ERROR: bad arguments - {e}"

            except Exception as e:

                return f"ERROR: {type(e).__name__}:{e}"

 

        def run(self, goal:str)->str:

            self.add("system", build_system_prompt(self.tools))

            self.add("user", goal)

            log.info(f"goal: {goal}")

 

            while self.answer is None and self.steps < self.max_steps:

                self.steps +=1

 

                reply = call_model(self.history)

                if not reply["ok"]:

                    log.error(f"model call failed: {reply['error']}")

                    break

 

                self.add("assistant", reply["text"])

                parsed = parse_reply(reply["text"])

 

                if not parsed["ok"]:

                    log.warning(f"step {self.steps}: {parsed['error']}")

                    self.add("user",f"That was not valid JSON ({parsed['error']}). Try again.")

                    continue

 

                tool = parsed["tool"]

 

                if tool == "finish":

                    self.answer = parsed["args"].get("answer","(no answer given)")

                    log.info(f"step {self.steps}: finished")

                    break

 

                observation = self.dispatch(tool, parsed["args"])

                log.info(f"step {self.steps}: {tool} -> {observation}")

                self.add("tool", f"Observation: {observation}")

 

            if self.answer is None:

                self.answer = f"Stopped after {self.steps} steps without an answer."

 

            self.save()

            return self.answer

 

        def save(self):

            DATA_DIR.mkdir(parents=True, exist_ok=True)

            stamp = datetime.now().strftime("%Y%m%d-%H%M%S")

            path = DATA_DIR/f"run-{stamp}.json"

            path.write_text(

                json.dumps(

                    {"steps":self.steps, "answer":self.answer, "history":self.history},

                    indent=2,

                    ensure_ascii=False,

                ),

                encoding="utf-8",

            )

            log.info(f"transcript saved to {path}")

 

    if __name__=="__main__":

        bot = Agent(TOOLS)

        result = bot.run("일곱개의 가격은 얼마인가요?")

        print("-"*50)

        print("ANSWER: ", result)

        print(bot)

 

이번 코드는 우리가 지금까지 따로 배웠던:

  • Tool
  • Tool Registry
  • inspect
  • Docstring
  • System Prompt
  • JSON
  • parse_reply()
  • Dispatcher
  • Agent Class
  • Agent Loop
  • Logging
  • Environment Variable
  • 파일 저장
  • __name__ == "__main__"

이 모두 실제로 연결되는 코드입니다.


0. 먼저 전체 구조를 한 장으로 보기

이 프로그램의 실제 흐름은 다음과 같습니다.

사용자
  │
  │ "일곱개의 가격은 얼마인가요?"
  ▼
main
  │
  ▼
Agent.run()
  │
  ├── System Prompt 생성
  │
  ├── History 생성
  │
  └── Agent Loop 시작
          │
          ▼
      call_model()
          │
          ▼
      JSON 응답
          │
          ▼
      parse_reply()
          │
          ├── finish ?
          │      │
          │      └── Yes → Answer 종료
          │
          └── Tool ?
                 │
                 ▼
             dispatch()
                 │
                 ▼
             TOOLS Registry
                 │
          ┌──────┼──────┐
          ▼      ▼      ▼
      calculator read_file lookup
          │
          ▼
      Observation
          │
          ▼
      History 저장
          │
          └─────────── 다시 Model 호출
          
최종 종료
  │
  ▼
save()
  │
  ▼
agent_data/run-날짜.json
 

이 구조를 머릿속에 두고 코드를 보면 훨씬 이해하기 쉽습니다.


1. 설계 주석

처음 부분입니다.

 
# 목표:       사용자로부터 입력받은 문장
# 도구:       계산기, 파일 읽기, 조회
# 반복:       최대 6단계
# 메모리:     메시지 목록 (완료 시 디스크에 저장)
# 종료:       모델이 '완료' 도구를 반환하거나 단계 제한에 도달함
 

이것은 단순 주석 같지만 사실 Agent 설계 문서의 축소판입니다.

Agent 설계
│
├── Goal
│     └── 사용자 질문 해결
│
├── Tools
│     ├── calculator
│     ├── read_file
│     └── lookup
│
├── Memory
│     └── history
│
├── Loop
│     └── 최대 6번
│
└── Exit
      ├── finish
      └── max_steps
 

실제 AI Agent를 만들 때는 코드를 먼저 쓰기보다 이렇게 먼저 생각하는 것이 좋습니다.

"이 Agent는 무엇을 할 수 있고, 어떻게 기억하며, 언제 멈추는가?"


2. Tool 부분

calculator

 
from pathlib import Path

def calculator(expr:str)->str:
    """(20*4)+15와 같은 간단한 산술 표현식을 계산합니다."""
 

이 함수의 구조는:

calculator
│
├── 입력
│     └── expr: str
│
└── 출력
      └── str
 

예:

 
calculator("20*4+15")
 

허용 문자

 
allowed = set("0123456789+-*/().")
 

여기서 중요한 것은 set()입니다.

문자열:

"0123456789+-*/()."
 

을 Set으로 바꾸면:

{
    '0', '1', '2', ...,
    '+', '-', '*', '/',
    '(', ')', '.'
}
 

가 됩니다.

즉, 계산기에 허용할 문자 목록입니다.


입력 검사

 
if not expr or not set(expr) <= allowed:
    return "오류: 숫자와 +-*/().만 허용됩니다."
 

두 가지를 검사합니다.

첫 번째

 
not expr
 

예:

 
expr = ""
 

이면 True입니다.

즉 빈 문자열을 막습니다.


두 번째

 
set(expr) <= allowed
 

예:

 
expr = "20*5"
 

이면:

set(expr)
=
{'2', '0', '*', '5'}
 

모든 문자가 allowed 안에 있으므로 True입니다.

하지만:

 
expr = "hello"
 

이면:

{'h', 'e', 'l', 'o'}
 

가 됩니다.

이 문자는 허용 목록에 없으므로 False입니다.

따라서:

"hello"
   ↓
차단
 

입니다.


왜 eval() 앞에 검사가 필요한가?

 
eval(expr)
 

는 매우 강력합니다.

예:

 
eval("20*5")
 

결과:

100
 

하지만 사용자가 이상한 Python 코드를 넣을 수도 있습니다.

그래서:

사용자 입력
     │
     ▼
허용 문자 검사
     │
     ▼
eval()
 

구조를 만든 것입니다.

다만 여기서는 한 가지 주의할 점이 있습니다.

잠재적인 문제점

현재 허용 문자에는 공백이 없습니다.

따라서:

 
calculator("20 * 5")
 

는 실패합니다.

왜냐하면 공백 " "이 allowed에 없기 때문입니다.

실제 사용성을 생각하면:

 
expr = expr.replace(" ", "")
 

를 먼저 해주는 것이 좋습니다.


eval 실행

 
try:
    return str(eval(expr))
 

예:

expr = "24*7"

eval(expr)
↓
168

str(...)
↓
"168"
 

왜 문자열로 바꿀까요?

Tool 결과의 형식을 통일하기 위해서입니다.

즉:

Tool Output
│
├── calculator → str
├── read_file → str
└── lookup → str
 

처럼 만드는 것입니다.

Agent에서는 Tool 결과 형식을 어느 정도 통일하면 다루기가 편합니다.


오류 처리

 
except (SyntaxError, ZeroDivisionError) as e:
    return f"ERROR: {type(e).__name__}"
 

예:

 
calculator("10/0")
 

ZeroDivisionError
 

ERROR: ZeroDivisionError
 

입니다.

 


3. read_file

 
def read_file(path:str, max_chars:int=800)->str:
 

구조:

read_file
│
├── path
│     └── 파일 경로
│
├── max_chars
│     └── 기본값 800
│
└── return
      └── str
 

Path 객체

 
p = Path(path)
 

예:

 
path = "notes.txt"
 

p
=
Path("notes.txt")
 

이제 p는 단순 문자열보다 더 많은 기능을 가진 Path 객체입니다.


파일 존재 여부

 
if not p.exists():
 

예:

notes.txt
 

가 실제로 없으면:

 
return f"오류: {path}라는 이름의 파일이 없습니다."
 

파일 읽기

 
text = p.read_text(encoding="utf-8")
 

이전까지 배운:

 
open()
 

의 간편한 버전이라고 생각하면 됩니다.

즉:

 
p.read_text()
 

는 대략:

 
with open(p, "r", encoding="utf-8") as f:
    text = f.read()
 

와 비슷합니다.


글자 수 제한

 
return text[:max_chars]
 

예:

파일 내용

ABCDEFGHIJKLMNOPQRSTUVWXYZ...
 

max_chars=10이면:

ABCDEFGHIJ
 

만 반환합니다.


왜 제한할까?

이것은 실제 Agent에서 매우 중요합니다.

파일이:

100페이지
1000페이지
 

라면 전부 LLM에게 보내는 것은 비효율적입니다.

따라서:

파일
 │
 ▼
최대 800 글자
 │
 ▼
Agent
 │
 ▼
LLM
 

처럼 제한하는 것입니다.

이것은 앞으로 Context 관리와 연결됩니다.


4. lookup

 
def lookup(topic:str)->str:
 

이 Tool은 가짜 내부 지식 DB 역할입니다.

 
facts = {
    "환불 정책":"배송후 30일 이내",
    "단가":"개당 $24",
    "배송":"$500 이상 무료",
}
 

구조:

facts
│
├── 환불 정책 → 배송후 30일 이내
├── 단가       → 개당 $24
└── 배송       → $500 이상 무료
 

문제점 하나 발견

 
return facts.get(topic.lower(), ...)
 

여기에는 약간 이상한 부분이 있습니다.

topic.lower()는 영어 대소문자를 소문자로 바꾸는 데 효과가 있습니다.

예:

 
"PRICE".lower()
 

price
 

그런데 Dictionary의 Key는:

"환불 정책"
"단가"
"배송"
 

처럼 한글입니다.

한글에는 대소문자가 없기 때문에 현재는 큰 문제는 없지만:

 
lookup("단가")
 

"단가".lower()
 

"단가"
 

입니다.

그리고:

 
SCRIPT = [
    ...
    '{"tool":"lookup", "args":{"topic":"discount"}}',
]
 

에서는 "discount"를 조회합니다.

당연히 facts에 없으므로:

오류: 'discount'에 대해 알려진 정보가 없습니다.
 

가 나옵니다.

이것은 일부러 Agent에게 Tool Error를 경험시키는 예제라고 볼 수 있습니다.


5. Tool Registry

 
TOOLS = {
    "calculator":calculator,
    "read_file":read_file,
    "lookup":lookup
}
 

우리가 이미 배운 핵심 구조입니다.

TOOLS
│
├── "calculator"
│       │
│       ▼
│   calculator 함수
│
├── "read_file"
│       │
│       ▼
│   read_file 함수
│
└── "lookup"
        │
        ▼
    lookup 함수
 

나중에:

 
TOOLS["calculator"]
 

하면 함수 자체를 얻습니다.

 
TOOLS["calculator"]("24*7")
 

168
 

6. tool_description()

이제 매우 중요한 부분입니다.

 
def tool_description(tools:dict)->str:
 

이 함수의 역할은:

Python Tool Registry를 사람이 읽을 수 있는 Tool 설명으로 바꾸는 것

입니다.


lines

 
lines = []
 

예를 들어 최종적으로:

 
[
    "- calculator(expr): ...",
    "- read_file(path,max_chars): ...",
    "- lookup(topic): ..."
]
 

를 만들 것입니다.


Tool 반복

 
for name, func in tools.items():
 

첫 번째:

name = "calculator"
func = calculator 함수
 

두 번째:

name = "read_file"
func = read_file 함수
 

함수 인자 분석

 
params = ",".join(
    inspect.signature(func).parameters
)
 

예:

 
calculator(expr)
 

이라면:

inspect.signature(func).parameters
 

expr
 

read_file은:

path
max_chars
 

입니다.

join()을 하면:

"path,max_chars"
 

Docstring 첫 줄

 
summary = (
    inspect.getdoc(func) or ""
).splitlines()[0]
 

예:

 
def calculator(...):
    """(20*4)+15와 같은 간단한 산술 표현식을 계산합니다."""
 

(20*4)+15와 같은 간단한 산술 표현식을 계산합니다.
 

한 줄 Tool 설명

 
lines.append(
    f"- {name}({params}):{summary}"
)
 

결과:

- calculator(expr):(20*4)+15와 같은 간단한 산술 표현식을 계산합니다.

- read_file(path,max_chars):로컬 텍스트 파일을 읽습니다.

- lookup(topic):내부 지식 기반에서 사실을 조회합니다.
 

7. build_system_prompt()

 
def build_system_prompt(tools:dict)->str:
 

이 함수는 Agent의 System Prompt를 만듭니다.

즉:

Agent의 행동 규칙
+
사용 가능한 Tool 목록
+
응답 형식
 

을 만드는 함수입니다.


핵심 구조

System Prompt
│
├── 너는 어떤 Agent인가?
│
├── 어떤 Tool을 사용할 수 있는가?
│
├── 응답은 어떤 형식인가?
│
└── 언제 finish 하는가?
 

Tool 목록 삽입

 
{tool_description(tools)}
 

예:

사용 가능한 도구:

- calculator(expr)
- read_file(path,max_chars)
- lookup(topic)
 

JSON만 반환하도록 지시

 
JSON 객체 하나만 응답으로 보내주세요.
다른 내용은 포함하지 마세요.
 

왜 중요할까요?

우리는 다음에:

 
parse_reply()
 

로 JSON을 파싱해야 하기 때문입니다.

모델이:

네, 알겠습니다!

{
    "tool":"lookup",
    ...
}
 

처럼 보내면 파싱이 복잡해질 수 있습니다.

그래서 처음부터:

JSON만 보내라
 

고 규칙을 주는 것입니다.


중괄호 두 번

 
{{"tool":"<tool name or finish>", "args": {{...}}}}
 

이전에 배운 중요한 문법입니다.

f-string 안에서는:

 
{{ 
 

실제 {
 

입니다.

따라서:

 
f"""
{{"tool":"lookup"}}
"""
 

출력하면:

 
{"tool":"lookup"}
 

가 됩니다.


8. Logging 설정

 
logging.basicConfig(
    level=logging.INFO,
    format="%(levelname)-7s %(message)s"
)
 

이전에 공부한 부분입니다.

예:

INFO    goal: ...
ERROR   model call failed
WARNING step 3: bad JSON
 

Logger 만들기

 
log = logging.getLogger("agent")
 

이 Agent 전용 Logger를 만드는 것입니다.

logging
│
└── agent logger
      │
      ├── INFO
      ├── WARNING
      └── ERROR
 

9. MAX_STEPS

 
MAX_STEPS = int(
    os.environ.get("MAX_STEPS", "6")
)
 

뜻:

환경변수 MAX_STEPS가 있으면
        ↓
그 값을 사용

없으면
        ↓
6 사용
 

예:

MAX_STEPS=10
 

이면:

 
MAX_STEPS = 10
 

입니다.


10. DATA_DIR

 
DATA_DIR = Path("agent_data")
 

Agent 실행 기록을 저장할 폴더입니다.

프로젝트
│
├── main.py
├── agent.py
└── agent_data/
       │
       ├── run-001.json
       └── run-002.json
 

11. SCRIPT

 
SCRIPT = [
    '{"tool":"lookup", "args":{"topic":"단가"}}',
    '```json\n{"tool":"calculator", "args":{"expr":"24*7"}}\n```',
    '{"tool":"lookup", "args":{"topic":"discount"}}',
    '{"tool":"finish", "args":{"answer":"Seven units cost 168."}}',
]
 

이 부분은 실제 OpenAI API를 대신하는 가짜 모델입니다.

즉:

현재
call_model()
↓
SCRIPT

나중
call_model()
↓
OpenAI API
 

입니다.


SCRIPT 실행 순서

1번째 모델 응답

 
{"tool":"lookup", "args":{"topic":"단가"}}
 

Agent:

단가를 lookup 해라
 

개당 $24
 

2번째 모델 응답

 
{"tool":"calculator", "args":{"expr":"24*7"}}
 

168
 

3번째

 
{"tool":"lookup", "args":{"topic":"discount"}}
 

오류 발생
 

왜냐하면 discount가 없기 때문입니다.


4번째

 
{"tool":"finish","args":{"answer":"Seven units cost 168."}}
 

Agent 종료.


12. call_model()

 
def call_model(messages:list[dict])->dict:
 

현재는 실제 AI 호출이 아닙니다.


assistant 메시지 수 계산

 
turn = len([
    m
    for m in messages
    if m["role"]=="assistant"
])
 

예를 들어 History가:

system
user
assistant
user
assistant
 

이면:

assistant = 2개
 

따라서:

 
turn = 2
 

SCRIPT에서 응답 선택

 
if turn < len(SCRIPT):
    return {
        "ok":True,
        "text":SCRIPT[turn],
        "error":None
    }
 

즉:

turn = 0
↓
SCRIPT[0]

turn = 1
↓
SCRIPT[1]

turn = 2
↓
SCRIPT[2]
 

13. parse_reply()

이 함수는 Agent에서 아주 중요합니다.

 
def parse_reply(text:str)->dict:
 

역할:

LLM 응답
     ↓
문자열
     ↓
JSON 찾기
     ↓
json.loads()
     ↓
Tool + Args 추출
 

빈 응답 검사

 
if not text or not text.strip():
 

예:

 
text = ""
 

또는:

 
text = "     "
 

이면 오류입니다.


{와 } 찾기

 
start = text.find("{")
end = text.rfind("}")
 

예:

```json
{"tool":"calculator"}
 

에서도:

```text
start
  ↓
{

end
  ↓
}
 

를 찾습니다.

그래서:

 
text[start:end+1]
 

만 가져옵니다.

이것이 SCRIPT의 두 번째 응답을 처리하기 위한 것입니다.


JSON 파싱

 
data = json.loads(text[start:end+1])
 

예:

'{"tool":"lookup","args":{"topic":"단가"}}'
 

 
{
    "tool":"lookup",
    "args":{
        "topic":"단가"
    }
}
 

14. Agent Class

드디어 핵심입니다.

 
class Agent:
 

이 객체 하나가 Agent의 상태를 관리합니다.


__init__

 
def __init__(
    self,
    tools:dict,
    max_steps:int=MAX_STEPS
):
 

Agent를 만들 때:

 
bot = Agent(TOOLS)
 

하면:

bot
│
├── tools
├── max_steps
├── steps
├── history
└── answer
 

를 갖게 됩니다.


Agent 상태

 
self.tools = tools
 

Tool Registry.

 
self.max_steps = max_steps
 

최대 반복 횟수.

 
self.steps = 0
 

현재 반복 횟수.

 
self.history = []
 

Agent Memory.

 
self.answer = None
 

아직 답변 없음.


15. __repr__

 
def __repr__(self):
    return f"Agent(steps={self.steps}/{self.max_steps}, msgs={len(self.history)})"
 

나중에:

 
print(bot)
 

하면:

Agent(steps=4/6, msgs=10)
 

처럼 보기 좋게 표시됩니다.


16. add()

 
def add(self, role:str, content:str):
    self.history.append({
        "role":role,
        "content":content
    })
 

History에 메시지를 추가합니다.

예:

 
self.add(
    "user",
    "일곱개의 가격은 얼마인가요?"
)
 

 
{
    "role":"user",
    "content":"일곱개의 가격은 얼마인가요?"
}
 

history
[
    ...
    새 메시지
]
 

17. dispatch()

이 부분은 우리가 배운 Dispatcher의 완성형입니다.

 
def dispatch(self, name:str, args:dict)->str:
 

역할:

Tool 요청
    ↓
Tool 존재 확인
    ↓
args 검사
    ↓
예상하지 않은 인자 검사
    ↓
실행
    ↓
결과 반환
 

Tool 존재 확인

 
if name not in self.tools:
 

예:

tool = "weather"
 

그런데:

TOOLS = {
    calculator,
    read_file,
    lookup
}
 

이면 오류입니다.


args가 Dictionary인가?

 
if not isinstance(args, dict):
 

AI가:

 
"args":"hello"
 

처럼 보내면 안 됩니다.

반드시:

 
"args":{
    "topic":"단가"
}
 

형태여야 합니다.


예상하지 않은 인자

 
expected = set(
    inspect.signature(func).parameters
)
 

예:

 
def lookup(topic):
 

이면:

expected = {"topic"}
 

AI가:

 
{
    "topic":"단가",
    "language":"ko"
}
 

라고 보내면:

language
 

는 예상하지 않은 인자입니다.


실제 Tool 실행

 
return str(func(**args))
 

예:

 
func = lookup

args = {
    "topic":"단가"
}
 

실제로는:

 
lookup(topic="단가")
 

가 됩니다.


18. Agent.run()

이 함수가 Agent Loop의 심장입니다.

 
def run(self, goal:str)->str:
 

사용자가:

 
bot.run("일곱개의 가격은 얼마인가요?")
 

라고 하면 여기서 시작합니다.


System Message 추가

 
self.add(
    "system",
    build_system_prompt(self.tools)
)
 

History:

[
    system
]
 

User Message 추가

 
self.add("user", goal)
 

History:

[
    system,
    user
]
 

Agent Loop

 
while (
    self.answer is None
    and
    self.steps < self.max_steps
):
 

즉:

답이 아직 없고
+
최대 단계에 도달하지 않았다면
 

계속 반복합니다.


Step 증가

 
self.steps += 1
 

첫 번째:

steps = 1
 

두 번째:

steps = 2
 

모델 호출

 
reply = call_model(self.history)
 

현재는:

SCRIPT
 

를 사용합니다.

나중에는:

OpenAI API
 

가 들어갈 자리입니다.


Assistant 메시지 저장

 
self.add(
    "assistant",
    reply["text"]
)
 

중요합니다.

모델이 어떤 결정을 했는지 History에 남깁니다.


JSON Parsing

 
parsed = parse_reply(reply["text"])
 

예:

 
{"tool":"lookup","args":{"topic":"단가"}}
 

 
{
    "ok":True,
    "tool":"lookup",
    "args":{
        "topic":"단가"
    },
    "error":None
}
 

finish 확인

 
if tool == "finish":
 

예:

 
{
    "tool":"finish",
    "args":{
        "answer":"Seven units cost 168."
    }
}
 

그러면:

 
self.answer = parsed["args"].get(
    "answer",
    "(no answer given)"
)
 

self.answer

=
"Seven units cost 168."
 

그리고 종료합니다.


Tool 실행

finish가 아니라면:

 
observation = self.dispatch(
    tool,
    parsed["args"]
)
 

예:

tool
=
lookup

args
=
{"topic":"단가"}
 

 
lookup("단가")
 

개당 $24
 

Observation 저장

 
self.add(
    "user",
    f"Observation: {observation}"
)
 

History에는:

system
user: 일곱개의 가격은 얼마인가요?
assistant: lookup 단가
user: Observation: 개당 $24
 

가 됩니다.


여기서 매우 중요한 점

왜 Tool 결과를 "user"로 저장했을까요?

단순한 Agent 예제에서는:

user:
Observation: ...
 

형태로 모델에게 결과를 알려주는 방식을 사용할 수 있습니다.

하지만 실제 OpenAI API의 Tool Calling 구조에서는 보통 Tool 결과를 별도의 Tool 역할 메시지로 관리합니다.

즉 앞으로는:

 
{
    "role":"tool",
    "content":"개당 $24"
}
 

같은 구조로 발전할 가능성이 큽니다.

이전에 공부한:

 
{
    "role":"tool",
    "content":"..."
}
 

와 연결되는 부분입니다.


19. 종료 처리

Loop가 끝났는데도 답이 없다면:

 
if self.answer is None:
 

 
self.answer = (
    f"Stopped after {self.steps} steps without an answer."
)
 

즉:

6단계까지 했는데 답을 못 찾았습니다.
 

라는 의미입니다.


20. save()

Agent 실행 기록을 저장합니다.

 
def save(self):
 

폴더 만들기

 
DATA_DIR.mkdir(
    parents=True,
    exist_ok=True
)
 
agent_data/
 

폴더가 없으면 생성합니다.


시간 만들기

 
stamp = datetime.now().strftime(
    "%Y%m%d-%H%M%S"
)
 

예:

20260826-163025
 

파일 이름

 
path = DATA_DIR / f"run - {stamp}.json"
 

예:

agent_data/
    run - 20260826-163025.json
 

잠재적인 개선점

여기에는 작은 문제가 있습니다.

 
f"run - {stamp}.json"
 

파일명에 공백이 들어갑니다.

실행에는 문제 없지만 보통은:

 
f"run-{stamp}.json"
 

처럼 씁니다.

즉:

 
path = DATA_DIR / f"run-{stamp}.json"
 

가 더 깔끔합니다.


JSON 저장

 
json.dumps(
    {
        "steps":self.steps,
        "answer":self.answer,
        "history":self.history
    },
    indent=2,
    ensure_ascii=False,
)
 

최종 파일은 대략:

 
{
  "steps": 4,
  "answer": "Seven units cost 168.",
  "history": [
    {
      "role": "system",
      "content": "..."
    },
    {
      "role": "user",
      "content": "일곱개의 가격은 얼마인가요?"
    },
    {
      "role": "assistant",
      "content": "{\"tool\":\"lookup\"...}"
    }
  ]
}
 

입니다.


ensure_ascii=False

한글을 그대로 저장하기 위해서입니다.

없으면 한글이:

\uC77C\uACF1...
 

처럼 저장될 수 있습니다.


21. 프로그램 시작점

 
if __name__=="__main__":
 

이전에 공부한 부분입니다.

직접 실행할 때만:

 
bot = Agent(TOOLS)
 

를 실행합니다.


Agent 생성

 
bot = Agent(TOOLS)
 

메모리 구조:

bot
│
├── tools
│     │
│     ├── calculator
│     ├── read_file
│     └── lookup
│
├── max_steps = 6
├── steps = 0
├── history = []
└── answer = None
 

실제 전체 실행 흐름

이 부분이 가장 중요합니다.

사용자 질문:

일곱개의 가격은 얼마인가요?
 

STEP 1

History:

system
user: 일곱개의 가격은 얼마인가요?
 

모델:

 
{"tool":"lookup","args":{"topic":"단가"}}
 

Dispatcher:

 
lookup(topic="단가")
 

결과:

개당 $24
 

History:

Observation: 개당 $24
 

STEP 2

모델:

 
{"tool":"calculator","args":{"expr":"24*7"}}
 

 
calculator("24*7")
 

168
 

History:

Observation: 168
 

STEP 3

모델:

 
{"tool":"lookup","args":{"topic":"discount"}}
 

 
lookup("discount")
 

오류: 'discount'에 대해 알려진 정보가 없습니다.
 

History:

Observation: 오류...
 

STEP 4

모델:

 
{
    "tool":"finish",
    "args":{
        "answer":"Seven units cost 168."
    }
}
 

 
self.answer =
"Seven units cost 168."
 

Loop 종료.


최종 구조를 다시 보면

                    ┌───────────────┐
                    │     User      │
                    └───────┬───────┘
                            │
                            ▼
                    ┌───────────────┐
                    │   Agent.run   │
                    └───────┬───────┘
                            │
                            ▼
                    ┌───────────────┐
                    │ System Prompt │
                    │ + Tool 설명   │
                    └───────┬───────┘
                            │
                            ▼
                    ┌───────────────┐
                    │  call_model   │
                    └───────┬───────┘
                            │
                            ▼
                    ┌───────────────┐
                    │  parse_reply  │
                    └───────┬───────┘
                            │
              ┌─────────────┴─────────────┐
              │                           │
              ▼                           ▼
         finish?                        Tool?
              │                           │
             Yes                          ▼
              │                     dispatch()
              │                           │
              ▼                           ▼
          Answer                     Tool 실행
              │                           │
              │                           ▼
              │                     Observation
              │                           │
              └───────────────┬───────────┘
                              │
                              ▼
                           History
                              │
                              ▼
                         다음 Loop
 

이 코드에서 발견되는 개선점

① calculator()의 공백 문제

현재:

 
allowed = set("0123456789+-*/().")
 

따라서:

 
"24 * 7"
 

는 공백 때문에 실패합니다.

개선:

 
expr = expr.replace(" ", "")
 

후 검사하는 것이 좋습니다.


② lookup()의 .lower()

현재:

 
facts.get(topic.lower(), ...)
 

그런데 facts는 한글 Key입니다.

향후 영어도 지원하려면 Dictionary Key를 통일하는 것이 좋습니다.

예:

 
facts = {
    "refund policy": "...",
    "unit price": "...",
}
 

③ dispatch()는 unknown만 검사

현재는:

 
unknown = set(args) - expected
 

만 검사합니다.

즉:

 
lookup()
 

처럼 필수 인자가 빠졌을 경우는 func(**args) 단계에서 TypeError가 발생합니다.

현재 코드도 결국 잡기는 합니다.

하지만 이전에 배운 check_args()처럼:

필수 인자 누락
예상하지 않은 인자
 

를 실행 전에 구분하는 것이 더 좋습니다.


④ Observation의 role

현재:

 
self.add("user", f"Observation: {observation}")
 

입니다.

학습용 Agent에서는 괜찮지만 실제 API Tool Calling으로 가면:

 
self.add("tool", observation)
 

처럼 Tool 결과를 별도 역할로 저장하는 구조가 더 자연스럽습니다.


가장 중요한 것: 이 코드의 현재 위치

지금 우리는 Agent 학습에서 정확히 여기까지 왔습니다.

1단계
Python 기초
   ↓
2단계
함수
   ↓
3단계
Dictionary / JSON
   ↓
4단계
Tool 함수
   ↓
5단계
Registry
   ↓
6단계
Dispatcher
   ↓
7단계
History
   ↓
8단계
Agent Loop
   ↓
9단계
Class 기반 Agent
   ↓
10단계
Fake Model (SCRIPT)
   ↓
★ 지금 여기
   ↓
11단계
실제 OpenAI API 연결
   ↓
12단계
실제 Tool Calling
   ↓
13단계
더 안정적인 JSON / Structured Output
   ↓
14단계
Memory / Context 관리
   ↓
15단계
실제 업무 Agent
 

이 코드의 SCRIPT가 현재 가장 중요한 교체 지점입니다.

 
def call_model(messages:list[dict])->dict:
 

현재는:

SCRIPT
 

를 반환하지만, 다음 단계에서는 여기를:

OpenAI API
 

로 교체하면 됩니다.

즉, Agent의 몸통은 거의 만들어졌습니다.

 

반응형