AX에이전트 실습 노트
DAY 1

LLM 호출부터 체인까지

모델은 결국 글을 이어 쓰는 함수입니다. 그 함수에 말을 거는 법부터, 부품을 이어 붙여 체인을 만드는 법까지 하루에 훑습니다.

Bedrock메시지와 역할토큰temperatureLangChainLCEL구조화 출력멀티턴도구 호출

이 날 배우는 것

첫날의 목표는 하나입니다. 'LLM을 부른다'는 말이 실제로 어떤 요청과 응답을 주고받는 일인지 눈으로 확인하고, 그 호출을 LangChain의 부품으로 조립해 작은 업무 프로그램(사내 문의 접수기)을 만드는 것입니다. 뒤의 6일은 전부 이 날 배운 호출 위에 무언가를 얹는 이야기입니다.

  • 모델에게 보내는 요청과 돌아오는 응답이 어떤 모양의 데이터인지 설명할 수 있다
  • system / user / assistant 세 역할과 토큰, temperature, max_tokens가 결과를 어떻게 바꾸는지 안다
  • LLM이 혼자서는 못하는 세 가지(모르는 사실, 정확한 계산, 기억)를 직접 재현해 본다
  • 프롬프트·모델·파서를 | 로 이어 체인을 만들고 invoke / stream / batch로 실행한다
  • 응답을 JSON 구조로 받아 프로그램이 바로 쓸 수 있게 만든다
  • 대화 기록을 이어 붙이는 원리와, 모델이 도구를 '부르기로 결정'하는 과정을 이해한다

시작 전에: 무엇을 가지고 실습하나

7일 내내 같은 재료를 씁니다. 모델은 Claude(대부분 Sonnet 4.5이고, 날에 따라 값싸고 빠른 Haiku 4.5나 Sonnet 4.6을 쓰는 파일도 있습니다), 모델을 빌려 쓰는 곳은 AWS Bedrock, 코드를 조립하는 도구는 LangChain과 LangGraph입니다. 이 셋의 관계만 먼저 잡아 두면 나머지가 쉽습니다.

모델 (LLM)

글을 입력받아 그럴듯한 다음 글을 이어 쓰는 프로그램입니다. 우리 컴퓨터가 아니라 클라우드의 서버에서 돕니다.

AWS Bedrock

여러 회사의 모델을 API로 빌려 쓰게 해 주는 AWS 서비스입니다. 우리는 인터넷으로 요청을 보내고 답을 받습니다.

LangChain

모델 호출 앞뒤에 필요한 잡일(프롬프트 채우기, 응답 다듬기, 여러 호출 잇기)을 부품으로 만들어 둔 파이썬 라이브러리입니다.

비밀번호는 코드에 적지 않는다

Bedrock을 쓰려면 "이 요청은 내 계정에서 보낸 것"이라는 증명이 필요합니다. 이것이 자격 증명(access key)입니다. 키를 코드에 직접 적으면 코드를 공유하는 순간 키도 같이 새어 나가므로, .env 라는 별도 파일에 적어 두고 실행할 때 읽어 들입니다. .env 는 git에 올리지 않습니다.

.env (레포 루트에 두는 파일)
1AWS_ACCESS_KEY_ID=...
2AWS_SECRET_ACCESS_KEY=...
3AWS_DEFAULT_REGION=us-east-1
값은 각자 발급받은 것으로 채웁니다. 변수 이름 목록은 레포의 .env.example 에 있습니다.

거의 모든 실습 파일 첫머리에 나오는 load_dotenv() 한 줄이 이 파일을 읽어 환경 변수로 올려 줍니다. AWS 라이브러리는 환경 변수에서 키를 알아서 찾습니다.

PYday1_practice/check_env.py파이썬 버전과 필수 패키지가 설치됐는지 점검15줄
실습을 시작하기 전에 한 번 돌려 보는 점검 스크립트입니다. 패키지를 실제로 import 해 보고 버전을 찍습니다.
 1# check_env.py - 환경 점검 스크립트
 2import sys
 3import importlib
 4
 5print(f"Python 버전: {sys.version}")
 6assert sys.version_info >= (3, 10), "Python 3.10 이상이 필요합니다."
 7
 8packages = ["langchain", "langchain_core", "langchain_aws", "dotenv"]
 9for name in packages:
10    try:
11        mod = importlib.import_module(name)
12        version = getattr(mod, "__version__", "버전 정보 없음")
13        print(f"[OK] {name} ({version})")
14    except ImportError:
15        print(f"[실패] {name} 이 설치되지 않았습니다. pip install 을 확인하세요.")
PYday1_practice/check_credentials.py.env 의 키가 제대로 읽히는지 확인12줄
키 전체를 화면에 찍지 않고 앞 4자리만 보여 주는 점에 주목하세요. 키는 로그에도 남기지 않는 것이 원칙입니다.
 1# check_credentials.py - 자격 증명 로드 확인
 2import os
 3from dotenv import load_dotenv
 4
 5load_dotenv()  # .env 파일의 내용을 환경변수로 읽어 들입니다
 6
 7key = os.getenv("AWS_ACCESS_KEY_ID")
 8if key:
 9    # 키 전체를 출력하면 안 되므로 앞 4자리만 확인합니다
10    print(f"[OK] AWS_ACCESS_KEY_ID 로드됨: {key[:4]}...")
11else:
12    print("[실패] AWS_ACCESS_KEY_ID 가 없습니다. .env 파일 위치와 내용을 확인하세요.")

모델을 처음 불러 보기

"AI에게 물어본다"를 코드로 쓰면 HTTP 요청 한 번입니다. 라이브러리의 도움 없이 날것 그대로 한 번 불러 보면, 그 뒤에 나오는 모든 도구가 무엇을 대신해 주는지 보입니다.

first_call_raw.py 7–18줄
 7client = boto3.client("bedrock-runtime", region_name="us-east-1")
 8
 9# 요청: modelId + messages 배열이 핵심입니다
10response = client.converse(
11    modelId="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
12    messages=[
13        {"role": "user", "content": [{"text": "안녕하세요. 자기소개를 한 문장으로 해주세요."}]}
14    ],
15)
16
17# 응답 전체를 JSON으로 출력해 구조를 눈으로 확인합니다
18print(json.dumps(response, indent=2, ensure_ascii=False, default=str))
AWS 공식 SDK(boto3)로 직접 호출합니다. 강조한 부분이 요청의 전부입니다.

요청에 꼭 들어가는 것은 두 가지뿐입니다. 어느 모델에게(modelId) 어떤 대화를(messages) 보낼 것인가. 대화는 "누가(role) 무슨 말을(content) 했는지"의 목록입니다.

보내는 것 (요청)

요청의 뼈대
1{
2  "modelId": "us.anthropic.claude-sonnet-4-5-...",
3  "messages": [
4    {
5      "role": "user",
6      "content": [{"text": "안녕하세요. ..."}]
7    }
8  ]
9}

돌아오는 것 (응답)

응답의 뼈대 (값은 예시)
 1{
 2  "output": {
 3    "message": {
 4      "role": "assistant",
 5      "content": [{"text": "안녕하세요, 저는 ..."}]
 6    }
 7  },
 8  "stopReason": "end_turn",
 9  "usage": {"inputTokens": 25, "outputTokens": 40, "totalTokens": 65}
10}
응답 항목뜻언제 쓰나
output.message모델이 쓴 답. 역할은 항상 assistant화면에 보여 주거나 다음 요청의 대화 기록에 붙일 때
stopReason모델이 글쓰기를 멈춘 이유end_turn(다 썼음), max_tokens(길이 제한에 걸려 잘림), tool_use(도구를 부르겠다)를 구분할 때
usage이번 호출에 쓴 토큰 수비용 계산, 입력이 너무 길어지는지 감시할 때
모델은 상태가 없는 함수입니다

요청을 받으면 답을 만들어 돌려주고 끝입니다. 서버는 방금 한 대화를 기억하지 않습니다. "대화가 이어진다"는 것은 우리가 지난 대화를 매번 다시 보내 주기 때문에 생기는 착시입니다. 이 사실이 뒤에 나오는 멀티턴, 메모리, 체크포인터를 이해하는 열쇠입니다.

PYday1_practice/first_call_raw.pyboto3로 Bedrock을 직접 불러 요청·응답 JSON을 통째로 출력18줄
응답 전체를 json.dumps 로 찍어 구조를 눈으로 확인하는 것이 목적입니다.
 1# first_call_raw.py - 원본 요청/응답 JSON 구조 확인
 2import json
 3import boto3
 4from dotenv import load_dotenv
 5
 6load_dotenv()
 7client = boto3.client("bedrock-runtime", region_name="us-east-1")
 8
 9# 요청: modelId + messages 배열이 핵심입니다
10response = client.converse(
11    modelId="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
12    messages=[
13        {"role": "user", "content": [{"text": "안녕하세요. 자기소개를 한 문장으로 해주세요."}]}
14    ],
15)
16
17# 응답 전체를 JSON으로 출력해 구조를 눈으로 확인합니다
18print(json.dumps(response, indent=2, ensure_ascii=False, default=str))

메시지와 세 가지 역할

대화의 각 줄에는 역할이 붙습니다. 역할은 셋뿐이고, 모델은 역할에 따라 그 글을 다르게 받아들입니다.

역할LangChain 클래스누가 하는 말인가쓰임새
systemSystemMessage개발자가 모델에게 주는 근무 지침말투, 맡은 일, 지켜야 할 규칙. 사용자 눈에는 보이지 않습니다
userHumanMessage사용자질문, 요청
assistantAIMessage모델모델이 한 답. 지난 답을 다시 넣어 주면 모델이 "내가 아까 이렇게 말했구나"라고 이해합니다
messages_role.py 12–30줄
12# 1) system 메시지 없이
13r1 = llm.invoke([HumanMessage(content="사내 메신저가 접속이 안 됩니다.")])
14print("[system 없음]", r1.content)
15
16# 2) system 메시지로 페르소나 지정
17r2 = llm.invoke([
18    SystemMessage(content="너는 삼성SDS IT지원팀 상담원이야. 접수 확인과 예상 처리 절차를 정중한 존댓말 두 문장으로 안내해."),
19    HumanMessage(content="사내 메신저가 접속이 안 됩니다."),
20])
21print("[system 있음]", r2.content)
22
23# 3) 대화 기록(assistant 메시지 포함)으로 맥락 전달
24r3 = llm.invoke([
25    SystemMessage(content="너는 삼성SDS IT지원팀 상담원이야."),
26    HumanMessage(content="사내 메신저가 접속이 안 됩니다."),
27    AIMessage(content="접수되었습니다. 사용 중인 운영체제를 알려주시겠어요?"),
28    HumanMessage(content="Windows 11이요."),  # 모델이 '메신저 장애' 맥락을 알고 답합니다
29])
30print("[대화 기록]", r3.content)
같은 질문을 세 가지 방식으로 보냅니다. 역할만 바뀌어도 답의 성격이 달라집니다.
  1. system 없이모델은 일반적인 조언을 길게 늘어놓습니다. 누구 입장에서 답해야 하는지 모르기 때문입니다.
  2. system으로 역할 지정"IT지원팀 상담원, 존댓말 두 문장"이라는 지침이 생기자 답이 접수 안내문처럼 바뀝니다. 같은 모델, 같은 질문인데 결과물의 형태가 달라집니다.
  3. 지난 대화를 함께 전달마지막 질문은 "Windows 11이요." 한마디뿐이지만, 앞선 메시지들이 같이 가므로 모델은 이것이 '메신저 접속 장애' 이야기라는 것을 압니다.
프롬프트 엔지니어링의 절반은 system 메시지입니다

뒤의 날들에서 에이전트의 성격, 담당 범위, 거절 규칙을 정하는 곳이 전부 system 메시지입니다. 6일차에는 "시스템 프롬프트는 5줄 이내"라는 점검 항목까지 나옵니다.

PYday1_practice/messages_role.pysystem / user / assistant 역할에 따라 답이 어떻게 달라지는지 비교30줄
 1# messages_role.py - role별 동작 차이 확인
 2from dotenv import load_dotenv
 3from langchain_aws import ChatBedrockConverse
 4from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
 5
 6load_dotenv()
 7llm = ChatBedrockConverse(
 8    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
 9    region_name="us-east-1",
10)
11
12# 1) system 메시지 없이
13r1 = llm.invoke([HumanMessage(content="사내 메신저가 접속이 안 됩니다.")])
14print("[system 없음]", r1.content)
15
16# 2) system 메시지로 페르소나 지정
17r2 = llm.invoke([
18    SystemMessage(content="너는 삼성SDS IT지원팀 상담원이야. 접수 확인과 예상 처리 절차를 정중한 존댓말 두 문장으로 안내해."),
19    HumanMessage(content="사내 메신저가 접속이 안 됩니다."),
20])
21print("[system 있음]", r2.content)
22
23# 3) 대화 기록(assistant 메시지 포함)으로 맥락 전달
24r3 = llm.invoke([
25    SystemMessage(content="너는 삼성SDS IT지원팀 상담원이야."),
26    HumanMessage(content="사내 메신저가 접속이 안 됩니다."),
27    AIMessage(content="접수되었습니다. 사용 중인 운영체제를 알려주시겠어요?"),
28    HumanMessage(content="Windows 11이요."),  # 모델이 '메신저 장애' 맥락을 알고 답합니다
29])
30print("[대화 기록]", r3.content)

토큰과 두 개의 다이얼

모델은 글자가 아니라 토큰 단위로 읽고 씁니다. 그리고 호출할 때 돌릴 수 있는 다이얼 가운데 가장 자주 만지는 것이 temperature 와 max_tokens 입니다.

토큰: 비용과 한계를 재는 단위

토큰은 단어를 더 잘게 쪼갠 조각입니다. 영어는 대략 단어 하나가 한두 토큰이고, 한글은 같은 뜻을 쓰는 데 토큰이 더 많이 듭니다. 요금은 입력 토큰 + 출력 토큰으로 매겨지고, 모델이 한 번에 읽을 수 있는 양(컨텍스트 윈도)도 토큰으로 정해집니다.

tokens.py 11–22줄
11result = llm.invoke("LangChain을 한 문장으로 설명해줘.")
12
13print("응답:", result.content)
14
15# 사용량은 호출 방식에 따라 안 실려 올 수 있으므로 먼저 있는지 확인합니다
16if result.usage_metadata:
17    usage = result.usage_metadata
18    print(f"입력 토큰: {usage['input_tokens']}")
19    print(f"출력 토큰: {usage['output_tokens']}")
20    print(f"전체 토큰: {usage['total_tokens']}")
21else:
22    print("이번 응답에는 토큰 사용량이 실려 오지 않았습니다.")
LangChain 응답 객체의 usage_metadata 에 이번 호출의 토큰 수가 들어 있습니다.
토큰 수를 왜 굳이 찍어 보나

에이전트는 한 번의 질문에 모델을 여러 번 부릅니다. 부를 때마다 지난 대화 전체가 입력으로 다시 들어가므로 입력 토큰이 눈덩이처럼 불어납니다. 아래 대화 이어가기 실습에서 이 숫자가 턴마다 커지는 것을 직접 봅니다.

temperature: 답의 흔들림

모델은 다음에 올 토큰의 후보마다 확률을 매긴 뒤 하나를 고릅니다. temperature 는 이 고르기를 얼마나 모험적으로 할지 정합니다.

temperature = 0

확률이 가장 높은 토큰만 고릅니다. 같은 질문에 거의 같은 답이 나옵니다. 분류, 추출, 판정처럼 결과가 흔들리면 안 되는 일에 씁니다. 이 강의의 실습 코드 대부분이 0입니다.

temperature = 1

확률이 낮은 토큰도 종종 고릅니다. 부를 때마다 다른 답이 나옵니다. 이름 짓기, 문구 초안처럼 다양성이 필요한 일에 어울립니다. 틀린 설정이 아니라 용도가 다른 설정입니다.

params.py 10–21줄
10# temperature 0: 결정적인 출력. 반복 호출해도 거의 같은 답
11llm_t0 = ChatBedrockConverse(model=MODEL, region_name="us-east-1", temperature=0)
12# temperature 1: 다양성 높은 출력. 호출마다 다른 답
13llm_t1 = ChatBedrockConverse(model=MODEL, region_name="us-east-1", temperature=1)
14
15print("--- temperature=0 (3회 호출) ---")
16for _ in range(3):
17    print(llm_t0.invoke(prompt).content)
18
19print("--- temperature=1 (3회 호출) ---")
20for _ in range(3):
21    print(llm_t1.invoke(prompt).content)
같은 질문을 세 번씩 던져 흔들림을 비교합니다.

max_tokens: 출력 길이의 상한

params.py 23–28줄
23# max_tokens: 출력 길이 제한. 너무 작으면 답이 잘립니다
24llm_short = ChatBedrockConverse(model=MODEL, region_name="us-east-1", max_tokens=20)
25result = llm_short.invoke("LangChain의 장점을 설명해줘.")
26print("--- max_tokens=20 ---")
27print(result.content)
28print("종료 사유:", result.response_metadata.get("stopReason"))
출력을 20토큰으로 묶으면 문장 중간에서 끊기고, 종료 사유가 max_tokens 로 찍힙니다.
잘린 답은 에러가 아닙니다

길이 제한에 걸려도 호출은 정상 종료됩니다. 끊긴 JSON을 그대로 파싱하다가 엉뚱한 곳에서 오류가 나는 일이 흔하니, 구조화된 출력을 받을 때는 종료 사유(stopReason)도 함께 확인하는 습관이 필요합니다.

PYday1_practice/tokens.py응답에 실려 오는 토큰 사용량 확인22줄
 1# tokens.py - 응답의 토큰 사용량 확인
 2from dotenv import load_dotenv
 3from langchain_aws import ChatBedrockConverse
 4
 5load_dotenv()
 6llm = ChatBedrockConverse(
 7    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
 8    region_name="us-east-1",
 9)
10
11result = llm.invoke("LangChain을 한 문장으로 설명해줘.")
12
13print("응답:", result.content)
14
15# 사용량은 호출 방식에 따라 안 실려 올 수 있으므로 먼저 있는지 확인합니다
16if result.usage_metadata:
17    usage = result.usage_metadata
18    print(f"입력 토큰: {usage['input_tokens']}")
19    print(f"출력 토큰: {usage['output_tokens']}")
20    print(f"전체 토큰: {usage['total_tokens']}")
21else:
22    print("이번 응답에는 토큰 사용량이 실려 오지 않았습니다.")
PYday1_practice/params.pytemperature 0과 1 비교, max_tokens로 잘린 응답 확인28줄
 1# params.py - temperature와 max_tokens 비교
 2from dotenv import load_dotenv
 3from langchain_aws import ChatBedrockConverse
 4
 5load_dotenv()
 6MODEL = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
 7
 8prompt = "삼성SDS 사내 카페 이름 아이디어를 하나만 제안해줘."
 9
10# temperature 0: 결정적인 출력. 반복 호출해도 거의 같은 답
11llm_t0 = ChatBedrockConverse(model=MODEL, region_name="us-east-1", temperature=0)
12# temperature 1: 다양성 높은 출력. 호출마다 다른 답
13llm_t1 = ChatBedrockConverse(model=MODEL, region_name="us-east-1", temperature=1)
14
15print("--- temperature=0 (3회 호출) ---")
16for _ in range(3):
17    print(llm_t0.invoke(prompt).content)
18
19print("--- temperature=1 (3회 호출) ---")
20for _ in range(3):
21    print(llm_t1.invoke(prompt).content)
22
23# max_tokens: 출력 길이 제한. 너무 작으면 답이 잘립니다
24llm_short = ChatBedrockConverse(model=MODEL, region_name="us-east-1", max_tokens=20)
25result = llm_short.invoke("LangChain의 장점을 설명해줘.")
26print("--- max_tokens=20 ---")
27print(result.content)
28print("종료 사유:", result.response_metadata.get("stopReason"))

LLM이 혼자서는 못하는 세 가지

모델의 한계를 일부러 건드려 봅니다. 이 세 가지 실패가 곧 남은 6일의 목차입니다. 에이전트란 결국 이 구멍을 메우는 장치들을 모델 둘레에 붙인 것이기 때문입니다.

limits.py 12–21줄
12# 1) 환각: 존재하지 않는 사내 규정을 물으면 지어낼 수 있습니다
13print(llm.invoke("삼성SDS 연차 이월 규정 제12조 3항 내용을 알려줘.").content)
14
15# 2) 계산 취약: 큰 수 곱셈
16print(llm.invoke("1234567 곱하기 8901234는? 숫자만 답해.").content)
17print("실제 답:", 1234567 * 8901234)
18
19# 3) 상태 망각: 두 번의 독립 호출은 서로를 모릅니다
20llm.invoke("내 이름은 김하늘이야.")
21print(llm.invoke("내 이름이 뭐였지?").content)
질문 세 개로 세 가지 한계를 차례로 재현합니다.
한계실습에서 일어나는 일왜 그런가해결하는 날
환각존재하지 않는 "연차 이월 규정 제12조 3항"을 물으면 그럴듯한 조문을 지어낼 수 있습니다모델은 사실을 찾아보는 것이 아니라 그럴듯한 글을 이어 씁니다. 사내 문서는 학습한 적도 없습니다2일차 RAG: 문서를 먼저 찾아서 같이 보여 줍니다
계산 취약7자리 수 곱셈의 답이 실제 값(10,989,169,755,678)과 어긋날 수 있습니다숫자도 토큰으로 쪼개어 '이어 쓰기'를 할 뿐 실제로 계산하지 않습니다도구 호출, 4일차: 계산은 계산기 함수에게 시킵니다
상태 망각"내 이름은 김하늘이야"라고 한 뒤 따로 "내 이름이 뭐였지?"를 물으면 모릅니다두 호출은 서로 무관한 별개의 요청입니다멀티턴, 5일차 체크포인터: 대화 기록을 저장해 다시 넣어 줍니다
기억상실증에 걸린 박식한 신입

글은 누구보다 잘 쓰지만, 우리 회사 규정집을 읽은 적이 없고, 암산은 대충 하고, 방금 한 대화도 잊어버리는 신입 사원을 떠올리면 됩니다. 이 신입에게 규정집을 건네고(RAG), 계산기와 사내 조회 화면을 쥐여 주고(도구), 업무 수첩을 쓰게 하면(메모리) 쓸 만한 동료가 됩니다. 이 강의는 그 과정을 차례로 만듭니다.

PYday1_practice/limits.py환각, 계산 오류, 상태 망각을 차례로 재현21줄
 1# limits.py - LLM 한계 재현
 2from dotenv import load_dotenv
 3from langchain_aws import ChatBedrockConverse
 4
 5load_dotenv()
 6llm = ChatBedrockConverse(
 7    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
 8    region_name="us-east-1",
 9    temperature=0,
10)
11
12# 1) 환각: 존재하지 않는 사내 규정을 물으면 지어낼 수 있습니다
13print(llm.invoke("삼성SDS 연차 이월 규정 제12조 3항 내용을 알려줘.").content)
14
15# 2) 계산 취약: 큰 수 곱셈
16print(llm.invoke("1234567 곱하기 8901234는? 숫자만 답해.").content)
17print("실제 답:", 1234567 * 8901234)
18
19# 3) 상태 망각: 두 번의 독립 호출은 서로를 모릅니다
20llm.invoke("내 이름은 김하늘이야.")
21print(llm.invoke("내 이름이 뭐였지?").content)

왜 LangChain을 쓰나

직접 호출해도 되는데 왜 라이브러리를 한 겹 더 얹을까요. 같은 번역 요청을 두 방식으로 써 보면 이유가 보입니다.

SDK 직접 호출

요청과 응답의 모양이 회사마다 다릅니다. Bedrock은 resp["output"]["message"]["content"][0]["text"], 다른 회사는 또 다른 경로입니다. 모델을 바꾸면 코드를 고쳐 써야 합니다.

LangChain

어느 회사 모델이든 같은 메시지 클래스를 넣고 .invoke() 로 부르고 .content 로 꺼냅니다. 모델을 바꿀 때는 모델 객체를 만드는 한 줄만 바꿉니다.

why_langchain.py 11–30줄
11# --- A. SDK 직접 호출: provider 전용 형식 ---
12client = boto3.client("bedrock-runtime", region_name="us-east-1")
13resp = client.converse(
14    modelId=MODEL,
15    system=[{"text": "너는 짧고 간결한 번역가야."}],
16    messages=[{"role": "user", "content": [{"text": f"{text}를 한국어로 번역해."}]}],
17)
18print("[SDK 직접]", resp["output"]["message"]["content"][0]["text"])
19
20# --- B. LangChain: 표준 인터페이스 ---
21messages = [
22    SystemMessage(content="너는 짧고 간결한 번역가야."),
23    HumanMessage(content=f"{text}를 한국어로 번역해."),
24]
25llm = ChatBedrockConverse(model=MODEL, region_name="us-east-1")
26print("[LangChain]", llm.invoke(messages).content)
27
28# 모델을 다른 provider로 바꿔도 messages와 invoke 코드는 그대로입니다
29# from langchain_openai import ChatOpenAI
30# llm = ChatOpenAI(model="gpt-5.4-mini")  # 동일하게 동작
A는 Bedrock 전용 형식, B는 표준 형식입니다. 맨 아래 주석은 모델을 바꾸는 데 필요한 변경이 한 줄임을 보여 줍니다.

표준 인터페이스의 진짜 이득은 다음 절부터 나옵니다. 모든 부품이 같은 방식으로 불리기 때문에 레고처럼 이어 붙일 수 있게 됩니다.

PYday1_practice/why_langchain.pyboto3 직접 호출과 LangChain 호출을 나란히 비교30줄
 1# why_langchain.py - 직접 호출 vs LangChain 비교
 2import boto3
 3from dotenv import load_dotenv
 4from langchain_aws import ChatBedrockConverse
 5from langchain_core.messages import SystemMessage, HumanMessage
 6
 7load_dotenv()
 8MODEL = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
 9text = "LangChain is the future of AI development"
10
11# --- A. SDK 직접 호출: provider 전용 형식 ---
12client = boto3.client("bedrock-runtime", region_name="us-east-1")
13resp = client.converse(
14    modelId=MODEL,
15    system=[{"text": "너는 짧고 간결한 번역가야."}],
16    messages=[{"role": "user", "content": [{"text": f"{text}를 한국어로 번역해."}]}],
17)
18print("[SDK 직접]", resp["output"]["message"]["content"][0]["text"])
19
20# --- B. LangChain: 표준 인터페이스 ---
21messages = [
22    SystemMessage(content="너는 짧고 간결한 번역가야."),
23    HumanMessage(content=f"{text}를 한국어로 번역해."),
24]
25llm = ChatBedrockConverse(model=MODEL, region_name="us-east-1")
26print("[LangChain]", llm.invoke(messages).content)
27
28# 모델을 다른 provider로 바꿔도 messages와 invoke 코드는 그대로입니다
29# from langchain_openai import ChatOpenAI
30# llm = ChatOpenAI(model="gpt-5.4-mini")  # 동일하게 동작

체인을 이루는 세 부품

LLM 호출 한 번은 거의 언제나 글 채우기 → 모델 호출 → 결과 다듬기 세 단계입니다. LangChain은 이 셋을 각각 독립된 부품으로 만들어 두었습니다. 조립하기 전에 하나씩 따로 돌려서 무엇이 들어가고 무엇이 나오는지 확인합니다.

dict{"text": ..., "language": ...} PromptTemplate빈칸 채우기 PromptValue ModelChatBedrockConverse AIMessage OutputParser결과 다듬기 str최종 문자열
부품하는 일입력출력
PromptTemplate빈칸이 있는 문장 틀에 값을 채워 넣습니다dictStringPromptValue
ChatBedrockConverse모델을 부릅니다PromptValue, 메시지 목록, 문자열 모두 가능AIMessage
StrOutputParserAIMessage에서 글만 꺼냅니다AIMessagestr
ChatPromptTemplate역할이 붙은 메시지 여러 개를 한 번에 만드는 틀입니다dictChatPromptValue (메시지 목록)
components.py 9–31줄
 9# --- 부품 1: PromptTemplate ---
10# 입력: dict, 출력: 완성된 프롬프트 (PromptValue)
11prompt = PromptTemplate.from_template("{text}를 {language}로 번역해줘.")
12filled = prompt.invoke({"text": "Good morning", "language": "한국어"})
13print("1) prompt 출력 타입:", type(filled).__name__)
14print("   내용:", filled.to_string())
15
16# --- 부품 2: Model ---
17# 입력: PromptValue(또는 메시지, 문자열), 출력: AIMessage
18llm = ChatBedrockConverse(
19    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
20    region_name="us-east-1",
21)
22ai_message = llm.invoke(filled)
23print("2) llm 출력 타입:", type(ai_message).__name__)
24print("   내용:", ai_message.content)
25
26# --- 부품 3: OutputParser ---
27# 입력: AIMessage, 출력: str
28parser = StrOutputParser()
29text = parser.invoke(ai_message)
30print("3) parser 출력 타입:", type(text).__name__)
31print("   내용:", text)
세 부품을 하나씩 따로 부르고, 단계마다 출력 타입을 찍어 봅니다.
components.py 33–39줄
33# --- 대화형 템플릿: ChatPromptTemplate ---
34chat_prompt = ChatPromptTemplate.from_messages([
35    ("system", "너는 삼성SDS {team} 담당자 톤으로 대답하는 어시스턴트야."),
36    ("human", "{question}"),
37])
38msgs = chat_prompt.invoke({"team": "IT지원팀", "question": "VPN 설정 방법 알려줘."})
39print("4) chat_prompt 출력:", msgs.to_messages())
system과 human 메시지를 한 틀에 담는 ChatPromptTemplate. 실무에서는 이쪽을 더 자주 씁니다.
프롬프트를 왜 틀로 만드나

문자열 덧셈으로 프롬프트를 만들면 바뀌는 부분과 고정된 부분이 뒤섞입니다. 틀로 만들면 무엇이 입력이고 무엇이 지시인지가 코드에 드러나고, 같은 틀에 값만 바꿔 수백 건을 처리할 수 있습니다.

PYday1_practice/components.pyPromptTemplate, 모델, OutputParser를 각각 실행해 입출력 타입 확인39줄
 1# components.py - 세 부품을 각각 실행해 입출력 타입 확인
 2from dotenv import load_dotenv
 3from langchain_aws import ChatBedrockConverse
 4from langchain_core.prompts import PromptTemplate, ChatPromptTemplate
 5from langchain_core.output_parsers import StrOutputParser
 6
 7load_dotenv()
 8
 9# --- 부품 1: PromptTemplate ---
10# 입력: dict, 출력: 완성된 프롬프트 (PromptValue)
11prompt = PromptTemplate.from_template("{text}를 {language}로 번역해줘.")
12filled = prompt.invoke({"text": "Good morning", "language": "한국어"})
13print("1) prompt 출력 타입:", type(filled).__name__)
14print("   내용:", filled.to_string())
15
16# --- 부품 2: Model ---
17# 입력: PromptValue(또는 메시지, 문자열), 출력: AIMessage
18llm = ChatBedrockConverse(
19    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
20    region_name="us-east-1",
21)
22ai_message = llm.invoke(filled)
23print("2) llm 출력 타입:", type(ai_message).__name__)
24print("   내용:", ai_message.content)
25
26# --- 부품 3: OutputParser ---
27# 입력: AIMessage, 출력: str
28parser = StrOutputParser()
29text = parser.invoke(ai_message)
30print("3) parser 출력 타입:", type(text).__name__)
31print("   내용:", text)
32
33# --- 대화형 템플릿: ChatPromptTemplate ---
34chat_prompt = ChatPromptTemplate.from_messages([
35    ("system", "너는 삼성SDS {team} 담당자 톤으로 대답하는 어시스턴트야."),
36    ("human", "{question}"),
37])
38msgs = chat_prompt.invoke({"team": "IT지원팀", "question": "VPN 설정 방법 알려줘."})
39print("4) chat_prompt 출력:", msgs.to_messages())

LCEL: 파이프 하나로 잇기

세 부품을 손으로 차례차례 부르던 것을 | 기호 하나로 잇습니다. 이 표기법이 LCEL(LangChain Expression Language)이고, 이렇게 이은 것을 체인이라고 부릅니다.

lcel_basic.py 18–19줄
18# 파이프 하나로 조립: dict -> 프롬프트 -> AIMessage -> str
19chain = prompt | llm | parser
앞 부품의 출력이 뒤 부품의 입력으로 그대로 흘러갑니다. 리눅스 셸의 파이프와 같은 발상입니다.

이것이 가능한 이유는 프롬프트, 모델, 파서가 모두 Runnable이라는 같은 규격을 따르기 때문입니다. Runnable은 "입력 하나를 받아 출력 하나를 내는 것"이고, 누구나 똑같은 세 가지 방법으로 실행할 수 있습니다. 그리고 Runnable 둘을 | 로 이으면 그 결과도 Runnable입니다. 그래서 체인 전체도 부품 하나처럼 다룰 수 있습니다.

실행 방법동작어울리는 상황
chain.invoke(입력)한 건을 넣고 끝까지 기다렸다가 결과 하나를 받습니다기본. 배치 작업, 내부 처리
chain.stream(입력)모델이 쓰는 대로 조각조각 받습니다챗봇 화면의 타이핑 효과. 첫 글자가 빨리 보여 체감 속도가 좋아집니다
chain.batch([입력들])여러 건을 동시에 보내고 결과 목록을 받습니다문서 여러 개 요약, 대량 분류
lcel_basic.py 21–41줄
21# 1) invoke: 단건 호출
22print("[invoke]")
23print(chain.invoke({"topic": "사내 시스템 정기 점검"}))
24print("---")
25
26# 2) stream: 토큰 단위 실시간 출력 (챗봇 UI의 타이핑 효과)
27print("[stream] ", end="", flush=True)
28for chunk in chain.stream({"topic": "보안 교육 일정"}):
29    print(chunk, end="", flush=True)
30print("\n---")
31
32# 3) batch: 여러 입력을 한꺼번에 병렬 처리
33inputs = [
34    {"topic": "주차장 공사"},
35    {"topic": "구내식당 메뉴 개편"},
36    {"topic": "사원증 재발급 안내"},
37]
38results = chain.batch(inputs)
39print("[batch]")
40for i, r in zip(inputs, results):
41    print(f"- {i['topic']}: {r[:30]}...")
같은 체인을 세 가지 방법으로 실행합니다.

체인 속을 들여다보기

파이프로 이으면 중간 값이 눈에 보이지 않습니다. 결과가 이상할 때 어느 단계에서 틀어졌는지 알려면 단계 사이에 관찰 지점을 끼워 넣습니다. 평범한 파이썬 함수도 RunnableLambda 로 감싸면 체인의 부품이 되므로, "값을 찍고 그대로 통과시키는 함수"를 만들어 끼우면 됩니다.

lcel_debug.py 10–16줄
10def tap(label):
11    """값을 출력하고 그대로 다음 단계로 통과시키는 관찰 지점을 만듭니다."""
12    def _tap(value):
13        print(f"\n=== [{label}] 타입: {type(value).__name__} ===")
14        print(value)
15        return value  # 반드시 그대로 반환해야 체인이 이어집니다
16    return RunnableLambda(_tap)
값을 출력하고 반드시 그대로 반환합니다. 반환을 빠뜨리면 다음 단계에 None이 흘러갑니다.
lcel_debug.py 25–33줄
25# 각 단계 사이에 관찰 지점을 끼워 넣습니다.
26chain = (
27    tap("입력")
28    | prompt
29    | tap("프롬프트 완성 후")
30    | llm
31    | tap("모델 응답 후")
32    | StrOutputParser()
33)
단계 사이마다 tap을 끼웁니다. dict → 프롬프트 → AIMessage 로 값의 타입이 바뀌는 것이 그대로 보입니다.
체인은 한 방향으로만 흐릅니다

파이프에는 "앞 단계로 되돌아가라"를 적을 자리가 없습니다. 결과가 나쁘면 다시 시도하거나, 조건에 따라 다른 길로 가야 하는 일은 체인으로 표현하기 어렵습니다. 이 한계가 3일차 LangGraph를 배우는 이유입니다.

앞 절의 부품 실험부터 이 절의 파이프 실행까지를 한 단계씩 넘겨 보며 값의 타입이 바뀌는 모습을 확인할 수 있습니다.

플레이어

LCEL 체인 플레이어

components.py 의 부품 세 개를 따로 돌린 결과부터 lcel_basic.py 의 invoke, stream, batch 와 lcel_debug.py 의 tap 관찰 지점까지 13단계로 따라갑니다. 단계마다 실제 코드 줄, 부품 사이를 지나가는 값의 타입(dict, PromptValue, AIMessage, str), 터미널 출력이 함께 바뀝니다.

새 탭으로day1_practice/lcel_chain_explained.html
PYday1_practice/lcel_basic.pyprompt | llm | parser 체인을 invoke, stream, batch로 실행41줄
 1# lcel_basic.py - 체인 구성과 invoke, stream, batch
 2from dotenv import load_dotenv
 3from langchain_aws import ChatBedrockConverse
 4from langchain_core.prompts import PromptTemplate
 5from langchain_core.output_parsers import StrOutputParser
 6
 7load_dotenv()
 8
 9prompt = PromptTemplate.from_template(
10    "너는 삼성SDS 사내 커뮤니케이션 담당자야. {topic}에 대한 공지 문구를 두 문장으로 써줘."
11)
12llm = ChatBedrockConverse(
13    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
14    region_name="us-east-1",
15)
16parser = StrOutputParser()
17
18# 파이프 하나로 조립: dict -> 프롬프트 -> AIMessage -> str
19chain = prompt | llm | parser
20
21# 1) invoke: 단건 호출
22print("[invoke]")
23print(chain.invoke({"topic": "사내 시스템 정기 점검"}))
24print("---")
25
26# 2) stream: 토큰 단위 실시간 출력 (챗봇 UI의 타이핑 효과)
27print("[stream] ", end="", flush=True)
28for chunk in chain.stream({"topic": "보안 교육 일정"}):
29    print(chunk, end="", flush=True)
30print("\n---")
31
32# 3) batch: 여러 입력을 한꺼번에 병렬 처리
33inputs = [
34    {"topic": "주차장 공사"},
35    {"topic": "구내식당 메뉴 개편"},
36    {"topic": "사원증 재발급 안내"},
37]
38results = chain.batch(inputs)
39print("[batch]")
40for i, r in zip(inputs, results):
41    print(f"- {i['topic']}: {r[:30]}...")
PYday1_practice/lcel_debug.pyRunnableLambda로 만든 관찰 지점을 끼워 단계별 입출력 추적36줄
 1# lcel_debug.py - 중간 단계 입출력 추적
 2from dotenv import load_dotenv
 3from langchain_aws import ChatBedrockConverse
 4from langchain_core.prompts import PromptTemplate
 5from langchain_core.output_parsers import StrOutputParser
 6from langchain_core.runnables import RunnableLambda
 7
 8load_dotenv()
 9
10def tap(label):
11    """값을 출력하고 그대로 다음 단계로 통과시키는 관찰 지점을 만듭니다."""
12    def _tap(value):
13        print(f"\n=== [{label}] 타입: {type(value).__name__} ===")
14        print(value)
15        return value  # 반드시 그대로 반환해야 체인이 이어집니다
16    return RunnableLambda(_tap)
17
18prompt = PromptTemplate.from_template("{word}의 반대말 하나만 답해.")
19llm = ChatBedrockConverse(
20    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
21    region_name="us-east-1",
22    temperature=0,
23)
24
25# 각 단계 사이에 관찰 지점을 끼워 넣습니다.
26chain = (
27    tap("입력")
28    | prompt
29    | tap("프롬프트 완성 후")
30    | llm
31    | tap("모델 응답 후")
32    | StrOutputParser()
33)
34
35result = chain.invoke({"word": "출근"})
36print("\n최종 결과:", result)

구조화 출력: 글이 아니라 데이터로 받기

사람에게 보여 줄 답은 문장이면 충분합니다. 하지만 프로그램이 이어서 처리할 답은 문장이면 곤란합니다. "긴급도가 높으면 담당자에게 알림"을 하려면 긴급도가 정해진 칸에 정해진 값으로 들어 있어야 합니다.

  1. 받고 싶은 모양을 클래스로 적는다Pydantic 모델에 칸 이름과 타입을 적습니다. Field(description=...) 의 설명은 사람을 위한 주석이 아니라 모델에게 전달되는 지시문입니다. 허용 값을 여기에 적어 둡니다.
  2. 파서가 형식 지침을 만들어 준다JsonOutputParser 가 클래스를 읽고 "이런 JSON으로 답하라"는 안내문을 자동으로 만듭니다.
  3. 지침을 프롬프트에 끼워 넣는다partial_variables 는 사용자 입력이 아니라 미리 정해진 값을 채워 두는 자리입니다.
  4. 파서가 응답을 dict로 바꾼다체인 끝에 같은 파서를 달면 모델이 쓴 JSON 글자를 파이썬 dict로 바꿔 줍니다.
structured_output.py 10–25줄
10# 1) 출력 스키마 정의 - Field의 description이 모델에게 전달되는 설명입니다
11class InquiryAnalysis(BaseModel):
12    category: str = Field(description="문의 분류: 'IT지원', '인사', '총무' 중 하나")
13    urgency: str = Field(description="긴급도: '높음', '보통', '낮음' 중 하나")
14    summary: str = Field(description="문의 내용 한 줄 요약")
15
16# 2) 파서 생성 - 스키마 기반 형식 지침을 자동 생성합니다
17parser = JsonOutputParser(pydantic_object=InquiryAnalysis)
18
19# 3) 프롬프트에 형식 지침 끼워 넣기
20#    partial_variables: 사용자 입력이 아닌 값을 미리 채워 두는 자리입니다
21prompt = PromptTemplate(
22    template="다음 사내 문의를 분석해줘.\n\n문의: {inquiry}\n\n{format_instructions}",
23    input_variables=["inquiry"],
24    partial_variables={"format_instructions": parser.get_format_instructions()},
25)
스키마 정의 → 파서 생성 → 프롬프트에 형식 지침 삽입
structured_output.py 32–38줄
32chain = prompt | llm | parser
33
34result = chain.invoke({"inquiry": "노트북이 갑자기 안 켜지는데 오후에 고객 발표가 있습니다. 빨리 도와주세요."})
35
36print("타입:", type(result).__name__)
37print("전체:", result)
38print("분류만:", result["category"])
결과는 문자열이 아니라 dict입니다. result["category"] 처럼 바로 꺼내 씁니다.
JsonOutputParser는 '부탁'이지 '강제'가 아닙니다

이 방식은 프롬프트로 형식을 부탁하고 돌아온 글자를 해석하는 것입니다. 모델이 "IT지원" 대신 "IT 지원팀"이라고 쓰면 그대로 들어옵니다. 값의 범위까지 묶으려면 타입에 Literal 을 쓰고 모델의 with_structured_output() 을 써야 하며, 3일차의 분기 실습부터는 그 방식을 씁니다.

PYday1_practice/structured_output.pyPydantic 스키마와 JsonOutputParser로 응답을 dict로 받기38줄
 1# structured_output.py - 응답을 JSON 스키마로 받기
 2from dotenv import load_dotenv
 3from pydantic import BaseModel, Field
 4from langchain_aws import ChatBedrockConverse
 5from langchain_core.prompts import PromptTemplate
 6from langchain_core.output_parsers import JsonOutputParser
 7
 8load_dotenv()
 9
10# 1) 출력 스키마 정의 - Field의 description이 모델에게 전달되는 설명입니다
11class InquiryAnalysis(BaseModel):
12    category: str = Field(description="문의 분류: 'IT지원', '인사', '총무' 중 하나")
13    urgency: str = Field(description="긴급도: '높음', '보통', '낮음' 중 하나")
14    summary: str = Field(description="문의 내용 한 줄 요약")
15
16# 2) 파서 생성 - 스키마 기반 형식 지침을 자동 생성합니다
17parser = JsonOutputParser(pydantic_object=InquiryAnalysis)
18
19# 3) 프롬프트에 형식 지침 끼워 넣기
20#    partial_variables: 사용자 입력이 아닌 값을 미리 채워 두는 자리입니다
21prompt = PromptTemplate(
22    template="다음 사내 문의를 분석해줘.\n\n문의: {inquiry}\n\n{format_instructions}",
23    input_variables=["inquiry"],
24    partial_variables={"format_instructions": parser.get_format_instructions()},
25)
26
27llm = ChatBedrockConverse(
28    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
29    region_name="us-east-1",
30    temperature=0,
31)
32chain = prompt | llm | parser
33
34result = chain.invoke({"inquiry": "노트북이 갑자기 안 켜지는데 오후에 고객 발표가 있습니다. 빨리 도와주세요."})
35
36print("타입:", type(result).__name__)
37print("전체:", result)
38print("분류만:", result["category"])

종합 실습: 사내 문의 접수기

지금까지의 부품을 모아 쓸모 있는 것을 하나 만듭니다. 사내 문의 한 건이 들어오면 담당 부서와 긴급도를 판단하고, 동시에 회신 초안을 써 주는 체인입니다.

두 가지 일은 성격이 다릅니다. 분류는 프로그램이 읽을 데이터(dict)여야 하고, 초안은 사람이 읽을 글(str)이어야 합니다. 그래서 체인을 두 개 만들고, 같은 입력을 양쪽에 동시에 넣습니다.

문의 한 건{"inquiry": "..."} 분류 체인프롬프트 | 모델 | JsonOutputParser 답변 초안 체인프롬프트 | 모델 | StrOutputParser 결과 dictrouting + draft dictstr
RunnableParallel: 같은 입력을 여러 체인에 동시에 넣고, 결과를 이름표가 붙은 dict 하나로 모읍니다.
inquiry_chain.py 16–39줄
16# --- 부품 A: 문의 분류 체인 (구조화 출력) ---
17class Routing(BaseModel):
18    department: str = Field(description="담당 부서: 'IT지원팀', '인사팀', '총무팀' 중 하나")
19    urgency: str = Field(description="긴급도: '높음', '보통', '낮음' 중 하나")
20
21routing_parser = JsonOutputParser(pydantic_object=Routing)
22routing_prompt = PromptTemplate(
23    template="다음 사내 문의의 담당 부서와 긴급도를 판단해.\n\n문의: {inquiry}\n\n{format_instructions}",
24    input_variables=["inquiry"],
25    partial_variables={"format_instructions": routing_parser.get_format_instructions()},
26)
27routing_chain = routing_prompt | llm | routing_parser
28
29# --- 부품 B: 답변 초안 체인 (문자열 출력) ---
30answer_prompt = ChatPromptTemplate.from_messages([
31    ("system",
32     "너는 삼성SDS 사내 헬프데스크 상담원이야. 정중한 존댓말로 답하고 "
33     "마지막 문장에 예상 처리 기한을 안내해. 세 문장 이내로 써."),
34    ("human", "{inquiry}"),
35])
36answer_chain = answer_prompt | llm | StrOutputParser()
37
38# --- 조립: 두 체인을 병렬 실행 후 하나의 dict로 합칩니다 ---
39full_chain = RunnableParallel(routing=routing_chain, draft=answer_chain)
부품 A(분류), 부품 B(초안), 그리고 둘을 병렬로 묶는 한 줄.
병렬은 덤으로 따라옵니다

두 체인은 서로의 결과가 필요 없으므로 RunnableParallel 이 동시에 실행합니다. 순서대로 부르는 것보다 전체 시간이 줄어듭니다. 체인도 Runnable이라 RunnableParallel 의 재료가 될 수 있다는 점, 곧 부품의 조립품도 다시 부품이라는 점이 LCEL의 요점입니다.

내 업무로 바꿔 보기

같은 뼈대에 분류 기준과 말투만 바꿔 끼우면 다른 업무용이 됩니다. 아래는 DevOps 도구(Jenkins, GitHub, Nexus, ArgoCD, Harbor) 운영팀의 접수 창구로 바꾼 버전입니다. 달라진 것은 스키마와 프롬프트뿐이고 조립 구조는 그대로입니다.

my_inquiry_chain.py 16–29줄
16# --- 부품 A: 접수 분류 체인 (구조화 출력) ---
17class DevOpsTicket(BaseModel):
18    severity: str = Field(
19        description="장애 등급: 'P1'(전사/다수 조직 서비스 중단), "
20                    "'P2'(일부 조직·기능 장애, 우회 가능), "
21                    "'P3'(단일 사용자 문제, 단순 요청·문의) 중 하나"
22    )
23    system: str = Field(
24        description="대상 시스템: 'Jenkins', 'GitHub', 'Nexus', 'ArgoCD', 'Harbor', '기타' 중 하나"
25    )
26    request_type: str = Field(
27        description="처리 유형: '장애', '권한요청', '설정변경', '사용문의' 중 하나"
28    )
29    summary: str = Field(description="접수 내용 한 줄 요약")
장애 등급(P1~P3)의 판단 기준을 Field 설명에 구체적으로 적었습니다. 설명이 모호하면 분류도 흔들립니다.
my_inquiry_chain.py 61–75줄
61# --- 실행: 서로 다른 입력 + 일부러 애매한 입력 ---
62inquiries = [
63    # 명확한 P1 장애
64    "지금 Jenkins 마스터 접속이 안 됩니다. 사업부 전체 빌드가 30분째 멈춰 있어요.",
65    # 권한 요청 (P3 예상)
66    "신규 입사자인데 GitHub의 dx-mobile 조직 repository 쓰기 권한 요청드립니다.",
67    # 일부 장애 (P2 예상)
68    "ArgoCD에서 stg 클러스터 앱 몇 개가 OutOfSync 상태로 계속 남아 있습니다. prod는 정상입니다.",
69    # 설정 변경
70    "Nexus에 사내 npm 프록시 저장소 하나 추가해 주실 수 있나요? 외부 registry 미러용입니다.",
71    # 애매한 입력: 시스템이 둘 이상 얽혀 있고 장애인지 설정 문제인지 불분명
72    "배포가 안 돼요. 이미지 pull 하다가 실패하는 것 같은데 어디 문제인지 모르겠어요.",
73    # 애매한 입력: 시스템도 현상도 불명확
74    "어제부터 뭔가 느려요. 급한 건 아닌데 확인 부탁드립니다.",
75]
일부러 애매한 입력을 넣어 봅니다. 잘 되는 경우만 시험하면 어디서 깨지는지 알 수 없습니다.
애매한 입력을 일부러 넣는 이유

"어제부터 뭔가 느려요"처럼 시스템도 증상도 불분명한 접수는 실제 업무에서 가장 흔합니다. 이런 입력에서 모델이 근거 없이 P1으로 올리는지, 프롬프트에 적은 대로 '기타'로 빼는지를 확인하는 것이 곧 평가의 시작입니다. 이 습관은 2일차의 평가 세트와 7일차의 자동 채점으로 이어집니다.

구조화 출력 절의 스키마부터 이 절의 병렬 조립까지, 값이 어떤 모양으로 바뀌며 두 갈래로 흐르는지는 아래 플레이어로 따라가 볼 수 있습니다.

플레이어

사내 문의 접수 체인 플레이어

Pydantic 스키마가 get_format_instructions() 로 형식 지침이 되어 프롬프트에 끼고, 모델이 쓴 JSON 글자가 JsonOutputParser 를 거쳐 dict 가 되는 과정을 structured_output.py 로 보여 준 뒤, RunnableParallel 이 같은 입력을 분류 체인과 초안 체인에 동시에 넣어 routing / draft 로 합치는 과정을 14단계로 따라갑니다. my_inquiry_chain.py 응용판의 차이도 짚습니다.

새 탭으로day1_practice/inquiry_chain_explained.html
PYday1_practice/inquiry_chain.py사내 문의의 담당 부서·긴급도 분류와 답변 초안 작성을 병렬로 수행49줄
강의에서 제공한 기본형입니다.
 1# inquiry_chain.py - 사내 문의 응답
 2from dotenv import load_dotenv
 3from pydantic import BaseModel, Field
 4from langchain_aws import ChatBedrockConverse
 5from langchain_core.prompts import ChatPromptTemplate, PromptTemplate
 6from langchain_core.output_parsers import StrOutputParser, JsonOutputParser
 7from langchain_core.runnables import RunnableParallel
 8
 9load_dotenv()
10llm = ChatBedrockConverse(
11    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
12    region_name="us-east-1",
13    temperature=0,
14)
15
16# --- 부품 A: 문의 분류 체인 (구조화 출력) ---
17class Routing(BaseModel):
18    department: str = Field(description="담당 부서: 'IT지원팀', '인사팀', '총무팀' 중 하나")
19    urgency: str = Field(description="긴급도: '높음', '보통', '낮음' 중 하나")
20
21routing_parser = JsonOutputParser(pydantic_object=Routing)
22routing_prompt = PromptTemplate(
23    template="다음 사내 문의의 담당 부서와 긴급도를 판단해.\n\n문의: {inquiry}\n\n{format_instructions}",
24    input_variables=["inquiry"],
25    partial_variables={"format_instructions": routing_parser.get_format_instructions()},
26)
27routing_chain = routing_prompt | llm | routing_parser
28
29# --- 부품 B: 답변 초안 체인 (문자열 출력) ---
30answer_prompt = ChatPromptTemplate.from_messages([
31    ("system",
32     "너는 삼성SDS 사내 헬프데스크 상담원이야. 정중한 존댓말로 답하고 "
33     "마지막 문장에 예상 처리 기한을 안내해. 세 문장 이내로 써."),
34    ("human", "{inquiry}"),
35])
36answer_chain = answer_prompt | llm | StrOutputParser()
37
38# --- 조립: 두 체인을 병렬 실행 후 하나의 dict로 합칩니다 ---
39full_chain = RunnableParallel(routing=routing_chain, draft=answer_chain)
40
41# --- 실행 ---
42# inquiry = {"inquiry": "재택근무 신청 절차가 어떻게 되나요? 다음 주부터 필요합니다."}
43inquiry = {"inquiry": "내일 당장 최사하고 싶습니다. 절차 알려주세요."}
44result = full_chain.invoke(inquiry)
45
46print("담당 부서:", result["routing"]["department"])
47print("긴급도:", result["routing"]["urgency"])
48print("답변 초안:")
49print(result["draft"])
PYday1_practice/my_inquiry_chain.py같은 구조를 DevOps 도구 운영팀 접수 창구로 바꾼 버전87줄
직접 만든 응용판입니다. 분류 칸이 네 개로 늘었고, 시험 입력 여섯 개 가운데 두 개는 일부러 애매하게 넣었습니다.
 1# my_inquiry_chain.py - DevOps 툴 장애/요청 접수 체인 (Jenkins, GitHub, Nexus, ArgoCD, Harbor)
 2from dotenv import load_dotenv
 3from pydantic import BaseModel, Field
 4from langchain_aws import ChatBedrockConverse
 5from langchain_core.prompts import ChatPromptTemplate, PromptTemplate
 6from langchain_core.output_parsers import StrOutputParser, JsonOutputParser
 7from langchain_core.runnables import RunnableParallel
 8
 9load_dotenv()
10llm = ChatBedrockConverse(
11    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
12    region_name="us-east-1",
13    temperature=0,
14)
15
16# --- 부품 A: 접수 분류 체인 (구조화 출력) ---
17class DevOpsTicket(BaseModel):
18    severity: str = Field(
19        description="장애 등급: 'P1'(전사/다수 조직 서비스 중단), "
20                    "'P2'(일부 조직·기능 장애, 우회 가능), "
21                    "'P3'(단일 사용자 문제, 단순 요청·문의) 중 하나"
22    )
23    system: str = Field(
24        description="대상 시스템: 'Jenkins', 'GitHub', 'Nexus', 'ArgoCD', 'Harbor', '기타' 중 하나"
25    )
26    request_type: str = Field(
27        description="처리 유형: '장애', '권한요청', '설정변경', '사용문의' 중 하나"
28    )
29    summary: str = Field(description="접수 내용 한 줄 요약")
30
31ticket_parser = JsonOutputParser(pydantic_object=DevOpsTicket)
32ticket_prompt = PromptTemplate(
33    template=(
34        "다음은 삼성전자 DX 사업부 DevOps 툴 운영팀에 들어온 접수 건이야. "
35        "장애 등급, 대상 시스템, 처리 유형을 판단하고 한 줄로 요약해. "
36        "대상 시스템이 불분명하면 '기타'로 분류해.\n\n"
37        "접수 내용: {inquiry}\n\n{format_instructions}"
38    ),
39    input_variables=["inquiry"],
40    partial_variables={"format_instructions": ticket_parser.get_format_instructions()},
41)
42ticket_chain = ticket_prompt | llm | ticket_parser
43
44# --- 부품 B: 접수 회신 초안 체인 (문자열 출력) ---
45answer_prompt = ChatPromptTemplate.from_messages([
46    ("system",
47     "너는 삼성전자 DX 사업부 DevOps 툴 운영팀의 접수 담당자야. "
48     "Jenkins, GitHub, Nexus, ArgoCD, Harbor를 운영하고 있어. "
49     "정중하지만 간결한 업무 톤의 존댓말로 정확히 세 문장으로 답해.\n"
50     "1문장: 접수 확인 (대상 시스템과 현상을 짚어서)\n"
51     "2문장: 예상 대응 방향과 1차 회신 시점 (장애는 30분 이내, 요청·문의는 1영업일 이내)\n"
52     "3문장: 처리에 필요한 추가 정보 요청 (예: Job/Repository/Application 이름, 에러 로그, 발생 시각, 영향 범위)\n"
53     "추측으로 원인을 단정하지 말고, 목록 기호나 인사말 없이 문장만 써."),
54    ("human", "{inquiry}"),
55])
56answer_chain = answer_prompt | llm | StrOutputParser()
57
58# --- 조립: 두 체인을 병렬 실행 후 하나의 dict로 합칩니다 ---
59full_chain = RunnableParallel(ticket=ticket_chain, draft=answer_chain)
60
61# --- 실행: 서로 다른 입력 + 일부러 애매한 입력 ---
62inquiries = [
63    # 명확한 P1 장애
64    "지금 Jenkins 마스터 접속이 안 됩니다. 사업부 전체 빌드가 30분째 멈춰 있어요.",
65    # 권한 요청 (P3 예상)
66    "신규 입사자인데 GitHub의 dx-mobile 조직 repository 쓰기 권한 요청드립니다.",
67    # 일부 장애 (P2 예상)
68    "ArgoCD에서 stg 클러스터 앱 몇 개가 OutOfSync 상태로 계속 남아 있습니다. prod는 정상입니다.",
69    # 설정 변경
70    "Nexus에 사내 npm 프록시 저장소 하나 추가해 주실 수 있나요? 외부 registry 미러용입니다.",
71    # 애매한 입력: 시스템이 둘 이상 얽혀 있고 장애인지 설정 문제인지 불분명
72    "배포가 안 돼요. 이미지 pull 하다가 실패하는 것 같은데 어디 문제인지 모르겠어요.",
73    # 애매한 입력: 시스템도 현상도 불명확
74    "어제부터 뭔가 느려요. 급한 건 아닌데 확인 부탁드립니다.",
75]
76
77for inquiry in inquiries:
78    result = full_chain.invoke({"inquiry": inquiry})
79    ticket = result["ticket"]
80    print("=" * 70)
81    print("접수 내용:", inquiry)
82    print("장애 등급:", ticket["severity"])
83    print("대상 시스템:", ticket["system"])
84    print("처리 유형:", ticket["request_type"])
85    print("요약:", ticket["summary"])
86    print("회신 초안:")
87    print(result["draft"])

대화 이어가기: 멀티턴

앞에서 모델은 방금 한 말도 잊는다는 것을 봤습니다. 그런데 챗봇은 대화가 이어집니다. 비밀은 단순합니다. 지난 대화를 매번 통째로 다시 보냅니다.

원리: 리스트 하나를 직접 들고 다니기

multiturn_manual.py 12–20줄
12chat_history = []  # 대화 기록을 우리가 직접 들고 있습니다
13
14for user_input in ["안녕, 내 이름은 김하늘이야.", "내 이름이 뭐였지?"]:
15    chat_history.append(HumanMessage(content=user_input))
16    response = llm.invoke(chat_history)   # 기록 전체를 매번 보냅니다
17    chat_history.append(response)         # AI 응답도 기록에 추가합니다
18    print(f"사용자: {user_input}")
19    print(f"AI: {response.content}")
20    print(f"(현재 기록 {len(chat_history)}개, 입력 토큰 {response.usage_metadata['input_tokens']})\n")
질문을 리스트에 넣고 → 리스트 전체를 보내고 → 답도 리스트에 넣습니다. 이것이 전부입니다.
입력 토큰이 턴마다 커집니다

마지막 줄이 매 턴의 입력 토큰 수를 찍습니다. 두 번째 질문은 짧지만 첫 번째 질문과 답까지 함께 보내므로 입력 토큰은 더 많습니다. 대화가 길어질수록 비용과 응답 시간이 늘고, 언젠가는 모델이 읽을 수 있는 한도를 넘습니다. 5일차의 요약 미들웨어가 이 문제를 다룹니다.

자동화: 세션별로 기록을 맡기기

사용자가 여럿이면 리스트도 사용자마다 따로 있어야 합니다. RunnableWithMessageHistory 는 "세션 ID를 주면 그 사람의 기록을 돌려주는 함수"를 받아, 기록을 꺼내고 붙이고 저장하는 일을 대신해 줍니다.

multiturn_history.py 16–23줄
16# 세션별 대화 기록 저장소 (실제 서비스에서는 Redis나 DB로 교체합니다)
17_store: dict[str, ChatMessageHistory] = {}
18
19def get_session_history(session_id: str) -> ChatMessageHistory:
20    """세션 ID별 기록 객체를 반환. 함수 시그니처만 유지하면 저장소는 무엇이든 됩니다."""
21    if session_id not in _store:
22        _store[session_id] = ChatMessageHistory()
23    return _store[session_id]
세션 ID마다 기록 객체를 하나씩 둡니다. 실습은 메모리의 dict지만 실제 서비스라면 이 함수 안만 DB 조회로 바꾸면 됩니다.
multiturn_history.py 30–40줄
30# 모델에 메모리를 결합: 기록 저장과 결합이 자동으로 처리됩니다
31with_memory = RunnableWithMessageHistory(model, get_session_history)
32
33# session_id로 사용자를 구분합니다
34config = {"configurable": {"session_id": "user_123"}}
35print("[1차]", with_memory.invoke("안녕, 내 이름은 김하늘이야.", config=config).content)
36print("[2차]", with_memory.invoke("내 이름이 뭐였지?", config=config).content)
37
38# 다른 세션은 격리됩니다: 같은 체인, 다른 기억
39other = {"configurable": {"session_id": "user_456"}}
40print("[다른 사용자]", with_memory.invoke("내 이름 알아?", config=other).content)
같은 모델, 같은 코드인데 session_id 가 다르면 서로의 대화를 모릅니다.
이 방식은 곧 다른 것으로 대체됩니다

RunnableWithMessageHistory 는 LangChain이 "이제는 LangGraph의 체크포인터를 쓰라"고 안내하는 구식 방법입니다. 코드 위쪽의 경고 끄기 구문이 그 흔적입니다. 원리(세션 ID로 기록을 구분해 저장한다)는 그대로이고, 5일차에서 같은 일을 thread_id 와 SQLite 체크포인터로 다시 만듭니다.

기록이 턴마다 어떻게 쌓이고 세션별로 어떻게 갈리는지는 아래 플레이어로 한 단계씩 넘겨 볼 수 있습니다.

플레이어

멀티턴 대화 플레이어

limits.py 의 상태 망각에서 출발해 multiturn_manual.py 의 chat_history 가 턴마다 자라며 입력 토큰이 늘어나는 모습, multiturn_history.py 의 RunnableWithMessageHistory 가 session_id 별로 기록을 나누는 모습을 14단계로 따라가고, 이 방식이 5일차의 thread_id 와 체크포인터로 이어지는 지점을 표로 정리했습니다.

새 탭으로day1_practice/multiturn_explained.html
PYday1_practice/multiturn_manual.py대화 기록 리스트를 직접 관리하며 입력 토큰이 늘어나는 것 확인20줄
 1# multiturn_manual.py - chat_history 리스트 직접 관리로 원리 확인
 2from dotenv import load_dotenv
 3from langchain_aws import ChatBedrockConverse
 4from langchain_core.messages import HumanMessage
 5
 6load_dotenv()
 7llm = ChatBedrockConverse(
 8    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
 9    region_name="us-east-1",
10)
11
12chat_history = []  # 대화 기록을 우리가 직접 들고 있습니다
13
14for user_input in ["안녕, 내 이름은 김하늘이야.", "내 이름이 뭐였지?"]:
15    chat_history.append(HumanMessage(content=user_input))
16    response = llm.invoke(chat_history)   # 기록 전체를 매번 보냅니다
17    chat_history.append(response)         # AI 응답도 기록에 추가합니다
18    print(f"사용자: {user_input}")
19    print(f"AI: {response.content}")
20    print(f"(현재 기록 {len(chat_history)}개, 입력 토큰 {response.usage_metadata['input_tokens']})\n")
PYday1_practice/multiturn_history.pyRunnableWithMessageHistory로 세션별 대화 기록 자동 관리40줄
 1# multiturn_history.py - 권장 방식: RunnableWithMessageHistory
 2import warnings
 3
 4from dotenv import load_dotenv
 5from langchain_aws import ChatBedrockConverse
 6from langchain_core._api import LangChainDeprecationWarning
 7from langchain_core.chat_history import InMemoryChatMessageHistory as ChatMessageHistory
 8from langchain_core.runnables.history import RunnableWithMessageHistory
 9
10# RunnableWithMessageHistory 등은 LangGraph 권장으로 deprecated 처리됨 (2.0 전까지 동작)
11# langchain_core가 import 시 자체 경고 필터를 켜므로, import 이후에 꺼야 합니다
12warnings.filterwarnings("ignore", category=LangChainDeprecationWarning)
13
14load_dotenv()
15
16# 세션별 대화 기록 저장소 (실제 서비스에서는 Redis나 DB로 교체합니다)
17_store: dict[str, ChatMessageHistory] = {}
18
19def get_session_history(session_id: str) -> ChatMessageHistory:
20    """세션 ID별 기록 객체를 반환. 함수 시그니처만 유지하면 저장소는 무엇이든 됩니다."""
21    if session_id not in _store:
22        _store[session_id] = ChatMessageHistory()
23    return _store[session_id]
24
25model = ChatBedrockConverse(
26    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
27    region_name="us-east-1",
28)
29
30# 모델에 메모리를 결합: 기록 저장과 결합이 자동으로 처리됩니다
31with_memory = RunnableWithMessageHistory(model, get_session_history)
32
33# session_id로 사용자를 구분합니다
34config = {"configurable": {"session_id": "user_123"}}
35print("[1차]", with_memory.invoke("안녕, 내 이름은 김하늘이야.", config=config).content)
36print("[2차]", with_memory.invoke("내 이름이 뭐였지?", config=config).content)
37
38# 다른 세션은 격리됩니다: 같은 체인, 다른 기억
39other = {"configurable": {"session_id": "user_456"}}
40print("[다른 사용자]", with_memory.invoke("내 이름 알아?", config=other).content)

도구 호출 맛보기

계산을 못하면 계산기를, 직원 정보를 모르면 조회 함수를 쥐여 주면 됩니다. 이것이 도구 호출(tool calling)입니다. 여기서 가장 흔한 오해 하나를 먼저 풉니다. 모델은 도구를 실행하지 않습니다.

모델이 하는 일은 '결정'까지

파이썬 함수에 @tool 을 붙이면 함수 이름, 설명글(docstring), 인자의 타입이 도구 설명서로 바뀝니다. bind_tools 는 이 설명서를 요청에 같이 실어 보냅니다. 모델은 질문과 설명서를 읽고, 도구가 필요하다고 판단하면 답 대신 "이 도구를 이 인자로 불러 달라"는 주문서를 돌려줍니다.

tool_decision.py 8–28줄
 8@tool
 9def multiply(a: int, b: int) -> int:
10    """두 정수 a와 b를 입력받아 곱을 반환한다. 정확한 산술 계산이 필요할 때 사용."""
11    return a * b
12
13@tool
14def search_employee(name: str) -> str:
15    """삼성SDS 임직원 디렉터리에서 이름으로 직원 정보를 조회한다."""
16    # 교육용 더미 데이터입니다
17    fake_db = {
18        "김하늘": "김하늘 / 클라우드운영팀 / haneul.kim@samsungsds.example.com",
19        "박도윤": "박도윤 / 물류플랫폼팀 / doyun.park@samsungsds.example.com",
20    }
21    return fake_db.get(name, f"{name}을(를) 찾을 수 없습니다.")
22
23# bind_tools: 모델에게 "이런 도구들이 있다"고 알려줍니다
24llm = ChatBedrockConverse(
25    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
26    region_name="us-east-1",
27)
28llm_with_tools = llm.bind_tools([multiply, search_employee])
docstring이 곧 모델이 읽는 설명서입니다. 모델은 이 글만 보고 언제 이 도구를 쓸지 판단합니다.
tool_decision.py 30–44줄
30queries = [
31    "1234567 곱하기 8901234 계산해줘.",              # 단일 도구
32    "김하늘 찾아서 이메일 알려주고 15 곱하기 25도.",  # 복합 (도구 2개)
33    "그냥 안녕이라고만 인사해줘.",                    # 도구 불필요
34]
35
36for q in queries:
37    response = llm_with_tools.invoke(q)
38    print(f"[질문] {q}")
39    if response.tool_calls:  # 모델이 호출하기로 "결정"한 도구 목록 (아직 실행 전)
40        for call in response.tool_calls:
41            print(f"  -> 호출 결정: {call['name']}({call['args']})")
42    else:
43        print(f"  -> 직접 응답: {response.content}")
44    print()
응답의 tool_calls 에는 주문서만 들어 있습니다. 이 시점에 multiply 는 한 번도 실행되지 않았습니다.
질문모델의 결정알 수 있는 것
"1234567 곱하기 8901234 계산해줘."multiply 한 번자기가 계산에 약한 것을 알고 도구에 맡깁니다
"김하늘 찾아서 이메일 알려주고 15 곱하기 25도."search_employee 와 multiply 를 한꺼번에한 번의 응답에 주문서를 여러 장 쓸 수 있습니다 (병렬 도구 호출)
"그냥 안녕이라고만 인사해줘."도구 없이 바로 답도구가 있다고 무조건 쓰지 않습니다

결정 → 실행 → 최종 답변까지 한 바퀴

주문서를 받았으면 누군가 실제로 함수를 실행하고 결과를 모델에게 돌려줘야 합니다. 그 '누군가'가 우리 코드입니다. 결과는 ToolMessage 라는 네 번째 종류의 메시지에 담아 대화 기록에 붙이고, 모델을 다시 부릅니다. 모델이 더 부를 도구가 없다고 판단할 때까지 이 과정을 되풀이합니다.

질문 모델도구 설명서를 알고 있음 최종 답변 우리 코드주문서대로 함수 실행 tool_calls 가 비어 있으면 ① 주문서(tool_calls) ② 실행 결과(ToolMessage)
이 고리가 에이전트의 심장입니다. 3일차에 ReAct 루프라는 이름으로 다시 만나고, 그래프로 옮겨 그립니다.
tool_playground.py 122–152줄
122def run(question: str, max_turns: int = 5) -> dict:
123    """모델이 도구를 고르고, 우리가 실행하고, 결과를 돌려주는 과정을 반복합니다."""
124    print(f"[질문] {question}")
125    messages = [HumanMessage(question)]
126    usage_sum = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "calls": 0}
127    for turn in range(1, max_turns + 1):
128        response = llm_with_tools.invoke(messages)
129        messages.append(response)
130
131        # 모델 호출 1회의 토큰 사용량 (도구 실행 자체는 토큰을 쓰지 않습니다)
132        usage = response.usage_metadata or {}
133        for key in ("input_tokens", "output_tokens", "total_tokens"):
134            usage_sum[key] += usage.get(key, 0)
135        usage_sum["calls"] += 1
136        print(f"  [토큰 {turn}회차] 입력 {usage.get('input_tokens', 0):,} / "
137              f"출력 {usage.get('output_tokens', 0):,} / 합계 {usage.get('total_tokens', 0):,}")
138
139        if not response.tool_calls:  # 더 부를 도구가 없으면 최종 답변
140            print(f"  [최종 답변] {response.text}")
141            break
142        for call in response.tool_calls:
143            print(f"  -> 호출 결정: {call['name']}({call['args']})")
144            result = TOOLS_BY_NAME[call["name"]].invoke(call["args"])
145            print(f"     결과: {str(result)[:150]}")
146            messages.append(ToolMessage(content=str(result), tool_call_id=call["id"]))
147    else:
148        print("  [중단] 최대 반복 횟수에 도달했습니다.")
149    print(f"  [토큰 질문 합계] 모델 호출 {usage_sum['calls']}회 / 입력 {usage_sum['input_tokens']:,} / "
150          f"출력 {usage_sum['output_tokens']:,} / 합계 {usage_sum['total_tokens']:,}")
151    print()
152    return usage_sum
for 반복 한 번이 모델 호출 한 번입니다. tool_calls 가 없으면 최종 답변으로 보고 빠져나옵니다.
  1. 모델을 부르고 응답을 기록에 붙인다주문서가 담긴 AIMessage도 대화 기록의 일부입니다. 빼먹으면 다음 호출에서 모델이 자기가 무엇을 주문했는지 모릅니다.
  2. 주문서가 없으면 끝response.tool_calls 가 비어 있다는 것은 모델이 "이제 답할 수 있다"고 판단했다는 뜻입니다.
  3. 주문서마다 함수를 실행한다이름으로 도구를 찾아 .invoke(call["args"]) 로 실행합니다.
  4. 결과를 ToolMessage로 돌려준다tool_call_id 로 어느 주문에 대한 결과인지 짝을 맞춥니다. 주문이 여러 장일 때 이 ID가 없으면 결과가 섞입니다.
  5. 최대 횟수를 둔다max_turns 가 없으면 모델이 도구를 끝없이 부르는 경우 멈출 방법이 없습니다.
도구가 실패해도 예외를 던지지 않습니다

web_search, get_weather 를 보면 실패했을 때 예외를 올리는 대신 "검색 실패: ..." 같은 문자열을 반환합니다. 실패도 모델에게 돌려줘야 모델이 "영문 도시명으로 다시 시도"처럼 스스로 고쳐 볼 수 있기 때문입니다. 이 설계는 4일차에서 본격적으로 다룹니다.

도구를 쓰면 토큰이 더 듭니다

이 파일은 질문마다 모델 호출 횟수와 토큰 합계를 찍습니다. 도구를 한 번 쓰는 질문은 모델을 최소 두 번(주문 한 번, 최종 답변 한 번) 부르고, 두 번째 호출에는 첫 번째의 내용이 전부 다시 들어갑니다. 도구 일곱 개의 설명서도 매 호출의 입력에 포함됩니다.

질문 하나가 이 고리를 두 바퀴 도는 동안 messages 가 어떻게 자라는지는 아래 플레이어로 따라가 볼 수 있습니다.

플레이어

도구 호출 루프 플레이어

@tool 이 함수 이름, docstring, 타입 힌트로 도구 설명서를 만들고 bind_tools 가 그것을 요청에 싣는 데서 시작해, 박도윤 이메일 알려주고, 부산 날씨도 알려줘. 라는 질문 하나로 주문서(tool_calls) → 우리 코드의 실행 → ToolMessage → 다시 모델 호출 → 종료까지 messages 가 1개에서 5개로 자라는 과정을 14단계로 보여 줍니다. max_turns 와 실패를 문자열로 돌려주는 도구 설계도 짚습니다.

새 탭으로day1_practice/tool_loop_explained.html
PYday1_practice/tool_decision.pybind_tools 후 모델이 어떤 도구를 부르기로 결정하는지만 확인 (실행은 안 함)44줄
 1# tool_decision.py - 모델의 도구 호출 "결정" 확인
 2from dotenv import load_dotenv
 3from langchain_core.tools import tool
 4from langchain_aws import ChatBedrockConverse
 5
 6load_dotenv()
 7
 8@tool
 9def multiply(a: int, b: int) -> int:
10    """두 정수 a와 b를 입력받아 곱을 반환한다. 정확한 산술 계산이 필요할 때 사용."""
11    return a * b
12
13@tool
14def search_employee(name: str) -> str:
15    """삼성SDS 임직원 디렉터리에서 이름으로 직원 정보를 조회한다."""
16    # 교육용 더미 데이터입니다
17    fake_db = {
18        "김하늘": "김하늘 / 클라우드운영팀 / haneul.kim@samsungsds.example.com",
19        "박도윤": "박도윤 / 물류플랫폼팀 / doyun.park@samsungsds.example.com",
20    }
21    return fake_db.get(name, f"{name}을(를) 찾을 수 없습니다.")
22
23# bind_tools: 모델에게 "이런 도구들이 있다"고 알려줍니다
24llm = ChatBedrockConverse(
25    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
26    region_name="us-east-1",
27)
28llm_with_tools = llm.bind_tools([multiply, search_employee])
29
30queries = [
31    "1234567 곱하기 8901234 계산해줘.",              # 단일 도구
32    "김하늘 찾아서 이메일 알려주고 15 곱하기 25도.",  # 복합 (도구 2개)
33    "그냥 안녕이라고만 인사해줘.",                    # 도구 불필요
34]
35
36for q in queries:
37    response = llm_with_tools.invoke(q)
38    print(f"[질문] {q}")
39    if response.tool_calls:  # 모델이 호출하기로 "결정"한 도구 목록 (아직 실행 전)
40        for call in response.tool_calls:
41            print(f"  -> 호출 결정: {call['name']}({call['args']})")
42    else:
43        print(f"  -> 직접 응답: {response.content}")
44    print()
PYday1_practice/tool_playground.py웹 검색·날씨·환율 등 도구 7개를 붙여 결정 → 실행 → 최종 답변까지 반복171줄
실제 외부 API(검색, 위키백과, 날씨, 환율)를 부르는 도구를 직접 만들어 붙인 확장판입니다. 실행에는 ddgs, requests, tzdata 패키지와 인터넷 연결이 필요합니다.
  1# tool_playground.py - 여러 도구를 붙여 "결정 -> 실행 -> 최종 답변"까지 테스트
  2# 추가 패키지: pip install ddgs tzdata
  3from datetime import datetime
  4from zoneinfo import ZoneInfo
  5
  6import requests
  7from ddgs import DDGS
  8from dotenv import load_dotenv
  9from langchain_aws import ChatBedrockConverse
 10from langchain_core.messages import HumanMessage, ToolMessage
 11from langchain_core.tools import tool
 12
 13load_dotenv()
 14TIMEOUT = 10  # 외부 API 대기 시간(초)
 15
 16
 17# --- 기존 도구 (tool_decision.py와 동일) ---
 18@tool
 19def multiply(a: int, b: int) -> int:
 20    """두 정수 a와 b를 입력받아 곱을 반환한다. 정확한 산술 계산이 필요할 때 사용."""
 21    return a * b
 22
 23
 24@tool
 25def search_employee(name: str) -> str:
 26    """삼성SDS 임직원 디렉터리에서 이름으로 직원 정보를 조회한다."""
 27    # 교육용 더미 데이터입니다
 28    fake_db = {
 29        "김하늘": "김하늘 / 클라우드운영팀 / haneul.kim@samsungsds.example.com",
 30        "박도윤": "박도윤 / 물류플랫폼팀 / doyun.park@samsungsds.example.com",
 31    }
 32    return fake_db.get(name, f"{name}을(를) 찾을 수 없습니다.")
 33
 34
 35# --- 새 도구 ---
 36@tool
 37def web_search(query: str, max_results: int = 3) -> str:
 38    """인터넷에서 최신 정보를 검색한다. 뉴스, 최근 사건, 모델이 모를 수 있는 사실을 확인할 때 사용."""
 39    try:
 40        results = DDGS().text(query, max_results=max_results)
 41    except Exception as e:
 42        return f"검색 실패: {e}"
 43    if not results:
 44        return "검색 결과가 없습니다."
 45    return "\n\n".join(f"- {r['title']}\n  {r['body']}\n  {r['href']}" for r in results)
 46
 47
 48@tool
 49def wikipedia_summary(title: str) -> str:
 50    """한국어 위키백과에서 문서 제목으로 요약을 가져온다. 인물, 개념, 용어의 정의를 찾을 때 사용."""
 51    url = "https://ko.wikipedia.org/api/rest_v1/page/summary/" + requests.utils.quote(title)
 52    try:
 53        r = requests.get(url, headers={"User-Agent": "sds-ax-practice/1.0"}, timeout=TIMEOUT)
 54    except requests.RequestException as e:
 55        return f"위키백과 조회 실패: {e}"
 56    if r.status_code == 404:
 57        return f"'{title}' 문서를 찾을 수 없습니다."
 58    return r.json().get("extract", "요약이 없습니다.")
 59
 60
 61WEATHER_CODES = {0: "맑음", 1: "대체로 맑음", 2: "구름 조금", 3: "흐림", 45: "안개", 48: "안개",
 62                 51: "이슬비", 53: "이슬비", 55: "이슬비", 61: "비", 63: "비", 65: "강한 비",
 63                 71: "눈", 73: "눈", 75: "강한 눈", 80: "소나기", 81: "소나기", 82: "강한 소나기",
 64                 95: "뇌우", 96: "뇌우", 99: "뇌우"}
 65
 66
 67@tool
 68def get_weather(city: str) -> str:
 69    """도시의 현재 날씨(기온, 날씨 상태, 습도, 풍속)를 조회한다. city는 반드시 영문 도시명으로 넣는다. 예: Seoul, Busan, Tokyo."""
 70    try:
 71        geo = requests.get("https://geocoding-api.open-meteo.com/v1/search",
 72                           params={"name": city, "count": 1, "language": "ko"}, timeout=TIMEOUT).json()
 73        if not geo.get("results"):
 74            return f"'{city}' 도시를 찾을 수 없습니다. 영문 도시명으로 다시 시도하세요."
 75        place = geo["results"][0]
 76        cur = requests.get("https://api.open-meteo.com/v1/forecast",
 77                           params={"latitude": place["latitude"], "longitude": place["longitude"],
 78                                   "current": "temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code"},
 79                           timeout=TIMEOUT).json()["current"]
 80    except requests.RequestException as e:
 81        return f"날씨 조회 실패: {e}"
 82    state = WEATHER_CODES.get(cur["weather_code"], f"코드 {cur['weather_code']}")
 83    return (f"{place['name']}({place.get('country', '')}) 현재 날씨: {state}, "
 84            f"기온 {cur['temperature_2m']}°C, 습도 {cur['relative_humidity_2m']}%, 풍속 {cur['wind_speed_10m']}km/h")
 85
 86
 87@tool
 88def get_current_time(timezone: str = "Asia/Seoul") -> str:
 89    """지정한 시간대의 현재 날짜와 시각을 반환한다. timezone은 IANA 형식이다. 예: Asia/Seoul, America/New_York."""
 90    try:
 91        now = datetime.now(ZoneInfo(timezone))
 92    except Exception:
 93        return f"알 수 없는 시간대입니다: {timezone}"
 94    return now.strftime(f"%Y-%m-%d %H:%M:%S ({timezone})")
 95
 96
 97@tool
 98def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
 99    """최신 환율로 금액을 환전 계산한다. 통화는 ISO 코드로 넣는다. 예: USD, KRW, JPY, EUR."""
100    try:
101        data = requests.get(f"https://open.er-api.com/v6/latest/{from_currency.upper()}", timeout=TIMEOUT).json()
102    except requests.RequestException as e:
103        return f"환율 조회 실패: {e}"
104    rate = data.get("rates", {}).get(to_currency.upper())
105    if data.get("result") != "success" or rate is None:
106        return f"지원하지 않는 통화입니다: {from_currency} -> {to_currency}"
107    return (f"{amount:,.2f} {from_currency.upper()} = {amount * rate:,.2f} {to_currency.upper()} "
108            f"(환율 {rate:,.4f}, 기준 {data.get('time_last_update_utc', '알 수 없음')})")
109
110
111TOOLS = [multiply, search_employee, web_search, wikipedia_summary,
112         get_weather, get_current_time, convert_currency]
113TOOLS_BY_NAME = {t.name: t for t in TOOLS}
114
115llm = ChatBedrockConverse(
116    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
117    region_name="us-east-1",
118)
119llm_with_tools = llm.bind_tools(TOOLS)
120
121
122def run(question: str, max_turns: int = 5) -> dict:
123    """모델이 도구를 고르고, 우리가 실행하고, 결과를 돌려주는 과정을 반복합니다."""
124    print(f"[질문] {question}")
125    messages = [HumanMessage(question)]
126    usage_sum = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "calls": 0}
127    for turn in range(1, max_turns + 1):
128        response = llm_with_tools.invoke(messages)
129        messages.append(response)
130
131        # 모델 호출 1회의 토큰 사용량 (도구 실행 자체는 토큰을 쓰지 않습니다)
132        usage = response.usage_metadata or {}
133        for key in ("input_tokens", "output_tokens", "total_tokens"):
134            usage_sum[key] += usage.get(key, 0)
135        usage_sum["calls"] += 1
136        print(f"  [토큰 {turn}회차] 입력 {usage.get('input_tokens', 0):,} / "
137              f"출력 {usage.get('output_tokens', 0):,} / 합계 {usage.get('total_tokens', 0):,}")
138
139        if not response.tool_calls:  # 더 부를 도구가 없으면 최종 답변
140            print(f"  [최종 답변] {response.text}")
141            break
142        for call in response.tool_calls:
143            print(f"  -> 호출 결정: {call['name']}({call['args']})")
144            result = TOOLS_BY_NAME[call["name"]].invoke(call["args"])
145            print(f"     결과: {str(result)[:150]}")
146            messages.append(ToolMessage(content=str(result), tool_call_id=call["id"]))
147    else:
148        print("  [중단] 최대 반복 횟수에 도달했습니다.")
149    print(f"  [토큰 질문 합계] 모델 호출 {usage_sum['calls']}회 / 입력 {usage_sum['input_tokens']:,} / "
150          f"출력 {usage_sum['output_tokens']:,} / 합계 {usage_sum['total_tokens']:,}")
151    print()
152    return usage_sum
153
154
155queries = [
156    "지금 서울 날씨 어때?",
157    "100달러는 원화로 얼마야? 그리고 지금 뉴욕은 몇 시야?",
158    "랭체인(LangChain)이 뭔지 위키백과 기준으로 알려줘.",
159    "삼성SDS 최근 뉴스 검색해서 두 줄로 요약해줘.",
160    "박도윤 이메일 알려주고, 부산 날씨도 알려줘.",
161    "그냥 안녕이라고만 인사해줘.",  # 도구 불필요
162]
163
164if __name__ == "__main__":
165    grand = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "calls": 0}
166    for q in queries:
167        for key, value in run(q).items():
168            grand[key] += value
169    print("=" * 60)
170    print(f"[전체 토큰 합계] 질문 {len(queries)}개 / 모델 호출 {grand['calls']}회")
171    print(f"  입력 {grand['input_tokens']:,} / 출력 {grand['output_tokens']:,} / 합계 {grand['total_tokens']:,}")

1일차 정리

오늘의 한 문장

LLM 호출은 "메시지 목록을 보내고 메시지 하나를 받는" 상태 없는 함수 호출이고, 나머지는 전부 그 앞뒤에 무엇을 붙이느냐의 문제입니다.

오늘 만든 것남은 숙제이어지는 날
프롬프트 | 모델 | 파서 체인모델이 모르는 사내 지식은 어떻게 알려 주나2일차 RAG
한 방향으로 흐르는 파이프실패하면 되돌아가거나 조건에 따라 갈라지려면3일차 LangGraph
while 루프로 돌린 도구 호출도구를 잘 설계하고, 남이 만든 도구도 가져다 쓰려면4일차 도구와 MCP
메모리 안의 대화 기록프로그램을 껐다 켜도 남는 기억, 위험한 요청 막기5일차 기억·승인·가드레일

스스로 확인하기

모델에게 "아까 말한 거 기억해?"가 통하는 이유는 무엇인가요?

모델이 기억해서가 아니라, 우리 코드가 지난 메시지를 전부 모아 이번 요청에 다시 실어 보내기 때문입니다. 기록을 빼고 보내면 모델은 아무것도 모릅니다.

문의 분류 체인에 temperature=0을 쓴 까닭은?

같은 문의가 어떤 때는 '높음', 어떤 때는 '보통'으로 분류되면 뒤따르는 처리(알림, 배정)가 예측 불가능해집니다. 판정하는 일에는 흔들림이 적은 설정을 씁니다.

response.tool_calls 에 값이 있다는 것은 도구가 실행됐다는 뜻인가요?

아닙니다. 모델이 "이 도구를 이 인자로 실행해 달라"고 요청했다는 뜻입니다. 실행은 우리 코드가 하고, 결과를 ToolMessage로 돌려줘야 모델이 그 값을 보고 답을 완성합니다.

prompt | llm | parser 에서 세 부품의 순서를 바꿀 수 없는 이유는?

앞 부품의 출력 타입이 뒤 부품의 입력 타입과 맞아야 하기 때문입니다. 프롬프트는 dict를 받아 PromptValue를 내고, 모델은 그것을 받아 AIMessage를 내고, 파서는 AIMessage를 받아 str을 냅니다.

오늘 쓴 파일 한눈에

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

파일무엇을 하는 코드인가분량
check_env.py파이썬 버전과 필수 패키지가 설치됐는지 점검15줄
check_credentials.py.env 의 키가 제대로 읽히는지 확인12줄
first_call_raw.pyboto3로 Bedrock을 직접 불러 요청·응답 JSON을 통째로 출력18줄
messages_role.pysystem / user / assistant 역할에 따라 답이 어떻게 달라지는지 비교30줄
tokens.py응답에 실려 오는 토큰 사용량 확인22줄
params.pytemperature 0과 1 비교, max_tokens로 잘린 응답 확인28줄
limits.py환각, 계산 오류, 상태 망각을 차례로 재현21줄
why_langchain.pyboto3 직접 호출과 LangChain 호출을 나란히 비교30줄
components.pyPromptTemplate, 모델, OutputParser를 각각 실행해 입출력 타입 확인39줄
lcel_basic.pyprompt | llm | parser 체인을 invoke, stream, batch로 실행41줄
lcel_debug.pyRunnableLambda로 만든 관찰 지점을 끼워 단계별 입출력 추적36줄
structured_output.pyPydantic 스키마와 JsonOutputParser로 응답을 dict로 받기38줄
inquiry_chain.py사내 문의의 담당 부서·긴급도 분류와 답변 초안 작성을 병렬로 수행49줄
my_inquiry_chain.py같은 구조를 DevOps 도구 운영팀 접수 창구로 바꾼 버전87줄
multiturn_manual.py대화 기록 리스트를 직접 관리하며 입력 토큰이 늘어나는 것 확인20줄
multiturn_history.pyRunnableWithMessageHistory로 세션별 대화 기록 자동 관리40줄
tool_decision.pybind_tools 후 모델이 어떤 도구를 부르기로 결정하는지만 확인 (실행은 안 함)44줄
tool_playground.py웹 검색·날씨·환율 등 도구 7개를 붙여 결정 → 실행 → 최종 답변까지 반복171줄

1일차 용어

LLM
Large Language Model. 글을 입력받아 다음에 올 글을 이어 쓰는 모델. 이 강의에서는 Bedrock의 Claude 모델(주로 Sonnet 4.5)을 씁니다.
Bedrock
여러 회사의 모델을 API로 빌려 쓰는 AWS 서비스. ChatBedrockConverse 가 이 서비스의 Converse API를 부르는 LangChain 클래스입니다.
토큰
모델이 글을 읽고 쓰는 최소 단위. 요금과 입력 한도를 재는 기준입니다.
프롬프트
모델에게 보내는 글 전체. 지시(system), 질문(user), 지난 대화가 모두 포함됩니다.
system 메시지
개발자가 모델에게 주는 역할·규칙 지침. 대화 맨 앞에 놓입니다.
temperature
다음 토큰을 고를 때의 무작위성. 0에 가까울수록 같은 입력에 같은 답이 나옵니다.
환각
모델이 사실이 아닌 내용을 그럴듯하게 지어내는 현상.
LangChain
LLM 응용 프로그램의 부품(프롬프트, 모델, 파서, 도구 등)을 표준 규격으로 제공하는 라이브러리.
Runnable
입력 하나를 받아 출력 하나를 내는 LangChain의 공통 규격. invoke, stream, batch로 실행합니다.
LCEL
LangChain Expression Language. Runnable을 | 로 이어 체인을 만드는 표기법.
체인
여러 Runnable을 한 방향으로 이어 붙인 것. 앞의 출력이 뒤의 입력이 됩니다.
OutputParser
모델의 응답(AIMessage)을 프로그램이 쓰기 좋은 형태(str, dict)로 바꾸는 부품.
구조화 출력
모델의 답을 자유로운 문장이 아니라 정해진 칸이 있는 데이터로 받는 것.
멀티턴
여러 차례 주고받는 대화. 지난 메시지를 매번 다시 보내는 방식으로 구현합니다.
도구 호출
모델이 답 대신 "어떤 함수를 어떤 인자로 실행해 달라"는 요청을 돌려주는 기능. 실행은 프로그램이 합니다.
ToolMessage
도구 실행 결과를 모델에게 돌려줄 때 쓰는 메시지. tool_call_id 로 어느 요청의 결과인지 표시합니다.