이번 주 학습 내용
지난주까지는 OpenAI API를 직접 두들겼다. 메시지 리스트를 손으로 쌓고, 스트리밍 청크를 손으로 걸러내고, 함수 호출 결과를 손으로 다시 메시지에 끼워 넣었다. 되긴 되는데 매번 비슷한 배관 작업을 반복하는 느낌이었는데, 이번 주에 그 배관을 대신 깔아주는 프레임워크로 넘어갔다. LangChain이다. 프롬프트·모델·검색기·에이전트를 부품처럼 다루고, 그 부품들을 파이프처럼 이어 붙이는 게 이번 주 전체를 관통하는 그림이었다. 후반부에는 그래프 DB와 2단계 RAG까지 이어지면서, "검색해서 답하기"라는 흐름이 실제로 손에 잡히는 모양으로 완성됐다.
01. LangChain 개관 — 하필 지금 v1.0 전환기
노트북을 열자마자 "이 자료는 LangChain v1.0(2025년 10월 출시) 기준"이라는 안내부터 봤다. 배우는 시점이 마침 프레임워크가 크게 개편된 직후였다. langchain 패키지 자체는 에이전트 구축 전용으로 가벼워지고, 예전에 흔히 쓰이던 체인·리트리버·메모리 구성요소는 langchain-classic이라는 별도 패키지로 옮겨갔다. 대신 LangGraph가 실행 엔진 자리를 차지했다. 검색해서 찾는 예제 코드와 지금 설치되는 버전의 문법이 어긋나는 경우가 종종 있어서, "최신 문서를 기준으로 삼아야 한다"는 걸 체감했다.
이어서 LangSmith도 같이 켰다. 체인이 길어지면 중간에 어떤 프롬프트가 실제로 모델에 들어갔는지, 어느 단계에서 시간이 오래 걸렸는지 눈으로 보기 어려워지는데, 환경 변수 세 줄만 심어두면 이후의 모든 실행이 자동으로 추적됐다.
os.environ['LANGSMITH_TRACING'] = 'true'
os.environ['LANGSMITH_ENDPOINT'] = 'https://api.smith.langchain.com'
os.environ['LANGSMITH_PROJECT'] = 'skn34-langchain'
agent = create_agent(model='openai:gpt-5-mini', tools=[get_weather], system_prompt='...')
response = agent.invoke({'messages': [{'role': 'user', 'content': '서울 날씨 어때?'}]})응답이 지난주처럼 문자열 하나로 오지 않고 response['messages']라는 메시지 배열로 온다는 게 처음엔 낯설었다. system·user·assistant·tool 메시지가 쌓인 상태 그 자체를 돌려받는 구조라, "대화 히스토리를 내가 직접 관리한다"에서 "프레임워크가 상태를 들고 있고 나는 필요할 때 들여다본다"로 관점이 넘어간 느낌이었다.
02. Model I/O — 프롬프트에서 구조화된 출력까지
PromptTemplate → LLM → OutputParser로 이어지는 가장 기본적인 파이프라인부터 짚었다. init_chat_model()이 편했는데, provider 이름만 바꿔주면 OpenAI든 허깅페이스 로컬 모델(Qwen2.5-0.5B-Instruct)이든 같은 방식으로 불러와졌다. 다만 provider마다 속성 이름이 조금씩 달라서 hasattr()로 분기하는 코드가 필요했는데, "인터페이스는 통일돼 있어도 내부 구현까지 완전히 같지는 않다"는 걸 확인한 지점이었다.
OutputParser는 CommaSeparatedListOutputParser부터 PydanticOutputParser까지 네 종류를 비교했다. 특히 책 목록처럼 중첩된 구조(리스트 안에 객체, 그 안에 필드)를 PydanticOutputParser로 파싱해보고 나서, 마지막엔 llm.with_structured_output(BookList) 한 줄로 파서 없이 바로 구조화된 값을 받는 방법을 봤다. 파서를 따로 만들 필요 없이 모델이 스키마를 보고 알아서 맞춰준다는 게, 지난주 JSON 출력을 강제하려고 프롬프트에 스키마를 길게 적어 넣던 것보다 훨씬 깔끔했다.
chain = prompt | llm | parser
result = chain.invoke({'topic': '오늘 읽을 만한 책'})이 | 문법이 이번 주 내내 반복해서 등장하는 걸 여기서 처음 봤다.
03. LCEL — 파이프로 체인 짜기
LCEL(LangChain Expression Language)이 방금 본 | 문법의 정체였다. 프롬프트, 모델, 파서, 심지어 순수 함수까지 전부 Runnable이라는 공통 인터페이스를 갖고 있어서, |로 이으면 그대로 하나의 체인이 됐다.
RunnableLambda로 일반 함수를 체인에 끼워 넣을 수 있었다. 제너레이터 함수를 넣고.stream()으로 돌리면 토큰 단위로 조각조각 나온다는 것도 확인했다RunnableParallel로 n행시·농담·시 세 체인을 동시에 실행해 dict 하나로 합친 뒤, 그 결과를 다시RunnableLambda로 후처리하는 예제가 재밌었다. 여러 체인을 순서대로 기다릴 필요 없이 한 번에 병렬로 던질 수 있다는 게 새로웠다RunnablePassthrough.assign(n=lambda x: len(x['topic']))으로 입력값에서 계산한 필드를 그 자리에서 추가해 다음 단계로 넘기는 패턴도 봤다. 입력한 주제의 글자 수만큼 자동으로 n행시의 n을 정하는 식이었는데, 체인 중간에 "계산"을 끼워 넣을 수 있다는 걸 체감했다
.invoke() / .batch() / .stream() 세 가지 실행 방식이 모든 Runnable에 동일하게 붙어 있다는 것도 정리하고 넘어갔다. 체인을 한 번 짜두면 실행 방식만 바꿔서 단건·배치·스트리밍을 골라 쓸 수 있는 구조였다.
04. Retrieval — 검색기를 체인에 끼우기
RAG의 검색 절반을 이루는 부품들을 하나씩 봤다. 순서가 재밌었는데, 벡터 검색을 배우기 전에 환각(hallucination)이 왜 생기는지(학습 데이터의 한계, 확률적 생성, 모호한 프롬프트)와 그걸 어떻게 줄이는지(RAG, 프롬프트 구체화, 후처리 검증, 파인튜닝)를 먼저 정리했다. "검색을 왜 붙이는가"에 대한 답을 먼저 듣고 나서 방법을 배우는 순서라, 뒤에 나오는 코드들이 그냥 API 사용법이 아니라 목적이 있는 도구로 읽혔다.
WebBaseLoader로 뉴스 페이지를,PyPDFLoader로 35페이지짜리 PDF를 불러와서 로더 종류에 따라Document객체가 어떻게 채워지는지 비교했다- 임베딩 모델도
OpenAIEmbeddings(1536차원)와HuggingFaceEmbeddings(384차원)를 나란히 써봤다. 같은 문장이라도 모델에 따라 벡터 차원 자체가 다르다는 걸 숫자로 확인했다 - FAISS로 인덱스를 만들고 로컬에 저장했다가 다시 불러오는데,
load_local()에allow_dangerous_deserialization=True를 명시적으로 켜야 했다. 내부적으로 pickle을 쓰기 때문에 아무 파일이나 불러오면 위험할 수 있다는 안전장치였다
가장 인상적이었던 건 마지막 실습이었다. 음식 리뷰 1,000건을 FAISS.from_texts()로 인덱싱해두고 "신선한 야채 판매처 리스트 알려줘"라고 물었더니, 리뷰 데이터에는 그런 정보가 없으니 모르겠다고 정확히 답했다. 검색기를 체인에 끼우는 이유가 "모르는 걸 아는 척하지 않게 만드는 것"이라는 게 결과로 확인된 순간이었다.
retriever = vector_db.as_retriever(search_type='similarity', search_kwargs={'k': 3})
chain = ({'question': RunnablePassthrough(), 'context': retriever | format_docs}
| prompt | llm | output_parser)이 한 줄이 이번 주에 배운 걸 다 모아놓은 문장 같았다. Runnable, 검색기, 프롬프트, 파서가 전부 파이프 하나로 이어져 있었다.
05. Agent와 Tool — 도구를 고르고, 기억하고, 걸러내기
지난주에 배운 Function Calling이 LangChain에서는 Agent+Tool로 확장됐다. 기본 그림은 같았다. 모델이 어떤 도구를 어떤 인자로 부를지 고르고, 실행은 코드가 한다. 다만 여기에 ReAct 패턴(생각→행동→관찰을 반복)과 메모리, 미들웨어까지 얹히면서 훨씬 실전에 가까운 형태가 됐다.
- Memory는
InMemorySaver(휘발성)와SqliteSaver(파일로 영구 저장)를 비교했다.thread_id로 대화 세션을 구분해서, 같은 에이전트라도 세션이 다르면 서로의 대화를 모른다는 걸 확인했다 - Middleware가 이번 주에 새로 알게 된 개념이었다.
SummarizationMiddleware는 대화가 길어져 토큰이 임계치를 넘으면 자동으로 요약해서 줄여주고,PIIMiddleware는 이메일·카드번호·API 키 같은 패턴을 정규식으로 찾아 자동으로 가려줬다. 일부러 가짜 이메일과 카드번호 형식 문자열을 넣어 실제로 레닥션되는지 테스트해봤는데, 지난주 모더레이션에서 본 "실서비스 사고를 막는 장치"와 같은 결이었다 agent.stream(stream_mode='messages')로 토큰 단위 스트리밍도 다시 확인했다
@tool
def get_current_weather(city_name: str, units: str = 'metric') -> str:
'''도시의 현재 날씨를 반환한다. city_name은 영문으로 입력할 것.'''
...
agent = create_agent(model=model, tools=[get_current_weather, calculate_age],
checkpointer=SqliteSaver.from_conn_string('checkpoint.db'))도구를 붙이는 실습으로 날씨 API, 현재 시각(pytz로 KST), 생년월일로 만나이 계산까지 만들어봤다. 계산기 도구에는 일부러 48÷2(9+3)처럼 사람마다 답이 갈리는 모호한 수식을 던져봤는데, 도구 하나를 붙이는 것과 그 도구가 실제로 신뢰할 만하게 동작하는 건 별개 문제라는 걸 새삼 느꼈다.
06. Neo4j — 벡터가 아니라 그래프로 검색하기
지금까지의 검색은 전부 "의미가 비슷한 문서 찾기"였는데, 여기서는 결이 다른 검색을 봤다. 학생-강의-강사-카테고리로 이루어진 미니 교육 도메인을 그래프 DB인 Neo4j에 넣고, 관계를 따라가며 답을 찾는 방식이다.
순수 드라이버로 driver.execute_query()를 직접 써보는 것부터 시작해서, 그다음엔 GraphCypherQAChain으로 넘어갔다. 자연어 질문을 넣으면 모델이 알아서 Cypher 쿼리를 만들고, 실행하고, 결과를 다시 자연어로 정리해서 답해주는 원스톱 체인이었다.
chain = GraphCypherQAChain.from_llm(
llm=ChatOpenAI(temperature=0), graph=graph,
verbose=True, allow_dangerous_requests=True)temperature=0으로 둔 이유를 "DB에 있는 정보를 그대로 가져올 것이므로 창의성은 0으로"라고 스스로 남겨둔 게 마음에 들었다. verbose=True로 켜두니 모델이 실제로 만든 Cypher 쿼리가 그대로 보여서, 자연어 질문이 어떤 그래프 순회로 번역되는지 눈으로 따라갈 수 있었다. allow_dangerous_requests=True도 필요했는데, FAISS의 allow_dangerous_deserialization과 마찬가지로 "편한 기능일수록 위험을 인지하고 명시적으로 켜라"는 패턴이 여기서도 반복됐다.
스키마를 Cypher로 직접 짜면서 MERGE ... ON CREATE SET ... ON MATCH SET 패턴도 처음 써봤다. 같은 스크립트를 몇 번 실행해도 노드가 중복 생성되지 않게 하는 방식인데, 파이썬의 "있으면 갱신, 없으면 생성" 로직을 DB 언어 차원에서 한 줄로 표현한 셈이었다.
07. 2-Stage RAG로 완성하기
RAG를 Indexing(문서 로드 → 분할 → 벡터 저장)과 Retrieval&Generation(검색 → 답변 생성) 두 단계로 나눠서 처음부터 끝까지 완성했다. 실습 자료로 백설공주 동화 PDF를 썼는데, 다 아는 이야기라서 모델이 지어낸 답인지 실제 근거가 있는 답인지 눈으로 바로 판단할 수 있다는 게 좋은 선택이었다.
RecursiveCharacterTextSplitter로 문단→줄바꿈→공백 순서로 텍스트를 쪼개는 걸 chunk_size를 작게 잡고 직접 눈으로 확인했다. 청크가 너무 작으면 문맥이 끊기고, 너무 크면 관련 없는 내용까지 딸려온다는 감각을 실습으로 잡았다- 이번엔 FAISS 대신
Chroma를 처음 써봤다.persist_directory를 지정하면 로컬 폴더에 그대로 저장되는 방식이라 FAISS의 save/load보다 더 자연스럽게 느껴졌다 - 프롬프트에 "유치원 교사" 페르소나를 부여해서 따뜻한 말투로, 그리고 반드시 주어진 문맥 안에서만 답하도록 지시했다. "왕비와 백설공주 중 누가 더 못생겼나"처럼 곤란한 질문을 던져서, RAG 체인은 문맥에 없다고 답하고 일반 챗 모델은 아예 답변을 거부하는 걸 나란히 비교해봤다
retriever_tool = tool(retriever.invoke, name='snow_white_search',
description='백설공주 동화 내용을 검색한다')
agent = create_agent(model=model, tools=[retriever_tool], system_prompt=...)마지막엔 검색기 자체를 도구로 감싸서 에이전트에게 넘겼다. 체인으로 고정해두는 대신, 에이전트가 필요하다고 판단할 때만 검색을 호출하는 구조다. "인어공주와 백설공주 중 누가 더 아름답나"처럼 문맥에 없는 인물을 슬쩍 섞은 질문에도 없는 내용을 지어내지 않는 걸 확인하고 나서야, 지난주부터 이어진 "환각을 어떻게 막을 것인가"라는 질문에 이번 주 나름의 답을 낸 느낌이 들었다.
08. 이미지도 보는 프롬프트 — AI 와인 소믈리에
주 후반에는 텍스트뿐 아니라 이미지까지 입력으로 받는 프롬프트를 다뤄봤다. 음식 사진을 보여주면 어울리는 와인을 추천해주는 프로젝트였는데, 벡터 검색을 붙이기 전 단계라 정확히는 RAG라기보다 멀티모달 프롬프트 엔지니어링에 가까웠다.
('human', [
{'type': 'text', 'text': '다음 요리에 어울리는 와인을 추천해줘: {query}'},
{'type': 'image_url', 'image_url': {'url': '{image_url}'}},
])공을 들인 건 프롬프트 쪽이었다. "맛있다" 같은 추상적인 표현 대신 "벨벳처럼 부드러운", "침샘을 자극하는 쨍한 산미"처럼 감각적인 표현을 쓰도록 지시하고, 리슬링·까베르네 소비뇽·샴페인 세 가지 few-shot 예시를 프롬프트에 미리 심어뒀다. 스테이크·탕수육 같은 음식 사진을 바꿔가며 넣어보니, 사진 속 재료와 조리법을 읽고 그에 맞는 와인 특징을 짚어내는 게 신기했다. 다음 단계로 와인 데이터베이스를 벡터로 검색해서 실제 제품까지 추천하는 걸 붙이면 완성될 것 같은데, 이번 주엔 여기서 멈췄다.
KPT 회고
Keep
지난주에 API를 직접 두들겨봤던 게 이번 주에 그대로 밑천이 됐다. 코사인 유사도로 손수 검색을 만들어봤으니 as_retriever()가 그 위에 얹은 껍데기라는 게 바로 읽혔고, Function Calling을 직접 구현해봤으니 Agent+Tool의 다섯 단계(도구 정의 → 모델이 선택 → 코드가 실행 → 결과 재전달 → 모델이 정리)가 낯설지 않았다. 프레임워크를 배울 때 "이게 뭘 대신 해주는 건지"가 바로 보이니까 API 표를 외우는 느낌이 아니라 기존에 짜본 코드를 정리해주는 느낌으로 받아들여졌다.
RAG 여러 실습에서 "문맥에 없으면 모른다고 답하기"를 계속 의도적으로 테스트해본 것도 좋았다. 되는 걸 확인하고 넘어가는 대신 일부러 곤란한 질문·문맥 밖 질문을 던져보는 습관이 자리 잡은 것 같다.
Problem
노트북에 에러를 그대로 남겨두고 넘어간 게 여러 번이었다. Wikipedia 도구에서 JSONDecodeError가 두 번 났는데 두 번 다 완전히 원인을 잡지 못한 채 넘어갔고, arxiv 도구도 처음엔 load_tools()로 시도했다가 에러가 나서 직접 만든 함수로 우회했을 뿐 왜 안 됐는지는 깊게 안 팠다. LangChain이 v1.0으로 넘어가는 전환기라 예제와 실제 동작이 어긋나는 경우가 잦았는데, "버전 문제겠거니" 하고 우회로만 찾은 게 몇 번 있었다.
와인 소믈리에 프로젝트는 이름과 다르게 검색 로직 없이 프롬프트만으로 끝났다. 시간 배분상 이번 주엔 여기까지였는데, RAG를 방금 배워놓고 정작 응용 프로젝트에는 못 붙인 게 아쉬웠다.
Try
와인 소믈리에에 실제 와인 데이터로 벡터DB를 붙여서, 이미지로 요리를 인식하고 그 결과로 문맥 검색까지 이어지는 완전한 RAG로 만들어보고 싶다. 이번 주에 배운 2-Stage RAG 패턴을 그대로 옮기면 될 것 같다.
미뤄뒀던 에러들도 재현해서 근본 원인을 확인해볼 생각이다. 특히 Wikipedia 도구의 JSONDecodeError는 langchain-community 쪽 문제인지 내 쪽 요청 형식 문제인지부터 구분해야 할 것 같다.
새롭게 발견한 것
RAG는 검색 기능이 아니라 "모른다고 말하게 하는" 장치에 더 가까웠다. 이번 주 여러 실습에서 반복된 건 검색이 잘 되는지가 아니라, 문맥에 없는 질문을 던졌을 때 모델이 지어내지 않고 모른다고 답하는지였다. 백설공주 동화에 없는 "인어공주"를 슬쩍 섞은 질문에도 없는 내용을 만들어내지 않는 걸 보고서야, 지난주 파인튜닝 비교에서 틀린 전제를 그대로 받아 답을 지어냈던 문제가 RAG로 어떻게 줄어드는지 실감났다.
검색기도 결국 도구 하나였다. 처음엔 검색(Retrieval)과 도구 호출(Function Calling)을 별개로 배웠는데, 2-Stage RAG 마지막에 retriever를 그대로 @tool로 감싸서 에이전트에 넘기는 걸 보고 두 개가 같은 틀 안에 있다는 걸 알았다. "고정된 체인으로 항상 검색한다"와 "에이전트가 필요할 때만 검색이라는 도구를 쓴다"는 완전히 다른 설계였다.
"위험을 명시적으로 동의하라"는 플래그가 여러 곳에서 똑같이 나왔다. FAISS의 allow_dangerous_deserialization과 그래프 체인의 allow_dangerous_requests가 서로 다른 기능인데도 같은 이름 패턴을 쓰고 있었다. 편의 기능일수록 사용자가 위험을 인지하고 켜게 만드는 게 LangChain 생태계 전반의 관례라는 게 느껴졌다.
배우는 시점 자체가 프레임워크의 과도기였다. v1.0 출시 직후라 예전 방식(LLMChain, AgentExecutor)과 새 방식(create_agent, LangGraph)이 문서마다 섞여 있었다. 처음엔 헷갈렸는데, 반대로 생각하면 "왜 이렇게 바뀌었는가"를 실제로 목격하면서 배우는 셈이라 각 컴포넌트가 왜 필요한지가 더 또렷하게 남았다.
트러블슈팅
문제 : load_tools()로 불러온 arxiv 도구가 AttributeError를 냈다
논문을 검색하는 도구를 붙이려고 load_tools(['arxiv', 'wikipedia'])로 간단히 불러왔는데, 실행하자 AttributeError: object has no attribute 'status'가 났다. langchain-community 쪽에 있는 arxiv 통합이 최신 arxiv 라이브러리와 어긋나 있는 게 원인으로 보였고, 실제로 같은 노트북에 "langchain-community is being sunset and is no longer actively maintained"라는 경고 문구도 같이 떠 있었다.
완전체 통합을 고치는 대신, arXiv API를 직접 호출하는 함수를 @tool로 감싸서 대체했다.
@tool
def search_arxiv(query: str) -> str:
'''arXiv에서 논문을 검색해 제목과 요약을 반환한다.'''
results = arxiv.Search(query=query, max_results=3).results()
return '\n\n'.join(f'{r.title}\n{r.summary[:200]}' for r in results)미리 만들어진 통합 도구가 편하긴 하지만, 유지보수가 끊긴 패키지에 기대는 순간 내가 고칠 수 없는 에러를 떠안게 된다는 걸 알았다. 직접 API를 부르는 몇 줄이 오히려 더 예측 가능하고 고치기 쉬웠다.
문제 : SqliteSaver를 with 블록 밖에서 재사용하려다 닫힌 DB에 접근했다
대화 히스토리를 파일로 영구 저장해보려고 SqliteSaver.from_conn_string()을 with 블록 안에서 만든 뒤, 그 안에서 만든 checkpointer 객체를 블록 밖으로 꺼내 다음 셀에서 다시 썼다. 그랬더니 ProgrammingError: Cannot operate on a closed database가 났다.
with SqliteSaver.from_conn_string('checkpoint.db') as saver:
agent = create_agent(model=model, tools=tools, checkpointer=saver)
# with 블록을 벗어나는 순간 커넥션이 닫힌다
agent.invoke(...) # 블록 밖에서 다시 쓰면 여기서 에러with는 블록을 벗어나는 순간 커넥션을 정리한다는 걸 알고는 있었는데, checkpointer를 만드는 코드와 실제로 쓰는 코드가 서로 다른 셀에 떨어져 있으니 그 사실을 깜빡했다. checkpointer를 만드는 시점과 에이전트를 실제로 호출하는 시점을 같은 with 블록 안에 몰아넣고 나서야 해결됐다. 노트북 환경은 셀을 자유롭게 오가며 실행할 수 있다 보니, 리소스의 생명주기가 셀 경계와 안 맞는 실수가 나오기 쉽다는 걸 새로 알았다.
다음 주 목표
- 와인 소믈리에 프로젝트에 실제 벡터 검색을 붙여서 이미지 인식부터 추천까지 끝까지 이어지는 RAG로 완성하기
- Wikipedia·arxiv 도구에서 반복된 에러를 재현해서 langchain-community 쪽 문제인지 내 요청 형식 문제인지 구분하기
- Neo4j 실습에서 만든 그래프 스키마의 관계 데이터를 다시 검토하고, 벡터 검색과 그래프 검색을 한 체인에서 같이 써보기
- 계속 미뤄온 트랜스포머 마스킹 구현, 이번 주도 넘어갔으니 다음 주엔 진도와 별개로 시간을 따로 잡기
체인으로 엮는 법을 배운 한 주였지만, 결국 배운 건 "무엇을 어디에 검색하러 보낼지 판단하는 법"이었다.