| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
- AGENT.PY
- AI Agent
- 파워포인트) #집(zip)파일 #아래한글(HWP) #brute-force(무차별 대입)
- LLM.PY
- 엑셀
- AI MODEL
- call model (history)
- LLM
- 함수 signature
- 함수 docstring
- # 암호(비밀번호) 분실 # 암호(비밀번호) 찾기 #오피스(doc
- xls
- AI
- list comprehension
- LLM(large language model)
- 오블완
- tool schema
- 인공지능
- 파이썬
- JSON
- 티스토리챌린지
- 함수 annotation
- ppt) 파일 #오피스(워드
- TOOLS.PY
- Fake Model-script 사용한 AI AGENT 프로그램
- #다산 정약용 #유배지에서 보낸 편지 #도덕 #용기 #염 #주역 #호연지기 #효제 #근검
- #선진국 대한민국 #선진국 #대한민국 #아이들 #청소년 #고민 #해결 #심리
- 에이전트
- MAIN.PY
- python함수 자동분석
- Today
- Total
아톨러브
XIV. 작은 AI Agent 프로그램(가짜 LLM Model-script 사용) 본문
#-------- 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("user", 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의 몸통은 거의 만들어졌습니다.
'AI, 클라우드, 문서, 자동화 > AI_AGENT' 카테고리의 다른 글
| XV. 작은 AI Agent 프로그램(실제 LLM -OpenAI 사용) (0) | 2026.08.26 |
|---|---|
| XIII. Python 함수 자체를 분석하여, AI가 사용할 수 있는 Tool 설명서(schema)를 자동으로 만드는 방법 (0) | 2026.08.25 |
| XII. Python 프로그램이 인터넷 너머의 API 서버와 대화하는 방법 → 오류, 재시도 ...→ 결국 실제 LLM 호출 함수 call_model()로 발전시키는 과정 (2) | 2026.08.25 |
| XI. Agent의 핵심 부품을 여러 파일로 나누어 실제 프로젝트 구조 만들기 (0) | 2026.08.24 |
| X. Agent의 여러 요소를 하나의 class Agent로 묶어가는 과정. (0) | 2026.08.24 |
