관리 메뉴

아톨러브

V. AI Agent의 핵심 부품들 하나씩 만들어보기 본문

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

V. AI Agent의 핵심 부품들 하나씩 만들어보기

아톨 2026. 8. 23. 11:25
반응형

 

    #-------- 1. Tool 실행과 안전장치

    TOOLS = {"calculator": lambda a: eval_expr(a.get("expr",""))}

 

    def eval_expr(expr):

        return f"{expr} 결과값"

 

    def run_tool(name, args, steps, max_steps):

        if steps >= max_steps:

            return "중지되었습니다. 단계 제한에 도달하였습니다."

        if name not in TOOLS:

            return f"중지되었습니다. {name}이라는 툴이 없습니다."

        if not isinstance(args, dict):

            return "중지되었습니다. Arguments 딕셔너리어야 합니다."

        if not args:

            return "중지되었습니다. Arguments 없습니다."

        return TOOLS[name](args)

 

    print(run_tool("calculator", {"expr":"9*9"}, steps=1, max_steps=5))

    print(run_tool("emailer", {"to":"x"}, steps=1, max_steps=5))

    print(run_tool("calculator", {}, steps=1, max_steps=5))

    print(run_tool("calculator", {"expr":1}, steps=5, max_steps=5))

    #9*9 결과값

    # 중지되었습니다. emailer이라는 툴이 없습니다.

    # 중지되었습니다. Arguments 없습니다.

    # 중지되었습니다. 단계 제한에 도달하였습니다.

 

    #-------- 2. LLM 종료 이유(stop_reason) 처리

    def describe(stop_reason):

        match stop_reason:

            case "end_turn":

                return "종료되었습니다."

            case "tool_use":

                return "툴을 원합니다."

            case "max_tokens" | "length":

                return "값이 잘렸습니다."

            case _:

                return f"unknown ({stop_reason})"

 

    for reason in ["end_turn", "tool_use", "length", "filtered"]:

        print(reason, "->", describe(reason))

 

    #-------- 3. 기본값 처리와 현재 상태 판단

    temperature = None

    steps = 4

    max_steps = 5

 

    temp = temperature if temperature is not None else 0.7

    status = "last step" if steps == max_steps-1 else "in progress"

 

    print(f"temperature = {temp} status = {status}")

 

    #-------- 4. 간단한 규칙 기반 Tool Router

    TOOLS = {"calculator", "search", "read_file", "finish"}

    MATH_CHARS = set("0123456789+-*/")

 

    def route(message):

        text = message.strip().lower()

 

        if not text:

            return {"tool":None, "why":"메시지가 없습니다."}

        if text in ("done", "thanks", "that is all"):

            return {"tool":"finish", "why":"사용자가 완료 신호를 보냈습니다."}

        if ".txt" in text or "file" in text:

            return {"tool":"read_file", "why":"file 언급하였습니다."}

 

        digits = sum(1 for c in text if c in MATH_CHARS)

        if digits >=3:

            return {"tool":"calculator", "why":"수학계산처럼 보입니다."}

 

        return {"tool":"search", "why":"디폴트가 lookup입니다."}

 

    messages = [

        "what is 150*13",

        "notes.txt 파일을 열고, 그것을 요약하세요.",

        "누가 회사를 발견했어요",

        "done",

        "",

    ]

 

    for m in messages:

        decision = route(m)

        tool = decision["tool"] or "none"

        print(f"{m.strip()[:32]:<40} -> {tool:<11}({decision['why']})")

1. 첫 번째 코드: Tool 실행과 안전장치

 
TOOLS = {"calculator": lambda a: eval_expr(a.get("expr",""))}

def eval_expr(expr):
    return f"{expr}의 결과값"
 

먼저 이 부분부터 보겠습니다.


1-1. TOOLS는 무엇인가?

 
TOOLS = {
    "calculator": lambda a: eval_expr(a.get("expr",""))
}
 

TOOLS는 사용 가능한 Tool들을 모아놓은 사전(Dictionary) 입니다.

구조는 이렇게 생각하면 됩니다.

TOOLS
│
└── calculator
      │
      └── 실행할 함수
 

즉:

 
TOOLS["calculator"]
 

를 실행하면 계산기 역할을 하는 함수가 실행됩니다.


1-2. lambda가 조금 복잡해 보이는데

이 코드:

 
lambda a: eval_expr(a.get("expr",""))
 

를 일반 함수로 바꾸면 훨씬 이해하기 쉽습니다.

 
def calculator_tool(a):
    return eval_expr(a.get("expr", ""))
 

그러면:

 
TOOLS = {
    "calculator": calculator_tool
}
 

와 거의 같은 의미입니다.

즉 전체를 풀어쓰면:

 
def calculator_tool(args):
    expr = args.get("expr", "")
    return eval_expr(expr)

TOOLS = {
    "calculator": calculator_tool
}
 

입니다.


2. eval_expr()

 
def eval_expr(expr):
    return f"{expr}의 결과값"
 

이 함수는 실제 계산을 하는 것이 아닙니다.

예를 들어:

 
eval_expr("9*9")
 

를 실행하면:

9*9의 결과값
 

을 반환합니다.

즉 지금은 계산기 Tool의 동작을 흉내 내는 예제입니다.

나중에는 실제로:

 
eval()
 

같은 것을 사용할 수도 있지만, 실제 서비스에서는 eval() 사용에 보안 문제가 있으므로 주의해야 합니다.


3. run_tool() — Tool 실행 관리자

 
def run_tool(name, args, steps, max_steps):
 

이 함수는 Agent 입장에서 보면:

LLM이 요청한 Tool을 실제로 실행하기 전에 여러 가지 안전 검사를 하는 관리자

입니다.

구조는:

LLM
 ↓
"calculator를 사용하세요"
 ↓
run_tool()
 ↓
① Step 제한 확인
② Tool 존재 확인
③ Arguments 형식 확인
④ Arguments 존재 확인
 ↓
실제 Tool 실행
 

입니다.


3-1. Step 제한 확인

 
if steps >= max_steps:
    return "중지되었습니다. 단계 제한에 도달하였습니다."
 

예를 들어:

 
steps = 5
max_steps = 5
 

라면:

5 >= 5
 

이므로 True입니다.

Agent가 계속:

LLM
 ↓
Tool
 ↓
LLM
 ↓
Tool
 ↓
LLM
 ↓
Tool
 

무한 반복하는 것을 막기 위한 장치입니다.

이전 코드에서 배운:

 
MAX_STEPS = 6
 

와 정확히 같은 개념입니다.


4. Tool이 존재하는지 확인

 
if name not in TOOLS:
    return f"중지되었습니다. {name}이라는 툴이 없습니다."
 

예를 들어:

 
name = "emailer"
 

인데:

 
TOOLS = {
    "calculator": ...
}
 

밖에 없다면:

emailer not in TOOLS
 

입니다.

따라서:

중지되었습니다. emailer이라는 툴이 없습니다.
 

를 반환합니다.

이것은 매우 중요한 안전장치입니다.

LLM이 가끔 존재하지 않는 Tool을 만들어낼 수도 있기 때문입니다.

예:

send_email
google_search
database_query
super_calculator
 

Agent 입장에서는:

"내가 실제로 가지고 있는 Tool인지 확인해야 한다."

는 것입니다.


5. Arguments가 Dictionary인지 확인

 
if not isinstance(args, dict):
    return "중지되었습니다. Arguments는 딕셔너리어야 합니다."
 

isinstance()는:

이 객체가 특정 타입인가?

를 검사합니다.

예:

 
isinstance({"expr":"9*9"}, dict)
 

결과:

True
 

하지만:

 
isinstance("9*9", dict)
 

결과:

False
 

입니다.

즉 Agent가 예상하는:

 
{
    "expr": "9*9"
}
 

형태가 아니라:

 
"9*9"
 

같은 형태가 들어오면 실행하지 않습니다.


6. Arguments가 비어있는지 확인

 
if not args:
    return "중지되었습니다. Arguments가 없습니다."
 

예:

 
args = {}
 

빈 dictionary입니다.

Python에서 빈 객체는 대부분 False처럼 취급됩니다.

{}       → False
[]       → False
""       → False
None     → False
 

따라서:

 
if not args:
 

는:

Arguments가 비어있으면

이라는 의미입니다.


7. 실제 Tool 실행

모든 검사를 통과하면:

 
return TOOLS[name](args)
 

를 실행합니다.

예:

 
name = "calculator"

args = {
    "expr": "9*9"
}
 

그러면:

 
TOOLS["calculator"](args)
 

입니다.

TOOLS["calculator"]에는:

 
lambda a: eval_expr(a.get("expr",""))
 

가 들어 있습니다.

따라서:

 
lambda a: eval_expr(a.get("expr",""))({"expr":"9*9"})
 

 
eval_expr("9*9")
 

9*9의 결과값
 

이 됩니다.


8. 전체 실행 흐름

 
print(run_tool(
    "calculator",
    {"expr":"9*9"},
    steps=1,
    max_steps=5
))
 

흐름:

run_tool()
   │
   ├─ steps >= max_steps?
   │      1 >= 5 → False
   │
   ├─ calculator in TOOLS?
   │      True
   │
   ├─ args는 dict?
   │      True
   │
   ├─ args가 비었는가?
   │      False
   │
   └─ calculator 실행
          ↓
       eval_expr("9*9")
          ↓
       "9*9의 결과값"
 

9. 두 번째 코드: match / case

 
def describe(stop_reason):
    match stop_reason:
 

이것은 Python의 match-case 문법입니다.

Java의 switch와 비슷하다고 생각하면 됩니다.


기존 방식

예전 방식은:

 
if stop_reason == "end_turn":
    ...
elif stop_reason == "tool_use":
    ...
elif stop_reason == "max_tokens":
    ...
 

였습니다.


match-case 방식

 
match stop_reason:
    case "end_turn":
        return "종료되었습니다."
 

의 의미:

stop_reason 값이 "end_turn"이면 실행해라.


"max_tokens" | "length"

 
case "max_tokens" | "length":
    return "값이 잘렸습니다."
 

이것은:

"max_tokens" 또는 "length" 둘 중 하나면

이라는 의미입니다.

즉:

max_tokens
     OR
length
 

입니다.


_

 
case _:
    return f"unknown ({stop_reason})"
 

_는:

위의 어느 경우에도 해당하지 않으면

입니다.

즉 default입니다.

Java의:

 
default:
 

와 비슷합니다.


결과

 
for reason in ["end_turn", "tool_use", "length", "filtered"]:
 

실행하면:

end_turn  -> 종료되었습니다.
tool_use  -> 툴을 원합니다.
length    -> 값이 잘렸습니다.
filtered  -> unknown (filtered)
 

가 됩니다.


10. Agent에서 stop_reason은 왜 중요한가?

LLM의 응답을 받았을 때 Agent는:

LLM 응답
    ↓
stop_reason 확인
 

을 해야 합니다.

예:

end_turn
    ↓
정상적으로 답변 종료

tool_use
    ↓
Tool 실행 필요

max_tokens
    ↓
응답이 너무 길어서 잘림

filtered
    ↓
특별한 처리 필요
 

입니다.

따라서 Agent는 stop_reason에 따라 행동을 바꿔야 합니다.


11. 세 번째 코드: 기본값 처리

 
temperature = None
steps = 4
max_steps = 5
 

여기서:

 
temperature = None
 

은:

아직 temperature 값이 지정되지 않았다.

는 의미입니다.


삼항 연산자

 
temp = temperature if temperature is not None else 0.7
 

처음 보면 조금 복잡합니다.

일반적인 if문으로 바꾸면:

 
if temperature is not None:
    temp = temperature
else:
    temp = 0.7
 

입니다.

즉:

temperature가 있으면 그것을 사용하고, 없으면 0.7을 사용해라.

현재:

 
temperature = None
 

이므로:

 
temp = 0.7
 

이 됩니다.


12. 왜 is not None을 사용하는가?

이 부분은 중요합니다.

만약:

 
if temperature:
 

라고 하면:

 
temperature = 0
 

인 경우도 False로 판단됩니다.

하지만:

 
temperature = 0
 

은 실제 값입니다.

따라서:

 
if temperature is not None:
 

이라고 하면:

0       → 값 있음
0.5     → 값 있음
1.0     → 값 있음
None    → 값 없음
 

을 정확하게 구분할 수 있습니다.


13. 현재가 마지막 Step인가?

 
status = "last step" if steps == max_steps-1 else "in progress"
 

현재:

 
steps = 4
max_steps = 5
 

입니다.

따라서:

max_steps - 1
=
5 - 1
=
4
 

그리고:

steps == 4
 

이므로:

4 == 4
 

True입니다.

그래서:

 
status = "last step"
 

이 됩니다.


왜 max_steps가 아니라 max_steps - 1일까?

Agent의 Step을:

Step 0
Step 1
Step 2
Step 3
Step 4
 

로 사용한다고 가정하면 총 5번입니다.

마지막 Step은:

max_steps - 1
 

입니다.

즉:

0부터 시작하는 index
 

개념과 관련이 있습니다.


14. 네 번째 코드: Tool Router

이 부분이 상당히 재미있습니다.

 
TOOLS = {"calculator", "search", "read_file", "finish"}
 

이번에는 Dictionary가 아니라 Set입니다.

왜냐하면:

 
TOOLS["calculator"]
 

처럼 함수를 실행하는 것이 아니라:

어떤 Tool들이 존재하는지 목록만 관리

하기 때문입니다.


15. MATH_CHARS

 
MATH_CHARS = set("0123456789+-*/")
 

결과는 대략:

{
    '0','1','2','3','4','5','6','7','8','9',
    '+','-','*','/'
}
 

입니다.

문자열의 문자들을 Set으로 만드는 것입니다.


16. route() 함수

 
def route(message):
 

이 함수의 역할은:

사용자 메시지를 보고 어떤 Tool을 사용할지 결정

하는 것입니다.

즉 간단한 Tool Router입니다.

실제 LLM 대신 아주 단순한 규칙을 사용합니다.


17. 문자열 정리

 
text = message.strip().lower()
 

두 가지를 합니다.

.strip()

앞뒤 공백 제거

"   Hello   "
 

"Hello"
 

.lower()

소문자로 변환

HELLO
 

hello
 

따라서:

" DONE "
 

"done"
 

으로 만들어서 비교하기 쉬워집니다.


18. 빈 메시지

 
if not text:
    return {
        "tool":None,
        "why":"메시지가 없습니다."
    }
 

사용자가:

""
 

를 입력하거나:

"     "
 

처럼 공백만 입력하면:

tool = None
 

으로 처리합니다.


19. 완료 신호 감지

 
if text in ("done", "thanks", "that is all"):
 

text가 다음 중 하나면:

done
thanks
that is all
 

작업이 끝났다고 판단합니다.

그리고:

 
return {
    "tool":"finish",
    "why":"사용자가 완료 신호를 보냈습니다."
}
 

를 반환합니다.

즉:

사용자
 ↓
done
 ↓
Router
 ↓
finish Tool
 

입니다.


20. 파일 관련 메시지 감지

 
if ".txt" in text or "file" in text:
 

예:

notes.txt 파일을 열어줘
 

에는:

.txt
 

가 들어 있습니다.

따라서:

 
{
    "tool":"read_file"
}
 

를 반환합니다.


21. 수학 문자 개수 세기

 
digits = sum(
    1 for c in text
    if c in MATH_CHARS
)
 

이 코드는 처음 보면 어렵습니다.

풀어쓰면:

 
digits = 0

for c in text:
    if c in MATH_CHARS:
        digits += 1
 

입니다.

즉 메시지 안에:

숫자
+
-
*
/
 

같은 문자가 몇 개 있는지 세는 것입니다.


22. "what is 150*13" 분석

문자 중:

1
5
0
*
1
3
 

은 MATH_CHARS에 포함됩니다.

따라서 최소 6개 정도가 계산됩니다.

그리고:

 
if digits >= 3:
 

조건을 만족합니다.

따라서:

 
{
    "tool":"calculator",
    "why":"수학계산처럼 보입니다."
}
 

가 됩니다.


23. 기본값은 search

위 조건에 모두 해당하지 않으면:

 
return {
    "tool":"search",
    "why":"디폴트가 lookup입니다."
}
 

입니다.

즉:

계산도 아니고
파일도 아니고
완료도 아니고
빈 메시지도 아니다
        ↓
search
 

입니다.


24. Router 전체 구조

이 코드를 그림으로 보면:

사용자 메시지
      ↓
 ┌───────────────┐
 │ 빈 메시지인가? │
 └───────┬───────┘
         │ Yes
         ↓
       None

         No
         ↓

 ┌────────────────┐
 │ 완료 신호인가? │
 └────────┬───────┘
          │ Yes
          ↓
        finish

          No
          ↓

 ┌────────────────┐
 │ 파일을 언급?   │
 └────────┬───────┘
          │ Yes
          ↓
       read_file

          No
          ↓

 ┌────────────────┐
 │ 계산처럼 보임? │
 └────────┬───────┘
          │ Yes
          ↓
      calculator

          No
          ↓
        search
 

이것이 바로 Rule-based Router입니다.


25. 마지막 출력 코드

 
for m in messages:
    decision = route(m)
 

각 메시지를 Router에 넣습니다.

예:

 
m = "what is 150*13"
 

 
decision = {
    "tool":"calculator",
    "why":"수학계산처럼 보입니다."
}
 

or "none"

 
tool = decision["tool"] or "none"
 

만약:

 
decision["tool"] = None
 

이면:

None or "none"
 

이므로:

"none"
 

을 사용합니다.

즉 화면 출력용 기본값입니다.


26. 출력 포맷

 
print(
    f"{m.strip()[:32]:<40} -> {tool:<11}({decision['why']})"
)
 

이것도 지금까지 배운 f-string 정렬 기능입니다.

m.strip()[:32]

문자열:

  1. 앞뒤 공백 제거
  2. 앞에서 32글자만 사용

:<40

40칸의 공간을 확보하고 왼쪽 정렬합니다.

메시지                                   -> calculator
 

이렇게 보기 좋게 정렬됩니다.


27. 예상 결과

대략 이렇게 나옵니다.

what is 150*13                           -> calculator (수학계산처럼 보입니다.)

notes.txt 파일을 열고, 그것을 요약하세요.       -> read_file  (file을 언급하였습니다.)

누가 그 회사를 발견했어요                        -> search     (디폴트가 lookup입니다.)

done                                     -> finish     (사용자가 완료 신호를 보냈습니다.)

                                         -> none       (메시지가 없습니다.)
 

28. 이번 코드에서 가장 중요한 개념

이번 코드는 사실 Agent의 내부 구조를 아주 작은 단위로 나눈 것입니다.

┌──────────────────┐
│     User Input   │
└────────┬─────────┘
         ↓
┌──────────────────┐
│   Tool Router    │
│ route(message)   │
└────────┬─────────┘
         ↓
    calculator?
    search?
    read_file?
         ↓
┌──────────────────┐
│    run_tool()    │
│                  │
│ Step 확인        │
│ Tool 존재 확인   │
│ Args 확인        │
└────────┬─────────┘
         ↓
┌──────────────────┐
│   실제 Tool 실행  │
└────────┬─────────┘
 

🌟 지금까지 배운 Agent 코드와 연결하면

지금까지 공부하신 코드들을 하나로 합치면 Agent는 이렇게 동작합니다.

사용자 질문
    ↓
history에 저장
    ↓
System Prompt + Tools
    ↓
LLM 호출
    ↓
응답 분석
    │
    ├── end_turn
    │      ↓
    │   최종 답변
    │
    └── tool_use
           ↓
        tool 이름 추출
           ↓
        args 추출
           ↓
        run_tool()
           │
           ├── MAX_STEPS 확인
           ├── Tool 존재?
           ├── args 정상?
           │
           ↓
        Tool 실행
           ↓
        결과를 history에 추가
           ↓
        다시 LLM 호출
 

한 문장으로 정리하면

이번 코드는 "LLM이 어떤 Tool을 요청했을 때, 그 Tool을 안전하게 선택하고 실행하며, 실행 횟수를 제한하는 Agent의 핵심 안전장치와 Router를 배우는 코드"입니다.

특히 지금까지 공부하신 Agent 코드 흐름에서 보면 run_tool()은 Agent의 실행 엔진, route()는 간단한 판단기, match/case는 LLM 응답 상태 처리기라고 이해하시면 가장 정확합니다.

반응형