<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>dev-lee 님의 블로그</title>
    <link>https://dev-lee.tistory.com/</link>
    <description>dev-lee 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Sun, 21 Jun 2026 08:16:50 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>dev-lee</managingEditor>
    <item>
      <title>LangGraph 7 - LangSmith로 에이전트 관측하기</title>
      <link>https://dev-lee.tistory.com/117</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 이번 단계에서 달라지는 것&lt;/h2&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;⑥편까지 에이전트는 &quot;돌아가긴&quot; 했다. 하지만 어느 단계에서 느린지, 토큰을 얼마 쓰는지는 print 로그로 짐작할 뿐이었음. 이번에는 LangSmith를 붙여 전 과정을 추적&amp;middot;계량한다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에이전트가 MCP 도구&amp;middot;RAG&amp;middot;멀티 노드로 복잡해질수록, &quot;왜 느리지 / 비용이 얼마지 / 어디서 실패했지&quot;를 코드 밖에서 봐야 함. &lt;b&gt;LangSmith&lt;/b&gt;는 LangChain/LangGraph 실행을 자동으로 추적해 대시보드로 보여주는 관측 도구다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;데이터 흐름(Flow)&lt;/b&gt; &amp;mdash; 사용자 입력 &amp;rarr; LLM &amp;rarr; Tool &amp;rarr; 최종 응답까지 각 단계가 자동 기록됨.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;처리 시간(Latency)&lt;/b&gt; &amp;mdash; 전체&amp;middot;LLM&amp;middot;Tool 실행 시간을 단계별로 분해해 병목을 찾음.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성능 지표(Metrics)&lt;/b&gt; &amp;mdash; 토큰 사용량, API 호출 비용, 성공률 등 운영 숫자를 산출함.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6편이 &quot;도구를 외부 표준으로 빼낸&quot; 단계였다면, 이번 편은 &quot;에이전트의 내부를 들여다보는&quot; 단계다. 기능을 더하는 게 아니라 관측을 더한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 설치와 연결&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;LangSmith는 코드를 거의 안 건드림. 가입해서 API 키를 받고 `.env`에 넣으면, LangChain이 환경변수를 자동 감지해 추적을 켠다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 가입과 API 키&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. https://smith.langchain.com/ 접속 -&amp;gt; signup -&amp;gt; login (구글 계정 등)
2. 좌측 메뉴 &amp;gt; settings &amp;gt; + API Key
3. 설명&amp;middot;만료일 지정 후 생성 -&amp;gt; 키 복사 (lsv2_pt_53c5...)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;프로젝트별로 추적이 묶이므로, 키 발급 시 용도를 적어두면 나중에 구분이 쉽다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 .env 설정&lt;/h3&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;LANGCHAIN_API_KEY=lsv2_pt_53...
LANGCHAIN_PROJECT=agent-mcp-llm
LANGCHAIN_TRACING_V2=true
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;세 줄이 전부임. `LANGCHAIN_TRACING_V2=true`가 추적 스위치, `LANGCHAIN_PROJECT`가 기록이 쌓일 프로젝트 이름. 키만 있으면 LangChain 내부에서 알아서 추적을 시작한다.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;env_path = Path(__file__).parent.parent / &quot;.env&quot;
if env_path.exists():
    load_dotenv(dotenv_path=env_path, override=True)

langsmith_api_key = os.getenv(&quot;LANGCHAIN_API_KEY&quot;)
langsmith_project = os.getenv(&quot;LANGCHAIN_PROJECT&quot;, &quot;agent-mcp-llm&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`.env`를 명시 경로로 로드하고(`override=True`로 기존 환경변수보다 우선), 키 존재 여부로 추적 활성/비활성을 판단한다. 키가 없어도 에이전트는 그대로 돌아가게 설계 &amp;mdash; 관측은 부가 기능이라 없어도 본체는 동작해야 함.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 추적 켜기 &amp;mdash; tracing 컨텍스트&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;그래프 실행을 추적 컨텍스트로 한 겹 감싸면, 그 안의 모든 단계가 LangSmith로 전송됨.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from langsmith import Client
from langchain_core.tracers.context import tracing_v2_enabled

# 클라이언트는 키가 있을 때만 생성
if langsmith_api_key:
    self.langsmith_client = Client()
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`Client()`는 환경변수에서 키를 자동으로 읽는다. 키가 없으면 아예 만들지 않고, 이후 분기에서 추적을 건너뛴다.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;async def process_query(self, user_input: str) -&amp;gt; str:
    messages = [HumanMessage(content=user_input)]

    if self.langsmith_client:
        with tracing_v2_enabled(project_name=langsmith_project):   # 추적 ON
            result = self.graph.invoke({&quot;messages&quot;: messages})
    else:
        result = self.graph.invoke({&quot;messages&quot;: messages})         # 추적 없이 그대로
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;핵심은 `tracing_v2_enabled` 한 줄. 이 with 블록 안에서 일어난 agent &amp;harr; tools 순환, LLM 호출, 도구 실행이 전부 한 묶음(Run)으로 기록된다. 키가 없으면 같은 `invoke`를 추적 없이 호출하므로 동작은 동일함.
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6편 그래프 코드는 한 줄도 안 바꿨다. 추적은 실행을 &quot;감싸기&quot;만 할 뿐, 그래프 내부 로직과 분리돼 있다. 관측 가능성(observability)을 끼워 넣을 때의 이상적인 모습.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 직접 계량하기 &amp;mdash; 메트릭 헬퍼&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;LangSmith 대시보드와 별개로, 콘솔에서도 단계별 시간을 보고 싶었음. 간단한 메트릭 수집 클래스를 따로 둠.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;class LangSmithMetrics:
    def __init__(self):
        self.start_time = None
        self.metrics = {}

    def start(self):
        self.start_time = time.time()
        self.metrics = {&quot;start_time&quot;: datetime.now().isoformat(), &quot;steps&quot;: []}

    def log_step(self, step_name: str, duration: float, data: dict = None):
        step_info = {
            &quot;name&quot;        : step_name,
            &quot;duration_ms&quot; : duration * 1000,
            &quot;timestamp&quot;   : datetime.now().isoformat()
        }
        if data:
            step_info[&quot;data&quot;] = data
        self.metrics[&quot;steps&quot;].append(step_info)

    def end(self):
        total = time.time() - self.start_time
        self.metrics[&quot;total_duration_ms&quot;] = total * 1000
        return self.metrics
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`start`로 타이머를 켜고, 각 단계가 끝날 때 `log_step`으로 이름&amp;middot;소요시간&amp;middot;부가데이터를 쌓고, `end`로 전체 시간을 마감한다. LangSmith가 자동으로 잡는 것과 중복되지만, 콘솔에서 즉시 확인할 용도.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM 노드 안에서 호출 시간을 직접 재 log_step에 넘김.&lt;/p&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;def call_model(state: MessagesState) -&amp;gt; dict:
    messages = state[&quot;messages&quot;]

    step_start = time.time()
    response = llm_with_tools.invoke(messages)
    step_duration = time.time() - step_start

    self.metrics.log_step(&quot;llm_call&quot;, step_duration, {
        &quot;input_length&quot;   : len(str(messages)),
        &quot;output_length&quot;  : len(response.content) if hasattr(response, 'content') else 0,
        &quot;has_tool_calls&quot; : bool(getattr(response, 'tool_calls', []))
    })
    return {&quot;messages&quot;: [response]}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;LLM 호출 직전&amp;middot;직후 시간을 빼 소요시간을 구하고, 입력 길이&amp;middot;출력 길이&amp;middot;도구 호출 여부를 함께 기록한다. `has_tool_calls`로 이 차례에 도구를 부르려 했는지까지 남김.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 처리 통계 출력&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;한 번의 쿼리가 끝나면, 누적한 메트릭을 사람이 읽기 좋은 트리 형태로 콘솔에 뿌림.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;metrics = self.metrics.end()

print(&quot; 처리 통계:&quot;)
print(f&quot;  ├─ 총 처리시간: {metrics['total_duration_ms']:.0f}ms&quot;)
print(f&quot;  ├─ 단계 수: {len(metrics['steps'])}&quot;)
for i, step in enumerate(metrics['steps'], 1):
    print(f&quot;  ├─ [{i}] {step['name']}: {step['duration_ms']:.0f}ms&quot;)
print(f&quot;  └─ LangSmith: {'기록됨' if self.langsmith_client else '비활성화'}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;총 처리시간 &amp;rarr; 단계 수 &amp;rarr; 단계별 시간 순으로 출력. 어느 LLM 호출이 오래 걸렸는지 대시보드에 들어가지 않고도 바로 보인다. 마지막 줄에 LangSmith 기록 여부를 표시해, 추적이 켜졌는지 한눈에 확인.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러도 메트릭에 남겨, 실패한 실행의 소요시간과 원인을 함께 보존함.&lt;/p&gt;
&lt;pre class=&quot;nimrod&quot;&gt;&lt;code&gt;except Exception as e:
    metrics = self.metrics.end()
    metrics[&quot;error&quot;] = str(e)
    import traceback
    traceback.print_exc()
    return f&quot; 처리 중 에러 발생: {str(e)}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;예외가 나도 `end()`로 타이머를 닫고 `error` 필드를 붙인다. &quot;얼마나 돌다가 어디서 터졌나&quot;가 함께 남아야 디버깅에 쓸모가 있음.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 실행 모드 &amp;mdash; 데모&amp;middot;대화형&amp;middot;단일&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;추적을 켠 채로 세 가지 방식으로 돌려봄. 모든 모드의 실행이 동일하게 LangSmith에 기록됨.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;async def run_demo(self):
    demo_queries = [
        &quot;10과 5를 더해줘&quot;,
        &quot;현재 시간은?&quot;,
        &quot;메모 저장해줘. ID는 'demo_001', 내용은 'LangSmith 통합 성공!'&quot;,
        &quot;저장된 메모들을 보여줘&quot;,
        &quot;20과 30을 곱해줘&quot;,
    ]
    for query in demo_queries:
        await self.process_query(query)
        await asyncio.sleep(0.5)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;데모 모드는 미리 짠 쿼리들을 순서대로 흘려, ⑥편 MCP 도구(add&amp;middot;get_time&amp;middot;save_note&amp;middot;list_note)가 실제로 호출되는 흐름을 한 번에 추적으로 남긴다. 마지막 &quot;곱하기&quot;는 서버에 없는 도구라, LLM이 어떻게 반응하는지 관측 포인트가 됨.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;async def interactive_mode(self):
    while True:
        user_input = input(&quot; 프롬프트: &quot;).strip()
        if not user_input:
            continue
        if user_input.lower() in ['exit', 'quit', '종료']:
            break
        await self.process_query(user_input)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;대화형 모드는 사용자가 직접 입력하며 매 턴을 추적으로 남긴다. `exit/quit/종료`로 빠져나옴. 단일 쿼리 모드는 한 번만 받아 처리하고 끝.
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모드는 셋이지만 추적 경로는 하나(process_query)로 모인다. 어떤 방식으로 돌려도 같은 프로젝트에 기록이 쌓이므로, 데모와 실사용을 한 대시보드에서 비교할 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;그래프 코드를 건드리지 않고 추적 컨텍스트로 감싸 LangSmith 관측을 붙였고, 콘솔용 메트릭 헬퍼로 단계별 시간까지 직접 계량했음.
&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;항목&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 6편까지 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 7편 (LangSmith) &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;흐름 파악&lt;/td&gt;
&lt;td&gt;print 로그로 짐작&lt;/td&gt;
&lt;td&gt;단계별 자동 추적(Flow)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;시간 측정&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;전체&amp;middot;LLM&amp;middot;Tool latency 분해&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비용&amp;middot;품질&lt;/td&gt;
&lt;td&gt;안 보임&lt;/td&gt;
&lt;td&gt;토큰&amp;middot;호출 비용&amp;middot;성공률 지표&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;코드 변경&lt;/td&gt;
&lt;td&gt;&amp;mdash;&lt;/td&gt;
&lt;td&gt;그래프 무수정, 실행만 감쌈&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;에러 추적&lt;/td&gt;
&lt;td&gt;스택트레이스만&lt;/td&gt;
&lt;td&gt;소요시간 + error 필드 보존&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5편이 무한 루프&amp;middot;비용 폭탄을 iterations 상한으로 사전 차단했다면, 7편의 LangSmith는 그 비용을 사후에 숫자로 보여준다. 안전장치와 관측은 짝이다 &amp;mdash; 막을 건 막고, 일어난 일은 본다. 이로써 1편 상태 그래프부터 6편 MCP 연계, 7편 관측까지 &quot;동작하고 / 외부 도구를 쓰고 / 관측 가능한&quot; 에이전트 한 벌이 완성됐다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>SK플래닛 ai활용 데이터엔지니어 과정 2기/ML &amp;amp; DL</category>
      <author>dev-lee</author>
      <guid isPermaLink="true">https://dev-lee.tistory.com/117</guid>
      <comments>https://dev-lee.tistory.com/117#entry117comment</comments>
      <pubDate>Fri, 5 Jun 2026 15:56:19 +0900</pubDate>
    </item>
    <item>
      <title>LangGraph 6 - MCP 도구 연계</title>
      <link>https://dev-lee.tistory.com/116</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 이번 단계에서 달라지는 것&lt;/h2&gt;
&lt;pre class=&quot;python&quot; data-ke-language=&quot;python&quot;&gt;&lt;code&gt;지금까지 도구는 코드 안에 들어있었다. 이번에는 도구를 별도 프로세스(MCP 서버)로 분리하고, 표준 프로토콜로 통신하며 가져다 쓰는 구조를 만들었음.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2편에서 @tool 데코레이터로 만든 곱셈 함수는 에이전트와 같은 파일&amp;middot;같은 프로세스 안에 있었다. 이번에는 도구를 &lt;b&gt;MCP(Model Context Protocol)&lt;/b&gt; 서버로 떼어내고, 에이전트는 그 서버에 접속해 도구 목록을 받아온 뒤 LangChain 도구로 변환해 LLM에 바인딩하였음.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;MCP 서버&lt;/b&gt; &amp;mdash; 도구의 구현체. 이름&amp;middot;인자&amp;middot;반환 스키마를 외부에 노출함. 에이전트와 분리된 별도 프로세스로 동작.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MCP 클라이언트&lt;/b&gt; &amp;mdash; 서버에 접속해 도구 목록을 조회하고, 도구를 호출(JSON-RPC)하는 통로.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;도구 어댑터&lt;/b&gt; &amp;mdash; MCP 도구를 LangChain StructuredTool로 변환. 이게 있어야 2~5편에서 써온 그래프에 그대로 끼울 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2편이 도구를 &quot;내 코드에 직접 넣은&quot; 단계였다면, 이번엔 도구를 &quot;외부 표준으로 빼낸&quot; 단계다. 한 번 어댑터를 만들어 두면 어떤 MCP 서버든 같은 방식으로 붙는다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. MCP 서버 &amp;mdash; 도구를 외부로 노출&lt;/h2&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;FastMCP로 6개 도구를 구성함. 계산&amp;middot;시간&amp;middot;메모 CRUD&amp;middot;RAG 자리까지, LLM이 가져다 쓸 외부 기능들을 한 파일에 모았다.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;from mcp.server.fastmcp import FastMCP

mcp = FastMCP('6ToolsMPCServer')

note_memory = dict()   # 메모 저장용 인메모리 저장소(데모용)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`FastMCP`로 서버 객체 하나를 만들고, 이후 `@mcp.tool()`을 붙인 함수들이 자동으로 도구로 등록된다. `note_memory`는 CRUD 도구가 공유하는 서버 측 임시 저장소.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 계산&amp;middot;시간 도구&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@mcp.tool()
def add(a: float, b: float) -&amp;gt; str:
    '''두 수를 더하는 계산기
    Args:
        a : 첫 번째 수치
        b : 두 번째 수치
    Returns
        계산 결과
    '''
    result = a + b
    logger.info(f'Tool 1 add 호출 : {a} + {b} = {result}')
    return f'계산결과 : {a}+{b} = {result}'

@mcp.tool()
def get_time() -&amp;gt; str:
    '''서버측 현재 시간을 조회
    Returns
        현재 시간 문자열
    '''
    cur_time = datetime.now().strftime(&quot;%Y-%m-%d %H:%M:%S&quot;)
    return f'현재 시간: {cur_time}'
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;②편 111글의 `@tool`과 판박이임. 데코레이터로 함수를 도구로 등록하고, docstring&amp;middot;타입힌트가 곧 LLM이 읽는 설명서가 된다. 차이는 이 함수가 에이전트가 아니라 &quot;서버 측&quot;에 있다는 것뿐. `get_time`처럼 인자 없는 도구도 그대로 등록된다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 메모 CRUD 도구&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;상태를 가진 도구 묶음. 저장(Create/Update)&amp;middot;조회(Read)&amp;middot;삭제(Delete)를 note_memory 딕셔너리 위에서 처리함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@mcp.tool()
def save_note(note_id: str, note_content: str) -&amp;gt; str:
    '''메모 저장
    Args:
        note_id: 메모의 고유 ID, 키값
        note_content: 메모 내용
    Returns
        저장 완료 메세지
    '''
    if not note_id or not note_content:        # 방어 코드
        return &quot;Fail: 필수 파라미터 누락&quot;
    note_memory[note_id] = {
        &quot;content&quot;    : note_content,
        &quot;created_at&quot; : datetime.now().isoformat()
    }
    return f&quot;메모 저장 완료 {note_id}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;필수 인자 누락을 먼저 막고(방어 코드), 통과하면 딕셔너리에 저장한다. 같은 `note_id`로 다시 부르면 덮어쓰므로 Create와 Update를 겸함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@mcp.tool()
def list_note() -&amp;gt; str:
    '''저장된 모든 메모 목록 조회'''
    if not note_memory:
        return &quot;저장된 메모 없음&quot;
    notes = &quot;\n&quot;.join([
        f'- id: {note_id},  content: {value[&quot;content&quot;]}'
        for note_id, value in note_memory.items()
    ])
    return f'저장된 모든 메모:\n{notes}'

@mcp.tool()
def delete_note(note_id: str) -&amp;gt; str:
    '''특정 메모 삭제
    Args:
        note_id: 메모의 고유 ID, 키값
    '''
    if note_id in note_memory:
        del note_memory[note_id]
        return f'메모 삭제 완료! {note_id}'
    else:
        return f'메모 삭제 실패! {note_id}로 구분되는 메모가 없음'
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`list_note`는 빈 저장소를 먼저 거르고, 존재하면 메모를 한 덩어리 문자열로 묶어 반환한다. `delete_note`는 키 존재 여부를 확인한 뒤 `del`로 지운다. 반환값이 전부 문자열인 점이 포인트 &amp;mdash; LLM이 읽을 자연어 결과로 돌려준다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.3 RAG 자리 &amp;mdash; 스텁&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@mcp.tool()
def rag_search():
    pass   # 검색증강 -&amp;gt; ④편 RAG와 연결 시 구현 예정
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;빈 도구지만 자리를 잡아둠. 서버 측 도구로 검색증강을 노출하려는 다음 단계(④편 113글 RAG)의 연결점이다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.4 로깅은 반드시 stderr로 &amp;mdash; STDIO 통신의 함정&lt;/h3&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;STDIO 모드에서 stdout은 JSON-RPC 메시지 전용 채널임. print/로그를 stdout으로 흘리면 프로토콜이 깨짐.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import sys, logging

logging.basicConfig(
    level  = logging.INFO,
    format = '[MCP Server] %(levelname)s: %(message)s',
    stream = sys.stderr          # stdout 아님. 반드시 stderr
)

if __name__ == '__main__':
    mcp.run(transport=&quot;stdio&quot;)   # 표준 입출력으로 통신
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;서버와 클라이언트는 stdin/stdout으로 JSON-RPC를 주고받는다. 로그를 stdout에 찍으면 클라이언트가 그걸 RPC 응답으로 오해해 파싱이 깨진다. 그래서 사람이 볼 메시지는 전부 stderr로 분리함.
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터엔지니어 감각으로 보면 stdout = 데이터 채널, stderr = 관측 채널의 분리다. 파이프라인에서 로그와 페이로드를 섞지 않는 것과 같은 원칙.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. MCP 클라이언트 &amp;mdash; 접속과 도구 호출&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;서버를 자식 프로세스로 띄우고, 세션을 열어 도구 목록을 받아 호출까지 해봄.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

server_params = StdioServerParameters(
    command = sys.executable,   # 현재 파이썬으로
    args    = ['server.py'],    # 서버 스크립트를 띄움
    env     = None,
)

async with stdio_client(server_params) as (read, write):
    async with ClientSession(read, write) as session:
        await session.initialize()              # 1. 세션 초기화
        res = await session.list_tools()        # 2. 도구 목록 조회
        self.tools = res.tools
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`stdio_client`가 서버 프로세스를 띄우고 입출력 스트림(read/write)을 돌려준다. 그 스트림으로 `ClientSession`을 만들고 초기화하면, `list_tools()`로 서버가 노출한 도구 6개를 받아온다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 도구는 name, description, inputSchema(JSON Schema)를 들고 옴. 인자가 있는 도구는 스키마에서 매개변수명을 뽑아 확인할 수 있음.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;for tool in self.tools:
    if hasattr(tool, &quot;inputSchema&quot;) and tool.inputSchema:
        props = tool.inputSchema.get('properties', {})
        if props:
            print(f'입력 : {&quot;, &quot;.join(props.keys())}')   # 예: a, b
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`inputSchema`가 비어 있을 수 있으니 `hasattr` + 기본값 `{}`로 방어한다. 이 스키마가 다음 단계 변환의 원재료다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 함정 &amp;mdash; 결과는 문자열이 아니라 content 블록&lt;/h3&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`call_tool` 반환값을 그냥 출력하면 객체가 찍힘. 결과는 content 리스트 안에 들어 있어, text 필드를 꺼내야 함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;async def call_tool(self, session, tool_name: str, arguments: dict):
    result = await session.call_tool(tool_name, arguments)
    if hasattr(result, 'content') and result.content:
        for content in result.content:
            if hasattr(content, 'text'):
                print(f' 결과 : \n{content.text}\n')   # 여기서 text를 꺼냄
            else:
                print(f' 결과 : \n{content}\n')
    else:
        print(f' 결과 : \n{result}\n')
    return result
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;MCP 응답은 평범한 문자열이 아니라 content 블록의 리스트다. 텍스트&amp;middot;이미지 등 여러 타입을 담을 수 있는 구조라, `content.text`로 명시적으로 꺼내야 사람이 읽을 결과가 나온다. 이 패턴은 클라이언트와 다음 장 어댑터 양쪽에 똑같이 등장함.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도구 호출 시나리오는 다음과 같이 굴렸음.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;await self.call_tool(session, &quot;add&quot;, {&quot;a&quot;: 100, &quot;b&quot;: 5})
await self.call_tool(session, &quot;get_time&quot;, {})
await self.call_tool(session, &quot;save_note&quot;, {&quot;note_id&quot;: &quot;de-001&quot;, &quot;note_content&quot;: &quot;MCP 1&quot;})
await self.call_tool(session, &quot;save_note&quot;, {&quot;note_id&quot;: &quot;de-002&quot;, &quot;note_content&quot;: &quot;MCP 2&quot;})
await self.call_tool(session, &quot;list_note&quot;, {})
await self.call_tool(session, &quot;delete_note&quot;, {&quot;note_id&quot;: &quot;de-002&quot;})
await self.call_tool(session, &quot;list_note&quot;, {})
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;저장 2건 &amp;rarr; 목록 &amp;rarr; 1건 삭제 &amp;rarr; 목록으로, CRUD가 서버 측 note_memory에 실제로 반영되는지 확인. 인자 없는 도구는 빈 딕셔너리 `{}`를 넘긴다.
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지는 사람이 직접 도구 이름과 인자를 적어 호출한 단계다. 다음 장부터는 이 호출을 LLM이 스스로 판단해 날리게 만든다. 그러려면 MCP 도구를 LangChain 도구로 바꿔야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 도구 어댑터 &amp;mdash; MCP &amp;rarr; LangChain 변환 (핵심)&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;MCP 도구를 LangChain `StructuredTool`로 자동 변환하는 어댑터. 이번 편에서 가장 손이 많이 간 부분임.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 함정 1 &amp;mdash; async with를 못 쓴다&lt;/h3&gt;
&lt;pre class=&quot;mercury&quot;&gt;&lt;code&gt;세션&amp;middot;스트림을 여러 메서드(initialize / call_tool / cleanup)에서 계속 써야 하므로, with 블록으로 자동 종료시킬 수 없음. 컨텍스트 매니저를 수동으로 연다.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;async def initialize(self):
    server_params = StdioServerParameters(
        command=sys.executable, args=[self.server_script], env=None
    )
    # with문은 블록을 벗어나면 세션을 닫아버림 -&amp;gt; 못 씀
    self._stdio_context = stdio_client(server_params)
    stdio_tuple = await self._stdio_context.__aenter__()   # 직접 진입
    self.read_stream, self.write_stream = stdio_tuple

    self.session = ClientSession(self.read_stream, self.write_stream)
    await self.session.__aenter__()      # 세션도 수동 활성화
    await self.session.initialize()

    res = await self.session.list_tools()
    self.mcp_tools = res.tools
    return self
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;3장 client.py처럼 `async with` 한 블록에서 다 끝내면 편하지만, 그건 &quot;접속&amp;rarr;호출&amp;rarr;종료&quot;가 한 흐름일 때 얘기다. 어댑터는 호출 시점이 다 다르므로, `__aenter__`로 열고 나중에 cleanup에서 `__aexit__`로 닫는다.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;async def cleanup(self):
    try:
        if self.session:
            await self.session.__aexit__(None, None, None)
    except Exception as e:
        print('세션 종료 에러', e)
    try:
        if self._stdio_context:
            await self._stdio_context.__aexit__(None, None, None)
    except Exception as e:
        print('입출력 스트림 종료 에러', e)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;수동으로 연 자원은 수동으로 닫아야 한다. 세션 &amp;rarr; 스트림 순서로 `__aexit__`을 호출하고, 각각 try로 감싸 한쪽 종료 실패가 다른 쪽을 막지 않게 한다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.2 함정 2 &amp;mdash; 클로저 늦은 바인딩&lt;/h3&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;도구 호출 함수를 반복문 안에서 그냥 만들면, 모든 함수가 마지막 도구 이름만 참조하게 됨. 한 겹 감싸 독립시킨다.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def create_tool_func(name: str):          # name을 인자로 고정(캡처)
    async def async_tool_func(**kwargs) -&amp;gt; str:
        return await self.call_tool(name, kwargs)
    return async_tool_func

tool_func = create_tool_func(tool_name)   # 도구마다 독립 함수 생성
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;이렇게 안 감싸고 루프 변수 `tool_name`을 직접 참조하면, 파이썬 클로저 특성상 모든 함수가 루프 종료 후의 마지막 값을 본다. add를 부르려 했는데 delete_note가 불리는 식. `create_tool_func(name)`으로 값을 인자에 박아 끊어준다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어댑터 측 call_tool도 결과에서 text를 꺼내는 건 동일함.&lt;/p&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;async def call_tool(self, tool_name: str, arguments: dict) -&amp;gt; str:
    result = await self.session.call_tool(tool_name, arguments)
    if hasattr(result, 'content') and result.content:
        for content in result.content:
            if hasattr(content, 'text'):
                return content.text
    return str(result)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;3.1장의 content 블록 패턴을 LangChain 도구가 기대하는 &quot;문자열 반환&quot; 형태로 정리한 것. LLM에는 결국 이 문자열이 도구 실행 결과로 전달된다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.3 스키마 &amp;rarr; pydantic 동적 모델&lt;/h3&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;MCP의 JSON Schema를 읽어 pydantic 모델을 런타임에 생성함. 타입별로 매핑하고, required면 기본값을 강제(...)로 둔다.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from pydantic import BaseModel, Field, create_model

tool_params = {}
props    = mcp_tool.inputSchema.get('properties', {})
required = mcp_tool.inputSchema.get('required',   [])

for param_name, param_info in props.items():
    param_type = param_info.get('type', 'string')
    param_desc = param_info.get('description', '')
    default = ... if param_name in required else None   # ... = 필수

    if   param_type == 'number':  py_type = float
    elif param_type == 'integer': py_type = int
    elif param_type == 'boolean': py_type = bool
    else:                         py_type = str

    tool_params[param_name] = (py_type, Field(default=default, description=param_desc))

if tool_params:
    args_schema = create_model(f'{tool_name}_args', **tool_params)
else:
    class EmptyArgs(BaseModel): pass     # get_time처럼 인자 없는 도구
    args_schema = EmptyArgs
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;MCP는 JSON으로 느슨하게 스키마를 주고, LangChain은 pydantic으로 엄격한 타입 검증을 건다. 그 사이를 메우는 게 이 변환. `create_model`로 도구 개수만큼 인자 스키마 클래스를 찍어내고, 인자 없는 도구는 빈 `EmptyArgs`로 처리한다.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;from langchain_core.tools import StructuredTool

try:
    langchain_tool = StructuredTool.from_function(
        func        = tool_func,
        name        = tool_name,
        description = tool_description,
        args_schema = args_schema,
        coroutine   = tool_func,      # 비동기 함수임을 명시
    )
except Exception:
    langchain_tool = StructuredTool.from_function(   # coroutine 미지원 버전 폴백
        func        = tool_func,
        name        = tool_name,
        description = tool_description,
        args_schema = args_schema,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;실행 함수 + 이름 + 설명 + 인자 스키마. 이 네 가지가 곧 ②편에서 LLM이 도구를 고를 때 보던 &quot;판단 재료&quot;다. `coroutine` 인자를 못 받는 버전을 대비해 try/except로 폴백을 둠. 이제 어떤 MCP 서버를 붙여도 이 어댑터 한 벌이면 도구 6개가 전부 LangChain 도구로 변환된다.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 에이전트에 결합 &amp;mdash; 그래프는 그대로&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;⑤편 114글에서 만든 그래프 구조(agent 노드 + tools 노드 + 조건부 엣지)를 그대로 재사용함. 도구 출처만 로컬 `@tool`에서 MCP로 바뀐다.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;monkey&quot;&gt;&lt;code&gt;from langgraph.graph import StateGraph, MessagesState, END
from langgraph.prebuilt import ToolNode

# MCP 도구 로드 -&amp;gt; LangChain 도구로 변환
self.mcp_adapter = MCPToolAdapter('server.py')
await self.mcp_adapter.initialize()
self.tools = self.mcp_adapter.create_langchain_tools()

llm_with_tools = self.llm.bind_tools(self.tools)   # ②편과 동일
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;초기화 순서가 곧 의존 순서다. MCP 서버에 붙어 도구를 받아오고(initialize) &amp;rarr; LangChain 도구로 변환하고(create_langchain_tools) &amp;rarr; LLM에 바인딩(bind_tools). 이 셋이 어긋나면 LLM이 도구를 못 본다.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;workflow = StateGraph(MessagesState)
workflow.add_node('agent', call_model)
workflow.add_node('tools', ToolNode(self.tools))

workflow.set_entry_point('agent')
workflow.add_edge('tools', 'agent')           # 도구 실행 후 다시 판단
workflow.add_conditional_edges('agent', should_continue, {
    'tools': 'tools',
    'end'  : END
})
self.graph = workflow.compile()
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`bind_tools` &amp;rarr; `ToolNode` &amp;rarr; 조건부 엣지 패턴은 ②편 111글, 분기 함수가 `tool_calls`를 보는 건 ④편 113글 그대로다.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;def should_continue(state: MessagesState) -&amp;gt; str:
    last_message = state[&quot;messages&quot;][-1]
    if hasattr(last_message, 'tool_calls') and last_message.tool_calls:
        return &quot;tools&quot;      # 도구 호출이 있으면 -&amp;gt; tools 노드
    return &quot;end&quot;            # 없으면 -&amp;gt; 종료
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;바뀐 건 `self.tools`를 만드는 출처뿐. 그래프 입장에선 도구가 로컬 함수인지 MCP 원격 도구인지 구분하지 않는다.
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어댑터가 변환을 책임지므로, 앞서 4편에 걸쳐 쌓은 그래프 자산을 한 줄도 안 고치고 재사용했다. 추상화가 잘 되면 교체 비용이 0에 수렴한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.1 troubleshooting &amp;mdash; 첫 작성본이 안 돌던 이유&lt;/h3&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;초안에서 막혔다가 정리하며 잡은 실수들.
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;import명 불일치&lt;/b&gt; &amp;mdash; 어댑터가 정의한 클래스는 MCPToolAdapter인데 MCPClient로 import해 ImportError. 클래스명을 맞춰 해결.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;await 누락&lt;/b&gt; &amp;mdash; initialize()가 비동기인데 await 없이 호출해 코루틴 객체만 생기고 실제 연결이 안 됨. await agent.initialize()로 수정.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;빈 도구로 bind&lt;/b&gt; &amp;mdash; MCP 로드를 건너뛴 채 bind_tools([])를 호출해 LLM이 도구를 못 봄. 초기화 순서를 MCP 로드 &amp;rarr; 변환 &amp;rarr; 바인딩으로 고정.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 실행 흐름&lt;/h2&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;&quot;10과 5를 더해줘&quot; 같은 자연어를 넣으면, LLM이 add 도구가 필요하다 판단 &amp;rarr; tools 노드에서 MCP 서버 호출 &amp;rarr; 결과를 받아 다시 판단 &amp;rarr; 최종 답변.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;async def process_query(self, user_input: str) -&amp;gt; str:
    messages = [HumanMessage(content=user_input)]
    result = self.graph.invoke({&quot;messages&quot;: messages})
    final_message = result[&quot;messages&quot;][-1]
    return final_message.content if hasattr(final_message, 'content') else str(final_message)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;사용자 입력을 HumanMessage로 감싸 그래프에 넣고, 누적된 messages의 마지막 응답을 꺼낸다. 그 사이 agent &amp;harr; tools 순환은 LangGraph가 알아서 돌린다.
&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; 사용자 입력 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; LLM 판단 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 실행 경로 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&quot;10과 5 더해줘&quot;&lt;/td&gt;
&lt;td&gt;add 필요&lt;/td&gt;
&lt;td&gt;agent &amp;rarr; tools(add) &amp;rarr; agent &amp;rarr; 응답&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&quot;지금 몇 시야?&quot;&lt;/td&gt;
&lt;td&gt;get_time 필요&lt;/td&gt;
&lt;td&gt;agent &amp;rarr; tools(get_time) &amp;rarr; agent &amp;rarr; 응답&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&quot;안녕?&quot;&lt;/td&gt;
&lt;td&gt;도구 불필요&lt;/td&gt;
&lt;td&gt;agent &amp;rarr; 응답 (tools 건너뜀)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;도구를 MCP 서버로 외부화하고, 어댑터로 LangChain 도구 6개로 변환해 ⑤편 그래프에 그대로 결합했음. 자연어 한 줄이 MCP 도구 호출로 이어지는 데까지 묶었다.
&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; 항목 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 2~5편 (로컬 도구) &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 6편 (MCP 연계) &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도구 위치&lt;/td&gt;
&lt;td&gt;에이전트와 같은 프로세스&lt;/td&gt;
&lt;td&gt;별도 MCP 서버 프로세스&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도구 등록&lt;/td&gt;
&lt;td&gt;@tool 데코레이터&lt;/td&gt;
&lt;td&gt;서버에서 list_tools로 수신&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;그래프 결합&lt;/td&gt;
&lt;td&gt;함수를 직접 바인딩&lt;/td&gt;
&lt;td&gt;어댑터가 StructuredTool로 변환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;통신&lt;/td&gt;
&lt;td&gt;함수 호출&lt;/td&gt;
&lt;td&gt;JSON-RPC (STDIO)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;결과 형태&lt;/td&gt;
&lt;td&gt;반환값 그대로&lt;/td&gt;
&lt;td&gt;content 블록에서 text 추출&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1~5편이 &quot;한 그래프 안에서 능력을 키운&quot; 과정이었다면, ⑥편은 도구를 그래프 밖 표준으로 빼냈다. 핵심은 어댑터다 &amp;mdash; MCP의 느슨한 JSON 스키마와 LangChain의 엄격한 pydantic 검증 사이를 메우는 한 벌만 갖추면, 세상의 어떤 MCP 도구든 기존 에이전트에 그대로 꽂힌다. 에이전트가 복잡해진 만큼 &quot;어디서 느리고 얼마를 쓰는지&quot; 관측이 필요해지는데, 그건 다음 편 LangSmith에서 다룬다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>SK플래닛 ai활용 데이터엔지니어 과정 2기/ML &amp;amp; DL</category>
      <author>dev-lee</author>
      <guid isPermaLink="true">https://dev-lee.tistory.com/116</guid>
      <comments>https://dev-lee.tistory.com/116#entry116comment</comments>
      <pubDate>Fri, 5 Jun 2026 15:54:42 +0900</pubDate>
    </item>
    <item>
      <title>2026.06.04 AI 뉴스</title>
      <link>https://dev-lee.tistory.com/115</link>
      <description>&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Anthropic, 650억 달러 조달하며 OpenAI 제치고 AI 기업 가치 1위 등극&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Anthropic이 시리즈 H 라운드에서 **650억 달러(약 88조 원)**를 조달하며 포스트머니 기준 기업가치 &lt;b&gt;9,650억 달러&lt;/b&gt;를 기록했습니다. 5월 28일 발표된 이번 라운드로 Anthropic은 지난 3월 8,520억 달러로 평가된 OpenAI를 처음으로 추월하며, 세계에서 가장 가치 있는 AI 기업 자리에 올랐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 라운드는 Altimeter Capital, Dragoneer, Greenoaks, Sequoia Capital이 주도했고, Capital Group&amp;middot;Coatue&amp;middot;D1&amp;middot;GIC&amp;middot;ICONIQ&amp;middot;XN 등이 공동 리드로 참여했습니다. Baillie Gifford, Blackstone, Brookfield, Fidelity, T. Rowe Price 같은 대형 기관투자자도 합류했고, 전략적 인프라 파트너로 &lt;b&gt;Samsung&amp;middot;SK Hynix&amp;middot;Micron&lt;/b&gt; 등 메모리&amp;middot;반도체 기업까지 이름을 올린 점이 눈에 띕니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;밸류에이션 급등&lt;/b&gt; &amp;mdash; 지난 2월 시리즈 G 당시 3,800억 달러였던 기업가치가 약 3개월 만에 2.5배 이상으로 뛰었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;매출 성장&lt;/b&gt; &amp;mdash; 연환산 매출(run-rate)이 2월 140억 달러에서 5월 470억 달러를 돌파했습니다. Claude Code 단독 매출도 2월 기준 25억 달러 수준으로 보고됐습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;숫자의 함정&lt;/b&gt; &amp;mdash; 650억 달러 중 150억 달러는 하이퍼스케일러가 기존에 약정한 투자(Amazon 50억 달러 포함)라, 순수 신규 자본은 약 500억 달러로 봐야 한다는 분석이 나옵니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 이번 자금이 컴퓨팅 인프라 비용으로 상당 부분 되돌아가는 구조라는 점이 지적됩니다. Amazon이 투자하면 Anthropic은 AWS에 대규모 사용을 약정하는 식이어서, 헤드라인 금액과 실질 증분 자본 사이에 차이가 있다는 것입니다. 다만 이는 업계 표준적 구조이며 위험 신호는 아니라는 평가입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조달과 거의 동시에 Anthropic은 &lt;b&gt;6월 1일 IPO 비공개 서류를 제출&lt;/b&gt;했습니다. 업계는 이르면 2026년 10월 상장 가능성을 점치고 있으며, 같은 시기 상장을 준비 중인 OpenAI보다 먼저 공개시장에 진입할 수 있다는 전망이 나옵니다. Goldman Sachs, JPMorgan, Morgan Stanley가 주관사로 거론되고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenAI 출신들이 &quot;지나치게 빠른 속도&quot;를 우려해 떠나 세운 회사가 6년 만에 OpenAI를 가치로 앞질렀다는 점에서 상징성이 큰 사건입니다. CFO Krishna Rao는 Claude가 글로벌 고객에게 점점 더 필수적인 도구가 되고 있으며, Claude Code와 Cowork를 더 강력하게 만드는 데 주력하고 있다고 밝혔습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Anthropic이 650억 달러 조달과 9,650억 달러 밸류에이션으로 OpenAI를 제치고 AI 기업가치 1위에 올랐으며, 곧바로 IPO 절차에 착수했습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. GitHub Copilot, 토큰 종량제 전환&amp;hellip;개발자 반발 폭발&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Copilot이 &lt;b&gt;6월 1일부터 정액제를 폐지하고 토큰 기반 종량제(&quot;AI Credits&quot;)로 전환&lt;/b&gt;했습니다. 4월 27일 GitHub 최고제품책임자(CPO) Mario Rodriguez가 예고한 변경으로, 3년간 유지돼 온 &quot;월정액 무제한&quot; 모델이 막을 내렸습니다. 약 470만 명의 유료 구독자가 영향을 받습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구독 기본료 자체는 그대로입니다. Pro는 월 10달러, Pro+ 39달러, Business 사용자당 19달러, Enterprise 사용자당 39달러를 유지합니다. 다만 이 금액은 이제 &quot;무제한 사용권&quot;이 아니라 &lt;b&gt;포함된 크레딧 한도&lt;/b&gt;를 뜻합니다. 1 AI Credit = 0.01달러이며, 입력&amp;middot;출력&amp;middot;캐시 토큰 사용량에 따라 차감됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;무료 유지&lt;/b&gt; &amp;mdash; 코드 자동완성(completions)과 Next Edit Suggestions는 종량 대상이 아니라 그대로 무제한입니다. 기본 사용자는 사실상 비용 증가가 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사라진 안전망&lt;/b&gt; &amp;mdash; 기존에는 크레딧이 소진되면 저가 모델로 자동 전환(fallback)됐지만, 이 기능이 폐지됐습니다. 이제 크레딧이 떨어지면 추가 구매하거나 다음 결제일까지 고급 기능을 못 씁니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이중 과금&lt;/b&gt; &amp;mdash; 코드 리뷰는 AI Credit과 GitHub Actions 분(minutes)을 동시에 소모합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반발의 핵심은 &lt;b&gt;에이전트(agentic) 사용자&lt;/b&gt;입니다. 대규모 코드 생성&amp;middot;반복 작업을 하는 사용자들은 비용이 10배에서 50배까지 뛸 것으로 추산하고 있습니다. Reddit&amp;middot;X에는 월 29달러가 750달러로, 50달러가 3,000달러로 늘어난다는 추정치가 쏟아졌고, TechCrunch는 이를 두고 Copilot &quot;황금기의 종말&quot;이라고 표현했습니다. 한 Pro+ 사용자는 두 시간 만에 월 크레딧의 8%를 소진했다고 보고했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub는 6~8월 한시적으로 Business에 월 30달러, Enterprise에 월 70달러의 프로모션 크레딧을 제공하고, 조직 내 미사용 크레딧을 헤비 유저에게 몰아주는 풀링(pooled usage) 기능도 도입했습니다. 다만 현재까지 가격 구조 자체를 되돌릴 계획은 없다고 밝혔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배경에는 비용 압박이 있습니다. 언론인 Ed Zitron이 입수한 내부 문서에 따르면 Copilot 운영 비용이 2026년 1월 이후 주 단위로 거의 두 배 가까이 늘어, 전환이 단순 제품 전략이 아니라 시급한 수익성 문제였음을 시사합니다. 일부 개발자는 Cursor, Windsurf, Claude Code 등 대안으로의 이전을 검토하고 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Copilot이 6월 1일 토큰 종량제로 전환되면서 에이전트 사용자 비용이 최대 50배 폭증할 수 있다는 추정에 개발자 반발이 거셉니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Microsoft Build 2026, &quot;윈도우를 에이전트 플랫폼으로&quot;&amp;hellip;Agent 365 정식 출시&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Microsoft가 6월 2일 &lt;b&gt;Build 2026&lt;/b&gt; 키노트에서 AI 에이전트를 전면에 내세운 발표를 쏟아냈습니다. 핵심은 윈도우와 365 생태계를 개발자용 에이전트 플랫폼으로 재편하는 방향입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Agent 365 정식 출시(GA)&lt;/b&gt; &amp;mdash; 기업이 AI 에이전트를 배포&amp;middot;관리&amp;middot;통제할 수 있는 플랫폼이 정식 버전으로 풀렸습니다. 함께 Microsoft 365 E7이라는 새 상위 요금제도 공개됐습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Azure AI Foundry 멀티모델&lt;/b&gt; &amp;mdash; 여러 모델을 한 환경에서 운영하는 멀티모델 지원이 강화됐습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Foundry Local&lt;/b&gt; &amp;mdash; Windows, Apple Silicon macOS, Linux x64에서 온디바이스 추론을 돌릴 수 있는 로컬 추론 도구가 공개됐습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Windows 에이전트 플랫폼화&lt;/b&gt; &amp;mdash; 윈도우 자체를 AI 에이전트가 동작하는 기반으로 제시했습니다. 반면 Windows 12에 대한 언급은 없었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자 입장에서 당장 챙겨야 할 변화도 명확합니다. 6월 1일 Copilot 종량제 시행에 맞춰 사용량 설정과 예산 알림을 점검해야 하고, 온디바이스 추론이 필요하면 Foundry Local을 받아볼 수 있습니다. 또한 Copilot SDK를 쓰는 팀은 8월 전까지 &lt;b&gt;Project Polaris&lt;/b&gt; 마이그레이션 가이드를 검토해 즉시 이전할지, 3개월 폴백을 쓸지 결정해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 발표에서 군사 분야 입장 차이도 부각됐습니다. Microsoft의 국방부(DoD) 계약은 생산성 소프트웨어와 AI 보조 운영에 관한 것으로, 자율 살상무기를 겨냥한 것이 아니라는 점을 강조했습니다. 이는 최근 Claude의 자율 살상무기 사용을 거부하며 DoD와 마찰을 빚은 Anthropic과 대비되는 행보입니다. &quot;군사 생산성용 AI&quot;와 &quot;살상 의사결정용 AI&quot; 사이의 경계를 두 회사가 정반대 방향에서 긋고 있다는 분석입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전반적으로 Build 2026은 모델 성능 경쟁에서 &lt;b&gt;에이전트 인프라&amp;middot;거버넌스 경쟁&lt;/b&gt;으로 무게중심이 옮겨가고 있음을 보여준 행사로 평가됩니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Microsoft가 Build 2026에서 Agent 365 정식 출시와 Foundry Local 등을 공개하며 윈도우를 AI 에이전트 플랫폼으로 재편하는 전략을 제시했습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;출처&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Anthropic, &quot;Anthropic raises $65B in Series H funding at $965B post-money valuation&quot; &amp;mdash; &lt;a href=&quot;https://www.anthropic.com/news/series-h&quot;&gt;https://www.anthropic.com/news/series-h&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;TechCrunch, &quot;Anthropic raises $65 billion, nears $1T valuation ahead of IPO&quot; &amp;mdash; &lt;a href=&quot;https://techcrunch.com/2026/05/28/anthropic-raises-65-billion-nears-1t-valuation-ahead-of-ipo/&quot;&gt;https://techcrunch.com/2026/05/28/anthropic-raises-65-billion-nears-1t-valuation-ahead-of-ipo/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;CNBC, &quot;Anthropic tops OpenAI as most valuable AI startup&quot; &amp;mdash; &lt;a href=&quot;https://www.cnbc.com/2026/05/28/anthropic-open-ai-startup-value.html&quot;&gt;https://www.cnbc.com/2026/05/28/anthropic-open-ai-startup-value.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Fortune, &quot;Anthropic confidentially files for IPO after raising $65 billion&quot; &amp;mdash; &lt;a href=&quot;https://fortune.com/2026/06/01/anthropic-confidentially-files-ipo-965-billion-valuation/&quot;&gt;https://fortune.com/2026/06/01/anthropic-confidentially-files-ipo-965-billion-valuation/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;MLQ News, &quot;GitHub Copilot Switches to Token-Based Billing June 1&quot; &amp;mdash; &lt;a href=&quot;https://mlq.ai/news/v2/github-copilot-switches-to-token-based-billing-june-1-drawing-developer-backlash/&quot;&gt;https://mlq.ai/news/v2/github-copilot-switches-to-token-based-billing-june-1-drawing-developer-backlash/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;TechTimes, &quot;GitHub Copilot Pricing Change Drives Backlash&quot; &amp;mdash; &lt;a href=&quot;https://www.techtimes.com/articles/317536/20260601/github-copilot-pricing-change-drives-backlash-agentic-bills-jump-10x-50x-power-users.htm&quot;&gt;https://www.techtimes.com/articles/317536/20260601/github-copilot-pricing-change-drives-backlash-agentic-bills-jump-10x-50x-power-users.htm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;TechCrunch, &quot;'What a joke': GitHub Copilot's new token-based billing&quot; &amp;mdash; &lt;a href=&quot;https://techcrunch.com/2026/05/30/what-a-joke-github-copilots-new-token-based-billing-spurs-consternation-among-devs/&quot;&gt;https://techcrunch.com/2026/05/30/what-a-joke-github-copilots-new-token-based-billing-spurs-consternation-among-devs/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Build Fast with AI, &quot;AI News Today &amp;mdash; June 2, 2026&quot; &amp;mdash; &lt;a href=&quot;https://www.buildfastwithai.com/blogs/ai-news-today-june-2-2026&quot;&gt;https://www.buildfastwithai.com/blogs/ai-news-today-june-2-2026&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>AI 뉴스</category>
      <author>dev-lee</author>
      <guid isPermaLink="true">https://dev-lee.tistory.com/115</guid>
      <comments>https://dev-lee.tistory.com/115#entry115comment</comments>
      <pubDate>Thu, 4 Jun 2026 17:51:31 +0900</pubDate>
    </item>
    <item>
      <title>LangGraph 5 - 멀티 에이전트 협업</title>
      <link>https://dev-lee.tistory.com/114</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 이번 단계에서 달라지는 것&lt;/h2&gt;
&lt;pre class=&quot;python&quot; data-ke-language=&quot;python&quot;&gt;&lt;code&gt;지금까지는 에이전트 하나가 도구&amp;middot;기억&amp;middot;RAG를 갖춰왔다. 이번에는 역할이 다른 두 에이전트가 협업하며 결과물을 스스로 고쳐 나가는 구조를 만들었음.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1~4편의 에이전트는 &quot;혼자 일하는 한 명&quot;이었다. 이번에는 **작성자(Coder)**와 **검수자(Reviewer)**로 역할을 나누고, 검수를 통과할 때까지 작성&amp;rarr;리뷰&amp;rarr;수정을 반복하는 자기수정(self-refine) 루프를 구성하였음. 예제 주제는 &quot;코드 작성과 코드 리뷰&quot;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Coder 에이전트&lt;/b&gt; &amp;mdash; 요청받은 기능을 구현하거나, 리뷰 피드백을 반영해 코드를 수정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Reviewer 에이전트&lt;/b&gt; &amp;mdash; 보안&amp;middot;효율&amp;middot;스타일을 점검해 PASS / FAIL을 판정하고, FAIL이면 수정 지시를 남김.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자기수정 순환&lt;/b&gt; &amp;mdash; Reviewer가 PASS를 줄 때까지 Coder가 반복 수정함. 단, 무한 루프와 비용 폭탄을 막기 위해 반복 횟수 상한을 두었음.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2~4편이 &quot;한 에이전트의 진화&quot;였다면, 이번엔 &quot;여러 에이전트의 분업&quot;이다. 같은 일을 먼저 LangChain 선형 체인으로 짜본 뒤, 같은 흐름을 LangGraph 순환 그래프로 다시 짜며 둘의 차이를 체감해본다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 먼저 LangChain으로 &amp;mdash; 선형 협업&lt;/h2&gt;
&lt;pre class=&quot;python&quot; data-ke-language=&quot;python&quot;&gt;&lt;code&gt;세 개의 체인(작성&amp;rarr;리뷰&amp;rarr;수정)을 직렬로 이어 한 번씩 흘려보내는 가장 단순한 협업 형태부터 만들었음.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 역할별 프롬프트&lt;/h3&gt;
&lt;pre class=&quot;scilab&quot;&gt;&lt;code&gt;developer_prompt = ChatPromptTemplate.from_messages([
    ('system', '당신은 열정적인 &quot;신입 파이썬 개발자&quot;입니다. 요청받은 기능을 구현하는 코드를 작성하세요. 설명은 최소화하고 코드 위주로 작성하세요.'),
    ('user'  , '{request}'),
])

reviewer_prompt = ChatPromptTemplate.from_messages([
    ('system', '당신은 까다로운 &quot;전문 개발자&quot;입니다. 신입 개발자가 작성한 코드를 리뷰하세요.\n'
               '보안 취약점, 비효율적인 부분, 스타일 가이드를 점검하고 수정 제안을 하세요.\n'
               '코드가 완벽하다면 &quot;PASS&quot;라고만 답하세요.'),
    ('user'  , '다음 코드를 리뷰해주세요:\n\n{code}'),
])

refiner_prompt = ChatPromptTemplate.from_messages([
    ('system', '당신은 열정적인 &quot;신입 파이썬 개발자&quot;입니다. 전문개발자의 리뷰를 보고 코드를 수정하여 다시 제출하세요.'),
    ('user'  , '이전 코드:\n{original_code}\n\n리뷰 내용:\n{feedback}\n\n위 내용을 반영하여 개선된 전체 코드를 다시 작성하세요'),
])
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;페르소나(system)만 다르고, ④편까지 써온 ChatPromptTemplate 구조 그대로임. 에이전트의 &quot;역할&quot;은 결국 system 프롬프트로 갈린다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 체인 연결과 실행&lt;/h3&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;developer_agent = developer_prompt | llm | StrOutputParser()
reviewer_agent  = reviewer_prompt  | llm | StrOutputParser()
refiner_agent   = refiner_prompt   | llm | StrOutputParser()

def run_agent_collaboration(topic):
    draft_code = developer_agent.invoke({&quot;request&quot;: topic})      # 1. 초안 작성
    feedback   = reviewer_agent.invoke({&quot;code&quot;: draft_code})     # 2. 리뷰

    if feedback == 'PASS':
        print(draft_code)
    else:
        final_code = refiner_agent.invoke({                      # 3. 1회 수정
            &quot;original_code&quot;: draft_code,
            &quot;feedback&quot;: feedback
        })
        print(final_code)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;②편 109글의 `prompt | llm | StrOutputParser()` 패턴을 세 번 쓴 것뿐임. 동작은 하지만, 구조적 한계가 분명함.
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;반복이 안 됨&lt;/b&gt; &amp;mdash; 수정은 코드에 박힌 1회뿐. &quot;PASS 나올 때까지&quot; 같은 순환을 표현하려면 while을 손으로 짜야 함.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상태가 흩어짐&lt;/b&gt; &amp;mdash; draft_code, feedback을 변수로 일일이 들고 다님. 대화 맥락이 누적되지 않음.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분기가 어색함&lt;/b&gt; &amp;mdash; if feedback == 'PASS'처럼 흐름 제어를 비즈니스 코드에 섞어야 함.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선형 체인은 &quot;한 방향으로 한 번&quot;에 최적화돼 있다. 작성&amp;rarr;리뷰&amp;rarr;체크&amp;rarr;재작성&amp;rarr;리뷰&amp;hellip;처럼 도는 흐름은 체인으로는 손이 많이 간다. 이 지점이 정확히 LangGraph가 필요한 이유다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. LangGraph로 재구성 &amp;mdash; 상태 정의&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;흩어진 변수 대신, 누적되는 대화와 반복 횟수를 하나의 공유 상태로 묶음.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;import operator
from typing import Annotated, List, TypedDict
from langchain_core.messages import BaseMessage

class AgentState(TypedDict):
    # operator.add -&amp;gt; 덮어쓰지 말고 기존 메시지에 누적하라
    messages   : Annotated[List[BaseMessage], operator.add]
    iterations : int   # 수정 반복 횟수 (비용 상한 비교용)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;①편 110글은 상태를 그냥 덮어썼지만, 여기선 Annotated[..., operator.add]로 &quot;누적&quot;을 명시함. ③편 112글의 단기기억(체크포인터)이 외부에서 상태를 보관했다면, 여기선 상태 정의 자체에 누적 규칙을 박아 넣은 셈임.
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;messages&lt;/b&gt; &amp;mdash; 작성&amp;middot;리뷰가 오갈수록 대화가 쌓임. 리듀서 operator.add가 리스트를 이어붙임.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;iterations&lt;/b&gt; &amp;mdash; Coder가 한 번 돌 때마다 +1. 이후 조건부 엣지에서 상한 비교에 씀.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 노드 &amp;mdash; 두 에이전트를 함수로&lt;/h2&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;2편에서 만든 세 체인 중 작성/리뷰를 각각 노드 함수로 옮김. 수정(refine)은 별도 노드가 아니라 &quot;리뷰를 본 Coder의 재실행&quot;으로 흡수됨.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 coder_node &amp;mdash; 작성과 수정 겸용&lt;/h3&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;def coder_node(state: AgentState):
    msg = state['messages']
    coder_prompt = ChatPromptTemplate.from_messages([
        ('system',      '당신은 &quot;초보 파이썬 개발자&quot;입니다. 요청받은 기능을 구현하세요. '
                        '리뷰어의 피드백이 있다면 반영하여 코드를 수정하시오'),
        ('placeholder', '{messages}'),   # 메시지 종류(Human/AI)를 알아서 채움
    ])
    chain = coder_prompt | llm
    draft_code = chain.invoke({&quot;messages&quot;: msg})
    return {
        &quot;messages&quot;   : [draft_code],
        &quot;iterations&quot; : state.get('iterations', 0) + 1
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;포인트는 `placeholder`임. 초안 작성이든 피드백 반영이든, 누적된 messages를 통째로 프롬프트에 흘려넣으므로 같은 노드 하나가 &quot;작성&quot;과 &quot;수정&quot;을 모두 담당함. 2편의 developer_agent와 refiner_agent가 하나로 합쳐진 것.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.2 reviewer_node &amp;mdash; 판정&lt;/h3&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;def reviewer_node(state: AgentState):
    last_msg = state['messages'][-1]   # Coder가 방금 쓴 코드
    reviewer_prompt = ChatPromptTemplate.from_messages([
        ('system', &quot;당신은 까다로운 '전문 개발자'입니다. 코드를 엄격하게 리뷰하세요.\n&quot;
                   &quot;보안 취약점, 비효율, 스타일을 점검하세요.\n&quot;
                   &quot;완벽하고 보안 문제가 없다면 반드시 'PASS'라고만 답하세요.\n&quot;
                   &quot;문제가 있다면 'FAIL'이라 적고 구체적인 수정 지시를 남기세요.&quot;),
        ('user'  , '다음 코드를 리뷰해주세요:\n\n{code}'),
    ])
    chain  = reviewer_prompt | llm
    review = chain.invoke({&quot;code&quot;: last_msg.content})
    return {'messages': [review]}   # iterations는 올리지 않음
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;리뷰 결과도 messages에 누적됨. 다음 차례에 Coder가 이 리뷰를 placeholder로 받아 수정에 반영함.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 조건부 엣지 &amp;mdash; 순환을 끊는 규칙&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;&quot;계속 수정할지 / 끝낼지&quot;를 직접 판정하는 분기 함수를 작성함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;perl&quot;&gt;&lt;code&gt;def is_continue(state: AgentState):
    last_msg   = state['messages'][-1].content
    iterations = state['iterations']

    # 1. 안전장치 &amp;mdash; 무한 루프 = 토큰 = 비용 폭탄 방지
    if iterations &amp;gt;= 3:
        print('-- [System] 최대 반복 도달. 종료. --')
        return 'my_end'
    # 2. 리뷰 통과
    if &quot;PASS&quot; in last_msg:
        print('-- [System] 리뷰 통과. 종료. --')
        return 'my_end'
    # 3. 거절 -&amp;gt; 다시 작성자에게
    print('-- [System] 리뷰 거절(FAIL). 재작성. --')
    return 'gogo'
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;②편 111글은 라이브러리 제공 `tools_condition`에, ④편 113글은 도구 호출 여부를 보는 커스텀 분기에 맡겼음. 이번 분기 기준은 &quot;PASS 여부 + 반복 횟수&quot;라는 도메인 규칙이라 직접 작성함. iterations 상한이 곧 ③편 단기기억(누적 상태)을 흐름 제어에 활용하는 지점.
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;iterations &amp;gt;= 3&lt;/b&gt; &amp;mdash; 비용&amp;middot;시간 안전장치. 품질이 안 나와도 무조건 멈춤.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PASS 포함&lt;/b&gt; &amp;mdash; 완전 일치(== 'PASS')가 아니라 부분 포함(in)으로 둬, 모델이 군더더기를 붙여도 통과 판정이 됨.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;그 외&lt;/b&gt; &amp;mdash; gogo 반환 &amp;rarr; Coder로 되돌아가 순환.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 그래프 구성과 실행&lt;/h2&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Coder를 시작점으로, reviewer 뒤에 조건부 엣지를 걸어 순환/종료를 매핑함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;from langgraph.graph import StateGraph, END

workflow = StateGraph(AgentState)
workflow.add_node('coder',    coder_node)
workflow.add_node('reviewer', reviewer_node)

workflow.set_entry_point('coder')
workflow.add_edge('coder', 'reviewer')        # 작성 -&amp;gt; 무조건 리뷰

workflow.add_conditional_edges('reviewer', is_continue, {
    'my_end': END,       # 통과/상한 -&amp;gt; 종료
    'gogo'  : 'coder'    # 거절 -&amp;gt; 다시 작성(순환)
})

app = workflow.compile()
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;ceylon&quot;&gt;&lt;code&gt;`{'my_end': END, 'gogo': 'coder'}` 매핑이 핵심임. is_continue가 돌려준 문자열이 실제 다음 행선지로 치환됨. 'gogo' -&amp;gt; 'coder' 연결이 곧 자기수정 순환(reviewer &amp;rarr; coder)을 만든다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름 시나리오는 다음과 같음.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;시나리오&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 흐름 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;한 번에 통과&lt;/td&gt;
&lt;td&gt;coder &amp;rarr; reviewer &amp;rarr; (PASS) &amp;rarr; END&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1회 수정&lt;/td&gt;
&lt;td&gt;coder &amp;rarr; reviewer &amp;rarr; (FAIL) &amp;rarr; coder &amp;rarr; reviewer &amp;rarr; (PASS) &amp;rarr; END&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;상한 종료&lt;/td&gt;
&lt;td&gt;coder &amp;rarr; reviewer &amp;rarr; FAIL &amp;rarr; &amp;hellip; &amp;rarr; (iterations=3) &amp;rarr; END&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행은 일부러 &quot;비효율적으로 짜달라&quot;고 주문해 순환을 유도함.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;if __name__ == '__main__':
    inputs = {
        'messages'  : [HumanMessage(content='리스트에서 중복을 제거하고 정렬하는 함수를 만들어줘. 단, 좀 비효율적으로 작성해줘')],
        'iterations': 0
    }
    for output in app.stream(inputs):   # stream -&amp;gt; 노드별 진행 관찰
        print(output)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Coder가 일부러 비효율 코드를 내면 Reviewer가 FAIL을 주고, 수정본이 다시 리뷰로 돌아감. 몇 바퀴 안에 PASS가 나거나 상한에서 멈춤.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;역할이 다른 두 에이전트를 노드로 두고, 조건부 엣지로 &quot;통과까지 반복&quot;하는 자기수정 협업 그래프를 완성했음.
&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 126px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;항목&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt; LangChain 선형 협업 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt; LangGraph 멀티 에이전트 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;흐름&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;작성&amp;rarr;리뷰&amp;rarr;1회 수정(고정)&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;작성&amp;harr;리뷰 순환(통과까지)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;상태&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;변수로 흩어짐&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;messages 누적 + iterations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;반복 제어&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;while 직접 구현 필요&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;조건부 엣지 + 상한값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;분기&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;if문을 비즈니스 코드에 혼합&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;is_continue 분기 함수로 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;수정 역할&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;refiner 별도 체인&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;coder 노드가 placeholder로 흡수&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1 ~ 4편이 한 에이전트에 도구&amp;middot;기억&amp;middot;RAG를 더해 &quot;능력&quot;을 키웠다면, 5편은 에이전트를 둘로 나눠 &quot;협업과 자기검증&quot;을 더했다. 같은 워크플로우를 선형 체인 &amp;rarr; 순환 그래프로 옮겨 보면, LangGraph가 왜 &quot;분기&amp;middot;순환&amp;middot;상태&quot;에 강한지가 코드로 드러난다. 다음 확장 지점은 에이전트 N명(작성&amp;middot;리뷰&amp;middot;테스트&amp;middot;문서화) 협업과 MCP 연계다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>SK플래닛 ai활용 데이터엔지니어 과정 2기/ML &amp;amp; DL</category>
      <author>dev-lee</author>
      <guid isPermaLink="true">https://dev-lee.tistory.com/114</guid>
      <comments>https://dev-lee.tistory.com/114#entry114comment</comments>
      <pubDate>Thu, 4 Jun 2026 17:23:51 +0900</pubDate>
    </item>
    <item>
      <title>LangGraph 4 - RAG 에이전트</title>
      <link>https://dev-lee.tistory.com/113</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 이번 단계에서 달라지는 것&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;LLM이 모르는 &quot;사내&amp;middot;최신 데이터&quot;를 RAG로 끌어와 추론에 반영하는 식사 추천 에이전트를 만듦.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;③편까지의 에이전트는 곱셈처럼 자체적으로 풀 수 있는 도구만 썼음. 이번에는 LLM이 모르는 정보를 다룸. RAG(Retrieval-Augmented Generation)를 도구로 차용해 다음 문제를 해결함.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;할루시네이션 방지&lt;/b&gt; &amp;mdash; 존재하지 않는 식당을 지어내지 않도록, 실제 데이터를 검색해 근거로 제공함.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;내부 데이터 접근&lt;/b&gt; &amp;mdash; LLM이 모르는 private 데이터(보안 이슈로 학습되지 않은 정보)를 보완함.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;최신 정보 부재 해소&lt;/b&gt; &amp;mdash; 학습 시점 이후의 정보를 외부에서 가져옴.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계 방향은 다음과 같음.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;전체 의사결정&lt;/b&gt; &amp;mdash; LangGraph가 담당함.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;부분 체인&lt;/b&gt; &amp;mdash; 프롬프트 &amp;rarr; LLM 추론은 LangChain으로 묶어 하나의 노드로 구성함.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;프롬프트 기법&lt;/b&gt; &amp;mdash; FewShot으로 답변 톤과 형식을 잡음.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;②~③편이 &quot;계산 도구&quot;였다면, ④편의 도구는 &quot;지식 검색&quot;이다. 도구의 성격이 행동에서 지식 보강으로 확장된다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. RAG 저장소 구성 (rag_store.py)&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;더미 식당 데이터를 임베딩해 FAISS 벡터 DB에 적재하고, 유사도 검색 함수를 제공함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;from langchain_community.vectorstores import FAISS
from langchain_aws import BedrockEmbeddings
import boto3, os
from dotenv import load_dotenv

load_dotenv()

# 임베딩 모델(토크나이저) 구성
tokenizer = BedrockEmbeddings(
    model_id=&quot;amazon.titan-embed-text-v2:0&quot;,
    region_name=os.getenv('AWS_REGION')
)

# 더미 데이터 &amp;mdash; LLM이 모르는 사내/최신 데이터 가정
data = [
    &quot;가게명: 스파이시 웍, 메뉴: 마라탕, 꿔바로우, 특징: 아주 매움, 스트레스 풀림, 가격: 15000원&quot;,
    &quot;가게명: 헬시 샐러드, 메뉴: 닭가슴살 샐러드, 샌드위치, 특징: 다이어트, 가벼움, 신선함, 가격: 9000원&quot;,
    &quot;가게명: 엄마손 백반, 메뉴: 김치찌개, 제육볶음, 특징: 집밥 스타일, 가성비, 든든함, 가격: 8000원&quot;,
    &quot;가게명: 골든 스시, 메뉴: 초밥 세트, 우동, 특징: 고급스러움, 깔끔함, 월급날 추천, 가격: 25000원&quot;,
    &quot;가게명: 해장국 천국, 메뉴: 뼈해장국, 순대국, 특징: 국물 진함, 비 오는 날 추천, 가격: 10000원&quot;
]

# 벡터화 -&amp;gt; 벡터 DB 세팅
vector_db = FAISS.from_texts(data, embedding=tokenizer)

# 검색 함수: 질의 -&amp;gt; 유사도 검색 -&amp;gt; 상위 k개 반환
def search_stores(query: str, k: int = 2):
    docs = vector_db.similarity_search(query, k)
    print(f'==== [RAG 검색 결과] : {docs}')
    return '\n'.join([doc.page_content for doc in docs])
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;현재는 인메모리 FAISS로 1회성 구성임. 향후 벡터 DB는 외부에 영속 구성하는 것이 실서비스 방향임.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 도구 래핑 (tools.py)&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;검색 함수를 `@tool`로 감싸 LLM이 호출 가능한 도구로 만듦.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;from langchain_core.tools import tool
from rag_store import search_stores

@tool
def rag_search(cate: str) -&amp;gt; str:
    '''
    가격, 특징, 메뉴, 카테고리 등을 입력받아 벡터 유사도 검색 -&amp;gt;
    실제 식당 정보 제공(할루시네이션 회피)
    '''
    res = search_stores(cate)  # 상위 2개 반환
    return res if res else '관련 식당 정보를 찾을 수 없습니다.'
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;도구를 별도 모듈로 분리해, 도구가 늘어나도 에이전트 본체가 비대해지지 않게 했음.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 에이전트 본체 (lg_rag_agent.py)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 모델&amp;middot;도구 준비&lt;/h3&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;온도&amp;middot;토큰 한도를 지정한 Bedrock 모델에 RAG 도구를 바인딩함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from langgraph.graph import StateGraph, END
from typing import TypedDict, List
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, BaseMessage
from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate
from langchain_aws import ChatBedrockConverse
from langgraph.prebuilt import ToolNode, tools_condition
from dotenv import load_dotenv
import os, boto3
from tools import rag_search   # 외부에 구성한 커스텀 RAG 도구

load_dotenv()

llm = ChatBedrockConverse(
    model=os.getenv('MODEL_ID'),
    max_tokens=1000,
    temperature=0.5,
    region_name=os.getenv('AWS_REGION')
)

tools = [rag_search]
llm_with_tools = llm.bind_tools(tools)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.2 FewShot 프롬프트 구성&lt;/h3&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;예시 입력-출력 쌍을 주어 답변 톤(센스 있는 추천)을 학습시킴.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 샘플 형태는 다양하게 구성 가능
examples = [
    {&quot;input&quot;: &quot;비 오는 날 국밥이 땡겨&quot;, &quot;output&quot;: &quot;국룰이죠. 칼국수와 잔치국수가 좋습니다.&quot;},
    {&quot;input&quot;: &quot;다이어트를 위해서 오늘 칼로리가 낮은 걸로 추천해줘.&quot;, &quot;output&quot;: &quot;관리하시는군요. 닭가슴 샐러드 드세요.&quot;}
]
examples_prompt = ChatPromptTemplate.from_messages([
    ('human', '{input}'),
    ('ai', '{output}')
])
few_shot_prompt = FewShotChatMessagePromptTemplate(
    examples=examples,
    example_prompt=examples_prompt
)

final_prompt = ChatPromptTemplate.from_messages([
    # 1. 페르소나
    ('system', '당신은 센스있는 식사 메뉴 추천 전문가입니다. 사용자의 상황에 맞춰 메뉴를 추천하고, 필요하면 도구를 사용해 실제 식당을 찾으세요.'),
    # 2. 퓨샷 샘플
    few_shot_prompt,
    # 3. 사용자 질의
    ('human', '{query}')
])
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`시스템(페르소나) &amp;rarr; FewShot 예시 &amp;rarr; 사용자 질의` 순서로 프롬프트가 조립됨.
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FewShot은 &quot;이런 식으로 답하라&quot;는 모범 답안을 보여주는 기법이다. 규칙을 길게 설명하는 것보다 예시 한두 개가 톤을 더 정확히 잡아준다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.3 상태 정의&lt;/h3&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;메시지 리스트를 담는 커스텀 상태를 ①편처럼 직접 정의함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class AgentState(TypedDict):
    messages: List[BaseMessage]
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 노드 3종 구성&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;&quot;생각 &amp;rarr; (필요 시)도구 &amp;rarr; 최종 답변&quot; 세 노드로 의사결정을 나눔.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.1 thinking_node &amp;mdash; 1차 추론&lt;/h3&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;사용자 질의를 받아 직접 답할지, 도구를 쓸지 판단함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;def thinking_node(state: AgentState):
    # 사용자 입력 추출 (UI 입력 -&amp;gt; 서버 -&amp;gt; 그래프.invoke(프롬프트) -&amp;gt; state['messages'])
    msg = state['messages'][-1].content
    # 랭체인 단방향 구성: FewShot 프롬프트(페르소나+샘플) | LLM
    chain = final_prompt | llm_with_tools
    # 1차 추론
    res = chain.invoke({&quot;query&quot;: msg})
    return {'messages': [res]}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;프롬프트와 LLM을 `|`로 묶은 LangChain 체인을 하나의 노드 안에 넣은 점이 포인트임.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.2 tool_node &amp;mdash; RAG 검색 수행&lt;/h3&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;LLM이 도구 사용을 결정했을 때, 실제 RAG 검색을 실행하고 결과를 메시지로 되돌림.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;def tool_node(state: AgentState):
    last_msg = state['messages'][-1]
    print('tool_node 호출 : 툴 사용 LLM이 응답', last_msg.tool_calls)
    if last_msg.tool_calls:
        # 등록 도구가 1개라 0으로 고정. 도구가 늘면 상황별 선택 부여
        tool = last_msg.tool_calls[0]
        # tool_calls 예시 형태:
        # [{'name':'rag_search', 'args':{'cate':'가벼운 식사'},
        #   'id':'tooluse-...', 'type':'tool_call'}]
        result = rag_search.invoke(tool['args'])  # RAG 수행
        print('RAG 호출 결과 :', result)
        return {&quot;messages&quot;: [
            HumanMessage(content=f'[사내데이터 검색결과]: {result}\n 제공된 정보를 기반으로 최종 답변을 해주세요.')
        ]}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;검색 결과를 다시 사람 메시지처럼 주입해, 다음 노드가 그 근거로 답변하도록 유도함.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.3 final_answer_node &amp;mdash; 근거 기반 최종 답변&lt;/h3&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;검색 결과가 포함된 전체 맥락으로 LLM이 마무리 답변을 생성함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;def final_answer_node(state: AgentState):
    msg = state['messages']
    res = llm.invoke(msg)
    return {'messages': [res]}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;여기서는 별도 노드로 분리했지만, 실제로는 다시 `thinking_node`로 돌려보내 마무리해도 무방함.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 조건부 엣지와 흐름&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;커스텀 분기 함수로 &quot;도구가 필요한지&quot;를 직접 판정함.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;②~③편은 라이브러리 제공 tools_condition을 썼음. 이번에는 분기 로직을 직접 작성해 동작을 명시적으로 드러냄.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;def custom_check_tool_node(state: AgentState):
    # 마지막 AI 응답에서 tool_calls 확인
    last_msg = state['messages'][-1]
    if last_msg.tool_calls:
        print('툴 사용 필요')
        return 'tool'
    else:  # 도구 불필요 -&amp;gt; 답변 완성 -&amp;gt; END
        return END

workflow = StateGraph(AgentState)
workflow.add_node('thinking', thinking_node)
workflow.add_node('tool', tool_node)
workflow.add_node('final_answer', final_answer_node)
workflow.set_entry_point('thinking')

workflow.add_conditional_edges('thinking', custom_check_tool_node)
workflow.add_edge('tool', 'final_answer')   # 도구 사용 -&amp;gt; 최종 답변
workflow.add_edge('final_answer', END)

랭그래프객체 = workflow.compile()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름 시나리오는 다음과 같음.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;시나리오&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 흐름 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;베스트&lt;/td&gt;
&lt;td&gt;프롬프트 &amp;rarr; thinking &amp;rarr; END&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAG 경유&lt;/td&gt;
&lt;td&gt;프롬프트 &amp;rarr; thinking &amp;rarr; (부족) &amp;rarr; tool &amp;rarr; RAG &amp;rarr; final_answer &amp;rarr; END&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;②편의 분기가 라이브러리에 맡긴 자동 분기였다면, 여기서는 분기 기준을 직접 코딩했다. 동작 원리를 손으로 짚어보는 단계다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 실행&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;상황과 위치를 담은 질의를 던지면, RAG로 실제 식당을 찾아 식단까지 구성함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;if __name__ == '__main__':
    res = 랭그래프객체.invoke({
        &quot;messages&quot;: [HumanMessage(content=&quot;근처에서 점심 추천 식당 세 군데 해주고 식단까지 그 식당 메뉴에 맞춰 짜줘. 영등포역이야&quot;)]
    })
    print(res)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;LLM이 추천만으로 부족하다고 판단하면 `rag_search`를 호출해 더미 DB의 실제 식당 정보를 근거로 답변을 마무리함.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;RAG를 도구로 결합하고 노드를 3단계로 나눠, 근거 기반으로 답하는 식사 추천 에이전트를 완성했음.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;항목&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;LangGraph 3&lt;/td&gt;
&lt;td&gt;LangGraph 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도구 성격&lt;/td&gt;
&lt;td&gt;계산(행동)&lt;/td&gt;
&lt;td&gt;지식 검색(RAG)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터 출처&lt;/td&gt;
&lt;td&gt;LLM 내부 지식&lt;/td&gt;
&lt;td&gt;외부 벡터 DB(FAISS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;프롬프트&lt;/td&gt;
&lt;td&gt;기본&lt;/td&gt;
&lt;td&gt;FewShot + 페르소나&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;노드 수&lt;/td&gt;
&lt;td&gt;2개(chatbot/tools)&lt;/td&gt;
&lt;td&gt;3개(thinking/tool/final)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;분기&lt;/td&gt;
&lt;td&gt;tools_condition&lt;/td&gt;
&lt;td&gt;커스텀 분기 함수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모듈 구성&lt;/td&gt;
&lt;td&gt;단일 파일&lt;/td&gt;
&lt;td&gt;본체/도구/저장소 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4단계를 거치며 그래프는 &quot;고정 흐름 &amp;rarr; 자율 분기 &amp;rarr; 기억 &amp;rarr; 외부 지식 보강&quot;으로 발전했다. 다음 확장 지점은 외부 영속 벡터 DB, 다중 도구 선택, 그리고 MCP 연계다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>SK플래닛 ai활용 데이터엔지니어 과정 2기/ML &amp;amp; DL</category>
      <author>dev-lee</author>
      <guid isPermaLink="true">https://dev-lee.tistory.com/113</guid>
      <comments>https://dev-lee.tistory.com/113#entry113comment</comments>
      <pubDate>Tue, 2 Jun 2026 13:01:06 +0900</pubDate>
    </item>
    <item>
      <title>LangGraph 3 - 단기기억</title>
      <link>https://dev-lee.tistory.com/112</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 이번 단계에서 달라지는 것&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;한 번 질의하면 잊어버리던 에이전트에, 대화를 이어 기억하는 단기기억을 추가함.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;②편의 에이전트는 매 질의가 &quot;새 채팅창&quot;이었음. 이전 대화를 기억하지 못해 같은 맥락을 반복 설명해야 했음. 이번에는 Checkpointer를 붙여 대화 상태를 저장하고, thread_id로 사용자별 기억을 관리함.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;MemorySaver&lt;/b&gt; &amp;mdash; 상태를 저장하는 단기기억 저장소. 현재는 RAM 기반(프로그램 종료 시 삭제)임.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;checkpointer 옵션&lt;/b&gt; &amp;mdash; compile 시 기억 공간을 그래프에 연결함.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;thread_id&lt;/b&gt; &amp;mdash; 대화 세션을 구분하는 식별자. 같은 id면 기억이 누적됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래프 구조 자체는 ②편과 동일하다. 바뀐 건 &quot;상태를 저장하고 다음 호출에 다시 불러온다&quot;는 점뿐이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 메모리 생성&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`MemorySaver` 객체 하나를 만들어 두면 됨.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;from langgraph.checkpoint.memory import MemorySaver  # 단기기억용. 종료 시 삭제

# 메모리 생성 -&amp;gt; 현재는 RAM 저장. 실제 운영은 외부 벡터/영속 DB로 대체
memory = MemorySaver()
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;인메모리라 재시작하면 사라짐. 실서비스에서는 물리적 DB(예: Redis, Postgres 등)로 교체하는 게 일반적임.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 모델&amp;middot;도구&amp;middot;노드 (②편과 동일)&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;기억 추가만이 목적이므로 LLM&amp;middot;도구&amp;middot;판단 노드는 그대로 가져감.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;from langgraph.graph import StateGraph, END, MessagesState, START
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
from langchain_aws import ChatBedrockConverse
from langgraph.prebuilt import ToolNode, tools_condition
from dotenv import load_dotenv
import os, boto3

load_dotenv()

llm = ChatBedrockConverse(
    model=os.getenv('MODEL_ID'),
    client=boto3.client('bedrock-runtime', region_name=os.getenv('AWS_REGION'))
)

@tool
def multiply(a: int, b: int) -&amp;gt; int:
    '''두 수를 곱한 후 반환'''
    print(f'       [TOOL 실행] {a}x{b} 계산중..')
    return a * b

tools = [multiply]
llm_with_tools = llm.bind_tools(tools)

def chatbot_node(state: MessagesState):
    res = llm_with_tools.invoke(state['messages'])
    return {&quot;messages&quot;: [res]}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;핵심 로직은 변하지 않았음. 기억은 그래프 외부(체크포인터)에서 관리되기 때문임.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 그래프 구성 &amp;mdash; 컴파일에 checkpointer 연결&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`compile(checkpointer=memory)` 한 줄이 단기기억을 켜는 스위치임.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;workflow = StateGraph(MessagesState)
workflow.add_node('chatbot', chatbot_node)     # 판단 노드
workflow.add_node('tools', ToolNode(tools))    # 행동 노드
workflow.add_edge(START, 'chatbot')

workflow.add_conditional_edges(
    'chatbot',          # 텍스트 응답 -&amp;gt; END
    tools_condition     # 도구 필요 -&amp;gt; tools 노드
)
workflow.add_edge('tools', 'chatbot')

# 컴파일 옵션으로 단기기억 공간 제공
app = workflow.compile(checkpointer=memory)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`checkpointer`가 붙으면, 그래프는 매 실행 후 상태를 저장하고 다음 호출 때 같은 `thread_id`의 상태를 자동으로 복원함.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 실행 &amp;mdash; thread_id로 세션 고정&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`config`에 `thread_id`를 담아 넘기면 대화가 누적됨.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;if __name__ == '__main__':
    # 사용자별 기억 관리. 여기서는 &quot;user-1&quot;로 고정
    config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;user-1&quot;}}
    while True:
        user_input = input('\n유저: ').lower()
        if user_input == 'q':
            break
        prompt = {&quot;messages&quot;: [HumanMessage(content=user_input)]}
        # 실행 시 config를 함께 전달해야 기억이 이어짐
        for evt in app.stream(prompt, stream_mode='values', config=config):
            msg = evt['messages'][-1]
            print(&quot;Agent&quot;, msg.content)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`config` 없이 호출하면 체크포인터가 어떤 세션인지 알 수 없어 기억이 이어지지 않음.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 동작 확인&lt;/h2&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;이전 질의를 기억하는지 3턴에 걸쳐 확인함.
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;1차&lt;/b&gt; &amp;mdash; 100 곱하기 2는 &amp;rarr; 200&lt;/li&gt;
&lt;li&gt;&lt;b&gt;2차&lt;/b&gt; &amp;mdash; 100 곱하기 3는 &amp;rarr; 300&lt;/li&gt;
&lt;li&gt;&lt;b&gt;3차&lt;/b&gt; &amp;mdash; 그간 물어봤던 질문들을 간단하게 정리해줘&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3차 응답에서 에이전트가 앞선 두 질문을 스스로 정리해 냈음.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Agent 지금까지 물어보신 질문들을 정리하면 다음과 같습니다:

1. 100 곱하기 2는? &amp;rarr; 답: 200
2. 100 곱하기 3은? &amp;rarr; 답: 300

두 번 모두 곱셈 계산을 요청하셨습니다.
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;질문을 거듭할수록 이전 대화가 상태에 누적되어 프롬프트로 함께 전달된다. 그래서 &quot;정리해줘&quot; 같은 맥락 의존 질의가 가능해진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;그래프 로직은 그대로 둔 채 체크포인터만 추가해, 맥락을 기억하는 멀티턴 에이전트로 발전시켰음.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 105px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;항목&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;LangGraph 2&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt; LangGraph 3&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;대화 기억&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;없음(매번 초기화)&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;있음(누적)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;추가 요소&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&amp;mdash;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;MemorySaver + thread_id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;변경 지점&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&amp;mdash;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;compile(checkpointer=...), config 전달&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;저장 위치&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&amp;mdash;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;RAM(향후 외부 DB로 확장)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 에이전트는 &quot;기억&quot;한다. 다음 단계에서는 LLM이 모르는 외부&amp;middot;사내 데이터를 RAG로 끌어와 할루시네이션을 막는다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>SK플래닛 ai활용 데이터엔지니어 과정 2기/ML &amp;amp; DL</category>
      <author>dev-lee</author>
      <guid isPermaLink="true">https://dev-lee.tistory.com/112</guid>
      <comments>https://dev-lee.tistory.com/112#entry112comment</comments>
      <pubDate>Tue, 2 Jun 2026 12:59:35 +0900</pubDate>
    </item>
    <item>
      <title>LangGraph 2 - Tool + LLM</title>
      <link>https://dev-lee.tistory.com/111</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 이번 단계에서 달라지는 것&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;고정된 단방향 흐름에서 벗어나, LLM이 &quot;직접 답할지 / 도구를 쓸지&quot;를 스스로 판단하는 구조로 발전시킴.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;①편의 그래프는 경로가 코드에 박혀 있었다. 이번에는 add_conditional_edges로 분기를 주고, LLM이 도구 호출 여부를 결정하게 만든다. 핵심 추가 요소는 다음과 같음.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;LLM 노드&lt;/b&gt; &amp;mdash; AWS Bedrock 모델을 호출해 추론하는 &quot;판단 노드&quot;임.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Tool 노드&lt;/b&gt; &amp;mdash; 실제 기능(여기서는 곱셈)을 수행하는 &quot;행동 노드&quot;임.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;조건부 엣지&lt;/b&gt; &amp;mdash; LLM의 응답이 도구 호출인지 텍스트인지에 따라 경로가 갈림.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;①편이 직선 도로였다면, 이번 단계는 LLM이 갈림길에서 핸들을 잡는 구조다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 준비 &amp;mdash; 모델과 환경변수&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;Bedrock LLM 객체를 만들고 `.env`에서 모델 정보를 로드함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from langgraph.graph import StateGraph, END, MessagesState, START
from langchain_core.tools import tool                # 툴 정의용 데코레이터
from langchain_core.messages import HumanMessage     # 사용자 메시지를 편하게 구성
from langchain_aws import ChatBedrock, ChatBedrockConverse  # AWS Bedrock LLM 호출
from langgraph.prebuilt import ToolNode, tools_condition    # 툴-&amp;gt;노드 변환, 조건부 분기
from dotenv import load_dotenv
import os, boto3

load_dotenv()

# LLM 추론용 객체(전역). 모델별로 ChatBedrock / ChatBedrockConverse 교체 적용
# 예: us.anthropic.claude-haiku-4-5-20251001-v1:0
llm = ChatBedrockConverse(
    model=os.getenv('MODEL_ID'),
    client=boto3.client('bedrock-runtime', region_name=os.getenv('AWS_REGION'))
)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`MessagesState`는 LangGraph가 미리 제공하는 상태로, 대화 메시지 누적에 특화되어 있음.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 MessagesState를 쓰는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;①편에서는 TypedDict로 상태를 직접 정의했음. 대화형 에이전트는 메시지 리스트를 계속 누적해야 하므로, 라이브러리가 제공하는 MessagesState(내부에 messages 키 보유)를 그대로 사용함.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 도구 정의와 등록&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`@tool` 데코레이터로 함수를 LLM이 이해할 수 있는 도구로 변환함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@tool   # LLM이 이해할 수 있는 형식으로 자동 변환됨
def multiply(a: int, b: int) -&amp;gt; int:
    '''두 수를 곱한 후 반환'''
    print(f'       [TOOL 실행] {a}x{b} 계산중..')
    return a * b

# 여러 툴을 모아 LLM에 등록(여기서는 1개)
tools = [multiply]
llm_with_tools = llm.bind_tools(tools)   # &quot;이런 도구를 쓸 수 있다&quot;고 LLM에 알림
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;`bind_tools`는 LLM에게 도구의 존재와 사용법을 알릴 뿐, 강제하지는 않음. 사용 여부는 LLM이 판단함.
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수의 docstring과 타입 힌트가 곧 LLM이 읽는 &quot;도구 설명서&quot;가 된다. 그래서 docstring을 명확히 쓰는 게 중요하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 노드 구성&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;사용자 입력을 LLM에 전달해 &quot;직접 답변&quot; 또는 &quot;도구 호출&quot;을 받아오는 판단 노드를 만듦.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;def chatbot_node(state: MessagesState):
    print('[chatbot_node 호출 전 상태값]', state)

    # 사용자 프롬프트를 LLM에 전달 -&amp;gt; LLM이 직접 해결할지/도구를 쓸지 판단 -&amp;gt; 응답
    res = llm_with_tools.invoke(state['messages'])  # messages 키는 MessagesState에 정의됨
    new_state = {&quot;messages&quot;: [res]}                  # MessagesState 형식에 맞춤

    print('[chatbot_node 호출 후 상태값]', new_state)
    return new_state
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;LLM이 도구가 필요하다고 판단하면, `res` 안에 `tool_calls` 정보가 담겨 돌아옴.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 조건부 엣지로 그래프 구성&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`tools_condition`이 LLM 응답을 보고 도구 노드로 보낼지 종료할지 자동 분기함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;workflow = StateGraph(MessagesState)

# 노드 추가
workflow.add_node('chatbot', chatbot_node)   # 생각/판단 노드
workflow.add_node('tools', ToolNode(tools))  # 도구 수행(곱하기) 행동 노드

# 시작점 (= set_entry_point)
workflow.add_edge(START, 'chatbot')          # 데이터 주입 시 가장 먼저 작동

# 조건부 엣지: 이전 노드 응답에 따라 경로 분기
workflow.add_conditional_edges(
    'chatbot',          # 텍스트 응답이면 -&amp;gt; END
    tools_condition     # 도구가 필요하다는 응답이면 -&amp;gt; tools 노드
)

# 도구 사용 -&amp;gt; 결과 -&amp;gt; 다시 chatbot으로 (순환 구조)
workflow.add_edge('tools', 'chatbot')

app = workflow.compile()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 만들어내는 두 가지 시나리오는 다음과 같음.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;시나리오&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 흐름 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;직접 응답&lt;/td&gt;
&lt;td&gt;질의 &amp;rarr; chatbot &amp;rarr; LLM 추론 &amp;rarr; 응답 &amp;rarr; END&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도구 사용&lt;/td&gt;
&lt;td&gt;질의 &amp;rarr; chatbot &amp;rarr; 도구 필요 판단 &amp;rarr; tools &amp;rarr; 결과 &amp;rarr; chatbot &amp;rarr; 응답 &amp;rarr; END&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;①편의 단방향 엣지가 여기서 &quot;순환 가능한&quot; 엣지로 진화했다. tools &amp;rarr; chatbot이 다시 이어지면서 사이클이 생긴다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 실행 &amp;mdash; 스트리밍 루프&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`stream` 모드로 응답을 실시간 출력하며 대화함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;if __name__ == '__main__':
    while True:
        user_input = input('\n유저:').lower()
        if user_input == 'q':
            break
        # 프롬프트 구성
        prompt = {&quot;messages&quot;: [HumanMessage(content=user_input)]}
        print(prompt)
        # invoke: 동기식 / stream: 비동기식(점진 출력)
        for evt in app.stream(prompt, stream_mode='values'):
            msg = evt['messages'][-1]   # 마지막에 추가된 응답
            print(&quot;Agent&quot;, msg.content)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;`stream_mode='values'`는 매 노드 실행 후 갱신된 전체 상태를 흘려보내, 진행 과정을 실시간으로 볼 수 있게 함.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6.1 모델별 동작 차이&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 그래프라도 LLM에 따라 도구 사용 성향이 달랐음.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;OpenAI 계열&lt;/b&gt; &amp;mdash; LLM이 직접 추론해 응답. 도구를 쓰지 않는 경우가 있었음.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Claude 계열&lt;/b&gt; &amp;mdash; 도구를 적극 사용. 도구 호출 후 재추론까지 LLM이 2회 추론하는 경우가 있었음.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;도구와 조건부 엣지를 더해, LLM이 경로를 스스로 결정하는 순환형 에이전트로 발전시켰음.
&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;항목&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;LangGraph 1&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; LangGraph 2&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;상태&lt;/td&gt;
&lt;td&gt;TypedDict 직접 정의&lt;/td&gt;
&lt;td&gt;MessagesState 활용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;흐름&lt;/td&gt;
&lt;td&gt;고정 단방향&lt;/td&gt;
&lt;td&gt;조건부 분기 + 순환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;판단 주체&lt;/td&gt;
&lt;td&gt;코드(개발자)&lt;/td&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 기능&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;@tool 도구 호출&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 한 번의 질의는 잘 처리한다. 그러나 새 질문마다 기억이 초기화되어 이전 대화를 모른다. 다음 단계에서 단기기억을 붙인다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>SK플래닛 ai활용 데이터엔지니어 과정 2기/ML &amp;amp; DL</category>
      <author>dev-lee</author>
      <guid isPermaLink="true">https://dev-lee.tistory.com/111</guid>
      <comments>https://dev-lee.tistory.com/111#entry111comment</comments>
      <pubDate>Tue, 2 Jun 2026 12:43:28 +0900</pubDate>
    </item>
    <item>
      <title>LangGraph 1 - 상태 그래프 기초</title>
      <link>https://dev-lee.tistory.com/110</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. LangGraph 개요&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;LLM 워크플로우를 &quot;상태를 공유하는 노드들의 그래프&quot;로 표현하는 프레임워크임.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LangChain이 호출을 직선으로 잇는 체인 구조라면, LangGraph는 노드를 자유롭게 연결하고 조건&amp;middot;순환까지 표현할 수 있는 그래프 구조다. 첫 단계에서는 가장 단순한 형태인 &lt;b&gt;단방향 그래프&lt;/b&gt;부터 시작함.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 핵심 3요소&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;State(상태)&lt;/b&gt; &amp;mdash; 모든 노드가 공유하는 전역 메모리. 데이터를 담는 그릇 역할을 함.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Node(노드)&lt;/b&gt; &amp;mdash; 작은 단위의 Task. 단순한 함수 하나로 구성됨.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Edge(엣지)&lt;/b&gt; &amp;mdash; 노드 간 실행 순서(방향성)를 지정하는 연결선임.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노드는 &quot;무엇을 할지&quot;, 엣지는 &quot;어떤 순서로 흐를지&quot;를 담당한다고 보면 된다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 상태 정의&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;공유 메모리의 형태를 `TypedDict`로 규정함.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태는 그래프 전체를 흐르는 데이터의 형태다. 어떤 키를 가질지 미리 정의해 두면 모든 노드가 같은 구조를 참조할 수 있음.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;from langgraph.graph import StateGraph, END
from typing import TypedDict   # 공유 메모리의 형태를 규정할 때 활용

# 상태 정의: 데이터를 저장할 그릇이자 공유 메모리. 모든 노드에서 접근 가능
class CustomState(TypedDict):
    msg: str
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`msg`라는 문자열 키 하나만 가진 단순한 상태이다.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 노드 구성&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;특정 목적을 가진 작은 함수가 곧 노드가 됨.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노드는 상태를 입력받아 일부를 가공해 다시 상태 형태로 반환하는 함수다. 여기서는 문자열 앞뒤에 텍스트를 덧붙이는 두 노드를 만듦.&lt;/p&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;# 노드는 단순한 함수로 구성됨
def add_prefix(state: CustomState):
    '''
    기존 상태값 앞에 특정 내용을 추가
    parameters:
        - state : 공유 메모리(전역 상태). 랭그래프가 관리하는 상태
    '''
    return {'msg': &quot;하이 &quot; + state['msg']}

def add_suffix(state: CustomState):
    # 기존 상태값 뒤에 특정 내용을 추가
    return {'msg': state['msg'] + &quot; !!&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;각 노드는 변경된 부분만 dict로 반환하면, 랭그래프가 상태에 병합해 다음 노드로 넘김.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 그래프 연결과 컴파일&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;노드를 등록하고 시작점&amp;middot;방향&amp;middot;끝점을 지정한 뒤 실행 가능한 형태로 컴파일함.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노드를 추가한 직후의 그래프는 시작과 끝을 모르는 &quot;원형(circle)&quot; 상태다. 엣지를 걸어줘야 비로소 방향이 생김.&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 4-1. 그래프를 연결할 타겟(구조적 틀) &amp;mdash; CustomState 기반 공유 메모리로 구성
workflow = StateGraph(CustomState)

# 4-2. 노드(task/tool/agent) 추가 &amp;mdash; 아직 시작&amp;middot;끝을 모르는 상태
workflow.add_node(&quot;T1&quot;, add_prefix)
workflow.add_node(&quot;T2&quot;, add_suffix)

# 4-3. 시작점 설정 &amp;mdash; 그래프 호출 시 이 노드가 먼저 호출됨(상태값을 전달받아)
workflow.set_entry_point(&quot;T1&quot;)

# 4-4. 작업 순서(방향성) 지정 &amp;mdash; T1 -&amp;gt; T2
workflow.add_edge('T1', 'T2')

# 4-5. 끝점 설정 &amp;mdash; T2 수행이 끝나면 종료
workflow.add_edge(&quot;T2&quot;, END)

# 4-6. 컴파일 &amp;mdash; 실행 가능한 형태로 완성
app = workflow.compile()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계의 구성 요소를 정리하면 다음과 같음.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;메서드&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 역할 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StateGraph(State)&lt;/td&gt;
&lt;td&gt;상태 기반 그래프의 골격 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;add_node(name, fn)&lt;/td&gt;
&lt;td&gt;노드(함수) 등록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;set_entry_point(name)&lt;/td&gt;
&lt;td&gt;시작 노드 지정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;add_edge(a, b)&lt;/td&gt;
&lt;td&gt;a &amp;rarr; b 단방향 연결&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;add_edge(b, END)&lt;/td&gt;
&lt;td&gt;종료 지점 연결&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;compile()&lt;/td&gt;
&lt;td&gt;실행 가능한 앱으로 변환&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 실행&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;데이터를 주입하면 그래프를 순환하며 요청을 처리함.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 형태에 맞춰 데이터를 넣고 invoke로 호출하면 데이터 &amp;rarr; T1 &amp;rarr; T2 &amp;rarr; END 순으로 흐름.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;# 데이터 주입 -&amp;gt; 그래프 호출 -&amp;gt; 데이터 -&amp;gt; 노드 -&amp;gt; 노드 -&amp;gt; END
res = app.invoke({&quot;msg&quot;: &quot;랭그래프&quot;})
print(res)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;{'msg': '하이 랭그래프 !!'}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;`add_prefix`가 &quot;하이 &quot;를 붙이고, `add_suffix`가 &quot; !!&quot;를 붙인 결과가 그대로 누적됨.
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직은 흐름이 코드에 고정되어 있어 분기&amp;middot;반복이 없다. 다음 단계에서 LLM이 직접 경로를 판단하게 만든다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;LangGraph의 최소 단위인 State&amp;middot;Node&amp;middot;Edge를 조립해 단방향 그래프를 만들고 실행했음.
&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;개념&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 이번 글에서의 구현 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State&lt;/td&gt;
&lt;td&gt;TypedDict로 msg 키 하나 정의&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node&lt;/td&gt;
&lt;td&gt;접두/접미 추가 함수 2개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge&lt;/td&gt;
&lt;td&gt;T1 &amp;rarr; T2 &amp;rarr; END 고정 단방향&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;흐름 제어&lt;/td&gt;
&lt;td&gt;없음(코드에 고정)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 그래프는 &quot;정해진 길만 가는&quot; 구조다. 이후 조건부 엣지&amp;middot;도구&amp;middot;기억을 더해가며 점점 자율적인 에이전트로 발전시킨다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>SK플래닛 ai활용 데이터엔지니어 과정 2기/ML &amp;amp; DL</category>
      <author>dev-lee</author>
      <guid isPermaLink="true">https://dev-lee.tistory.com/110</guid>
      <comments>https://dev-lee.tistory.com/110#entry110comment</comments>
      <pubDate>Tue, 2 Jun 2026 12:40:59 +0900</pubDate>
    </item>
    <item>
      <title>LLM 2 - 서비스 구축 실습</title>
      <link>https://dev-lee.tistory.com/109</link>
      <description>&lt;h1&gt;LLM 서비스 구축 &amp;middot; RAG&lt;/h1&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;프롬프트 엔지니어링 개념을 실제 동작하는 서비스로 구현함.
Streamlit(프론트) + FastAPI(백엔드) + Bedrock + LangChain으로 &quot;식사 메뉴 추천 AI&quot;를 구성하고,
이후 RAG + Vector DB(FAISS)를 도입하여 LLM이 모르는 데이터를 추론에 활용함.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 서비스 아키텍처&lt;/h2&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;화면(Streamlit)과 API(FastAPI)를 분리하고, LLM 호출은 별도 모듈로 격리하는 계층형 구조로 설계함.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 구성 요소&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;계층&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;역할&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 기술 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;프론트엔드&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;채팅 화면, 입력/출력 처리&lt;/td&gt;
&lt;td&gt;Streamlit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;백엔드&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;/chat API 제공, 프론트 &amp;harr; Bedrock 중계&lt;/td&gt;
&lt;td&gt;FastAPI + uvicorn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;LLM 모듈&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;프롬프트 구성, Bedrock 호출, 체인 구성&lt;/td&gt;
&lt;td&gt;LangChain (langchain-aws)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;모델 서빙&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;LLM 추론 담당&lt;/td&gt;
&lt;td&gt;AWS Bedrock&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;서비스 주제&lt;/b&gt; &amp;mdash; 점심/저녁 식사 메뉴 추천 (날씨&amp;middot;기분&amp;middot;단체여부&amp;middot;예산&amp;middot;MBTI 등 상황 기반)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;프론트와 백엔드 분리&lt;/b&gt; &amp;mdash; 프론트는 화면만, 백엔드는 LLM 통신만 담당하여 책임을 나눔&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 프로젝트 구조&lt;/h3&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;/
├─ .env              : 환경변수 (Bedrock 키, 리전, 모델 ID)
├─ .gitignore        : 가상환경/.env 등 git 미반영 처리
├─ requirements.txt  : 패키지 목록
├─ app.py            : 프론트엔드 (Streamlit)
├─ server.py         : 백엔드 (FastAPI)
└─ llm/__init__.py   : Bedrock 통신 + LangChain 체인 모듈
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 환경 설정&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# 가상환경 구축 및 활성화 (Windows)
python -m venv llm_venv
./llm_venv/Scripts/activate

# 패키지 설치
pip install -r requirements.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;# requirements.txt
fastapi          # 백엔드 구성
uvicorn          # FastAPI 구동
streamlit        # 프론트 구성
requests         # 프론트 -&amp;gt; 백엔드 요청
boto3            # AWS SDK
langchain-aws    # 랭체인 AWS 전용
langchain-core   # 랭체인 코어
python-dotenv    # .env 로드
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# .env (키는 git에 올리지 않음)
AWS_REGION='us-east-1'
MODEL_ID='google.gemma-3-27b-it'
AWS_BEARER_TOKEN_BEDROCK='bedrock-api-key-...'
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키 정보는 .env로 분리하고 .gitignore에 등록함. 코드에는 os.getenv()로 주입받아 자격증명이 소스에 노출되지 않도록 함.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 백엔드 &amp;mdash; FastAPI&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;프론트에서 들어온 채팅 입력을 받아 LLM 체인을 호출하고, 응답을 다시 프론트로 전달하는 중계 서버.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 server.py&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;from fastapi import FastAPI
from pydantic import BaseModel   # 입력 구조 정의 + 유효성 검사
from llm import chain            # LLM 모듈의 체인 import

app = FastAPI(title='식사 메뉴 추천 AI')

# 요청 데이터 구조 정의
class UserRequest(BaseModel):
    query: str

@app.post('/chat')
async def chat(req: UserRequest):
    try:
        response = chain.invoke({&quot;user_input&quot;: req.query})
        return {&quot;response&quot;: response.content}
    except Exception as e:
        return {&quot;response&quot;: f&quot;에러 {e}&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Pydantic BaseModel&lt;/b&gt; &amp;mdash; 요청 JSON의 구조(query: str)를 강제하여 잘못된 입력을 사전 차단&lt;/li&gt;
&lt;li&gt;&lt;b&gt;chain.invoke()&lt;/b&gt; &amp;mdash; LangChain 체인 호출, 입력 변수명(user_input)이 프롬프트 템플릿과 일치해야 함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;response.content&lt;/b&gt; &amp;mdash; LangChain 응답 객체에서 본문 텍스트만 추출&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 요청 흐름&lt;/h3&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;클라이언트 채팅 입력 -&amp;gt; POST /chat -&amp;gt; 프롬프트 구성 -&amp;gt; Bedrock 호출 -&amp;gt; 응답 파싱 -&amp;gt; 프론트 전달
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면이 없는 순수 API 서버임. try/except로 LLM 호출 실패 시에도 서버가 죽지 않고 에러 메시지를 응답으로 반환하도록 처리함.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. LLM 모듈 &amp;mdash; Bedrock + LangChain&lt;/h2&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;Bedrock 클라이언트 생성부터 Few-Shot 프롬프트, 체인 구성까지 담당하는 핵심 모듈.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 클라이언트 / 모델 구성&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import os, boto3
from dotenv import load_dotenv
from langchain_aws import ChatBedrockConverse
from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate

load_dotenv()

# Bedrock 클라이언트
bedrock_client = boto3.client(
    service_name='bedrock-runtime',
    region_name=os.getenv('AWS_REGION')
)

# LLM 객체
llm = ChatBedrockConverse(
    client=bedrock_client,
    model_id=os.getenv('MODEL_ID'),
    max_tokens=512,
    temperature=0.7
)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;ChatBedrockConverse&lt;/b&gt; &amp;mdash; Bedrock의 converse API를 LangChain 인터페이스로 래핑, 모델명만 바꿔도 동일 코드로 호출 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;temperature=0.7&lt;/b&gt; &amp;mdash; 추천 서비스 특성상 약간의 다양성을 허용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;max_tokens=512&lt;/b&gt; &amp;mdash; 응답 길이 제한&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 Few-Shot 프롬프트 구성&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 1. 예시 데이터 (입력 -&amp;gt; 모범 출력)
fewshot_samples = [
    {&quot;input&quot;: &quot;오늘 점심 메뉴 추천해줘. 비가 오고 있어서 나가기 귀찮아. 혼자 먹을 거야.&quot;,
     &quot;output&quot;: &quot;비 오는 날, 집에서 편하게 즐길 수 있는 '따뜻한 김치수제비와 해물파전'을 추천합니다! ...&quot;},
    # ... (회식, 데이트, 스트레스 상황 등 4개)
]

# 2. 예시 1건의 포맷 (human -&amp;gt; ai)
fewshot_samples_format = ChatPromptTemplate.from_messages([
    ('human', '{input}'),
    ('ai',    '{output}')
])

# 3. Few-Shot 프롬프트로 변환
fewshot_prompt = FewShotChatMessagePromptTemplate(
    examples=fewshot_samples,
    example_prompt=fewshot_samples_format
)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Few-Shot의 목적&lt;/b&gt; &amp;mdash; 파인튜닝 없이 &quot;일정한 품질의 결과물&quot;을 유도, 답변 톤과 형식을 예시로 학습시킴&lt;/li&gt;
&lt;li&gt;&lt;b&gt;FewShotChatMessagePromptTemplate&lt;/b&gt; &amp;mdash; 예시를 실제 대화(human/ai) 형태로 주입하여 채팅 모델에 자연스럽게 전달&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상황 다양화&lt;/b&gt; &amp;mdash; 비 오는 날 혼밥, 단체 회식, 데이트, 스트레스 등 시나리오를 골고루 넣어 일반화 유도&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.3 체인 구성&lt;/h3&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;# 최종 프롬프트 = 페르소나(system) + 예시(few-shot) + 사용자 입력(human)
last_prompt = ChatPromptTemplate.from_messages([
    ('system', '당신은 직장인들의 식사 메뉴 고민을 해결해주는 계획적인 메뉴 추천 전문가입니다. ...'),
    fewshot_prompt,           # 샘플 삽입
    ('human', '{user_input}') # 실제 사용자 질의
])

# 단방향 체인 (프롬프트 -&amp;gt; LLM)
chain = last_prompt | llm
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트는 &lt;b&gt;페르소나 &amp;rarr; Few-Shot 예시 &amp;rarr; 사용자 입력&lt;/b&gt; 순으로 쌓임. 파이프 연산자(|)로 prompt | llm 체인을 만들면, 어떤 모델을 쓰든 chain.invoke() 하나로 호출 인터페이스가 통일됨.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 프론트엔드 &amp;mdash; Streamlit&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;파이썬만으로 채팅 UI를 구성하고, 대화 맥락은 세션 상태로 관리함.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 세션 상태 기반 대화 관리&lt;/h3&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;import streamlit as st
import requests as req

API_URL = 'http://localhost:8000/chat'

st.set_page_config(page_title='식사 메뉴 추천')
st.title('AI 식사 메뉴 추천')

# 대화 내용 저장 공간 (최초 1회만 초기화)
if &quot;messages&quot; not in st.session_state:
    st.session_state.messages = [
        {'role': 'assistant', 'content': '안녕하세요! 오늘 식사 메뉴를 추천해드릴게요. 정보를 입력해주세요.'}
    ]

# 이전 대화 내용 화면 출력
for msg in st.session_state.messages:
    with st.chat_message(msg['role']):
        st.markdown(msg['content'])
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;st.session_state&lt;/b&gt; &amp;mdash; 재실행(rerun) 사이에도 대화 내역을 유지하는 저장소, 새로고침 전까지 맥락 보존&lt;/li&gt;
&lt;li&gt;&lt;b&gt;초기화 가드&lt;/b&gt; &amp;mdash; if &quot;messages&quot; not in st.session_state로 최초 1회만 기본 안내 메시지 세팅&lt;/li&gt;
&lt;li&gt;&lt;b&gt;st.chat_message&lt;/b&gt; &amp;mdash; role(user/assistant)에 따라 채팅 말풍선 UI 자동 렌더링&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.2 채팅 입력 처리&lt;/h3&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;if prompt := st.chat_input('현재 상황을 자세하게 입력하세요...'):
    # 1. 사용자 입력 저장 및 출력
    st.session_state.messages.append({'role': 'user', 'content': prompt})
    with st.chat_message('user'):
        st.markdown(prompt)

    # 2. &quot;생각 중&quot; 연출
    with st.chat_message('assistant'):
        msg_holder = st.empty()
        msg_holder.markdown('생각 중입니다...')

    # 3. 백엔드 호출
    try:
        res = req.post(API_URL, json={&quot;query&quot;: prompt})
        result = res.json().get('response', '...') if res.status_code == 200 else f'서버 오류 {res.status_code}'
    except Exception as e:
        result = f'서버 오류 {e}'

    # 4. 응답 출력 및 저장
    msg_holder.markdown(result)
    st.session_state.messages.append({'role': 'assistant', 'content': result})
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;st.chat_input&lt;/b&gt; &amp;mdash; 하단 고정 입력창, 왈러스 연산자(:=)로 입력 여부 판정과 값 할당을 동시에 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;st.empty() placeholder&lt;/b&gt; &amp;mdash; &quot;생각 중&quot; 메시지를 먼저 띄우고, 응답이 오면 같은 자리를 덮어쓰기&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상태 동기화&lt;/b&gt; &amp;mdash; 사용자/AI 메시지를 모두 session_state에 append하여 다음 rerun에서도 누적 표시&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트는 LLM을 직접 모르고 오직 /chat API만 호출함. 화면 로직과 추론 로직이 분리되어 모델을 교체해도 프론트 코드는 손댈 필요가 없음.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. RAG &amp;mdash; 검색 증강 생성&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;LLM이 학습하지 않은 데이터(사내 데이터, 최신 정보)를 검색하여 프롬프트에 함께 전달하는 기법.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.1 RAG vs 파인튜닝&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;항목&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;파인튜닝&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; RAG &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;신규 데이터로 모델 재학습&lt;/td&gt;
&lt;td&gt;추론 시 외부 데이터를 검색하여 주입&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;비용&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;학습 비용 높음&lt;/td&gt;
&lt;td&gt;Vector DB 업데이트만 하면 됨 (효율적)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;최신성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;재학습 전까지 고정&lt;/td&gt;
&lt;td&gt;DB만 갱신하면 즉시 반영&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;보안&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;사내 데이터를 학습에 노출&lt;/td&gt;
&lt;td&gt;데이터를 외부 학습에 넘기지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;RAG 사용 동기&lt;/b&gt; &amp;mdash; 사내 데이터는 노출하기 싫지만, LLM의 강력한 추론/생성 능력은 쓰고 싶을 때&lt;/li&gt;
&lt;li&gt;&lt;b&gt;대상 데이터&lt;/b&gt; &amp;mdash; 회사 데이터, 개인 데이터, LLM 생성 시점 이후 발생한 최신 데이터 등&lt;/li&gt;
&lt;li&gt;&lt;b&gt;흐름&lt;/b&gt; &amp;mdash; 추론 요청 시 관련 데이터를 검색해 프롬프트에 첨부 &amp;rarr; (질의 + 검색 결과) 형태로 LLM 전달&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.2 Vector DB&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;역할&lt;/b&gt; &amp;mdash; RAG를 위한 장기 기억 저장소, 유사도 기반 검색 기능 제공&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데이터 형태&lt;/b&gt; &amp;mdash; 자연어를 토크나이저로 벡터화하여 저장&lt;/li&gt;
&lt;li&gt;&lt;b&gt;임베딩 교체&lt;/b&gt; &amp;mdash; BedrockEmbeddings에서 모델만 바꾸면 토크나이저 교체 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;제품&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 특징 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Pinecone&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;관리형, 무료 계정은 DB 1개 제한&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Milvus / Qdrant&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;대규모 처리에 적합한 오픈소스&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Chroma / FAISS&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;메모리 기반, 로컬 실습에 적합&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최신 흐름은 클라우드 중심에서 온프레미스&amp;middot;디바이스 AI로 이동 중임. 데이터 주권과 비용 문제로 인해 사내 구축 + RAG 조합이 현실적 대안이 됨.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. RAG 실습 &amp;mdash; FAISS&lt;/h2&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;인메모리 검색 -&amp;gt; 대량 문서 청킹/저장 -&amp;gt; 검색 + 추론 체인까지 3단계로 RAG를 구현함.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6.1 기본 &amp;mdash; 인메모리 FAISS&lt;/h3&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;from langchain_community.vectorstores import FAISS
from langchain_aws import BedrockEmbeddings

# LLM이 모르는 데이터라고 가정
data = [
    &quot;맥도날드의 대표 제품은 빅맥이다.&quot;,
    &quot;삼성은 한국의 최대 기업이다.&quot;,
    &quot;6월 3일에 선거가 예정되어 있다.&quot;,
    &quot;서브노티카2가 최근 OBT를 시작하였다.&quot;
]

# 임베딩 모델 (자연어 -&amp;gt; 분절 -&amp;gt; 벡터화 -&amp;gt; 패딩)
tokenizer = BedrockEmbeddings(
    model_id=&quot;amazon.titan-embed-text-v2:0&quot;,
    region_name=os.getenv('AWS_REGION')
)

# 텍스트를 벡터화하여 메모리 DB에 적재
vector_db = FAISS.from_texts(data, tokenizer)

# 유사도 기반 검색
docs = vector_db.similarity_search(&quot;요즘 OBT를 시작한 게임이 뭐였지&quot;)
print(docs[0].page_content)  # -&amp;gt; &quot;서브노티카2가 최근 OBT를 시작하였다.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;BedrockEmbeddings&lt;/b&gt; &amp;mdash; 자연어를 벡터로 변환하는 임베딩 모델, 검색&amp;middot;저장 모두 동일 모델 사용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;FAISS.from_texts&lt;/b&gt; &amp;mdash; 문자열 리스트를 벡터화하여 메모리 기반 DB 구성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;similarity_search&lt;/b&gt; &amp;mdash; 질의와 의미적으로 가장 가까운 문서를 반환 (키워드 일치가 아닌 유사도 기반)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6.2 대량 문서 &amp;mdash; 청킹 &amp;amp; 저장&lt;/h3&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
import glob

# 1. 문서 로드
files = glob.glob('./rag/data/*.txt')
raw_docs = [TextLoader(file, encoding='utf-8').load()[0] for file in files]

# 2. 청크 단위 분할
splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,      # 자르는 단위
    chunk_overlap=100,   # 문맥 유지를 위한 겹침 구간
)
splits = splitter.split_documents(raw_docs)

# 3. 벡터화 후 DB 적재 및 로컬 저장
vector_db = FAISS.from_documents(splits, tokenizer)
vector_db.save_local('hp-story')
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;청크(chunk)&lt;/b&gt; &amp;mdash; 말뭉치를 토큰 제한에 맞춰 일정 크기로 분할한 단위, 크기 설정이 검색 성능에 직접 영향&lt;/li&gt;
&lt;li&gt;&lt;b&gt;chunk_overlap&lt;/b&gt; &amp;mdash; 청크 경계에서 문맥이 끊기지 않도록 앞뒤를 겹쳐 자름&lt;/li&gt;
&lt;li&gt;&lt;b&gt;save_local&lt;/b&gt; &amp;mdash; 한 번 구축한 DB를 파일(index.faiss, index.pkl)로 저장하여 재사용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;최적 청크 크기&lt;/b&gt; &amp;mdash; 정답이 없으므로 여러 차례 시도하며 성능을 비교해 찾아야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6.3 RAG 체인 &amp;mdash; 검색 + 추론&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;from langchain_aws import ChatBedrock
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

# 저장된 DB 로드
vector_db = FAISS.load_local('hp-story', tokenizer, allow_dangerous_deserialization=True)

# 프롬프트 (context + 질문)
prompt = ChatPromptTemplate.from_template('''
    다음의 제공된 context를 사용하여 질문에 답변해주세요.
    문맥에서 답을 찾을 수 없다면 &quot;잘 모르겠음&quot;이라고 답변하세요.
    &amp;lt;context&amp;gt;{context}&amp;lt;/context&amp;gt;
    질문 : {user_input}
''')

# 리트리버 (상위 3개 청크 참조)
retriever = vector_db.as_retriever(search_kwargs={&quot;k&quot;: 3})

def format_docs(docs):
    return &quot;\n\n&quot;.join(doc.page_content for doc in docs)

# LCEL 파이프라인
rag_chain = (
    {&quot;context&quot;: retriever | format_docs, &quot;user_input&quot;: RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

res = rag_chain.invoke('톰 마볼로 리들의 능력은?')
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;as_retriever(k=3)&lt;/b&gt; &amp;mdash; 유사도 상위 3개 청크를 검색하는 리트리버 생성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;format_docs&lt;/b&gt; &amp;mdash; 검색된 여러 청크를 하나의 context 문자열로 병합&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RunnablePassthrough&lt;/b&gt; &amp;mdash; 사용자 질문을 검색과 프롬프트에 동시에 흘려보냄&lt;/li&gt;
&lt;li&gt;&lt;b&gt;StrOutputParser&lt;/b&gt; &amp;mdash; LLM 응답 객체에서 문자열만 추출 (.content 수동 호출 불필요)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LCEL&lt;/b&gt; &amp;mdash; | 연산자로 검색 &amp;rarr; 프롬프트 &amp;rarr; LLM &amp;rarr; 파싱을 하나의 흐름으로 연결&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메뉴 추천 체인은 prompt | llm 단방향이었지만, RAG 체인은 앞단에 &lt;b&gt;검색(retriever) + 결합(format_docs)&lt;/b&gt; 단계가 추가됨. 결과적으로 프롬프트가 (검색된 문맥 + 질문) 형태로 구성되어, LLM이 학습하지 않은 데이터에도 근거 기반 답변을 생성함.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 요약&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;주제&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;핵심&lt;span&gt; 내용 &lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;아키텍처&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Streamlit(프론트) / FastAPI(백엔드) / LangChain(LLM) 계층 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;백엔드&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;/chat API가 체인을 호출하고 응답을 중계, Pydantic으로 입력 검증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;LLM 모듈&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;ChatBedrockConverse + Few-Shot으로 일정 품질의 추천 유도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;프론트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;session_state로 대화 맥락 유지, API만 호출하여 모델과 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;RAG&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;파인튜닝 없이 외부 데이터를 검색&amp;middot;주입, 비용/보안 효율적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Vector DB&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;BedrockEmbeddings로 벡터화, FAISS로 유사도 검색&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;RAG 체인&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;리트리버 + LCEL로 검색 &amp;rarr; 프롬프트 &amp;rarr; LLM &amp;rarr; 파싱 통일&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &lt;b&gt;체인 인터페이스의 통일&lt;/b&gt;임. 메뉴 추천이든 RAG든 결국 chain.invoke() 하나로 호출되며, 차이는 체인 앞단에 검색 단계가 붙느냐일 뿐임. 프롬프트 엔지니어링(LLM 1)에서 다룬 Few-Shot&amp;middot;페르소나 설계가 LangChain 위에서 그대로 코드화되고, RAG가 더해지면서 &quot;모델이 모르는 데이터&quot;까지 다룰 수 있는 실용 서비스로 확장됨.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>SK플래닛 ai활용 데이터엔지니어 과정 2기/ML &amp;amp; DL</category>
      <author>dev-lee</author>
      <guid isPermaLink="true">https://dev-lee.tistory.com/109</guid>
      <comments>https://dev-lee.tistory.com/109#entry109comment</comments>
      <pubDate>Fri, 29 May 2026 17:53:48 +0900</pubDate>
    </item>
    <item>
      <title>LLM 1 - 프롬프트, 컨텍스트</title>
      <link>https://dev-lee.tistory.com/108</link>
      <description>&lt;h1&gt;프롬프트 &amp;middot; 컨텍스트&lt;/h1&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;프롬프트 엔지니어링은 LLM이 최적의 결과를 생성하도록 자연어로 구성된 입력을 설계&amp;middot;최적화하는 기술/방법론임.
Bedrock을 통해 Claude, GPT, Gemma를 단일 인터페이스로 호출하고, 모델별 프롬프트 전략의 차이를 실습함.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. API 키 발급 및 연동&lt;/h2&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;세 가지 LLM 벤더(Claude, OpenAI, Gemini)의 API 키를 발급하고 Bedrock을 통해 통합 연동하는 방법을 다룸.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 벤더별 API 키 발급&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Gemini&lt;/b&gt; &amp;mdash; Google AI Studio에서 API 키 발급, 하루 1,500 call 무료 제공&lt;/li&gt;
&lt;li&gt;&lt;b&gt;AWS Bedrock&lt;/b&gt; &amp;mdash; AWS 콘솔에서 Bearer Token 발급, 사용량 기반 과금 (토큰 제한 없음)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Claude&lt;/b&gt; &amp;mdash; Anthropic Console에서 직접 발급&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 Gemini 연동 확인&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from google import genai

client = genai.Client(api_key=GEMINI_API_KEY)

def gemini_invoke(prompt, history):
    res = client.models.generate_content(
        model='gemini-2.5-flash',
        contents=prompt
    )
    return res.text

gradio.ChatInterface(gemini_invoke, type='messages').launch()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 Bedrock 연동 확인&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import boto3

bedrock = boto3.client(
    service_name='bedrock-runtime',
    region_name='us-east-1',
)

models = {
    &quot;anthropic&quot;: &quot;us.anthropic.claude-haiku-4-5-20251001-v1:0&quot;,
    &quot;openai&quot;:    &quot;openai.gpt-oss-120b-1:0&quot;,
    &quot;google&quot;:    &quot;google.gemma-3-27b-it&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bedrock을 사용하면 벤더별 API를 각각 관리하지 않고, 동일한 AWS SDK 코드베이스에서 모델명만 바꿔 다수의 LLM을 호출할 수 있음.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 프롬프트 엔지니어링&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;효과적인 프롬프트 구성 방법과 LLM 벤더별 최적화 전략을 다룸.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 공통 구성 (90% 비중)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구성 요소 설명 예시&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;페르소나/역할 (Role)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;구체적인 아이덴티티 부여&lt;/td&gt;
&lt;td&gt;너는 10년차 재산 관리자야&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;배경/상황 (Context)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;구체적인 배경 정보 제공&lt;/td&gt;
&lt;td&gt;현재 가진 자산으로 은퇴 자금 설계&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;지시 (Task)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;구체적인 요청/요구사항&lt;/td&gt;
&lt;td&gt;10년 플랜 초안 작성, 손해비율 산정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;출력 형식/제약 (Format)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;토큰 제약, 형식, 말투 지정&lt;/td&gt;
&lt;td&gt;500자 이내, 표 형태, 반말 금지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 모델 특화 구성 (10% 비중)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Claude&lt;/b&gt; &amp;mdash; XML 태그로 섹션을 명확히 구획, 논리적&amp;middot;긴 글 요청에 적합하여 맥락 파악이 우수함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;GPT&lt;/b&gt; &amp;mdash; 대화형에 최적화, Few-Shot 프롬프트 활용을 권장하며 단계별 결과 유도 방식이 효과적임&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Gemini&lt;/b&gt; &amp;mdash; 구글 검색 연동에 유리, 팩트 체크 및 트렌드 반영 지시에서 강점을 보임&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.3 모델별 프롬프트 예시 &amp;mdash; 텀블러 SNS 초안 작성&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;공통 프롬프트 구성&lt;/h4&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# 역할
당신은 20년차 프로 카피라이터 입니다.

# 배경
우리 회사에서 이번에 &quot;24시간 동안 얼음이 녹지 않는 셀럽형 텀플러&quot;를 출시하였습니다.
타겟 고객은 하루 종일 아이스 커피를 달고 사는 20대 직장인입니다.

# 지시
타겟 고객에게 보낼 홍보용 SNS 초안을 작성해 주세요.

# 제약조건, 포맷
1. 톤앤매너: 유머러스하게, 위트 있게
2. 형식: 도입부(장점 4개) + 마무리(구매유도)
3. 분량: 400자 이내
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Claude 특화 &amp;mdash; XML 태그 구획&lt;/h4&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;role&amp;gt;
당신은 20년차 논리적이고 설득력 있는 프로 카피라이터입니다.
&amp;lt;/role&amp;gt;

&amp;lt;context&amp;gt;
제품 : 24시간동안 얼음이 녹지 않는 셀럽형 텀블러
타겟 : 하루종일 커피를 마시는 20대 직장인
&amp;lt;/context&amp;gt;

&amp;lt;instruction&amp;gt;
위 타겟에 대한 홍보용 SNS 초안을 작성하세요.
&amp;lt;/instruction&amp;gt;

&amp;lt;constraints&amp;gt;
톤앤매너: 전문적이지만 너무 딱딱하지 않게
형식:
  - 도입부 : 텀플러의 장점(4개 포인트)
  - 마무리 : 구매유도
&amp;lt;/constraints&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;GPT 특화 &amp;mdash; Few-Shot 샘플 제공&lt;/h4&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;# few shot 제시 (샘플 제공)
&quot;점심시간이 되면 오전에 구매한 아이스 아메리카노(아아)가 미지근한 맹물이 되지요?
 이 고통에서 벗어날 수 있는 ...&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Gemini 특화 &amp;mdash; 검색 연동&lt;/h4&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# 특별지시
현재 한국 20대 직장인들이 오피스 아이템 트렌드를 검색하여,
이 텀블러가 왜 필수템인지 설명하는 문구를 도입부에 자연스럽게 녹여줘.
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델별로 동일한 주제에 대해 결과물이 상이하게 나옴. Claude는 논리&amp;middot;구조 중심, GPT는 친근한 대화체, Gemini는 트렌드 반영 문구가 강점.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. GPT &amp;middot; Claude &amp;middot; Gemma Bedrock 호출 코드&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;벤더별 프롬프트 구조와 응답 파싱 방식의 차이를 구체적인 코드로 비교함.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 범용 호출 함수&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;def llm_invoke(prompt='', vender=&quot;openai&quot;, history=None):
    res = bedrock.invoke_model(
        modelId=models[vender],
        body=json.dumps(prompt)
    )
    content = json.loads(res['body'].read())
    return content
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 벤더별 프롬프트 구조 비교&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;항목&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Claude&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;GPT&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; Gemma(Google) &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;시스템 역할&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&quot;system&quot; 별도 키&lt;/td&gt;
&lt;td&gt;messages 내 role: system&lt;/td&gt;
&lt;td&gt;converse API의 system 파라미터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;few-shot&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;messages 배열에 직접 삽입&lt;/td&gt;
&lt;td&gt;messages 배열에 직접 삽입&lt;/td&gt;
&lt;td&gt;content에 [{&quot;text&quot;: ...}] 형태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;응답 파싱&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;content['content'][0]['text']&lt;/td&gt;
&lt;td&gt;content['choices'][0]['message']['content']&lt;/td&gt;
&lt;td&gt;res['output']['message']['content'][0]['text']&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;# Claude 응답 파싱
res['content'][0]['text']

# GPT 응답 파싱
res['choices'][0]['message']['content']

# Gemma(converse API) 응답 파싱
res['output']['message']['content'][0]['text']
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bedrock을 활용하면 동일 SDK에서 모델명만 교체하여 여러 LLM을 호출할 수 있음. 다만 프롬프트 구성 방식과 응답 파싱 구조가 모델별로 다르므로, 인터페이스 클래스로 추상화하는 설계가 권장됨. sklearn에서 어떤 알고리즘을 쓰든 fit()으로 학습하는 것과 같은 패턴.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 제로샷 &amp;middot; 원샷 &amp;middot; 퓨샷 프롬프트&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;LLM에게 예시를 얼마나 제공하느냐에 따라 결과 품질과 일관성이 달라짐.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 유형 비교&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; 유형 예시&amp;nbsp; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;개수&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 언제 쓰는가 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Zero-shot&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;상식적 질문, 간단한 요약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;One-shot&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;특정 포맷(JSON 등) 준수 필요 시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Few-shot&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;2~5&lt;/td&gt;
&lt;td&gt;복잡한 분류, 특수한 규칙 적용 시&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.2 퓨샷 특징&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;초기 서비스 구축&lt;/b&gt; 단계에서 파인튜닝 없이 활용 가능&lt;/li&gt;
&lt;li&gt;예시 품질이 높으면 파인튜닝 모델보다 더 좋은 성과를 내기도 함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;일정한 품질의 결과물&lt;/b&gt; 획득이 목적 &amp;mdash; 사내 데이터를 직접 파인튜닝에 사용하면 비용&amp;middot;보안 이슈가 있으나, few-shot으로 동일 효과를 얻을 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.3 리뷰 감정 분류 &amp;mdash; 퓨샷 실습&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;GPT&lt;/h4&gt;
&lt;pre class=&quot;scilab&quot;&gt;&lt;code&gt;few_shot_prompt = [
    {&quot;role&quot;: &quot;system&quot;,    &quot;content&quot;: &quot;당신은 커피 고객 리뷰 분석가입니다. '긍정','부정','가격민감' 중 하나로 분류하세요.&quot;},
    {&quot;role&quot;: &quot;user&quot;,      &quot;content&quot;: &quot;일찍와서 핫초코 샀어요~ 맛있고 좋아요 &quot;},
    {&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: &quot;긍정&quot;},
    {&quot;role&quot;: &quot;user&quot;,      &quot;content&quot;: &quot;직원이 인사를 않 해서 기분이 나빴습니다.&quot;},
    {&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: &quot;부정&quot;},
    {&quot;role&quot;: &quot;user&quot;,      &quot;content&quot;: &quot;스페셜 음료는 맛있는데 매일 사 먹기엔 가격이 너무 부담스럽네요&quot;},
    {&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: &quot;가격민감&quot;},
]
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Claude&lt;/h4&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Claude는 system을 messages 밖 별도 키로 분리
prompt = {
    &quot;anthropic_version&quot;: &quot;bedrock-2023-05-31&quot;,
    &quot;max_tokens&quot;: 100,
    &quot;system&quot;: &quot;당신은 커피 고객 리뷰 분석가입니다. '긍정','부정','가격민감' 중 하나로 분류하세요.&quot;,
    &quot;messages&quot;: few_shot_prompt  # role: system 제외한 배열
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Gemma (Google)&lt;/h4&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;# content 값을 [{&quot;text&quot;: ...}] 형태로 변환 필요
change = lambda x: [{&quot;text&quot;: x}]

few_shot_messages = [
    {&quot;role&quot;: &quot;user&quot;,      &quot;content&quot;: change(&quot;일찍와서 핫초코 샀어요~ 맛있고 좋아요 &quot;)},
    {&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: change(&quot;긍정&quot;)},
    ...
]

# converse API 사용
res = bedrock.converse(
    modelId=models['google'],
    messages=final_messages,
    inferenceConfig={&quot;maxTokens&quot;: 100, &quot;temperature&quot;: 0}
)
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bedrock 기준 Claude&amp;middot;Gemma가 응답이 빠르고, GPT가 상대적으로 느림. 벤더 직접 API 사용 시 GPT는 Azure에서 빠른 응답 가능.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. LangChain &amp;mdash; LLM 오케스트레이션&lt;/h2&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;여러 모델을 단일 인터페이스로 다루고, 프롬프트 템플릿&amp;middot;체인&amp;middot;메모리를 통합 관리하는 오픈소스 프레임워크.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.1 LangChain 개요&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;정의&lt;/b&gt; &amp;mdash; LLM 기반 AI 애플리케이션 개발을 돕는 오픈소스 오케스트레이션 프레임워크&lt;/li&gt;
&lt;li&gt;&lt;b&gt;핵심 구성 요소&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;RAG &amp;mdash; LLM이 모르는 지식을 검색 증강으로 제공, 프롬프트에 함께 전달&lt;/li&gt;
&lt;li&gt;Agent &amp;mdash; LLM이 스스로 판단하고 행동, A2A 연동 가능&lt;/li&gt;
&lt;li&gt;Chain / Graph &amp;mdash; 단방향(체인) 또는 순환(그래프) 워크플로우 구성&lt;/li&gt;
&lt;li&gt;Memory &amp;mdash; 단기 기억(대화 맥락)과 장기 기억(벡터 DB)으로 구분&lt;/li&gt;
&lt;li&gt;LangSmith &amp;mdash; 개발&amp;middot;디버깅&amp;middot;테스트&amp;middot;모니터링 통합 지원 (LLMOps)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.2 프롬프트 템플릿 종류&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 105px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;클래스&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt; 용도 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;PromptTemplate&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;기본형, f-string / Jinja2 변수 치환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;ChatPromptTemplate&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;대화형 모델용, system/human/ai 역할 구조&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;FewShotPromptTemplate&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;예시 리스트 + 접두사/접미사 기능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;FewShotChatMessagePromptTemplate&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;실제 대화 스타일로 예시 주입&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PromptTemplate &amp;mdash; f-string&lt;/h4&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from langchain_core.prompts import PromptTemplate

input_prompt = PromptTemplate(
    input_variables=['place', 'content'],
    template=&quot;{place}에서 가장 {content} 놀이기구는?&quot;
)

input_prompt.format(place='에버랜드', content='대기줄이 가장 긴')
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PromptTemplate &amp;mdash; Jinja2&lt;/h4&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;input_prompt = PromptTemplate(
    input_variables=['items', 'user_q'],
    template_format='jinja2',
    template='''
다음 예시를 참고하여 마지막 질문에 대한 답변을 제시해줘.

{% for item in items %}
  Q: {{item.q}}
  A: {{item.a}}
{% endfor %}
  Q : {{user_q}}
  A:
'''
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ChatPromptTemplate&lt;/h4&gt;
&lt;pre class=&quot;capnproto&quot;&gt;&lt;code&gt;from langchain_core.prompts import ChatPromptTemplate

chat_template = ChatPromptTemplate.from_messages([
    (&quot;system&quot;, &quot;당신은 {style} 스타일의 고용관리 상담사입니다.&quot;),
    (&quot;human&quot;,  &quot;저는 {name}입니다.&quot;),
    (&quot;system&quot;, &quot;네.. 반갑습니다.&quot;),
    (&quot;human&quot;,  &quot;제 고민은 {topic}입니다. 조언해주세요.&quot;),
])
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;FewShotPromptTemplate&lt;/h4&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from langchain_core.prompts import FewShotPromptTemplate

examples = [
    {&quot;input&quot;: &quot;높다&quot;,   &quot;output&quot;: &quot;낮다&quot;},
    {&quot;input&quot;: &quot;빠르다&quot;, &quot;output&quot;: &quot;느리다&quot;},
    {&quot;input&quot;: &quot;밝다&quot;,   &quot;output&quot;: &quot;어둡다&quot;},
]

example_format = PromptTemplate.from_template(&quot;단어:{input}\n반대말:{output}&quot;)

few_shot_prompt = FewShotPromptTemplate(
    examples=examples,
    example_prompt=example_format,
    prefix=&quot;다음 단어의 반대말을 알려줘.&quot;,
    suffix=&quot;단어:{user_input}\n반대말:&quot;,
    input_variables=['user_input']
)
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 형태에 따라 채팅 계열(ChatPromptTemplate), 일반 처리(PromptTemplate)를 구분하여 사용하는 것이 기본 원칙임.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. Bedrock + LangChain 결합&lt;/h2&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;LangChain의 `ChatBedrock`을 통해 모델과의 인터페이스를 통일하고, 파이프라인(`chain`)으로 프롬프트 &amp;rarr; LLM &amp;rarr; 응답을 연결함.
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;from langchain_aws import ChatBedrock
from langchain_core.prompts import ChatPromptTemplate

def run_bedrock_llm(model_id, user_query, style):
    llm = ChatBedrock(
        client=bedrock_client,
        model_id=models[model_id],
        model_kwargs={&quot;max_tokens&quot;: 1024, &quot;temperature&quot;: 0.7}
    )

    prompt = ChatPromptTemplate.from_messages([
        ('system', '당신은 {style} 스타일의 20년차 초고수 투자자입니다.'),
        ('human',  '{input}')
    ])

    # chain 연결 : 프롬프트 구성 -&amp;gt; LLM 호출 -&amp;gt; 응답
    chain = prompt | llm

    res = chain.invoke({&quot;input&quot;: user_query, &quot;style&quot;: style})
    return res.content  # 랭체인 통해 출력 형식 통일
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LangChain의 핵심은 &lt;b&gt;파이프 연산자(|)&lt;/b&gt; 로 표현되는 체인 구조임. prompt | llm | output_parser 형태로 확장하면 응답 파싱까지 통일된 흐름으로 처리 가능. 어떤 모델을 써도 chain.invoke()로 호출하는 인터페이스가 동일하여, sklearn의 fit() 패턴처럼 모델 교체가 간편해짐.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 요약&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style9&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;주제&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 핵심 내용 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;프롬프트 공통 구성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Role + Context + Task + Format 4요소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;모델별 특화&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Claude(XML), GPT(Few-Shot), Gemini(검색 연동)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;퓨샷 프롬프트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;파인튜닝 없이 고품질 분류&amp;middot;생성 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Bedrock&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;단일 SDK로 다수 모델 호출, 응답 파싱 구조는 모델별 상이&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;LangChain&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;프롬프트 템플릿&amp;middot;체인&amp;middot;메모리&amp;middot;에이전트 통합 오케스트레이션&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;설계 원칙&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;모델별 인터페이스 추상화 + Pydantic으로 응답 구조 통일&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM 활용의 핵심은 &quot;어떤 모델이 더 좋은가&quot;가 아니라, &quot;어떤 모델의 특성에 맞게 프롬프트를 설계하는가&quot;임. 공통 구성 4요소를 기반으로 모델 특화 전략을 레이어로 얹는 구조가 실무에서 가장 안정적인 접근법이며, LangChain은 이 과정을 코드 레벨에서 추상화하는 도구임.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>SK플래닛 ai활용 데이터엔지니어 과정 2기/ML &amp;amp; DL</category>
      <author>dev-lee</author>
      <guid isPermaLink="true">https://dev-lee.tistory.com/108</guid>
      <comments>https://dev-lee.tistory.com/108#entry108comment</comments>
      <pubDate>Thu, 28 May 2026 17:53:54 +0900</pubDate>
    </item>
  </channel>
</rss>