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

VII. Python 함수 → Tool 함수 → Agent의 Tool Dispatcher 구조

아톨 2026. 8. 23. 15:50
반응형

 

    #-------- 1. 함수의 기본값과 키워드 인수

    def search(query, limit=3, safe=True):

        mode = "safe" if safe else "unfiltered"

        return f"searching '{query}' | limit={limit} | {mode}"

 

    print(search("환불정책"))

    print(search("환불정책", 10))

    print(search("환불정책", limit=5, safe=False))

    print(search(limit=1, query="환불정책"))

 

    #-------- 2. 함수 기본값의 함정 (mutable default)

    def add_bad(item, bucket=[]):

        bucket.append(item)

        return bucket

 

    def add_good(item, bucket=None):

        if bucket is None:

            bucket = []

        bucket.append(item)

        return bucket

 

    print(add_bad("a"), add_bad("b"), add_bad("c"))

    print(add_good("a"), add_good("b"), add_good("c"))

 

    #-------- 3. 함수의 표준화된 반환값

    def read_stock(sku):

        inventory = {"5441": 200, "9876": 0}

 

        if sku not in inventory:

            return {"ok":False, "error":f"제품번호 {sku} 자료가 없습니다.", "data":None}

        return {"ok":True, "error":None, "data":{"sku":sku, "units":inventory[sku]}}

 

    for sku in ["5441", "1234"]:

        result = read_stock(sku)

        if result["ok"]:

            print(f"{sku}: {result['data']['units']}개의 재고가 있습니다.")

        else:

            print(f"{sku}: 실패하였습니다 - {result['error']}")

 

    #-------- 4. 지역변수와 외부변수

    counter = 0

 

    def bump_local():

        counter = 99

        return counter

 

    def bump_properly(current):

        return current+1

 

    print(bump_local(), "but outside it is still", counter)

 

    counter = bump_properly(counter)

    counter = bump_properly(counter)

    print("after passing it in and out", counter)

 

    #-------- 5. *args, **kwargs

    def log(*parts, **fields):

        line = " ".join(str(p) for p in parts)

        extras = ", ".join(f"{k} = {v}" for k, v in fields.items())

        print(f"{line}  |  {extras}")

 

    log("tool", "finished", tool="search", ms=150, ok=True)

 

    def make_note(title, body, pinned=False):

        star = "*" if pinned else "-"

        return f"{star} {title}: {body}"

 

    args_from_model = {"title":"환불정책", "body":"30 이내", "pinned":True}

    print(make_note(**args_from_model))

 

    #-------- 6. 함수를 변수에 저장하기

    def add(a,b):

        return a+b

 

    operation = add

    print(operation)

    print(operation(10,100))

 

    #-------- 7. Tool Registry + Dispatcher

    def calculator(expr):

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

        if not set(expr) <= allowed:

            return "표현식에 지원되지 않는 문자가 포함되어 있습니다."

        return str(eval(expr))

 

    def word_count(text):

        return f"{len(text.split())} words"

 

    def reverse(text):

        return text[::-1]

 

    TOOLS = {

        "calculator":calculator,

        "word_count":word_count,

        "reverse":reverse,

    }

 

    def dispatch(name, args):

        if name not in TOOLS:

            known = ",".join(TOOLS)

            return {"ok":False, "result":None, "error":f"unknown tool '{name}', try: {known}"}

        if not isinstance(args, dict):

            return {"ok":False, "result":None, "error":"인수(args) 딕셔너리이어야 합니다."}

 

        try:

            value= TOOLS[name](**args)

            return {"ok":True, "result":value, "error": None}

        except TypeError as e:

            return {"ok":False, "result":None, "error":f"잘못된 인수: {e}"}

 

    calls = [

        ("calculator", {"expr":"(50*3)+15"}),

        ("word_count", {"text":"에이전트는 루프입니다."}),

        ("reverse", {"text":"stressed"}),

        ("emailer", {"to":"boss"}),

        ("calculator", {"wrong":"5+5"})

    ]

 

    for name, args in calls:

        out = dispatch(name, args)

        status = "OK" if out["ok"] else "FAIL"

        print(f"{status:>5} {name:<10} {out['result'] or out['error']}")

 

1. 함수의 기본값과 키워드 인수

 
def search(query, limit=3, safe=True):
    mode = "safe" if safe else "unfiltered"
    return f"searching '{query}' | limit={limit} | {mode}"
 

이 함수의 구조는:

search(
    query,          ← 반드시 필요
    limit=3,        ← 기본값 3
    safe=True       ← 기본값 True
)
 

입니다.


query

 
query
 

검색어입니다.

기본값이 없으므로 반드시 넣어야 합니다.

 
search("환불정책")
 

limit=3

 
limit=3
 

검색 결과 개수의 기본값입니다.

사용자가 지정하지 않으면 자동으로:

limit = 3
 

이 됩니다.


safe=True

 
safe=True
 

안전 검색 여부입니다.

지정하지 않으면:

safe = True
 

입니다.


호출 1

 
print(search("환불정책"))
 

실제로는:

 
search(
    query="환불정책",
    limit=3,
    safe=True
)
 

처럼 동작합니다.

결과:

searching '환불정책' | limit=3 | safe
 

호출 2

 
print(search("환불정책", 10))
 

순서대로 전달됩니다.

query → "환불정책"
limit → 10
safe → True
 

결과:

searching '환불정책' | limit=10 | safe
 

호출 3

 
print(search("환불정책", limit=5, safe=False))
 

여기서는 이름을 지정했습니다.

 
limit=5
safe=False
 

이런 방식을 키워드 인수(keyword argument)라고 합니다.

결과:

searching '환불정책' | limit=5 | unfiltered
 

호출 4

 
print(search(limit=1, query="환불정책"))
 

매개변수 순서가 바뀌었습니다.

하지만 이름을 명시했기 때문에 가능합니다.

limit → 1
query → 환불정책
 

즉:

 
search(
    limit=1,
    query="환불정책"
)
 

는 정상입니다.

Agent에서 왜 중요할까?

LLM이 Tool을 호출할 때 보통 이런 JSON을 만듭니다.

 
{
    "query": "환불정책",
    "limit": 5,
    "safe": False
}
 

그리고 Python에서는:

 
search(**args)
 

처럼 호출할 수 있습니다.


2. 매우 중요한 개념

Mutable Default Argument의 함정

 
def add_bad(item, bucket=[]):
    bucket.append(item)
    return bucket
 

이 코드는 겉보기에는 문제가 없어 보입니다.

하지만:

 
print(add_bad("a"))
print(add_bad("b"))
print(add_bad("c"))
 

결과는:

['a']
['a', 'b']
['a', 'b', 'c']
 

가 됩니다.

왜냐하면:

 
bucket=[]
 

가 매번 새로 만들어지는 것이 아니기 때문입니다.


실제 내부 느낌

Python은 기본값 리스트를 한 번 만들어 놓고 재사용합니다.

bucket
   ↓
[]
 

첫 번째:

 
add_bad("a")
 

['a']
 

두 번째:

 
add_bad("b")
 

기존 리스트를 다시 사용:

['a', 'b']
 

세 번째:

['a', 'b', 'c']
 

올바른 방법

 
def add_good(item, bucket=None):
    if bucket is None:
        bucket = []

    bucket.append(item)
    return bucket
 

기본값으로:

 
None
 

을 사용합니다.

그리고 함수가 호출될 때마다:

 
if bucket is None:
    bucket = []
 

새로운 리스트를 만듭니다.


결과

 
print(add_good("a"))
print(add_good("b"))
print(add_good("c"))
 

결과:

['a']
['b']
['c']
 

입니다.

이건 정말 중요합니다.

Agent에서 다음처럼 쓰면 위험할 수 있습니다.

 
def run(history=[]):
 

대신:

 
def run(history=None):
    if history is None:
        history = []
 

를 사용하는 것이 안전합니다.


3. Tool의 표준화된 반환값

 
def read_stock(sku):
 

재고를 읽는 Tool입니다.


재고 데이터

 
inventory = {
    "5441": 200,
    "9876": 0
}
 

즉:

제품번호 5441 → 재고 200개
제품번호 9876 → 재고 0개
 

입니다.


제품이 없는 경우

 
if sku not in inventory:
    return {
        "ok":False,
        "error":f"제품번호 {sku}는 자료가 없습니다.",
        "data":None
    }
 

예:

 
read_stock("1234")
 

결과:

 
{
    "ok": False,
    "error": "제품번호 1234는 자료가 없습니다.",
    "data": None
}
 

정상인 경우

 
return {
    "ok":True,
    "error":None,
    "data":{
        "sku":sku,
        "units":inventory[sku]
    }
}
 

예:

 
read_stock("5441")
 

결과:

 
{
    "ok": True,
    "error": None,
    "data": {
        "sku": "5441",
        "units": 200
    }
}
 

왜 이렇게 복잡하게 반환할까?

단순히:

 
return 200
 

하면 간단합니다.

하지만 실패했을 때 문제가 생깁니다.

제품이 없음
서버 오류
권한 없음
시간 초과
 

이런 상황을 표현하기 어렵습니다.

그래서 Agent Tool에서는 보통:

ok
data
error
 

같은 표준 구조를 사용합니다.

Tool 결과
│
├── ok      → 성공 여부
├── data    → 실제 데이터
└── error   → 실패 이유
 

이것은 실제 Agent Tool 설계에서 매우 좋은 습관입니다.


4. 지역변수와 외부변수

 
counter = 0
 

전역 변수입니다.


bump_local()

 
def bump_local():
    counter = 99
    return counter
 

함수 안에서:

 
counter = 99
 

를 만들었습니다.

하지만 이것은 밖의 counter가 아닙니다.

밖

counter = 0


함수 안

counter = 99
 

서로 다른 변수입니다.


따라서:

 
print(bump_local(), "but outside it is still", counter)
 

결과:

99 but outside it is still 0
 

입니다.


좋은 방식

 
def bump_properly(current):
    return current+1
 

현재 값을 함수에 넣고:

 
counter = bump_properly(counter)
 

결과를 다시 받습니다.

처음:

counter = 0
 

첫 번째:

bump_properly(0)
↓
1
 

두 번째:

bump_properly(1)
↓
2
 

최종:

counter = 2
 

Agent에서 좋은 이유

이런 방식:

 
step = increase(step)
 

은 상태 변화가 명확합니다.

Agent에서는:

history
step
answer
cost
token
 

등 상태가 많기 때문에 함수에 값을 넣고 결과를 받는 방식이 이해하기 쉽고 안전합니다.


5. *args와 **kwargs

 
def log(*parts, **fields):
 

이 부분은 매우 중요합니다.


*parts

여러 개의 위치 인수를 받습니다.

 
log(
    "tool",
    "finished",
    ...
)
 

함수 안에서는:

 
parts
 

가:

 
("tool", "finished")
 

라는 tuple이 됩니다.


**fields

이름이 붙은 여러 값을 받습니다.

 
tool="search",
ms=150,
ok=True
 

함수 안에서는:

 
fields
 

가:

 
{
    "tool":"search",
    "ms":150,
    "ok":True
}
 

가 됩니다.


첫 번째 부분

 
line = " ".join(str(p) for p in parts)
 

parts:

 
("tool", "finished")
 

tool finished
 

두 번째 부분

 
extras = ", ".join(
    f"{k} = {v}"
    for k, v in fields.items()
)
 

결과:

tool = search, ms = 150, ok = True
 

최종

tool finished | tool = search, ms = 150, ok = True
 

이런 함수는 Agent 로그를 만들 때 매우 유용합니다.


6. **dictionary로 함수 호출

 
def make_note(title, body, pinned=False):
 

필요한 값:

title
body
pinned
 

입니다.


그런데 모델이 JSON 형태로 보내왔다고 합시다.

 
args_from_model = {
    "title":"환불정책",
    "body":"30일 이내",
    "pinned":True
}
 

이걸:

 
make_note(**args_from_model)
 

이라고 호출합니다.

그러면 Python이 자동으로:

 
make_note(
    title="환불정책",
    body="30일 이내",
    pinned=True
)
 

로 풀어줍니다.

이게 바로:

 
function(**args)
 

입니다.

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

LLM

{
  "tool": "make_note",
  "args": {
    "title": "...",
    "body": "...",
    "pinned": true
  }
}

        ↓

Python

TOOLS[name](**args)
 

7. Python에서는 함수도 변수에 저장할 수 있다

 
def add(a,b):
    return a+b
 

함수 이름 자체를 변수에 넣을 수 있습니다.

 
operation = add
 

중요한 것은:

 
operation = add
 

와:

 
operation = add()
 

는 다릅니다.


함수 자체 저장

 
operation = add
 

operation
   ↓
add 함수
 

따라서:

 
operation(10,100)
 

는:

 
add(10,100)
 

과 같습니다.

결과:

110
 

이 개념이 바로:

 
TOOLS = {
    "calculator": calculator,
    "search": search,
}
 

같은 Tool Registry를 가능하게 합니다.


8. 마지막 코드

⭐ 실제 Agent Tool Dispatcher 구조

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


8-1. calculator

 
def calculator(expr):
 

계산기 Tool입니다.


허용 문자

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

즉 허용되는 문자:

숫자
+
-
*
/
(
)
.
 

입니다.


안전 검사

 
if not set(expr) <= allowed:
 

예:

 
expr = "10+20"
 

문자를 Set으로 만들면:

{'1','0','+','2'}
 

이것들이 모두:

 
allowed
 

안에 있으면 OK입니다.


하지만:

 
expr = "import os"
 

라면:

i
m
p
o
r
t
...
 

같은 허용되지 않은 문자가 있습니다.

따라서 거부합니다.

표현식에 지원되지 않는 문자가 포함되어 있습니다.
 

eval()

 
return str(eval(expr))
 

예:

 
eval("(50*3)+15")
 

165
 

따라서 문자열:

"165"
 

를 반환합니다.

참고로 실제 서비스에서는 이 정도 문자 검사만으로 eval()을 완전히 안전하다고 보기는 어렵습니다. 실무에서는 AST 기반 계산기 등을 사용하는 것이 더 안전합니다.


8-2. word_count

 
def word_count(text):
    return f"{len(text.split())} words"
 

예:

 
text = "에이전트는 루프입니다."
 
 
text.split()
 

 
["에이전트는", "루프입니다."]
 

길이는:

2
 

결과:

2 words
 

8-3. reverse

 
def reverse(text):
    return text[::-1]
 

문자열을 거꾸로 뒤집습니다.

예:

stressed
 

desserts
 

[::-1]은 문자열 전체를 역순으로 가져오는 Python 문법입니다.


9. Tool Registry

 
TOOLS = {
    "calculator":calculator,
    "word_count":word_count,
    "reverse":reverse,
}
 

이것은 Agent가 사용할 수 있는 Tool 목록입니다.

TOOLS
│
├── calculator ───→ calculator()
│
├── word_count ───→ word_count()
│
└── reverse ──────→ reverse()
 

10. Dispatcher

 
def dispatch(name, args):
 

역할은:

Tool 이름과 Arguments를 받아서 적절한 Tool을 찾아 실행하는 중앙 관리자

입니다.

실제 흐름:

LLM
 ↓
{
 "tool": "calculator",
 "args": {
   "expr": "(50*3)+15"
 }
}
 ↓
dispatch()
 ↓
TOOLS에서 calculator 찾기
 ↓
calculator(**args)
 ↓
결과 반환
 

11. Tool 존재 확인

 
if name not in TOOLS:
 

예:

 
name = "emailer"
 

하지만:

 
TOOLS = {
    "calculator": ...,
    "word_count": ...,
    "reverse": ...
}
 

에는 없습니다.

따라서:

 
known = ",".join(TOOLS)
 

결과:

calculator,word_count,reverse
 

오류:

unknown tool 'emailer', try: calculator,word_count,reverse
 

12. args가 Dictionary인지 확인

 
if not isinstance(args, dict):
 

Tool 호출은 보통:

 
{
    "expr":"50*3"
}
 

처럼 Dictionary여야 합니다.

예:

 
"50*3"
 

같은 문자열이면:

FAIL
 

처리합니다.


13. 가장 중요한 부분

 
value = TOOLS[name](**args)
 

이 한 줄이 핵심입니다.

예:

 
name = "calculator"

args = {
    "expr":"(50*3)+15"
}
 

먼저:

 
TOOLS[name]
 

 
TOOLS["calculator"]
 

 
calculator
 

그리고:

 
calculator(**args)
 

 
calculator(expr="(50*3)+15")
 

165
 

14. try / except TypeError

 
try:
    value = TOOLS[name](**args)
 

예를 들어 모델이:

 
{
    "wrong":"5+5"
}
 

를 보냈다고 합시다.

그러면:

 
calculator(wrong="5+5")
 

가 됩니다.

하지만 calculator 함수는:

 
def calculator(expr):
 

이므로:

expr
 

이 필요합니다.

따라서 TypeError가 발생합니다.

이걸 잡아서:

 
except TypeError as e:
 

에러를 프로그램 전체가 죽지 않도록 처리합니다.

결과:

 
{
    "ok":False,
    "result":None,
    "error":"잘못된 인수: ..."
}
 

15. calls 실행

첫 번째:

 
("calculator", {"expr":"(50*3)+15"})
 

165
 

두 번째:

 
("word_count", {"text":"에이전트는 루프입니다."})
 

2 words
 

세 번째:

 
("reverse", {"text":"stressed"})
 

desserts
 

네 번째:

 
("emailer", {"to":"boss"})
 

Tool 없음

FAIL
 

다섯 번째:

 
("calculator", {"wrong":"5+5"})
 

인수 이름이 잘못됨

FAIL
 

예상 출력 구조

   OK calculator 165
   OK word_count 2 words
   OK reverse    desserts
 FAIL emailer    unknown tool 'emailer', try: calculator,word_count,reverse
 FAIL calculator 잘못된 인수: calculator() got an unexpected keyword argument 'wrong'
 

🌟 이번 코드 전체의 가장 중요한 흐름

이제 지금까지 배운 내용을 연결하면:

사용자 질문
     ↓
LLM 판단
     ↓
Tool Call 생성
     ↓

{
  "tool": "calculator",
  "args": {
    "expr": "(50*3)+15"
  }
}

     ↓
dispatch(name, args)
     ↓
Tool 존재 확인
     ↓
args 형식 확인
     ↓
TOOLS[name]
     ↓
실제 함수 선택
     ↓
TOOLS[name](**args)
     ↓
Tool 실행 결과
     ↓

{
  "ok": True,
  "result": "165",
  "error": None
}
 

이번 코드에서 꼭 기억할 핵심 5개

① 기본값

 
def search(query, limit=3):
 

필요한 경우에만 값을 변경할 수 있습니다.


② 리스트 기본값 주의

❌ 위험:

 
def func(items=[]):
 

⭕ 안전:

 
def func(items=None):
    if items is None:
        items = []
 

③ Tool 결과는 표준화

 
{
    "ok": True,
    "result": ...,
    "error": None
}
 

또는:

 
{
    "ok": False,
    "result": None,
    "error": "..."
}
 

④ 함수도 변수에 넣을 수 있다

 
TOOLS = {
    "calculator": calculator
}
 

⑤ Agent Tool Calling의 핵심

 
TOOLS[name](**args)
 

이 한 줄입니다.

LLM이:

 
name = "calculator"
args = {"expr":"5+5"}
 

를 보내면:

 
TOOLS[name](**args)
 

 
calculator(expr="5+5")
 

가 됩니다.


한 문장으로 정리하면

이번 코드는 AI Agent가 LLM으로부터 받은 tool 이름 + arguments를 안전하게 검증하고, 실제 Python 함수를 찾아 실행한 뒤, 성공 또는 실패 결과를 일정한 형식으로 반환하는 Tool Dispatcher 구조를 배우는 코드입니다.

 

반응형