도구 설계 플레이어
tools_basic.py 의 @tool 세 단계와 tool_error.py 의 네 갈래를 코드와 함께 한 단계씩 넘겨 봅니다. 단계마다 도구 객체의 이름·설명·스키마와, Bedrock 모델 호출에 실리는 toolSpec 을 실제로 변환해 얻은 값으로 나란히 보여 줍니다.
모델이 무엇을 할 수 있는지는 결국 도구의 설명서가 정합니다. 설명서를 쓰는 법, 실패를 다루는 법, 그리고 도구를 프로세스 밖의 서버(MCP)로 떼어 내는 법을 차례로 다룹니다.
3일차에 ToolNode로 ReAct 그래프를 조립했다면, 4일차는 그 그래프에 꽂히는 도구 자체를 다룹니다. 앞 절반에서는 도구를 정의하는 세 단계, 실패를 예외가 아닌 메시지로 돌려주는 원칙, ToolNode의 병렬 실행과 에러 처리, messages로 실행 과정을 추적하는 방법을 익힙니다. 뒤 절반에서는 도구를 별도 프로세스의 MCP 서버로 분리해 표준 프로토콜로 연결하고, 직접 만든 서버와 남이 만든 서버를 한 Agent에 함께 붙입니다. 마지막에는 백엔드 장애를 일부러 주입해 도구의 재시도와 Agent의 백업 경로 전환이 실제로 이어지는지 확인합니다.
ToolNode 가 한 턴의 도구 요청 여러 건을 함께 실행하는 것과, handle_tool_errors 가 예외를 ToolMessage 로 바꾸는 과정을 안다messages 리스트만 보고 Agent가 어떤 도구를 어떤 인자로 불렀는지 재구성할 수 있다1일차 끝에서는 while 루프로, 3일차에서는 ToolNode 로 도구를 돌렸습니다. 그때 도구는 "이미 주어진 것"이었습니다. 오늘은 방향을 바꿔 도구 자체를 어떻게 만들고, 어떻게 고장에 대비하고, 어떻게 여러 프로그램이 나눠 쓰게 할 것인가를 다룹니다.
먼저 용어를 한 번 정리합니다. 도구 호출(tool calling)은 모델이 답 대신 "이 함수를 이 인자로 실행해 달라"는 요청(tool_calls)을 돌려주는 기능입니다. 모델은 함수를 실행하지 않습니다. 실행은 우리 프로그램이 하고, 결과를 ToolMessage 에 담아 다시 모델에게 보냅니다. 이 왕복을 모델이 "더 부를 도구가 없다"고 판단할 때까지 되풀이하는 프로그램을 이 강의에서는 Agent라고 부릅니다.
모델이 도구에 대해 아는 것은 우리가 써 준 이름, 설명, 입력 스키마뿐이고, 도구를 부른 뒤에 아는 것은 도구가 돌려준 문자열뿐입니다. 따라서 "엉뚱한 도구를 고른다", "인자를 잘못 넣는다", "실패했는데 포기해 버린다" 같은 문제는 모델을 바꾸기 전에 이 두 가지 글부터 고쳐야 풀립니다. 오늘 실습의 대부분은 그 글을 어떻게 쓰느냐에 관한 것입니다.
@tool 의 세 단계, 실패를 메시지로 돌려주기, ToolNode 의 병렬 실행과 에러 처리, 실행 추적.
도구를 별도 프로세스의 MCP 서버로 분리하고, 클라이언트로 검증한 뒤 Agent에 연결합니다. 남이 만든 서버도 같은 방식으로 붙입니다.
백엔드에 타임아웃, 호출 한도 초과, 형식 오류를 일부러 주입하고, 재시도와 백업 도구로 답을 끝까지 만들어 내는지 판정합니다.
tools_basic.py, tool_error.py, make_data.py, mcp_server.py, mcp_verify.py 에는 모델 객체가 없습니다. Bedrock 자격 증명이 없어도 실행되고 비용도 들지 않습니다. 도구와 MCP 서버는 모델 없이 먼저 검증할 수 있다는 점이 오늘 내내 되풀이되는 작업 방식입니다. 나머지 파일(toolnode_parallel.py, trace.py, mcp_agent.py, mcp_agent_external.py, failure_agent.py)은 코드에 적힌 대로 Bedrock의 Claude를 호출합니다. MCP 관련 패키지는 requirements.txt 에 mcp==1.30.0, fastmcp==2.13.0, langchain-mcp-adapters==0.3.2 로 버전이 고정돼 있고, 이 페이지에서 라이브러리 동작을 설명할 때는 이 버전을 기준으로 합니다.
파이썬 함수에 @tool 을 붙이면 함수는 도구 객체가 됩니다. 이 객체에서 모델에게 전달되는 것은 함수의 본문이 아니라 세 가지 메타데이터입니다. 같은 데코레이터를 세 단계 깊이로 써 보면서 그 세 가지를 얼마나 세밀하게 통제할 수 있는지 확인합니다.
| 모델에게 가는 것 | 기본으로 어디서 오나 | 모델이 이것으로 하는 판단 |
|---|---|---|
이름 (name) | 함수 이름 | 어떤 도구를 부를지 지목할 때 쓰는 식별자 |
설명 (description) | docstring | 이 도구를 언제 쓰고 언제 쓰지 않을지 |
입력 스키마 (args_schema) | 인자의 타입 힌트 | 인자 이름, 타입, 필수 여부. 어떤 값을 채워 넣을지 |
6# 단계 1: 가장 단순한 형태 7# 이름은 함수 이름, 설명은 docstring, 스키마는 타입 힌트에서 자동 추론 8@tool 9def add(a: int, b: int) -> int: 10 """두 정수를 더한다.""" 11 return a + b
타입 힌트 a: int, b: int 가 "정수 두 개가 필수"라는 스키마가 되고, docstring 한 줄이 설명이 됩니다. 타입 힌트를 빼먹으면 스키마에 타입 정보가 실리지 않으므로, 도구로 쓸 함수에는 타입 힌트를 반드시 적습니다.
14# 단계 2: 이름과 설명을 데코레이터 인자로 명시 15# 도구 이름은 calculator, 함수 이름은 _internal_calc로 분리 가능 16@tool("calculator", description="수학 계산을 정확하게 수행한다. LLM은 큰 숫자 계산에 약하므로 계산이 필요하면 이 도구를 쓴다.") 17def _internal_calc(expression: str) -> str: 18 return str(eval(expression, {"__builtins__": {}}, {}))
코드에서 쓰기 좋은 이름과 모델이 읽기 좋은 이름은 다를 수 있습니다. 데코레이터의 첫 인자로 이름을, description 으로 설명을 주면 함수 이름·docstring과 분리됩니다. 이 설명문에는 "무엇을 한다"에 더해 "LLM은 큰 숫자 계산에 약하므로 계산이 필요하면 이 도구를 쓴다"는 사용 시점이 적혀 있습니다. 설명은 기능 소개가 아니라 모델에게 주는 지시문이라는 점이 드러나는 대목입니다.
이 계산기는 eval(expression, {"__builtins__": {}}, {}) 로 수식을 실행합니다. 내장 함수를 비워 두었지만 이것만으로 안전한 격리가 되지는 않습니다. 모델이 만든 문자열, 더 거슬러 올라가면 사용자가 입력한 문자열이 코드로 실행되는 구조이기 때문입니다. 실제 서비스에서는 사칙연산만 해석하는 수식 파서를 쓰거나 허용할 문자를 제한합니다.
타입 힌트로는 "문자열이다"까지만 말할 수 있습니다. "2자 이상 20자 이하", "부분 일치는 안 된다" 같은 규칙과 인자별 설명을 적으려면 Pydantic 모델을 만들어 args_schema 로 넘깁니다. Pydantic은 클래스에 적은 타입과 제약으로 데이터를 검증하는 라이브러리이고, 1일차의 구조화 출력에서 이미 쓴 것과 같은 것입니다.
21# 단계 3: Pydantic으로 인자 스키마를 강제 22# 타입 힌트만으로 부족할 때 (검증 규칙, 기본값, 인자별 설명) 23class SearchEmployeeInput(BaseModel): 24 name: str = Field( 25 description="직원의 정확한 한국어 이름 (부분 일치 불가)", 26 min_length=2, max_length=20, # 너무 짧거나 긴 값을 실행 전에 차단 27 ) 28 29 30@tool(args_schema=SearchEmployeeInput) 31def search_employee(name: str) -> str: 32 """삼성SDS 임직원 디렉터리에서 이름으로 직원 정보를 조회한다. 33 34 직원의 소속 팀, 직무, 이메일이 필요할 때 사용한다. 35 사내 규정이나 정책 질문에는 사용하지 않는다. 36 없는 이름이면 "찾을 수 없습니다"를 돌려준다. 37 """ 38 # 교육용 더미 데이터입니다 39 fake_db = { 40 "김하늘": "김하늘 / 클라우드운영팀 / 팀장 / [email protected]", 41 "박도윤": "박도윤 / 물류플랫폼팀 / 백엔드 / [email protected]", 42 } 43 return fake_db.get(name, f"'{name}' 직원을 찾을 수 없습니다.")
이 docstring은 네 줄이 각각 다른 역할을 합니다. 도구 설명을 쓸 때 그대로 본떠 쓸 수 있는 구조입니다.
46if __name__ == "__main__": 47 # 도구는 함수가 아니라 객체이므로 .invoke()로 호출합니다 48 print(add.invoke({"a": 3, "b": 5})) 49 print(_internal_calc.invoke({"expression": "15 * 25"})) 50 print(search_employee.invoke({"name": "김하늘"})) 51 52 # Pydantic 검증: 잘못된 인자는 도구 실행 전에 차단됩니다 53 try: 54 search_employee.invoke({"name": "김"}) # min_length=2 위반 55 except Exception as e: 56 print(f"검증 에러 (예상된 동작): {type(e).__name__}") 57 58 print("=== 도구 메타데이터 (LLM이 실제로 보는 것) ===") 59 for t in [add, _internal_calc, search_employee]: 60 print(f"이름: {t.name}") 61 print(f"설명: {t.description}") 62 print(f"스키마: {t.args_schema.model_json_schema() if t.args_schema else None}") 63 print()
이 파일은 모델을 부르지 않으므로 그대로 실행해 볼 수 있습니다. 아래는 이 저장소의 가상환경에서 실행한 실제 출력입니다.
18 2375 3김하늘 / 클라우드운영팀 / 팀장 / [email protected] 4검증 에러 (예상된 동작): ValidationError 5=== 도구 메타데이터 (LLM이 실제로 보는 것) === 6이름: add 7설명: 두 정수를 더한다. 8스키마: {'description': '두 정수를 더한다.', 'properties': {'a': {'title': 'A', 'type': 'integer'}, 'b': {'title': 'B', 'type': 'integer'}}, 'required': ['a', 'b'], 'title': 'add', 'type': 'object'} 9 10이름: calculator 11설명: 수학 계산을 정확하게 수행한다. LLM은 큰 숫자 계산에 약하므로 계산이 필요하면 이 도구를 쓴다. 12스키마: {'properties': {'expression': {'title': 'Expression', 'type': 'string'}}, 'required': ['expression'], 'title': 'calculator', 'type': 'object'} 13 14이름: search_employee 15설명: 삼성SDS 임직원 디렉터리에서 이름으로 직원 정보를 조회한다. 16 17직원의 소속 팀, 직무, 이메일이 필요할 때 사용한다. 18사내 규정이나 정책 질문에는 사용하지 않는다. 19없는 이름이면 "찾을 수 없습니다"를 돌려준다. 20스키마: {'properties': {'name': {'description': '직원의 정확한 한국어 이름 (부분 일치 불가)', 'maxLength': 20, 'minLength': 2, 'title': 'Name', 'type': 'string'}}, 'required': ['name'], 'title': 'SearchEmployeeInput', 'type': 'object'}
출력에서 확인할 것은 세 가지입니다.
| 관찰 | 뜻 |
|---|---|
스키마가 type, properties, required 로 이뤄진 dict다 | 이 형식이 JSON Schema입니다. 데이터의 모양을 JSON으로 기술하는 표준이고, 모델 API가 도구의 입력 형식을 받는 방식입니다. 타입 힌트 int 가 'type': 'integer' 로 바뀌어 있습니다. |
search_employee 의 스키마에 minLength, maxLength, description 이 들어 있다 | Field 에 적은 규칙과 설명이 스키마에 실려 모델에게 전달됩니다. 모델은 호출 전에 "이름은 2자 이상, 부분 일치 불가"를 알 수 있습니다. |
{"name": "김"} 로 부르자 ValidationError 가 났다 | 같은 규칙이 실행 직전에 한 번 더 쓰입니다. 규칙을 어긴 인자는 함수 본문에 도달하지 못합니다. |
한 번은 모델에게 "이렇게 넣어라"고 알려 주는 안내문으로, 또 한 번은 모델이 그래도 틀리게 넣었을 때 막아 주는 검문소로 일합니다. 모델의 출력은 확률적이므로 안내만으로는 부족하고, 검문소만 두면 모델이 규칙을 모른 채 계속 부딪힙니다.
"""직원 검색."""
무엇을 넣어야 하는지, 팀 명단을 물을 때도 써도 되는지, 없는 사람을 넣으면 어떻게 되는지 알 수 없습니다. 모델은 이름만 보고 추측합니다.
무엇을 하는지, 언제 쓰고 언제 쓰지 않는지, 실패하면 무엇이 오는지를 적습니다. 인자에는 Field(description=...) 로 형식과 예시를 붙입니다.
도구 설명과 스키마는 모델을 부를 때마다 입력에 함께 실립니다. 도구가 많고 설명이 길수록 매 호출의 입력 토큰이 늘어납니다. 그렇다고 설명을 줄이면 오선택이 늘어나므로, 도구의 개수를 관리하는 쪽이 먼저입니다. 도구가 너무 많아져 한 Agent가 감당하기 어려워지는 문제는 6일차 멀티 에이전트에서 역할을 나누는 방식으로 다룹니다.
세 단계와 다음 절의 tool_error.py 를 한 줄씩 넘겨 보며, 모델 호출에 실제로 실리는 도구 정보가 어떻게 달라지는지 확인할 수 있는 자료입니다.
tools_basic.py 의 @tool 세 단계와 tool_error.py 의 네 갈래를 코드와 함께 한 단계씩 넘겨 봅니다. 단계마다 도구 객체의 이름·설명·스키마와, Bedrock 모델 호출에 실리는 toolSpec 을 실제로 변환해 얻은 값으로 나란히 보여 줍니다.
.invoke() 로 실행하고, Pydantic 검증이 실행 전에 걸리는 것과 도구별 이름·설명·스키마를 확인합니다.1# tools_basic.py - @tool 데코레이터 3단계 깊이로 써보기 2from langchain_core.tools import tool 3from pydantic import BaseModel, Field 4 5 6# 단계 1: 가장 단순한 형태 7# 이름은 함수 이름, 설명은 docstring, 스키마는 타입 힌트에서 자동 추론 8@tool 9def add(a: int, b: int) -> int: 10 """두 정수를 더한다.""" 11 return a + b 12 13 14# 단계 2: 이름과 설명을 데코레이터 인자로 명시 15# 도구 이름은 calculator, 함수 이름은 _internal_calc로 분리 가능 16@tool("calculator", description="수학 계산을 정확하게 수행한다. LLM은 큰 숫자 계산에 약하므로 계산이 필요하면 이 도구를 쓴다.") 17def _internal_calc(expression: str) -> str: 18 return str(eval(expression, {"__builtins__": {}}, {})) 19 20 21# 단계 3: Pydantic으로 인자 스키마를 강제 22# 타입 힌트만으로 부족할 때 (검증 규칙, 기본값, 인자별 설명) 23class SearchEmployeeInput(BaseModel): 24 name: str = Field( 25 description="직원의 정확한 한국어 이름 (부분 일치 불가)", 26 min_length=2, max_length=20, # 너무 짧거나 긴 값을 실행 전에 차단 27 ) 28 29 30@tool(args_schema=SearchEmployeeInput) 31def search_employee(name: str) -> str: 32 """삼성SDS 임직원 디렉터리에서 이름으로 직원 정보를 조회한다. 33 34 직원의 소속 팀, 직무, 이메일이 필요할 때 사용한다. 35 사내 규정이나 정책 질문에는 사용하지 않는다. 36 없는 이름이면 "찾을 수 없습니다"를 돌려준다. 37 """ 38 # 교육용 더미 데이터입니다 39 fake_db = { 40 "김하늘": "김하늘 / 클라우드운영팀 / 팀장 / [email protected]", 41 "박도윤": "박도윤 / 물류플랫폼팀 / 백엔드 / [email protected]", 42 } 43 return fake_db.get(name, f"'{name}' 직원을 찾을 수 없습니다.") 44 45 46if __name__ == "__main__": 47 # 도구는 함수가 아니라 객체이므로 .invoke()로 호출합니다 48 print(add.invoke({"a": 3, "b": 5})) 49 print(_internal_calc.invoke({"expression": "15 * 25"})) 50 print(search_employee.invoke({"name": "김하늘"})) 51 52 # Pydantic 검증: 잘못된 인자는 도구 실행 전에 차단됩니다 53 try: 54 search_employee.invoke({"name": "김"}) # min_length=2 위반 55 except Exception as e: 56 print(f"검증 에러 (예상된 동작): {type(e).__name__}") 57 58 print("=== 도구 메타데이터 (LLM이 실제로 보는 것) ===") 59 for t in [add, _internal_calc, search_employee]: 60 print(f"이름: {t.name}") 61 print(f"설명: {t.description}") 62 print(f"스키마: {t.args_schema.model_json_schema() if t.args_schema else None}") 63 print()
도구는 실패합니다. 없는 이름이 들어오고, 인자가 비어 있고, 뒤쪽 시스템이 응답하지 않습니다. 이때 실패를 어떤 형태로 알리느냐가 Agent가 스스로 고쳐 볼 수 있는지를 결정합니다.
일반 프로그램에서 함수가 실패하면 예외를 던지고, 호출한 쪽의 except 가 받아 처리합니다. 그런데 Agent에서 도구를 "부르기로 결정한" 쪽은 모델입니다. 모델은 예외를 받을 수 없고 메시지만 읽습니다. 예외가 모델에게 닿지 못하고 프로그램 위쪽으로 올라가 버리면, 모델은 무엇이 잘못됐는지 알 기회를 얻지 못한 채 실행이 끝납니다.
처리하는 코드가 없으면 그래프 실행이 예외와 함께 중단됩니다. 사용자는 답 대신 오류를 받고, 모델은 인자를 고쳐 다시 시도할 기회가 없습니다.
실패 내용이 ToolMessage 에 담겨 대화 기록에 들어갑니다. 모델은 그것을 도구 결과로 읽고 다음 행동(다른 인자로 재호출, 다른 도구 사용, 사용자에게 되묻기)을 정합니다.
12@tool 13def search_employee(name: str) -> str: 14 """삼성SDS 임직원 디렉터리에서 이름으로 직원 정보를 조회한다. 15 16 Args: 17 name: 직원의 정확한 한국어 이름 (최소 2자) 18 19 Returns: 20 직원 정보 문자열, 또는 다음 행동을 안내하는 에러 메시지 21 """ 22 try: 23 # 1) 입력 검증 실패 -> raise 대신 안내 메시지 반환 24 if not name or len(name) < 2: 25 return "에러: 이름이 너무 짧습니다. 최소 2자 이상의 정확한 한국어 이름으로 다시 호출하세요." 26 27 # 2) 정상 검색 28 for emp in EMPLOYEES: 29 if emp["name"] == name: 30 return f"{emp['name']} / {emp['team']} / {emp['email']}" 31 32 # 3) 결과 없음 -> 후보를 함께 알려주면 LLM이 다음 판단을 하기 쉬움 33 candidates = ", ".join(e["name"] for e in EMPLOYEES) 34 return f"'{name}' 직원을 찾을 수 없습니다. 등록된 직원: {candidates}" 35 36 except Exception as e: 37 # 4) 예상 못한 예외도 그래프를 죽이지 않고 메시지로 변환 38 return f"에러: 조회 중 문제가 발생했습니다 ({type(e).__name__}). 잠시 후 다시 시도하세요."
try 로 감싸고, 무엇이 터지든 예외 종류의 이름만 담은 안내문으로 바꿉니다.이 파일도 모델 없이 실행됩니다. 실제 출력은 다음과 같습니다.
41if __name__ == "__main__": 42 print(search_employee.invoke({"name": "김"})) # 너무 짧음 43 print(search_employee.invoke({"name": "홍길동"})) # 없는 직원
1에러: 이름이 너무 짧습니다. 최소 2자 이상의 정확한 한국어 이름으로 다시 호출하세요. 2'홍길동' 직원을 찾을 수 없습니다. 등록된 직원: 김하늘, 박도윤, 이서준 3김하늘 / 클라우드운영팀 / [email protected]
| 조건 | 이 코드에서 | 없으면 생기는 일 |
|---|---|---|
| 무엇이 잘못됐는지 말한다 | "이름이 너무 짧습니다" | 모델이 같은 인자로 다시 부릅니다 |
| 다음에 할 일을 말한다 | "...로 다시 호출하세요", "잠시 후 다시 시도하세요" | 모델이 그대로 포기하고 "조회할 수 없습니다"라고 답합니다 |
| 고르는 데 필요한 정보를 준다 | "등록된 직원: 김하늘, 박도윤, 이서준" | 모델이 이름을 지어내어 다시 시도합니다 |
| 에러임을 알아볼 수 있다 | "에러:" 로 시작 | 모델이 실패 문구를 정상 데이터로 오해해 답변에 그대로 옮깁니다 |
앞 절의 tools_basic.py 는 "2자 이상" 규칙을 args_schema 에 적었고, 이 파일은 같은 규칙을 함수 본문의 if 로 검사합니다. 둘은 실패가 드러나는 방식이 다릅니다.
args_schema 로 검증 | 본문에서 검사 후 메시지 반환 | |
|---|---|---|
| 규칙이 모델에게 미리 전달되나 | 예. 스키마에 minLength 로 실립니다 | docstring에 적은 만큼만 전달됩니다 (여기서는 "최소 2자") |
| 위반하면 | ValidationError 예외가 납니다. 이것을 메시지로 바꾸는 일은 도구를 실행하는 쪽(다음 절의 ToolNode)이 맡습니다 | 도구가 직접 쓴 안내문이 반환됩니다 |
| 메시지의 내용 | 검증 라이브러리가 만든 문구 | 우리가 원하는 말투와 지시를 넣을 수 있습니다 |
예외를 문자열로 바꾸면 프로그램은 조용히 계속 돌아갑니다. 모델에게는 좋은 일이지만 운영자에게는 장애가 숨는 일입니다. 4번 갈래처럼 예상하지 못한 예외를 삼킬 때는 로그를 따로 남겨야 합니다. 이 파일에는 로그가 없고, 뒤의 장애 주입 실습에서 logger 로 남기는 형태를 봅니다. 또 후보 목록을 통째로 돌려주는 방식은 데이터가 세 건뿐인 실습이라 가능한 것입니다. 실제 임직원 명부라면 비슷한 이름 몇 개로 줄여야 하고, 조회 권한 밖의 정보가 에러 메시지로 새지 않는지도 확인해야 합니다.
1# tool_error.py - 실패를 메시지로 돌려주고 Agent가 복구하게 만들기 2from langchain_core.tools import tool 3 4# 교육용 더미 데이터입니다! 5EMPLOYEES = [ 6 {"name": "김하늘", "team": "클라우드운영팀", "email": "[email protected]"}, 7 {"name": "박도윤", "team": "물류플랫폼팀", "email": "[email protected]"}, 8 {"name": "이서준", "team": "클라우드운영팀", "email": "[email protected]"}, 9] 10 11 12@tool 13def search_employee(name: str) -> str: 14 """삼성SDS 임직원 디렉터리에서 이름으로 직원 정보를 조회한다. 15 16 Args: 17 name: 직원의 정확한 한국어 이름 (최소 2자) 18 19 Returns: 20 직원 정보 문자열, 또는 다음 행동을 안내하는 에러 메시지 21 """ 22 try: 23 # 1) 입력 검증 실패 -> raise 대신 안내 메시지 반환 24 if not name or len(name) < 2: 25 return "에러: 이름이 너무 짧습니다. 최소 2자 이상의 정확한 한국어 이름으로 다시 호출하세요." 26 27 # 2) 정상 검색 28 for emp in EMPLOYEES: 29 if emp["name"] == name: 30 return f"{emp['name']} / {emp['team']} / {emp['email']}" 31 32 # 3) 결과 없음 -> 후보를 함께 알려주면 LLM이 다음 판단을 하기 쉬움 33 candidates = ", ".join(e["name"] for e in EMPLOYEES) 34 return f"'{name}' 직원을 찾을 수 없습니다. 등록된 직원: {candidates}" 35 36 except Exception as e: 37 # 4) 예상 못한 예외도 그래프를 죽이지 않고 메시지로 변환 38 return f"에러: 조회 중 문제가 발생했습니다 ({type(e).__name__}). 잠시 후 다시 시도하세요." 39 40 41if __name__ == "__main__": 42 print(search_employee.invoke({"name": "김"})) # 너무 짧음 43 print(search_employee.invoke({"name": "홍길동"})) # 없는 직원 44 print(search_employee.invoke({"name": "김하늘"})) # 정상
ToolNode 는 LangGraph가 미리 만들어 둔 "도구 실행 노드"입니다. 직전 AIMessage 의 tool_calls 를 읽어 해당 도구를 실행하고, 결과를 ToolMessage 로 포장해 State에 추가합니다. 1일차 while 루프에서 손으로 쓰던 "주문서마다 함수 실행 → ToolMessage 만들기"가 이 노드 하나에 들어 있습니다. 여기서는 이 노드의 두 가지 성질, 한 턴에 여러 도구를 함께 실행하는 것과 도구가 예외를 던졌을 때의 처리를 확인합니다.
40class State(TypedDict): 41 messages: Annotated[list, add_messages] 42 43 44llm = ChatBedrockConverse( 45 model="us.anthropic.claude-sonnet-4-6", 46 region_name="us-east-1", 47).bind_tools(tools) 48 49 50def agent(state: State) -> dict: 51 msgs = [SystemMessage(content="도구를 사용해 답하세요.")] + state["messages"] 52 return {"messages": [llm.invoke(msgs)]} 53 54 55def should_continue(state: State) -> Literal["tools", "end"]: 56 return "tools" if state["messages"][-1].tool_calls else "end" 57 58 59builder = StateGraph(State) 60builder.add_node("agent", agent) 61builder.add_node("tools", ToolNode(tools, handle_tool_errors=True)) 62builder.add_edge(START, "agent") 63builder.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END}) 64builder.add_edge("tools", "agent") 65graph = builder.compile()
| 코드 | 역할 |
|---|---|
Annotated[list, add_messages] | State의 messages 는 노드가 돌려준 메시지를 덮어쓰지 않고 뒤에 이어 붙입니다. 그래서 실행이 끝난 뒤 messages 에 전 과정이 순서대로 남습니다. |
.bind_tools(tools) | 도구 두 개의 이름·설명·스키마를 모델 호출에 함께 실어 보냅니다. 모델이 도구의 존재를 아는 것은 이 줄 때문입니다. |
should_continue | 마지막 메시지에 tool_calls 가 있으면 "tools", 없으면 "end" 를 돌려주는 분기 함수입니다. |
ToolNode(tools, handle_tool_errors=True) | 도구를 실행하는 노드. 두 번째 인자가 이 절 후반의 주제입니다. |
add_edge("tools", "agent") | 도구 결과가 나오면 무조건 모델에게 돌아갑니다. 이 엣지가 루프를 만듭니다. |
22@tool 23def get_temperature(city: str) -> str: 24 """도시의 현재 온도를 조회한다 (교육용 더미 데이터).""" 25 data = {"서울": "3도", "부산": "8도", "제주": "11도"} 26 if city not in data: 27 raise ValueError(f"{city}는 지원하지 않는 도시입니다. 지원: {list(data)}") 28 return f"{city}: {data[city]}" 29 30 31@tool 32def divide(a: float, b: float) -> float: 33 """두 수를 나눈다.""" 34 return a / b # b가 0이면 ZeroDivisionError, ToolNode가 처리 35 36 37tools = [get_temperature, divide]
"서울과 부산의 온도"는 서로의 결과가 필요 없는 두 조회입니다. 모델은 이런 경우 한 번의 응답에 tool_calls 를 두 건 담아 보낼 수 있습니다. 이것을 병렬 도구 호출(parallel tool calls)이라고 합니다. ToolNode 는 한 번의 노드 실행에서 그 두 건을 모두 처리하고 ToolMessage 두 개를 돌려줍니다.
68if __name__ == "__main__": 69 # 1) 병렬 호출: 한 질문에 도구 두 개가 한 턴에 호출됨 70 print("=== 병렬 호출 ===") 71 result = graph.invoke({"messages": [HumanMessage(content="서울과 부산의 온도를 동시에 알려주세요.")]}) 72 for m in result["messages"]: 73 if getattr(m, "tool_calls", None): 74 for tc in m.tool_calls: 75 print(f" 도구 호출 요청: {tc['name']}({tc['args']})") 76 elif m.type == "tool": 77 print(f" 도구 결과: {get_text(m)}") 78 print(f"최종 답변: {get_text(result['messages'][-1])}")
1=== 병렬 호출 === 2 도구 호출 요청: get_temperature({'city': '서울'}) 3 도구 호출 요청: get_temperature({'city': '부산'}) 4 도구 결과: 서울: 3도 5 도구 결과: 부산: 8도 6최종 답변: ...
설치된 langgraph-prebuilt 의 ToolNode 소스를 보면, 동기 실행(invoke)에서는 요청들을 실행기(executor)의 map 으로 넘기고 비동기 실행에서는 asyncio.gather 로 모읍니다. 곧 한 턴의 요청들은 차례로 하나씩이 아니라 함께 실행되도록 짜여 있습니다.
요청을 한 턴에 묶을지 여러 턴에 나눌지는 모델이 정합니다. 같은 질문이라도 한 건씩 차례로 부를 수 있고, 그래도 결과는 같습니다. 반대로 함께 실행될 수 있다는 사실은 도구 설계에 조건을 답니다. 한 턴에 같이 불릴 수 있는 도구들은 서로 독립이어야 합니다. 같은 파일을 고치는 도구 두 개, "생성 후 조회"처럼 순서가 중요한 도구 쌍이 한 턴에 묶이면 결과가 실행 순서에 따라 달라집니다.
80 # 2) 에러 처리: 0으로 나누기 -> ToolMessage로 변환되어 Agent가 복구 81 print("\n=== 에러 처리 (handle_tool_errors=True) ===") 82 result = graph.invoke({"messages": [HumanMessage(content="10 나누기 0은?")]}) 83 print(f"최종 답변: {get_text(result['messages'][-1])}")
도구는 예외를 그대로 던지지만 그래프는 멈추지 않습니다. handle_tool_errors=True 로 만든 ToolNode 가 예외를 잡아 에러 내용이 담긴 ToolMessage 로 바꾸기 때문입니다. 설치된 버전의 소스 기준으로 이 메시지의 내용은 Error: (예외의 repr) 뒤에 Please fix your mistakes. 가 붙는 고정 문구이고, 메시지의 status 는 "error" 로 표시됩니다. 모델은 이 메시지를 읽고 "0으로는 나눌 수 없다"는 최종 답변을 만들 수 있습니다.
handle_tool_errors 값 | 도구가 예외를 던지면 |
|---|---|
| 적지 않음 (기본값) | 모델이 준 인자가 스키마에 맞지 않는 검증 오류만 메시지로 바꾸고, 도구 본문에서 난 그 밖의 예외는 다시 던집니다. 그래프 실행이 예외로 중단됩니다. |
True | 모든 예외를 위의 고정 문구 ToolMessage 로 바꿉니다. 이 실습의 설정입니다. |
| 문자열 | 예외 종류와 상관없이 그 문자열을 내용으로 하는 ToolMessage 를 만듭니다. |
| 함수 | 예외를 인자로 받아 문자열을 돌려주는 함수를 넘깁니다. 예외 종류별로 다른 안내문을 쓸 수 있습니다. |
False | 아무것도 잡지 않습니다. 예외가 그대로 올라갑니다. |
이 표는 이 저장소에 설치된 langgraph-prebuilt 1.1.1 의 소스에서 확인한 동작입니다. 기본값의 동작은 라이브러리 버전에 따라 달라질 수 있으므로, 의도한 동작을 코드에 명시적으로 적어 두는 편이 안전합니다.
앞 절의 방식입니다. "무엇을 고쳐 다시 부르라", "백업 도구를 쓰라"처럼 다음 행동을 우리말로 구체적으로 적을 수 있습니다. 예상할 수 있는 실패에 씁니다.
이 절의 방식입니다. 도구 작성자가 놓친 예외까지 잡는 안전망입니다. 문구는 일반적이지만 그래프가 죽지 않습니다. 예상하지 못한 실패에 대비해 켜 둡니다.
둘은 대체 관계가 아니라 겹쳐 쓰는 관계입니다. get_temperature 가 던지는 ValueError 에는 "지원: ['서울', '부산', '제주']" 가 들어 있어, 변환된 메시지에서도 모델이 지원 도시를 알 수 있습니다. 예외를 던지더라도 예외 메시지를 모델이 읽을 글로 생각하고 쓰면 안전망의 품질이 올라갑니다.
14def get_text(message): 15 """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다.""" 16 content = message.content 17 if isinstance(content, list): 18 return "".join(block.get("text", "") for block in content if isinstance(block, dict)) 19 return content
ChatBedrockConverse 가 돌려주는 메시지의 content 는 문자열일 때도 있고, {"type": "text", ...} 같은 블록의 리스트일 때도 있습니다. get_text 는 리스트면 텍스트 블록만 모아 붙이고, 문자열이면 그대로 돌려줍니다. 출력 코드에서 message.content 를 바로 찍지 않는 이유입니다.
질문 하나가 agent 와 tools 노드를 오가며 messages 에 무엇을 쌓는지, 다음 절의 trace.py 까지 이어서 따라가 볼 수 있는 자료입니다.
tool_calls 두 건이 ToolNode 에서 ToolMessage 두 개로 돌아오는 과정, divide 의 0 나누기가 handle_tool_errors 설정마다 어떻게 처리되는지, trace.py 가 messages 를 표로 찍는 방식을 코드와 messages 상태를 함께 넘기며 봅니다. 모델의 답은 예시이고, 도구 결과와 에러 문구는 같은 도구로 ToolNode 를 직접 돌려 얻은 값입니다.
1# toolnode_parallel.py - ToolNode + 병렬 호출 + 에러 처리 확인 2from typing import TypedDict, Annotated, Literal 3from dotenv import load_dotenv 4from langgraph.graph import StateGraph, START, END 5from langgraph.graph.message import add_messages 6from langgraph.prebuilt import ToolNode 7from langchain_aws import ChatBedrockConverse 8from langchain_core.tools import tool 9from langchain_core.messages import HumanMessage, SystemMessage 10 11load_dotenv() 12 13 14def get_text(message): 15 """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다.""" 16 content = message.content 17 if isinstance(content, list): 18 return "".join(block.get("text", "") for block in content if isinstance(block, dict)) 19 return content 20 21 22@tool 23def get_temperature(city: str) -> str: 24 """도시의 현재 온도를 조회한다 (교육용 더미 데이터).""" 25 data = {"서울": "3도", "부산": "8도", "제주": "11도"} 26 if city not in data: 27 raise ValueError(f"{city}는 지원하지 않는 도시입니다. 지원: {list(data)}") 28 return f"{city}: {data[city]}" 29 30 31@tool 32def divide(a: float, b: float) -> float: 33 """두 수를 나눈다.""" 34 return a / b # b가 0이면 ZeroDivisionError, ToolNode가 처리 35 36 37tools = [get_temperature, divide] 38 39 40class State(TypedDict): 41 messages: Annotated[list, add_messages] 42 43 44llm = ChatBedrockConverse( 45 model="us.anthropic.claude-sonnet-4-6", 46 region_name="us-east-1", 47).bind_tools(tools) 48 49 50def agent(state: State) -> dict: 51 msgs = [SystemMessage(content="도구를 사용해 답하세요.")] + state["messages"] 52 return {"messages": [llm.invoke(msgs)]} 53 54 55def should_continue(state: State) -> Literal["tools", "end"]: 56 return "tools" if state["messages"][-1].tool_calls else "end" 57 58 59builder = StateGraph(State) 60builder.add_node("agent", agent) 61builder.add_node("tools", ToolNode(tools, handle_tool_errors=True)) 62builder.add_edge(START, "agent") 63builder.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END}) 64builder.add_edge("tools", "agent") 65graph = builder.compile() 66 67 68if __name__ == "__main__": 69 # 1) 병렬 호출: 한 질문에 도구 두 개가 한 턴에 호출됨 70 print("=== 병렬 호출 ===") 71 result = graph.invoke({"messages": [HumanMessage(content="서울과 부산의 온도를 동시에 알려주세요.")]}) 72 for m in result["messages"]: 73 if getattr(m, "tool_calls", None): 74 for tc in m.tool_calls: 75 print(f" 도구 호출 요청: {tc['name']}({tc['args']})") 76 elif m.type == "tool": 77 print(f" 도구 결과: {get_text(m)}") 78 print(f"최종 답변: {get_text(result['messages'][-1])}") 79 80 # 2) 에러 처리: 0으로 나누기 -> ToolMessage로 변환되어 Agent가 복구 81 print("\n=== 에러 처리 (handle_tool_errors=True) ===") 82 result = graph.invoke({"messages": [HumanMessage(content="10 나누기 0은?")]}) 83 print(f"최종 답변: {get_text(result['messages'][-1])}")
Agent가 틀린 답을 냈을 때 가장 먼저 할 일은 "어디서 틀어졌는가"를 찾는 것입니다. 따로 기록 장치를 달지 않아도, 그래프가 돌려준 State의 messages 리스트에 모델의 결정과 도구의 결과가 일어난 순서대로 들어 있습니다. 이것을 읽기 좋게 찍는 함수 하나가 가장 값싼 디버깅 도구입니다.
15def print_trace(result: dict): 16 """messages 리스트를 순회하며 실행 과정을 표 형태로 출력.""" 17 print(f"{'순서':<4} {'타입':<10} {'내용'}") 18 print("-" * 70) 19 for i, m in enumerate(result["messages"]): 20 if m.type == "human": 21 print(f"{i:<4} {'사용자':<10} {get_text(m)}") 22 elif m.type == "ai" and getattr(m, "tool_calls", None): 23 for tc in m.tool_calls: 24 print(f"{i:<4} {'도구요청':<10} {tc['name']} args={json.dumps(tc['args'], ensure_ascii=False)}") 25 elif m.type == "tool": 26 print(f"{i:<4} {'도구결과':<10} [{m.name}] {get_text(m)[:60]}") 27 elif m.type == "ai": 28 print(f"{i:<4} {'최종답변':<10} {get_text(m)[:60]}")
| 조건 | 표시 | 이 줄이 말해 주는 것 |
|---|---|---|
m.type == "human" | 사용자 | 질문 원문 |
"ai" 이고 tool_calls 가 있음 | 도구요청 | 모델이 고른 도구 이름과 인자. 요청이 여러 건이면 같은 순서 번호로 여러 줄이 찍힙니다 |
m.type == "tool" | 도구결과 | 어느 도구(m.name)가 무엇을 돌려줬는지. 앞 60자만 보여 줍니다 |
"ai" 이고 tool_calls 가 없음 | 최종답변 | 모델이 도구 없이 쓴 글. 루프가 여기서 끝났습니다 |
31if __name__ == "__main__": 32 result = graph.invoke({"messages": [HumanMessage(content="서울 온도를 100으로 나눈 값은?")]}) 33 print_trace(result)
질문은 "서울 온도를 100으로 나눈 값은?"입니다. 앞 절의 병렬 질문과 달리 이번에는 앞 도구의 결과가 뒤 도구의 인자가 됩니다. 온도를 알기 전에는 나눗셈을 요청할 수 없으므로 모델은 도구를 한 턴에 하나씩 부를 수밖에 없습니다.
1순서 타입 내용 2---------------------------------------------------------------------- 30 사용자 서울 온도를 100으로 나눈 값은? 41 도구요청 get_temperature args={"city": "서울"} 52 도구결과 [get_temperature] 서울: 3도 63 도구요청 divide args={"a": 3, "b": 100} 74 도구결과 [divide] 0.03 85 최종답변 ...
| 서울과 부산의 온도 | 서울 온도를 100으로 나눈 값 | |
|---|---|---|
| 도구 사이의 관계 | 서로 독립 | 앞 결과가 뒤 인자 |
| 모델이 낼 수 있는 요청 | 한 턴에 두 건 | 턴마다 한 건씩 |
| 모델 호출 횟수 | 2번 | 3번 |
도구 요청이 담긴 AIMessage 에 모델이 쓴 글이 함께 들어 있어도, 이 함수는 tool_calls 가 있으면 요청 줄만 찍고 글은 찍지 않습니다. 결과와 답변은 60자에서 잘립니다. 또 토큰 수와 소요 시간도 나오지 않습니다. 원인을 좁히는 데는 충분하지만 운영 기록으로는 부족하고, 실행 흐름을 파일에 남기고 모아서 채점하는 일은 7일차에서 다룹니다.
from toolnode_parallel import graph 는 그 파일의 최상위 코드를 실행합니다. 도구 정의, 모델 객체 생성, 그래프 컴파일까지가 이때 일어나고, if __name__ == "__main__": 아래의 질문 실행은 일어나지 않습니다. 실습 파일마다 실행 코드를 이 조건문 안에 두는 까닭이 이것입니다. 모듈을 파일 이름만으로 찾으므로 trace.py 는 day4_practice 폴더에서 실행해야 합니다.
print_trace 함수는 뒤의 failure_agent.py 에도 조금 줄인 형태로 다시 나옵니다.1# trace.py - messages 리스트를 순회하며 실행 과정을 표 형태로 출력 2import json 3from langchain_core.messages import HumanMessage 4from toolnode_parallel import graph # 지난 실습부분에서 사용한 그래프를 그대로 사용 5 6 7def get_text(message): 8 """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다.""" 9 content = message.content 10 if isinstance(content, list): 11 return "".join(block.get("text", "") for block in content if isinstance(block, dict)) 12 return content 13 14 15def print_trace(result: dict): 16 """messages 리스트를 순회하며 실행 과정을 표 형태로 출력.""" 17 print(f"{'순서':<4} {'타입':<10} {'내용'}") 18 print("-" * 70) 19 for i, m in enumerate(result["messages"]): 20 if m.type == "human": 21 print(f"{i:<4} {'사용자':<10} {get_text(m)}") 22 elif m.type == "ai" and getattr(m, "tool_calls", None): 23 for tc in m.tool_calls: 24 print(f"{i:<4} {'도구요청':<10} {tc['name']} args={json.dumps(tc['args'], ensure_ascii=False)}") 25 elif m.type == "tool": 26 print(f"{i:<4} {'도구결과':<10} [{m.name}] {get_text(m)[:60]}") 27 elif m.type == "ai": 28 print(f"{i:<4} {'최종답변':<10} {get_text(m)[:60]}") 29 30 31if __name__ == "__main__": 32 result = graph.invoke({"messages": [HumanMessage(content="서울 온도를 100으로 나눈 값은?")]}) 33 print_trace(result)
지금까지 만든 도구는 모두 Agent와 같은 파이썬 프로세스 안의 함수였습니다. 혼자 쓰는 동안은 문제가 없습니다. 같은 사내 데이터 조회 기능을 다른 Agent, 다른 팀의 프로그램, 코딩 도우미 같은 완성된 앱에서도 쓰고 싶어지는 순간부터 이 구조가 걸림돌이 됩니다.
도구를 쓰려는 프로그램마다 함수 코드를 복사해 넣어야 합니다. 그 프로그램이 파이썬이 아니거나 LangChain을 쓰지 않으면 도구 정의부터 다시 써야 합니다. 도구를 고치면 그 도구를 품고 있는 모든 Agent를 다시 배포해야 하고, 데이터에 접근하는 권한과 비밀값이 Agent마다 흩어집니다. 도구 N개와 응용 프로그램 M개를 잇는 연결 코드가 조합마다 필요해집니다.
MCP(Model Context Protocol)는 이 연결을 표준화한 개방형 프로토콜입니다. Anthropic이 2024년에 공개했고, LLM 응용 프로그램과 외부 도구·데이터 사이에서 "어떤 도구가 있는가", "이 도구를 이 인자로 실행하라", "결과는 이것이다"를 주고받는 메시지 형식(JSON-RPC 2.0 기반)과 절차를 정해 둔 것입니다. 도구를 가진 쪽은 이 규약대로 말하는 서버를 한 번 만들고, 도구를 쓰려는 쪽은 규약대로 말하는 클라이언트를 한 번 붙이면, 서로 누가 만들었는지 몰라도 연결됩니다.
도구를 가지고 있는 프로그램입니다. 도구 목록을 알려 주고, 호출 요청을 받아 실제 함수를 실행하고, 결과를 돌려줍니다. 오늘 만드는 mcp_server.py 가 서버입니다.
서버에 연결해 목록을 묻고 호출을 요청하는 쪽입니다. Agent 프로그램 안에 들어 있습니다. mcp_verify.py 의 ClientSession, mcp_agent.py 의 MultiServerMCPClient 가 클라이언트입니다.
둘이 메시지를 실어 나르는 통로입니다. 오늘 쓰는 stdio는 클라이언트가 서버를 자식 프로세스로 실행하고, 그 프로세스의 표준 입력과 표준 출력으로 메시지를 주고받는 방식입니다.
command(파이썬 실행 파일)와 args(서버 스크립트 경로)로 적습니다.mcp_verify.py 의 session.initialize() 가 이 단계입니다.session.list_tools()). 앞에서 @tool 이 만들던 세 가지 메타데이터와 같은 것입니다.session.call_tool()), 서버가 함수를 실행해 결과를 돌려줍니다.@tool (프로세스 안의 함수) | MCP 도구 (다른 프로세스의 서버) | |
|---|---|---|
| 정의하는 방법 | langchain_core.tools.tool 데코레이터 | FastMCP 객체의 @mcp.tool() 데코레이터 |
| 실행되는 곳 | Agent와 같은 프로세스 | 별도 프로세스. 클라이언트가 띄운 자식 프로세스(stdio)이거나 원격 서버 |
| 호출 경로 | 파이썬 함수 호출 | 프로토콜 메시지를 직렬화해 전송 |
| 모델에게 전달되는 것 | 같습니다. 이름, 설명, 입력 스키마 | |
| 누가 쓸 수 있나 | 그 함수를 import 한 파이썬 프로그램 | MCP 클라이언트를 가진 프로그램이면 어느 것이든. 언어와 프레임워크를 가리지 않습니다 |
| 도구를 고칠 때 | Agent 코드를 고치고 다시 배포 | 서버만 교체. 도구의 이름과 스키마가 그대로면 클라이언트는 손대지 않습니다 |
| 이 실습의 호출 방식 | 동기 호출 가능 (graph.invoke) | 어댑터가 만든 도구는 비동기 전용이라 await, astream 으로 실행 |
| 치르는 비용 | 없음 | 프로세스 기동, 직렬화, 연결 관리. 장애 지점이 하나 늘어납니다 |
| 어울리는 경우 | 이 Agent에서만 쓰는 가벼운 로직 (계산, 형식 변환) | 여러 응용 프로그램이 함께 쓰는 데이터와 시스템, 다른 팀이나 외부에서 제공하는 기능 |
에디터마다, 언어마다 자동 완성 플러그인을 따로 만들던 문제를 LSP(Language Server Protocol)가 "언어 서버 하나를 만들면 어느 에디터에서나 쓴다"로 바꾼 것과 구조가 같습니다. MCP에서는 에디터 자리에 LLM 응용 프로그램이, 언어 서버 자리에 도구 서버가 놓입니다.
MCP는 우리 프로그램과 도구 사이의 약속이지 모델과의 약속이 아닙니다. 모델이 받는 것은 어느 쪽이든 도구의 이름, 설명, 스키마이고, 돌려받는 것은 ToolMessage 입니다. 그래서 앞의 세 절에서 배운 설명서 쓰기와 에러 메시지 원칙은 MCP 서버를 만들 때도 그대로 적용됩니다.
이어지는 네 절(서버, 검증, Agent 연결, 외부 서버)에서 오가는 메시지를 한 장의 시퀀스로 미리 훑어보거나, 절을 다 읽은 뒤 복습용으로 넘겨 볼 수 있는 자료입니다.
Agent, MCP 클라이언트, stdio, MCP 서버, 데이터 파일 사이의 메시지 왕복을 시퀀스 그림과 단계별 플레이어로 따라갑니다. mcp_server.py 의 도구 등록부터 mcp_verify.py 의 initialize, tools/list, tools/call, mcp_agent.py 의 도구 변환과 호출마다 새 세션, 외부 서버와 공공 API 서버까지 다룹니다. JSON-RPC 메시지는 모양만 보인 예시입니다.
임직원, 자산, 프로젝트 세 가지 더미 데이터를 조회하는 MCP 서버를 만듭니다. 이 서버는 오늘로 끝나지 않습니다. 5~7일차 폴더에 주석 한 군데만 다른 mcp_server.py 와 같은 JSON 세 개가 그대로 들어 있고, 5일차의 종합 실습과 6일차 멀티 에이전트의 데이터 담당 에이전트가 이 서버를 도구로 씁니다.
서버가 읽을 데이터는 make_data.py 가 JSON 파일 세 개로 만들어 둡니다. 데이터베이스 대신 파일을 쓰는 것은 실습을 단순하게 하기 위해서이고, 서버 입장에서는 뒤에 무엇이 있든 도구의 모양은 같습니다.
21# 노트북 5대, 모니터 2대, GPU 서버 1대로 총 8건입니다. GPU 서버는 6일차 MCP 통합 실습이 조회하므로 빼지 마세요. 22# 5-2의 총액 계산(노트북 5대 x 200만원 = 1,000만원)이 이 구성에 맞춰져 있으니 건수를 바꾸지 마세요. 23ASSETS = [ 24 {"asset_id": "A-1001", "type": "노트북", "model": "갤럭시북4 프로", "owner": "김하늘", "status": "사용중"}, 25 {"asset_id": "A-1002", "type": "노트북", "model": "갤럭시북4 프로", "owner": "박도윤", "status": "사용중"}, 26 {"asset_id": "A-1003", "type": "노트북", "model": "갤럭시북4", "owner": "이서준", "status": "사용중"}, 27 {"asset_id": "A-1004", "type": "노트북", "model": "갤럭시북4", "owner": None, "status": "재고"}, 28 {"asset_id": "A-1005", "type": "노트북", "model": "갤럭시북4 프로", "owner": "최민준", "status": "사용중"}, 29 {"asset_id": "A-1006", "type": "모니터", "model": "뷰피니티 S8", "owner": "김하늘", "status": "사용중"}, 30 {"asset_id": "A-1007", "type": "모니터", "model": "뷰피니티 S8", "owner": "정하은", "status": "사용중"}, 31 {"asset_id": "A-1008", "type": "GPU 서버", "model": "NVIDIA A100 80GB", "owner": "이서준", "status": "사용중"}, 32]
| 파일 | 건수 | 한 건의 필드 | 눈여겨볼 점 |
|---|---|---|---|
employees.json | 5 | id, name, team, role, email | 팀은 둘뿐입니다. 클라우드운영팀 3명, 물류플랫폼팀 2명 |
assets.json | 8 | asset_id, type, model, owner, status | 노트북 5, 모니터 2, GPU 서버 1. A-1004 는 사용자가 없는 재고라 owner 가 null 입니다 |
projects.json | 4 | project_id, name, team, status, member_count | 팀마다 2건씩, 모두 "진행중" |
50if __name__ == "__main__": 51 for filename, rows in [("employees.json", EMPLOYEES), 52 ("assets.json", ASSETS), 53 ("projects.json", PROJECTS)]: 54 write_json(filename, rows) 55 print(f"생성: {filename} ({len(rows)}건)") 56 57 laptops = [a for a in ASSETS if a["type"] == "노트북"] 58 total = len(laptops) * 2000000 59 print(f"확인: 자산 {len(ASSETS)}건 중 노트북 {len(laptops)}대, 한 대당 200만원이면 총액 {total:,}원")
1생성: employees.json (5건) 2생성: assets.json (8건) 3생성: projects.json (4건) 4확인: 자산 8건 중 노트북 5대, 한 대당 200만원이면 총액 10,000,000원
11import json 12import os 13from fastmcp import FastMCP 14 15mcp = FastMCP("sds-company-data") 16 17# make_data.py가 만든 JSON 3개를 코드와 같은 폴더에서 로드합니다 (교육용 더미 데이터) 18BASE = os.path.dirname(os.path.abspath(__file__)) 19EMPLOYEES = json.load(open(os.path.join(BASE, "employees.json"), encoding="utf-8")) 20ASSETS = json.load(open(os.path.join(BASE, "assets.json"), encoding="utf-8")) 21PROJECTS = json.load(open(os.path.join(BASE, "projects.json"), encoding="utf-8"))
FastMCP는 MCP 서버를 짧게 만들게 해 주는 파이썬 라이브러리입니다. 프로토콜 메시지를 읽고 쓰는 일, 도구 목록 요청에 답하는 일을 대신해 주므로 우리는 도구 함수만 쓰면 됩니다. 데이터 파일의 경로를 __file__ 기준의 절대 경로로 만드는 것에 주목하세요. 이 서버는 사람이 터미널에서 실행하는 프로그램이 아니라 클라이언트가 띄우는 자식 프로세스입니다. 어느 폴더를 작업 디렉터리로 해서 실행될지 서버는 알 수 없으므로 상대 경로에 기대지 않습니다.
24@mcp.tool() 25def search_employee(name: str) -> str: 26 """삼성SDS 임직원 디렉터리에서 이름으로 직원 정보를 조회한다. 27 28 직원의 소속 팀, 직무, 이메일이 필요할 때 사용한다. 29 팀 전체 명단이나 인원수에는 사용하지 않는다 (list_team_members 사용). 30 자산이나 프로젝트 질문에도 사용하지 않는다 (get_asset, list_projects 사용). 31 32 Args: 33 name: 직원의 정확한 한국어 이름 34 """ 35 for emp in EMPLOYEES: 36 if emp["name"] == name: 37 return json.dumps(emp, ensure_ascii=False) 38 names = ", ".join(e["name"] for e in EMPLOYEES) 39 return f"'{name}' 직원을 찾을 수 없습니다. 등록된 직원: {names}"
@tool 과 원리가 같습니다. 함수 이름이 도구 이름, docstring이 설명, 타입 힌트가 입력 스키마가 됩니다. 달라진 것은 docstring의 내용입니다. 도구가 넷으로 늘었기 때문에 "이 질문에는 내가 아니라 저 도구를 쓰라"는 안내가 들어갔습니다. "팀 전체 명단이나 인원수에는 사용하지 않는다 (list_team_members 사용)". 이름이 비슷한 도구들 사이의 경계는 각 도구의 설명에 서로를 가리키며 적어 두는 것이 가장 확실합니다. 없는 직원을 물으면 등록된 이름을 함께 돌려주는 것은 앞에서 본 원칙 그대로입니다.
| 도구 | 인자 | 정상일 때 반환 | 결과가 없을 때 |
|---|---|---|---|
search_employee | name (필수) | 직원 한 명의 JSON | "찾을 수 없습니다" + 등록된 직원 이름 전체 |
list_team_members | team (필수) | {"count": 인원수, "members": [...]} | "찾을 수 없습니다" + 등록된 팀 이름 |
get_asset | owner (기본값 빈 문자열) | {"count": 건수, "assets": [...]} | "'...' 사용자의 자산이 없습니다." |
list_projects | team (기본값 빈 문자열) | 프로젝트 목록 JSON | "'...' 팀의 프로젝트가 없습니다." |
59@mcp.tool() 60def get_asset(owner: str = "") -> str: 61 """사내 자산(노트북, 모니터 등)을 조회한다. 62 63 Args: 64 owner: 사용자 이름으로 필터. 빈 문자열이면 전체 자산 반환 65 """ 66 rows = [a for a in ASSETS if not owner or a["owner"] == owner] 67 if not rows: 68 return f"'{owner}' 사용자의 자산이 없습니다." 69 return json.dumps({"count": len(rows), "assets": rows}, ensure_ascii=False, indent=2)
list_team_members 와 get_asset 은 목록과 함께 count 를 돌려줍니다. 모델은 긴 목록의 항목 수를 세는 일에 약하므로, 개수를 물을 법한 도구는 개수를 직접 계산해 넣어 줍니다. 다만 get_asset 의 count 는 필터 뒤의 전체 건수입니다. 이 도구에는 자산 종류로 거르는 인자가 없어서, "노트북이 몇 대인가"를 물으면 모델이 8건의 type 을 읽고 직접 세어야 합니다. 8건이니 가능한 일이고, 데이터가 커지면 type 인자를 추가하는 것이 맞는 개선입니다.
85if __name__ == "__main__": 86 mcp.run() # stdio 모드로 실행
python mcp_server.py 를 터미널에서 직접 실행하면 프롬프트가 돌아오지 않고 멈춘 것처럼 보입니다. 고장이 아니라 표준 입력으로 프로토콜 메시지가 들어오기를 기다리는 상태입니다. 5일차 폴더에 있는 같은 파일의 주석은 이 점을 "단독으로 쓰는 파일이 아니라 클라이언트가 자식 프로세스로 띄워서 사용합니다"라고 적어 두었습니다. 서버가 제대로 동작하는지는 직접 실행해서가 아니라 다음 절처럼 클라이언트를 붙여서 확인합니다.
stdio 전송에서 서버의 표준 출력은 프로토콜 메시지가 지나가는 통로입니다. 도구 함수 안에서 print() 로 무언가를 찍으면 그 글자가 메시지 사이에 끼어들어 클라이언트가 해석에 실패할 수 있습니다. 확인하고 싶은 값은 표준 에러나 파일 로그로 내보냅니다.
1# make_data.py - Day4 백데이터 3종을 만드는 스크립트 (교육용 더미 데이터) 2"""실행하면 같은 폴더에 employees.json, assets.json, projects.json을 만듭니다. 3 4실행: python make_data.py 5 65, 6, 7일차가 이 데이터를 그대로 이어 쓰므로 값은 바꾸지 마세요. 7""" 8import json 9import os 10 11BASE = os.path.dirname(os.path.abspath(__file__)) 12 13EMPLOYEES = [ 14 {"id": 1, "name": "김하늘", "team": "클라우드운영팀", "role": "팀장", "email": "[email protected]"}, 15 {"id": 2, "name": "박도윤", "team": "물류플랫폼팀", "role": "백엔드", "email": "[email protected]"}, 16 {"id": 3, "name": "이서준", "team": "클라우드운영팀", "role": "인프라 엔지니어", "email": "[email protected]"}, 17 {"id": 4, "name": "최민준", "team": "물류플랫폼팀", "role": "프론트엔드", "email": "[email protected]"}, 18 {"id": 5, "name": "정하은", "team": "클라우드운영팀", "role": "데이터 분석가", "email": "[email protected]"}, 19] 20 21# 노트북 5대, 모니터 2대, GPU 서버 1대로 총 8건입니다. GPU 서버는 6일차 MCP 통합 실습이 조회하므로 빼지 마세요. 22# 5-2의 총액 계산(노트북 5대 x 200만원 = 1,000만원)이 이 구성에 맞춰져 있으니 건수를 바꾸지 마세요. 23ASSETS = [ 24 {"asset_id": "A-1001", "type": "노트북", "model": "갤럭시북4 프로", "owner": "김하늘", "status": "사용중"}, 25 {"asset_id": "A-1002", "type": "노트북", "model": "갤럭시북4 프로", "owner": "박도윤", "status": "사용중"}, 26 {"asset_id": "A-1003", "type": "노트북", "model": "갤럭시북4", "owner": "이서준", "status": "사용중"}, 27 {"asset_id": "A-1004", "type": "노트북", "model": "갤럭시북4", "owner": None, "status": "재고"}, 28 {"asset_id": "A-1005", "type": "노트북", "model": "갤럭시북4 프로", "owner": "최민준", "status": "사용중"}, 29 {"asset_id": "A-1006", "type": "모니터", "model": "뷰피니티 S8", "owner": "김하늘", "status": "사용중"}, 30 {"asset_id": "A-1007", "type": "모니터", "model": "뷰피니티 S8", "owner": "정하은", "status": "사용중"}, 31 {"asset_id": "A-1008", "type": "GPU 서버", "model": "NVIDIA A100 80GB", "owner": "이서준", "status": "사용중"}, 32] 33 34PROJECTS = [ 35 {"project_id": "P-2601", "name": "차세대 물류 플랫폼 구축", "team": "물류플랫폼팀", "status": "진행중", "member_count": 12}, 36 {"project_id": "P-2602", "name": "클라우드 비용 최적화", "team": "클라우드운영팀", "status": "진행중", "member_count": 7}, 37 {"project_id": "P-2603", "name": "물류 관제 대시보드 고도화", "team": "물류플랫폼팀", "status": "진행중", "member_count": 5}, 38 {"project_id": "P-2604", "name": "사내 AI 어시스턴트 도입", "team": "클라우드운영팀", "status": "진행중", "member_count": 4}, 39] 40 41 42def write_json(filename: str, rows: list) -> str: 43 """한글이 깨지지 않게 UTF-8과 ensure_ascii=False로 씁니다.""" 44 path = os.path.join(BASE, filename) 45 with open(path, "w", encoding="utf-8") as f: 46 json.dump(rows, f, ensure_ascii=False, indent=2) 47 return path 48 49 50if __name__ == "__main__": 51 for filename, rows in [("employees.json", EMPLOYEES), 52 ("assets.json", ASSETS), 53 ("projects.json", PROJECTS)]: 54 write_json(filename, rows) 55 print(f"생성: {filename} ({len(rows)}건)") 56 57 laptops = [a for a in ASSETS if a["type"] == "노트북"] 58 total = len(laptops) * 2000000 59 print(f"확인: 자산 {len(ASSETS)}건 중 노트북 {len(laptops)}대, 한 대당 200만원이면 총액 {total:,}원")
1[ 2 { 3 "id": 1, 4 "name": "김하늘", 5 "team": "클라우드운영팀", 6 "role": "팀장", 7 "email": "[email protected]" 8 }, 9 { 10 "id": 2, 11 "name": "박도윤", 12 "team": "물류플랫폼팀", 13 "role": "백엔드", 14 "email": "[email protected]" 15 }, 16 { 17 "id": 3, 18 "name": "이서준", 19 "team": "클라우드운영팀", 20 "role": "인프라 엔지니어", 21 "email": "[email protected]" 22 }, 23 { 24 "id": 4, 25 "name": "최민준", 26 "team": "물류플랫폼팀", 27 "role": "프론트엔드", 28 "email": "[email protected]" 29 }, 30 { 31 "id": 5, 32 "name": "정하은", 33 "team": "클라우드운영팀", 34 "role": "데이터 분석가", 35 "email": "[email protected]" 36 } 37]
A-1004 의 owner 가 null 인 것이 재고 자산입니다.1[ 2 { 3 "asset_id": "A-1001", 4 "type": "노트북", 5 "model": "갤럭시북4 프로", 6 "owner": "김하늘", 7 "status": "사용중" 8 }, 9 { 10 "asset_id": "A-1002", 11 "type": "노트북", 12 "model": "갤럭시북4 프로", 13 "owner": "박도윤", 14 "status": "사용중" 15 }, 16 { 17 "asset_id": "A-1003", 18 "type": "노트북", 19 "model": "갤럭시북4", 20 "owner": "이서준", 21 "status": "사용중" 22 }, 23 { 24 "asset_id": "A-1004", 25 "type": "노트북", 26 "model": "갤럭시북4", 27 "owner": null, 28 "status": "재고" 29 }, 30 { 31 "asset_id": "A-1005", 32 "type": "노트북", 33 "model": "갤럭시북4 프로", 34 "owner": "최민준", 35 "status": "사용중" 36 }, 37 { 38 "asset_id": "A-1006", 39 "type": "모니터", 40 "model": "뷰피니티 S8", 41 "owner": "김하늘", 42 "status": "사용중" 43 }, 44 { 45 "asset_id": "A-1007", 46 "type": "모니터", 47 "model": "뷰피니티 S8", 48 "owner": "정하은", 49 "status": "사용중" 50 }, 51 { 52 "asset_id": "A-1008", 53 "type": "GPU 서버", 54 "model": "NVIDIA A100 80GB", 55 "owner": "이서준", 56 "status": "사용중" 57 } 58]
1[ 2 { 3 "project_id": "P-2601", 4 "name": "차세대 물류 플랫폼 구축", 5 "team": "물류플랫폼팀", 6 "status": "진행중", 7 "member_count": 12 8 }, 9 { 10 "project_id": "P-2602", 11 "name": "클라우드 비용 최적화", 12 "team": "클라우드운영팀", 13 "status": "진행중", 14 "member_count": 7 15 }, 16 { 17 "project_id": "P-2603", 18 "name": "물류 관제 대시보드 고도화", 19 "team": "물류플랫폼팀", 20 "status": "진행중", 21 "member_count": 5 22 }, 23 { 24 "project_id": "P-2604", 25 "name": "사내 AI 어시스턴트 도입", 26 "team": "클라우드운영팀", 27 "status": "진행중", 28 "member_count": 4 29 } 30]
sds-company-data 입니다. 모델을 부르지 않으며, 클라이언트가 자식 프로세스로 실행합니다.1# mcp_server.py - 삼성SDS 교육용 더미 사내 데이터 MCP 서버 (fastmcp) 2"""노출하는 도구 4개: 3 - search_employee(name) 임직원 개인 조회 4 - list_team_members(team) 팀별 임직원 목록 5 - get_asset(owner) 자산 조회 6 - list_projects(team) 프로젝트 목록 7 8실행: python mcp_server.py 9(stdio 모드로 대기합니다.) 10""" 11import json 12import os 13from fastmcp import FastMCP 14 15mcp = FastMCP("sds-company-data") 16 17# make_data.py가 만든 JSON 3개를 코드와 같은 폴더에서 로드합니다 (교육용 더미 데이터) 18BASE = os.path.dirname(os.path.abspath(__file__)) 19EMPLOYEES = json.load(open(os.path.join(BASE, "employees.json"), encoding="utf-8")) 20ASSETS = json.load(open(os.path.join(BASE, "assets.json"), encoding="utf-8")) 21PROJECTS = json.load(open(os.path.join(BASE, "projects.json"), encoding="utf-8")) 22 23 24@mcp.tool() 25def search_employee(name: str) -> str: 26 """삼성SDS 임직원 디렉터리에서 이름으로 직원 정보를 조회한다. 27 28 직원의 소속 팀, 직무, 이메일이 필요할 때 사용한다. 29 팀 전체 명단이나 인원수에는 사용하지 않는다 (list_team_members 사용). 30 자산이나 프로젝트 질문에도 사용하지 않는다 (get_asset, list_projects 사용). 31 32 Args: 33 name: 직원의 정확한 한국어 이름 34 """ 35 for emp in EMPLOYEES: 36 if emp["name"] == name: 37 return json.dumps(emp, ensure_ascii=False) 38 names = ", ".join(e["name"] for e in EMPLOYEES) 39 return f"'{name}' 직원을 찾을 수 없습니다. 등록된 직원: {names}" 40 41 42@mcp.tool() 43def list_team_members(team: str) -> str: 44 """팀 이름으로 소속 임직원 목록을 조회한다. 45 46 특정 팀의 인원수나 명단이 필요할 때 사용한다. 47 개별 직원의 상세 정보는 search_employee를 사용한다. 48 49 Args: 50 team: 팀 이름 (예: '클라우드운영팀', '물류플랫폼팀') 51 """ 52 rows = [e for e in EMPLOYEES if e["team"] == team] 53 if not rows: 54 teams = sorted({e["team"] for e in EMPLOYEES}) 55 return f"'{team}' 팀을 찾을 수 없습니다. 등록된 팀: {teams}" 56 return json.dumps({"count": len(rows), "members": rows}, ensure_ascii=False, indent=2) 57 58 59@mcp.tool() 60def get_asset(owner: str = "") -> str: 61 """사내 자산(노트북, 모니터 등)을 조회한다. 62 63 Args: 64 owner: 사용자 이름으로 필터. 빈 문자열이면 전체 자산 반환 65 """ 66 rows = [a for a in ASSETS if not owner or a["owner"] == owner] 67 if not rows: 68 return f"'{owner}' 사용자의 자산이 없습니다." 69 return json.dumps({"count": len(rows), "assets": rows}, ensure_ascii=False, indent=2) 70 71 72@mcp.tool() 73def list_projects(team: str = "") -> str: 74 """진행 중인 프로젝트 목록을 조회한다. 75 76 Args: 77 team: 팀 이름으로 필터 (예: '물류플랫폼팀'). 빈 문자열이면 전체 78 """ 79 rows = [p for p in PROJECTS if not team or p["team"] == team] 80 if not rows: 81 return f"'{team}' 팀의 프로젝트가 없습니다." 82 return json.dumps(rows, ensure_ascii=False, indent=2) 83 84 85if __name__ == "__main__": 86 mcp.run() # stdio 모드로 실행
서버를 만들자마자 Agent에 연결하면, 답이 틀렸을 때 원인이 서버인지 모델인지 가려내기 어렵습니다. 그래서 먼저 모델 없이 파이썬 MCP 클라이언트로 서버를 직접 불러 봅니다. 결과가 매번 같고, 비용이 들지 않고, 실패하면 원인은 서버 쪽 하나로 좁혀집니다.
11async def main(): 12 # 1) 서버를 자식 프로세스로 띄울 방법을 기술 13 # sys.executable: 현재 파이썬 인터프리터 경로 (python/python3 문제 회피) 14 params = StdioServerParameters(command=sys.executable, args=[SERVER_PATH]) 15 16 # 2) stdio로 연결하고 세션 시작 17 async with stdio_client(params) as (read, write): 18 async with ClientSession(read, write) as session: 19 await session.initialize() # MCP 초기 핸드셰이크 20 21 # 3) 검증 1: 도구 목록 확인 22 tools = await session.list_tools() 23 print("=== 노출된 도구 목록 ===") 24 for t in tools.tools: 25 print(f"- {t.name}: {t.description.splitlines()[0]}")
StdioServerParameters(command=..., args=[...]). 서버를 미리 띄워 두는 것이 아니라 "이 명령으로 띄우라"고 알려 주는 것입니다. sys.executable 은 지금 이 스크립트를 돌리고 있는 파이썬의 경로입니다. 가상환경 안에서 실행했다면 서버도 같은 가상환경의 파이썬으로 뜨므로 fastmcp 가 설치된 환경이 보장됩니다.stdio_client(params) 가 서버 프로세스를 띄우고, 읽기 통로와 쓰기 통로 한 쌍을 돌려줍니다.ClientSession(read, write) 가 그 통로 위에서 MCP 메시지를 주고받는 객체입니다. initialize() 로 초기 핸드셰이크를 마친 뒤에 도구 목록 요청과 호출을 보냅니다.list_tools() 로 도구가 넷 다 노출됐는지, call_tool(이름, 인자) 로 각 도구가 기대한 값을 돌려주는지 봅니다.MCP의 파이썬 클라이언트는 비동기 API입니다. async def 로 정의한 함수 안에서 await 를 붙여 호출하고, 맨 바깥에서 asyncio.run(main()) 으로 실행합니다. async with 는 연결을 열고 블록을 벗어날 때 정리까지 해 주는 구문입니다. 프로세스 사이의 입출력은 응답을 기다리는 시간이 대부분이라 비동기로 다루는 것이 자연스럽고, 다음 절에서 Agent를 astream 으로 돌리는 이유도 여기서 시작됩니다.
27 # 4) 검증 2: 도구 호출 28 print("\n=== 도구 호출 검증 ===") 29 r1 = await session.call_tool("search_employee", {"name": "김하늘"}) 30 print(f"search_employee -> {r1.content[0].text}") 31 32 r2 = await session.call_tool("list_team_members", {"team": "클라우드운영팀"}) 33 print(f"list_team_members -> {r2.content[0].text[:80]}") 34 35 r3 = await session.call_tool("get_asset", {"owner": "김하늘"}) 36 print(f"get_asset -> {r3.content[0].text}") 37 38 r4 = await session.call_tool("list_projects", {"team": "물류플랫폼팀"}) 39 print(f"list_projects -> {r4.content[0].text}") 40 41 # 5) 검증 3: 에러 케이스도 확인 42 r5 = await session.call_tool("search_employee", {"name": "홍길동"}) 43 print(f"없는 직원 -> {r5.content[0].text}")
이 파일은 로컬에서 서버 프로세스를 띄울 뿐 모델이나 외부 네트워크를 쓰지 않습니다. 아래는 이 저장소에서 실행한 실제 출력입니다. 긴 JSON은 뒤를 줄였습니다.
1=== 노출된 도구 목록 === 2- search_employee: 삼성SDS 임직원 디렉터리에서 이름으로 직원 정보를 조회한다. 3- list_team_members: 팀 이름으로 소속 임직원 목록을 조회한다. 4- get_asset: 사내 자산(노트북, 모니터 등)을 조회한다. 5- list_projects: 진행 중인 프로젝트 목록을 조회한다. 6 7=== 도구 호출 검증 === 8search_employee -> {"id": 1, "name": "김하늘", "team": "클라우드운영팀", "role": "팀장", "email": "[email protected]"} 9list_team_members -> { 10 "count": 3, 11 "members": [ 12 { 13 "id": 1, 14 "name": "김하늘", 15 " 16get_asset -> { 17 "count": 2, 18 "assets": [ 19 { 20 "asset_id": "A-1001", 21 "type": "노트북", 22 ... (이하 줄임: A-1001 노트북과 A-1006 모니터 두 건) 23list_projects -> [ 24 { 25 "project_id": "P-2601", 26 "name": "차세대 물류 플랫폼 구축", 27 ... (이하 줄임: P-2601 과 P-2603 두 건) 28없는 직원 -> '홍길동' 직원을 찾을 수 없습니다. 등록된 직원: 김하늘, 박도윤, 이서준, 최민준, 정하은
| 확인한 것 | 출력에서 보이는 근거 |
|---|---|
| 도구 네 개가 모두 노출됐다 | 목록에 네 줄. 설명의 첫 줄은 docstring의 첫 줄과 같습니다 (코드가 splitlines()[0] 로 첫 줄만 찍습니다) |
| 인자가 제대로 전달되고 필터가 동작한다 | 클라우드운영팀 count 가 3, 김하늘의 자산이 2건, 물류플랫폼팀 프로젝트가 2건 |
| 한글이 깨지지 않는다 | 서버가 ensure_ascii=False 로 JSON을 만들었고, 프로세스 경계를 넘어서도 그대로입니다 |
| 실패 경로도 메시지로 온다 | 없는 직원을 물었을 때 예외가 아니라 안내문이 돌아옵니다 |
list_team_members 의 결과가 중간에 끊긴 것은 서버 문제가 아니라 검증 코드가 [:80] 으로 앞 80자만 찍었기 때문입니다.
정상 호출만 확인한 서버는 Agent에 붙인 뒤 첫 번째 오타에서 문제를 드러냅니다. 모델은 인자를 틀리게 넣는 일이 잦으므로, 서버 검증에서 "틀린 인자를 넣으면 무엇이 돌아오는가"는 정상 경로만큼 중요한 확인 항목입니다.
mcp_server.py 를 자식 프로세스로 띄우므로 서버를 따로 실행해 둘 필요가 없습니다.1# mcp_verify.py - 파이썬 MCP 클라이언트로 서버를 직접 검증 2import asyncio 3import os 4import sys 5from mcp import ClientSession, StdioServerParameters 6from mcp.client.stdio import stdio_client 7 8SERVER_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mcp_server.py") 9 10 11async def main(): 12 # 1) 서버를 자식 프로세스로 띄울 방법을 기술 13 # sys.executable: 현재 파이썬 인터프리터 경로 (python/python3 문제 회피) 14 params = StdioServerParameters(command=sys.executable, args=[SERVER_PATH]) 15 16 # 2) stdio로 연결하고 세션 시작 17 async with stdio_client(params) as (read, write): 18 async with ClientSession(read, write) as session: 19 await session.initialize() # MCP 초기 핸드셰이크 20 21 # 3) 검증 1: 도구 목록 확인 22 tools = await session.list_tools() 23 print("=== 노출된 도구 목록 ===") 24 for t in tools.tools: 25 print(f"- {t.name}: {t.description.splitlines()[0]}") 26 27 # 4) 검증 2: 도구 호출 28 print("\n=== 도구 호출 검증 ===") 29 r1 = await session.call_tool("search_employee", {"name": "김하늘"}) 30 print(f"search_employee -> {r1.content[0].text}") 31 32 r2 = await session.call_tool("list_team_members", {"team": "클라우드운영팀"}) 33 print(f"list_team_members -> {r2.content[0].text[:80]}") 34 35 r3 = await session.call_tool("get_asset", {"owner": "김하늘"}) 36 print(f"get_asset -> {r3.content[0].text}") 37 38 r4 = await session.call_tool("list_projects", {"team": "물류플랫폼팀"}) 39 print(f"list_projects -> {r4.content[0].text}") 40 41 # 5) 검증 3: 에러 케이스도 확인 42 r5 = await session.call_tool("search_employee", {"name": "홍길동"}) 43 print(f"없는 직원 -> {r5.content[0].text}") 44 45 46if __name__ == "__main__": 47 asyncio.run(main())
검증이 끝난 서버를 LangGraph Agent에 연결합니다. langchain-mcp-adapters 패키지가 MCP 서버의 도구 목록을 받아 LangChain 도구 객체로 변환해 줍니다. 변환된 뒤에는 @tool 로 만든 도구와 같은 리스트에 넣어 함께 씁니다.
47async def main(): 48 # 1) MCP 클라이언트 설정: 서버 이름 -> 실행 방법 49 client = MultiServerMCPClient({ 50 "sds-company-data": { 51 "command": sys.executable, 52 "args": [SERVER_PATH], 53 "transport": "stdio", 54 }, 55 # 서버를 더 붙이고 싶으면 여기에 항목 추가 56 }) 57 58 # 2) MCP 도구를 LangChain Tool로 변환해 가져오기 (await 필수) 59 mcp_tools = await client.get_tools() 60 print(f"MCP 도구 {len(mcp_tools)}개: {[t.name for t in mcp_tools]}") 61 62 # 3) 일반 도구와 합치기: 여기부터는 MCP 여부 구분이 사라집니다 63 tools = mcp_tools + [calculator] 64 65 # 4) 2-2에서 손으로 조립한 ReAct 구조를 한 줄로 66 llm = ChatBedrockConverse( 67 model="us.anthropic.claude-sonnet-4-6", 68 region_name="us-east-1", 69 ) 70 agent = create_react_agent(llm, tools, prompt=SYSTEM_PROMPT)
MultiServerMCPClient 는 이름 그대로 서버 여러 개를 한꺼번에 다루는 클라이언트입니다. 키가 서버 이름이고, 값에 command, args, transport 를 적습니다. 서버를 더 붙이려면 항목을 하나 더 추가합니다.await client.get_tools() 가 각 서버에 연결해 도구 목록을 받아 LangChain 도구 리스트로 돌려줍니다. 서버의 docstring과 스키마가 그대로 도구의 설명과 args_schema 가 됩니다.mcp_tools + [calculator]. 리스트 덧셈 한 번입니다. 코드의 주석대로 "여기부터는 MCP 여부 구분이 사라집니다."create_react_agent(llm, tools, prompt=...) 는 앞에서 손으로 조립한 agent ⇄ tools 그래프(모델에 도구 묶기, ToolNode, 조건부 엣지, 루프)를 만들어 돌려주는 함수입니다.이 저장소에 설치된 LangGraph 1.x의 소스에는 create_react_agent 에 "langchain.agents 의 create_agent 로 옮겼다"는 사용 중단 예고가 붙어 있습니다. 구조는 같고, 5일차의 실습 파일들은 from langchain.agents import create_agent 를 씁니다.
22SERVER_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mcp_server.py") 23 24SYSTEM_PROMPT = """당신은 삼성SDS 사내 어시스턴트입니다. 교육용 더미 데이터를 조회해 답합니다. 25 26[도구 사용 규칙] 27- 임직원 개인 정보 -> search_employee 28- 팀 인원수, 팀 명단 -> list_team_members 29- 자산 현황 -> get_asset 30- 프로젝트 현황 -> list_projects 31- 수치 계산 -> calculator (암산하지 말고 반드시 도구 사용) 32 33도구에서 에러 메시지가 오면 그 지침을 따라 수정해서 다시 시도하세요. 34도구 결과에 근거해서만 답하세요."""
도구 설명에 이미 적은 경계를 시스템 프롬프트에 다시 요약해 두면 오선택이 줄어듭니다. 강조한 두 줄은 오늘 배운 것과 직접 이어집니다. "암산하지 말고 반드시 도구 사용"은 계산을 모델에게 맡기지 않겠다는 것이고, "에러 메시지가 오면 그 지침을 따라 수정해서 다시 시도하세요"는 도구가 메시지로 돌려준 실패를 모델이 실제로 따르게 만드는 나머지 절반입니다. 도구가 안내문을 잘 써도 모델이 그것을 지시로 받아들이지 않으면 복구는 일어나지 않습니다.
37# MCP 도구와 섞어 쓸 일반 도구 38@tool 39def calculator(expression: str) -> str: 40 """수학 계산을 정확하게 수행한다. 예: '3 * 2000000'""" 41 try: 42 return str(eval(expression, {"__builtins__": {}}, {})) 43 except Exception as e: 44 return f"에러: 계산식이 잘못되었습니다 ({e}). 올바른 수식으로 다시 호출하세요."
73 # 5) 단일 도구 질문과 복합 질문을 섞어 검증 74 queries = [ 75 # 단일 MCP 도구 76 "박도윤 님의 이메일 주소를 알려주세요.", 77 # 팀 단위 조회 (list_team_members) 78 "클라우드운영팀 인원이 몇 명인지 알려주세요.", 79 # MCP 도구 두 개를 이어야 하는 질문 80 "이서준 님이 소속된 팀에서 진행 중인 프로젝트를 알려주세요.", 81 # MCP 조회 + 일반 계산 도구를 엮는 복합 질문 82 "노트북 자산이 총 몇 대인지 세고 한 대당 200만원이면 총액이 얼마인지 계산해 주세요.", 83 ]
아래 표의 "기대하는 경로"와 "정답"은 코드 주석의 의도와 JSON 데이터에서 계산한 값입니다. 실제 실행에서 모델이 이 경로를 따랐는지는 화면에 찍히는 도구 호출 줄로 확인합니다.
| 질문 | 기대하는 도구 경로 | 데이터로 본 정답 |
|---|---|---|
| 박도윤 님의 이메일 주소 | search_employee | [email protected] |
| 클라우드운영팀 인원 | list_team_members | 3명 (김하늘, 이서준, 정하은) |
| 이서준 님이 소속된 팀의 프로젝트 | search_employee → list_projects | 클라우드운영팀의 "클라우드 비용 최적화", "사내 AI 어시스턴트 도입" |
| 노트북 대수와 대당 200만원일 때 총액 | get_asset → calculator | 5대, 10,000,000원 |
셋째 질문은 추적 절에서 본 순차 의존의 예입니다. 팀 이름을 알아야 프로젝트를 조회할 수 있으므로 두 도구는 한 턴에 묶일 수 없습니다. 넷째 질문은 프로세스 밖의 MCP 도구와 프로세스 안의 일반 도구가 한 답변 안에서 이어지는 경우입니다.
85 for q in queries: 86 print(f"\n{'=' * 60}\n질문: {q}\n{'=' * 60}") 87 async for event in agent.astream( 88 {"messages": [("user", q)]}, 89 stream_mode="updates", 90 ): 91 for node, update in event.items(): 92 for m in update.get("messages", []): 93 if getattr(m, "tool_calls", None): 94 for tc in m.tool_calls: 95 print(f" 도구 호출: {tc['name']}({tc['args']})") 96 elif m.type == "tool": 97 print(f" 도구 결과: [{m.name}] {get_text(m)[:80]}") 98 elif m.content: 99 print(f" 답변: {get_text(m)[:200]}")
stream_mode="updates" 는 그래프의 노드가 하나 끝날 때마다 {노드 이름: 그 노드가 돌려준 변경분} 을 내보냅니다. 앞의 trace.py 가 실행이 다 끝난 뒤 messages 를 훑었다면, 이쪽은 같은 내용을 일어나는 대로 봅니다. 분기하는 조건(tool_calls 유무, type == "tool")은 print_trace 와 같습니다.
설치된 langchain-mcp-adapters 는 MCP 도구를 비동기 함수만 가진 도구 객체로 만듭니다. 그래서 이 Agent는 agent.invoke 나 agent.stream 이 아니라 await agent.ainvoke, async for ... in agent.astream 으로 돌려야 합니다. 동기 방식으로 부르면 도구 실행 단계에서 동기 호출을 지원하지 않는다는 오류가 납니다. main 이 async def 이고 맨 아래가 asyncio.run(main()) 인 이유입니다.
같은 패키지의 get_tools 설명에는 "도구 호출마다 새 세션을 만든다"고 적혀 있습니다. stdio 전송에서 세션을 연다는 것은 서버 프로세스를 띄운다는 뜻이므로, 한 번의 호출에서 서버가 전역 변수에 담아 둔 값이 다음 호출 때 남아 있다고 가정하면 안 됩니다. 오늘의 서버는 조회만 하고 매번 파일에서 데이터를 읽으므로 문제가 없습니다. 호출 사이에 이어져야 하는 값은 파일이나 데이터베이스처럼 프로세스 밖에 둡니다.
1# mcp_agent.py - MCP 서버 + 일반 도구를 함께 쓰는 LangGraph 통합 Agent 2import asyncio 3import os 4import sys 5from dotenv import load_dotenv 6from langchain_aws import ChatBedrockConverse 7from langchain_core.tools import tool 8from langchain_mcp_adapters.client import MultiServerMCPClient 9from langgraph.prebuilt import create_react_agent 10 11load_dotenv() 12 13 14def get_text(message): 15 """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다.""" 16 content = message.content 17 if isinstance(content, list): 18 return "".join(block.get("text", "") for block in content if isinstance(block, dict)) 19 return content 20 21 22SERVER_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mcp_server.py") 23 24SYSTEM_PROMPT = """당신은 삼성SDS 사내 어시스턴트입니다. 교육용 더미 데이터를 조회해 답합니다. 25 26[도구 사용 규칙] 27- 임직원 개인 정보 -> search_employee 28- 팀 인원수, 팀 명단 -> list_team_members 29- 자산 현황 -> get_asset 30- 프로젝트 현황 -> list_projects 31- 수치 계산 -> calculator (암산하지 말고 반드시 도구 사용) 32 33도구에서 에러 메시지가 오면 그 지침을 따라 수정해서 다시 시도하세요. 34도구 결과에 근거해서만 답하세요.""" 35 36 37# MCP 도구와 섞어 쓸 일반 도구 38@tool 39def calculator(expression: str) -> str: 40 """수학 계산을 정확하게 수행한다. 예: '3 * 2000000'""" 41 try: 42 return str(eval(expression, {"__builtins__": {}}, {})) 43 except Exception as e: 44 return f"에러: 계산식이 잘못되었습니다 ({e}). 올바른 수식으로 다시 호출하세요." 45 46 47async def main(): 48 # 1) MCP 클라이언트 설정: 서버 이름 -> 실행 방법 49 client = MultiServerMCPClient({ 50 "sds-company-data": { 51 "command": sys.executable, 52 "args": [SERVER_PATH], 53 "transport": "stdio", 54 }, 55 # 서버를 더 붙이고 싶으면 여기에 항목 추가 56 }) 57 58 # 2) MCP 도구를 LangChain Tool로 변환해 가져오기 (await 필수) 59 mcp_tools = await client.get_tools() 60 print(f"MCP 도구 {len(mcp_tools)}개: {[t.name for t in mcp_tools]}") 61 62 # 3) 일반 도구와 합치기: 여기부터는 MCP 여부 구분이 사라집니다 63 tools = mcp_tools + [calculator] 64 65 # 4) 2-2에서 손으로 조립한 ReAct 구조를 한 줄로 66 llm = ChatBedrockConverse( 67 model="us.anthropic.claude-sonnet-4-6", 68 region_name="us-east-1", 69 ) 70 agent = create_react_agent(llm, tools, prompt=SYSTEM_PROMPT) 71 72 73 # 5) 단일 도구 질문과 복합 질문을 섞어 검증 74 queries = [ 75 # 단일 MCP 도구 76 "박도윤 님의 이메일 주소를 알려주세요.", 77 # 팀 단위 조회 (list_team_members) 78 "클라우드운영팀 인원이 몇 명인지 알려주세요.", 79 # MCP 도구 두 개를 이어야 하는 질문 80 "이서준 님이 소속된 팀에서 진행 중인 프로젝트를 알려주세요.", 81 # MCP 조회 + 일반 계산 도구를 엮는 복합 질문 82 "노트북 자산이 총 몇 대인지 세고 한 대당 200만원이면 총액이 얼마인지 계산해 주세요.", 83 ] 84 85 for q in queries: 86 print(f"\n{'=' * 60}\n질문: {q}\n{'=' * 60}") 87 async for event in agent.astream( 88 {"messages": [("user", q)]}, 89 stream_mode="updates", 90 ): 91 for node, update in event.items(): 92 for m in update.get("messages", []): 93 if getattr(m, "tool_calls", None): 94 for tc in m.tool_calls: 95 print(f" 도구 호출: {tc['name']}({tc['args']})") 96 elif m.type == "tool": 97 print(f" 도구 결과: [{m.name}] {get_text(m)[:80]}") 98 elif m.content: 99 print(f" 답변: {get_text(m)[:200]}") 100 101 102if __name__ == "__main__": 103 asyncio.run(main())
MCP의 이득은 내 서버를 내가 쓸 때보다 남이 만든 서버를 가져다 쓸 때 분명해집니다. 서버가 어떤 언어로 어떻게 구현됐는지 몰라도, 실행 명령만 알면 설정 한 항목으로 Agent의 도구가 늘어납니다.
23# 공개 서버는 여기 한 항목만 바꿔 끼웁니다. 셋 중 하나를 고르거나 직접 찾은 서버를 넣습니다. 24# time : python -m pip install "mcp==1.30.0" "mcp-server-time==2026.10.10" -> ["-m", "mcp_server_time", "--local-timezone", "Asia/Seoul"] 25# ddg : python -m pip install "mcp==1.30.0" "duckduckgo-mcp-server==0.6.1" -> ["-m", "duckduckgo_mcp_server.server"] 26# fetch : python -m pip install "mcp==1.30.0" "mcp-server-fetch==2026.10.10" -> ["-m", "mcp_server_fetch"] 27EXTERNAL = { 28 "command": sys.executable, 29 "args": ["-m", "mcp_server_time", "--local-timezone", "Asia/Seoul"], 30 "transport": "stdio", 31}
| 주석의 후보 | 설치하는 패키지 (주석에 적힌 버전) | 실행 인자 |
|---|---|---|
| time (코드에 설정된 것) | mcp-server-time==2026.10.10 | -m mcp_server_time --local-timezone Asia/Seoul |
| ddg | duckduckgo-mcp-server==0.6.1 | -m duckduckgo_mcp_server.server |
| fetch | mcp-server-fetch==2026.10.10 | -m mcp_server_fetch |
세 후보 모두 pip로 설치하는 파이썬 패키지이고, python -m 모듈이름 으로 실행되는 stdio 서버입니다. 그래서 command 는 우리 서버와 똑같이 sys.executable 이고 args 만 다릅니다.
38async def main(): 39 client = MultiServerMCPClient({ 40 "sds-company-data": {"command": sys.executable, "args": [SERVER_PATH], "transport": "stdio"}, 41 "external": EXTERNAL, 42 }) 43 tools = await client.get_tools() 44 print(f"도구 {len(tools)}개: {[t.name for t in tools]}")
52 # 두 서버의 도구를 모두 써야 답할 수 있는 질문으로 바꿔 봅니다 53 question = "지금 서울 시각을 알려주고, 클라우드운영팀 인원도 함께 알려주세요."
"지금 서울 시각"은 외부 서버의 도구가, "클라우드운영팀 인원"은 우리 서버의 list_team_members 가 답합니다. 두 요청은 서로 독립이므로 모델이 한 턴에 함께 요청할 수 있는 조합이기도 합니다. 첫 줄의 print 가 도구 개수와 이름을 찍으므로, 외부 서버가 어떤 이름의 도구를 내놓았는지는 실행해서 확인합니다. 참고로 이 파일의 모델 ID는 다른 실습과 달리 us.anthropic.claude-haiku-4-5-20251001-v1:0 으로 적혀 있습니다.
시스템 프롬프트는 "사내 데이터는 sds-company-data 서버의 도구를" 쓰라고 하지만, 이 코드는 도구 이름에 서버 이름을 붙이는 옵션(tool_name_prefix, 설치된 버전의 기본값은 꺼짐)을 켜지 않았습니다. 모델에게 가는 것은 search_employee 같은 도구 이름과 설명뿐이므로, 실제 선택은 각 도구의 설명에 기대어 이뤄집니다. 서버를 여럿 붙일 때는 서로 다른 서버가 같은 이름의 도구를 내놓지 않는지도 확인해야 합니다.
반대 방향도 성립합니다. 오늘 만든 mcp_server.py 는 LangGraph Agent 전용이 아닙니다. Claude Code 같은 MCP 클라이언트 기능을 가진 앱에 등록하면, 서버 코드를 한 줄도 고치지 않고 그 앱의 도구가 됩니다. 등록은 설정 파일에 서버 이름, 실행 명령, 인자를 적는 것이 전부이고, 내용은 위의 MultiServerMCPClient 설정과 같은 정보입니다.
1{ 2 "mcpServers": { 3 "sds-company-data": { 4 "command": "(가상환경의 파이썬 실행 파일 경로)", 5 "args": ["(mcp_server.py 의 절대 경로)"] 6 } 7 } 8}
stdio 서버를 붙인다는 것은 그 패키지의 코드를 내 권한으로 실행한다는 뜻입니다. 또 서버가 돌려주는 도구 설명과 도구 결과는 그대로 모델의 입력이 되므로, 웹 페이지를 가져오는 도구처럼 외부 글을 들여오는 서버는 모델에게 엉뚱한 지시를 주입하는 통로가 될 수 있습니다. 출처를 확인하고, 주석처럼 버전을 고정하고, 꼭 필요한 서버만 붙입니다. 도구 결과와 입력을 걸러 내는 가드레일과 위험한 도구 호출 전에 사람의 승인을 받는 절차는 5일차에서 다룹니다.
EXTERNAL 항목의 args 와 질문을 함께 바꿉니다.1# mcp_agent_external.py - 우리 서버 + 공개 MCP 서버를 한 Agent에 붙이기 (심화 과제) 2import asyncio 3import os 4import sys 5from dotenv import load_dotenv 6from langchain_aws import ChatBedrockConverse 7from langchain_mcp_adapters.client import MultiServerMCPClient 8from langgraph.prebuilt import create_react_agent 9 10load_dotenv() 11 12 13def get_text(message): 14 """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다.""" 15 content = message.content 16 if isinstance(content, list): 17 return "".join(block.get("text", "") for block in content if isinstance(block, dict)) 18 return content 19 20 21SERVER_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mcp_server.py") 22 23# 공개 서버는 여기 한 항목만 바꿔 끼웁니다. 셋 중 하나를 고르거나 직접 찾은 서버를 넣습니다. 24# time : python -m pip install "mcp==1.30.0" "mcp-server-time==2026.10.10" -> ["-m", "mcp_server_time", "--local-timezone", "Asia/Seoul"] 25# ddg : python -m pip install "mcp==1.30.0" "duckduckgo-mcp-server==0.6.1" -> ["-m", "duckduckgo_mcp_server.server"] 26# fetch : python -m pip install "mcp==1.30.0" "mcp-server-fetch==2026.10.10" -> ["-m", "mcp_server_fetch"] 27EXTERNAL = { 28 "command": sys.executable, 29 "args": ["-m", "mcp_server_time", "--local-timezone", "Asia/Seoul"], 30 "transport": "stdio", 31} 32 33SYSTEM_PROMPT = """당신은 삼성SDS 사내 어시스턴트입니다. 교육용 더미 데이터를 조회해 답합니다. 34사내 데이터(임직원, 팀, 자산, 프로젝트)는 sds-company-data 서버의 도구를, 그 밖의 정보는 다른 서버의 도구를 씁니다. 35도구 결과에 근거해서만 답하세요.""" 36 37 38async def main(): 39 client = MultiServerMCPClient({ 40 "sds-company-data": {"command": sys.executable, "args": [SERVER_PATH], "transport": "stdio"}, 41 "external": EXTERNAL, 42 }) 43 tools = await client.get_tools() 44 print(f"도구 {len(tools)}개: {[t.name for t in tools]}") 45 46 llm = ChatBedrockConverse( 47 model="us.anthropic.claude-haiku-4-5-20251001-v1:0", 48 region_name="us-east-1", 49 ) 50 agent = create_react_agent(llm, tools, prompt=SYSTEM_PROMPT) 51 52 # 두 서버의 도구를 모두 써야 답할 수 있는 질문으로 바꿔 봅니다 53 question = "지금 서울 시각을 알려주고, 클라우드운영팀 인원도 함께 알려주세요." 54 async for event in agent.astream({"messages": [("user", question)]}, stream_mode="updates"): 55 for node, update in event.items(): 56 for m in update.get("messages", []): 57 if getattr(m, "tool_calls", None): 58 for tc in m.tool_calls: 59 print(f" 도구 호출: {tc['name']}({tc['args']})") 60 elif m.type == "tool": 61 print(f" 도구 결과: [{m.name}] {get_text(m)[:80]}") 62 elif m.content: 63 print(f" 답변: {get_text(m)[:200]}") 64 65 66if __name__ == "__main__": 67 asyncio.run(main())
강의 제공 코드가 아니라 직접 만든 응용입니다. 국토교통부의 아파트 매매 실거래가 조회 API를 MCP 서버로 감쌌습니다. 더미 JSON 대신 진짜 외부 API가 뒤에 있을 때 도구 설계가 어떻게 달라지는지 볼 수 있는 예입니다.
12load_dotenv() # 이 파일 위치에서 위로 올라가며 .env 를 찾습니다 13 14mcp = FastMCP("apt-trade") 15 16API_URL = "https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade" 17# 키는 코드에 적지 않고 레포 루트의 .env 에서 읽습니다 (DATA_GO_KR_API_KEY) 18API_KEY = os.environ.get("DATA_GO_KR_API_KEY", "")
1일차에 정한 원칙 그대로입니다. 비밀값은 .env 에 두고 load_dotenv() 로 읽습니다. 주석에 적힌 대로 load_dotenv() 는 이 파일의 위치에서 위쪽 폴더로 올라가며 .env 를 찾으므로, 서버가 어느 폴더에서 실행되든 레포 루트의 파일을 찾습니다. 키가 없으면 빈 문자열이 기본값입니다.
21@mcp.tool() 22def apt_trades(region_code: str, year_month: str, limit: int = 20) -> str: 23 """시군구와 계약 연월로 아파트 매매 실거래 내역을 조회한다. 24 25 특정 지역의 아파트 매매 가격이나 거래 내역이 필요할 때 사용한다. 26 전월세, 오피스텔, 연립다세대 거래에는 사용하지 않는다. 27 금액 단위는 만원이고, cancelled가 true면 해제된 거래다. 28 29 Args: 30 region_code: 시군구 코드 5자리 (예: 서울 종로구 11110, 강남구 11680) 31 year_month: 계약 연월 YYYYMM (예: 202409) 32 limit: 가져올 개수 (최대 30) 33 """ 34 query = urllib.parse.urlencode({ 35 "serviceKey": API_KEY, 36 "LAWD_CD": region_code, 37 "DEAL_YMD": year_month, 38 "numOfRows": min(limit, 30), 39 })
| 설명서의 요소 | 이 도구에서 | 왜 필요한가 |
|---|---|---|
| 쓰지 않는 경우 | "전월세, 오피스텔, 연립다세대 거래에는 사용하지 않는다" | 이 API는 아파트 매매만 다룹니다. 적지 않으면 모델이 전세 시세를 물을 때도 이 도구를 부르고, 매매가를 전세가처럼 답합니다 |
| 결과를 읽는 법 | "금액 단위는 만원이고, cancelled가 true면 해제된 거래다" | 단위를 모르면 85,000을 8만 5천 원으로 읽을 수 있습니다. 숫자를 돌려주는 도구는 단위를 반드시 적습니다 |
| 인자의 형식과 예시 | "시군구 코드 5자리 (예: 서울 종로구 11110, 강남구 11680)", "YYYYMM (예: 202409)" | 사용자는 "강남구", "작년 9월"이라고 말합니다. 모델이 그것을 어떤 형식으로 바꿔 넣어야 하는지 알려 줍니다 |
| 상한 | "최대 30", 코드의 min(limit, 30) | 모델이 큰 값을 넣어도 응답이 무한정 길어지지 않게 코드에서 한 번 더 막습니다 |
40 try: 41 with urllib.request.urlopen(f"{API_URL}?{query}", timeout=10) as response: 42 root = ET.fromstring(response.read()) 43 trades = [{ 44 "apt": item.findtext("aptNm"), 45 "dong": item.findtext("umdNm"), 46 "price_manwon": int(item.findtext("dealAmount").replace(",", "")), 47 "area_m2": float(item.findtext("excluUseAr")), 48 "floor": item.findtext("floor"), 49 "date": f'{item.findtext("dealYear")}-{item.findtext("dealMonth")}-{item.findtext("dealDay")}', 50 "cancelled": bool((item.findtext("cdealType") or "").strip()), 51 } for item in root.findall("body/items/item")] 52 except Exception: 53 return "에러: 실거래가 조회에 실패했습니다. 잠시 후 다시 시도하세요." 54 55 if not trades: 56 return "거래가 없습니다. region_code(5자리)와 year_month(YYYYMM)를 확인하세요." 57 total = root.findtext("body/totalCount") 58 return json.dumps({"total": total, "trades": trades}, ensure_ascii=False, indent=2)
urlopen(..., timeout=10). 외부 API가 응답하지 않으면 10초 뒤에 예외가 나고 도구가 끝납니다. 제한이 없으면 Agent 전체가 그 도구에서 멈춰 섭니다.aptNm, umdNm, excluUseAr 같은 약어입니다. 도구는 이것을 apt, dong, area_m2 처럼 뜻이 드러나는 키로 바꾸고 일곱 항목만 남깁니다. 원본을 통째로 넘기면 토큰이 낭비되고 모델이 약어를 추측해야 합니다.price_manwon), 면적은 실수로, 해제 여부는 cdealType 에 값이 있는지로 불리언을 만듭니다. 키 이름에 단위(_manwon, _m2)를 넣은 것도 모델을 위한 장치입니다.total 은 조건에 맞는 전체 거래 수이고 trades 는 그 가운데 최대 30건입니다. 모델이 "일부만 본 것"임을 알 수 있어야 평균이나 최고가를 물었을 때 표본의 한계를 밝힐 수 있습니다.원인이 메시지에 드러나지 않는 실패가 있습니다. except Exception 이 모든 실패를 같은 문구로 바꿉니다. API 키가 비었거나 잘못된 경우에도 코드는 요청을 그대로 보내고, 그 결과가 예외로 끝나면 "잠시 후 다시 시도", 항목 없는 응답으로 끝나면 "거래가 없습니다"가 됩니다. 어느 쪽이든 모델은 키 문제임을 알 수 없어 의미 없는 재시도나 인자 수정을 하게 됩니다. 서버 쪽 로그에라도 원인을 남겨야 사람이 찾을 수 있습니다.
30건 뒤는 볼 수 없습니다. 페이지를 넘기는 인자가 없어 한 번에 최대 30건이 전부입니다. 거래가 많은 지역의 통계를 물으면 표본이 한쪽으로 치우칠 수 있습니다.
test 모드는 설치된 버전에서 그대로 동작하지 않을 수 있습니다. 맨 아래의 print(apt_trades(...)) 는 도구를 일반 함수처럼 부릅니다. 이 저장소의 fastmcp 2.13.0 에서 최소 예제로 확인해 보면 @mcp.tool() 을 붙인 이름은 함수가 아니라 도구 객체(FunctionTool)이고, 직접 호출하면 TypeError 가 납니다. 원래 함수는 그 객체의 .fn 에 들어 있습니다. tools_basic.py 의 주석 "도구는 함수가 아니라 객체"가 여기에도 해당하는 셈이고, 서버를 확인하는 정석은 앞 절처럼 클라이언트를 붙이는 것입니다.
61if __name__ == "__main__": 62 if "test" in sys.argv: 63 print(apt_trades("11110", "202409", 2)) 64 print(apt_trades("99999", "202409")) 65 else: 66 mcp.run()
이 API는 키를 쿼리 문자열(serviceKey)로 받습니다. 예외 내용이나 요청 URL을 에러 메시지에 그대로 넣으면 키가 모델의 입력과 대화 기록, 로그로 흘러갑니다. 이 코드의 except 가 예외 내용을 메시지에 넣지 않고 고정 문구만 돌려주는 것은 그 점에서는 안전한 선택입니다. 원인을 알리는 것과 비밀을 지키는 것 사이의 균형은 다음 절의 로그 마스킹에서 다시 나옵니다.
apt-trade 입니다. 실행하면 공공데이터포털의 외부 API를 호출하므로 .env 에 DATA_GO_KR_API_KEY 가 있어야 하고 인터넷 연결이 필요합니다. 모델은 부르지 않습니다.1# apt_trade_mcp.py - 국토교통부 아파트 매매 실거래가 조회 MCP 서버 2import json 3import os 4import sys 5import urllib.parse 6import urllib.request 7import xml.etree.ElementTree as ET 8 9from dotenv import load_dotenv 10from fastmcp import FastMCP 11 12load_dotenv() # 이 파일 위치에서 위로 올라가며 .env 를 찾습니다 13 14mcp = FastMCP("apt-trade") 15 16API_URL = "https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade" 17# 키는 코드에 적지 않고 레포 루트의 .env 에서 읽습니다 (DATA_GO_KR_API_KEY) 18API_KEY = os.environ.get("DATA_GO_KR_API_KEY", "") 19 20 21@mcp.tool() 22def apt_trades(region_code: str, year_month: str, limit: int = 20) -> str: 23 """시군구와 계약 연월로 아파트 매매 실거래 내역을 조회한다. 24 25 특정 지역의 아파트 매매 가격이나 거래 내역이 필요할 때 사용한다. 26 전월세, 오피스텔, 연립다세대 거래에는 사용하지 않는다. 27 금액 단위는 만원이고, cancelled가 true면 해제된 거래다. 28 29 Args: 30 region_code: 시군구 코드 5자리 (예: 서울 종로구 11110, 강남구 11680) 31 year_month: 계약 연월 YYYYMM (예: 202409) 32 limit: 가져올 개수 (최대 30) 33 """ 34 query = urllib.parse.urlencode({ 35 "serviceKey": API_KEY, 36 "LAWD_CD": region_code, 37 "DEAL_YMD": year_month, 38 "numOfRows": min(limit, 30), 39 }) 40 try: 41 with urllib.request.urlopen(f"{API_URL}?{query}", timeout=10) as response: 42 root = ET.fromstring(response.read()) 43 trades = [{ 44 "apt": item.findtext("aptNm"), 45 "dong": item.findtext("umdNm"), 46 "price_manwon": int(item.findtext("dealAmount").replace(",", "")), 47 "area_m2": float(item.findtext("excluUseAr")), 48 "floor": item.findtext("floor"), 49 "date": f'{item.findtext("dealYear")}-{item.findtext("dealMonth")}-{item.findtext("dealDay")}', 50 "cancelled": bool((item.findtext("cdealType") or "").strip()), 51 } for item in root.findall("body/items/item")] 52 except Exception: 53 return "에러: 실거래가 조회에 실패했습니다. 잠시 후 다시 시도하세요." 54 55 if not trades: 56 return "거래가 없습니다. region_code(5자리)와 year_month(YYYYMM)를 확인하세요." 57 total = root.findtext("body/totalCount") 58 return json.dumps({"total": total, "trades": trades}, ensure_ascii=False, indent=2) 59 60 61if __name__ == "__main__": 62 if "test" in sys.argv: 63 print(apt_trades("11110", "202409", 2)) 64 print(apt_trades("99999", "202409")) 65 else: 66 mcp.run()
지금까지의 도구는 뒤쪽 시스템이 늘 정상이라고 가정했습니다. 운영에서는 백엔드가 늦게 답하고, 호출 한도에 걸리고, 어느 날 응답 형식이 바뀝니다. 이런 상황을 기다리지 않고 일부러 만들어 넣어 대응 코드를 시험하는 것을 장애 주입(failure injection)이라고 합니다. failure_injection.py 는 강의에서 제공한 코드로, 환경 변수 하나로 장애의 종류를 고릅니다.
가짜 자산 관리 백엔드. FAILURE_MODE 값에 따라 정상 응답을 주거나 장애를 재현합니다. 도구가 아니라 도구가 부르는 내부 함수입니다.
모델이 부르는 실시간 조회 도구. 백엔드를 최대 세 번 시도하고, 끝내 실패하면 다음 행동을 안내하는 메시지를 돌려줍니다.
백업 조회 도구. 1시간 전 스냅샷 기준의 값을 항상 성공적으로 돌려줍니다.
45def _call_backend(asset_id: str) -> dict: 46 """가짜 자산 관리 백엔드 호출. FAILURE_MODE에 따라 장애를 재현한다.""" 47 # 주의: Secret은 절대 로그에 남기지 않습니다. 마스킹해서 기록. 48 logger.info("backend 호출 asset_id=%s api_key=%s***", asset_id, API_KEY[:6]) 49 trace(3, f"_call_backend({asset_id!r}) FAILURE_MODE={FAILURE_MODE}") 50 51 if FAILURE_MODE == "timeout": 52 time.sleep(5) # 응답 지연 재현 53 trace(3, "-> raise TimeoutError (5초 대기 후)") 54 raise TimeoutError("backend 응답이 5초를 초과했습니다") 55 if FAILURE_MODE == "rate_limit": 56 trace(3, "-> raise RateLimitError (429)") 57 raise RateLimitError("429 Too Many Requests: 분당 호출 한도 초과") 58 if FAILURE_MODE == "bad_schema": 59 trace(3, "-> return {'assetStatus': ...} (계약과 다른 응답)") 60 return {"assetStatus": "사용중인듯"} # 계약(status, owner 키)과 다른 응답 61 trace(3, "-> return 정상 응답") 62 return {"asset_id": asset_id, "status": "사용중", "owner": "김하늘"}
65@tool 66def get_asset_status(asset_id: str) -> str: 67 """자산 관리 백엔드에서 자산 ID로 현재 상태를 실시간 조회한다. 68 69 Args: 70 asset_id: 자산 ID (예: 'A-1001') 71 """ 72 trace(2, f"get_asset_status({asset_id!r}) 시작") 73 max_retries = 3 74 for attempt in range(1, max_retries + 1): 75 trace(2, f"시도 {attempt}/{max_retries}") 76 try: 77 data = _call_backend(asset_id) 78 79 # bad_schema 방어: 응답 계약 검증 (재시도해도 소용없으므로 즉시 fallback 안내) 80 if "status" not in data or "owner" not in data: 81 logger.error("응답 스키마 불일치: keys=%s", list(data.keys())) 82 trace(2, f"스키마 검증 실패 keys={list(data.keys())} -> 재시도 없이 포기") 83 return _done("에러: 백엔드 응답 형식이 올바르지 않습니다. " 84 "get_asset_status_backup 도구를 대신 사용하세요.") 85 86 return _done(f"{data['asset_id']}: {data['status']} (사용자: {data['owner']})") 87 88 except RateLimitError as e: 89 wait = 2 ** attempt # 2초, 4초, 8초 90 logger.warning("rate limit (시도 %d/%d), %d초 대기: %s", attempt, max_retries, wait, e) 91 if attempt == max_retries: 92 return _done("에러: 호출 한도를 초과했습니다. get_asset_status_backup 도구를 사용하세요.") 93 trace(2, f"except RateLimitError -> {wait}초 대기 후 재시도") 94 time.sleep(wait) 95 96 except TimeoutError as e: 97 logger.warning("timeout (시도 %d/%d): %s", attempt, max_retries, e) 98 if attempt == max_retries: 99 return _done("에러: 백엔드 응답이 계속 지연됩니다. get_asset_status_backup 도구를 사용하세요.") 100 trace(2, "except TimeoutError -> 바로 재시도") 101 102 except Exception as e: 103 logger.exception("예상 못한 장애") 104 return _done(f"에러: 조회 실패 ({type(e).__name__}). get_asset_status_backup 도구를 사용하세요.")
FAILURE_MODE | 재현하는 상황 | 백엔드의 동작 | 도구 안의 대응 | 도구가 쓰는 시간 | 모델에게 돌아가는 것 |
|---|---|---|---|---|---|
| none | 정상 | status, owner 가 든 dict 반환 | 첫 시도에 성공 | 즉시 | "A-1001: 사용중 (사용자: 김하늘)" |
| timeout | 응답 지연 | 5초 기다린 뒤 TimeoutError | 일시적일 수 있으므로 기다리지 않고 바로 재시도. 세 번째도 실패하면 포기 | 약 15초 (5초 × 3) | "에러: 백엔드 응답이 계속 지연됩니다. get_asset_status_backup 도구를 사용하세요." |
| rate_limit | 호출 한도 초과 (HTTP 429) | 즉시 RateLimitError | 바로 다시 부르면 또 거절되므로 2초, 4초 쉬고 재시도. 세 번째도 실패하면 포기 | 약 6초 (2초 + 4초) | "에러: 호출 한도를 초과했습니다. get_asset_status_backup 도구를 사용하세요." |
| bad_schema | 응답 형식이 약속과 다름 | 예외 없이 {"assetStatus": "사용중인듯"} 반환 | 필수 키 검증에 실패. 다시 불러도 같은 답이 올 것이므로 재시도 없이 즉시 포기 | 즉시 | "에러: 백엔드 응답 형식이 올바르지 않습니다. get_asset_status_backup 도구를 대신 사용하세요." |
| (그 밖의 예외) | 예상하지 못한 오류 | - | except Exception 이 받아 로그에 남기고 즉시 포기 | 즉시 | "에러: 조회 실패 (예외 이름). get_asset_status_backup 도구를 사용하세요." |
세 장애를 가르는 기준은 하나입니다. 같은 요청을 다시 보내면 결과가 달라질 가능성이 있는가. 지연은 다시 부르면 될 수도 있고, 한도 초과는 시간이 지나야 풀리며, 형식 오류는 몇 번을 불러도 같습니다. 그래서 재시도의 유무와 간격이 장애마다 다릅니다. 기다리는 시간을 2초, 4초로 두 배씩 늘리는 방식을 지수 백오프(exponential backoff)라고 하며, 코드에서는 2 ** attempt 가 그 계산입니다.
일반적인 백엔드 코드라면 재시도가 모두 실패했을 때 함수 안에서 백업 조회를 바로 호출했을 것입니다. 이 도구는 그 대신 "get_asset_status_backup 도구를 사용하세요"라는 문장을 돌려줍니다. 경로를 바꾸는 결정을 모델에게 넘긴 것입니다. 이렇게 하면 모델이 "지금 보여 주는 값은 백업 기준"이라는 사실을 알고 답변에 밝힐 수 있고, 백업 도구의 반환값도 "백업 스냅샷 기준, 갱신 주기 1시간"이라고 스스로 출처를 적어 줍니다.
107@tool 108def get_asset_status_backup(asset_id: str) -> str: 109 """백업 자산 상태 조회. get_asset_status 실패 시 사용한다. 1시간 전 스냅샷 기준이라 느리지만 안정적이다.""" 110 trace(2, f"get_asset_status_backup({asset_id!r}) 시작") 111 logger.info("backup 경로 사용 asset_id=%s", asset_id) 112 return _done(f"{asset_id}: 사용중 (백업 스냅샷 기준, 갱신 주기 1시간)")
13logging.basicConfig( 14 level=logging.INFO, 15 format="%(asctime)s %(levelname)s %(name)s %(message)s", 16 # mode="w": 실행할 때마다 agent.log를 비우고 새로 씁니다 17 # 화면 출력은 아래 trace()가 맡으므로 로그는 파일에만 남깁니다 18 handlers=[logging.FileHandler("agent.log", mode="w", encoding="utf-8")], 19) 20logger = logging.getLogger("tools") 21 22FAILURE_MODE = os.environ.get("FAILURE_MODE", "none") 23API_KEY = os.environ.get("INTERNAL_API_KEY", "sk-internal-dummy-1234") # 점검용 더미 Secret
앞에서 실패를 메시지로 바꾸면 운영자 눈에서 장애가 사라진다고 했습니다. 이 파일은 그 짝을 갖췄습니다. 모델에게는 안내문을 돌려주면서, 같은 순간에 logger.warning 과 logger.error 로 무엇이 몇 번째 시도에서 실패했는지를 파일에 남깁니다. 그리고 백엔드를 부를 때 쓰는 키는 API_KEY[:6] 뒤에 *** 를 붙여 앞 여섯 글자만 기록합니다. 값의 일부를 가려 기록하는 것을 마스킹이라고 합니다.
아래는 이 저장소에 남아 있던 agent.log 의 일부입니다. rate_limit 모드로 실행했을 때의 실제 기록이고, Bedrock 호출을 알리는 라이브러리 로그 줄은 뺐습니다.
12026-11-05 15:19:31,596 INFO tools backend 호출 asset_id=A-1001 api_key=sk-int*** 22026-11-05 15:19:34,447 INFO tools backend 호출 asset_id=A-1001 api_key=sk-int*** 32026-11-05 15:19:34,448 WARNING tools rate limit (시도 1/3), 2초 대기: 429 Too Many Requests: 분당 호출 한도 초과 42026-11-05 15:19:36,448 INFO tools backend 호출 asset_id=A-1001 api_key=sk-int*** 52026-11-05 15:19:36,448 WARNING tools rate limit (시도 2/3), 4초 대기: 429 Too Many Requests: 분당 호출 한도 초과 62026-11-05 15:19:40,448 INFO tools backend 호출 asset_id=A-1001 api_key=sk-int*** 72026-11-05 15:19:40,449 WARNING tools rate limit (시도 3/3), 8초 대기: 429 Too Many Requests: 분당 호출 한도 초과 82026-11-05 15:19:42,068 INFO tools backup 경로 사용 asset_id=A-1001
첫 줄은 정상 실행의 호출이고, 둘째 줄부터가 장애 주입 실행입니다. 시각을 보면 34초, 36초, 40초에 백엔드를 불렀으니 대기는 2초와 4초였고, 42초에 백업 도구가 호출됐습니다. 키는 어디에도 원문으로 남지 않았습니다.
"8초 대기"는 실제로 일어나지 않습니다. 주석에는 "2초, 4초, 8초"라고 적혀 있고 세 번째 시도의 로그에도 "8초 대기"가 찍히지만, 코드는 로그를 남긴 직후 마지막 시도인지 확인하고 바로 반환합니다. 위 기록에서도 40초의 경고 뒤에 기다림 없이 다음 단계로 넘어갑니다. 로그 문구와 실제 동작이 어긋난 자리입니다.
모드 이름을 틀리면 조용히 정상 동작합니다. _call_backend 는 세 문자열과 일치할 때만 장애를 냅니다. FAILURE_MODE 에 오타가 있으면 어떤 조건에도 걸리지 않아 정상 응답이 나가고, 장애 대응을 시험했다고 착각하게 됩니다.
로그 파일에는 라이브러리의 로그도 섞입니다. logging.basicConfig 는 프로그램 전체의 기본 로그 설정이라, tools 로거뿐 아니라 Bedrock 호출 라이브러리의 INFO 로그도 같은 agent.log 에 쌓입니다. 파일 경로가 상대 경로이므로 로그는 스크립트를 실행한 폴더에 생기고, mode="w" 라서 실행할 때마다 새로 쓰입니다.
failure_agent.py 가 import 해서 씁니다. import 되는 순간 로그 설정이 적용되고 agent.log 가 새로 만들어집니다. trace() 는 호출 깊이만큼 들여쓰기해 화면에 실행 흐름을 찍는 보조 함수입니다.1# failure_injection.py - 운영 장애 주입 도구 (제공 코드) 2"""FAILURE_MODE 환경 변수로 장애 종류를 골라 주입합니다. 3값: timeout | rate_limit | bad_schema | none 4 5실행 예: 6 $env:FAILURE_MODE="timeout"; python failure_agent.py 7""" 8import os 9import time 10import logging 11from langchain_core.tools import tool 12 13logging.basicConfig( 14 level=logging.INFO, 15 format="%(asctime)s %(levelname)s %(name)s %(message)s", 16 # mode="w": 실행할 때마다 agent.log를 비우고 새로 씁니다 17 # 화면 출력은 아래 trace()가 맡으므로 로그는 파일에만 남깁니다 18 handlers=[logging.FileHandler("agent.log", mode="w", encoding="utf-8")], 19) 20logger = logging.getLogger("tools") 21 22FAILURE_MODE = os.environ.get("FAILURE_MODE", "none") 23API_KEY = os.environ.get("INTERNAL_API_KEY", "sk-internal-dummy-1234") # 점검용 더미 Secret 24 25_T0 = time.perf_counter() 26 27 28def trace(depth: int, msg: str): 29 """함수 실행 흐름을 화면에 출력합니다. depth만큼 들여써서 누가 누구를 불렀는지 보이게 합니다. 30 depth 0: __main__ / 1: 그래프 노드 / 2: 도구 함수 / 3: 백엔드 31 """ 32 print(f"[{time.perf_counter() - _T0:5.1f}s] {' ' * depth}{msg}") 33 34 35def _done(result: str) -> str: 36 """도구가 LLM에게 돌려줄 값을 출력하고 그대로 반환합니다.""" 37 trace(2, f"<- return {result!r}") 38 return result 39 40 41class RateLimitError(Exception): 42 pass 43 44 45def _call_backend(asset_id: str) -> dict: 46 """가짜 자산 관리 백엔드 호출. FAILURE_MODE에 따라 장애를 재현한다.""" 47 # 주의: Secret은 절대 로그에 남기지 않습니다. 마스킹해서 기록. 48 logger.info("backend 호출 asset_id=%s api_key=%s***", asset_id, API_KEY[:6]) 49 trace(3, f"_call_backend({asset_id!r}) FAILURE_MODE={FAILURE_MODE}") 50 51 if FAILURE_MODE == "timeout": 52 time.sleep(5) # 응답 지연 재현 53 trace(3, "-> raise TimeoutError (5초 대기 후)") 54 raise TimeoutError("backend 응답이 5초를 초과했습니다") 55 if FAILURE_MODE == "rate_limit": 56 trace(3, "-> raise RateLimitError (429)") 57 raise RateLimitError("429 Too Many Requests: 분당 호출 한도 초과") 58 if FAILURE_MODE == "bad_schema": 59 trace(3, "-> return {'assetStatus': ...} (계약과 다른 응답)") 60 return {"assetStatus": "사용중인듯"} # 계약(status, owner 키)과 다른 응답 61 trace(3, "-> return 정상 응답") 62 return {"asset_id": asset_id, "status": "사용중", "owner": "김하늘"} 63 64 65@tool 66def get_asset_status(asset_id: str) -> str: 67 """자산 관리 백엔드에서 자산 ID로 현재 상태를 실시간 조회한다. 68 69 Args: 70 asset_id: 자산 ID (예: 'A-1001') 71 """ 72 trace(2, f"get_asset_status({asset_id!r}) 시작") 73 max_retries = 3 74 for attempt in range(1, max_retries + 1): 75 trace(2, f"시도 {attempt}/{max_retries}") 76 try: 77 data = _call_backend(asset_id) 78 79 # bad_schema 방어: 응답 계약 검증 (재시도해도 소용없으므로 즉시 fallback 안내) 80 if "status" not in data or "owner" not in data: 81 logger.error("응답 스키마 불일치: keys=%s", list(data.keys())) 82 trace(2, f"스키마 검증 실패 keys={list(data.keys())} -> 재시도 없이 포기") 83 return _done("에러: 백엔드 응답 형식이 올바르지 않습니다. " 84 "get_asset_status_backup 도구를 대신 사용하세요.") 85 86 return _done(f"{data['asset_id']}: {data['status']} (사용자: {data['owner']})") 87 88 except RateLimitError as e: 89 wait = 2 ** attempt # 2초, 4초, 8초 90 logger.warning("rate limit (시도 %d/%d), %d초 대기: %s", attempt, max_retries, wait, e) 91 if attempt == max_retries: 92 return _done("에러: 호출 한도를 초과했습니다. get_asset_status_backup 도구를 사용하세요.") 93 trace(2, f"except RateLimitError -> {wait}초 대기 후 재시도") 94 time.sleep(wait) 95 96 except TimeoutError as e: 97 logger.warning("timeout (시도 %d/%d): %s", attempt, max_retries, e) 98 if attempt == max_retries: 99 return _done("에러: 백엔드 응답이 계속 지연됩니다. get_asset_status_backup 도구를 사용하세요.") 100 trace(2, "except TimeoutError -> 바로 재시도") 101 102 except Exception as e: 103 logger.exception("예상 못한 장애") 104 return _done(f"에러: 조회 실패 ({type(e).__name__}). get_asset_status_backup 도구를 사용하세요.") 105 106 107@tool 108def get_asset_status_backup(asset_id: str) -> str: 109 """백업 자산 상태 조회. get_asset_status 실패 시 사용한다. 1시간 전 스냅샷 기준이라 느리지만 안정적이다.""" 110 trace(2, f"get_asset_status_backup({asset_id!r}) 시작") 111 logger.info("backup 경로 사용 asset_id=%s", asset_id) 112 return _done(f"{asset_id}: 사용중 (백업 스냅샷 기준, 갱신 주기 1시간)")
도구가 "백업 도구를 사용하세요"라고 돌려줘도 모델이 그 말을 따르지 않으면 복구는 없습니다. failure_agent.py 는 고장 나는 도구를 Agent에 연결해 같은 질문을 정상 상태와 장애 상태에서 한 번씩 던지고, 네 가지 기준으로 통과 여부를 판정합니다.
21llm = ChatBedrockConverse( 22 model="us.anthropic.claude-sonnet-4-6", 23 region_name="us-east-1", 24) 25agent = create_react_agent( 26 llm, 27 [get_asset_status, get_asset_status_backup], 28 prompt="자산 상태 질문에는 get_asset_status를 먼저 사용하고 도구 에러 메시지에 안내된 지침을 그대로 따르세요.", 29)
for 루프와 except 로 짠 결정적인 동작입니다. 같은 장애에는 항상 같은 횟수, 같은 간격으로 재시도합니다. 모델은 이 과정을 모릅니다.
에러 메시지를 읽고 백업 도구를 부르는 것은 모델의 판단이라 확률적입니다. 프롬프트의 "지침을 그대로 따르세요"가 그 가능성을 높일 뿐 보장하지는 않습니다. 그래서 실제로 일어났는지를 실행 기록으로 검사합니다.
86if __name__ == "__main__": 87 question = "A-1001 자산의 현재 상태를 알려주세요." 88 89 # 1) 정상 호출: 장애 없이 한 번 돌려 봅니다 90 trace(0, "=== 1) 정상 실행 (FAILURE_MODE=none) ===") 91 failure_injection.FAILURE_MODE = "none" 92 normal = run(question) 93 normal_ok = ("get_asset_status" in tool_names(normal) 94 and "에러" not in get_text(normal["messages"][-1])) 95 96 # 2) 실패 호출: FAILURE_MODE로 고른 장애를 주입합니다 97 mode = os.environ.get("FAILURE_MODE", "timeout") 98 print() 99 trace(0, f"=== 2) 장애 주입 실행 (FAILURE_MODE={mode}) ===") 100 failure_injection.FAILURE_MODE = mode 101 broken = run(question) 102 print(f"\n=== 장애 주입 messages 요약 (FAILURE_MODE={mode}) ===") 103 print_trace(broken) 104 recovered = "get_asset_status_backup" in tool_names(broken) 105 answered = bool(get_text(broken["messages"][-1]).strip())
"none" 으로 두고 "A-1001 자산의 현재 상태"를 묻습니다. 실시간 도구가 한 번에 성공하는 기준선입니다.FAILURE_MODE 를 읽어(없으면 timeout) 스위치를 바꾸고 같은 질문을 다시 던집니다.agent.log 에 비밀값 원문이 남았는지 검색합니다.1$env:FAILURE_MODE="timeout"; python failure_agent.py 2$env:FAILURE_MODE="rate_limit"; python failure_agent.py 3$env:FAILURE_MODE="bad_schema"; python failure_agent.py
failure_injection.py 는 import 될 때 환경 변수를 한 번 읽어 전역 변수 FAILURE_MODE 에 담습니다. failure_agent.py 는 실행 도중 이 값을 failure_injection.FAILURE_MODE = "none" 처럼 모듈의 속성으로 바꿉니다. _call_backend 가 호출될 때마다 그 전역 변수를 읽으므로 바로 반영됩니다. 만약 from failure_injection import FAILURE_MODE 로 가져와 바꿨다면, 바뀌는 것은 가져온 쪽의 이름일 뿐 원래 모듈의 변수는 그대로라 스위치가 듣지 않습니다. 맨 위에서 모듈 전체를 import failure_injection 으로 따로 가져온 이유입니다.
32def run(question: str) -> dict: 33 """agent.invoke 대신 stream으로 돌려, 그래프 노드가 끝날 때마다 무슨 일이 있었는지 출력합니다. 34 도구 함수 안의 출력(depth 2~3)은 tools 노드가 끝나기 전에 찍히므로 실행 순서대로 섞여 보입니다. 35 """ 36 trace(1, "[agent 노드] LLM 호출 중...") 37 result = None 38 for mode, chunk in agent.stream({"messages": [("user", question)]}, stream_mode=["updates", "values"]): 39 if mode == "values": 40 result = chunk # 마지막 values가 최종 State (invoke 결과와 같음) 41 continue 42 for node, update in chunk.items(): 43 for m in (update or {}).get("messages", []): 44 if node == "agent" and m.tool_calls: 45 for tc in m.tool_calls: 46 trace(1, f"[agent 노드] LLM -> 도구 요청 {tc['name']}({tc['args']})") 47 trace(1, "[tools 노드] 도구 실행") 48 elif node == "agent": 49 trace(1, f"[agent 노드] LLM -> 최종 답변 (도구 요청 없음, 종료): {get_text(m)[:40]!r}...") 50 else: 51 trace(1, f"[tools 노드] ToolMessage를 State에 추가 -> 다시 agent 노드로") 52 trace(1, "[agent 노드] LLM 호출 중...") 53 return result
stream_mode 에 리스트를 주면 (모드, 내용) 쌍이 흘러나옵니다. "updates" 는 노드가 끝날 때마다의 변경분이고 "values" 는 그 시점의 State 전체입니다. 마지막 values 가 invoke 의 반환값과 같으므로 이것을 result 로 돌려줘 뒤의 판정에 씁니다. 이 함수가 찍는 줄(깊이 1)과 도구 함수 안에서 trace() 가 찍는 줄(깊이 2~3)이 실행 순서대로 섞여, 누가 누구를 불렀는지가 들여쓰기로 드러납니다.
1=== 2) 장애 주입 실행 (FAILURE_MODE=timeout) === 2 [agent 노드] LLM 호출 중... 3 [agent 노드] LLM -> 도구 요청 get_asset_status({'asset_id': 'A-1001'}) 4 [tools 노드] 도구 실행 5 get_asset_status('A-1001') 시작 6 시도 1/3 7 _call_backend('A-1001') FAILURE_MODE=timeout 8 -> raise TimeoutError (5초 대기 후) 9 except TimeoutError -> 바로 재시도 10 시도 2/3 11 _call_backend('A-1001') FAILURE_MODE=timeout 12 -> raise TimeoutError (5초 대기 후) 13 except TimeoutError -> 바로 재시도 14 시도 3/3 15 _call_backend('A-1001') FAILURE_MODE=timeout 16 -> raise TimeoutError (5초 대기 후) 17 <- return '에러: 백엔드 응답이 계속 지연됩니다. get_asset_status_backup 도구를 사용하세요.' 18 [tools 노드] ToolMessage를 State에 추가 -> 다시 agent 노드로 19 [agent 노드] LLM 호출 중... 20 [agent 노드] LLM -> 도구 요청 get_asset_status_backup({'asset_id': 'A-1001'}) 21 [tools 노드] 도구 실행 22 get_asset_status_backup('A-1001') 시작 23 <- return 'A-1001: 사용중 (백업 스냅샷 기준, 갱신 주기 1시간)' 24 [tools 노드] ToolMessage를 State에 추가 -> 다시 agent 노드로 25 [agent 노드] LLM 호출 중... 26 [agent 노드] LLM -> 최종 답변 (도구 요청 없음, 종료): '...'...
70def tool_names(result: dict) -> list: 71 """trace에서 실제로 호출된 도구 이름만 뽑습니다.""" 72 return [tc["name"] for m in result["messages"] 73 if getattr(m, "tool_calls", None) for tc in m.tool_calls] 74 75 76def check_secret_log(path="agent.log") -> tuple: 77 """로그 파일에 자격 증명 원문이 남았는지 확인합니다.""" 78 if not os.path.exists(path): 79 return True, "agent.log 없음" 80 with open(path, encoding="utf-8") as f: 81 text = f.read() 82 hits = [p for p in ("sk-internal-dummy-1234", "AKIA") if p in text] 83 return (not hits), ("발견 0건" if not hits else f"발견: {hits}")
112 print("\n=== DoD 판정 ===") 113 print(f"정상 호출 처리 : {'통과' if normal_ok else '실패'}") 114 print(f"실패 호출 처리 : {'통과' if answered else '실패'} (그래프 중단 없이 답변 생성)") 115 print(f"필수 장애 1개 복구 : {'통과' if recovered else '실패'} (장애 {mode}, 백업 도구 호출 {'있음' if recovered else '없음'})") 116 print(f"로그 Secret 점검 : {'통과' if secret_ok else '실패'} ({secret_msg})") 117 print(f"최종 : {'DoD 충족' if all([normal_ok, answered, recovered, secret_ok]) else 'DoD 미충족'}")
DoD(Definition of Done)는 "이 조건을 모두 만족하면 끝난 것으로 본다"는 완료 기준입니다. 여기서는 네 항목이고, 각각 코드의 조건식 하나에 대응합니다.
| 판정 항목 | 통과 조건 (코드) | 확인하려는 것 |
|---|---|---|
| 정상 호출 처리 | 정상 실행에서 get_asset_status 가 요청됐고, 최종 답변에 "에러"라는 글자가 없다 | 장애가 없을 때 원래 경로로 답하는가 |
| 실패 호출 처리 | 장애 실행의 마지막 메시지에 글이 있다 | 그래프가 예외로 죽지 않고 답변까지 갔는가 |
| 필수 장애 1개 복구 | 장애 실행에서 get_asset_status_backup 이 요청됐다 | 모델이 안내문을 따라 실제로 경로를 바꿨는가 |
| 로그 Secret 점검 | agent.log 에 sk-internal-dummy-1234 와 AKIA 가 없다 | 키 원문이 로그에 새지 않았는가. AKIA 는 AWS 액세스 키 ID의 앞머리입니다 |
셋째 항목의 채점 방식이 요점입니다. 최종 답변에 "백업"이라는 말이 있는지를 보지 않고, tool_names() 로 실제 요청된 도구 이름 목록을 뽑아 확인합니다. 모델의 문장은 매번 달라지고 하지 않은 일을 했다고 쓸 수도 있지만, messages 에 남은 tool_calls 는 실제로 일어난 일입니다.
일반 백엔드의 재시도·폴백 코드와 이 실습의 차이, 실행 여섯 단계, timeout 모드에서 누가 누구를 부르는지의 시퀀스 그림, 장애 3종 비교표, 이 설계의 약점까지 한 장에 모은 자료입니다. 강의 중 초기 버전 코드를 기준으로 만들어 현재 파일과 세부가 조금 다릅니다.
자료는 Agent를 agent.invoke 로 돌리고 로그를 화면과 파일에 함께 남기며 agent.log 가 실행마다 쌓인다고 설명합니다. 현재 코드는 agent.stream 과 trace() 로 진행을 찍고, 로그는 파일에만 남기며, mode="w" 로 매번 새로 씁니다. 모델 ID 표기도 다릅니다. 재시도, 안내문, 판정의 구조는 같습니다.
| 한계 | 코드에서의 근거 | 보완하는 방법 |
|---|---|---|
| 폴백이 확률적이다 | 백업 도구 호출은 프롬프트와 에러 문구를 읽은 모델의 선택입니다 | 반드시 백업으로 가야 한다면 그래프의 조건부 엣지로 경로를 코드에 고정하거나, 도구 안에서 백업을 직접 부릅니다 |
| "정상 호출" 판정이 느슨하다 | 답변에 "에러" 두 글자가 없는지만 봅니다 | 답변에 기대하는 값("사용중", "김하늘")이 들어 있는지 확인합니다 |
| timeout 은 흉내다 | 5초 sleep 뒤 예외를 던질 뿐, 기다림 자체를 끊지 않습니다 | 실제 호출에서는 실거래가 서버의 timeout=10 처럼 클라이언트의 시간 제한 옵션을 씁니다 |
| 도구가 오래 붙잡는다 | timeout 모드에서 도구 한 번이 약 15초 동안 반환하지 않습니다 | 사용자가 기다릴 수 있는 시간에 맞춰 재시도 횟수와 전체 시간 상한을 정합니다 |
| 재시도가 겹칠 수 있다 | 도구 안에 재시도 3회가 있고, 모델이 같은 도구를 다시 부르는 것은 막지 않습니다 | 에러 문구가 "다시 시도"가 아니라 "백업 사용"을 지시하는 것이 이 겹침을 줄이는 장치입니다. 그래프의 반복 횟수 상한도 함께 둡니다 |
day4_practice 폴더에서 실행해야 failure_injection 모듈을 찾고, agent.log 도 그 폴더에 생깁니다. timeout 모드는 도구가 15초가량 기다리므로 전체 실행이 그만큼 길어집니다.1# failure_agent.py - 장애 주입 도구를 Agent에 연결해 복구 확인 2import json 3import os 4import failure_injection 5from dotenv import load_dotenv 6from langchain_aws import ChatBedrockConverse 7from langgraph.prebuilt import create_react_agent 8from failure_injection import get_asset_status, get_asset_status_backup, trace 9 10load_dotenv() 11 12 13def get_text(message): 14 """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다.""" 15 content = message.content 16 if isinstance(content, list): 17 return "".join(block.get("text", "") for block in content if isinstance(block, dict)) 18 return content 19 20 21llm = ChatBedrockConverse( 22 model="us.anthropic.claude-sonnet-4-6", 23 region_name="us-east-1", 24) 25agent = create_react_agent( 26 llm, 27 [get_asset_status, get_asset_status_backup], 28 prompt="자산 상태 질문에는 get_asset_status를 먼저 사용하고 도구 에러 메시지에 안내된 지침을 그대로 따르세요.", 29) 30 31 32def run(question: str) -> dict: 33 """agent.invoke 대신 stream으로 돌려, 그래프 노드가 끝날 때마다 무슨 일이 있었는지 출력합니다. 34 도구 함수 안의 출력(depth 2~3)은 tools 노드가 끝나기 전에 찍히므로 실행 순서대로 섞여 보입니다. 35 """ 36 trace(1, "[agent 노드] LLM 호출 중...") 37 result = None 38 for mode, chunk in agent.stream({"messages": [("user", question)]}, stream_mode=["updates", "values"]): 39 if mode == "values": 40 result = chunk # 마지막 values가 최종 State (invoke 결과와 같음) 41 continue 42 for node, update in chunk.items(): 43 for m in (update or {}).get("messages", []): 44 if node == "agent" and m.tool_calls: 45 for tc in m.tool_calls: 46 trace(1, f"[agent 노드] LLM -> 도구 요청 {tc['name']}({tc['args']})") 47 trace(1, "[tools 노드] 도구 실행") 48 elif node == "agent": 49 trace(1, f"[agent 노드] LLM -> 최종 답변 (도구 요청 없음, 종료): {get_text(m)[:40]!r}...") 50 else: 51 trace(1, f"[tools 노드] ToolMessage를 State에 추가 -> 다시 agent 노드로") 52 trace(1, "[agent 노드] LLM 호출 중...") 53 return result 54 55 56def print_trace(result: dict): 57 """2-3에서 만든 trace 출력 (실패 지점 분석용).""" 58 for i, m in enumerate(result["messages"]): 59 if m.type == "human": 60 print(f"{i} 사용자 {get_text(m)}") 61 elif m.type == "ai" and getattr(m, "tool_calls", None): 62 for tc in m.tool_calls: 63 print(f"{i} 도구요청 {tc['name']} args={json.dumps(tc['args'], ensure_ascii=False)}") 64 elif m.type == "tool": 65 print(f"{i} 도구결과 [{m.name}] {get_text(m)[:70]}") 66 elif m.type == "ai": 67 print(f"{i} 최종답변 {get_text(m)[:70]}") 68 69 70def tool_names(result: dict) -> list: 71 """trace에서 실제로 호출된 도구 이름만 뽑습니다.""" 72 return [tc["name"] for m in result["messages"] 73 if getattr(m, "tool_calls", None) for tc in m.tool_calls] 74 75 76def check_secret_log(path="agent.log") -> tuple: 77 """로그 파일에 자격 증명 원문이 남았는지 확인합니다.""" 78 if not os.path.exists(path): 79 return True, "agent.log 없음" 80 with open(path, encoding="utf-8") as f: 81 text = f.read() 82 hits = [p for p in ("sk-internal-dummy-1234", "AKIA") if p in text] 83 return (not hits), ("발견 0건" if not hits else f"발견: {hits}") 84 85 86if __name__ == "__main__": 87 question = "A-1001 자산의 현재 상태를 알려주세요." 88 89 # 1) 정상 호출: 장애 없이 한 번 돌려 봅니다 90 trace(0, "=== 1) 정상 실행 (FAILURE_MODE=none) ===") 91 failure_injection.FAILURE_MODE = "none" 92 normal = run(question) 93 normal_ok = ("get_asset_status" in tool_names(normal) 94 and "에러" not in get_text(normal["messages"][-1])) 95 96 # 2) 실패 호출: FAILURE_MODE로 고른 장애를 주입합니다 97 mode = os.environ.get("FAILURE_MODE", "timeout") 98 print() 99 trace(0, f"=== 2) 장애 주입 실행 (FAILURE_MODE={mode}) ===") 100 failure_injection.FAILURE_MODE = mode 101 broken = run(question) 102 print(f"\n=== 장애 주입 messages 요약 (FAILURE_MODE={mode}) ===") 103 print_trace(broken) 104 recovered = "get_asset_status_backup" in tool_names(broken) 105 answered = bool(get_text(broken["messages"][-1]).strip()) 106 107 # 3) 로그에 Secret이 남았는지 점검합니다 108 secret_ok, secret_msg = check_secret_log() 109 print() 110 trace(0, f"=== 3) check_secret_log('agent.log') -> {secret_msg} ===") 111 112 print("\n=== DoD 판정 ===") 113 print(f"정상 호출 처리 : {'통과' if normal_ok else '실패'}") 114 print(f"실패 호출 처리 : {'통과' if answered else '실패'} (그래프 중단 없이 답변 생성)") 115 print(f"필수 장애 1개 복구 : {'통과' if recovered else '실패'} (장애 {mode}, 백업 도구 호출 {'있음' if recovered else '없음'})") 116 print(f"로그 Secret 점검 : {'통과' if secret_ok else '실패'} ({secret_msg})") 117 print(f"최종 : {'DoD 충족' if all([normal_ok, answered, recovered, secret_ok]) else 'DoD 미충족'}")
도구를 고치고 프롬프트를 바꿀 때마다 "전에 되던 것이 여전히 되는가"를 확인할 고정된 질문 묶음이 필요합니다. 이 강의는 날마다 그날 배운 기능을 시험하는 문항을 eval_set.json 에 보태 갑니다. 4일차 폴더의 파일은 2일차부터 쌓인 10문항입니다.
type | 문항 수 | 질문 | expected_source | 확인하려는 것 |
|---|---|---|---|---|
| fact | 2 | "연차 이월은 며칠까지 가능한가요?", "해외 출장 식비 한도는 하루 얼마인가요?" | 문서 경로 (docs/leave_policy.md, docs/travel_policy.md) | 문서의 표현 그대로 물었을 때 맞는 문서를 찾는가 |
| paraphrase | 2 | "월차 얼마나 받을 수 있어?", "집에서 일하려면 어떻게 신청해?" | 문서 경로 (docs/leave_policy.md, docs/remote_work_policy.md) | 문서와 다른 말(월차, 집에서 일하기)로 물어도 찾는가 |
| no_answer | 1 | "육아휴직은 얼마나 쓸 수 있나요?" | null | 근거 문서가 없을 때 지어내지 않고 모른다고 하는가 |
| single_tool | 2 | "김하늘 님은 어느 팀 소속인가요?", "A-1001 자산의 현재 상태를 알려주세요." | 도구 이름 하나 (search_employee, get_asset_status) | 맞는 도구 하나를 고르는가 |
| multi_tool | 2 | "'프린터가 고장 났어요' 문의 부서를 분류하고 48건이 12% 늘면 몇 건인지 계산해줘.", "이서준 님이 소속된 팀에서 진행 중인 프로젝트를 알려주세요." | 도구 이름 여럿 (classify_request, calculate / search_employee,list_projects) | 도구 여러 개를 빠짐없이 쓰는가 |
| no_tool | 1 | "어떤 일을 도와줄 수 있어?" | 문자열 "none" | 도구가 필요 없는 질문에 도구를 부르지 않는가 |
앞의 다섯 문항은 문서를 찾아 답하는 2일차 RAG용이고, 레포 루트의 eval_set.json 에 같은 다섯 문항이 들어 있습니다. classify_request 와 calculate 는 3일차 ReAct 에이전트의 도구 이름입니다. 마지막 두 문항이 오늘 것입니다. "이서준 님이 소속된 팀의 프로젝트"는 mcp_agent.py 의 셋째 질문과 같고, "A-1001 자산의 현재 상태"는 failure_agent.py 의 질문과 같습니다.
모델의 답변 문장은 실행할 때마다 달라져서 문자열 비교로 채점하기 어렵습니다. 반면 "어느 문서를 근거로 삼았는가", "어느 도구를 불렀는가"는 실행 기록에서 뽑아 기대값과 비교할 수 있습니다. 앞 절의 tool_names() 가 하던 일이 바로 그 추출입니다. 도구를 부르지 않아야 정답인 문항과 근거가 없어야 정답인 문항을 따로 둔 것도 눈여겨볼 점입니다. "해야 할 때 하는가"만큼 "하지 말아야 할 때 하지 않는가"가 Agent 평가에서 중요합니다.
expected_source 한 칸에 문서 경로, null, 문자열 "none", 쉼표로 이은 도구 이름이 들어갑니다. 쉼표 뒤에 공백이 있는 문항("classify_request, calculate")과 없는 문항("search_employee,list_projects")도 있습니다. 채점 코드는 type 을 보고 이 칸을 다르게 해석해야 하고, 도구 이름을 나눌 때 공백을 정리해야 합니다. 또 "A-1001" 문항의 기대값 get_asset_status 는 백엔드가 정상일 때의 경로입니다. 장애 모드에서는 백업 도구까지 불리는 것이 올바른 동작이므로, 이 문항은 실행 환경에 따라 기록이 달라집니다.
4일차 폴더에는 이 파일을 읽어 채점하는 스크립트가 없습니다. 문항만 준비해 둔 것이고, 이후 폴더의 같은 파일은 5일차 10문항, 6일차 12문항(guardrail 유형 추가), 7일차 14문항(multi_agent 유형 추가)으로 늘어납니다. 전체를 자동으로 돌려 채점하는 일은 7일차에서 합니다.
1[ 2 {"question": "연차 이월은 며칠까지 가능한가요?", "expected_source": "docs/leave_policy.md", "type": "fact"}, 3 {"question": "월차 얼마나 받을 수 있어?", "expected_source": "docs/leave_policy.md", "type": "paraphrase"}, 4 {"question": "집에서 일하려면 어떻게 신청해?", "expected_source": "docs/remote_work_policy.md", "type": "paraphrase"}, 5 {"question": "해외 출장 식비 한도는 하루 얼마인가요?", "expected_source": "docs/travel_policy.md", "type": "fact"}, 6 {"question": "육아휴직은 얼마나 쓸 수 있나요?", "expected_source": null, "type": "no_answer"}, 7 {"question": "김하늘 님은 어느 팀 소속인가요?", "expected_source": "search_employee", "type": "single_tool"}, 8 {"question": "'프린터가 고장 났어요' 문의 부서를 분류하고 48건이 12% 늘면 몇 건인지 계산해줘.", "expected_source": "classify_request, calculate", "type": "multi_tool"}, 9 {"question": "어떤 일을 도와줄 수 있어?", "expected_source": "none", "type": "no_tool"}, 10 {"question": "이서준 님이 소속된 팀에서 진행 중인 프로젝트를 알려주세요.", "expected_source": "search_employee,list_projects", "type": "multi_tool"}, 11 {"question": "A-1001 자산의 현재 상태를 알려주세요.", "expected_source": "get_asset_status", "type": "single_tool"} 12]
모델은 도구의 설명서와 도구가 돌려준 글만 보고 움직이므로, 도구를 어디에 두든(프로세스 안의 함수든 밖의 MCP 서버든) 설명과 에러 메시지를 "모델에게 주는 지시문"으로 쓰는 것이 Agent의 동작을 결정합니다.
| 오늘 만든 것 | 남은 숙제 | 이어지는 날 |
|---|---|---|
| 질문 하나에 답하고 끝나는 Agent | 프로그램을 껐다 켜도 대화를 이어 가고, 사용자별 기억을 남기려면 | 5일차 체크포인터·메모리 |
| 모델이 고르면 곧바로 실행되는 도구 | 되돌릴 수 없는 작업 전에 사람의 승인을 받고, 위험한 입력과 출력을 걸러 내려면 | 5일차 사람 승인·가드레일 |
사내 데이터 MCP 서버 sds-company-data | 도구가 늘어 한 Agent의 설명서가 감당하기 어려워지면 | 6일차 멀티 에이전트 (이 서버가 데이터 담당 에이전트의 도구가 됩니다) |
print 로 찍은 추적과 10문항 평가 세트 | 실행 기록을 자동으로 남기고, 문항 전체를 돌려 채점하려면 | 7일차 추적·누적 평가 |
달라집니다. docstring은 도구의 description 이 되어 모델을 부를 때마다 입력으로 전달됩니다. 모델은 이 글을 보고 도구를 쓸지, 어떤 인자를 넣을지 정하므로 설명을 바꾸는 것은 프롬프트를 바꾸는 것과 같습니다. tools_basic.py 의 맨 아래 출력이 모델에게 전달되는 내용입니다.
raise 대신 문자열을 return 하는 것과 handle_tool_errors=True 는 어떻게 다른가요?둘 다 실패를 ToolMessage 로 모델에게 전달해 그래프가 멈추지 않게 합니다. 직접 return 하면 "무엇을 고쳐 다시 부르라", "백업 도구를 쓰라"처럼 다음 행동을 구체적으로 쓸 수 있습니다. handle_tool_errors=True 는 도구가 던진 예외를 ToolNode 가 정해진 문구로 바꾸는 것이라 내용은 일반적이지만, 도구 작성자가 예상하지 못한 예외까지 잡습니다. 예상되는 실패는 앞의 방식으로, 나머지는 뒤의 방식으로 겹쳐 대비합니다.
앞의 질문은 두 조회가 서로 독립이라 모델이 한 응답에 도구 요청 두 건을 담을 수 있고, ToolNode 가 한 번에 처리합니다. 뒤의 질문은 온도를 알아야 나눗셈의 인자를 정할 수 있으므로 요청을 한 턴에 하나씩 낼 수밖에 없습니다. 도구 사이에 순차 의존이 있으면 병렬 호출이 불가능합니다.
search_employee 가 MCP 서버의 도구인지 @tool 로 만든 함수인지 알 수 있나요?알 수 없고 알 필요도 없습니다. 모델에게 전달되는 것은 어느 쪽이든 이름, 설명, 입력 스키마입니다. mcp_agent.py 에서 mcp_tools + [calculator] 로 한 리스트에 합친 뒤에는 코드에서도 구분이 사라집니다. 차이는 요청을 실행하는 자리(같은 프로세스의 함수 호출인가, 다른 프로세스로 보내는 메시지인가)에만 있습니다.
mcp_verify.py 로 먼저 확인하는 이유는?모델이 끼면 결과가 실행마다 달라지고, 답이 틀렸을 때 서버 문제인지 모델의 도구 선택 문제인지 가리기 어렵습니다. 클라이언트로 직접 부르면 결과가 매번 같고 비용이 들지 않으며, 실패의 원인이 서버 쪽으로 좁혀집니다. 없는 직원을 조회하는 실패 경로까지 이 단계에서 확인합니다.
get_asset_status 는 왜 timeout에는 세 번 재시도하고 bad_schema에는 한 번도 재시도하지 않나요?재시도는 같은 요청을 다시 보냈을 때 결과가 달라질 가능성이 있을 때만 의미가 있습니다. 응답 지연은 일시적일 수 있어 다시 부르고, 호출 한도 초과는 시간이 지나야 풀리므로 간격을 늘려 가며 다시 부릅니다. 응답 형식이 약속과 다른 것은 몇 번을 불러도 같은 답이 올 것이므로 시간을 쓰지 않고 바로 백업 경로를 안내합니다.
이 페이지에 실린 코드 파일 전체 목록입니다. 파일 이름을 누르면 본문에서 그 코드가 있는 자리로 이동합니다.
| 파일 | 무엇을 하는 코드인가 | 분량 |
|---|---|---|
tools_basic.py | @tool 데코레이터를 세 단계 깊이로 쓰고, 모델에게 전달되는 메타데이터를 출력 | 63줄 |
tool_error.py | 입력 오류, 결과 없음, 예상 밖 예외를 모두 안내 메시지로 반환하는 도구 | 44줄 |
toolnode_parallel.py | ToolNode로 ReAct 그래프를 만들고 병렬 도구 호출과 handle_tool_errors 동작을 확인 | 83줄 |
trace.py | 그래프 실행 결과의 messages 를 순회하며 사용자·도구요청·도구결과·최종답변을 표로 출력 | 33줄 |
make_data.py | 더미 백데이터 3종(employees, assets, projects)을 JSON 파일로 생성 | 59줄 |
employees.json | 임직원 5명 (id, name, team, role, email) | 37줄 |
assets.json | 자산 8건 (노트북 5, 모니터 2, GPU 서버 1) | 58줄 |
projects.json | 진행 중인 프로젝트 4건 (팀별 2건) | 30줄 |
mcp_server.py | 사내 더미 데이터를 조회하는 도구 4개를 노출하는 FastMCP 서버 (stdio) | 86줄 |
mcp_verify.py | 파이썬 MCP 클라이언트로 서버에 직접 연결해 도구 목록과 호출 결과를 검증 | 47줄 |
mcp_agent.py | MCP 서버의 도구 4개와 일반 도구 calculator 를 합쳐 ReAct Agent로 실행 | 103줄 |
mcp_agent_external.py | 직접 만든 서버와 공개 MCP 서버(time)를 한 Agent에 함께 연결 (심화 과제) | 67줄 |
apt_trade_mcp.py | 국토교통부 아파트 매매 실거래가 API를 감싼 MCP 서버 (도구 apt_trades 하나) | 66줄 |
failure_injection.py | FAILURE_MODE 로 timeout·rate_limit·bad_schema 장애를 주입하는 자산 조회 도구와 백업 도구 (강의 제공 코드) | 112줄 |
failure_agent.py | 장애 주입 도구를 Agent에 연결해 정상·장애 실행을 비교하고 DoD 네 항목을 판정 | 117줄 |
eval_set.json | 2~4일차 누적 평가 세트 10문항 (질문, 기대 출처, 유형) | 12줄 |
@tool 에 Pydantic 모델을 넘겨 입력 스키마를 직접 지정하는 인자. 길이 제한 같은 검증 규칙과 인자별 설명을 넣을 수 있습니다.AIMessage 의 tool_calls 를 실행하고 결과를 ToolMessage 로 만들어 State에 추가합니다.ToolNode 는 한 번의 노드 실행에서 이들을 함께 처리합니다.ToolNode 가 도구의 예외를 어떻게 다룰지 정하는 설정. True 면 예외를 에러 내용이 담긴 ToolMessage 로 바꿔 그래프가 중단되지 않게 합니다.messages 를 순회해 사용자, 도구요청, 도구결과, 최종답변으로 찍었습니다.mcp_server.py, apt_trade_mcp.py 가 서버입니다.@mcp.tool() 을 붙이면 도구로 등록됩니다.langchain-mcp-adapters 의 클라이언트. 여러 MCP 서버의 도구를 받아 LangChain 도구 객체 리스트로 변환해 줍니다.