LCEL 체인 플레이어
components.py 의 부품 세 개를 따로 돌린 결과부터 lcel_basic.py 의 invoke, stream, batch 와 lcel_debug.py 의 tap 관찰 지점까지 13단계로 따라갑니다. 단계마다 실제 코드 줄, 부품 사이를 지나가는 값의 타입(dict, PromptValue, AIMessage, str), 터미널 출력이 함께 바뀝니다.
모델은 결국 글을 이어 쓰는 함수입니다. 그 함수에 말을 거는 법부터, 부품을 이어 붙여 체인을 만드는 법까지 하루에 훑습니다.
첫날의 목표는 하나입니다. 'LLM을 부른다'는 말이 실제로 어떤 요청과 응답을 주고받는 일인지 눈으로 확인하고, 그 호출을 LangChain의 부품으로 조립해 작은 업무 프로그램(사내 문의 접수기)을 만드는 것입니다. 뒤의 6일은 전부 이 날 배운 호출 위에 무언가를 얹는 이야기입니다.
| 로 이어 체인을 만들고 invoke / stream / batch로 실행한다7일 내내 같은 재료를 씁니다. 모델은 Claude(대부분 Sonnet 4.5이고, 날에 따라 값싸고 빠른 Haiku 4.5나 Sonnet 4.6을 쓰는 파일도 있습니다), 모델을 빌려 쓰는 곳은 AWS Bedrock, 코드를 조립하는 도구는 LangChain과 LangGraph입니다. 이 셋의 관계만 먼저 잡아 두면 나머지가 쉽습니다.
글을 입력받아 그럴듯한 다음 글을 이어 쓰는 프로그램입니다. 우리 컴퓨터가 아니라 클라우드의 서버에서 돕니다.
여러 회사의 모델을 API로 빌려 쓰게 해 주는 AWS 서비스입니다. 우리는 인터넷으로 요청을 보내고 답을 받습니다.
모델 호출 앞뒤에 필요한 잡일(프롬프트 채우기, 응답 다듬기, 여러 호출 잇기)을 부품으로 만들어 둔 파이썬 라이브러리입니다.
Bedrock을 쓰려면 "이 요청은 내 계정에서 보낸 것"이라는 증명이 필요합니다. 이것이 자격 증명(access key)입니다. 키를 코드에 직접 적으면 코드를 공유하는 순간 키도 같이 새어 나가므로, .env 라는 별도 파일에 적어 두고 실행할 때 읽어 들입니다. .env 는 git에 올리지 않습니다.
1AWS_ACCESS_KEY_ID=... 2AWS_SECRET_ACCESS_KEY=... 3AWS_DEFAULT_REGION=us-east-1
거의 모든 실습 파일 첫머리에 나오는 load_dotenv() 한 줄이 이 파일을 읽어 환경 변수로 올려 줍니다. AWS 라이브러리는 환경 변수에서 키를 알아서 찾습니다.
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 을 확인하세요.")
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 요청 한 번입니다. 라이브러리의 도움 없이 날것 그대로 한 번 불러 보면, 그 뒤에 나오는 모든 도구가 무엇을 대신해 주는지 보입니다.
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))
요청에 꼭 들어가는 것은 두 가지뿐입니다. 어느 모델에게(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 | 이번 호출에 쓴 토큰 수 | 비용 계산, 입력이 너무 길어지는지 감시할 때 |
요청을 받으면 답을 만들어 돌려주고 끝입니다. 서버는 방금 한 대화를 기억하지 않습니다. "대화가 이어진다"는 것은 우리가 지난 대화를 매번 다시 보내 주기 때문에 생기는 착시입니다. 이 사실이 뒤에 나오는 멀티턴, 메모리, 체크포인터를 이해하는 열쇠입니다.
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 클래스 | 누가 하는 말인가 | 쓰임새 |
|---|---|---|---|
| system | SystemMessage | 개발자가 모델에게 주는 근무 지침 | 말투, 맡은 일, 지켜야 할 규칙. 사용자 눈에는 보이지 않습니다 |
| user | HumanMessage | 사용자 | 질문, 요청 |
| assistant | AIMessage | 모델 | 모델이 한 답. 지난 답을 다시 넣어 주면 모델이 "내가 아까 이렇게 말했구나"라고 이해합니다 |
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)
뒤의 날들에서 에이전트의 성격, 담당 범위, 거절 규칙을 정하는 곳이 전부 system 메시지입니다. 6일차에는 "시스템 프롬프트는 5줄 이내"라는 점검 항목까지 나옵니다.
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 입니다.
토큰은 단어를 더 잘게 쪼갠 조각입니다. 영어는 대략 단어 하나가 한두 토큰이고, 한글은 같은 뜻을 쓰는 데 토큰이 더 많이 듭니다. 요금은 입력 토큰 + 출력 토큰으로 매겨지고, 모델이 한 번에 읽을 수 있는 양(컨텍스트 윈도)도 토큰으로 정해집니다.
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("이번 응답에는 토큰 사용량이 실려 오지 않았습니다.")
에이전트는 한 번의 질문에 모델을 여러 번 부릅니다. 부를 때마다 지난 대화 전체가 입력으로 다시 들어가므로 입력 토큰이 눈덩이처럼 불어납니다. 아래 대화 이어가기 실습에서 이 숫자가 턴마다 커지는 것을 직접 봅니다.
모델은 다음에 올 토큰의 후보마다 확률을 매긴 뒤 하나를 고릅니다. temperature 는 이 고르기를 얼마나 모험적으로 할지 정합니다.
확률이 가장 높은 토큰만 고릅니다. 같은 질문에 거의 같은 답이 나옵니다. 분류, 추출, 판정처럼 결과가 흔들리면 안 되는 일에 씁니다. 이 강의의 실습 코드 대부분이 0입니다.
확률이 낮은 토큰도 종종 고릅니다. 부를 때마다 다른 답이 나옵니다. 이름 짓기, 문구 초안처럼 다양성이 필요한 일에 어울립니다. 틀린 설정이 아니라 용도가 다른 설정입니다.
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)
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"))
길이 제한에 걸려도 호출은 정상 종료됩니다. 끊긴 JSON을 그대로 파싱하다가 엉뚱한 곳에서 오류가 나는 일이 흔하니, 구조화된 출력을 받을 때는 종료 사유(stopReason)도 함께 확인하는 습관이 필요합니다.
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("이번 응답에는 토큰 사용량이 실려 오지 않았습니다.")
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"))
모델의 한계를 일부러 건드려 봅니다. 이 세 가지 실패가 곧 남은 6일의 목차입니다. 에이전트란 결국 이 구멍을 메우는 장치들을 모델 둘레에 붙인 것이기 때문입니다.
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), 계산기와 사내 조회 화면을 쥐여 주고(도구), 업무 수첩을 쓰게 하면(메모리) 쓸 만한 동료가 됩니다. 이 강의는 그 과정을 차례로 만듭니다.
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)
직접 호출해도 되는데 왜 라이브러리를 한 겹 더 얹을까요. 같은 번역 요청을 두 방식으로 써 보면 이유가 보입니다.
요청과 응답의 모양이 회사마다 다릅니다. Bedrock은 resp["output"]["message"]["content"][0]["text"], 다른 회사는 또 다른 경로입니다. 모델을 바꾸면 코드를 고쳐 써야 합니다.
어느 회사 모델이든 같은 메시지 클래스를 넣고 .invoke() 로 부르고 .content 로 꺼냅니다. 모델을 바꿀 때는 모델 객체를 만드는 한 줄만 바꿉니다.
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") # 동일하게 동작
표준 인터페이스의 진짜 이득은 다음 절부터 나옵니다. 모든 부품이 같은 방식으로 불리기 때문에 레고처럼 이어 붙일 수 있게 됩니다.
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은 이 셋을 각각 독립된 부품으로 만들어 두었습니다. 조립하기 전에 하나씩 따로 돌려서 무엇이 들어가고 무엇이 나오는지 확인합니다.
| 부품 | 하는 일 | 입력 | 출력 |
|---|---|---|---|
PromptTemplate | 빈칸이 있는 문장 틀에 값을 채워 넣습니다 | dict | StringPromptValue |
ChatBedrockConverse | 모델을 부릅니다 | PromptValue, 메시지 목록, 문자열 모두 가능 | AIMessage |
StrOutputParser | AIMessage에서 글만 꺼냅니다 | AIMessage | str |
ChatPromptTemplate | 역할이 붙은 메시지 여러 개를 한 번에 만드는 틀입니다 | dict | ChatPromptValue (메시지 목록) |
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)
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())
문자열 덧셈으로 프롬프트를 만들면 바뀌는 부분과 고정된 부분이 뒤섞입니다. 틀로 만들면 무엇이 입력이고 무엇이 지시인지가 코드에 드러나고, 같은 틀에 값만 바꿔 수백 건을 처리할 수 있습니다.
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(LangChain Expression Language)이고, 이렇게 이은 것을 체인이라고 부릅니다.
18# 파이프 하나로 조립: dict -> 프롬프트 -> AIMessage -> str 19chain = prompt | llm | parser
이것이 가능한 이유는 프롬프트, 모델, 파서가 모두 Runnable이라는 같은 규격을 따르기 때문입니다. Runnable은 "입력 하나를 받아 출력 하나를 내는 것"이고, 누구나 똑같은 세 가지 방법으로 실행할 수 있습니다. 그리고 Runnable 둘을 | 로 이으면 그 결과도 Runnable입니다. 그래서 체인 전체도 부품 하나처럼 다룰 수 있습니다.
| 실행 방법 | 동작 | 어울리는 상황 |
|---|---|---|
chain.invoke(입력) | 한 건을 넣고 끝까지 기다렸다가 결과 하나를 받습니다 | 기본. 배치 작업, 내부 처리 |
chain.stream(입력) | 모델이 쓰는 대로 조각조각 받습니다 | 챗봇 화면의 타이핑 효과. 첫 글자가 빨리 보여 체감 속도가 좋아집니다 |
chain.batch([입력들]) | 여러 건을 동시에 보내고 결과 목록을 받습니다 | 문서 여러 개 요약, 대량 분류 |
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 로 감싸면 체인의 부품이 되므로, "값을 찍고 그대로 통과시키는 함수"를 만들어 끼우면 됩니다.
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)
25# 각 단계 사이에 관찰 지점을 끼워 넣습니다. 26chain = ( 27 tap("입력") 28 | prompt 29 | tap("프롬프트 완성 후") 30 | llm 31 | tap("모델 응답 후") 32 | StrOutputParser() 33)
파이프에는 "앞 단계로 되돌아가라"를 적을 자리가 없습니다. 결과가 나쁘면 다시 시도하거나, 조건에 따라 다른 길로 가야 하는 일은 체인으로 표현하기 어렵습니다. 이 한계가 3일차 LangGraph를 배우는 이유입니다.
앞 절의 부품 실험부터 이 절의 파이프 실행까지를 한 단계씩 넘겨 보며 값의 타입이 바뀌는 모습을 확인할 수 있습니다.
components.py 의 부품 세 개를 따로 돌린 결과부터 lcel_basic.py 의 invoke, stream, batch 와 lcel_debug.py 의 tap 관찰 지점까지 13단계로 따라갑니다. 단계마다 실제 코드 줄, 부품 사이를 지나가는 값의 타입(dict, PromptValue, AIMessage, str), 터미널 출력이 함께 바뀝니다.
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]}...")
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)
사람에게 보여 줄 답은 문장이면 충분합니다. 하지만 프로그램이 이어서 처리할 답은 문장이면 곤란합니다. "긴급도가 높으면 담당자에게 알림"을 하려면 긴급도가 정해진 칸에 정해진 값으로 들어 있어야 합니다.
Field(description=...) 의 설명은 사람을 위한 주석이 아니라 모델에게 전달되는 지시문입니다. 허용 값을 여기에 적어 둡니다.JsonOutputParser 가 클래스를 읽고 "이런 JSON으로 답하라"는 안내문을 자동으로 만듭니다.partial_variables 는 사용자 입력이 아니라 미리 정해진 값을 채워 두는 자리입니다.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)
32chain = prompt | llm | parser 33 34result = chain.invoke({"inquiry": "노트북이 갑자기 안 켜지는데 오후에 고객 발표가 있습니다. 빨리 도와주세요."}) 35 36print("타입:", type(result).__name__) 37print("전체:", result) 38print("분류만:", result["category"])
이 방식은 프롬프트로 형식을 부탁하고 돌아온 글자를 해석하는 것입니다. 모델이 "IT지원" 대신 "IT 지원팀"이라고 쓰면 그대로 들어옵니다. 값의 범위까지 묶으려면 타입에 Literal 을 쓰고 모델의 with_structured_output() 을 써야 하며, 3일차의 분기 실습부터는 그 방식을 씁니다.
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)이어야 합니다. 그래서 체인을 두 개 만들고, 같은 입력을 양쪽에 동시에 넣습니다.
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)
두 체인은 서로의 결과가 필요 없으므로 RunnableParallel 이 동시에 실행합니다. 순서대로 부르는 것보다 전체 시간이 줄어듭니다. 체인도 Runnable이라 RunnableParallel 의 재료가 될 수 있다는 점, 곧 부품의 조립품도 다시 부품이라는 점이 LCEL의 요점입니다.
같은 뼈대에 분류 기준과 말투만 바꿔 끼우면 다른 업무용이 됩니다. 아래는 DevOps 도구(Jenkins, GitHub, Nexus, ArgoCD, Harbor) 운영팀의 접수 창구로 바꾼 버전입니다. 달라진 것은 스키마와 프롬프트뿐이고 조립 구조는 그대로입니다.
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="접수 내용 한 줄 요약")
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 응용판의 차이도 짚습니다.
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"])
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"])
앞에서 모델은 방금 한 말도 잊는다는 것을 봤습니다. 그런데 챗봇은 대화가 이어집니다. 비밀은 단순합니다. 지난 대화를 매번 통째로 다시 보냅니다.
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를 주면 그 사람의 기록을 돌려주는 함수"를 받아, 기록을 꺼내고 붙이고 저장하는 일을 대신해 줍니다.
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]
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)
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 와 체크포인터로 이어지는 지점을 표로 정리했습니다.
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")
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 는 이 설명서를 요청에 같이 실어 보냅니다. 모델은 질문과 설명서를 읽고, 도구가 필요하다고 판단하면 답 대신 "이 도구를 이 인자로 불러 달라"는 주문서를 돌려줍니다.
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])
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()
| 질문 | 모델의 결정 | 알 수 있는 것 |
|---|---|---|
| "1234567 곱하기 8901234 계산해줘." | multiply 한 번 | 자기가 계산에 약한 것을 알고 도구에 맡깁니다 |
| "김하늘 찾아서 이메일 알려주고 15 곱하기 25도." | search_employee 와 multiply 를 한꺼번에 | 한 번의 응답에 주문서를 여러 장 쓸 수 있습니다 (병렬 도구 호출) |
| "그냥 안녕이라고만 인사해줘." | 도구 없이 바로 답 | 도구가 있다고 무조건 쓰지 않습니다 |
주문서를 받았으면 누군가 실제로 함수를 실행하고 결과를 모델에게 돌려줘야 합니다. 그 '누군가'가 우리 코드입니다. 결과는 ToolMessage 라는 네 번째 종류의 메시지에 담아 대화 기록에 붙이고, 모델을 다시 부릅니다. 모델이 더 부를 도구가 없다고 판단할 때까지 이 과정을 되풀이합니다.
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
response.tool_calls 가 비어 있다는 것은 모델이 "이제 답할 수 있다"고 판단했다는 뜻입니다..invoke(call["args"]) 로 실행합니다.tool_call_id 로 어느 주문에 대한 결과인지 짝을 맞춥니다. 주문이 여러 장일 때 이 ID가 없으면 결과가 섞입니다.max_turns 가 없으면 모델이 도구를 끝없이 부르는 경우 멈출 방법이 없습니다.web_search, get_weather 를 보면 실패했을 때 예외를 올리는 대신 "검색 실패: ..." 같은 문자열을 반환합니다. 실패도 모델에게 돌려줘야 모델이 "영문 도시명으로 다시 시도"처럼 스스로 고쳐 볼 수 있기 때문입니다. 이 설계는 4일차에서 본격적으로 다룹니다.
이 파일은 질문마다 모델 호출 횟수와 토큰 합계를 찍습니다. 도구를 한 번 쓰는 질문은 모델을 최소 두 번(주문 한 번, 최종 답변 한 번) 부르고, 두 번째 호출에는 첫 번째의 내용이 전부 다시 들어갑니다. 도구 일곱 개의 설명서도 매 호출의 입력에 포함됩니다.
질문 하나가 이 고리를 두 바퀴 도는 동안 messages 가 어떻게 자라는지는 아래 플레이어로 따라가 볼 수 있습니다.
@tool 이 함수 이름, docstring, 타입 힌트로 도구 설명서를 만들고 bind_tools 가 그것을 요청에 싣는 데서 시작해, 박도윤 이메일 알려주고, 부산 날씨도 알려줘. 라는 질문 하나로 주문서(tool_calls) → 우리 코드의 실행 → ToolMessage → 다시 모델 호출 → 종료까지 messages 가 1개에서 5개로 자라는 과정을 14단계로 보여 줍니다. max_turns 와 실패를 문자열로 돌려주는 도구 설계도 짚습니다.
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()
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']:,}")
LLM 호출은 "메시지 목록을 보내고 메시지 하나를 받는" 상태 없는 함수 호출이고, 나머지는 전부 그 앞뒤에 무엇을 붙이느냐의 문제입니다.
| 오늘 만든 것 | 남은 숙제 | 이어지는 날 |
|---|---|---|
| 프롬프트 | 모델 | 파서 체인 | 모델이 모르는 사내 지식은 어떻게 알려 주나 | 2일차 RAG |
| 한 방향으로 흐르는 파이프 | 실패하면 되돌아가거나 조건에 따라 갈라지려면 | 3일차 LangGraph |
| while 루프로 돌린 도구 호출 | 도구를 잘 설계하고, 남이 만든 도구도 가져다 쓰려면 | 4일차 도구와 MCP |
| 메모리 안의 대화 기록 | 프로그램을 껐다 켜도 남는 기억, 위험한 요청 막기 | 5일차 기억·승인·가드레일 |
모델이 기억해서가 아니라, 우리 코드가 지난 메시지를 전부 모아 이번 요청에 다시 실어 보내기 때문입니다. 기록을 빼고 보내면 모델은 아무것도 모릅니다.
같은 문의가 어떤 때는 '높음', 어떤 때는 '보통'으로 분류되면 뒤따르는 처리(알림, 배정)가 예측 불가능해집니다. 판정하는 일에는 흔들림이 적은 설정을 씁니다.
response.tool_calls 에 값이 있다는 것은 도구가 실행됐다는 뜻인가요?아닙니다. 모델이 "이 도구를 이 인자로 실행해 달라"고 요청했다는 뜻입니다. 실행은 우리 코드가 하고, 결과를 ToolMessage로 돌려줘야 모델이 그 값을 보고 답을 완성합니다.
prompt | llm | parser 에서 세 부품의 순서를 바꿀 수 없는 이유는?앞 부품의 출력 타입이 뒤 부품의 입력 타입과 맞아야 하기 때문입니다. 프롬프트는 dict를 받아 PromptValue를 내고, 모델은 그것을 받아 AIMessage를 내고, 파서는 AIMessage를 받아 str을 냅니다.
이 페이지에 실린 코드 파일 전체 목록입니다. 파일 이름을 누르면 본문에서 그 코드가 있는 자리로 이동합니다.
| 파일 | 무엇을 하는 코드인가 | 분량 |
|---|---|---|
check_env.py | 파이썬 버전과 필수 패키지가 설치됐는지 점검 | 15줄 |
check_credentials.py | .env 의 키가 제대로 읽히는지 확인 | 12줄 |
first_call_raw.py | boto3로 Bedrock을 직접 불러 요청·응답 JSON을 통째로 출력 | 18줄 |
messages_role.py | system / user / assistant 역할에 따라 답이 어떻게 달라지는지 비교 | 30줄 |
tokens.py | 응답에 실려 오는 토큰 사용량 확인 | 22줄 |
params.py | temperature 0과 1 비교, max_tokens로 잘린 응답 확인 | 28줄 |
limits.py | 환각, 계산 오류, 상태 망각을 차례로 재현 | 21줄 |
why_langchain.py | boto3 직접 호출과 LangChain 호출을 나란히 비교 | 30줄 |
components.py | PromptTemplate, 모델, OutputParser를 각각 실행해 입출력 타입 확인 | 39줄 |
lcel_basic.py | prompt | llm | parser 체인을 invoke, stream, batch로 실행 | 41줄 |
lcel_debug.py | RunnableLambda로 만든 관찰 지점을 끼워 단계별 입출력 추적 | 36줄 |
structured_output.py | Pydantic 스키마와 JsonOutputParser로 응답을 dict로 받기 | 38줄 |
inquiry_chain.py | 사내 문의의 담당 부서·긴급도 분류와 답변 초안 작성을 병렬로 수행 | 49줄 |
my_inquiry_chain.py | 같은 구조를 DevOps 도구 운영팀 접수 창구로 바꾼 버전 | 87줄 |
multiturn_manual.py | 대화 기록 리스트를 직접 관리하며 입력 토큰이 늘어나는 것 확인 | 20줄 |
multiturn_history.py | RunnableWithMessageHistory로 세션별 대화 기록 자동 관리 | 40줄 |
tool_decision.py | bind_tools 후 모델이 어떤 도구를 부르기로 결정하는지만 확인 (실행은 안 함) | 44줄 |
tool_playground.py | 웹 검색·날씨·환율 등 도구 7개를 붙여 결정 → 실행 → 최종 답변까지 반복 | 171줄 |
ChatBedrockConverse 가 이 서비스의 Converse API를 부르는 LangChain 클래스입니다.| 로 이어 체인을 만드는 표기법.tool_call_id 로 어느 요청의 결과인지 표시합니다.