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

XVII. AI Agent Engine 기본 완성: Python · Agent Loop · Tool Dispatcher · Function Call · Context Block · History Trim

아톨 2026. 8. 30. 12:47
반응형

 

    # 1. import

    import os

    import json

    import time

    import logging

    import inspect

 

    from pathlib import Path

    from datetime import datetime

    from dataclasses import dataclass, field

    from typing import Any

    from dotenv import load_dotenv

    from openai import OpenAI

 

    # ============================================================

    # 2. Environment settings

    # ============================================================

    # .env 파일을 환경변수로 읽기

    load_dotenv()

 

    MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini",)

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

    DATA_DIR = Path("agent_data")

 

    # API 유지할 최근 대화 블록 : block 하나 = 모델응답 + function call + function_call_output

    MAX_CONTEXT_BLOCKS = int(os.environ.get("MAX_CONTEXT_BLOCKS", "8",))

 

    # Tool 결과가 너무 길어지는 것을 방지

    MAX_TOOL_OUTPUT_CHARS = int(os.environ.get("MAX_TOOL_OUTPUT_CHARS", "4000",))

 

    # Retry 대기시간

    RETRY_DELAYS = [1, 2, 4]

 

    # ============================================================

    # 3. Logging

    # ============================================================

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

    log = logging.getLogger("agent")

 

    # ============================================================

    # 4. OpenAI Client

    # ============================================================

    client = OpenAI()

 

    # ============================================================

    # 5. Tool Result

    # ============================================================

    @dataclass

    class ToolResult:

        """모든 Tool 실행 결과를, 동일한/일정한 구조로 관리하기 위한 클래스"""

        ok:bool

        output:str=""

        error:str | None=None

        meta:dict=field(default_factory=dict)

 

    # ============================================================

    # 6. TOOLS

    # ============================================================

    # ------------------------------------------------------------

    # 6.1 calculator

    # ------------------------------------------------------------

    def calculator(expr:str)->str:

        """

        간단한 산술 계산을 수행합니다.  

        :20*5, (24*7)+100

        숫자와 +-*/(). 사용할 있습니다.

        """

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

        #공백제거

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

        if not expr:

            return "ERROR: 계산식입니다."    

        if not set(expr) <= allowed:

            return ("ERROR: 숫자와 +-*/(). 사용할 있습니다.")

        try:

            #builtins 제거   #eval 문자열을 코드로 실행하므로, 입력이 신뢰할 없으면 보안 취약점이 있슴.

            result = eval(expr, {"__builtins__":{}}, {},) #eval(expr, globals, locals)

            return str(result)

        except ZeroDivisionError:

            return "ERROR: ZeroDivisionError"

        except SyntaxError:

            return "ERROR: SyntaxError"

        except Exception as e:

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

       

    # ------------------------------------------------------------

    # 6.2 read_file

    # ------------------------------------------------------------

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

        """

        로컬 텍스트 파일을 읽습니다.

        URL에는 사용하지 마십시오.

        path: 읽을 파일 경로

        max_chars: 최대 읽을 문자

        """

        try:

            p = Path(path)

            if not p.exists():

                return (f"ERROR: 파일이 없습니다: {path}")

            if not p.is_file():

                return (f"ERROR: 파일이 아닙니다: {path}")

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

            return text[:max_chars]

        except UnicodeDecodeError:

            return ("ERROR: UTF-8 텍스트 파일이 아닙니다.")

        except Exception as e:

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

 

    # ------------------------------------------------------------

    # 6.3 lookup

    # ------------------------------------------------------------

    def lookup(topic:str)->str:

        """

        내부 업무 지식 기반으로 정보를 조회합니다.

        사용 가능한 정보:

        - 단가

        - 환불 정책

        - 배송

        """    

        facts = {

            "환불 정책":"배송 30 이내 환불 가능합니다.",

            "단가":"$24",

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

        }

        topic = topic.strip()

        return facts.get(topic, f"ERROR: '{topic}' 대한 정보가 없습니다.")

       

    # ------------------------------------------------------------

    # 6.4 word_count

    # ------------------------------------------------------------

    def word_count(text:str)->str:

        """ 입력된 문장의 단어 수를 계산합니다."""

        count = len(text.split())

        return str(count)

 

    # ------------------------------------------------------------

    # 6.5 search_documents

    # ------------------------------------------------------------

    # search_documents: 실제 업무용 Agent 위한 예시 Tool. 현재는 샘플 데이터. 나중에:

    # - Vector DB

    # - Qdrant

    # - PostgreSQL

    # - 사내 문서 검색 ... 등으로 교체 가능

    # ------------------------------------------------------------

    def search_documents(query:str, limit:int=3,)->str:

        """

        사내 문서 또는 지식 기반에서 관련 정보를 검색합니다.    

        query: 검색할 내용

        limit: 최대 결과

        """

        documents = [

            {

                "title": "환불 정책",

                "content":"제품 배송 30 이내 환불 가능합니다."

            },

            {

                "title": "배송 정책",

                "content":"500달러 이상 주문 무료 배송됩니다."

            },

            {

                "title": "가격 정책",

                "content":"기본 제품 단가는 개당 24달러입니다."

            },

        ]

        results =[]

        query_lower = query.lower()

        for doc in documents:

            text = (doc["title"]+" "+doc["content"]).lower()

            if query_lower in text:

                results.append(doc)

        results = results[:limit]

 

        if not results:

            return (f"검색 결과가 없습니다: {query}")

        return json.dumps(results, ensure_ascii=False, indent=2)

 

    # ============================================================

    # 7. TOOL REGISTRY

    # ============================================================

    TOOLS={

        "calculator":calculator,    

        "read_file":read_file,

        "lookup":lookup,

        "word_count":word_count,

        "search_documents":search_documents,

    }

 

    # ============================================================

    # 8. PYTHON TYPE -> JSON SCHEMA TYPE

    # ============================================================

    PYTHON_TO_JSON = {

        str:"string",

        int:"integer",

        float:"number",

        bool:"boolean",

    }

 

    # ============================================================

    # 9. PYTHON FUNCTION -> OPENAI TOOL SCHEMA

    # ============================================================

    # 예시:#

    # def calculator(expr: str)

    # ↓

    # {

    #   "type": "function",

    #   "name": "calculator",

    #   "description": "...",

    #   "parameters": {

    #       "type": "object",

    #       "properties": {

    #           "expr": {

    #               "type": "string"

    #           }

    #       },

    #       "required": ["expr"]

    #   }

    # }

    # ============================================================

    def describe_tool(func)->dict:

        """Python 함수를 분석하여, OpenAI Tool Schema 변환합니다.

        (Python 함수 → OpenAI Tool Schema 변환기)

        """

        sig = inspect.signature(func)

 

        #함수 docstring

        doc = inspect.getdoc(func) or "No description provided"

        #첫번째 줄을 Tool 설명으로 사용

        description = (doc.strip().splitlines()[0])

 

        properties = {}

        required = []

 

        #함수의 parameter 분석

        for name, param in sig.parameters.items():

            annotation = param.annotation

 

            #Python type -> JSON type

            json_type = PYTHON_TO_JSON.get(annotation, "string",)

 

            properties[name] = {

                "type":json_type,

                "description":f"{name} parameter"

            }

 

            #기본값이 없으면 required

            if (param.default is inspect.Parameter.empty):

                required.append(name)

            else:

                properties[name]["default"] = (param.default)

 

        # OpenAI Function Tool Schema

        return {

            "type":"function",

            "name":func.__name__,

            "description":description,

            "parameters":{

                "type":"object",

                "properties":properties,

                "required":required,

                "additionalProperties":False,

            },

        }

 

    # ============================================================

    # 10. 모든 Tool Schema 생성

    # ============================================================

    OPENAI_TOOLS = [describe_tool(func) for func in TOOLS.values()]

 

    # ============================================================

    # 11. SYSTEM PROMPT

    # ============================================================

    # 이제 Tool 선택은 정식 Tool Calling 담당. 따라서 이전처럼

    # {"tool":"calculator"...} 같이, JSON 출력을 강제로 요구할 필요가 없음

    # ============================================================

    SYSTEM_PROMPT ="""

    당신은 신중하고 정확한 업무용 AI Agent입니다.

    작업 규칙:

    1. 필요한 정보가 없으면 적절한 Tool 사용하세요.

    2. 계산이 필요한 경우 calculator Tool 사용하세요.

    3. 파일 내용을 확인해야 하면 read_file Tool 사용하세요.

    4. 내부 정책이나 가격 정보를 확인해야 하면 lookup Tool 사용하세요.

    5. 문서 검색이 필요하면 search_documents Tool 사용하세요.

    6. Tool 결과를 확인한 다음 행동을 결정하세요.

    7. 충분한 정보를 얻으면 사용자에게 자연스럽고 명확하게 답변하세요.

    8. Tool 결과에 ERROR 포함되어 있으면, 다른 방법을 시도하거나 사용자에게 문제를 설명하세요.

    9. 추측하지 마세요.

    10. 필요한 정보를 얻을 없으면, 부족한 정보를 명확하게 설명하세요.

    """.strip()

 

    # ============================================================

    # 12. INTERNAL HISTORY TRIM

    # ============================================================

    def trim_history(history:list[dict], max_messages:int=30)->list[dict]:

        """

        내부 History 최근 메시지 중심으로 제한합니다.

        system message 항상 유지합니다.

        주의:

        함수는 저장용 / 로그용 History 관리 목적입니다.  

        """

        if len(history) <= max_messages:

            return history

        system_messages = [m for m in history if m.get("role")=="system"]

        remaining = (max_messages - len(system_messages))

        recent_messages = history[-remaining:]

        return (system_messages + recent_messages)

 

    # ============================================================

    # 13. API CONTEXT BLOCK TRIM

    # ============================================================

    def build_api_input(initial_user_message:dict, context_blocks:list[list], max_blocks:int,)->list:

        """

        실제 OpenAI API 호출용 Context 생성.

        구조: initial user message + 최근 context blocks

        block 하나는 반드시 함께 유지: response.output + function_call_output

        이렇게 하면 Tool Call Tool Output 서로 분리되는 것을 방지할 있습니다.  

        """

        #최근 block 선택

        recent_blocks = context_blocks[-max_blocks:]

        api_input = [initial_user_message]

 

        #block들을 순서대로 연결

        for block in recent_blocks:

            api_input.extend(block)

        return api_input

 

    # ============================================================

    # 14. 중복 Tool 호출 확인

    # ============================================================

    # 같은 Tool...같은 arguments...반복 실행하는 것을 방지

    # ============================================================

    def is_repeat_tool_call(tool_name:str, args:dict, recent_calls:set,)->bool:

        """같은 Tool 같은 arguments 반복 호출하는 것을 방지"""

        signature = (tool_name + ":" + json.dumps(args, sort_keys=True, ensure_ascii=False))

        if signature in recent_calls:

            return True #방금 만든 지문이 recent_calls 집합에 들어있는지 확인 , 들어있다면 True (중복됨) 반환.

        recent_calls.add(signature) #처음 보는 지문(signature)이라면 recent_calls 집합에 새롭게 기록해 둡니다.

        return False

 

    # ============================================================

    # 15. TOOL DISPATCHER

    # ============================================================

    # dispatch_tool() - Tool 실행 담당자

    # ============================================================

    def dispatch_tool(tools:dict, name:str, args:dict)->ToolResult:

        """

        Tool 실행 검증

        1. Tool 존재 여부

        2. args dict인지

        3. 예상하지 못한 argument

        4. 필수 argument 누락

        5. 실제 Tool 실행    

        """

        # Tool 존재 여부

        if name not in tools:

            return ToolResult(ok=False, error=(f"Unknown tool:{name}"),)

        #args type 검사

        if not isinstance(args, dict,):

            return ToolResult(ok=False, error=("Tool arguments must be an object."),)

        func = tools[name]

        sig = inspect.signature(func)

        expected = set(sig.parameters)

 

        #에상하지 못한 argument

        unknown = (set(args) - expected)

        if unknown:

            return ToolResult(ok=False, error=(f"Unexpected arguments: {sorted(unknown)}"),)

        #필수 argument

        required = {

            param_name for param_name, param in sig.parameters.items()

            if(param.default is inspect.Parameter.empty) # 기본값이 없으면 required

        }

        missing = (required - set(args))

        if missing:

            return ToolResult(ok=False, error=(f"Missing arguments: {sorted(missing)}"),)

 

        #실제 Tool 실행

        try:

            output = func(**args)

            return ToolResult(ok=True, output=str(output))

        except TypeError as e:

            return ToolResult(ok=False, error=(f"Bad arguments:{e}"),)

        except Exception as e:

            log.exception("Tool execution failed")

            return ToolResult(ok=False, error=(f"{type(e).__name__}: {e}"),)    

 

    # ============================================================

    # 16. RETRY 가능한 ERROR 확인

    # ============================================================

    def is_retryable_error(error:Exception,)->bool:

        """일시적인 API 오류인지 확인"""

        error_name = (type(error).__name__)

        retryable_names = {

            "RateLimitError",

            "APIConnectionError",

            "APITimeoutError",

            "InternalServerError",        

        }    

        return (error_name in retryable_names)

 

    # ============================================================

    # 17. MODEL CALL

    # Retry + Exponential Backoff

    # ============================================================

    def call_model(api_input:list,)->Any:

        """

        OpenAI Responses API 호출

        Retry 가능한 오류 발생 :

        1

        2

        4

        대기 재시도

        """

        last_error = None

        total_attempts = (len(RETRY_DELAYS)+1) #3+1=>4

 

        for attempt in range(1, total_attempts+1):  #1,2,3,4. 최대 4 시도.

            try:

                log.info(f"model call attempt: {attempt}/{total_attempts}")

                response = (client.responses.create(

                    model=MODEL_NAME,

                    instructions=SYSTEM_PROMPT,

                    input=api_input,

                    tools=OPENAI_TOOLS,

                ))

                return response

            except Exception as e:

                last_error = e

 

                #재시도 불가능한 오류

                if not is_retryable_error(e):

                    log.error(f"non-retryable error: {type(e).__name__}: {e}")  

                    raise

                #마지막 시도

                if attempt==total_attempts:

                    break

 

                wait = RETRY_DELAYS[attempt-1]

                log.warning("retryable error: {type(e).__name__} | waiting {wait}s")

 

            #모든 retry 실패

            raise RuntimeError("Model failed after {total_attempts} attempts: {last_error}")

 

    # ============================================================

    # 18. AGENT

    # ============================================================

    class Agent:

 

        # --------------------------------------------------------

        # 18.1 __init__

        # --------------------------------------------------------

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

            self.tools = tools

            self.max_steps = max_steps

            self.steps = 0

 

            # 전체 History: 저장/로그/Transcript

            self.history = []

            self.answer = None

 

            # 중복 Tool 호출 확인용

            self.recent_tool_calls = set()

 

            # 실제 API Context: [block1, block2, block3, ...]

            self.api_context_blocks = []

 

        # --------------------------------------------------------

        # 18.2 __repr__

        # --------------------------------------------------------

        def __repr__(self):

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

 

        # --------------------------------------------------------

        # 18.3 add_history

        # --------------------------------------------------------

        def add_history(self, role:str, content:str, **extra,):

            message = {"role":role, "content":content,}

            message.update(extra)

            self.history.append(message)

 

        # --------------------------------------------------------

        # 18.4 run

        # --------------------------------------------------------

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

 

            # ====================================================

            # 18.4.1 Agent 상태 초기화

            # ====================================================

            self.history = []

            self.steps = 0

            self.answer = None

            self.recent_tool_calls.clear()

            self.api_context_blocks = []

 

            # 내부 History

            self.add_history("system",SYSTEM_PROMPT,)

            self.add_history("user", goal,)

 

            #최초 사용자 메시지

            initial_user_message = {

                "role":"user",

                "content":goal,

            }

 

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

 

            # ====================================================

            # 18.4.2 Agent Loop

            # ====================================================

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

 

                # ------------------------------------------------

                # STEP 증가

                # ------------------------------------------------

                self.steps += 1

                log.info(

                    f"=========="

                    f"STEP {self.steps}"

                    f"=========="

                )                

 

                # ------------------------------------------------

                # Internal History Trim

                # ------------------------------------------------

                active_history = trim_history(self.history)    

                log.info(f"internal history: {len(active_history)}/{len(self.history)}")

 

                # ------------------------------------------------

                # 실제 API Input 생성            

                # 핵심: 최근 API Context Block 사용. History Trim 실제 API 호출에 적용됨

                # ------------------------------------------------          

                api_input = build_api_input(

                    initial_user_message,

                    self.api_context_blocks,

                    MAX_CONTEXT_BLOCKS,

                )        

                log.info(

                    f"API context blocks: "

                    f"{min(len(self.api_context_blocks),MAX_CONTEXT_BLOCKS)}"

                    f"/"

                    f"{len(self.api_context_blocks)}"

                )

                log.info(f"API input items: {len(api_input)}")

 

                # ------------------------------------------------

                # MODEL CALL

                # ------------------------------------------------

                try:

                    response = call_model(api_input)

                except Exception as e:

                    log.error(f"model failed: {type(e).__name__}: {e}")

                    self.answer = ("모델 호출 오류가 발생했습니다.")

                    break

 

                # ------------------------------------------------

                # Function Call 찾기

                # ------------------------------------------------  

                # 모델이 " Tool 실행해 주세요"라고 요청

                # ------------------------------------------------  

                function_calls = [item for item in response.output if item.type=="function_call"]

 

                # ====================================================

                # Tool Call 없음  → 일반 텍스트 최종 답변

                # ====================================================

                if not function_calls:

                    self.answer = (response.output_text)

                    self.add_history("assistant", self.answer)

                    log.info("finished: normal response")

                    break

 

                # ====================================================

                # Tool Call 있음

                # ====================================================

                tool_outputs = []

 

                # ------------------------------------------------

                # 여러 Tool Call 처리 가능

                # ------------------------------------------------

                for call in function_calls:

                    tool_name = (call.name)

 

                    # ============================================

                    # Tool Arguments JSON Parsing

                    # ============================================

                    try:

                        args = json.loads(call.arguments)

                    except json.JSONDecodeError:

                        args = {}

                        result = ToolResult(ok=False, error=("Tool arguments were not valid JSON"),)

 

                    # ============================================

                    # 중복 Tool 검사

                    # ============================================

                    else:

                        if is_repeat_tool_call(tool_name, args, self.recent_tool_calls):

                            result = ToolResult(

                                ok=False,

                                error=("Duplicate tool call detected.Try another approach."),

                            )

                        else:

                            result = dispatch_tool(self.tools, tool_name, args,)

 

                    # ============================================

                    # Tool Result -> Text

                    # ============================================

                    if result.ok:

                        output_text = (result.output)

                    else:

                        output_text = ("ERROR: " + str(result.error))

 

                    # ============================================

                    # Tool 결과 길이 제한

                    # ============================================

                    if len(output_text) > MAX_TOOL_OUTPUT_CHARS:

                        output_text = (output_text[:MAX_TOOL_OUTPUT_CHARS] + "\n[TRUNCATED]")

 

                    # ============================================

                    # Logging

                    # ============================================

                    log.info(f"tool: {tool_name} | args={args} | result={output_text}")

 

                    # ============================================

                    # 내부 History 저장

                    # ============================================

                    # 우리가 배우고 있는 Agent 구조에서는 명확하게 role="tool" 저장

                    # ========================================

                    self.add_history(

                        "assistant", f"Function call:{tool_name}({args})", tool_name=tool_name,

                    )      

                    self.add_history(

                        "tool", output_text, tool_name=tool_name, args=args, ok=result.ok,

                    )        

 

                    # ============================================

                    # OpenAI API Tool Output

                    # ============================================

                    tool_outputs.append({

                        "type":"function_call_output",

                        "call_id":call.call_id,

                        "output":output_text,

                    })

 

                # ====================================================

                # API Context Block 생성   매우 중요            

                # 모델 응답과 Tool 결과를 하나의 Block으로 묶음   .        

                # 이렇게 해야 Trim function_call function_call_output 분리되지 않음

                # ====================================================

                context_block = []

 

                # 모델이 만든 response output

                context_block.extend(response.output)

 

                # Tool 실행 결과

                context_block.extend(tool_outputs)

 

                # API Context 하나의 Block 추가

                self.api_context_blocks.append(context_block)

 

            # ====================================================

            # 18.4.3 Step Limit

            # ====================================================

            if self.answer is None:

                self.answer = (f"최대단계 {self.max_steps}회에 도달하여 작업을 종료했습니다.")

                self.add_history("assistant", self.answer)

 

            # ====================================================

            # 18.4.4 Save

            # ====================================================

            self.save()

            return self.answer

 

        # --------------------------------------------------------

        # 18.5 save

        # --------------------------------------------------------

        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")

 

            data = {

                "model":MODEL_NAME,

                "steps":self.steps,

                "answer":self.answer,

                "history":self.history,

            }        

            path.write_text(json.dumps(data, indent=2, ensure_ascii=False,),encoding="utf-8",)    

            log.info(f"saved: {path}")

 

    # ============================================================

    # 19. MAIN

    # ============================================================

    if __name__=="__main__":

        print("="*60)

        print("AI AGENT START")

        print("="*60)

        bot = Agent(TOOLS)

        result = bot.run(

            """

            단가를 확인해서 제품 7개의 가격을 계산해 주세요.

 

            반드시 필요한 Tool 사용하세요.

            """    

        )

 

        print()

        print("-"*60)

        print("ANSWER: ")

        print(result)

        print("-"*60)

        print(bot)      

 

이 코드는 지금까지 우리가 단계적으로 발전시켜 온 Agent 중에서 상당히 중요한 전환점입니다.

이전 Agent가:

모델에게 JSON 문자열을 직접 만들어 달라고 요청 → 우리가 파싱 → Tool 실행

이었다면, 현재 코드는:

OpenAI의 정식 Function/Tool Calling → Tool 실행 → Tool 결과를 다시 모델에게 전달 → 다음 판단

구조로 발전했습니다.

특히 이번 코드의 핵심은 단순히 Tool Calling이 아니라 다음 5가지입니다.

① Python 함수 → 자동 Tool Schema 생성
② 모델이 function_call 생성
③ Python이 실제 Tool 실행
④ function_call_output으로 결과를 모델에게 반환
⑤ Context Block 단위로 관리하여 안전하게 History Trim
 

전체 구조 먼저 보기

전체 프로그램을 아주 크게 보면 이렇게 흘러갑니다.

사용자 질문
    ↓
Agent.run()
    ↓
API Context 생성
    ↓
OpenAI 모델 호출
    ↓
┌──────────────────────────────┐
│ 모델의 판단                  │
├──────────────────────────────┤
│ Tool 필요 → function_call    │
│ Tool 불필요 → 일반 답변      │
└──────────────────────────────┘
        ↓
Python Tool 실행
        ↓
function_call_output 생성
        ↓
Context Block 저장
        ↓
다음 모델 호출
        ↓
최종 답변
 

이제 1번 import부터 19번 MAIN까지 작은 블록으로 나누어 설명하겠습니다.


1. import

 
import os
import json
import time
import logging
import inspect
 

이 다섯 개는 Agent의 핵심 운영 기능에 사용됩니다.


1-1. import os

 
import os
 

운영체제의 환경변수를 읽기 위해 사용합니다.

예:

 
MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")
 

의미:

환경변수 MODEL_NAME이 있으면 → 그것 사용
없으면 → gpt-4o-mini 사용
 

이렇게 하면 코드 자체를 수정하지 않고도 실행 환경에서 설정을 바꿀 수 있습니다.

예:

MODEL_NAME=gpt-4o
 

또는:

MAX_STEPS=20
 

이런 식으로 운영 환경을 조정할 수 있습니다.


1-2. import json

 
import json
 

JSON을 처리합니다.

이번 Agent에서 JSON은 매우 중요합니다.

사용되는 곳:

Tool Arguments

모델이:

 
{
    "expr": "24*7"
}
 

을 보내면 Python은:

 
args = json.loads(call.arguments)
 

로 Python Dictionary로 변환합니다.

즉:

JSON 문자열
     ↓
json.loads()
     ↓
Python dict
 

예:

 
'{"expr":"24*7"}'
 

 
{"expr": "24*7"}
 

1-3. import time

 
import time
 

Retry 대기 시간에 사용합니다.

예:

 
time.sleep(wait)
 

API 서버가 일시적으로 문제가 있을 때:

1초 대기
↓
재시도

2초 대기
↓
재시도

4초 대기
↓
재시도
 

이런 방식을 Backoff라고 합니다.


1-4. import logging

 
import logging
 

프로그램의 실행 상황을 기록합니다.

예:

 
log.info("goal: ...")
 

출력:

INFO    goal: 제품 7개의 가격을 계산해 주세요.
 

Agent는 일반 프로그램보다 로그가 중요합니다.

왜냐하면 Agent는 여러 단계를 거치기 때문입니다.

모델 호출
↓
Tool 선택
↓
Tool 실행
↓
결과 반환
↓
다시 모델 호출
 

문제가 생겼을 때 로그가 없으면 어디서 문제가 발생했는지 알기 어렵습니다.


1-5. import inspect

 
import inspect
 

이 코드에서 매우 중요한 라이브러리입니다.

Python 함수 자체를 분석합니다.

예:

 
def calculator(expr: str) -> str:
 

inspect를 사용하면 프로그램이 자동으로 알아낼 수 있습니다.

함수 이름 → calculator

파라미터 → expr

타입 → str

기본값 → 없음
 

즉:

 
inspect.signature(calculator)
 

를 통해 Python 함수 정보를 자동으로 읽습니다.

이것이 바로 뒤에서 설명할:

Python 함수 → OpenAI Tool Schema 자동 변환

의 핵심입니다.


추가 import

 
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, field
from typing import Any
from dotenv import load_dotenv
from openai import OpenAI
 

1-6. Path

 
from pathlib import Path
 

파일과 폴더를 다룹니다.

예:

 
DATA_DIR = Path("agent_data")
 

그리고:

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

는:

agent_data 폴더 생성
 

입니다.


1-7. datetime

 
from datetime import datetime
 

실행 기록 파일에 시간을 붙이기 위해 사용합니다.

예:

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

결과:

20260830-103015
 

파일명:

run-20260830-103015.json
 

1-8. dataclass

 
from dataclasses import dataclass, field
 

Tool 실행 결과를 일정한 구조로 관리하기 위해 사용합니다.

뒤에서:

 
@dataclass
class ToolResult:
 

로 사용합니다.


1-9. Any

 
from typing import Any
 

여러 종류의 객체가 올 수 있을 때 사용합니다.

예:

 
def call_model(api_input: list) -> Any:
 

OpenAI API Response 객체의 구체적인 타입을 여기서는 제한하지 않는다는 의미입니다.


1-10. load_dotenv

 
from dotenv import load_dotenv
 

.env 파일의 환경변수를 읽습니다.

예를 들어 .env 파일:

OPENAI_API_KEY=sk-...
MODEL_NAME=gpt-4o-mini
MAX_STEPS=10
 

그리고:

 
load_dotenv()
 

하면 프로그램이 환경변수처럼 사용할 수 있습니다.


1-11. OpenAI

 
from openai import OpenAI
 

OpenAI API를 사용하기 위한 공식 Client입니다.


2. Environment settings

 
load_dotenv()
 

프로그램이 시작될 때 .env 파일을 읽습니다.


MODEL_NAME

 
MODEL_NAME = os.environ.get(
    "MODEL_NAME",
    "gpt-4o-mini",
)
 

구조:

환경변수 MODEL_NAME 존재?
        │
       Yes
        ↓
환경변수 값 사용

       No
        ↓
gpt-4o-mini 사용
 

MAX_STEPS

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

Agent가 최대 몇 번 생각하고 행동할지 결정합니다.

예:

Step 1 → lookup
Step 2 → calculator
Step 3 → finish
 

이라면:

3 steps 사용
 

입니다.

무한 루프 방지 장치입니다.


DATA_DIR

 
DATA_DIR = Path("agent_data")
 

실행 기록을 저장하는 폴더입니다.

agent_data/
    run-20260830-100000.json
    run-20260830-103000.json
 

MAX_CONTEXT_BLOCKS

 
MAX_CONTEXT_BLOCKS = int(
    os.environ.get("MAX_CONTEXT_BLOCKS", "8")
)
 

이 부분이 이번 Agent에서 가장 중요한 개선 중 하나입니다.

예전에는:

History 전체
↓
그대로 API 전달
 

이었습니다.

그러면 대화가 길어질수록:

Token 증가
비용 증가
속도 저하
Context overflow 위험
 

이 발생합니다.

그래서 이제:

최근 Context Block 8개만 API에 전달
 

합니다.


3. Logging

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

로그 형식을 지정합니다.

예:

INFO    STEP 1
INFO    tool: lookup
WARNING retryable error
ERROR   model failed
 

그리고:

 
log = logging.getLogger("agent")
 

Agent 전용 Logger를 만듭니다.


4. OpenAI Client

 
client = OpenAI()
 

이제 client가 OpenAI 서버와 통신합니다.

예:

 
client.responses.create(...)
 

5. ToolResult

 
@dataclass
class ToolResult:
 

모든 Tool 결과를 동일한 구조로 관리합니다.

 
@dataclass
class ToolResult:
    ok: bool
    output: str = ""
    error: str | None = None
    meta: dict = field(default_factory=dict)
 

예를 들어 성공:

 
ToolResult(
    ok=True,
    output="168"
)
 

실패:

 
ToolResult(
    ok=False,
    error="ZeroDivisionError"
)
 

이렇게 구조가 통일됩니다.


6. TOOLS

Agent가 사용할 수 있는 실제 기능들입니다.

현재:

calculator
read_file
lookup
word_count
search_documents
 

6.1 calculator

 
def calculator(expr: str) -> str:
 

문자열 형태의 계산식을 받아 계산합니다.

예:

 
calculator("24*7")
 

결과:

168
 

허용 문자

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

허용:

0~9
+
-
*
/
(
)
.
 

공백 제거

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

예:

"24 * 7"
 

"24*7"
 

빈 문자열 검사

 
if not expr:
 

입력이:

""
 

이면 오류입니다.


허용되지 않은 문자 검사

 
if not set(expr) <= allowed:
 

예:

 
calculator("hello")
 

은 실패합니다.


eval

 
result = eval(
    expr,
    {"__builtins__": {}},
    {}
)
 

eval()은 문자열을 Python 코드처럼 실행합니다.

예:

 
eval("24*7")
 

168
 

하지만 일반적인 eval()은 위험할 수 있습니다.

그래서:

 
{"__builtins__": {}}
 

로 Python 기본 기능 접근을 제한합니다.

게다가 앞에서:

 
allowed
 

검사도 합니다.


6.2 read_file

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

로컬 파일을 읽습니다.

예:

 
read_file("contract.txt")
 

Path 객체 생성

 
p = Path(path)
 

파일 존재 확인

 
if not p.exists():
 

실제 파일인지 확인

 
if not p.is_file():
 

폴더를 읽으려고 하면 막습니다.


UTF-8로 읽기

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

길이 제한

 
return text[:max_chars]
 

예:

파일 전체: 100,000자
max_chars: 2,000
 

Agent에게는:

처음 2,000자만 전달
 

합니다.


6.3 lookup

 
def lookup(topic: str) -> str:
 

현재는 간단한 내부 지식 DB입니다.

 
facts = {
    "단가": "$24",
    "배송": "$500 이상 구매 시 무료 배송"
}
 

예:

 
lookup("단가")
 

결과:

$24
 

실제 업무에서는 이것을:

PostgreSQL
ERP
CRM
사내 DB
API
Vector DB
 

등으로 바꿀 수 있습니다.


6.4 word_count

 
def word_count(text: str) -> str:
 

입력 문장의 단어 수를 계산합니다.

 
text.split()
 

예:

AI Agent는 매우 강력하다
 

 
["AI", "Agent는", "매우", "강력하다"]
 

4
 

6.5 search_documents

이 Tool은 앞으로 실제 업무용 Agent로 발전할 때 매우 중요합니다.

현재:

 
documents = [
    {...},
    {...},
    {...}
]
 

라는 샘플 데이터입니다.

하지만 나중에는:

사용자 질문
    ↓
Embedding
    ↓
Vector DB
    ↓
Qdrant
    ↓
관련 문서 검색
    ↓
LLM
 

으로 발전할 수 있습니다.


7. TOOL REGISTRY

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

이것은 Agent의 Tool 목록입니다.

구조:

Tool 이름
    ↓
실제 Python 함수
 

예:

 
TOOLS["calculator"]
 

 
calculator 함수
 

8. Python Type → JSON Schema Type

 
PYTHON_TO_JSON = {
    str: "string",
    int: "integer",
    float: "number",
    bool: "boolean",
}
 

Python과 JSON Schema의 타입 표현이 다르기 때문입니다.

PythonJSON Schema
str string
int integer
float number
bool boolean

예:

 
def calculator(expr: str):
 

 
{
    "expr": {
        "type": "string"
    }
}
 

9. Python Function → OpenAI Tool Schema

이 부분이 이번 코드의 핵심 중 하나입니다.

 
def describe_tool(func) -> dict:
 

역할:

Python 함수
      ↓
자동 분석
      ↓
OpenAI가 이해할 수 있는 Tool Schema 생성
 

실제 예제

Python 함수:

 
def calculator(expr: str) -> str:
 

이 함수는 사람에게는 이해됩니다.

하지만 OpenAI API에게는 다음처럼 설명해야 합니다.

 
{
  "type": "function",
  "name": "calculator",
  "description": "간단한 산술 계산을 수행합니다.",
  "parameters": {
    "type": "object",
    "properties": {
      "expr": {
        "type": "string"
      }
    },
    "required": [
      "expr"
    ]
  }
}
 

describe_tool()이 이것을 자동으로 만듭니다.


함수 Signature 분석

 
sig = inspect.signature(func)
 

예:

 
inspect.signature(calculator)
 

개념적으로:

(expr: str) -> str
 

을 얻습니다.


docstring

 
doc = inspect.getdoc(func)
 

calculator의 설명:

 
"""
간단한 산술 계산을 수행합니다.
예:20*5
"""
 

첫 번째 줄

 
description = doc.strip().splitlines()[0]
 

결과:

간단한 산술 계산을 수행합니다.
 

Parameter 분석

 
for name, param in sig.parameters.items():
 

calculator의 경우:

name = expr
param.annotation = str
 

Python → JSON Type 변환

 
json_type = PYTHON_TO_JSON.get(annotation, "string")
 
str
 ↓
"string"
 

Required 확인

 
if param.default is inspect.Parameter.empty:
 

기본값이 없다면:

 
def calculator(expr: str)
 

expr는 반드시 필요합니다.

 
"required": ["expr"]
 

반면:

 
def read_file(path, max_chars=2000)
 

max_chars는 선택사항입니다.


10. 모든 Tool Schema 생성

 
OPENAI_TOOLS = [
    describe_tool(func)
    for func in TOOLS.values()
]
 

결과적으로:

calculator schema
read_file schema
lookup schema
word_count schema
search_documents schema
 

가 만들어집니다.

개념적으로:

 
OPENAI_TOOLS = [
    {...calculator schema...},
    {...read_file schema...},
    {...lookup schema...},
]
 

그리고 API 호출 시:

 
tools=OPENAI_TOOLS
 

로 모델에게 전달합니다.


11. SYSTEM PROMPT

 
SYSTEM_PROMPT = """
당신은 신중하고 정확한 업무용 AI Agent입니다.
...
"""
 

예전 코드와 가장 큰 차이점:

예전에는:

반드시 JSON으로 응답하세요.

{"tool":"calculator", "args": {...}}
 

를 강제했습니다.

하지만 지금은:

OpenAI 정식 Tool Calling
 

을 사용하므로 모델이 Tool을 호출할 때 별도의 구조화된 function_call을 생성합니다.

즉:

System Prompt → 행동 규칙
Tool Schema → 사용할 수 있는 능력
 

으로 역할이 분리되었습니다.


12. INTERNAL HISTORY TRIM

 
def trim_history(history, max_messages=30):
 

이것은 내부 기록 관리용입니다.

전체 History:

system
user
assistant
tool
assistant
tool
assistant
tool
...
 

너무 길어질 수 있습니다.

그래서 최근 것만 남깁니다.

다만:

 
system_messages = [
    m for m in history
    if m.get("role") == "system"
]
 

System Prompt는 유지합니다.

중요한 점:

이 함수는 현재 코드에서 실제 API 호출 Context를 자르는 핵심 함수가 아닙니다.

진짜 API Context 관리는 다음 13번이 담당합니다.


13. API CONTEXT BLOCK TRIM

 
def build_api_input(
    initial_user_message,
    context_blocks,
    max_blocks,
):
 

이것이 이번 코드의 가장 중요한 구조 개선입니다.


왜 Block이 필요한가?

단순하게 메시지를 자르면 위험합니다.

예:

Assistant:
calculator를 호출하겠습니다.

Function Call:
calculator({"expr":"24*7"})

Tool Result:
168
 

만약 중간에서 Trim하면:

Tool Result:
168
 

만 남고:

어떤 Tool의 결과인지
왜 실행했는지
어떤 계산이었는지
 

를 잃을 수 있습니다.


그래서 하나의 작업 단위를:

┌──────────────────────────────┐
│ Context Block               │
│                              │
│ response.output             │
│   └ function_call            │
│                              │
│ function_call_output         │
└──────────────────────────────┘
 

으로 묶습니다.

예:

Block 1
 ├─ 모델 응답
 │   └─ lookup("단가")
 │
 └─ Tool 결과
     └─ "$24"
 

다음:

Block 2
 ├─ 모델 응답
 │   └─ calculator("24*7")
 │
 └─ Tool 결과
     └─ "168"
 

이렇게 하면 오래된 Block 전체를 제거할 수 있습니다.

Block 1 삭제
Block 2 유지
Block 3 유지
 

Tool Call과 Tool Output이 분리되지 않습니다.


14. 중복 Tool 호출 확인

 
def is_repeat_tool_call(...)
 

예:

lookup("단가")
 

를 이미 호출했는데 모델이 또:

lookup("단가")
 

를 요청하면 중복입니다.

그래서:

 
signature = (
    tool_name
    + ":"
    + json.dumps(args)
)
 

예:

lookup:{"topic":"단가"}
 

를 만들어 Set에 저장합니다.

 
recent_calls.add(signature)
 

다음에 같은 호출이 들어오면:

중복 감지
 

합니다.


15. TOOL DISPATCHER

 
def dispatch_tool(
    tools,
    name,
    args
) -> ToolResult:
 

이 함수는 Tool 실행 관리자입니다.

실제 Tool을 바로 실행하지 않고 먼저 검증합니다.


1단계 Tool 존재 확인

 
if name not in tools:
 

예:

모델 요청:

delete_database()
 

하지만 등록된 Tool에 없으면 실행하지 않습니다.


2단계 args 타입 확인

 
if not isinstance(args, dict):
 

정상:

 
{
    "expr": "24*7"
}
 

비정상:

 
["24*7"]
 

3단계 예상하지 않은 Argument

예:

Tool:

 
def calculator(expr):
 

그런데 모델이:

 
{
    "expr": "24*7",
    "currency": "USD"
}
 

를 보내면:

currency는 예상하지 못한 argument
 

입니다.


4단계 필수 argument 확인

예:

 
calculator()
 

은 expr이 없으므로 실행하면 안 됩니다.


5단계 실제 실행

 
output = func(**args)
 

예:

 
func = calculator
args = {
    "expr": "24*7"
}
 

결과:

 
calculator(expr="24*7")
 

와 같습니다.


16. Retry 가능한 Error 확인

 
def is_retryable_error(error):
 

모든 오류를 Retry하면 안 됩니다.

예:

API Key 오류
잘못된 요청
모델 이름 오류
 

이런 것은 다시 해도 실패합니다.

하지만:

RateLimitError
APIConnectionError
APITimeoutError
InternalServerError
 

는 일시적인 문제일 수 있습니다.

그래서 Retry합니다.


17. MODEL CALL

 
def call_model(api_input):
 

Agent의 두뇌 호출 부분입니다.


Retry 횟수

 
RETRY_DELAYS = [1, 2, 4]
 
 
total_attempts = len(RETRY_DELAYS) + 1
 

즉:

첫 번째 시도
↓ 실패
1초 대기

두 번째 시도
↓ 실패
2초 대기

세 번째 시도
↓ 실패
4초 대기

네 번째 시도
↓ 실패
최종 실패
 

OpenAI 호출

 
response = client.responses.create(
    model=MODEL_NAME,
    instructions=SYSTEM_PROMPT,
    input=api_input,
    tools=OPENAI_TOOLS,
)
 

여기서 중요한 세 가지:

instructions

Agent의 행동 규칙
 

input

현재까지 필요한 Context
 

tools

모델이 사용할 수 있는 능력
 

18. AGENT

이제 전체 시스템을 실제로 운영하는 부분입니다.


18.1 __init__

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

Agent가 생성될 때 필요한 상태를 준비합니다.


tools

 
self.tools = tools
 

Tool Registry 저장.


steps

 
self.steps = 0
 

현재 몇 단계 실행했는지 기록합니다.


history

 
self.history = []
 

전체 실행 기록입니다.

예:

system
user
assistant
tool
assistant
tool
assistant
 

recent_tool_calls

 
self.recent_tool_calls = set()
 

중복 Tool 호출 방지용입니다.


api_context_blocks

 
self.api_context_blocks = []
 

이것이 실제 API Context를 관리하는 핵심입니다.

구조:

[
    Block1,
    Block2,
    Block3
]
 

18.2 __repr__

 
def __repr__(self):
 

Agent 객체를 출력할 때 보기 쉽게 만듭니다.

예:

Agent(
    steps=3/10,
    msgs=7,
    api_blocks=2
)
 

18.3 add_history

 
def add_history(self, role, content, **extra):
 

내부 History에 기록합니다.

예:

 
self.add_history(
    "tool",
    "168",
    tool_name="calculator",
    args={"expr":"24*7"},
    ok=True
)
 

저장 결과:

 
{
    "role": "tool",
    "content": "168",
    "tool_name": "calculator",
    "args": {
        "expr": "24*7"
    },
    "ok": True
}
 

18.4 run

이제 실제 Agent가 움직입니다.


18.4.1 초기화

 
self.history = []
self.steps = 0
self.answer = None
self.recent_tool_calls.clear()
self.api_context_blocks = []
 

새로운 작업 시작입니다.


System History

 
self.add_history("system", SYSTEM_PROMPT)
 

내부 기록에 System Prompt 저장.


User Goal

 
self.add_history("user", goal)
 

사용자 질문 저장.


최초 API 메시지

 
initial_user_message = {
    "role": "user",
    "content": goal,
}
 

첫 번째 API 호출의 출발점입니다.


Agent Loop

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

계속 반복합니다.

조건:

아직 답이 없음
AND
최대 단계 미만
 

STEP 증가

 
self.steps += 1
 

예:

STEP 1
STEP 2
STEP 3
 

실제 API Input 생성

 
api_input = build_api_input(
    initial_user_message,
    self.api_context_blocks,
    MAX_CONTEXT_BLOCKS,
)
 

예를 들어:

Initial User Message

Block 1
Block 2
Block 3
Block 4
Block 5
Block 6
Block 7
Block 8
Block 9
Block 10
 

인데 최대:

8 Blocks
 

라면 API에는:

Initial User Message
Block 3
Block 4
Block 5
Block 6
Block 7
Block 8
Block 9
Block 10
 

만 전달합니다.

이것이 진짜 API Context Trim입니다.


Model Call

 
response = call_model(api_input)
 

모델이 두 가지 중 하나를 합니다.


경우 1: Tool 필요 없음

예:

사용자:
안녕하세요
 

모델:

안녕하세요. 무엇을 도와드릴까요?
 

이 경우:

 
function_calls = []
 

그래서:

 
self.answer = response.output_text
 

으로 끝납니다.


경우 2: Tool 필요

사용자:

제품 7개의 가격을 계산해줘
 

모델:

먼저 단가를 알아야 한다.
 

그래서:

function_call
 

을 생성합니다.

예:

lookup
arguments:
{
    "topic": "단가"
}
 

1. response.output에는 무엇이 들어오는가?

예를 들어 모델이 Tool을 호출하면 개념적으로:

response.output
[
    reasoning item,
    function_call item
]
 

또는 일반적으로:

[
    {
        type: "function_call",
        name: "lookup",
        arguments: "{\"topic\":\"단가\"}",
        call_id: "call_abc123"
    }
]
 

핵심:

response.output
 

에는 모델이 이번 턴에 만든 구조화된 출력들이 들어 있습니다.

그중:

 
item.type == "function_call"
 

인 것을 찾습니다.


2. function_call과 function_call_output의 연결

매우 중요합니다.

모델:

function_call
 

생성:

call_id = "call_123"

name = "calculator"

arguments = {
    "expr": "24*7"
}
 

Python:

 
calculator("24*7")
 

168
 

그리고 API에:

 
{
    "type": "function_call_output",
    "call_id": "call_123",
    "output": "168"
}
 

를 보냅니다.

즉:

function_call
       │
       │ call_id = call_123
       ↓
Python Tool 실행
       ↓
function_call_output
       │
       │ call_id = call_123
       ↓
모델이 "이 결과가 내가 요청한 Tool 결과"라고 이해
 

이 연결이 call_id입니다.


Tool Arguments Parsing

 
args = json.loads(call.arguments)
 

예:

'{"expr":"24*7"}'
 

 
{
    "expr": "24*7"
}
 

중복 Tool 검사

 
if is_repeat_tool_call(...):
 

같은 Tool:

lookup("단가")
 

를 계속 반복하면 막습니다.


실제 Tool 실행

 
result = dispatch_tool(
    self.tools,
    tool_name,
    args
)
 

Tool Result → Text

성공:

 
output_text = result.output
 

실패:

 
output_text = "ERROR: ..."
 

Tool Output 길이 제한

 
if len(output_text) > MAX_TOOL_OUTPUT_CHARS:
 

긴 문서를 그대로 모델에 넣으면:

비용 증가
Context 증가
응답 속도 저하
 

가 발생합니다.

그래서:

4000자
 

로 제한합니다.


내부 History 저장

 
self.add_history(
    "assistant",
    f"Function call:{tool_name}({args})"
)
 

그리고:

 
self.add_history(
    "tool",
    output_text
)
 

이것은 사람이 나중에 실행 기록을 볼 수 있게 하기 위한 것입니다.


OpenAI API Tool Output 생성

 
tool_outputs.append({
    "type": "function_call_output",
    "call_id": call.call_id,
    "output": output_text,
})
 

이것이 정식 Tool Calling의 핵심입니다.


Context Block 생성

 
context_block = []
 

먼저:

 
context_block.extend(response.output)
 

모델의 Tool 요청을 넣습니다.

그리고:

 
context_block.extend(tool_outputs)
 

Tool 결과를 넣습니다.

결과:

Context Block

response.output
   ↓
function_call
   ↓
function_call_output
 

이것을 하나로 묶습니다.


왜 Context Block이 필요한가?

예를 들어:

Block 1

function_call
lookup("단가")

function_call_output
"$24"
 

그리고:

Block 2

function_call
calculator("24*7")

function_call_output
"168"
 

가 있습니다.

Context를 줄일 때:

Block 1 전체 삭제
Block 2 유지
 

할 수 있습니다.

하지만 메시지를 개별적으로 자르면:

function_call만 삭제
function_call_output만 남음
 

같은 문제가 생길 수 있습니다.

그래서 Block 구조가 필요합니다.

이것은 현재 Agent 구조에서 아주 좋은 설계입니다.


18.4.3 Step Limit

 
if self.answer is None:
 

최대 단계까지 갔는데 답을 못 얻으면:

최대 단계에 도달했습니다.
 

로 종료합니다.

무한 Agent Loop를 막는 중요한 안전장치입니다.


18.5 save

 
def save(self):
 

실행 기록을 파일로 저장합니다.

저장 데이터:

 
data = {
    "model": MODEL_NAME,
    "steps": self.steps,
    "answer": self.answer,
    "history": self.history,
}
 

예:

 
{
    "model": "gpt-4o-mini",
    "steps": 3,
    "answer": "7개의 가격은 168달러입니다.",
    "history": [...]
}
 

19. MAIN

 
if __name__ == "__main__":
 

이 파일을 직접 실행했을 때만 아래 코드가 실행됩니다.


Agent 생성

 
bot = Agent(TOOLS)
 

Agent에게 Tool들을 제공합니다.

Agent
 │
 ├── calculator
 ├── read_file
 ├── lookup
 ├── word_count
 └── search_documents
 

Agent 실행

 
result = bot.run(...)
 

질문:

단가를 확인해서 제품 7개의 총 가격을 계산해 주세요.
 

실제 전체 실행 흐름도

이 질문이 들어왔다고 가정해 보겠습니다.

"단가를 확인해서 제품 7개의 총 가격을 계산해 주세요."
 

STEP 0: Agent 시작

Agent 생성
    ↓
상태 초기화
    ↓
System Prompt 준비
    ↓
User Goal 준비
 

STEP 1

API Input:

User:
단가를 확인해서 제품 7개의 총 가격을 계산해 주세요.
 

모델 판단:

단가 정보가 없다.
lookup Tool 사용해야 한다.
 

모델 생성:

function_call

name:
lookup

arguments:
{"topic":"단가"}

call_id:
call_001
 

Python:

 
lookup("단가")
 

결과:

$24
 

Python:

function_call_output

call_id:
call_001

output:
$24
 

Context Block 1 저장:

Block 1
 ├─ function_call lookup
 └─ function_call_output $24
 

STEP 2

API Input:

User Goal

Block 1
 ├ lookup("단가")
 └ "$24"
 

모델 판단:

단가 = $24
수량 = 7

계산 필요
calculator 사용
 

function_call:

calculator

{
    "expr": "24*7"
}
 

Python:

 
calculator("24*7")
 

결과:

168
 

function_call_output:

168
 

Block 2 저장:

Block 2
 ├─ calculator("24*7")
 └─ 168
 

STEP 3

API Input:

User Goal

Block 1
lookup → $24

Block 2
calculator → 168
 

모델 판단:

정보 충분
Tool 필요 없음
최종 답변
 

모델:

제품 7개의 총 가격은 168달러입니다.
 

Agent 종료


전체 그림

┌─────────────────────────────┐
│           USER              │
│ "제품 7개의 가격 계산"       │
└──────────────┬──────────────┘
               │
               ▼
┌─────────────────────────────┐
│         Agent.run()         │
└──────────────┬──────────────┘
               │
               ▼
       ┌───────────────┐
       │ API Context   │
       │ 최근 Blocks   │
       └───────┬───────┘
               │
               ▼
       ┌───────────────┐
       │ OpenAI Model  │
       └───────┬───────┘
               │
        ┌──────┴──────┐
        │             │
        ▼             ▼
   Tool 필요       Tool 불필요
        │             │
        ▼             ▼
 function_call    최종 답변
        │
        ▼
 Python Tool
        │
        ▼
 ToolResult
        │
        ▼
function_call_output
        │
        ▼
 Context Block 생성
        │
        └───────────────┐
                        │
                        ▼
                 다음 Model Call
 

이 코드에서 특히 중요한 구조 5가지

① Tool Schema 자동 생성

Python 함수
    ↓ inspect
함수명 / parameter / type / default
    ↓
JSON Schema
    ↓
OpenAI Tool 등록
 

② 정식 Tool Calling

예전:

모델에게 JSON 문자열을 잘 만들어 달라고 부탁
 

현재:

OpenAI API의 공식 function_call 구조 사용
 

훨씬 안정적입니다.


③ call_id 연결

function_call
    │
    │ call_id
    ▼
Python Tool 실행
    │
    ▼
function_call_output
    │
    │ 동일한 call_id
    ▼
모델
 

④ Context Block

Model Response
+
Function Call
+
Tool Output
 

을 하나의 작업 단위로 관리합니다.

그래서 Context Trim 시 관계가 깨지지 않습니다.


⑤ 실제 업무용 Agent의 기본 구조

현재 구조는 앞으로 다음과 같이 발전시키기 좋은 형태입니다.

현재
│
├── lookup
├── calculator
├── read_file
└── search_documents
        │
        ▼
다음 단계
│
├── PostgreSQL
├── Qdrant
├── 사내 문서
├── CRM
├── ERP
├── 이메일
├── 웹 검색
├── 한컴 문서 자동화
└── 업무 Workflow
 

마지막으로, 현재 코드에서 제가 가장 중요하게 보는 부분

지금 코드의 핵심적인 발전은 단순한 챗봇이 아니라 Agent Loop의 기본 골격을 제대로 갖추기 시작했다는 것입니다.

특히:

Model
  ↓ 판단
Tool Call
  ↓
Python 실행
  ↓
Tool Output
  ↓
Model 재판단
 

이라는 구조를 갖췄고,

여기에:

Retry
Error Handling
Tool Validation
Duplicate Tool Call 방지
Output 길이 제한
Context Block
Context Trim
Transcript 저장
 

까지 들어왔습니다.

이제 다음 단계에서는 이 구조를 더욱 실제 업무용으로 발전시키는 것이 좋습니다. 특히 현재 Agent에 가장 먼저 추가할 만한 것은 Resume(이전 작업 이어가기), Tool 권한/보안 정책, 작업 계획(Planning), 그리고 실제 사내 문서·DB 연결입니다.

반응형