LangChain 기초 (1) - Why LangChain: 손으로 짠 파이프라인이 무너지는 지점
SDK를 직접 호출해 만든 파이프라인에 제공자 교체와 스트리밍과 배치 요구를 얹었을 때 무엇이 무너지는지 보고, LangChain 1.x의 패키지 구성과 프레임워크를 쓰지 않아도 되는 경우를 다룹니다.
LangChain 기초 시리즈의 1편입니다. 전체 목차는 0편에 있습니다.
잘 돌아가는 코드에서 시작한다
RAG 기초 5편에서 만든 답변 함수입니다. 검색한 chunk를 프롬프트에 넣고 Claude를 호출해 출처가 붙은 답을 받습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import anthropic
client = anthropic.Anthropic()
SYSTEM = """너는 온라인 쇼핑몰의 고객 지원 담당자다.
<규칙>
- <자료>에 적힌 내용만 근거로 답한다. 자료에 없는 내용은 답에 쓰지 않는다.
- 자료로 답할 수 없으면 "자료에 없습니다"라고만 답하고 추측하지 않는다.
- 답에 쓴 근거의 자료 번호를 함께 밝힌다.
</규칙>"""
def answer(question: str, k: int = 3) -> str:
hits = search(question, k=k)
message = client.messages.create(
model="claude-sonnet-5",
max_tokens=2048,
system=SYSTEM,
messages=[{"role": "user", "content":
f"{build_context(hits)}\n\n<질문>\n{question}\n</질문>"}],
)
return message.content[0].text
print(answer("골드 등급이면 배송비가 무료인가요?"))
# 네, 골드 등급부터는 주문 금액과 관계없이 배송비가 무료입니다. (자료 1)
이 코드에는 문제가 없습니다. 읽기 쉽고, 무슨 일이 일어나는지 한눈에 보이고, 의존성도 anthropic 하나입니다. 프레임워크를 얹어야 할 이유가 아직 없습니다.
이유는 요구가 늘어날 때 생깁니다.
요구 세 개를 얹어 본다
요구 1. 다른 제공자로도 돌려 보고 비교하자.
client를 다른 SDK로 바꾸면 될 것 같지만 그렇지 않습니다. 클라이언트 생성 방식이 다르고, 호출 메서드 이름이 다르고, system을 별도 파라미터로 받는지 메시지 배열 안에 넣는지가 다르고, 응답에서 텍스트를 꺼내는 경로가 다릅니다. message.content[0].text 한 줄만 봐도 그렇습니다. 결국 answer 전체를 제공자별로 하나씩 쓰거나, 함수 안에 분기를 심게 됩니다.
요구 2. 답을 스트리밍해서 화면에 흘려라.
messages.create를 messages.stream으로 바꿔야 하고, 컨텍스트 매니저 안에서 이벤트를 순회해야 하고, 반환 타입이 str에서 제너레이터로 바뀝니다. 함수 시그니처가 바뀌니 호출하는 쪽도 전부 고쳐야 합니다. 스트리밍 버전과 논스트리밍 버전을 둘 다 유지하면 프롬프트를 고칠 때마다 두 곳을 고칩니다.
요구 3. 질문 100건을 한 번에 채점하자.
동시성을 직접 짭니다. 스레드풀을 만들고, 동시 요청 수를 제한하고, 실패한 건을 재시도하고, 순서를 맞춰 결과를 모읍니다. 이 코드는 RAG와 아무 상관이 없는데 answer 주변에 쌓입니다.
세 요구의 공통점은 프롬프트도 검색도 건드리지 않는다는 것입니다. 바뀌는 것은 “이 파이프라인을 어떻게 실행할 것인가”뿐인데, 그때마다 파이프라인 자체를 고치게 됩니다. 실행 방식과 파이프라인 정의가 한 함수 안에 붙어 있기 때문입니다.
인터페이스를 하나로 맞춘다는 발상
LangChain의 답은 단순합니다. 프롬프트든 모델이든 파서든 검색기든 전부 같은 인터페이스를 따르게 하고, 그 인터페이스가 실행 방식을 책임지게 하는 것입니다. 그 인터페이스의 이름이 Runnable이고, 부품을 이어 붙이는 문법이 LCEL입니다.
같은 파이프라인을 부품으로 다시 씁니다.
1
2
3
4
5
6
7
8
9
10
11
12
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
model = init_chat_model("anthropic:claude-sonnet-5", max_tokens=2048)
prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM),
("user", "{context}\n\n<질문>\n{question}\n</질문>"),
])
chain = prompt | model | StrOutputParser()
chain은 “무엇을 할지”만 적힌 정의입니다. 어떻게 실행할지는 아직 정하지 않았습니다.
1
2
3
4
question = "골드 등급이면 배송비가 무료인가요?"
payload = {"context": build_context(search(question)), "question": question}
print(chain.invoke(payload))
이제 앞의 요구 세 개를 다시 봅니다.
1
2
3
4
5
6
7
8
9
10
11
12
# 요구 1. 제공자 교체
model = init_chat_model("openai:gpt-5.5", max_tokens=2048)
chain = prompt | model | StrOutputParser()
# 요구 2. 스트리밍
for piece in chain.stream(payload):
print(piece, end="", flush=True)
# 요구 3. 배치
questions = ["골드 등급이면 배송비가 무료인가요?", "제주도 배송비는 얼마인가요?", ...]
payloads = [{"context": build_context(search(q)), "question": q} for q in questions]
answers = chain.batch(payloads, config={"max_concurrency": 5})
| 요구 | 손으로 짠 코드 | 부품으로 조립한 코드 |
|---|---|---|
| 제공자 교체 | 클라이언트 생성, 호출 메서드, 응답 파싱을 전부 수정 | init_chat_model의 문자열만 교체 |
| 스트리밍 | 호출부를 바꾸고 반환 타입이 바뀌어 호출하는 쪽까지 수정 | invoke를 stream으로 |
| 배치 | 스레드풀, 동시성 제한, 재시도, 순서 맞추기를 직접 구현 | batch에 리스트를 넘김 |
핵심은 chain의 정의가 세 경우 모두 동일하다는 점입니다. 프롬프트와 모델과 파서를 어떻게 이었는지는 그대로 두고 실행 방식만 바꿉니다. 실행 방식이 파이프라인 정의에서 떨어져 나온 것이 프레임워크가 준 것의 전부이고, 실제로는 그게 큽니다.
공짜는 아니다
프레임워크를 얹으면 세 가지 비용이 생깁니다.
한 겹이 더 생깁니다. 프롬프트에 값이 잘못 들어갔을 때 예외 스택이 내 코드가 아니라 라이브러리 안쪽에서 시작합니다. 실제로 어떤 문자열이 모델에 갔는지 보려면 별도 수단이 필요하고, 그래서 7편에서 추적을 다룹니다.
제공자 고유 기능이 늦게 들어옵니다. 통합 패키지가 새 파라미터를 노출할 때까지 기다리거나, 우회 경로로 넘겨야 합니다. 프롬프트 캐싱처럼 요금에 직접 영향을 주는 기능을 세밀하게 다뤄야 한다면 SDK를 직접 부르는 편이 확실합니다.
버전 이동 비용이 있습니다. LangChain은 1.0에서 네임스페이스를 크게 정리했습니다. 이건 뒤에서 따로 봅니다.
안 써도 되는 경우
프레임워크가 손해인 경우가 분명히 있습니다.
- 호출이 한 번이고 이어 붙일 부품이 없는 경우.
prompt | model | parser는 SDK 호출 한 줄보다 짧지 않습니다 - 제공자를 바꿀 계획이 없고 실행 방식도 고정인 경우. 위 표의 세 열이 전부 비어 있다면 얻을 것이 없습니다
- 제공자 고유 기능이 파이프라인의 핵심인 경우
거꾸로 이런 신호가 보이면 프레임워크가 이깁니다. 같은 프롬프트를 여러 실행 방식으로 돌려야 할 때, 단계가 세 개를 넘어가고 중간에 부품을 끼웠다 뺐다 해야 할 때, 그리고 여러 사람이 같은 파이프라인을 손대야 할 때입니다.
LangChain 1.x 지형
패키지가 여러 개로 쪼개져 있어서, 어떤 이름이 어디서 오는지 알고 있어야 예제를 읽을 수 있습니다.
| 패키지 | 역할 |
|---|---|
langchain-core | Runnable, 메시지, 프롬프트, 파서 같은 기반 추상. 다른 패키지가 전부 여기에 의존한다 |
langchain | 표준 인터페이스와 create_agent. init_chat_model이 여기 있다 |
langchain-anthropic, langchain-openai 등 | 제공자별 통합 |
langchain-text-splitters, langchain-chroma 등 | 검색 관련 부품 |
langchain-classic | 0.x에서 본체 밖으로 밀려난 것들. 유지보수 모드다 |
langgraph | 순환, 분기, 상태 지속. LangChain의 에이전트가 이 위에 올라간다 |
0.x 예제를 그대로 복사하면 대부분 여기서 막힙니다. 대응은 이렇습니다.
| 0.x | 1.x |
|---|---|
LLMChain | LCEL (prompt \| model \| parser) |
AgentExecutor, create_react_agent | langchain.agents의 create_agent (6편) |
ConversationBufferMemory 등 Memory 클래스 | LangGraph checkpointer (6편) |
langchain-classic을 설치하면 옛 이름이 당장은 동작합니다. 다만 새 기능은 들어오지 않으므로, 새로 짜는 코드는 오른쪽 열로 씁니다.
정리
- 손으로 짠 파이프라인이 무너지는 지점은 프롬프트가 복잡해질 때가 아니라 실행 방식이 늘어날 때다
- LangChain의 제안은 모든 부품에 같은 인터페이스를 씌우고, 실행 방식을 그 인터페이스가 책임지게 하는 것이다
- 파이프라인 정의와 실행 방식이 분리되면 제공자 교체, 스트리밍, 배치가 정의를 건드리지 않고 처리된다
- 대신 한 겹이 더 생기고, 제공자 고유 기능은 늦게 들어오고, 버전 이동 비용이 붙는다
- 호출 한 번짜리 작업에는 SDK 직접 호출이 여전히 낫다
다음 편에서 그 인터페이스인 Runnable을 직접 다룹니다. |가 무엇을 만들어 내는지, invoke와 batch와 stream이 왜 부품마다 다 있는지, 그리고 파이프 사이로 값을 흘려보내고 갈래를 나누는 방법을 봅니다.