[3/3] Harness와 MCP를 결합한 AI Agent 구축
지금까지 진행한 Python → Tool Calling → Agent Loop → MCP → Skill → Harness가 실제 코드 하나로 어떻게 합쳐지는지 이해하는 단계입니다.
특히 이번에는 코드를 단순히 한 줄씩 번역하기보다는,
“이 코드가 왜 필요한가 → 실행하면 무슨 일이 일어나는가 → 데이터가 어디로 이동하는가”
를 중심으로 설명하겠습니다.
1. 먼저 전체 그림부터 잡겠습니다
우리가 지금 만든 시스템은 크게 두 개의 프로그램입니다.
┌──────────────────────────────────────────────────────────┐
│ Harness / AI Agent │
│ │
│ harness.py │
│ │
│ Plan → Skill → LLM → Tool Call → MCP Client │
│ ↑ │ │
│ │ │ │
│ └──── Tool 결과 ─┘ │
│ │
│ Persistence / Retry / Context Reset │
└──────────────────────────┬───────────────────────────────┘
│
MCP Protocol
│
▼
┌──────────────────────────────────────────────────────────┐
│ MCP Server │
│ │
│ weather_mcp_server.py │
│ │
│ get_weather_info() │
│ │ │
│ ▼ │
│ Open-Meteo API │
└──────────────────────────────────────────────────────────┘
아주 간단하게 말하면:
weather_mcp_server.py
"날씨를 가져오는 능력을 MCP Tool로 만들어 제공하는 프로그램"
입니다.
반면,
harness.py
"그 Tool을 AI Agent가 사용하도록 관리하고, 여러 도시의 작업을 계획하고 실행하고 저장하는 프로그램"
입니다.
2. 두 프로그램의 역할 차이
이것을 먼저 확실히 구분해 두면 코드가 훨씬 쉬워집니다.
| weather_mcp_server.py | 날씨 Tool 제공 |
| harness.py | AI Agent 작업 관리 |
| SKILL.md | AI가 어떻게 판단할지 지시 |
| OpenAI LLM | 판단/추론 |
| MCP | Client ↔ Server 연결 규격 |
| Open-Meteo | 실제 날씨 데이터 제공 |
| plan.json | 작업 상태 저장 |
| reports/*.md | 결과 저장 |
따라서:
SKILL.md
↓
"날씨를 이렇게 분석해라"
LLM
↓
"날씨 데이터가 필요하군.
get_weather_info를 호출해야겠다."
MCP Client
↓
"get_weather_info 호출"
MCP Server
↓
"알겠습니다. 제가 실행하겠습니다."
Open-Meteo
↓
실제 날씨 데이터
MCP Server
↓
결과 전달
MCP Client
↓
결과 전달
LLM
↓
SKILL.md 기준으로 분석
Harness
↓
report 저장 + plan 완료 처리
이 흐름입니다.

3. 먼저 weather_mcp_server.py부터 보겠습니다
전체 코드를 다시 놓고 보겠습니다.
import requests
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Open-Meteo Weather Server")
def get_coordinates(location: str):
url = "https://geocoding-api.open-meteo.com/v1/search"
params = {
"name": location,
"count": 1,
"language": "en",
"format": "json",
}
response = requests.get(
url,
params=params,
timeout=10,
)
response.raise_for_status()
data = response.json()
if not data.get("results"):
raise ValueError(
f"도시를 찾을 수 없습니다: {location}"
)
result = data["results"][0]
return {
"name": result["name"],
"latitude": result["latitude"],
"longitude": result["longitude"],
"country": result.get("country", ""),
}
@mcp.tool()
def get_weather_info(location: str) -> dict:
coordinates = get_coordinates(location)
latitude = coordinates["latitude"]
longitude = coordinates["longitude"]
url = "https://api.open-meteo.com/v1/forecast"
params = {
"latitude": latitude,
"longitude": longitude,
"current": [
"temperature_2m",
"relative_humidity_2m",
"weather_code",
],
"timezone": "auto",
}
response = requests.get(
url,
params=params,
timeout=10,
)
response.raise_for_status()
data = response.json()
current = data["current"]
weather_code = current["weather_code"]
condition_map = {
0: "맑음",
1: "대체로 맑음",
2: "부분적으로 흐림",
3: "흐림",
45: "안개",
48: "안개",
51: "이슬비",
53: "이슬비",
55: "이슬비",
61: "비",
63: "비",
65: "강한 비",
71: "눈",
73: "눈",
75: "강한 눈",
80: "소나기",
81: "소나기",
82: "강한 소나기",
95: "뇌우",
96: "뇌우",
99: "강한 뇌우",
}
condition = condition_map.get(
weather_code,
"알 수 없는 날씨",
)
return {
"location": coordinates["name"],
"country": coordinates["country"],
"temperature_c": current["temperature_2m"],
"humidity": current["relative_humidity_2m"],
"condition": condition,
"weather_code": weather_code,
"timezone": data.get("timezone"),
}
if __name__ == "__main__":
mcp.run(
transport="sse"
)
이제 하나씩 보겠습니다.
4. requests
import requests
이것은 Python에서 HTTP 요청을 보내기 위한 라이브러리입니다.
우리가 Open-Meteo API에:
"서울의 날씨를 알려줘."
라고 요청하려면 인터넷으로 HTTP 요청을 보내야 합니다.
그 역할을 requests가 합니다.
구조적으로 보면:
Python
│
│ requests.get()
▼
인터넷
│
▼
Open-Meteo API
입니다.
5. FastMCP 가져오기
from mcp.server.fastmcp import FastMCP
여기서 MCP Server를 만들기 위한 FastMCP를 가져옵니다.
예전에는 MCP Server를 만들려면 여러 가지 설정을 직접 해야 했지만 FastMCP를 사용하면 비교적 간단하게 Tool을 만들 수 있습니다.
6. MCP Server 생성
mcp = FastMCP("Open-Meteo Weather Server")
이 한 줄이 중요합니다.
우리는 여기서:
"이 프로그램은 MCP Server입니다."
라고 선언하고 있는 것입니다.
이 객체 mcp가 앞으로:
- Tool 등록
- MCP protocol 처리
- Client 요청 처리
- Server 실행
등을 담당합니다.
즉:
mcp
│
├── Tool 등록
├── Client 요청 수신
├── Tool 실행
└── 결과 반환
이라고 생각하면 됩니다.
7. get_coordinates() 함수
다음 함수가 있습니다.
def get_coordinates(location: str):
이 함수의 역할은 단순합니다.
도시 이름을 위도/경도로 바꾸는 함수
입니다.
왜 필요할까요?
Open-Meteo의 날씨 API는 보통 다음처럼 좌표를 이용합니다.
latitude = 37.5665
longitude = 126.9780
그런데 사용자는:
서울
이라고 합니다.
따라서 중간에:
서울
↓
위도/경도
↓
날씨 API
가 필요합니다.
8. Geocoding API 주소
url = "https://geocoding-api.open-meteo.com/v1/search"
Open-Meteo의 Geocoding API 주소입니다.
쉽게 말하면:
"도시 이름으로 위치를 찾아주는 API"
입니다.
9. API에 전달할 파라미터
params = {
"name": location,
"count": 1,
"language": "en",
"format": "json",
}
예를 들어 location이 "서울"이라면:
name = 서울
입니다.
count
"count": 1
검색 결과를 하나만 가져오겠다는 의미입니다.
language
"language": "en"
검색 결과의 언어를 영어로 요청합니다.
format
"format": "json"
결과를 JSON 형태로 받겠다는 뜻입니다.
10. 실제 API 호출
response = requests.get(
url,
params=params,
timeout=10,
)
이 부분에서 실제 인터넷 요청이 발생합니다.
개념적으로:
GET
https://geocoding-api.open-meteo.com/v1/search
?name=서울
&count=1
&language=en
&format=json
와 비슷한 요청이 만들어집니다.
11. raise_for_status()
response.raise_for_status()
이것은 API 호출이 정상인지 확인합니다.
예를 들어:
200 OK
이면 정상입니다.
반대로:
404
500
503
같은 HTTP 오류가 발생하면 예외를 발생시킵니다.
즉:
API 호출
↓
정상?
┌─┴─┐
Yes No
│ │
▼ ▼
계속 Exception
입니다.
12. JSON으로 변환
data = response.json()
Open-Meteo가 보내준 JSON 데이터를 Python 객체로 변환합니다.
예를 들어 API가:
{
"results": [
{
"name": "Seoul",
"latitude": 37.566,
"longitude": 126.978
}
]
}
을 보내면 Python에서는:
data["results"]
처럼 접근할 수 있게 됩니다.
13. 도시가 없으면
if not data.get("results"):
검색 결과가 없는지 확인합니다.
예를 들어:
"아무도시"
라고 했는데 검색 결과가 없다면:
raise ValueError(
f"도시를 찾을 수 없습니다: {location}"
)
를 실행합니다.
이것도 중요한 부분입니다.
예전 Mock 데이터에서는 모르는 도시가 들어오면:
20°C
50%
같은 가짜 데이터를 반환했습니다.
그런데 지금은 그렇게 하지 않습니다.
모르는 도시
↓
에러
입니다.
이것이 우리가 SKILL.md에서 강조한:
추측하지 말라
와도 맞습니다.
14. 검색 결과 하나 가져오기
result = data["results"][0]
count=1로 요청했기 때문에 첫 번째 결과를 가져옵니다.
15. 필요한 데이터만 정리해서 반환
return {
"name": result["name"],
"latitude": result["latitude"],
"longitude": result["longitude"],
"country": result.get("country", ""),
}
여기서 중요한 개념이 있습니다.
API가 제공하는 모든 데이터를 그대로 넘기지 않고 우리가 필요한 데이터만 추려서 반환합니다.
예:
Open-Meteo Geocoding API
↓
많은 데이터
↓
필요한 데이터만
↓
name
latitude
longitude
country
이런 식입니다.
16. 이제 진짜 MCP Tool입니다
가장 중요한 부분입니다.
@mcp.tool()
def get_weather_info(location: str) -> dict:
여기서:
@mcp.tool()
가 핵심입니다.
이것은 단순한 Python 함수였던:
get_weather_info()
를
MCP를 통해 외부에서 호출할 수 있는 Tool
로 등록합니다.
즉:
그냥 Python 함수
def get_weather_info():
에서
MCP Tool
@mcp.tool()
def get_weather_info():
로 바뀐 것입니다.
17. MCP Tool의 의미
이제 MCP Client가 Server에게:
"어떤 Tool이 있습니까?"
라고 물으면 Server는:
get_weather_info
를 알려줄 수 있습니다.
우리가 실제 실행 로그에서 본:
>> MCP Tools:
- get_weather_info
가 바로 이것입니다.
즉, @mcp.tool()이 없으면 MCP Client가 이 함수를 Tool로 발견할 수 없습니다.
18. location: str
def get_weather_info(location: str) -> dict:
여기서:
location: str
은:
도시 이름을 문자열로 받는다.
라는 뜻입니다.
예:
get_weather_info("서울")
그리고:
-> dict
는:
결과는 dictionary 형태로 반환한다.
는 뜻입니다.
19. 도시 → 좌표
Tool 내부에서:
coordinates = get_coordinates(location)
을 호출합니다.
즉:
get_weather_info("서울")
│
▼
get_coordinates("서울")
│
▼
latitude / longitude
입니다.
20. 좌표 꺼내기
latitude = coordinates["latitude"]
longitude = coordinates["longitude"]
예를 들어:
latitude = 37.5665
longitude = 126.9780
같은 값이 들어갑니다.
21. Forecast API
이제 두 번째 API를 호출합니다.
url = "https://api.open-meteo.com/v1/forecast"
이것은 실제 날씨 데이터를 가져오는 API입니다.
앞의 API와 구분해야 합니다.
Geocoding API
↓
"서울이 어디인가?"
↓
위도 / 경도
Forecast API
↓
"그 위치의 날씨는?"
↓
기온 / 습도 / 날씨 상태
즉 Open-Meteo API를 두 번 사용하는 것입니다.
22. 필요한 날씨 데이터 지정
params = {
"latitude": latitude,
"longitude": longitude,
"current": [
"temperature_2m",
"relative_humidity_2m",
"weather_code",
],
"timezone": "auto",
}
여기서:
"latitude": latitude
"longitude": longitude
로 위치를 지정합니다.
그리고:
"current"
안에서 현재 필요한 날씨 데이터를 지정합니다.
기온
"temperature_2m"
습도
"relative_humidity_2m"
날씨 코드
"weather_code"
입니다.
23. 왜 날씨 상태를 문자열로 직접 받지 않을까요?
Open-Meteo는 날씨 상태를 코드로 제공합니다.
예:
0
1
2
3
45
61
95
등입니다.
그래서 우리가 아래에서 사람이 읽을 수 있는 한국어로 변환합니다.
24. API 호출
response = requests.get(
url,
params=params,
timeout=10,
)
실제 날씨 API를 호출합니다.
25. JSON → Python
data = response.json()
API 결과를 Python 객체로 바꿉니다.
그리고:
current = data["current"]
현재 날씨 데이터만 꺼냅니다.
26. 날씨 코드
weather_code = current["weather_code"]
예를 들어:
weather_code = 0
일 수 있습니다.
그런데 AI에게:
weather_code = 0
이라고 주는 것보다:
condition = 맑음
이라고 주는 것이 훨씬 좋습니다.
그래서 다음 코드가 필요합니다.
27. condition_map
condition_map = {
0: "맑음",
1: "대체로 맑음",
2: "부분적으로 흐림",
3: "흐림",
45: "안개",
...
}
이것은 단순한 Python dictionary입니다.
예:
condition_map[0]
→
맑음
입니다.
28. .get()
condition = condition_map.get(
weather_code,
"알 수 없는 날씨",
)
여기서 .get()의 두 번째 인자는 기본값입니다.
예를 들어:
weather_code = 61
이면:
비
가 됩니다.
하지만 우리가 모르는 코드:
weather_code = 999
가 들어오면:
알 수 없는 날씨
가 됩니다.
29. 최종 결과
가장 중요한 반환 부분입니다.
return {
"location": coordinates["name"],
"country": coordinates["country"],
"temperature_c": current["temperature_2m"],
"humidity": current["relative_humidity_2m"],
"condition": condition,
"weather_code": weather_code,
"timezone": data.get("timezone"),
}
예를 들어 최종적으로:
{
"location": "Seoul",
"country": "South Korea",
"temperature_c": 22.3,
"humidity": 48,
"condition": "맑음",
"weather_code": 0,
"timezone": "Asia/Seoul"
}
같은 결과가 됩니다.
이것이 MCP Tool의 결과입니다.
30. 마지막 MCP Server 실행
if __name__ == "__main__":
이것은 Python 파일을 직접 실행했을 때만 아래 코드를 실행하라는 의미입니다.
mcp.run(
transport="sse"
)
여기서 MCP Server가 실제로 시작됩니다.
즉:
python weather_mcp_server.py
하면:
Python 프로그램 시작
↓
FastMCP Server 생성
↓
get_weather_info Tool 등록
↓
mcp.run()
↓
MCP Server 대기
상태가 됩니다.
31. 여기까지가 weather_mcp_server.py
한 문장으로 정리하면:
weather_mcp_server.py는 Open-Meteo API를 이용해 실제 날씨를 조회하는 Python 함수를 MCP Tool로 등록하고, MCP Client가 이 Tool을 호출할 수 있도록 Server로 실행하는 프로그램입니다.
32. 이제 harness.py를 보겠습니다
이 파일은 훨씬 중요합니다.
왜냐하면 지금까지 우리가 배운 개념들이 대부분 들어 있기 때문입니다.
전체 구조는:
harness.py
환경설정
↓
경로 설정
↓
Plan
↓
Skill 읽기
↓
MCP 연결
↓
MCP Tool 발견
↓
OpenAI Tool Schema 변환
↓
도시별 Agent 실행
↓
LLM
↓
Tool Call
↓
MCP Tool 실행
↓
결과를 LLM에 전달
↓
최종 답변
↓
파일 저장
↓
Plan 완료
입니다.
33. import
import asyncio
import json
import os
import pathlib
import time
각각의 역할을 보면:
asyncio
MCP Client가 비동기 방식으로 동작하기 때문에 필요합니다.
async def
await
asyncio.run()
등에 사용합니다.
json
plan.json을 읽고 저장하거나 Tool 인자를 처리할 때 사용합니다.
os
환경변수:
os.getenv()
를 사용하기 위해 필요합니다.
pathlib
파일 경로를 다루기 쉽게 해줍니다.
BASE_DIR / "skills" / "SKILL.md"
같은 방식이 가능합니다.
time
현재 코드에서는 사실상 사용하지 않아도 됩니다.
나중에 retry나 로그 시간 처리 등에 사용할 수 있습니다.
34. MCP Client 관련 import
from mcp import ClientSession
from mcp.client.sse import sse_client
여기가 MCP Client의 핵심입니다.
sse_client
MCP Server와 SSE 방식으로 연결합니다.
Harness
↓
sse_client()
↓
MCP Server
ClientSession
MCP 연결 이후 실제 MCP 작업을 수행하는 객체입니다.
예:
session.list_tools()
session.call_tool()
입니다.
즉:
sse_client
= 연결 통로
ClientSession
= 실제 MCP 대화 담당
라고 이해하면 좋습니다.
35. OpenAI Client
openai_client = OpenAI(
api_key=os.getenv("OPENAI_API_KEY", "")
)
LLM을 호출하기 위한 OpenAI Client입니다.
그리고:
MODEL = os.getenv(
"OPENAI_MODEL",
"gpt-5.4-mini",
)
에서 사용할 모델을 결정합니다.
36. BASE_DIR
BASE_DIR = pathlib.Path(__file__).parent
이것은 매우 좋은 습관입니다.
현재 실행 중인 Python 파일의 폴더를 기준으로 잡습니다.
예를 들어:
C:\Dev\book_agentic_ai\test_\project_1
에 harness.py가 있다면:
BASE_DIR
=
C:\Dev\book_agentic_ai\test_\project_1
가 됩니다.
37. 파일 경로
SKILL_PATH = BASE_DIR / "skills" / "SKILL.md"
REPORT_DIR = BASE_DIR / "reports"
PLAN_FILE = BASE_DIR / "plan.json"
따라서:
project_1/
│
├── harness.py
├── plan.json
│
├── skills/
│ └── SKILL.md
│
└── reports/
구조가 됩니다.
38. reports 폴더 생성
REPORT_DIR.mkdir(exist_ok=True)
reports 폴더가 없으면 만들어줍니다.
이미 있으면 그냥 넘어갑니다.
39. MCP Server 주소
MCP_SERVER_URL = os.getenv(
"MCP_SERVER_URL",
"http://localhost:8000/sse",
)
.env에:
MCP_SERVER_URL=http://localhost:8000/sse
가 있다면 그것을 사용합니다.
없으면 기본값:
http://localhost:8000/sse
를 사용합니다.
40. Retry 설정
MAX_RETRIES = 3
RETRY_DELAYS = [1, 2, 4]
최대 3번 시도합니다.
실패하면:
1초
↓
2초
↓
4초
식으로 재시도하도록 만든 것입니다.
이것은 Harness의 중요한 역할 중 하나입니다.
41. initialize_plan()
def initialize_plan(cities):
plan = {
city: "pending"
for city in cities
}
예를 들어:
cities = [
"서울",
"도쿄",
"뉴욕",
]
이면:
{
"서울": "pending",
"도쿄": "pending",
"뉴욕": "pending"
}
이 만들어집니다.
42. Plan 저장
with open(
PLAN_FILE,
"w",
encoding="utf-8",
) as f:
json.dump(
plan,
f,
indent=4,
ensure_ascii=False,
)
파일에 저장합니다.
결과:
{
"서울": "pending",
"도쿄": "pending",
"뉴욕": "pending"
}
이 됩니다.
이것이 Harness의 State입니다.
43. load_plan()
def load_plan():
with open(
PLAN_FILE,
"r",
encoding="utf-8",
) as f:
return json.load(f)
저장되어 있는 작업 상태를 읽습니다.
44. save_plan()
def save_plan(plan):
작업 상태를 다시 저장합니다.
예를 들어:
서울 completed
도쿄 pending
뉴욕 pending
을 저장합니다.
45. load_skill()
def load_skill():
with open(
SKILL_PATH,
"r",
encoding="utf-8",
) as f:
return f.read()
SKILL.md 전체를 문자열로 읽습니다.
이 부분이 중요합니다.
Harness가 Skill을 Python 코드로 해석하는 것이 아닙니다.
그냥 텍스트로 읽어서:
LLM의 system message
로 전달합니다.
즉:
SKILL.md
↓
문자열
↓
LLM system message
입니다.
46. MCP Tool을 OpenAI Tool로 변환
여기가 아까 오류가 발생했던 부분입니다.
현재 수정된 코드는:
def convert_mcp_tool_to_openai(mcp_tool):
return {
"type": "function",
"name": mcp_tool.name,
"description": mcp_tool.description or "",
"parameters": mcp_tool.input_schema,
}
이 함수는 굉장히 중요합니다.
왜냐하면:
MCP Tool Schema
↓
OpenAI Tool Schema
로 바꾸기 때문입니다.
47. 왜 변환이 필요한가?
MCP와 OpenAI는 서로 다른 시스템입니다.
MCP Server는:
"나는 get_weather_info라는 Tool을 가지고 있다."
라고 MCP 방식으로 알려줍니다.
하지만 OpenAI LLM에게는:
{
"type": "function",
"name": "get_weather_info",
...
}
형태로 Tool을 알려줘야 합니다.
그래서 중간에서 변환합니다.
MCP
│
│ Tool Schema
▼
Harness
│
│ 변환
▼
OpenAI Tool Schema
│
▼
LLM
이것이 Harness가 하는 통합/Orchestration 역할 중 하나입니다.
48. 우리가 아까 수정한 input_schema
현재 MCP SDK의 Tool 객체에서는:
mcp_tool.input_schema
를 사용합니다.
이것을:
"parameters": mcp_tool.input_schema
로 OpenAI Tool의 parameters에 넣습니다.
즉:
MCP Tool
├── name
├── description
└── input_schema
│
▼
OpenAI Tool
├── name
├── description
└── parameters
입니다.
49. MCP 결과를 문자열로 만드는 함수
def extract_mcp_result(result):
parts = []
for content in result.content:
if hasattr(content, "text"):
parts.append(content.text)
else:
parts.append(str(content))
return "\n".join(parts)
이것도 중요한 함수입니다.
MCP Tool의 결과가 단순히 Python 문자열 하나라고 가정하지 않고, MCP 결과 안의 content를 확인합니다.
50. result.content
예를 들어 MCP Tool 결과가:
content
├── text
└── ...
형태일 수 있습니다.
그래서:
for content in result.content:
로 하나씩 확인합니다.
51. hasattr()
if hasattr(content, "text"):
이것은:
이 객체에 text라는 속성이 있는가?
를 확인합니다.
있으면:
parts.append(content.text)
로 텍스트를 꺼냅니다.
52. 최종 문자열
return "\n".join(parts)
여러 결과를 하나의 문자열로 합칩니다.
그래서 최종적으로:
{
"location": "Seoul",
"temperature_c": 22,
...
}
같은 텍스트가 만들어집니다.
그리고 이것을 다시 LLM에게 줍니다.
53. 이제 핵심 함수 process_city()
async def process_city(
city,
skill_instruction,
session,
tools,
):
이 함수가:
도시 하나를 AI Agent가 처리하는 부분
입니다.
예를 들어:
process_city("서울", ...)
이면 서울 하나를 처리합니다.
54. Context Reset
messages = [
{
"role": "system",
"content": skill_instruction,
},
{
"role": "user",
"content": (
f"{city}의 현재 날씨를 조회하고 "
f"기상 분석 리포트를 작성해줘."
),
},
]
이 부분이 매우 중요합니다.
매 도시마다 새로운 messages를 만듭니다.
즉:
서울
messages = 새로 생성
도쿄
messages = 새로 생성
뉴욕
messages = 새로 생성
입니다.
이것이 Context Reset입니다.
55. 왜 Context Reset이 필요할까요?
만약 하나의 messages를 계속 사용한다면:
서울 날씨
서울 분석
서울 결과
도쿄 날씨
도쿄 분석
...
이 모두 하나의 대화 기록에 들어갑니다.
그러면 Agent가 이전 도시의 정보를 참고할 가능성이 있습니다.
우리는 도시별로 독립된 작업을 원합니다.
따라서:
서울 Agent
= 서울만 알고 시작
도쿄 Agent
= 도쿄만 알고 시작
하도록 합니다.
56. Agent Loop
for step in range(1, 6):
최대 5번의 Agent Loop를 허용합니다.
왜 필요할까요?
LLM이 한 번에 끝내지 않을 수도 있기 때문입니다.
예:
Step 1
LLM → Tool Call
Step 2
Tool 결과 → LLM
Step 3
LLM → 최종 답변
이런 구조가 가능합니다.
57. LLM 호출
response = openai_client.responses.create(
model=MODEL,
input=messages,
tools=tools,
tool_choice="auto",
)
이 부분이 AI Agent의 두뇌입니다.
LLM에게:
- 지금까지의 대화
- Skill
- 사용할 수 있는 Tool
을 전달합니다.
58. LLM은 Tool을 실행하지 않습니다
이것을 반드시 기억하면 좋습니다.
LLM은:
"get_weather_info를 호출해야겠다."
라고 결정합니다.
그리고:
function_call
을 반환합니다.
실제 Tool 실행은 Harness가 합니다.
즉:
LLM
│
│ 판단
▼
Tool Call
│
▼
Harness
│
│ 실제 실행
▼
MCP
입니다.
59. messages.extend(response.output)
messages.extend(response.output)
이것은 LLM의 출력을 대화 기록에 추가합니다.
즉:
messages
↓
LLM 응답
↓
messages에 추가
합니다.
이것이 중요한 이유는 다음 LLM 호출 때:
"아까 내가 Tool을 호출하려고 했다는 사실"
도 알고 있어야 하기 때문입니다.
60. Tool Call 찾기
tool_calls = [
item
for item in response.output
if item.type == "function_call"
]
LLM의 응답 중에서:
function_call
인 것만 골라냅니다.
예를 들어 LLM이:
function_call:
name = get_weather_info
arguments = {"location":"서울"}
을 반환했다면 이것을 잡습니다.
61. Tool Call이 없다면?
if not tool_calls:
Tool을 호출하지 않았다면:
report_content = response.output_text
로 최종 답변을 가져옵니다.
즉:
LLM
↓
Tool Call 없음
↓
최종 답변
입니다.
62. Tool Call이 있으면
for tool_call in tool_calls:
Tool 호출 요청을 하나씩 처리합니다.
63. Tool 이름
tool_name = tool_call.name
예:
get_weather_info
가 됩니다.
64. Tool arguments
tool_args = json.loads(
tool_call.arguments
)
LLM이 반환한 arguments는 JSON 문자열 형태이기 때문에 Python dictionary로 바꿉니다.
예:
'{"location":"서울"}'
↓
{
"location": "서울"
}
65. 드디어 MCP 호출
가장 중요한 코드 중 하나입니다.
result = await session.call_tool(
tool_name,
arguments=tool_args,
)
이 한 줄이:
Harness
↓
MCP Client
↓
MCP Server
를 실행합니다.
예를 들어:
tool_name = "get_weather_info"
tool_args = {
"location": "서울"
}
이면:
MCP Server에게
get_weather_info(
location="서울"
)
실행해 주세요.
라고 요청하는 것입니다.
66. MCP Server에서는 무슨 일이 일어날까요?
weather_mcp_server.py에서:
@mcp.tool()
def get_weather_info(location):
가 실행됩니다.
그리고:
서울
↓
Geocoding API
↓
위도/경도
↓
Forecast API
↓
기온/습도/weather code
↓
한국어 날씨 상태 변환
↓
dict 반환
합니다.
67. 결과가 Harness로 돌아옵니다
result = await session.call_tool(...)
의 result에 MCP Server의 결과가 들어옵니다.
그 다음:
result_text = extract_mcp_result(result)
로 LLM에게 전달할 문자열로 바꿉니다.
68. 가장 중요한 부분: Tool 결과를 다시 LLM에게
messages.append(
{
"type": "function_call_output",
"call_id": tool_call.call_id,
"output": result_text,
}
)
이것이 Agent Loop의 핵심입니다.
전체를 보면:
LLM
│
│ Tool Call
▼
Harness
│
▼
MCP Client
│
▼
MCP Server
│
▼
Open-Meteo
│
▼
결과
│
▼
MCP Client
│
▼
Harness
│
│ function_call_output
▼
LLM
입니다.
69. 그래서 이것이 진짜 Agent Loop입니다
우리가 이전에 공부했던 개념과 정확히 연결됩니다.
Think
↓
Act
↓
Observe
↓
Think
↓
Respond
구체적으로:
LLM 판단
↓
Tool Call
↓
MCP Tool 실행
↓
Open-Meteo 결과
↓
LLM이 결과 관찰
↓
Skill 정책 적용
↓
최종 리포트
입니다.
70. run_harness()가 전체 지휘자입니다
이제:
async def run_harness():
를 봅니다.
이 함수는 말 그대로 Harness 전체를 실행하는 지휘자입니다.
71. Plan 확인
if not PLAN_FILE.exists():
print("계획 파일이 없습니다.")
return
계획이 없으면 실행하지 않습니다.
실제로 프로그램 맨 아래에서 처음 한 번 생성합니다.
72. Plan 읽기
plan = load_plan()
예:
{
"서울": "pending",
"도쿄": "completed",
"뉴욕": "pending"
}
73. Skill 읽기
skill_instruction = load_skill()
SKILL.md를 읽어서 문자열로 가져옵니다.
74. MCP Server 연결
async with sse_client(
MCP_SERVER_URL
) as (
read_stream,
write_stream,
):
여기서 MCP Server에 연결합니다.
우리가 실행할 때 보았던:
>> MCP Server 연결 성공
이 단계입니다.
75. ClientSession 생성
async with ClientSession(
read_stream,
write_stream,
) as session:
이제 session이라는 MCP Client 세션이 만들어집니다.
이후:
session.list_tools()
session.call_tool()
을 사용할 수 있습니다.
76. MCP 초기화
await session.initialize()
MCP Client와 Server가 서로:
"너는 누구고 어떤 기능을 지원하는가?"
등을 확인하면서 MCP 연결을 초기화합니다.
77. Tool 목록 조회
tool_result = await session.list_tools()
Server에게:
"사용할 수 있는 Tool을 알려줘."
라고 합니다.
Server가:
get_weather_info
를 알려줍니다.
그래서 실제 실행 결과에서:
>> MCP Tools:
- get_weather_info
가 나온 것입니다.
78. MCP Tool을 OpenAI Tool로 변환
tools = [
convert_mcp_tool_to_openai(tool)
for tool in tool_result.tools
]
이제:
MCP 세계
에서:
OpenAI LLM 세계
로 Tool 정보를 전달할 수 있도록 바꿉니다.
79. 도시별 반복
for city, status in plan.items():
예:
서울
도쿄
뉴욕
런던
파리
를 하나씩 처리합니다.
80. 완료된 도시는 건너뛰기
if status == "completed":
continue
이것이 Persistence의 핵심입니다.
예를 들어:
{
"서울": "completed",
"도쿄": "completed",
"뉴욕": "pending"
}
이면:
서울 → Skip
도쿄 → Skip
뉴욕 → 실행
합니다.
81. Retry
for attempt in range(
1,
MAX_RETRIES + 1,
):
도시 하나를 최대 3번 시도합니다.
1회
↓ 실패
2회
↓ 실패
3회
입니다.
82. process_city() 호출
report_content = await process_city(
city=city,
skill_instruction=skill_instruction,
session=session,
tools=tools,
)
여기서 실제 Agent 작업이 시작됩니다.
즉:
run_harness()
│
▼
process_city()
│
▼
LLM
│
▼
MCP
│
▼
Open-Meteo
│
▼
LLM
│
▼
report
입니다.
83. 결과 저장
report_path = (
REPORT_DIR
/ f"{city}.md"
)
예를 들어:
reports/서울.md
가 됩니다.
그리고:
with open(
report_path,
"w",
encoding="utf-8",
) as f:
f.write(report_content)
최종 결과를 파일로 저장합니다.
이것이 Harness의 Persistence입니다.
84. 상태 업데이트
plan[city] = "completed"
예:
서울 pending
이:
서울 completed
로 바뀝니다.
그리고:
save_plan(plan)
으로 저장합니다.
85. 실패하면
모든 retry가 실패하면:
if not success:
plan[city] = "failed"
save_plan(plan)
이 됩니다.
따라서 최종적으로:
{
"서울": "completed",
"도쿄": "completed",
"뉴욕": "failed",
"런던": "completed",
"파리": "pending"
}
같은 상태가 가능합니다.
이것이 실제 업무 시스템에서 상당히 중요한 개념입니다.
86. 마지막 asyncio.run()
파일 마지막:
if __name__ == "__main__":
cities = [
"서울",
"도쿄",
"뉴욕",
"런던",
"파리",
]
if not PLAN_FILE.exists():
initialize_plan(cities)
asyncio.run(
run_harness()
)
입니다.
Python 파일을 직접 실행하면:
cities 정의
↓
plan.json 없나?
↓
없음 → Plan 생성
↓
asyncio.run()
↓
run_harness()
가 실행됩니다.
87. 이제 정말 중요한 "전체 실행 과정"
사용자가:
python harness.py
를 실행했다고 생각해보겠습니다.
STEP 1. Plan
plan.json 확인
없으면:
{
"서울": "pending",
"도쿄": "pending",
"뉴욕": "pending",
"런던": "pending",
"파리": "pending"
}
생성.
STEP 2. Skill
SKILL.md 읽기
↓
system message
로 준비.
STEP 3. MCP 연결
Harness
↓
SSE
↓
MCP Server
STEP 4. Tool 발견
list_tools()
↓
get_weather_info
STEP 5. Tool Schema 변환
MCP Tool Schema
↓
OpenAI Tool Schema
STEP 6. 서울 Agent 생성
system:
SKILL.md
user:
서울의 현재 날씨를 조회하고
기상 분석 리포트를 작성해줘.
STEP 7. LLM 판단
LLM:
날씨 데이터가 필요하다.
↓
function_call
get_weather_info
location="서울"
STEP 8. Harness가 MCP Tool 실행
Harness
↓
MCP Client
↓
MCP Server
STEP 9. MCP Server
get_weather_info("서울")
↓
Geocoding API
↓
서울의 위도/경도
↓
Forecast API
↓
기온
습도
weather_code
STEP 10. MCP 결과
예:
{
"location": "Seoul",
"temperature_c": 22.4,
"humidity": 47,
"condition": "맑음"
}
STEP 11. 다시 LLM
Harness가:
function_call_output
으로 전달합니다.
LLM:
기온 22.4℃
습도 47%
맑음
을 확인합니다.
STEP 12. Skill 적용
Skill:
15~25℃
습도 60% 미만
이면:
최적
이라고 판단.
STEP 13. 최종 리포트
### 서울 기상 분석 리포트
- 현재 날씨: 22.4°C, 맑음
(습도: 47%)
- 전문가 분석: ...
- 오늘의 권고:
조깅 적합도: 최적
...
STEP 14. Persistence
reports/서울.md
저장.
그리고:
{
"서울": "completed"
}
로 Plan 업데이트.
88. 결국 두 파일의 관계는 이렇게 이해하면 됩니다
weather_mcp_server.py
"나는 이런 능력이 있다."
┌──────────────────────┐
│ Weather MCP Server │
│ │
│ get_weather_info() │
│ ↓ │
│ Open-Meteo │
└──────────────────────┘
harness.py
"그 능력을 이용해서 이 업무를 끝까지 수행하겠다."
┌─────────────────────────────┐
│ Harness │
│ │
│ Plan │
│ Skill │
│ LLM │
│ Context Reset │
│ MCP Client │
│ Agent Loop │
│ Retry │
│ Persistence │
└─────────────────────────────┘
89. 특히 SKILL.md와 MCP를 혼동하면 안 됩니다
이 부분은 앞으로 AI Agent 공부에서 굉장히 중요합니다.
SKILL
"어떻게 일할 것인가?"
입니다.
예:
기온과 습도를 분석해서 조깅 적합도를 판단해라.
MCP Tool
"무엇을 할 수 있는가?"
입니다.
예:
현재 서울 날씨를 가져올 수 있다.
LLM
"어떻게 판단할 것인가?"
입니다.
Harness
"전체 일을 어떻게 안정적으로 끝낼 것인가?"
입니다.
90. 네 가지를 한 문장으로 연결하면
SKILL은 업무 방법을 알려주고, LLM은 판단하고, MCP Tool은 실제 행동을 수행하며, Harness는 그 전체 업무가 계획대로 끝까지 수행되도록 관리합니다.
이 문장을 기억하시면 좋습니다.
91. 지금 우리가 만든 시스템을 역할별로 다시 정리
| Skill | 어떻게 일할까? | SKILL.md |
| LLM | 무엇을 판단할까? | OpenAI |
| Tool | 무엇을 실행할까? | get_weather_info |
| MCP Server | Tool을 어디서 제공할까? | weather_mcp_server.py |
| MCP Client | Server와 어떻게 연결할까? | harness.py |
| Agent Loop | 판단→실행→결과→판단을 어떻게 반복할까? | process_city() |
| Harness | 여러 작업을 어떻게 관리할까? | run_harness() |
| Plan | 어디까지 했나? | plan.json |
| Persistence | 결과를 어디에 저장하나? | reports/*.md |
| Retry | 실패하면 어떻게 하나? | MAX_RETRIES |
92. 그리고 지금 코드에서 가장 중요한 함수 6개
처음에는 전체 코드가 길어서 복잡해 보이지만, 실제로는 아래 6개를 중심으로 이해하면 됩니다.
① get_coordinates()
도시 → 좌표
② get_weather_info()
좌표 → 실제 날씨
③ convert_mcp_tool_to_openai()
MCP Tool → LLM이 사용할 Tool
④ process_city()
한 도시를 AI Agent가 처리
⑤ run_harness()
전체 도시 작업 관리
⑥ initialize_plan()
작업 계획 생성
이 6개가 머릿속에 들어오면 전체 코드가 상당히 단순해집니다.
93. 지금 단계에서 가장 중요한 그림
마지막으로 이것 하나는 꼭 기억하셨으면 합니다.
┌───────────────┐
│ SKILL.md │
│ "어떻게 할까" │
└───────┬───────┘
│
▼
┌──────────┐ ┌───────────┐
│ Harness │───────▶│ LLM │
│ │ │ "판단" │
│ Plan │ └─────┬─────┘
│ State │ │
│ Retry │ Tool Call
│ Context │ │
│ Save │ ▼
└──────────┘ ┌───────────┐
▲ │ MCP Client│
│ └─────┬─────┘
│ │
│ ▼
│ ┌───────────┐
│ │MCP Server │
│ └─────┬─────┘
│ │
│ ▼
│ get_weather_info()
│ │
│ ▼
│ Open-Meteo
│ │
│ ▼
│ 실제 날씨
│ │
│ ▼
└────────── LLM ← 결과
│
▼
최종 리포트
│
▼
reports/서울.md
지금까지 공부한 것들이 여기에서 하나로 합쳐졌습니다.
특히 이번 코드를 이해할 때는 weather_mcp_server.py를 "Tool 제공자", harness.py를 "AI Agent + 작업 관리자"라고 구분해서 생각하면 훨씬 쉽습니다.
그리고 다음 단계에서는 이 코드에서 **responses.create() → function_call → session.call_tool() → function_call_output → 두 번째 responses.create()**가 실제로 어떻게 이어지는지를 별도로 떼어서 보면 좋습니다.