|
| 1 | +--- |
| 2 | +title: 'Langchain 기반 챗봇 만들기 #2 - Runnable과 체이닝 기초 익히기' |
| 3 | +description: 'Runnable 인터페이스·LCEL 파이프 연산자부터 Prompt-LLM-OutputParser 체인 구성, 그리고 RunnableLambda로 |
| 4 | + 커스텀 단계를 삽입하는 방법까지: LangChain 0.2 기준으로 챗봇 파이프라인의 핵심 개념과 예제 코드를 한눈에 정리합니다.' |
| 5 | +categories: Programming |
| 6 | +mermaid: true |
| 7 | +date: 2025-06-07 21:15 +0900 |
| 8 | +--- |
| 9 | +[Langchain 기반 챗봇 만들기 #1](/posts/chatbot_1_api_key/) 에서는 OpenAI API 키를 발급받아 기본 질문·응답 예제를 실행해 보았다. 그런데 단순한 요청-응답만으로는 이전 대화 내용을 참고하지 못해, 대화가 이어진다는 느낌이 들지 않는다. |
| 10 | + |
| 11 | +이럴 때 필요한 도구가 바로 Langchain이다. Langchain은 단일 프롬프트 호출을 넘어, 대화 맥락을 유지하거나 외부 도구를 활용하는 파이프라인을 쉽게 구성할 수 있게 돕는다. |
| 12 | + |
| 13 | +Langchain은 기능이 많아 처음 접할 때 개념이 다소 복잡하게 느껴졌다. 여기에 최대한 정리해놓도록 하려 한다. |
| 14 | + |
| 15 | + |
| 16 | + |
| 17 | +## Langchain이란 |
| 18 | +> Langchain은 대형 언어 모델(LLM) 애플리케이션 개발을 돕는 파이썬 라이브러리다. 프롬프트 관리, 컨텍스트 유지, 외부 도구 연동 등 반복 작업을 추상화해 개발 생산성을 높여준다... |
| 19 | +
|
| 20 | +라고 적혀있다. 모르겠고 코드를 통해 보자. |
| 21 | + |
| 22 | + |
| 23 | +## Runnable |
| 24 | +LangChain은 말 그대로 ‘체인(chain)’ 방식으로 동작을 구성한다. 마치 파이프처럼 각 구성 요소를 연결하여, 한 요소의 출력(output)이 다음 요소의 입력(input)이 되도록 한다. |
| 25 | + |
| 26 | +이를 위해 Chain, Agent, Tool, Memory 등 주요 실행 단위는 Runnable 인터페이스를 구현하거나 래핑된 형태로 제공된다. |
| 27 | + |
| 28 | +다음 예시코드에서는 직접 Runnable을 상속받아서 두 클래스를 제작했다. |
| 29 | +```python |
| 30 | +from langchain_core.runnables import Runnable |
| 31 | + |
| 32 | +class AddOne(Runnable): |
| 33 | + def invoke(self, input: int, config=None, **kwargs): |
| 34 | + return input + 1 # (+1) |
| 35 | + |
| 36 | +class MultiplyByTwo(Runnable): |
| 37 | + def invoke(self, input: int, config=None, **kwargs): |
| 38 | + return input * 2 # (×2) |
| 39 | + |
| 40 | +chain = AddOne() | MultiplyByTwo() # 체이닝 – LCEL 파이프 |
| 41 | + |
| 42 | +print(chain.invoke(3)) # (3 + 1) * 2 → 8 |
| 43 | +``` |
| 44 | + |
| 45 | +`AddOne`: 입력값에 1을 더하는 연산 |
| 46 | + |
| 47 | +`MultiplyByTwo`: 입력값을 2배로 곱하는 연산 |
| 48 | + |
| 49 | +이 두 클래스를 파이프 연산자 `|`를 통해 연결했다. 이 방식은 앞에서 나온 출력이 자동으로 다음 입력으로 넘어가는 체이닝 구조를 만든다. 이러한 구조 덕분에 복잡한 LLM 파이프라인도 매우 직관적으로 표현할 수 있다. |
| 50 | + |
| 51 | +```python |
| 52 | +chain = AddOne() | MultiplyByTwo() |
| 53 | +``` |
| 54 | +`invoke()` 메서드를 통해 전체 체인을 실행할 수 있으며, 이는 내부적으로 각 `Runnable`의 `invoke()`를 순차적으로 호출한다. |
| 55 | +```mermaid |
| 56 | +flowchart LR |
| 57 | + A["입력값: 3"] -- 3 --> B["AddOne(+1)"] |
| 58 | + B -- 4 --> C["MultiplyByTwo(×2)"] |
| 59 | + C -- 8 --> D["출력값: 8"] |
| 60 | +``` |
| 61 | + |
| 62 | + |
| 63 | +여기서 주의할 점은 `AddOne`이 input으로 integer형 변수 1개를 받기 때문에 `invoke()`에도 integer형 변수인 `3`을 넘겨주었다는 것이다. |
| 64 | + |
| 65 | + |
| 66 | + |
| 67 | +## LLM, Prompt, Chain |
| 68 | +그러면 위의 Runnable을 이용해서 각종 LLM을 어떻게 구동하는걸까? |
| 69 | +LLM을 구동시키는 데는 크게 세 가지 구성 요소가 필요하다: |
| 70 | +* **LLM**: 어떤 언어모델을 사용할 것인지 |
| 71 | +* **Prompt**: 언어모델에 어떤 입력을 줄 것인지 (예: "너는 LLM 전문가야. 아래 질문에 답해줘. 질문: {input}") |
| 72 | +* **OutputParser**: 어떤 출력값을 원하는지 (예: 전체 응답에서 텍스트만 추출) |
| 73 | + |
| 74 | +이 세 가지는 모두 Runnable로 동작하기 때문에, 앞서 봤던 파이프 연산자 `|`를 이용해 다음과 같이 쉽게 연결할 수 있다. |
| 75 | + |
| 76 | +```python |
| 77 | +from langchain_core.prompts import ChatPromptTemplate |
| 78 | +from langchain_core.output_parsers import StrOutputParser |
| 79 | +from langchain_openai import ChatOpenAI |
| 80 | + |
| 81 | +# 프롬프트 템플릿 정의 |
| 82 | +prompt = ChatPromptTemplate.from_template( |
| 83 | + "너는 전문가야. 아래 질문에 답해줘.\n\n질문: {input}" |
| 84 | +) |
| 85 | + |
| 86 | +# 사용할 언어모델 지정 |
| 87 | +llm = ChatOpenAI(model="gpt-4.1-nano", openai_api_key="YOUR_API_KEY") |
| 88 | + |
| 89 | +# 출력 결과를 문자열로 정리 |
| 90 | +output_parser = StrOutputParser() |
| 91 | + |
| 92 | +# 체이닝: Prompt → LLM → OutputParser |
| 93 | +chain = prompt | llm | output_parser |
| 94 | + |
| 95 | +# 실행 |
| 96 | +print(chain.invoke({"input": "지구의 자전 주기는?"})) |
| 97 | +``` |
| 98 | + |
| 99 | +```mermaid |
| 100 | +graph LR |
| 101 | + %% 노드 정의 (줄바꿈에 <br/> 사용) |
| 102 | + A["Input<br/>(dict)"] |
| 103 | + B["PromptTemplate<br/>(ChatPromptValue)"] |
| 104 | + C["ChatOpenAI<br/>(AIMessage)"] |
| 105 | + D["StrOutputParser<br/>(str)"] |
| 106 | + E["Final Output<br/>(str)"] |
| 107 | +
|
| 108 | + %% 데이터 흐름 |
| 109 | + A -- "{'input': '지구의 자전 주기는?'}" --> B |
| 110 | + B -- "list[HumanMessage]<br/>(프롬프트 메시지)" --> C |
| 111 | + C -- "AIMessage<br/>(모델 응답 전체)" --> D |
| 112 | + D -- "str<br/>(가공된 텍스트)" --> E |
| 113 | +``` |
| 114 | + |
| 115 | +실행해보면 다음과 같은 결과가 나온다. |
| 116 | +``` |
| 117 | +지구의 자전 주기(즉, 지구가 자신을 한 바퀴 도는 시간)는 약 23시간 56분 4초입니다. 이를 "항성일(sidereal day)"이라고 하며, 태양을 기준으로 한 태양일(약 24시간)과는 약 4분 정도 차이가 있습니다. 태양일은 태양이 하늘에서 같은 위치에 다시 도달하는 데 걸리는 시간을 의미하며, 일상적으로 우리가 사용하는 하루는 이 태양일에 해당합니다. |
| 118 | +``` |
| 119 | + |
| 120 | +prompt가 `{"input": "지구의 자전 주기는?"}` 같은 dict를 받으면, 그 안의 `"input"` 값을 템플릿의 `{input}` 자리에 자동으로 치환해서 LLM 호출용 메시지를 완성해준다. |
| 121 | + |
| 122 | +--- |
| 123 | + |
| 124 | + |
| 125 | +## PromptTemplate 주요 종류 |
| 126 | + |
| 127 | +| 분류 | 대표 클래스 | 한 줄 설명 | |
| 128 | +|------------------|-----------------------------|-------------------------------------------| |
| 129 | +| 문자열 프롬프트 | `PromptTemplate` | f-string·Jinja2 등으로 LLM 입력 문자열 생성 | |
| 130 | +| 대화형 프롬프트 | `ChatPromptTemplate` | 시스템·사용자·AI 메시지를 묶어 대화 맥락 구성 | |
| 131 | +| 메시지 자리표시 | `MessagesPlaceholder` | 기존 메시지 리스트를 그대로 삽입(메모리 연동) | |
| 132 | +| Few-Shot 프롬프트 | `FewShotPromptTemplate` 등 | I/O 예시를 접두·접미로 배치해 few-shot 학습 | |
| 133 | + |
| 134 | +> 이외에도 `PipelinePromptTemplate`, `StructuredChatPromptTemplate` 등 다양한 고급 템플릿을 제공한다. |
| 135 | +
|
| 136 | + |
| 137 | +## LLM 래퍼 주요 종류 |
| 138 | + |
| 139 | +| 클래스명 | 비고(필요 패키지·API) | |
| 140 | +|---------------|-------------------| |
| 141 | +| `ChatOpenAI` | `openai` 패키지, GPT 계열 | |
| 142 | +| `ChatAnthropic` | Claude 계열 | |
| 143 | +| `ChatGoogleVertexAI` (`ChatGooglePalm` 구 버전) | Vertex AI, PaLM-2/Gecko 등 | |
| 144 | +| `ChatCohere` | Cohere Command-R 등 | |
| 145 | + |
| 146 | +> LiteLLM, Ollama 등 로컬·프록시형 래퍼도 다수 존재한다. |
| 147 | +
|
| 148 | + |
| 149 | +## OutputParser 주요 종류 |
| 150 | + |
| 151 | +| 클래스명 | 용도 | |
| 152 | +|----------------------|-------------------------------------| |
| 153 | +| `StrOutputParser` | 전체 응답을 문자열로 반환(가장 기본) | |
| 154 | +| `JSONOutputParser` | JSON 형태 응답을 딕셔너리로 파싱 | |
| 155 | +| `PydanticOutputParser` | 결과를 Pydantic 모델 인스턴스로 변환 | |
| 156 | + |
| 157 | +> 이외에 `RegexOutputParser`, `CommaSeparatedListOutputParser` 등 특수 목적 파서도 제공된다. |
| 158 | +
|
| 159 | +--- |
| 160 | + |
| 161 | +## RunnableLambda로 커스텀 처리 단계 추가하기 |
| 162 | +만약 나만의 Custom 로직을 추가하고 싶다면? |
| 163 | + |
| 164 | +[`Runnable`](#runnable)을 직접 상속하여 클래스를 구현하는 방법도 있지만, `RunnableLambda`를 이용하면 더 간단하게 사용자 정의 단계를 체인에 삽입할 수 있다. |
| 165 | + |
| 166 | +```python |
| 167 | +from langchain_core.prompts import ChatPromptTemplate |
| 168 | +from langchain_core.prompt_values import ChatPromptValue |
| 169 | +from langchain_core.runnables import RunnableLambda |
| 170 | +from langchain.schema import HumanMessage, BaseMessage |
| 171 | + |
| 172 | +# 1) Prompt와 LLM 정의 |
| 173 | +prompt = ChatPromptTemplate.from_template( |
| 174 | + "질문에 친절히 답할게요:\n\n{input}" |
| 175 | +) |
| 176 | + |
| 177 | +# 2) ChatPromptValue를 받아 HumanMessage에 '?' 추가하는 단순 함수 |
| 178 | +def emphasize_question_fn(pv: ChatPromptValue) -> ChatPromptValue: |
| 179 | + msgs: list[BaseMessage] = pv.to_messages() |
| 180 | + new_msgs: list[BaseMessage] = [] |
| 181 | + for m in msgs: |
| 182 | + if isinstance(m, HumanMessage): |
| 183 | + # 사용자 메시지 앞뒤에 물음표 이모지 추가 |
| 184 | + content = f"? {m.content.strip()} ?" |
| 185 | + new_msgs.append(HumanMessage(content=content)) |
| 186 | + else: |
| 187 | + new_msgs.append(m) |
| 188 | + return ChatPromptValue(messages=new_msgs) |
| 189 | + |
| 190 | +# 3) RunnableLambda로 래핑 |
| 191 | +normalize_input = RunnableLambda(emphasize_question_fn) |
| 192 | + |
| 193 | +# 4) 체인: prompt → normalize_input → llm |
| 194 | +chain = prompt | normalize_input |
| 195 | + |
| 196 | +# 5) 실행 예시 |
| 197 | +print(chain.invoke({"input": "지구의 자전 주기는?"})) |
| 198 | +``` |
| 199 | + |
| 200 | +``` |
| 201 | +# 실행결과 |
| 202 | +messages=[HumanMessage(content='? 질문에 친절히 답할게요:\n\n지구의 자전 주기는? ?', additional_kwargs={}, response_metadata={})] |
| 203 | +``` |
| 204 | + |
| 205 | +> `RunnableLambda`는 주로 데이터 전처리, 형 변환, 로깅 등의 목적에 활용되며 체인의 유연성을 크게 높여준다. |
0 commit comments