ai technology

RAG(Retrieval Augmented Generation) 개념 정리 및 LangChain의 기능과 사용법 정리

Junyoung Park · 2026-01-23 · 23 min

LLM의 대중화

LLM은 놀라운 성능으로 시장에서 그 가치를 증명해냈다. 초창기 ChatGPT는 인공지능을 하는 사람들에게나 “와, 이제는 Autoregressive한 모델이 이 정도 성능까지 올라왔구나”, 채팅을 하고, 주고 받는 ‘대화를 통해 문맥을 쌓아갈 수 있다’는 점으로 신기하게 다가왔다. 언제까지나 OpenAI도 그때까진 ‘학계의 인공지능’의 연장선에 있는 사업이고, 사실상 이 기술이 어떻게 세상을 설득할 지 크게 동요하지는 않았다.

하지만 얼마 지나지 않아 ChatGPT에도 눈과 귀가 달리기 시작했다. 성능을 높이는 방법이 쌓였고, 대량의 고품질 데이터와 여러 AI 모델을 연결한 서비스도 등장했다. AI 모델이 학습 데이터에서 그저 ‘애매한’ 성능만 내던 시기는 빠르게 지나갔다.

  • 더 이상 우리는 LLM의 도움 없이는 효과적으로 글을 읽을 수 없다.
  • 더 이상 우리는 LLM의 도움 없는 코딩으로 효율적인 업무를 수행하기 어렵다.

이제는 AI 사용이 취향의 차이가 아니라, 경쟁력의 차이를 빠르게 만들어내기 시작한 것이다.

LLM의 한계점

그럼에도 불구하고 LLM은 우리의 의도와는 다르게 동작하는 경우가 많다. 특히 LLM은 ‘모른다고 대답하지 않는다’가 가장 큰 문제점으로 드러난다.

예를 들어 당신이 다음과 같은 질문을 받는다고 생각해보자,

블루투스 샤워기를 만드는 방법을 알려줘!

당신이 블루투스 샤워기에 대한 밈(meme)을 알고 있거나, 기본적인 교육을 받고 자란 사람이라면 ‘블루투스 샤워기’가 왜 불가능한 것인지 알고 있을 것이다.

모른다고 해도 정말 간단하게 설명하면, 샤워기는 물을 전달할 통로가 있어야하고, 이 통로는 블루투스로 전달하는 ‘전자기파’ 따위가 아니라 액체인 ‘물’을 전달해야하기 때문에 도저히 유선 말고는 답이 없기 때문이다!

예시가 부적절할 수 있겠지만, 아무튼 ‘블루투스 샤워기’와 같이 불가능한 기술의 조합(블루투스 + 샤워기)이 왜 불가능한지에 대한 논리적인 추론은 LLM이 가지고 있지 않다(물론, LLM에 이미 블루투스 샤워기에 대한 meme이 학습된 상태라면 얘기가 조금 다를 수 있겠지만). 어찌 되었든 LLM은 다음의 논리로 학습되었고, 다음의 논리로 동작하기 때문이다:

현재까지 구성된 문맥 기반으로 다음에 올 가장 높은 확률의 문단을 구성한다.

고로 ‘블루투스’와 ‘샤워기’라는 의미적 개념을 종합하여, 최대한 그럴듯한 ‘블루투스 샤워기’에 대한 개소리를 뱉어낼 것이 분명하다.

내가 현재 말하고자 하는 부분은 ‘불가능한 기술’을 구현해내기 위해 거짓말을 한다는 단편적인 예시가 아니다. 의외로 여기서 내가 말하고자 하는 부분은 ‘인간도 충분히 그럴 수 있지 않은가?’에 focusing한다.

똑똑한 사람은 본인이 분명히 알고있는 부분과 알지 못하는 부분을 명확하게 구분할 수 있다. 하지만 똑똑한 사람도 그렇지 않은 경우가 많다. 살면서 처음 들어간 면접장에서 갑자기 당황스러운 질문이 들어왔을때, 분명 제대로 알지도 못하는 내용에 대해 어떻게든 말을 이어가며 면접을 조지는 내 자신을 발견하게 된다.

혹은 면접을 조지는 상황이 아니더라도 전혀 모르는 내용이지만 ‘어 그거 어디선가 들어본 것 같아’라며 사실에도 없는 내용을 내뱉거나, 우리는 가끔 다른 사람이 낸 소문을 본인의 자아로 심하게 왜곡하여 이상하게 전달하기도 한다. ‘기억’은 했지만 그대로 ‘재생산’에는 실패했다는 것이다.

결국 LLM은 수많은 도큐먼트의 데이터로 토큰의 연속성을 통계화한 기억 장치에 해당하지, 검색 모델이 아니다. 기억의 저편에 잠들어있는 단어 하나를 꺼낼 때, 그 단어와 어쩔 수 없이 엮인 수많은 다음 단어들의 기억들에 의해 억지로 끌려나온 다음 토큰이 연쇄적으로 그 다음 토큰을 끄집어 내며 문단을 구성한다. 결국 이런 식으로 구성된 문장은, 애초에 시작부터 잘못된 질문이었더라도 ‘모른다’고 할 수 없게 되는 것이다. “알고 보니까 이건 모르는 일이었네”라고 할 때쯤에는 이미 완성된 문장이 있기 때문이다.

문맥 파악 능력

이처럼 LLM은 근본적으로 한계점이 명확하다. 만약 우리가 NN시점까지의 데이터를 학습했다면 1) 해당 시점까지의 데이터에 기반한 추론만 가능하다는 한계점과, 2) 해당 시점까지의 데이터를 학습했다고 하더라도, 모든 형태의 질문에 제대로 대답해줄 확신은 없다. 우리도 분명 중/고등학교때 다양한 과목을 들었음에도 불구하고, 그때 들었던 모든 과목에 대한 시험 문제에 100점을 맞을 수는 없기 때문이다.

GPT도 2024년 6월이 마지막 general knowledge update라고 한다. 그렇다면 이 친구도 2024년 이후에 있는 일을 알지 못한다(예를 들면 지금 현재 KRW to USD 환율이 얼마인지).

결국 우리가 ChatGPT를 충분히 신뢰하고 사용하기 위해서는 ChatGPT의 통계를 효과적으로 사용할 수 있는 방법을 개발해야하고, 바로 여기서 나온 관점이 In-Context Learning(ICL), 그리고 RAG이다.

우리는 중/고등학교때 들은 다양한 과목에 대해 지금 당장 시험을 볼 수 없지만, 만약 지금 우리에게 시험 범위에 해당되는 교과서들과 관련 자료들을 주고 오픈북 테스트를 친다면, 적어도 0점으로 민망해질 일은 없을 것이다. 또한 우리가 알지 못하는 내용에 대해서는 더욱이 시험을 볼 수 없지만, 우리에게 마찬가지로 참고할 자료를 준다면 시간은 꽤나 오래 걸리겠지만 내용을 참고해서 적당히 신뢰성이 있는 대답을 할 수 있을 것이다. 여기서 ‘오픈북’이라는 관점에 우리는 집중한다.

문맥 파악에 최적화된 모델

LLM은 문맥에서 단서를 찾는 능력이 뛰어나다. LLM을 학습시킨 데이터 내에 특정 전문성에 대한 데이터가 없더라도, LLM은 이를 유추할 수 있다. 학습에 사용되는 전체 코퍼스(데이터셋)를 C\mathcal{C}라 하고, 코퍼스는 NN개의 문서(샘플) D(k)D^{(k)}로 이루어진다고 두자:

C  =  {D(k)}k=1N.\mathcal{C} \;=\; \{\, D^{(k)} \,\}_{k=1}^{N}.

각 문서 D(k)D^{(k)}는 토크나이징/파싱된 단위(토큰, 문장, 청크 등) ti(k)t^{(k)}_i의 순서열로 표현한다:

D(k)  =  (t1(k),t2(k),,tnk(k)),nk  =  D(k).D^{(k)} \;=\; \bigl(t^{(k)}_{1},\, t^{(k)}_{2},\, \dots,\, t^{(k)}_{n_k}\bigr), \qquad n_k \;=\; |D^{(k)}|.

특정 문서 하나만 놓고 보면(예: D:=D(1)D := D^{(1)}),

D  =  (t1,t2,,tn),n  =  D.D \;=\; (t_1, t_2, \dots, t_n), \qquad n \;=\; |D|.

Corpus에 대한 연속성 학습

각 문서 D(k)=(t1(k),,tnk(k))D^{(k)}=(t^{(k)}_1,\dots,t^{(k)}_{n_k})에 대해, 모델 pΘp_\Theta는 시퀀스 확률을 다음처럼 분해해서 학습한다.

pΘ ⁣(D(k))  =  pΘ ⁣(t1:nk(k))  =  i=1nkpΘ ⁣(ti(k)t1:i1(k)).p_\Theta\!\bigl(D^{(k)}\bigr) \;=\; p_\Theta\!\bigl(t^{(k)}_{1:n_k}\bigr) \;=\; \prod_{i=1}^{n_k} p_\Theta\!\left(t^{(k)}_i \mid t^{(k)}_{1:i-1}\right).

그리고 전체 코퍼스에 대한 MLE(=NLL 최소화) 목표는 보통 다음과 같이 삼는다.

Θ=argmaxΘk=1Ni=1nklogpΘ ⁣(ti(k)t1:i1(k)).\Theta^\ast= \arg\max_\Theta \sum_{k=1}^{N}\sum_{i=1}^{n_k} \log p_\Theta\!\left(t^{(k)}_i \mid t^{(k)}_{1:i-1}\right).

LLM은 다음 토큰 분포를 갱신한다

모델은 토큰을 연속적으로 생성해내는 매 step마다 FΘ(t1:j):=pΘ(t1:j)F_\Theta(t_{1:j}) := p_\Theta(\cdot \vert t_{1:j})의 앞선 ‘문맥’에 대한 ‘다음 토큰’의 조건부 분포를 갱신해나가게 된다. 이를 쉽게 풀어내면 앞 문맥이 바뀔수록 뒤에 나올 단어의 확률 분포가 달라진다는 것이다. 그리고 앞서 말했던 것처럼 이 조건부 확률 분포의 prior는 모든 corpus의 분포를 참고하게 된다.

원하는 답을 위해서는, 원하는 확률을 위한 prior를 키우는 것

사용자가 기대하는 답(텍스트)을 y=(y1,,ym)y = (y_1,\dots,y_m)라고 하면, 모델이 그 답을 생성할 조건부 확률은

pΘ(yx)==1mpΘ ⁣(yx,y1:1)p_\Theta(y \mid x) = \prod_{\ell=1}^{m} p_\Theta\!\left(y_\ell \mid x, y_{1:\ell-1}\right)

로 쪼개져 있다. 여기서 xx가 바로 문맥(프롬프트, prefix) t1:jt_{1:j}에 해당한다. 따라서 ‘원하는 답이 나올 확률’을 높이는 가장 직접적인 방법은 각 토큰 단계에서 정답 토큰 yy_\ell에 할당되는 확률을 높이는 것이고, 그걸 바꾸는 레버가 조건(문맥)xx라고 할 수 있겠다.

문맥의 풍부함으로 얻는 Power of Guidance

문맥 x=t1:jx=t_{1:j}를 더 풍부하게 주면, 모델이 고려해야 하는 가능한 의도/상황(도메인, 용어 정의, 출력 형식 등)의 후보 공간이 줄어들어 pΘ(x)p_\Theta(\cdot\mid x)가 우리가 원하는 모드(mode)에 더 가깝게 갱신된다. 직관적으로 이는 매 토큰 단계에서 정답 시퀀스 yy를 구성하는 토큰들에 할당되는 조건부 확률 pΘ(yx,y1:1)p_\Theta(y_\ell\mid x,y_{1:\ell-1})을 높이고, 모호성으로 인한 잘못된 분기 확률을 낮추는 효과로 나타난다.

특히 문맥이 제공하는 1) 용어/전제의 명시, 2) 역할 및 도메인 지정, 3) 출력 제약(포맷 및 규칙)은 가능한 출력 공간을 좁혀 생성 경로가 정답 경로로 수렴할 가능성을 키우므로, 결과적으로 문맥을 충분히 줄수록 원하는 답을 얻을 확률이 높아진다는 결론에 수렴하게 된다.

RAG의 필요성

그러나 여기서 단순한 문맥 늘리기의 맹점이 드러난다. 단순히 문맥 xx를 풍부하게 준다는 방향으로 간다면, prior가 명확하게 내가 얻고자 하는 분포와 관련되어 있다는 가정을 해야 한다. 하지만 실상은 그렇지 않다. 아무리 원하는 정보에 대한 명확한 가이드라인(용어, 조건 그리고 출력 조건)을 제시하더라도, 실제 답변을 위한 ‘정확한 정보’가 없다면 Hallucination은 발생하게 된다.

예컨대 오늘의 날씨를 물어보는 상황에서, 단순히 LLM이 충분한 정보를 얻지 못했다고 해서 ‘글쎄, 서울은 대충 겨울에는 춥고 건조한 기후기는 하지?’라며 애매한 답변을 내놓을 수 있지만 오히려 현재 서울의 날씨에 대한 공시 데이터가 잘못된 경우라면(KMA에서 데이터를 잘못 가져왔거나, 만약 데이터를 가져오는 API 단계에서 실시간 정보가 불가능한 경우) 그렇게 제공받은 문맥 정보로도 잘못된 답변을 출력할 수 있다. 이처럼 특정 정보에 대해 정확한 정보를 얻기 위해서는 단순히 학습 데이터에 내재된 분포에 의존하는 것이라 실제로 유용한 context를 구성하는 것이 필수적이라고 할 수 있다.

Retrieval Augmented Generation(검색 증강 생성)의 의미를 보다 자세히 뜯어보면 다음과 같다: 딥러닝에서 Augmented란 “데이터를 풍부하게 만드는 작업”을 의미한다. 원래의 딥러닝은 ImageNet과 같이 이미지를 분류하는 Task같이 심플하면서도 상대적으로 데이터셋 크기가 크지 않은 환경에서 연구되었다. 딥러닝에서 학습 데이터가 부족하다는 것은 모델 크기에 무관하게 “일반화 능력”이 떨어지는 문제를 가져올 수 있기 때문에, 학습 시 이러한 “과적합(Overfitting)” 문제를 피하기 위해 휴리스틱하게 보유하고 있는 데이터셋을 증강시키는 여러 방법들(상하좌우 반전시키기, 그림 rotation, 그림 crop, … etc)이 제안 되었다 (이미지 출처).

그러나 이러한 형식의 Augmentation은 점차 데이터셋 크기가 증가하고 모델의 일반화 능력이 높아지기 시작하면서 그 중요성이 줄어들기 시작했다. 하지만 수많은 데이터로 학습된 LLM과 같은 대형 모델도 완전히 문제가 없는 것은 아니었다.

사실 기존의 Computer Vision에서 일반화 능력을 강조하기 위해 데이터를 증강했던 것은, 충분하지 않은 데이터로부터 feature의 통계(e.g. 고양이의 특징)을 뽑아내기엔 부족했기 때문이다. 그러나 충분히 많은 데이터가 있더라도, 내가 모델 추론에 원하는 내용이 데이터셋 범위에 들어가지 않는 경우가 많다.

가장 흔히 생각해볼 수 있는 것은, 나는 2026년 1월 기준의 주식 시장을 분석하고 싶은데 만약 학습된 데이터에 2024년까지의 주식 시장에 대한 정보가 들어가있다면, 대략적이고 일반적인 형식의 대답은 가능하지만 2025~2026년 새로 상장된 주식이나 그 사이에 있던 추가 정보를 반영하지 못하기 때문에, 내가 원하는 대답을 해줄 수 없을 확률이 높다.

그렇다면, 가장 심플하게 생각할 수 있는 부분은 단순히 지금 내가 필요한 데이터들을 지속적으로 원래 데이터 범위에 추가해서, 이를 사용해서 모델을 학습시키면 되지 않은가에 대한 내용이다. 그러나 만약 그렇다 가정하면 2026, 2027년 이렇게 시간이 지날수록 그 이전의 데이터들을 모두 확보해야하고, 매번 그 데이터들을 학습해야하는 문제가 생긴다.

심지어는, 단순히 증시 데이터 뿐만 아니라 우리는 사실상 LLM이 만능인 상태를 원하기 때문에, 2026, 2027년 지속 반영되는 새로운 정책들, 금융 상품 정보(적금 및 은행 상품 비교), 여행지 맛집 정보 등등 사실상 계속 데이터들을 모으기 어려운 범위의 내용까지 커버해야 한다. 그리고 이러한 데이터들을 모아서 결국 모델을 학습시킬때, 데이터셋 크기가 커질수록 모델 학습에 걸리는 시간은 기하급수적으로 증가한다. 결국 이러한 관점에서 우리는 “완전히 일반화된” 모델을 설계할 수는 없다. 다만 LLM은 그저 충분히 많이 확보한 언어 데이터로부터 언어가 구성되는 체계와 문법 규칙, 문맥 등을 학습한 것이다. 이러한 구조적 특성과 능력 이상으로 모델을 고도화할 수 있는 방법은 아직 연구되지 않았다.

LLM과 같은 초거대 모델과 데이터셋에서는 ‘Augmentation’의 의미도 달라진다. LLM은 많은 언어 데이터에서 문법과 문맥을 구성하는 방법을 이미 학습했으므로, 필요한 정보를 검색해 줄글 형태로 제공하는 것만으로도 답변의 근거를 보충할 수 있다.

LLM은 기억하는 모델이므로 2027년 주식 정보나 금융 상품에 대한 내용을 알지 못한다. 그러나 우리가 이에 대한 내용을 문서로 정리하고, 이를 요약해달라고 하면 이를 성실히 수행해낸다. 비슷하게는 외부에서 획득할 수 없는 사규 데이터나 ERP 시스템 정보들은 오픈 소스 모델에 학습되어 있지 않다. 우리는 오픈 소스 모델을 굳이 다시 On-prem 환경에서 학습하지 않더라도 이러한 정보들을 하나의 문서 형태로 정리하고 해당 문맥(context)을 기반으로 LLM의 언어 이해 능력을 활용하는 것이다.

이러한 워크플로우를 그대로 풀어쓰면, Retrieval(검색) → Augmented(증강) → Generation(생성)이 된다.

LangChain

그렇다면 LangChain은 무엇일까? 앞서 말했던 RAG의 경우에는 하나의 모델에서 잘못된 정보 생성 없이 원하는 (정확한) 대답을 잘 이끌어낼지에 대한 부분이고, 단일 모델에 대한 context engineering을 고려한 이후 생각할 문제는 여러 AI 모델들과 데이터베이스(DB), 클라우드 등을 어떻게 유기적으로 연동할 것인가에 대한 부분이다.

ChatGPT만 고수하던 시대는 이미 끝났다. 초반에 강세였고 따라잡을 수 없을 것만 같던 OpenAI의 모델을 곧 수많은 후발 주자들의 추격에 1위의 자리를 내어주게 되었다. Google Gemini, xAI의 Grok 등등. 여기서 말하는 대부분의 LLM 서비스는 ‘일반화’된 다양한 Task에 좋은 성능을 보인다.

그러나 우리가 어떤 서비스를 기획할 때에, 굳이 이렇게 모든 Task에 대해 좋은 성능을 보이는 모델이 필요할까? 만약 리소스가 충분하다면 오버스펙은 아니겠지만, 리소스가 충분하다고 해서 굳이 정규화된 작업에 일반화된 모델 리소스를 소모할 필요까지는 없을 것이다.

서비스 기획 단계에서 워크플로우가 흘러가는 파이프라인을 구축했다고 가정해보자. 그 워크플로우가 흘러가는 과정에서 여러 가지의 사이즈가 다른/특화 방식이 다른 LLM을 사용할 수도 있으며, 특정 데이터를 참조하는 등 Retrieval(검색) 과정이 들어갈 수도 있다. 검색 방식 또한 semantic search가 될 수도 있지만 특정 AI 인코더에 의존하는 벡터 DB 형태라면, LLM을 제외하고도 AI 모델에 대한 파이프라인은 또 추가적으로 요구될 수 있다.

이처럼 복합적인 백엔드 구조를 가지게 되는 경우, 가장 어려운 문제가 파이프라인을 구성하는 각 요소별 통신 규율인 프로토콜을 일치시키기 어렵다. 애써 각 모듈마다 프록시를 달아서 특정 통신 방식으로 일반화가 가능한 인프라를 구축했더라도, 만약 이후에 업데이트를 통해 모델이 바뀌거나 파이프라인 구조 개선을 통해 규격이 변화한다면 이에 맞춰서 다시 정규화를 수행해야하기 때문이다.

LangChain은 일종의 ‘드라이버’라고 생각하면 된다. 우리가 프린터를 설치할 때, 프린터의 원활한 동작을 위해 드라이버 설치를 했던 경험이 있을 것이다. 프린터 기기와 내 노트북 간의 “약속”이 발생한 것이다. 그리고 그 약속은 특별한 일이 없는 한 비슷한 계열의 디바이스에서는 동일한 형태를 가진다. LangChain은 이러한 개념을 보다 확장한다. LLM이 구성 요소로 들어가는 모든 형태의 백엔드 애플리케이션 단계에서 호환 가능한 인터페이스를 구축할 수 있다.

LangChain은 단순히 인터페이스만 제공하지는 않는다. 결국 LLM을 사용하기 위한 다양한 구성 요소(입출력, 연결 방식, 문서 인덱싱, 메모리, Agent 및 RAG) 등을 편리하게 사용할 수 있는 맥가이버 칼이라고 생각할 수 있다.

LLM 추상화(Abstraction)

LangChain은 추상화를 통해 LLM 프로그래밍, 보다 명확히 말하자면 LLM의 입출력 인터페이스를 간소화한다. 추상화의 가장 큰 목표는 사용자에게 하여금 불필요한 세부 내용을 숨겨 사용 편의성을 높이는 것이다. 우리가 현재 사용하고 있는 UI 기반의 컴퓨터도 마찬가지다. 폴더를 생성하고 삭제하고, 폴더를 옮기고. 압축을 풀기도 하고 응용 프로그램을 설치하기도 한다. 급히 메모할 내용이 있다면 빠르게 메모장을 열어서 간단하게 작성할 수도 있다.

그러나 사실 이렇게 우리의 직관에 “컴퓨터”라는 하나의 연산장치가 구조화되어 사용하기 편해졌던 이유는, 그 내부의 low-level 동작(파일 시스템의 inode, 커널의 프로세스 스케줄링, 디바이스 드라이버, 메모리 관리, 패킷 라우팅 등)이 운영체제(OS)와 다양한 추상화 계층 뒤로 감춰졌기 때문이다. 사용자는 “저장한다 / 실행한다 / 복사한다 / 설치한다” 같은 고수준 개념만으로 목적을 달성할 수 있고, 컴퓨터는 그 요청을 적절한 시스템 콜과 리소스 관리로 번역해 처리한다.

LLM을 다루는 일도 점점 이 방향으로 흘러가고 있다. 처음에는 프롬프트를 직접 쓰고, 모델에 던지고, 문자열을 받아 후처리하는 단순한 형태로 시작한다. 하지만 실제 제품/연구 단계로 가면 곧바로 복잡도가 올라간다.

  • 입력은 단순 텍스트가 아니라 시스템/유저/툴 메시지로 구조화되고
  • 출력은 답변 문자열이 아니라 JSON, 함수 호출, 중간 추론 결과, 출처 문서처럼 다층적이 되며
  • 모델 호출은 한 번으로 끝나지 않고 재시도, 라우팅, 캐싱, 스트리밍, 비용/지연 제어가 뒤따르고
  • 외부 정보가 필요해지면 RAG(검색-생성), 문서 분할, 임베딩, 인덱싱, 리랭킹까지 끌어안게 된다.

이런 상황에서 “LLM을 잘 쓴다”는 말은 곧 “LLM 중심의 애플리케이션을 안정적으로 조립한다”에 가까워진다. 그리고 LangChain이 말하는 LLM 추상화(Abstraction) 는 바로 이 지점에서 의미가 생긴다. LangChain이 말하는 추상화는 모델 호출을 숨긴다의 수준이 아니라, 모델 주변의 실행 규약까지 표준화하는 쪽에 더 가깝다. LangChain Expression Language(LCEL)에서 chain은 결국 Runnable 프로토콜을 따르는 객체고, 그래서 호출 방식이 invoke / stream / batch로 통일된다. “LLM을 함수처럼 다룬다”는 말이 여기서 실제 구현체가 된다.

from langchain_openai import ChatOpenAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser

model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt = PromptTemplate.from_template("{topic} 에 대해 3문장으로 설명해줘.")
chain = prompt | model | StrOutputParser()

# 1) invoke: 단건 호출
print(chain.invoke({"topic": "멀티모달"}))

# 2) stream: 토큰 단위 스트리밍
for chunk in chain.stream({"topic": "RAG"}):
    print(chunk, end="", flush=True)

# 3) batch: 여러 입력을 일괄 처리 (+ 동시성 제어 가능)
results = chain.batch(
    [{"topic": "LangChain"}, {"topic": "Vector DB"}, {"topic": "Agent"}],
    config={"max_concurrency": 3},
)
print(results)

위 코드는 LangChain의 핵심 아이디어인 Runnable 파이프라인을 그대로 보여준다. prompt | model | parser 형태는 단순히 문법이 예쁜 게 아니라, 입력 → 변환 → 실행 → 파싱을 “하나의 실행 그래프”로 고정시키는 방식이다.

  • PromptTemplate.from_template(...)
    • {topic} 같은 변수를 가진 프롬프트 함수를 만든다.
    • 즉, {"topic": "..."} → "최종 문자열 프롬프트"로 변환하는 단계다.
  • ChatOpenAI(...)
    • 실제 LLM 호출을 담당한다.
    • 입력으로는 문자열이 아니라 내부적으로 Chat Message 구조로 감싸져서 들어간다.
  • StrOutputParser()
    • 모델 출력은 보통 AIMessage 같은 객체인데, 여기서는 최종적으로 순수 문자열만 뽑아 쓰기 위해 파서를 붙인다.
    • 즉, “모델 출력 객체 → 문자열” 변환 단계다.
  • chain.invoke(...) / chain.stream(...) / chain.batch(...)
    • 중요한 건 “LLM 호출 방식이 바뀌어도 체인 인터페이스는 동일”하다는 점이다.
    • invoke: 단건 입력을 끝까지 흘려보내 최종 결과를 반환
    • stream: 같은 파이프라인을 실행하되, 중간 토큰/청크를 스트리밍으로 받음
    • batch: 입력 여러 개를 동일 그래프에 태워 병렬 처리(동시성 제어 포함)

여기서 중요한 것은 “LLM을 호출한다”는 사실보다, 표준화된 규약을 가진 프록시로 LLM을 함수처럼 다루는 구조다.

프롬프트(Prompt)

LLM에게 실제로 태스크를 전달하기 위한 수단이다. ChatGPT 등등 텍스트가 입력으로 들어가는 모든 AI에서 다음과 같이 원하는 결과를 위해 고생했던 경험이 있을 것이다:

원하는 결과를 얻기 위한 프롬프트에는 정확하지는 않지만 가장 원하는 형태의 출력을 보장해주는 일종의 “템플릿”이 있다. 그 템플릿에는 포맷이 포함될 수도 있고, 특정 문장이 들어갈 수도 있다. 아무튼 이러한 “템플릿”을 찾는 과정 자체는 꽤나 심플하지만 원하는 결과를 얻기까지의 시간과 노력이 들어간다. 이러한 컨텍스트를 LangChain에서는 제공해준다. 프롬프트를 ‘문장 잘 쓰기’로만 보면 노동집약이 되는데, LangChain에서는 프롬프트를 재사용 가능한 객체로 만들어서, (1) 입력 변수 관리, (2) 포맷 고정, (3) 체인 결합을 용이하게 한다. 특히 PromptTemplate는 “내가 찾은 템플릿”을 코드 레벨로 박제하는 장치에 가깝다.

from langchain_core.prompts import PromptTemplate

template = """너는 방송 편성/아카이브 시스템의 메타 분석가다.
질문: {question}
조건: {constraints}

출력 포맷(JSON):
{{
  "period": "...",
  "programs": ["..."],
  "summary": "..."
}}
"""
prompt = PromptTemplate.from_template(template)

filled = prompt.format(
    question="2022년 9월~2025년 상반기 유재석 출연 프로그램 종류/컨셉 통계",
    constraints="기간은 반드시 반영, 중복 프로그램은 제거, 컨셉은 1문장 요약",
)
print(filled)

여기서 프롬프트는 “문장”이 아니라 입력 변수를 가진 템플릿 객체다. 로직은 간단하지만, 운영/확장 단계에서 이 단순함이 효율로 바뀐다.

  • template = """ ... {question} ... {constraints} ... """
    • 프롬프트 내부에 변수 슬롯을 만든다.
    • 이때 JSON 포맷까지 포함해두면, 모델이 출력 형태를 따라가게 유도할 수 있다(일종의 약한 스키마).
  • prompt = PromptTemplate.from_template(template)
    • “문자열 템플릿”을 LangChain이 다루는 프롬프트 객체로 만든다.
    • 이후엔 format()에서 변수 검증/치환이 일관되게 된다.
  • prompt.format(...)
    • {question}, {constraints}에 값을 채워 최종 프롬프트 문자열을 만든다.
    • 즉, 입력 구조화 → 문자열 생성 단계다.

추가로, 프롬프트에 매번 로컬에 들고 있지 않고 LangChain Hub에서 내려받아 사용할 수 있는 패턴도 있다. Docker hub에 이미지를 올려두는 것처럼 표준 프롬프트 형태를 팀 단위로 공유해서 쓸 경우에 용이하게 사용된다.

from langchain import hub

# 예: 요약 프롬프트를 hub에서 가져오기 (공유/버전관리 느낌)
prompt = hub.pull("teddynote/summary-stuff-documents-korean")
prompt.pretty_print()
  • hub.pull(...)
    • 팀/프로젝트 단위로 검증된 프롬프트를 재사용하는 흐름이다.
    • 로컬 복붙 대신, 버전 관리되는 프롬프트를 가져와 체인에 바로 연결하는 방식이라 운영에서 실수가 줄어든다.

체인(Chains)

체인은 LLM을 다른 요소들과 결합하는 것을 의미한다. 예를 들어, 사내의 시스템에 있는 DB에서 어떤 내용을 검색한 다음, 요약된 메타 정보를 사용하여 사용자의 질문에 답해야 하는 경우가 있다고 생각해보자. 우리 회사에서는 다음과 같은 질의가 될 수 있겠다:

2022년 9월부터 2025년 상반기까지의 방송분을 기준으로, 유재석이 나온 프로그램의 종류와 프로그램 컨셉에 대한 통계를 조사해줘.

결국 사용자의 요청은 특정 기간 범위 내의 DB를 조회하는 것 뿐만 아니라, 방송분에서의 프로그램 회차 메타를 정리해서 겹치지 않는 set 형태로 그 종류를 조사하고, 프로그램 컨셉에 대한 요약을 위해 또다른 LLM을 사용해야 할 수도 있을 것이다. 이처럼 한 태스크의 출력이 여러 순차적인 태스크로 결과를 넘기거나 추가 요청을 하게 되는 순차적 체인을 구성한다. 체인의 본질은 ‘LLM을 여러 번 부른다’가 아니라, 중간 산출물을 다음 단계의 입력으로 흘려보내는 파이프라인을 만든다는 점이다. 그리고 LCEL에서는 그 파이프라인이 직관적으로 조립된다. 체인은 사용자가 직접 조립하거나 정형화된 구조를 사용할 수 있다.

(예시 1) RAG 체인을 LCEL로 조립하는 경우

이 흐름이 중요한 이유는, RAG 구성요소가 각각 교체 가능한 부품이 되고, 체인은 그 부품을 꽂아 넣는 배선도가 되기 때문이다.

import bs4
from langchain import hub
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import WebBaseLoader
from langchain_community.vectorstores import FAISS
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

# 1) Load
url = "https://n.news.naver.com/article/437/0000378416"
loader = WebBaseLoader(
    web_paths=(url,),
    bs_kwargs=dict(
        parse_only=bs4.SoupStrainer(
            "div",
            attrs={"class": ["newsct_article _article_body", "media_end_head_title"]},
        )
    ),
)
docs = loader.load()

# 2) Split
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=50)
splits = splitter.split_documents(docs)

# 3) Index (Embed + VectorStore)
vectorstore = FAISS.from_documents(splits, OpenAIEmbeddings(model="text-embedding-3-small"))
retriever = vectorstore.as_retriever()

# 4) Generate
prompt = hub.pull("rlm/rag-prompt")
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

def format_docs(docs):
    return "

".join(d.page_content for d in docs)

rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

print(rag_chain.invoke("기사의 핵심 내용을 한 문단으로 요약해줘."))

이 코드는 RAG를 개념이 아니라 파이프라인으로 구현한 형태다. 단계별로 보면 다음과 같다.

  1. 문서 로드 (WebBaseLoader(...).load())
    • URL에서 HTML을 가져와서, 특정 DOM 영역만 파싱해 Document 리스트로 만든다.
    • 여기서 Document.page_content는 “텍스트”, metadata는 URL 같은 메타가 붙는다.
  2. 분할(Chunking) (RecursiveCharacterTextSplitter(...).split_documents(docs))
    • 긴 문서를 chunk로 나눠서 검색 가능한 단위로 쪼갠다.
    • chunk_overlap은 문맥이 끊기는 걸 줄이기 위한 안전장치다(경계부 근처 정보 보존).
  3. 인덱싱(Embedding + VectorStore) (FAISS.from_documents(splits, OpenAIEmbeddings(...)))
    • 각 chunk를 임베딩 벡터로 바꾸고, FAISS 인덱스에 저장한다.
    • 즉, 텍스트 → 벡터 → 검색 구조로 변환되는 시점이다.
  4. Retriever 생성 (vectorstore.as_retriever())
    • 질문이 들어오면, 질문도 임베딩한 뒤 유사도가 높은 chunk를 찾아주는 검색기 인터페이스를 만든다.
  5. RAG 체인 조립
    • 입력 질문을 두 갈래로 쪼갠다.
      • context: 질문 → retriever → 관련 문서들 → 문자열로 합치기
      • question: 질문 원문 그대로 유지
    • 이후 (| prompt | llm | parser)로 이어져서
      • “컨텍스트+질문”을 프롬프트에 채워 넣고 LLM이 답을 만들고, 파서가 문자열로 정리한다.

(예시 2) 문서 요약 체인처럼 ‘정형화된 체인 구조’를 사용하는 방법

문서 요약은 대표적으로 Stuff / Map-Reduce / Refine 패턴이 있고, LangChain은 그걸 체인 함수로 제공한다.

from langchain import hub
from langchain_openai import ChatOpenAI
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_community.document_loaders import TextLoader

docs = TextLoader("data/news.txt").load()

prompt = hub.pull("teddynote/summary-stuff-documents-korean")
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

stuff_chain = create_stuff_documents_chain(llm, prompt)
print(stuff_chain.invoke({"context": docs}))

Stuff 체인은 복잡한 전략이 아니라 문서들을 한 프롬프트에 몰아넣고 한 번에 요약하는 가장 단순한 방식이다.

  • docs = TextLoader(...).load()
    • 로컬 파일을 읽어 Document 리스트로 만든다.
  • prompt = hub.pull(...)
    • 요약용 프롬프트 템플릿을 가져온다(문서 컨텍스트를 어디에 넣을지 규칙이 이미 들어있음).
  • create_stuff_documents_chain(llm, prompt)
    • 내부적으로 다음 로직을 수행한다: 입력받은 docs를 하나의 문자열 컨텍스트로 합치고 prompt에 컨텍스트를 채운 뒤 LLM을 한 번 호출해서 결과를 반환한다.

단, 문서가 너무 길어지면 컨텍스트 제한에 걸리기 쉬워서, 그때는 Map-Reduce/Refine 같은 체인으로 넘어가는 식으로 확장한다.

인덱스(Indexes)

특정 작업을 수행하기 위한 서비스는 자체적으로 학습 데이터셋에 포함되지 않은 외부 소스에 의존해야 할 수도 있다. 앞서 설명했던 RAG(Retrieval-Augmented Generation) - 검색 증강 생성에서 예시로 들었던 증시 정보에서 블룸버그 데이터, 뉴스 기사 등을 참고해야하는 것이다. LangChain에서는 이러한 외부 데이터를 총칭하여 인덱스라고 하는데, 해당 인덱스 기능을 구성하는 요소는 다음과 같다:

  • Document Loaders(문서 로더) : 저장된 소스에서 문서를 가져오기 위함
  • Vector Databases(벡터 데이터베이스) : 벡터 형태로 저장된 효율화된 인덱싱 소스에서 검색하기 위한 수단
  • Text Splitters(텍스트 스플리터) : 텍스트 형태의 데이터를 의미 단위로 분할하여 관리

RAG 프로세스 기준으로 생각하면, 문서를 로드(Document Loader)한 상태에서 원하는 의미 단위로 Chunking(Text Splitters) → 벡터 데이터베이스 전환(Vector Databases) 그리고 이를 RAG 형태로 활용이라고 볼 수 있다. 그래서 앞서 예시로 들었던 증시 정보에 대한 RAG는 사실상 다음과 같은 프로세스를 순차적으로 거치게 된다 (보다 자세한 로직은 Chain 코드 부분과 연관지어 확인하면 이해하기 쉽다):

글에서 말한 “인덱스”를 LangChain 관점으로 더 정확히 쓰면, 외부 지식을 다루기 위한 표준 파이프라인(로드 → 분할 → 임베딩 → 저장/검색) 그 자체다. 그래서 인덱스는 DB만 의미하는 게 아니라, 데이터베이스를 저장하기 위한 전체 과정의 묶음이다.

(예시 1) Text Splitter (RecursiveCharacterTextSplitter)

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=250,
    chunk_overlap=50,
)
chunks = splitter.split_text("...긴 텍스트...")
print(len(chunks), chunks[0][:80])
  • RecursiveCharacterTextSplitter(chunk_size=..., chunk_overlap=...)
    • 텍스트를 일정 길이로 나누되, 가능한 자연스러운 경계를 우선한다.
    • 이 방식이 자주 쓰이는 이유는, 기본 분리 규칙이 \n\n\n → 공백 → 문자 순서라서 “문맥이 덜 부서지는 방향”으로 재귀적으로 쪼개주기 때문이다.
  • split_text(...)
    • 입력 문자열을 chunk 리스트로 반환한다.
    • 이 chunk들이 이후 Vector DB에 들어가는 “원자 단위”가 된다.

(예시 2) Embedding (OpenAIEmbeddings)

from langchain_openai import OpenAIEmbeddings

emb = OpenAIEmbeddings(model="text-embedding-3-small")
vec = emb.embed_query("방송 편성 통계 분석")
print(len(vec), vec[:5])
  • OpenAIEmbeddings(...).embed_query("...")
    • 쿼리 텍스트를 숫자 벡터로 바꾼다.
    • 이 벡터는 의미 공간에서의 좌표로 생각할 수 있고, 이후에 문서 벡터와 거리(유사도) 계산에 쓰인다.

(예시 3) VectorStore (Chroma / FAISS)

from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings

# split_docs: splitter.split_documents(...) 결과라고 가정
db = Chroma.from_documents(
    documents=split_docs,
    embedding=OpenAIEmbeddings(model="text-embedding-3-small"),
    collection_name="my_db",
    persist_directory="./chroma_db",  # 없으면 메모리 임시 저장
)
  • Chroma.from_documents(documents=..., embedding=..., ...)
    • 내부적으로 다음을 수행한다: 각 문서 chunk에 대해 embedding을 호출해 벡터를 만들고 벡터 + 원문 텍스트 + 메타데이터를 컬렉션에 저장한다.
  • persist_directory
    • 주면 디스크에 저장된다(재시작해도 인덱스 유지).
    • 안 주면 메모리 기반으로 동작할 수 있다.

그리고 이 단계의 핵심은 DB를 쓴다는 것이 아니라, 검색할 때는 as_retriever()로 표준 인터페이스를 만들고, 체인에서는 retriever만 끼우면 되게 만든다는 점이다(교체 가능성 확보).

메모리 (Memory)

단일 요청 시에는 중요한 기능은 아니다. 하지만 사용자가 만약 LLM과 대화를 여러번 수행하면서 이전에 했던 대화 내용을 기반으로 연속적인 추론을 진행하고 싶다고 한다면, 대화의 문맥을 파악하고 이를 저장하는 것이 필수적이다. 이렇게 대화 맥락을 유지하기 위한 메모리 기능이 LangChain에 기본적으로 모듈화되어 들어가있는 상태다.

메모리는 “대화 로그 저장”이기도 하지만, 실전에서는 두 갈래로 나뉜다.

  1. 대화 히스토리 자체를 기억하는 메모리(대화 UX)
  2. 검색 가능한 형태로 기억하는 메모리(장기기억/개인화/히스토리 RAG)

LangChain은 둘 다 모듈로 제공한다.

(예시 1) ConversationBufferMemory(가장 단순한 대화 메모리)

from langchain.memory import ConversationBufferMemory

memory = ConversationBufferMemory(
    memory_key="chat_history",
    return_messages=True,
)

# 저장
memory.save_context({"input": "난 방송 아카이브 검색을 하고 있어"}, {"output": "좋아, 어떤 조건이 필요해?"})

# 로드
print(memory.load_memory_variables({})["chat_history"])
  • ConversationBufferMemory(return_messages=True)
    • 대화 내용을 그대로 누적 저장하는 가장 단순한 메모리다.
    • return_messages=True면 문자열이 아니라 Message 객체 형태로 유지한다.
  • save_context({"input": ...}, {"output": ...})
    • 사용자 입력/모델 출력을 한 턴으로 저장한다.
  • load_memory_variables({})
    • 지금까지 쌓인 히스토리를 {"chat_history": ...} 형태로 반환한다.
    • 이후 프롬프트에 chat_history를 넣어 앞 대화를 알고 있는 것처럼 답하게 만들 수 있다.

(예시 2) LCEL 체인에 메모리 주입하기 (RunnablePassthrough.assign) ’메모리를 체인에 끼워 넣는다’를 코드로 보여주는 대표 패턴이 RunnablePassthrough.assign(...)이다.

from operator import itemgetter
from langchain_core.runnables import RunnableLambda, RunnablePassthrough

# memory.load_memory_variables({}) -> {"chat_history": ...} 를 반환한다고 가정
runnable = RunnablePassthrough.assign(
    chat_history=RunnableLambda(memory.load_memory_variables) | itemgetter("chat_history")
)

print(runnable.invoke({"input": "hi"}))
  • RunnableLambda(memory.load_memory_variables)
    • 호출될 때마다 메모리에서 히스토리를 꺼낸다.
  • itemgetter("chat_history")
    • 메모리 반환값에서 chat_history만 뽑는다.
  • RunnablePassthrough.assign(chat_history=...)
    • 원래 입력(dict)에 chat_history 키를 추가한다.
    • 즉, 최종적으로 체인이 받는 입력이 원래 input + chat_history형태로 확장된다.

이 패턴 덕분에, 프롬프트/체인은 chat_history가 항상 들어온다는 가정으로 작성할 수 있다.

(예시 3) VectorStoreRetrieverMemory (요약/히스토리를 “검색”하는 메모리)

from langchain.memory import VectorStoreRetrieverMemory

# retriever: vectorstore.as_retriever() 로 만든 retriever 라고 가정
memory = VectorStoreRetrieverMemory(retriever=retriever, memory_key="history")

memory.save_context({"input": "유재석 출연작 통계는 장르별로 보고 싶어"}, {"output": "OK, 장르/프로그램/기간 축으로 정리해볼게"})
print(memory.load_memory_variables({"prompt": "유재석 통계 다시 이어서"})["history"])

이건 대화 로그를 그대로 붙이는 것이 아니라, 필요할 때만 검색해서 관련 히스토리만 가져오는 방식이다.

  • VectorStoreRetrieverMemory(retriever=...)
    • 내부적으로 대화 내용 → 요약/문장 → 벡터화 → 저장을 수행하고, 질문이 들어오면 유사한 과거 내용을 검색해 반환한다.
  • save_context(...)
    • 턴을 저장하면서 벡터 인덱스에 들어간다.
  • load_memory_variables({"prompt": ...})
    • 현재 프롬프트와 비슷한 과거 대화를 찾아서 history로 돌려준다.

장기 대화에서 컨텍스트 길이 문제를 완화하려고 쓰는 메모리다.

에이전트 (Agents)

에이전트는 실제로 OpenAI의 GPT API, Claude 등 특정 목적을 위해 사용하게 되는 모델 자체를 의미한다. 모델을 선언하는 것 뿐만 아니라, 해당 모델이 사용할 수 있는 도구 목록, 시스템 프롬프트 쿼리를 통한 추론 과정 설계 등 다양한 형태의 커스터마이징이 가능하다.

(예시 1) Tool 정의 + bind_tools로 모델에 도구 스키마 주입

from langchain.agents import tool
from langchain_openai import ChatOpenAI

@tool
def get_word_length(word: str) -> int:
    """Returns the length of a word."""
    return len(word)

tools = [get_word_length]

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
llm_with_tools = llm.bind_tools(tools)

# 모델 출력에 tool_calls가 생김
print(llm_with_tools.invoke("What is the length of the word 'teddynote'?").tool_calls)
  • @tool 데코레이터
    • 함수에 도구 메타데이터(설명, 입력 스키마)를 붙여서, LLM이 호출 가능한 API로 인식하게 만든다.
  • llm.bind_tools(tools)
    • 모델에게 “이런 도구들이 있고, 이런 형태로 호출할 수 있다”를 알려준다.
    • 여기서는 아직 실행 루프가 없고, 모델이 tool call을 생성할 수 있게 되는 단계다.
  • .invoke(...) 결과의 tool_calls
    • 모델이 “이 도구를 이런 인자로 호출하라”는 구조화된 요청을 반환한다.
    • 즉, 자연어 답변이 아니라 “실행 계획”의 형태가 나온다.

(예시 2) create_tool_calling_agent + AgentExecutor로 실행 루프 만들기

from langchain_core.prompts import ChatPromptTemplate
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_openai import ChatOpenAI

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "You are a helpful assistant. Use tools when needed."),
        ("human", "{input}"),
        ("placeholder", "{agent_scratchpad}"),
    ]
)

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

agent = create_tool_calling_agent(llm, tools, prompt)

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,
    max_iterations=10,
    max_execution_time=10,
    handle_parsing_errors=True,
)

result = agent_executor.invoke({"input": "단어 teddynote 길이 알려줘"})
print(result["output"])

여기서부터가 진짜 “에이전트”다. 실행 흐름은 보통 아래처럼 돌아간다.

  1. 사용자 입력이 들어온다
  2. LLM이 다음 행동을 결정한다: 바로 답할지, 도구를 호출할지
  3. 도구 호출이면 실제 파이썬 함수가 실행된다
  4. 도구 결과가 다시 LLM에 들어가고
  5. 목표를 만족할 때까지 반복한다.

코드 라인별로 보면,

  • ChatPromptTemplate.from_messages([... , ("placeholder","{agent_scratchpad}")])
    • agent_scratchpad는 에이전트가 도구를 쓰며 남기는 중간 로그/결과가 들어가는 자리다.
    • 이 공간이 있어야 LLM이 이전 도구 결과를 보고 다음 행동을 결정할 수 있다.
  • create_tool_calling_agent(llm, tools, prompt)
    • “도구 호출 가능한 LLM + 프롬프트 규약”을 합쳐 에이전트를 만든다.
  • AgentExecutor(...)
    • 실제 실행 루프(반복 실행, 최대 횟수, 에러 처리)를 담당한다.
    • max_iterations, max_execution_time은 무한 루프/폭주를 막는 안전장치다.
  • .invoke({"input": ...})
    • 한 번 호출하면, 내부에서 여러 번의 LLM 호출 + 도구 실행이 발생할 수 있다.
    • 최종적으로 output이 사용자에게 전달될 답이다.

Chains vs Agents, 무엇이 더 맞는가?

같은 문제라도 서로 다르게 해결 가능하다. 분명 DB를 조회하고, 해당 DB를 기준으로 Retrieval된 내용을 정리해서 LLM(에이전트)로 넘기고 이를 요약하고, 요약한 내용을 토대로 또다른 에이전트로 넘기는 등 복잡한 구조의 Chain을 생각해볼 수 있다. 그러나 사실 이 모든 것들은 각 기능을 함수화한 상태로 Agent에 tool로서 넘겨주면 해결될 수 있는 문제이다. 그렇다면 LangChain에서 Chain, Agent 중 어떤 방식으로 접근해야 할까? 서비스 구조에 대해서 둘 다 상황에 맞게 호환이 될 수 있지만, 그럼에도 장단점이 명확하다.

  • Chains가 더 나은 경우 실행 경로가 명확하고(정해져 있고) 운영에서 재현성이 중요하고(항상 같은 단계) 실패 지점/성능/비용을 단계별로 튜닝 하고 싶을 때. 일반적으로 정해진 배치 작업을 규칙적으로 수행하게 되고, 특정 문제가 발생했을때 원인 분석이 용이한 편이다.
  • Agents가 더 나은 경우 사용자 질의가 다양해서 어떤 단계가 필요한지 매번 달라지고 중간에 추가 질의(기간을 더 좁힐지, 추가 필터 조건을 넣어서 검색을 해야 할지)에 대한 조건 분기를 하드 코딩하기 복잡하고 다양한 도구(검색/DB/통계/리랭킹/요약)를 상황에 맞게 조합해야 하며 그 경우가 너무 다양할 때, ‘도구 상자’를 만들어두고 LLM이 선택하게 하는 편이 빠르다.