AX에이전트 실습 노트
DAY 4

도구를 쥐여 주기: Tool과 MCP

모델이 무엇을 할 수 있는지는 결국 도구의 설명서가 정합니다. 설명서를 쓰는 법, 실패를 다루는 법, 그리고 도구를 프로세스 밖의 서버(MCP)로 떼어 내는 법을 차례로 다룹니다.

@toolargs_schemaToolNode병렬 도구 호출handle_tool_errors실행 추적MCPFastMCPstdio장애 주입

이 날 배우는 것

3일차에 ToolNode로 ReAct 그래프를 조립했다면, 4일차는 그 그래프에 꽂히는 도구 자체를 다룹니다. 앞 절반에서는 도구를 정의하는 세 단계, 실패를 예외가 아닌 메시지로 돌려주는 원칙, ToolNode의 병렬 실행과 에러 처리, messages로 실행 과정을 추적하는 방법을 익힙니다. 뒤 절반에서는 도구를 별도 프로세스의 MCP 서버로 분리해 표준 프로토콜로 연결하고, 직접 만든 서버와 남이 만든 서버를 한 Agent에 함께 붙입니다. 마지막에는 백엔드 장애를 일부러 주입해 도구의 재시도와 Agent의 백업 경로 전환이 실제로 이어지는지 확인합니다.

  • 모델이 도구에 대해 실제로 받는 정보(이름, 설명, 입력 스키마)를 직접 출력해 확인하고, 세 단계 깊이로 도구를 정의할 수 있다
  • 도구의 실패를 예외 대신 '다음 행동을 안내하는 메시지'로 돌려주는 이유를 설명할 수 있다
  • ToolNode 가 한 턴의 도구 요청 여러 건을 함께 실행하는 것과, handle_tool_errors 가 예외를 ToolMessage 로 바꾸는 과정을 안다
  • messages 리스트만 보고 Agent가 어떤 도구를 어떤 인자로 불렀는지 재구성할 수 있다
  • MCP의 서버·클라이언트·전송 방식(stdio)을 구분하고, FastMCP로 서버를 만들어 클라이언트로 검증한 뒤 LangGraph Agent에 연결한다
  • 타임아웃, 호출 한도 초과, 응답 형식 오류를 주입했을 때 도구 코드와 모델이 각각 무엇을 책임지는지 설명할 수 있다

오늘의 지도: 함수 하나에서 MCP 서버까지

1일차 끝에서는 while 루프로, 3일차에서는 ToolNode 로 도구를 돌렸습니다. 그때 도구는 "이미 주어진 것"이었습니다. 오늘은 방향을 바꿔 도구 자체를 어떻게 만들고, 어떻게 고장에 대비하고, 어떻게 여러 프로그램이 나눠 쓰게 할 것인가를 다룹니다.

먼저 용어를 한 번 정리합니다. 도구 호출(tool calling)은 모델이 답 대신 "이 함수를 이 인자로 실행해 달라"는 요청(tool_calls)을 돌려주는 기능입니다. 모델은 함수를 실행하지 않습니다. 실행은 우리 프로그램이 하고, 결과를 ToolMessage 에 담아 다시 모델에게 보냅니다. 이 왕복을 모델이 "더 부를 도구가 없다"고 판단할 때까지 되풀이하는 프로그램을 이 강의에서는 Agent라고 부릅니다.

하루를 통째로 도구에 쓰는 이유

모델이 도구에 대해 아는 것은 우리가 써 준 이름, 설명, 입력 스키마뿐이고, 도구를 부른 뒤에 아는 것은 도구가 돌려준 문자열뿐입니다. 따라서 "엉뚱한 도구를 고른다", "인자를 잘못 넣는다", "실패했는데 포기해 버린다" 같은 문제는 모델을 바꾸기 전에 이 두 가지 글부터 고쳐야 풀립니다. 오늘 실습의 대부분은 그 글을 어떻게 쓰느냐에 관한 것입니다.

1부. 도구를 잘 만든다

@tool 의 세 단계, 실패를 메시지로 돌려주기, ToolNode 의 병렬 실행과 에러 처리, 실행 추적.

2부. 도구를 밖으로 뺀다

도구를 별도 프로세스의 MCP 서버로 분리하고, 클라이언트로 검증한 뒤 Agent에 연결합니다. 남이 만든 서버도 같은 방식으로 붙입니다.

3부. 고장 나도 버틴다

백엔드에 타임아웃, 호출 한도 초과, 형식 오류를 일부러 주입하고, 재시도와 백업 도구로 답을 끝까지 만들어 내는지 판정합니다.

@tool 3단계tools_basic.py 에러 메시지tool_error.py ToolNodetoolnode_parallel.py 추적trace.py MCP 서버mcp_server.py 검증mcp_verify.py Agent 연결mcp_agent.py 장애 주입failure_agent.py
모델을 부르지 않고 돌릴 수 있는 파일

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 로 버전이 고정돼 있고, 이 페이지에서 라이브러리 동작을 설명할 때는 이 버전을 기준으로 합니다.

도구 설계 3단계: 모델이 읽는 것은 설명서뿐

파이썬 함수에 @tool 을 붙이면 함수는 도구 객체가 됩니다. 이 객체에서 모델에게 전달되는 것은 함수의 본문이 아니라 세 가지 메타데이터입니다. 같은 데코레이터를 세 단계 깊이로 써 보면서 그 세 가지를 얼마나 세밀하게 통제할 수 있는지 확인합니다.

모델에게 가는 것기본으로 어디서 오나모델이 이것으로 하는 판단
이름 (name)함수 이름어떤 도구를 부를지 지목할 때 쓰는 식별자
설명 (description)docstring이 도구를 언제 쓰고 언제 쓰지 않을지
입력 스키마 (args_schema)인자의 타입 힌트인자 이름, 타입, 필수 여부. 어떤 값을 채워 넣을지

단계 1: 타입 힌트와 docstring에 맡기기

tools_basic.py 6–11줄
 6# 단계 1: 가장 단순한 형태
 7# 이름은 함수 이름, 설명은 docstring, 스키마는 타입 힌트에서 자동 추론
 8@tool
 9def add(a: int, b: int) -> int:
10    """두 정수를 더한다."""
11    return a + b
가장 짧은 형태입니다. 이름, 설명, 스키마 세 가지가 모두 함수 정의에서 자동으로 만들어집니다.

타입 힌트 a: int, b: int 가 "정수 두 개가 필수"라는 스키마가 되고, docstring 한 줄이 설명이 됩니다. 타입 힌트를 빼먹으면 스키마에 타입 정보가 실리지 않으므로, 도구로 쓸 함수에는 타입 힌트를 반드시 적습니다.

단계 2: 이름과 설명을 따로 적기

tools_basic.py 14–18줄
14# 단계 2: 이름과 설명을 데코레이터 인자로 명시
15# 도구 이름은 calculator, 함수 이름은 _internal_calc로 분리 가능
16@tool("calculator", description="수학 계산을 정확하게 수행한다. LLM은 큰 숫자 계산에 약하므로 계산이 필요하면 이 도구를 쓴다.")
17def _internal_calc(expression: str) -> str:
18    return str(eval(expression, {"__builtins__": {}}, {}))
모델에게 보이는 이름은 calculator 이고, 파이썬 코드 안의 이름은 _internal_calc 입니다.

코드에서 쓰기 좋은 이름과 모델이 읽기 좋은 이름은 다를 수 있습니다. 데코레이터의 첫 인자로 이름을, description 으로 설명을 주면 함수 이름·docstring과 분리됩니다. 이 설명문에는 "무엇을 한다"에 더해 "LLM은 큰 숫자 계산에 약하므로 계산이 필요하면 이 도구를 쓴다"는 사용 시점이 적혀 있습니다. 설명은 기능 소개가 아니라 모델에게 주는 지시문이라는 점이 드러나는 대목입니다.

eval 은 교육용입니다

이 계산기는 eval(expression, {"__builtins__": {}}, {}) 로 수식을 실행합니다. 내장 함수를 비워 두었지만 이것만으로 안전한 격리가 되지는 않습니다. 모델이 만든 문자열, 더 거슬러 올라가면 사용자가 입력한 문자열이 코드로 실행되는 구조이기 때문입니다. 실제 서비스에서는 사칙연산만 해석하는 수식 파서를 쓰거나 허용할 문자를 제한합니다.

단계 3: Pydantic으로 입력 규칙까지 적기

타입 힌트로는 "문자열이다"까지만 말할 수 있습니다. "2자 이상 20자 이하", "부분 일치는 안 된다" 같은 규칙과 인자별 설명을 적으려면 Pydantic 모델을 만들어 args_schema 로 넘깁니다. Pydantic은 클래스에 적은 타입과 제약으로 데이터를 검증하는 라이브러리이고, 1일차의 구조화 출력에서 이미 쓴 것과 같은 것입니다.

tools_basic.py 21–43줄
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        "김하늘": "김하늘 / 클라우드운영팀 / 팀장 / haneul.kim@samsungsds.example.com",
41        "박도윤": "박도윤 / 물류플랫폼팀 / 백엔드 / doyun.park@samsungsds.example.com",
42    }
43    return fake_db.get(name, f"'{name}' 직원을 찾을 수 없습니다.")
스키마 클래스에는 인자의 규칙을, docstring에는 도구의 사용 조건을 적습니다.

이 docstring은 네 줄이 각각 다른 역할을 합니다. 도구 설명을 쓸 때 그대로 본떠 쓸 수 있는 구조입니다.

  1. 무엇을 하는 도구인가"임직원 디렉터리에서 이름으로 직원 정보를 조회한다." 첫 줄은 도구 목록을 훑을 때 가장 먼저 읽히는 요약입니다.
  2. 언제 쓰는가"소속 팀, 직무, 이메일이 필요할 때 사용한다." 사용자의 질문이 어떤 모양일 때 이 도구를 떠올려야 하는지 적습니다.
  3. 언제 쓰지 않는가"사내 규정이나 정책 질문에는 사용하지 않는다." 비슷해 보이는 다른 수단(여기서는 2일차의 문서 검색)과의 경계를 긋습니다. 도구가 늘어날수록 이 줄이 오선택을 줄입니다.
  4. 실패하면 무엇이 돌아오는가"없는 이름이면 '찾을 수 없습니다'를 돌려준다." 모델이 실패 결과를 보고 당황하지 않도록 미리 알려 둡니다.

실행해서 확인하기

tools_basic.py 46–63줄
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()
도구는 함수가 아니라 객체이므로 .invoke() 에 인자 dict를 넘겨 실행합니다. 아래쪽 반복문은 모델에게 전달되는 메타데이터를 그대로 출력합니다.

이 파일은 모델을 부르지 않으므로 그대로 실행해 볼 수 있습니다. 아래는 이 저장소의 가상환경에서 실행한 실제 출력입니다.

tools_basic.py 실행 결과
 18
 2375
 3김하늘 / 클라우드운영팀 / 팀장 / haneul.kim@samsungsds.example.com
 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 을 실제로 변환해 얻은 값으로 나란히 보여 줍니다.

새 탭으로day4_practice/tool_design_explained.html
PYday4_practice/tools_basic.py@tool 데코레이터를 세 단계 깊이로 쓰고, 모델에게 전달되는 메타데이터를 출력63줄
모델을 부르지 않는 파일입니다. 세 도구를 직접 .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        "김하늘": "김하늘 / 클라우드운영팀 / 팀장 / haneul.kim@samsungsds.example.com",
41        "박도윤": "박도윤 / 물류플랫폼팀 / 백엔드 / doyun.park@samsungsds.example.com",
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에서 도구를 "부르기로 결정한" 쪽은 모델입니다. 모델은 예외를 받을 수 없고 메시지만 읽습니다. 예외가 모델에게 닿지 못하고 프로그램 위쪽으로 올라가 버리면, 모델은 무엇이 잘못됐는지 알 기회를 얻지 못한 채 실행이 끝납니다.

raise 로 끝내기

처리하는 코드가 없으면 그래프 실행이 예외와 함께 중단됩니다. 사용자는 답 대신 오류를 받고, 모델은 인자를 고쳐 다시 시도할 기회가 없습니다.

return 으로 알리기

실패 내용이 ToolMessage 에 담겨 대화 기록에 들어갑니다. 모델은 그것을 도구 결과로 읽고 다음 행동(다른 인자로 재호출, 다른 도구 사용, 사용자에게 되묻기)을 정합니다.

tool_error.py 12–38줄
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__}). 잠시 후 다시 시도하세요."
네 갈래 모두 문자열을 return 합니다. 강조한 세 줄이 실패 경로입니다.
  1. 입력이 잘못됐다이름이 비었거나 한 글자면 "최소 2자 이상의 정확한 한국어 이름으로 다시 호출하세요"를 돌려줍니다. 무엇이 틀렸는지와 함께 어떻게 고쳐서 다시 부를지를 적었습니다.
  2. 정상직원을 찾으면 이름, 팀, 이메일을 한 줄로 돌려줍니다.
  3. 결과가 없다"찾을 수 없습니다"로 끝내지 않고 등록된 직원 이름을 함께 줍니다. 모델은 오타였는지 판단하거나, 사용자에게 "혹시 이 중 누구를 찾으시나요"라고 물을 수 있게 됩니다.
  4. 예상하지 못한 예외함수 전체를 try 로 감싸고, 무엇이 터지든 예외 종류의 이름만 담은 안내문으로 바꿉니다.

이 파일도 모델 없이 실행됩니다. 실제 출력은 다음과 같습니다.

tool_error.py 41–43줄
41if __name__ == "__main__":
42    print(search_employee.invoke({"name": "김"}))       # 너무 짧음
43    print(search_employee.invoke({"name": "홍길동"}))   # 없는 직원
tool_error.py 실행 결과
1에러: 이름이 너무 짧습니다. 최소 2자 이상의 정확한 한국어 이름으로 다시 호출하세요.
2'홍길동' 직원을 찾을 수 없습니다. 등록된 직원: 김하늘, 박도윤, 이서준
3김하늘 / 클라우드운영팀 / haneul.kim@samsungsds.example.com

좋은 에러 메시지의 조건

조건이 코드에서없으면 생기는 일
무엇이 잘못됐는지 말한다"이름이 너무 짧습니다"모델이 같은 인자로 다시 부릅니다
다음에 할 일을 말한다"...로 다시 호출하세요", "잠시 후 다시 시도하세요"모델이 그대로 포기하고 "조회할 수 없습니다"라고 답합니다
고르는 데 필요한 정보를 준다"등록된 직원: 김하늘, 박도윤, 이서준"모델이 이름을 지어내어 다시 시도합니다
에러임을 알아볼 수 있다"에러:" 로 시작모델이 실패 문구를 정상 데이터로 오해해 답변에 그대로 옮깁니다

단계 3의 검증과 무엇이 다른가

앞 절의 tools_basic.py 는 "2자 이상" 규칙을 args_schema 에 적었고, 이 파일은 같은 규칙을 함수 본문의 if 로 검사합니다. 둘은 실패가 드러나는 방식이 다릅니다.

args_schema 로 검증본문에서 검사 후 메시지 반환
규칙이 모델에게 미리 전달되나예. 스키마에 minLength 로 실립니다docstring에 적은 만큼만 전달됩니다 (여기서는 "최소 2자")
위반하면ValidationError 예외가 납니다. 이것을 메시지로 바꾸는 일은 도구를 실행하는 쪽(다음 절의 ToolNode)이 맡습니다도구가 직접 쓴 안내문이 반환됩니다
메시지의 내용검증 라이브러리가 만든 문구우리가 원하는 말투와 지시를 넣을 수 있습니다
메시지로 바꾼 실패는 눈에 띄지 않습니다

예외를 문자열로 바꾸면 프로그램은 조용히 계속 돌아갑니다. 모델에게는 좋은 일이지만 운영자에게는 장애가 숨는 일입니다. 4번 갈래처럼 예상하지 못한 예외를 삼킬 때는 로그를 따로 남겨야 합니다. 이 파일에는 로그가 없고, 뒤의 장애 주입 실습에서 logger 로 남기는 형태를 봅니다. 또 후보 목록을 통째로 돌려주는 방식은 데이터가 세 건뿐인 실습이라 가능한 것입니다. 실제 임직원 명부라면 비슷한 이름 몇 개로 줄여야 하고, 조회 권한 밖의 정보가 에러 메시지로 새지 않는지도 확인해야 합니다.

PYday4_practice/tool_error.py입력 오류, 결과 없음, 예상 밖 예외를 모두 안내 메시지로 반환하는 도구44줄
모델을 부르지 않는 파일입니다. 같은 도구를 세 가지 인자로 실행해 각 갈래의 반환값을 확인합니다. 1일차 tool_playground.py의 도구들이 실패할 때 문자열을 돌려주던 것과 같은 설계입니다.
 1# tool_error.py - 실패를 메시지로 돌려주고 Agent가 복구하게 만들기
 2from langchain_core.tools import tool
 3
 4# 교육용 더미 데이터입니다!
 5EMPLOYEES = [
 6    {"name": "김하늘", "team": "클라우드운영팀", "email": "haneul.kim@samsungsds.example.com"},
 7    {"name": "박도윤", "team": "물류플랫폼팀", "email": "doyun.park@samsungsds.example.com"},
 8    {"name": "이서준", "team": "클라우드운영팀", "email": "seojun.lee@samsungsds.example.com"},
 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: 병렬 호출과 에러 처리

ToolNode 는 LangGraph가 미리 만들어 둔 "도구 실행 노드"입니다. 직전 AIMessage 의 tool_calls 를 읽어 해당 도구를 실행하고, 결과를 ToolMessage 로 포장해 State에 추가합니다. 1일차 while 루프에서 손으로 쓰던 "주문서마다 함수 실행 → ToolMessage 만들기"가 이 노드 하나에 들어 있습니다. 여기서는 이 노드의 두 가지 성질, 한 턴에 여러 도구를 함께 실행하는 것과 도구가 예외를 던졌을 때의 처리를 확인합니다.

그래프 조립 (3일차 복습)

toolnode_parallel.py 40–65줄
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()
agent 노드와 tools 노드가 서로를 오가는 ReAct 구조입니다. 3일차에 만든 것과 같은 뼈대입니다.
START agent모델 호출 tool_calls 있음 toolsToolNode ToolMessage agent tool_calls 없음 END
코드역할
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")도구 결과가 나오면 무조건 모델에게 돌아갑니다. 이 엣지가 루프를 만듭니다.

이번에는 일부러 예외를 던지는 도구

toolnode_parallel.py 22–37줄
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]
앞 절과 반대로 이 두 도구는 실패하면 예외를 냅니다. get_temperature 는 직접 raise 하고, divide 는 b가 0이면 파이썬이 ZeroDivisionError 를 냅니다.

병렬 도구 호출

"서울과 부산의 온도"는 서로의 결과가 필요 없는 두 조회입니다. 모델은 이런 경우 한 번의 응답에 tool_calls 를 두 건 담아 보낼 수 있습니다. 이것을 병렬 도구 호출(parallel tool calls)이라고 합니다. ToolNode 는 한 번의 노드 실행에서 그 두 건을 모두 처리하고 ToolMessage 두 개를 돌려줍니다.

AIMessagetool_calls 2건 ToolNode (tools 노드 실행 한 번) get_temperaturecity = 서울 get_temperaturecity = 부산 함께 실행 ToolMessage 2개tool_call_id 로 짝 맞춤
모델 호출은 두 번(요청 한 번, 최종 답변 한 번)으로 끝납니다. 도구를 하나씩 차례로 불렀다면 세 번이 필요했을 일입니다.
toolnode_parallel.py 68–78줄
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])}")
실행 뒤 messages 를 훑어 도구 요청과 도구 결과를 찍습니다. 요청 두 건이 같은 AIMessage 안에 들어 있는지가 관찰 포인트입니다.
출력의 모양 (예시: 모델이 두 요청을 한 턴에 낸 경우)
1=== 병렬 호출 ===
2  도구 호출 요청: get_temperature({'city': '서울'})
3  도구 호출 요청: get_temperature({'city': '부산'})
4  도구 결과: 서울: 3도
5  도구 결과: 부산: 8도
6최종 답변: ...
도구 결과 문자열은 코드에서 정해지는 값이고, 최종 답변의 문장은 실행할 때마다 달라집니다.

설치된 langgraph-prebuilt 의 ToolNode 소스를 보면, 동기 실행(invoke)에서는 요청들을 실행기(executor)의 map 으로 넘기고 비동기 실행에서는 asyncio.gather 로 모읍니다. 곧 한 턴의 요청들은 차례로 하나씩이 아니라 함께 실행되도록 짜여 있습니다.

병렬 호출은 모델의 결정이지 보장이 아닙니다

요청을 한 턴에 묶을지 여러 턴에 나눌지는 모델이 정합니다. 같은 질문이라도 한 건씩 차례로 부를 수 있고, 그래도 결과는 같습니다. 반대로 함께 실행될 수 있다는 사실은 도구 설계에 조건을 답니다. 한 턴에 같이 불릴 수 있는 도구들은 서로 독립이어야 합니다. 같은 파일을 고치는 도구 두 개, "생성 후 조회"처럼 순서가 중요한 도구 쌍이 한 턴에 묶이면 결과가 실행 순서에 따라 달라집니다.

예외가 났을 때: handle_tool_errors

toolnode_parallel.py 80–83줄
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])}")
divide 가 b=0 으로 호출되면 ZeroDivisionError 가 납니다. 도구 안에는 try 가 없습니다.

도구는 예외를 그대로 던지지만 그래프는 멈추지 않습니다. 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 의 소스에서 확인한 동작입니다. 기본값의 동작은 라이브러리 버전에 따라 달라질 수 있으므로, 의도한 동작을 코드에 명시적으로 적어 두는 편이 안전합니다.

도구가 직접 메시지를 return

앞 절의 방식입니다. "무엇을 고쳐 다시 부르라", "백업 도구를 쓰라"처럼 다음 행동을 우리말로 구체적으로 적을 수 있습니다. 예상할 수 있는 실패에 씁니다.

ToolNode 가 예외를 변환

이 절의 방식입니다. 도구 작성자가 놓친 예외까지 잡는 안전망입니다. 문구는 일반적이지만 그래프가 죽지 않습니다. 예상하지 못한 실패에 대비해 켜 둡니다.

둘은 대체 관계가 아니라 겹쳐 쓰는 관계입니다. get_temperature 가 던지는 ValueError 에는 "지원: ['서울', '부산', '제주']" 가 들어 있어, 변환된 메시지에서도 모델이 지원 도시를 알 수 있습니다. 예외를 던지더라도 예외 메시지를 모델이 읽을 글로 생각하고 쓰면 안전망의 품질이 올라갑니다.

응답에서 글자만 꺼내는 get_text

toolnode_parallel.py 14–19줄
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 까지 이어서 따라가 볼 수 있는 자료입니다.

플레이어

ToolNode 병렬 호출 플레이어

tool_calls 두 건이 ToolNode 에서 ToolMessage 두 개로 돌아오는 과정, divide 의 0 나누기가 handle_tool_errors 설정마다 어떻게 처리되는지, trace.py 가 messages 를 표로 찍는 방식을 코드와 messages 상태를 함께 넘기며 봅니다. 모델의 답은 예시이고, 도구 결과와 에러 문구는 같은 도구로 ToolNode 를 직접 돌려 얻은 값입니다.

새 탭으로day4_practice/toolnode_parallel_explained.html
PYday4_practice/toolnode_parallel.pyToolNode로 ReAct 그래프를 만들고 병렬 도구 호출과 handle_tool_errors 동작을 확인83줄
Bedrock을 호출하는 파일입니다. 질문 두 개를 차례로 던집니다. 첫 질문은 도구 두 개가 한 턴에 요청되는지, 둘째 질문은 0으로 나누는 예외가 메시지로 바뀌어 답변까지 이어지는지를 봅니다.
 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])}")

실행 추적: messages 가 곧 실행 기록

Agent가 틀린 답을 냈을 때 가장 먼저 할 일은 "어디서 틀어졌는가"를 찾는 것입니다. 따로 기록 장치를 달지 않아도, 그래프가 돌려준 State의 messages 리스트에 모델의 결정과 도구의 결과가 일어난 순서대로 들어 있습니다. 이것을 읽기 좋게 찍는 함수 하나가 가장 값싼 디버깅 도구입니다.

trace.py 15–28줄
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]}")
메시지의 type 과 tool_calls 유무로 네 가지 줄을 구분해 출력합니다.
조건표시이 줄이 말해 주는 것
m.type == "human"사용자질문 원문
"ai" 이고 tool_calls 가 있음도구요청모델이 고른 도구 이름과 인자. 요청이 여러 건이면 같은 순서 번호로 여러 줄이 찍힙니다
m.type == "tool"도구결과어느 도구(m.name)가 무엇을 돌려줬는지. 앞 60자만 보여 줍니다
"ai" 이고 tool_calls 가 없음최종답변모델이 도구 없이 쓴 글. 루프가 여기서 끝났습니다
trace.py 31–33줄
31if __name__ == "__main__":
32    result = graph.invoke({"messages": [HumanMessage(content="서울 온도를 100으로 나눈 값은?")]})
33    print_trace(result)
앞 절의 그래프를 import 해서 그대로 씁니다.

질문은 "서울 온도를 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번

추적을 읽는 순서

  1. 도구를 맞게 골랐나도구요청 줄의 이름을 봅니다. 엉뚱한 도구를 골랐거나 도구를 써야 할 질문에 바로 답했다면 도구 설명이나 시스템 프롬프트의 문제입니다.
  2. 인자를 맞게 넣었나이름은 맞는데 인자가 틀렸다면(팀 이름을 넣을 자리에 사람 이름) 인자 스키마와 인자 설명의 문제입니다.
  3. 도구가 무엇을 돌려줬나요청은 맞는데 결과가 에러나 빈 값이면 도구 구현이나 뒤쪽 시스템의 문제입니다. 모델을 탓할 일이 아닙니다.
  4. 결과를 답에 제대로 옮겼나도구결과는 맞는데 최종답변이 다르면 모델이 결과를 잘못 읽은 것입니다. 도구의 반환 형식이 읽기 어렵지 않은지(단위 누락, 너무 긴 JSON) 살핍니다.
이 추적 함수가 보여 주지 않는 것

도구 요청이 담긴 AIMessage 에 모델이 쓴 글이 함께 들어 있어도, 이 함수는 tool_calls 가 있으면 요청 줄만 찍고 글은 찍지 않습니다. 결과와 답변은 60자에서 잘립니다. 또 토큰 수와 소요 시간도 나오지 않습니다. 원인을 좁히는 데는 충분하지만 운영 기록으로는 부족하고, 실행 흐름을 파일에 남기고 모아서 채점하는 일은 7일차에서 다룹니다.

import 하면 그래프가 조립됩니다

from toolnode_parallel import graph 는 그 파일의 최상위 코드를 실행합니다. 도구 정의, 모델 객체 생성, 그래프 컴파일까지가 이때 일어나고, if __name__ == "__main__": 아래의 질문 실행은 일어나지 않습니다. 실습 파일마다 실행 코드를 이 조건문 안에 두는 까닭이 이것입니다. 모듈을 파일 이름만으로 찾으므로 trace.py 는 day4_practice 폴더에서 실행해야 합니다.

PYday4_practice/trace.py그래프 실행 결과의 messages 를 순회하며 사용자·도구요청·도구결과·최종답변을 표로 출력33줄
Bedrock을 호출하는 파일입니다. 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)

MCP: 도구를 프로세스 밖으로 떼어 내기

지금까지 만든 도구는 모두 Agent와 같은 파이썬 프로세스 안의 함수였습니다. 혼자 쓰는 동안은 문제가 없습니다. 같은 사내 데이터 조회 기능을 다른 Agent, 다른 팀의 프로그램, 코딩 도우미 같은 완성된 앱에서도 쓰고 싶어지는 순간부터 이 구조가 걸림돌이 됩니다.

함수로 묶여 있을 때 생기는 일

도구를 쓰려는 프로그램마다 함수 코드를 복사해 넣어야 합니다. 그 프로그램이 파이썬이 아니거나 LangChain을 쓰지 않으면 도구 정의부터 다시 써야 합니다. 도구를 고치면 그 도구를 품고 있는 모든 Agent를 다시 배포해야 하고, 데이터에 접근하는 권한과 비밀값이 Agent마다 흩어집니다. 도구 N개와 응용 프로그램 M개를 잇는 연결 코드가 조합마다 필요해집니다.

MCP(Model Context Protocol)는 이 연결을 표준화한 개방형 프로토콜입니다. Anthropic이 2024년에 공개했고, LLM 응용 프로그램과 외부 도구·데이터 사이에서 "어떤 도구가 있는가", "이 도구를 이 인자로 실행하라", "결과는 이것이다"를 주고받는 메시지 형식(JSON-RPC 2.0 기반)과 절차를 정해 둔 것입니다. 도구를 가진 쪽은 이 규약대로 말하는 서버를 한 번 만들고, 도구를 쓰려는 쪽은 규약대로 말하는 클라이언트를 한 번 붙이면, 서로 누가 만들었는지 몰라도 연결됩니다.

MCP 서버

도구를 가지고 있는 프로그램입니다. 도구 목록을 알려 주고, 호출 요청을 받아 실제 함수를 실행하고, 결과를 돌려줍니다. 오늘 만드는 mcp_server.py 가 서버입니다.

MCP 클라이언트

서버에 연결해 목록을 묻고 호출을 요청하는 쪽입니다. Agent 프로그램 안에 들어 있습니다. mcp_verify.py 의 ClientSession, mcp_agent.py 의 MultiServerMCPClient 가 클라이언트입니다.

전송 방식 (transport)

둘이 메시지를 실어 나르는 통로입니다. 오늘 쓰는 stdio는 클라이언트가 서버를 자식 프로세스로 실행하고, 그 프로세스의 표준 입력과 표준 출력으로 메시지를 주고받는 방식입니다.

Agent 프로세스 (mcp_agent.py) LangGraph Agent모델 + ToolNode MCP 클라이언트MultiServerMCPClient 일반 도구@tool calculator 서버 프로세스 (자식 프로세스) MCP 서버mcp_server.py · 도구 4개 데이터employees · assets · projects 요청 (stdin) 응답 (stdout) 함수 호출 파일 읽기
모델은 여전히 "도구를 불러 달라"고 결정할 뿐입니다. 달라지는 것은 그 요청을 실행하는 자리입니다. 일반 도구는 같은 프로세스의 함수로, MCP 도구는 프로세스 경계를 넘는 메시지로 실행됩니다.

연결 한 번에 일어나는 일

  1. 클라이언트가 서버 프로세스를 띄운다stdio 방식에서는 "어떤 명령으로 서버를 실행하는가"가 곧 접속 주소입니다. 코드에서는 command(파이썬 실행 파일)와 args(서버 스크립트 경로)로 적습니다.
  2. 초기 핸드셰이크양쪽이 프로토콜 버전과 지원 기능을 교환합니다. mcp_verify.py 의 session.initialize() 가 이 단계입니다.
  3. 도구 목록 요청클라이언트가 묻고 서버가 도구별 이름, 설명, 입력 스키마를 돌려줍니다(session.list_tools()). 앞에서 @tool 이 만들던 세 가지 메타데이터와 같은 것입니다.
  4. 도구 호출모델이 도구를 고르면 클라이언트가 이름과 인자를 보내고(session.call_tool()), 서버가 함수를 실행해 결과를 돌려줍니다.

@tool 과 MCP 도구 비교

@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를 모릅니다

MCP는 우리 프로그램과 도구 사이의 약속이지 모델과의 약속이 아닙니다. 모델이 받는 것은 어느 쪽이든 도구의 이름, 설명, 스키마이고, 돌려받는 것은 ToolMessage 입니다. 그래서 앞의 세 절에서 배운 설명서 쓰기와 에러 메시지 원칙은 MCP 서버를 만들 때도 그대로 적용됩니다.

이어지는 네 절(서버, 검증, Agent 연결, 외부 서버)에서 오가는 메시지를 한 장의 시퀀스로 미리 훑어보거나, 절을 다 읽은 뒤 복습용으로 넘겨 볼 수 있는 자료입니다.

플레이어

MCP 흐름 플레이어

Agent, MCP 클라이언트, stdio, MCP 서버, 데이터 파일 사이의 메시지 왕복을 시퀀스 그림과 단계별 플레이어로 따라갑니다. mcp_server.py 의 도구 등록부터 mcp_verify.py 의 initialize, tools/list, tools/call, mcp_agent.py 의 도구 변환과 호출마다 새 세션, 외부 서버와 공공 API 서버까지 다룹니다. JSON-RPC 메시지는 모양만 보인 예시입니다.

새 탭으로day4_practice/mcp_flow_explained.html

MCP 서버 만들기: 사내 데이터 서버

임직원, 자산, 프로젝트 세 가지 더미 데이터를 조회하는 MCP 서버를 만듭니다. 이 서버는 오늘로 끝나지 않습니다. 5~7일차 폴더에 주석 한 군데만 다른 mcp_server.py 와 같은 JSON 세 개가 그대로 들어 있고, 5일차의 종합 실습과 6일차 멀티 에이전트의 데이터 담당 에이전트가 이 서버를 도구로 씁니다.

백데이터 준비

서버가 읽을 데이터는 make_data.py 가 JSON 파일 세 개로 만들어 둡니다. 데이터베이스 대신 파일을 쓰는 것은 실습을 단순하게 하기 위해서이고, 서버 입장에서는 뒤에 무엇이 있든 도구의 모양은 같습니다.

make_data.py 21–32줄
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]
자산 8건. 주석은 뒤의 날들이 이 건수와 구성에 기대고 있으니 바꾸지 말라고 경고합니다.
파일건수한 건의 필드눈여겨볼 점
employees.json5id, name, team, role, email팀은 둘뿐입니다. 클라우드운영팀 3명, 물류플랫폼팀 2명
assets.json8asset_id, type, model, owner, status노트북 5, 모니터 2, GPU 서버 1. A-1004 는 사용자가 없는 재고라 owner 가 null 입니다
projects.json4project_id, name, team, status, member_count팀마다 2건씩, 모두 "진행중"
make_data.py 50–59줄
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:,}원")
파일 세 개를 쓰고, 뒤 실습의 정답(노트북 5대 × 200만원)을 미리 계산해 보여 줍니다.
make_data.py 가 찍는 내용 (예시: 코드의 데이터에서 정해지는 값)
1생성: employees.json (5건)
2생성: assets.json (8건)
3생성: projects.json (4건)
4확인: 자산 8건 중 노트북 5대, 한 대당 200만원이면 총액 10,000,000원

서버의 뼈대

mcp_server.py 11–21줄
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 객체가 서버입니다. 인자는 클라이언트에게 알려 줄 서버 이름입니다.

FastMCP는 MCP 서버를 짧게 만들게 해 주는 파이썬 라이브러리입니다. 프로토콜 메시지를 읽고 쓰는 일, 도구 목록 요청에 답하는 일을 대신해 주므로 우리는 도구 함수만 쓰면 됩니다. 데이터 파일의 경로를 __file__ 기준의 절대 경로로 만드는 것에 주목하세요. 이 서버는 사람이 터미널에서 실행하는 프로그램이 아니라 클라이언트가 띄우는 자식 프로세스입니다. 어느 폴더를 작업 디렉터리로 해서 실행될지 서버는 알 수 없으므로 상대 경로에 기대지 않습니다.

도구 등록

mcp_server.py 24–39줄
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}"
@mcp.tool() 을 붙이면 이 함수가 서버의 도구 목록에 올라갑니다.

@tool 과 원리가 같습니다. 함수 이름이 도구 이름, docstring이 설명, 타입 힌트가 입력 스키마가 됩니다. 달라진 것은 docstring의 내용입니다. 도구가 넷으로 늘었기 때문에 "이 질문에는 내가 아니라 저 도구를 쓰라"는 안내가 들어갔습니다. "팀 전체 명단이나 인원수에는 사용하지 않는다 (list_team_members 사용)". 이름이 비슷한 도구들 사이의 경계는 각 도구의 설명에 서로를 가리키며 적어 두는 것이 가장 확실합니다. 없는 직원을 물으면 등록된 이름을 함께 돌려주는 것은 앞에서 본 원칙 그대로입니다.

도구인자정상일 때 반환결과가 없을 때
search_employeename (필수)직원 한 명의 JSON"찾을 수 없습니다" + 등록된 직원 이름 전체
list_team_membersteam (필수){"count": 인원수, "members": [...]}"찾을 수 없습니다" + 등록된 팀 이름
get_assetowner (기본값 빈 문자열){"count": 건수, "assets": [...]}"'...' 사용자의 자산이 없습니다."
list_projectsteam (기본값 빈 문자열)프로젝트 목록 JSON"'...' 팀의 프로젝트가 없습니다."
mcp_server.py 59–69줄
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 인자를 추가하는 것이 맞는 개선입니다.

실행: stdio 로 대기

mcp_server.py 85–86줄
85if __name__ == "__main__":
86    mcp.run()   # stdio 모드로 실행
전송 방식을 지정하지 않으면 FastMCP는 stdio 로 실행합니다.

python mcp_server.py 를 터미널에서 직접 실행하면 프롬프트가 돌아오지 않고 멈춘 것처럼 보입니다. 고장이 아니라 표준 입력으로 프로토콜 메시지가 들어오기를 기다리는 상태입니다. 5일차 폴더에 있는 같은 파일의 주석은 이 점을 "단독으로 쓰는 파일이 아니라 클라이언트가 자식 프로세스로 띄워서 사용합니다"라고 적어 두었습니다. 서버가 제대로 동작하는지는 직접 실행해서가 아니라 다음 절처럼 클라이언트를 붙여서 확인합니다.

stdio 서버에서는 print 로 디버깅하지 않습니다

stdio 전송에서 서버의 표준 출력은 프로토콜 메시지가 지나가는 통로입니다. 도구 함수 안에서 print() 로 무언가를 찍으면 그 글자가 메시지 사이에 끼어들어 클라이언트가 해석에 실패할 수 있습니다. 확인하고 싶은 값은 표준 에러나 파일 로그로 내보냅니다.

PYday4_practice/make_data.py더미 백데이터 3종(employees, assets, projects)을 JSON 파일로 생성59줄
모델을 부르지 않는 파일입니다. 실행하면 같은 폴더의 JSON 세 개를 덮어씁니다. 주석대로 5, 6, 7일차가 같은 값을 전제로 하므로 내용은 바꾸지 않습니다.
 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": "haneul.kim@samsungsds.example.com"},
15    {"id": 2, "name": "박도윤", "team": "물류플랫폼팀", "role": "백엔드", "email": "doyun.park@samsungsds.example.com"},
16    {"id": 3, "name": "이서준", "team": "클라우드운영팀", "role": "인프라 엔지니어", "email": "seojun.lee@samsungsds.example.com"},
17    {"id": 4, "name": "최민준", "team": "물류플랫폼팀", "role": "프론트엔드", "email": "minjun.choi@samsungsds.example.com"},
18    {"id": 5, "name": "정하은", "team": "클라우드운영팀", "role": "데이터 분석가", "email": "haeun.jung@samsungsds.example.com"},
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:,}원")
JSONday4_practice/employees.json임직원 5명 (id, name, team, role, email)37줄
 1[
 2  {
 3    "id": 1,
 4    "name": "김하늘",
 5    "team": "클라우드운영팀",
 6    "role": "팀장",
 7    "email": "haneul.kim@samsungsds.example.com"
 8  },
 9  {
10    "id": 2,
11    "name": "박도윤",
12    "team": "물류플랫폼팀",
13    "role": "백엔드",
14    "email": "doyun.park@samsungsds.example.com"
15  },
16  {
17    "id": 3,
18    "name": "이서준",
19    "team": "클라우드운영팀",
20    "role": "인프라 엔지니어",
21    "email": "seojun.lee@samsungsds.example.com"
22  },
23  {
24    "id": 4,
25    "name": "최민준",
26    "team": "물류플랫폼팀",
27    "role": "프론트엔드",
28    "email": "minjun.choi@samsungsds.example.com"
29  },
30  {
31    "id": 5,
32    "name": "정하은",
33    "team": "클라우드운영팀",
34    "role": "데이터 분석가",
35    "email": "haeun.jung@samsungsds.example.com"
36  }
37]
JSONday4_practice/assets.json자산 8건 (노트북 5, 모니터 2, GPU 서버 1)58줄
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]
JSONday4_practice/projects.json진행 중인 프로젝트 4건 (팀별 2건)30줄
 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]
PYday4_practice/mcp_server.py사내 더미 데이터를 조회하는 도구 4개를 노출하는 FastMCP 서버 (stdio)86줄
서버 이름은 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에 붙이기 전에: 클라이언트로 서버 검증

서버를 만들자마자 Agent에 연결하면, 답이 틀렸을 때 원인이 서버인지 모델인지 가려내기 어렵습니다. 그래서 먼저 모델 없이 파이썬 MCP 클라이언트로 서버를 직접 불러 봅니다. 결과가 매번 같고, 비용이 들지 않고, 실패하면 원인은 서버 쪽 하나로 좁혀집니다.

mcp_verify.py 11–25줄
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]}")
서버를 띄울 방법을 적고 → stdio 로 연결하고 → 세션을 열어 핸드셰이크하고 → 도구 목록을 받습니다.
  1. 서버 실행 방법을 적는다StdioServerParameters(command=..., args=[...]). 서버를 미리 띄워 두는 것이 아니라 "이 명령으로 띄우라"고 알려 주는 것입니다. sys.executable 은 지금 이 스크립트를 돌리고 있는 파이썬의 경로입니다. 가상환경 안에서 실행했다면 서버도 같은 가상환경의 파이썬으로 뜨므로 fastmcp 가 설치된 환경이 보장됩니다.
  2. 연결한다stdio_client(params) 가 서버 프로세스를 띄우고, 읽기 통로와 쓰기 통로 한 쌍을 돌려줍니다.
  3. 세션을 연다ClientSession(read, write) 가 그 통로 위에서 MCP 메시지를 주고받는 객체입니다. initialize() 로 초기 핸드셰이크를 마친 뒤에 도구 목록 요청과 호출을 보냅니다.
  4. 목록과 호출을 검증한다list_tools() 로 도구가 넷 다 노출됐는지, call_tool(이름, 인자) 로 각 도구가 기대한 값을 돌려주는지 봅니다.
async 와 await 가 처음 나옵니다

MCP의 파이썬 클라이언트는 비동기 API입니다. async def 로 정의한 함수 안에서 await 를 붙여 호출하고, 맨 바깥에서 asyncio.run(main()) 으로 실행합니다. async with 는 연결을 열고 블록을 벗어날 때 정리까지 해 주는 구문입니다. 프로세스 사이의 입출력은 응답을 기다리는 시간이 대부분이라 비동기로 다루는 것이 자연스럽고, 다음 절에서 Agent를 astream 으로 돌리는 이유도 여기서 시작됩니다.

mcp_verify.py 27–43줄
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}")
정상 호출 네 건과 일부러 틀린 호출 한 건. 결과는 content 블록 리스트의 첫 번째 text 에 들어 있습니다.

이 파일은 로컬에서 서버 프로세스를 띄울 뿐 모델이나 외부 네트워크를 쓰지 않습니다. 아래는 이 저장소에서 실행한 실제 출력입니다. 긴 JSON은 뒤를 줄였습니다.

mcp_verify.py 실행 결과 (긴 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": "haneul.kim@samsungsds.example.com"}
 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에 붙인 뒤 첫 번째 오타에서 문제를 드러냅니다. 모델은 인자를 틀리게 넣는 일이 잦으므로, 서버 검증에서 "틀린 인자를 넣으면 무엇이 돌아오는가"는 정상 경로만큼 중요한 확인 항목입니다.

PYday4_practice/mcp_verify.py파이썬 MCP 클라이언트로 서버에 직접 연결해 도구 목록과 호출 결과를 검증47줄
모델을 부르지 않는 파일입니다. 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())

MCP 도구와 일반 도구를 한 Agent에

검증이 끝난 서버를 LangGraph Agent에 연결합니다. langchain-mcp-adapters 패키지가 MCP 서버의 도구 목록을 받아 LangChain 도구 객체로 변환해 줍니다. 변환된 뒤에는 @tool 로 만든 도구와 같은 리스트에 넣어 함께 씁니다.

mcp_agent.py 47–70줄
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)
클라이언트 설정 → 도구 변환 → 일반 도구와 합치기 → Agent 생성. 네 단계가 한 함수에 들어 있습니다.
  1. 서버 이름과 실행 방법을 dict로 적는다MultiServerMCPClient 는 이름 그대로 서버 여러 개를 한꺼번에 다루는 클라이언트입니다. 키가 서버 이름이고, 값에 command, args, transport 를 적습니다. 서버를 더 붙이려면 항목을 하나 더 추가합니다.
  2. 도구를 가져온다await client.get_tools() 가 각 서버에 연결해 도구 목록을 받아 LangChain 도구 리스트로 돌려줍니다. 서버의 docstring과 스키마가 그대로 도구의 설명과 args_schema 가 됩니다.
  3. 일반 도구와 합친다mcp_tools + [calculator]. 리스트 덧셈 한 번입니다. 코드의 주석대로 "여기부터는 MCP 여부 구분이 사라집니다."
  4. Agent를 만든다create_react_agent(llm, tools, prompt=...) 는 앞에서 손으로 조립한 agent ⇄ tools 그래프(모델에 도구 묶기, ToolNode, 조건부 엣지, 루프)를 만들어 돌려주는 함수입니다.
create_react_agent 는 자리를 옮기는 중입니다

이 저장소에 설치된 LangGraph 1.x의 소스에는 create_react_agent 에 "langchain.agents 의 create_agent 로 옮겼다"는 사용 중단 예고가 붙어 있습니다. 구조는 같고, 5일차의 실습 파일들은 from langchain.agents import create_agent 를 씁니다.

시스템 프롬프트: 도구 선택 규칙과 에러 대응

mcp_agent.py 22–34줄
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도구 결과에 근거해서만 답하세요."""
도구별 담당 범위를 한 번 더 적고, 에러 메시지가 오면 그 지침을 따르라고 지시합니다.

도구 설명에 이미 적은 경계를 시스템 프롬프트에 다시 요약해 두면 오선택이 줄어듭니다. 강조한 두 줄은 오늘 배운 것과 직접 이어집니다. "암산하지 말고 반드시 도구 사용"은 계산을 모델에게 맡기지 않겠다는 것이고, "에러 메시지가 오면 그 지침을 따라 수정해서 다시 시도하세요"는 도구가 메시지로 돌려준 실패를 모델이 실제로 따르게 만드는 나머지 절반입니다. 도구가 안내문을 잘 써도 모델이 그것을 지시로 받아들이지 않으면 복구는 일어나지 않습니다.

mcp_agent.py 37–44줄
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}). 올바른 수식으로 다시 호출하세요."
MCP 도구와 섞어 쓸 일반 도구. 수식이 잘못되면 예외 내용을 담은 안내문을 반환합니다.

질문 네 개: 단일 도구에서 복합 질문까지

mcp_agent.py 73–83줄
73    # 5) 단일 도구 질문과 복합 질문을 섞어 검증
74    queries = [
75        # 단일 MCP 도구
76        "박도윤 님의 이메일 주소를 알려주세요.",
77        # 팀 단위 조회 (list_team_members)
78        "클라우드운영팀 인원이 몇 명인지 알려주세요.",
79        # MCP 도구 두 개를 이어야 하는 질문
80        "이서준 님이 소속된 팀에서 진행 중인 프로젝트를 알려주세요.",
81        # MCP 조회 + 일반 계산 도구를 엮는 복합 질문
82        "노트북 자산이 총 몇 대인지 세고 한 대당 200만원이면 총액이 얼마인지 계산해 주세요.",
83    ]
도구 하나로 끝나는 질문부터 MCP 도구와 일반 도구를 엮어야 하는 질문까지 난도를 올립니다.

아래 표의 "기대하는 경로"와 "정답"은 코드 주석의 의도와 JSON 데이터에서 계산한 값입니다. 실제 실행에서 모델이 이 경로를 따랐는지는 화면에 찍히는 도구 호출 줄로 확인합니다.

질문기대하는 도구 경로데이터로 본 정답
박도윤 님의 이메일 주소search_employeedoyun.park@samsungsds.example.com
클라우드운영팀 인원list_team_members3명 (김하늘, 이서준, 정하은)
이서준 님이 소속된 팀의 프로젝트search_employee → list_projects클라우드운영팀의 "클라우드 비용 최적화", "사내 AI 어시스턴트 도입"
노트북 대수와 대당 200만원일 때 총액get_asset → calculator5대, 10,000,000원

셋째 질문은 추적 절에서 본 순차 의존의 예입니다. 팀 이름을 알아야 프로젝트를 조회할 수 있으므로 두 도구는 한 턴에 묶일 수 없습니다. 넷째 질문은 프로세스 밖의 MCP 도구와 프로세스 안의 일반 도구가 한 답변 안에서 이어지는 경우입니다.

실행 과정을 흘려 받기

mcp_agent.py 85–99줄
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]}")
노드 하나가 끝날 때마다 그 노드가 State에 추가한 내용을 받아 바로 찍습니다.

stream_mode="updates" 는 그래프의 노드가 하나 끝날 때마다 {노드 이름: 그 노드가 돌려준 변경분} 을 내보냅니다. 앞의 trace.py 가 실행이 다 끝난 뒤 messages 를 훑었다면, 이쪽은 같은 내용을 일어나는 대로 봅니다. 분기하는 조건(tool_calls 유무, type == "tool")은 print_trace 와 같습니다.

MCP 도구가 섞이면 동기 호출을 쓸 수 없습니다

설치된 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 전송에서 세션을 연다는 것은 서버 프로세스를 띄운다는 뜻이므로, 한 번의 호출에서 서버가 전역 변수에 담아 둔 값이 다음 호출 때 남아 있다고 가정하면 안 됩니다. 오늘의 서버는 조회만 하고 매번 파일에서 데이터를 읽으므로 문제가 없습니다. 호출 사이에 이어져야 하는 값은 파일이나 데이터베이스처럼 프로세스 밖에 둡니다.

PYday4_practice/mcp_agent.pyMCP 서버의 도구 4개와 일반 도구 calculator 를 합쳐 ReAct Agent로 실행103줄
Bedrock을 호출하는 파일입니다. 질문 네 개를 차례로 돌리며 도구 호출, 도구 결과, 답변을 노드가 끝날 때마다 출력합니다.
  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 서버 붙이기

MCP의 이득은 내 서버를 내가 쓸 때보다 남이 만든 서버를 가져다 쓸 때 분명해집니다. 서버가 어떤 언어로 어떻게 구현됐는지 몰라도, 실행 명령만 알면 설정 한 항목으로 Agent의 도구가 늘어납니다.

mcp_agent_external.py 23–31줄
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
ddgduckduckgo-mcp-server==0.6.1-m duckduckgo_mcp_server.server
fetchmcp-server-fetch==2026.10.10-m mcp_server_fetch

세 후보 모두 pip로 설치하는 파이썬 패키지이고, python -m 모듈이름 으로 실행되는 stdio 서버입니다. 그래서 command 는 우리 서버와 똑같이 sys.executable 이고 args 만 다릅니다.

mcp_agent_external.py 38–44줄
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]}")
서버 두 개를 한 클라이언트에. get_tools() 는 두 서버의 도구를 합친 리스트를 돌려줍니다.
mcp_agent_external.py 52–53줄
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 설정과 같은 정보입니다.

MCP 클라이언트 설정 파일의 일반적인 모양 (예시)
1{
2  "mcpServers": {
3    "sds-company-data": {
4      "command": "(가상환경의 파이썬 실행 파일 경로)",
5      "args": ["(mcp_server.py 의 절대 경로)"]
6    }
7  }
8}
Claude Code는 프로젝트 폴더의 .mcp.json 에서 이런 형태를 읽습니다. 경로는 PC마다 다르므로 이 저장소는 .mcp.json 을 .gitignore 에 올려 두었습니다.
외부 MCP 서버는 내 컴퓨터에서 도는 남의 프로그램입니다

stdio 서버를 붙인다는 것은 그 패키지의 코드를 내 권한으로 실행한다는 뜻입니다. 또 서버가 돌려주는 도구 설명과 도구 결과는 그대로 모델의 입력이 되므로, 웹 페이지를 가져오는 도구처럼 외부 글을 들여오는 서버는 모델에게 엉뚱한 지시를 주입하는 통로가 될 수 있습니다. 출처를 확인하고, 주석처럼 버전을 고정하고, 꼭 필요한 서버만 붙입니다. 도구 결과와 입력을 걸러 내는 가드레일과 위험한 도구 호출 전에 사람의 승인을 받는 절차는 5일차에서 다룹니다.

PYday4_practice/mcp_agent_external.py직접 만든 서버와 공개 MCP 서버(time)를 한 Agent에 함께 연결 (심화 과제)67줄
Bedrock을 호출하는 파일입니다. 실행 전에 주석의 pip 명령으로 고른 공개 서버 패키지를 설치해야 합니다. 다른 서버로 바꾸려면 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 서버로 감싸기

강의 제공 코드가 아니라 직접 만든 응용입니다. 국토교통부의 아파트 매매 실거래가 조회 API를 MCP 서버로 감쌌습니다. 더미 JSON 대신 진짜 외부 API가 뒤에 있을 때 도구 설계가 어떻게 달라지는지 볼 수 있는 예입니다.

apt_trade_mcp.py 12–18줄
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", "")
API 키는 코드에 없습니다. 레포 루트의 .env 에 적어 둔 DATA_GO_KR_API_KEY 를 환경 변수로 읽습니다.

1일차에 정한 원칙 그대로입니다. 비밀값은 .env 에 두고 load_dotenv() 로 읽습니다. 주석에 적힌 대로 load_dotenv() 는 이 파일의 위치에서 위쪽 폴더로 올라가며 .env 를 찾으므로, 서버가 어느 폴더에서 실행되든 레포 루트의 파일을 찾습니다. 키가 없으면 빈 문자열이 기본값입니다.

apt_trade_mcp.py 21–39줄
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    })
도구 하나. docstring에 쓰는 경우, 쓰지 않는 경우, 결과를 읽는 법, 인자의 형식과 예시가 모두 들어 있습니다.
설명서의 요소이 도구에서왜 필요한가
쓰지 않는 경우"전월세, 오피스텔, 연립다세대 거래에는 사용하지 않는다"이 API는 아파트 매매만 다룹니다. 적지 않으면 모델이 전세 시세를 물을 때도 이 도구를 부르고, 매매가를 전세가처럼 답합니다
결과를 읽는 법"금액 단위는 만원이고, cancelled가 true면 해제된 거래다"단위를 모르면 85,000을 8만 5천 원으로 읽을 수 있습니다. 숫자를 돌려주는 도구는 단위를 반드시 적습니다
인자의 형식과 예시"시군구 코드 5자리 (예: 서울 종로구 11110, 강남구 11680)", "YYYYMM (예: 202409)"사용자는 "강남구", "작년 9월"이라고 말합니다. 모델이 그것을 어떤 형식으로 바꿔 넣어야 하는지 알려 줍니다
상한"최대 30", 코드의 min(limit, 30)모델이 큰 값을 넣어도 응답이 무한정 길어지지 않게 코드에서 한 번 더 막습니다
apt_trade_mcp.py 40–58줄
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)
API를 부르고, XML에서 필요한 일곱 항목만 뽑아 JSON으로 다시 만듭니다.
  1. 시간 제한을 두고 부른다urlopen(..., timeout=10). 외부 API가 응답하지 않으면 10초 뒤에 예외가 나고 도구가 끝납니다. 제한이 없으면 Agent 전체가 그 도구에서 멈춰 섭니다.
  2. 필요한 것만 골라 이름을 바꾼다API는 XML로 답하고 항목 이름은 aptNm, umdNm, excluUseAr 같은 약어입니다. 도구는 이것을 apt, dong, area_m2 처럼 뜻이 드러나는 키로 바꾸고 일곱 항목만 남깁니다. 원본을 통째로 넘기면 토큰이 낭비되고 모델이 약어를 추측해야 합니다.
  3. 타입을 정리한다쉼표가 든 금액 문자열은 정수로(price_manwon), 면적은 실수로, 해제 여부는 cdealType 에 값이 있는지로 불리언을 만듭니다. 키 이름에 단위(_manwon, _m2)를 넣은 것도 모델을 위한 장치입니다.
  4. 실패와 빈 결과를 구분한다호출이나 해석이 실패하면 "에러: ... 잠시 후 다시 시도하세요", 호출은 됐는데 거래가 없으면 "region_code(5자리)와 year_month(YYYYMM)를 확인하세요"를 돌려줍니다. 앞의 것은 재시도를, 뒤의 것은 인자 수정을 안내합니다.
  5. 전체 건수를 함께 준다응답의 total 은 조건에 맞는 전체 거래 수이고 trades 는 그 가운데 최대 30건입니다. 모델이 "일부만 본 것"임을 알 수 있어야 평균이나 최고가를 물었을 때 표본의 한계를 밝힐 수 있습니다.
코드에서 보이는 한계 세 가지

원인이 메시지에 드러나지 않는 실패가 있습니다. except Exception 이 모든 실패를 같은 문구로 바꿉니다. API 키가 비었거나 잘못된 경우에도 코드는 요청을 그대로 보내고, 그 결과가 예외로 끝나면 "잠시 후 다시 시도", 항목 없는 응답으로 끝나면 "거래가 없습니다"가 됩니다. 어느 쪽이든 모델은 키 문제임을 알 수 없어 의미 없는 재시도나 인자 수정을 하게 됩니다. 서버 쪽 로그에라도 원인을 남겨야 사람이 찾을 수 있습니다.

30건 뒤는 볼 수 없습니다. 페이지를 넘기는 인자가 없어 한 번에 최대 30건이 전부입니다. 거래가 많은 지역의 통계를 물으면 표본이 한쪽으로 치우칠 수 있습니다.

test 모드는 설치된 버전에서 그대로 동작하지 않을 수 있습니다. 맨 아래의 print(apt_trades(...)) 는 도구를 일반 함수처럼 부릅니다. 이 저장소의 fastmcp 2.13.0 에서 최소 예제로 확인해 보면 @mcp.tool() 을 붙인 이름은 함수가 아니라 도구 객체(FunctionTool)이고, 직접 호출하면 TypeError 가 납니다. 원래 함수는 그 객체의 .fn 에 들어 있습니다. tools_basic.py 의 주석 "도구는 함수가 아니라 객체"가 여기에도 해당하는 셈이고, 서버를 확인하는 정석은 앞 절처럼 클라이언트를 붙이는 것입니다.

apt_trade_mcp.py 61–66줄
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()
인자에 test 가 있으면 직접 호출해 보고, 없으면 stdio 서버로 실행합니다.
키가 URL에 들어가는 API와 에러 메시지

이 API는 키를 쿼리 문자열(serviceKey)로 받습니다. 예외 내용이나 요청 URL을 에러 메시지에 그대로 넣으면 키가 모델의 입력과 대화 기록, 로그로 흘러갑니다. 이 코드의 except 가 예외 내용을 메시지에 넣지 않고 고정 문구만 돌려주는 것은 그 점에서는 안전한 선택입니다. 원인을 알리는 것과 비밀을 지키는 것 사이의 균형은 다음 절의 로그 마스킹에서 다시 나옵니다.

PYday4_practice/apt_trade_mcp.py국토교통부 아파트 매매 실거래가 API를 감싼 MCP 서버 (도구 apt_trades 하나)66줄
직접 만든 응용입니다. 서버 이름은 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 는 강의에서 제공한 코드로, 환경 변수 하나로 장애의 종류를 고릅니다.

_call_backend

가짜 자산 관리 백엔드. FAILURE_MODE 값에 따라 정상 응답을 주거나 장애를 재현합니다. 도구가 아니라 도구가 부르는 내부 함수입니다.

get_asset_status

모델이 부르는 실시간 조회 도구. 백엔드를 최대 세 번 시도하고, 끝내 실패하면 다음 행동을 안내하는 메시지를 돌려줍니다.

get_asset_status_backup

백업 조회 도구. 1시간 전 스냅샷 기준의 값을 항상 성공적으로 돌려줍니다.

고장 나는 백엔드

failure_injection.py 45–62줄
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": "김하늘"}
FAILURE_MODE 에 따라 세 가지 장애를 재현합니다. 어느 것에도 해당하지 않으면 정상 응답입니다.

도구의 대응: 다시 부르면 나아질 장애인가

failure_injection.py 65–104줄
 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 도구를 사용하세요.")
for 루프가 재시도입니다. 장애의 종류마다 except 갈래가 다르고, 어느 갈래든 마지막에는 문자열을 return 합니다.
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 가 그 계산입니다.

모델agent 노드 get_asset_status도구 안에서 최대 3회 시도 _call_backendFAILURE_MODE get_asset_status_backup1시간 전 스냅샷 최종 답변 ① 도구 요청 ② 에러 메시지 시도 1~3 실패 ③ 백업 요청 ④ 스냅샷 결과 ⑤ 답변 생성
위쪽 오른편의 왕복(시도와 실패)은 도구 함수 안의 for 루프에서 일어납니다. 모델은 그 과정을 보지 못하고 ②의 메시지 한 줄만 받습니다. ②에서 ③으로 넘어가는 것은 코드의 분기가 아니라 모델의 판단입니다.
도구는 백업을 직접 부르지 않습니다

일반적인 백엔드 코드라면 재시도가 모두 실패했을 때 함수 안에서 백업 조회를 바로 호출했을 것입니다. 이 도구는 그 대신 "get_asset_status_backup 도구를 사용하세요"라는 문장을 돌려줍니다. 경로를 바꾸는 결정을 모델에게 넘긴 것입니다. 이렇게 하면 모델이 "지금 보여 주는 값은 백업 기준"이라는 사실을 알고 답변에 밝힐 수 있고, 백업 도구의 반환값도 "백업 스냅샷 기준, 갱신 주기 1시간"이라고 스스로 출처를 적어 줍니다.

failure_injection.py 107–112줄
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시간)")
백업 도구. docstring의 'get_asset_status 실패 시 사용한다'도 모델이 읽는 설명이므로 언제 쓸지 판단하는 근거가 됩니다.

로그와 Secret 마스킹

failure_injection.py 13–23줄
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
로그는 화면이 아니라 agent.log 파일에만 남깁니다. API_KEY 는 '로그에 새면 안 되는 값'을 흉내 낸 더미입니다.

앞에서 실패를 메시지로 바꾸면 운영자 눈에서 장애가 사라진다고 했습니다. 이 파일은 그 짝을 갖췄습니다. 모델에게는 안내문을 돌려주면서, 같은 순간에 logger.warning 과 logger.error 로 무엇이 몇 번째 시도에서 실패했는지를 파일에 남깁니다. 그리고 백엔드를 부를 때 쓰는 키는 API_KEY[:6] 뒤에 *** 를 붙여 앞 여섯 글자만 기록합니다. 값의 일부를 가려 기록하는 것을 마스킹이라고 합니다.

아래는 이 저장소에 남아 있던 agent.log 의 일부입니다. rate_limit 모드로 실행했을 때의 실제 기록이고, Bedrock 호출을 알리는 라이브러리 로그 줄은 뺐습니다.

agent.log (rate_limit 모드 실행 기록에서 tools 로거의 줄만)
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" 라서 실행할 때마다 새로 쓰입니다.

PYday4_practice/failure_injection.pyFAILURE_MODE 로 timeout·rate_limit·bad_schema 장애를 주입하는 자산 조회 도구와 백업 도구 (강의 제공 코드)112줄
단독으로 실행하는 파일이 아니라 다음 절의 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시간)")

복구 확인: Agent가 백업 도구로 갈아타는가

도구가 "백업 도구를 사용하세요"라고 돌려줘도 모델이 그 말을 따르지 않으면 복구는 없습니다. failure_agent.py 는 고장 나는 도구를 Agent에 연결해 같은 질문을 정상 상태와 장애 상태에서 한 번씩 던지고, 네 가지 기준으로 통과 여부를 판정합니다.

failure_agent.py 21–29줄
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)
도구 두 개와 한 문장짜리 프롬프트. 강조한 문장이 도구의 안내문과 모델의 행동을 잇는 고리입니다.

1단 방어: 도구 코드의 재시도

for 루프와 except 로 짠 결정적인 동작입니다. 같은 장애에는 항상 같은 횟수, 같은 간격으로 재시도합니다. 모델은 이 과정을 모릅니다.

2단 방어: 모델의 경로 전환

에러 메시지를 읽고 백업 도구를 부르는 것은 모델의 판단이라 확률적입니다. 프롬프트의 "지침을 그대로 따르세요"가 그 가능성을 높일 뿐 보장하지는 않습니다. 그래서 실제로 일어났는지를 실행 기록으로 검사합니다.

실행 순서: 대조군과 실험군

failure_agent.py 86–105줄
 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())
같은 질문을 두 번 돌립니다. 한 번은 장애 없이, 한 번은 환경 변수로 고른 장애와 함께.
  1. 정상 실행장애 스위치를 "none" 으로 두고 "A-1001 자산의 현재 상태"를 묻습니다. 실시간 도구가 한 번에 성공하는 기준선입니다.
  2. 장애 주입 실행환경 변수 FAILURE_MODE 를 읽어(없으면 timeout) 스위치를 바꾸고 같은 질문을 다시 던집니다.
  3. messages 요약장애 실행의 기록을 앞에서 만든 추적 형식으로 찍습니다. 어느 도구가 무엇을 돌려줬는지 여기서 확인합니다.
  4. 로그 점검agent.log 에 비밀값 원문이 남았는지 검색합니다.
  5. 판정네 항목의 통과 여부를 찍고, 모두 통과하면 "DoD 충족"입니다.
PowerShell에서 장애 종류를 골라 실행 (day4_practice 폴더에서)
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 으로 따로 가져온 이유입니다.

두 겹의 출력을 함께 보기

failure_agent.py 32–53줄
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
updates 로는 노드별 진행을 찍고, values 로는 마지막 State 전체를 받아 둡니다.

stream_mode 에 리스트를 주면 (모드, 내용) 쌍이 흘러나옵니다. "updates" 는 노드가 끝날 때마다의 변경분이고 "values" 는 그 시점의 State 전체입니다. 마지막 values 가 invoke 의 반환값과 같으므로 이것을 result 로 돌려줘 뒤의 판정에 씁니다. 이 함수가 찍는 줄(깊이 1)과 도구 함수 안에서 trace() 가 찍는 줄(깊이 2~3)이 실행 순서대로 섞여, 누가 누구를 불렀는지가 들여쓰기로 드러납니다.

장애 주입 실행의 화면 출력 모양 (예시: timeout 모드, 모델이 안내를 따른 경우. 줄 앞의 경과 시간은 뺐습니다)
 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 -> 최종 답변 (도구 요청 없음, 종료): '...'...
가장 깊이 들어간 줄이 백엔드, 그 위가 도구, 그 위가 그래프 노드입니다. 최종 답변의 문장은 실행마다 달라집니다.

판정: 말이 아니라 기록으로 채점

failure_agent.py 70–83줄
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}")
판정에 쓰는 두 함수. 하나는 실제로 요청된 도구 이름을, 하나는 로그 파일에 남은 비밀값을 찾습니다.
failure_agent.py 112–117줄
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 충족'입니다.

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종 비교표, 이 설계의 약점까지 한 장에 모은 자료입니다. 강의 중 초기 버전 코드를 기준으로 만들어 현재 파일과 세부가 조금 다릅니다.

새 탭으로day4_practice/failure_agent_explained.html
설명 자료와 현재 코드가 다른 곳

자료는 Agent를 agent.invoke 로 돌리고 로그를 화면과 파일에 함께 남기며 agent.log 가 실행마다 쌓인다고 설명합니다. 현재 코드는 agent.stream 과 trace() 로 진행을 찍고, 로그는 파일에만 남기며, mode="w" 로 매번 새로 씁니다. 모델 ID 표기도 다릅니다. 재시도, 안내문, 판정의 구조는 같습니다.

이 설계의 한계

한계코드에서의 근거보완하는 방법
폴백이 확률적이다백업 도구 호출은 프롬프트와 에러 문구를 읽은 모델의 선택입니다반드시 백업으로 가야 한다면 그래프의 조건부 엣지로 경로를 코드에 고정하거나, 도구 안에서 백업을 직접 부릅니다
"정상 호출" 판정이 느슨하다답변에 "에러" 두 글자가 없는지만 봅니다답변에 기대하는 값("사용중", "김하늘")이 들어 있는지 확인합니다
timeout 은 흉내다5초 sleep 뒤 예외를 던질 뿐, 기다림 자체를 끊지 않습니다실제 호출에서는 실거래가 서버의 timeout=10 처럼 클라이언트의 시간 제한 옵션을 씁니다
도구가 오래 붙잡는다timeout 모드에서 도구 한 번이 약 15초 동안 반환하지 않습니다사용자가 기다릴 수 있는 시간에 맞춰 재시도 횟수와 전체 시간 상한을 정합니다
재시도가 겹칠 수 있다도구 안에 재시도 3회가 있고, 모델이 같은 도구를 다시 부르는 것은 막지 않습니다에러 문구가 "다시 시도"가 아니라 "백업 사용"을 지시하는 것이 이 겹침을 줄이는 장치입니다. 그래프의 반복 횟수 상한도 함께 둡니다
PYday4_practice/failure_agent.py장애 주입 도구를 Agent에 연결해 정상·장애 실행을 비교하고 DoD 네 항목을 판정117줄
Bedrock을 호출하는 파일입니다. 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 미충족'}")

누적 평가 세트: 10문항

도구를 고치고 프롬프트를 바꿀 때마다 "전에 되던 것이 여전히 되는가"를 확인할 고정된 질문 묶음이 필요합니다. 이 강의는 날마다 그날 배운 기능을 시험하는 문항을 eval_set.json 에 보태 갑니다. 4일차 폴더의 파일은 2일차부터 쌓인 10문항입니다.

type문항 수질문expected_source확인하려는 것
fact2"연차 이월은 며칠까지 가능한가요?", "해외 출장 식비 한도는 하루 얼마인가요?"문서 경로 (docs/leave_policy.md, docs/travel_policy.md)문서의 표현 그대로 물었을 때 맞는 문서를 찾는가
paraphrase2"월차 얼마나 받을 수 있어?", "집에서 일하려면 어떻게 신청해?"문서 경로 (docs/leave_policy.md, docs/remote_work_policy.md)문서와 다른 말(월차, 집에서 일하기)로 물어도 찾는가
no_answer1"육아휴직은 얼마나 쓸 수 있나요?"null근거 문서가 없을 때 지어내지 않고 모른다고 하는가
single_tool2"김하늘 님은 어느 팀 소속인가요?", "A-1001 자산의 현재 상태를 알려주세요."도구 이름 하나 (search_employee, get_asset_status)맞는 도구 하나를 고르는가
multi_tool2"'프린터가 고장 났어요' 문의 부서를 분류하고 48건이 12% 늘면 몇 건인지 계산해줘.", "이서준 님이 소속된 팀에서 진행 중인 프로젝트를 알려주세요."도구 이름 여럿 (classify_request, calculate / search_employee,list_projects)도구 여러 개를 빠짐없이 쓰는가
no_tool1"어떤 일을 도와줄 수 있어?"문자열 "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일차에서 합니다.

JSONday4_practice/eval_set.json2~4일차 누적 평가 세트 10문항 (질문, 기대 출처, 유형)12줄
한 줄이 한 문항입니다. 위에서 다섯 줄이 2일차, 다음 세 줄이 3일차, 마지막 두 줄이 4일차에 보탠 것입니다.
 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]

4일차 정리

오늘의 한 문장

모델은 도구의 설명서와 도구가 돌려준 글만 보고 움직이므로, 도구를 어디에 두든(프로세스 안의 함수든 밖의 MCP 서버든) 설명과 에러 메시지를 "모델에게 주는 지시문"으로 쓰는 것이 Agent의 동작을 결정합니다.

오늘 만든 것남은 숙제이어지는 날
질문 하나에 답하고 끝나는 Agent프로그램을 껐다 켜도 대화를 이어 가고, 사용자별 기억을 남기려면5일차 체크포인터·메모리
모델이 고르면 곧바로 실행되는 도구되돌릴 수 없는 작업 전에 사람의 승인을 받고, 위험한 입력과 출력을 걸러 내려면5일차 사람 승인·가드레일
사내 데이터 MCP 서버 sds-company-data도구가 늘어 한 Agent의 설명서가 감당하기 어려워지면6일차 멀티 에이전트 (이 서버가 데이터 담당 에이전트의 도구가 됩니다)
print 로 찍은 추적과 10문항 평가 세트실행 기록을 자동으로 남기고, 문항 전체를 돌려 채점하려면7일차 추적·누적 평가

스스로 확인하기

함수의 로직은 그대로 두고 docstring만 고쳤습니다. Agent의 동작이 달라질 수 있나요?

달라집니다. docstring은 도구의 description 이 되어 모델을 부를 때마다 입력으로 전달됩니다. 모델은 이 글을 보고 도구를 쓸지, 어떤 인자를 넣을지 정하므로 설명을 바꾸는 것은 프롬프트를 바꾸는 것과 같습니다. tools_basic.py 의 맨 아래 출력이 모델에게 전달되는 내용입니다.

도구가 실패했을 때 raise 대신 문자열을 return 하는 것과 handle_tool_errors=True 는 어떻게 다른가요?

둘 다 실패를 ToolMessage 로 모델에게 전달해 그래프가 멈추지 않게 합니다. 직접 return 하면 "무엇을 고쳐 다시 부르라", "백업 도구를 쓰라"처럼 다음 행동을 구체적으로 쓸 수 있습니다. handle_tool_errors=True 는 도구가 던진 예외를 ToolNode 가 정해진 문구로 바꾸는 것이라 내용은 일반적이지만, 도구 작성자가 예상하지 못한 예외까지 잡습니다. 예상되는 실패는 앞의 방식으로, 나머지는 뒤의 방식으로 겹쳐 대비합니다.

"서울과 부산의 온도"는 모델 호출 두 번으로 끝나는데 "서울 온도를 100으로 나눈 값"은 왜 세 번이 필요한가요?

앞의 질문은 두 조회가 서로 독립이라 모델이 한 응답에 도구 요청 두 건을 담을 수 있고, ToolNode 가 한 번에 처리합니다. 뒤의 질문은 온도를 알아야 나눗셈의 인자를 정할 수 있으므로 요청을 한 턴에 하나씩 낼 수밖에 없습니다. 도구 사이에 순차 의존이 있으면 병렬 호출이 불가능합니다.

모델은 search_employee 가 MCP 서버의 도구인지 @tool 로 만든 함수인지 알 수 있나요?

알 수 없고 알 필요도 없습니다. 모델에게 전달되는 것은 어느 쪽이든 이름, 설명, 입력 스키마입니다. mcp_agent.py 에서 mcp_tools + [calculator] 로 한 리스트에 합친 뒤에는 코드에서도 구분이 사라집니다. 차이는 요청을 실행하는 자리(같은 프로세스의 함수 호출인가, 다른 프로세스로 보내는 메시지인가)에만 있습니다.

MCP 서버를 Agent에 붙이기 전에 mcp_verify.py 로 먼저 확인하는 이유는?

모델이 끼면 결과가 실행마다 달라지고, 답이 틀렸을 때 서버 문제인지 모델의 도구 선택 문제인지 가리기 어렵습니다. 클라이언트로 직접 부르면 결과가 매번 같고 비용이 들지 않으며, 실패의 원인이 서버 쪽으로 좁혀집니다. 없는 직원을 조회하는 실패 경로까지 이 단계에서 확인합니다.

get_asset_status 는 왜 timeout에는 세 번 재시도하고 bad_schema에는 한 번도 재시도하지 않나요?

재시도는 같은 요청을 다시 보냈을 때 결과가 달라질 가능성이 있을 때만 의미가 있습니다. 응답 지연은 일시적일 수 있어 다시 부르고, 호출 한도 초과는 시간이 지나야 풀리므로 간격을 늘려 가며 다시 부릅니다. 응답 형식이 약속과 다른 것은 몇 번을 불러도 같은 답이 올 것이므로 시간을 쓰지 않고 바로 백업 경로를 안내합니다.

오늘 쓴 파일 한눈에

이 페이지에 실린 코드 파일 전체 목록입니다. 파일 이름을 누르면 본문에서 그 코드가 있는 자리로 이동합니다.

파일무엇을 하는 코드인가분량
tools_basic.py@tool 데코레이터를 세 단계 깊이로 쓰고, 모델에게 전달되는 메타데이터를 출력63줄
tool_error.py입력 오류, 결과 없음, 예상 밖 예외를 모두 안내 메시지로 반환하는 도구44줄
toolnode_parallel.pyToolNode로 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.pyMCP 서버의 도구 4개와 일반 도구 calculator 를 합쳐 ReAct Agent로 실행103줄
mcp_agent_external.py직접 만든 서버와 공개 MCP 서버(time)를 한 Agent에 함께 연결 (심화 과제)67줄
apt_trade_mcp.py국토교통부 아파트 매매 실거래가 API를 감싼 MCP 서버 (도구 apt_trades 하나)66줄
failure_injection.pyFAILURE_MODE 로 timeout·rate_limit·bad_schema 장애를 주입하는 자산 조회 도구와 백업 도구 (강의 제공 코드)112줄
failure_agent.py장애 주입 도구를 Agent에 연결해 정상·장애 실행을 비교하고 DoD 네 항목을 판정117줄
eval_set.json2~4일차 누적 평가 세트 10문항 (질문, 기대 출처, 유형)12줄

4일차 용어

도구 (Tool)
모델이 "실행해 달라"고 요청할 수 있도록 이름, 설명, 입력 스키마를 붙여 등록한 함수. 실행은 모델이 아니라 프로그램이 합니다.
입력 스키마
도구가 받는 인자의 이름, 타입, 필수 여부, 제약을 JSON Schema 형식으로 적은 것. 모델에게 전달되고, 실행 전 인자 검증에도 쓰입니다.
args_schema
@tool 에 Pydantic 모델을 넘겨 입력 스키마를 직접 지정하는 인자. 길이 제한 같은 검증 규칙과 인자별 설명을 넣을 수 있습니다.
도구 설명 (description)
모델이 도구를 언제 쓰고 언제 쓰지 않을지 판단하는 근거가 되는 글. 기본값은 함수의 docstring입니다.
ToolNode
LangGraph가 제공하는 도구 실행 노드. 직전 AIMessage 의 tool_calls 를 실행하고 결과를 ToolMessage 로 만들어 State에 추가합니다.
병렬 도구 호출
모델이 한 번의 응답에 도구 요청을 여러 건 담아 보내는 것. ToolNode 는 한 번의 노드 실행에서 이들을 함께 처리합니다.
handle_tool_errors
ToolNode 가 도구의 예외를 어떻게 다룰지 정하는 설정. True 면 예외를 에러 내용이 담긴 ToolMessage 로 바꿔 그래프가 중단되지 않게 합니다.
실행 추적 (trace)
Agent가 어떤 순서로 무엇을 결정하고 실행했는지의 기록. 오늘은 State의 messages 를 순회해 사용자, 도구요청, 도구결과, 최종답변으로 찍었습니다.
MCP
Model Context Protocol. LLM 응용 프로그램과 외부 도구·데이터를 잇는 개방형 표준 프로토콜. 도구 목록 조회와 도구 호출의 메시지 형식과 절차를 정합니다.
MCP 서버
도구를 가지고 있다가 목록 요청과 호출 요청에 답하는 프로그램. 오늘의 mcp_server.py, apt_trade_mcp.py 가 서버입니다.
MCP 클라이언트
서버에 연결해 도구 목록을 받고 호출을 요청하는 쪽. Agent 프로그램이나 Claude Code 같은 앱 안에 들어 있습니다.
stdio 전송
클라이언트가 서버를 자식 프로세스로 실행하고 표준 입력·표준 출력으로 MCP 메시지를 주고받는 연결 방식. 서버의 실행 명령이 곧 접속 방법입니다.
FastMCP
MCP 서버를 짧게 만들게 해 주는 파이썬 라이브러리. 함수에 @mcp.tool() 을 붙이면 도구로 등록됩니다.
MultiServerMCPClient
langchain-mcp-adapters 의 클라이언트. 여러 MCP 서버의 도구를 받아 LangChain 도구 객체 리스트로 변환해 줍니다.
create_react_agent
모델과 도구 리스트를 받아 agent ⇄ tools 루프 그래프를 만들어 주는 LangGraph 함수. 손으로 조립하던 ReAct 구조의 완성품입니다.
장애 주입
지연, 호출 한도 초과, 형식 오류 같은 장애를 일부러 재현해 대응 코드가 동작하는지 시험하는 방법.
지수 백오프
재시도 사이의 대기 시간을 2초, 4초처럼 배로 늘려 가는 방식. 한도 초과처럼 시간이 지나야 풀리는 장애에 씁니다.
폴백 (fallback)
원래 경로가 실패했을 때 쓰는 대체 경로. 오늘은 실시간 조회가 실패하면 백업 스냅샷 도구로 넘어가는 것이 폴백입니다.
마스킹
비밀값을 로그나 화면에 남길 때 일부만 보이고 나머지를 가리는 것. 오늘은 키의 앞 여섯 글자만 기록했습니다.
DoD
Definition of Done. 작업이 끝났다고 판단하는 완료 기준. 오늘은 정상 처리, 실패 처리, 복구, 로그 점검 네 항목이었습니다.