에이전트는 그래프입니다
요약
LangGraph를 사용하여 Claude 기반 챗봇 Claudius의 스트리밍 채팅 백본을 구축하는 튜토리얼입니다. 에이전트를 단순한 프롬프트 호출이 아닌 상태 머신(State Machine) 형태의 그래프 구조로 설계하는 방법을 다룹니다.
핵심 포인트
- 에이전트는 단일 프롬프트가 아닌 LangGraph 기반의 상태 머신으로 설계해야 함
- 그래프 구조(노드, 엣지, 루프)를 통해 도구 호출과 답변 생성을 제어함
- 실행 시점에 모델을 선택할 수 있도록 LangGraph의 configurable 채널 활용
- 모델이 도구 호출을 요청하면 실행기로 넘어가고 다시 에이전트로 돌아오는 루프 구현
이 튜토리얼은 Néstor Daza에 의해 작성되었습니다.
이 글은 제가 만든 Claude 기반 챗봇인 Claudius (Github)를 구축하는 시리즈의 네 번째 기사입니다. 이전 기사에서는 앱의 핵심 구성 요소를 스캐폴딩(scaffolding)했습니다.
우리는 이미 데이터 모델, 클라이언트가 조작할 수 없는 신원(identity), 그리고 애플리케이션이 Atlas와 Bedrock 모두에 도달할 수 있음을 증명하는 헬스 체크(health check)와 같은 기반을 구축했습니다. 이제 스트리밍 채팅 백본(backbone)으로 넘어갈 수 있으며, 이 단계에서 대화가 마침내 실체화되고 Claudius는 여러분이 매일 좋아하는 AI 도구로 사용할 수 있는 무언가로 변모하게 됩니다.
우리는 전송 버튼을 누른 시점부터 토큰(tokens)이 화면에 도착하기까지 발생하는 모든 과정인 라이브 경로(live path)를 구축할 것입니다. 모델은 웹을 검색하기 위해 일시 중지하거나, 답변을 단어별로 스트리밍(streaming)하여 다시 보내고, 대화 도중에 맥락을 잃지 않으면서 저렴한 모델에서 강력한 모델로 전환할 수 있는 작은 에이전트(agent)로서 작동합니다. 다음 기사에서는 토큰이 멈춘 후 대화가 머무는 곳, 즉 영속성(persistence), 미터링(metering), 그리고 전체 시스템을 안정적으로 유지하는 보호된 쓰기 작업(guarded writing operations)에 대해 다룰 것입니다. 여기서 질문은 더 좁고 즉각적입니다: 전송을 누르면 실제로 어떤 일이 일어날까요?
에이전트는 프롬프트가 아닙니다
채팅 엔진은 영리한 프롬프트(prompt)로 감싸진 모델에 대한 단일 호출이 아닙니다. 그것은 LangGraph로 구축된 작은 상태 머신(state machine)이며, 이는 모델이 한 번에 답변하는 것 이상의 일을 할 수 있게 해주는 설계 선택입니다.
그래프에는 세 개의 노드(node)가 있습니다. load_context는 실행되지만 아직은 아무것도 하지 않으며, 이는 나중에 메모리 검색(memory retrieval)이 위치하게 될 의도적인 플레이스홀더(placeholder)입니다. agent는 모델 그 자체이며, 호출이 허용된 도구(tools)들과 결합되어 있습니다. tools는 모델이 요청하는 무엇이든 실행하는 실행기(executor)입니다. 에이전트(agent) 뒤에는 조건부 엣지(conditional edge)가 위치합니다. 만약 모델이 도구 호출(tool call)을 요청하면 제어권이 tools 노드로 넘어갑니다. 만약 모델이 일반적인 답변을 반환하면 실행이 종료됩니다. tools 노드는 항상 에이전트로 다시 루프(loop)를 돌며 돌아옵니다.
const builder = new StateGraph(MessagesAnnotation)
.addNode("load_context", loadContext)
.addNode("agent", agent)
...
이 루프는 모델이 필요한 것을 얻고, 또 다른 도구 호출 대신 자연어 답변을 반환할 때까지 계속 실행됩니다. 프롬프트(prompt)는 한 번 답변하고 멈추지만, 그래프는 작업이 완료될 때까지 계속 작동합니다.
그래프 단위가 아닌 실행 단위로 모델을 선택하세요
여기에 가장 많은 고민이 들어간 설계 결정 사항이 있으며, 이는 위의 다이어그램에서는 보이지 않습니다. 모델은 그래프에 컴파일되어 포함되지 않습니다.
그래프가 구축될 때 agent 노드가 고정된 모델 인스턴스를 클로저(closure)로 닫아 가질 것이라고 예상할 수도 있습니다. 대신, 모델은 실행(run)마다 선택되며 LangGraph의 configurable 채널을 통해 전달됩니다. 이 채널은 그래프 상태(state)와 함께 이동하는 호출별 설정(per-invocation configuration)입니다. agent 노드는 해당 채널에서 추론 프로필(inference profile)을 읽어 들여 매 턴마다 모델 클라이언트(model client)를 새로 구축합니다.
async function agent(
state: typeof MessagesAnnotation.State,
config: RunnableConfig,
...
readConfigurable은 실행별 설정(per-run config)에서 추론 프로필과 토큰 상한선(token ceiling)을 가져옵니다. buildChatModel은 해당 프로필에 맞는 ChatBedrockConverse 클라이언트를 구축하며, bindTools는 모델이 무엇을 호출할 수 있는지(예: web_search) 알 수 있도록 도구 스키마(tool schema)를 부착합니다. 그런 다음 노드는 시스템 프롬프트(system prompt)와 전체 메시지 기록(message history)을 사용하여 모델을 호출하고, 모델의 응답을 그래프 상태에 대한 단일 업데이트로 반환합니다.
왜 이런 번거로운 과정을 거칠까요? 대화 도중에 모델을 교체하는 비용을 거의 제로(free)에 가깝게 만들기 위해서입니다. 스레드 중간에 Haiku에서 Sonnet으로 전환하더라도, 저장된 대화 내용에는 아무런 변화가 없습니다. 메시지 기록(message history)은 그대로 유지됩니다. 다음 턴(turn)에서 우리는 설정(config)으로부터 다른 추론 프로필(inference profile)을 읽어오고, 동일한 기록을 사용하여 다른 클라이언트(client)를 구축합니다. 다시 구축해야 할 그래프도, 마이그레이션해야 할 체크포인트(checkpoint)도 없습니다. 모델은 대화의 속성이 아니라, 턴(turn)의 파라미터(parameter)입니다.
모델 전환은 화면에 보이는 스레드가 아니라, 클라이언트가 보내는 요청(request)에 나타납니다. 렌더링된 대화에는 메시지별 모델 라벨이 포함되지 않으며, 대화 행(conversation row)에는 현재 모델만 단일 필드로 저장되므로, 어떤 모델이 어떤 턴을 생성했는지는 어느 쪽에도 기록되지 않습니다. 하지만 요청 페이로드(request payload)에는 대화 식별자(conversation identifier), 선택된 모델, 그리고 새로운 텍스트를 포함하여 매 전송 시마다 기록됩니다. 동일한 conversationId를 공유하고 modelId만 다른 한 대화 내의 두 번의 연속된 전송은 다음과 같이 나타납니다:
// 3번째 턴, 선택기가 Haiku로 설정되어 있을 때 전송됨
{
"conversationId": "6770f1a2b3c4d5e6f7a8b9c0",
...
두 번째 턴은 서버가 보유한 이전의 전체 기록을 여전히 읽어오므로, 3번째 턴이 Haiku에서 실행되었더라도 Sonnet은 3번째 턴이 이미 컨텍스트(context)에 포함된 상태로 4번째 턴에 답변합니다. modelId는 선택기가 클라이언트에게 전달한 공개 카탈로그 식별자이며, 화면에 표시되는 이름과 쌍을 이룹니다. 이 식별자가 해결(resolve)하는 교차 지역 추론 프로필(cross-region inference profile)은 서버에 남아 있습니다.
하나의 도구, 하나의 예측 가능한 형태
이 단계에서 에이전트는 단 하나의 도구인 web_search만을 가지며, 이는 Tavily를 얇게 감싼 래퍼(wrapper)입니다. 이 도구는 결과당 정확히 세 개의 필드인 title, url, snippet을 반환합니다. 그 외의 다른 것은 전달되지 않습니다.
이 래퍼(wrapper)는 출력 형태(output shape)를 조금 더 제어하기 위해 라이브러리에서 가져오는 대신 직접 수동으로 작성되었습니다. 모델은 압축된 JSON 블록을 읽고, 사용자 인터페이스는 이를 소스 목록으로 렌더링합니다. 양측 모두 형태가 고정되어 있다는 점에 의존합니다. Tavily는 결과 텍스트를 content라는 필드에 반환하며, 래퍼는 그 경계에서 이를 snippet으로 이름을 변경합니다. 나중에 Tavily를 다른 검색 서비스로 교체하더라도, 변경되는 코드는 해당 래퍼 내부뿐입니다.
이는 이 도구 하나를 넘어 계속 유지할 만한 가치가 있는 습관입니다. 경계에서의 예측 가능한 형태는 구축 비용이 저렴하며, 시스템의 나머지 부분이 특정 벤더의 응답 형식에 은밀하게 의존하게 되는 것을 방지합니다.
이벤트 간극 메우기 (Bridging the event gap)
그래프는 한 종류의 스트림(stream)을 생성하지만, 브라우저는 다른 종류를 기대합니다. 이 둘 사이의 경로는 번역 계층(translation layer)이며, 이는 이 단계에서 가장 복잡한 부분입니다.
LangGraph는 streamEvents 인터페이스를 통해 평탄한(flat) 이벤트 로그를 방출합니다. 모델 토큰 청크(token chunks), 도구 시작(tool starts), 도구 종료(tool ends), 그리고 실행 경계(run boundaries)가 모두 순서대로 소비되는 타입화된 이벤트(typed events) 시퀀스로 전달됩니다. 프론트엔드는 Vercel AI SDK를 사용하는데, 이는 이와 다른 것, 즉 사용자 인터페이스 메시지 파트의 타입화된 스트림을 기대합니다. 일반 텍스트는 text-start로 들어온 뒤, 일련의 text-delta 이벤트가 이어지고, 마지막으로 text-end가 발생합니다. 도구 활동은 tool-input 및 tool-output 파트로 전달됩니다. 두 형식은 동일한 대화를 설명하지만, 동일한 형식은 아닙니다. 우리는 이 둘 사이의 간극을 메워야 합니다.
작은 상태 머신(state machine)이 이 작업을 처리합니다. 이 머신은 LangGraph 이벤트 로그를 따라가며 진행 과정에서 AI SDK 파트를 방출합니다.
for await (const ev of events) {
switch (ev.event) {
case "on_chat_model_stream": {
...
이벤트를 하나씩 읽습니다. 모델 토큰 청크 (on_chat_model_stream)에서 아직 열려 있는 텍스트 파트가 없다면, 머신은 새로운 식별자와 text-start를 사용하여 파트를 열고, 토큰을 text-delta로 작성하여 assistantText에 유지되는 진행 중인 답변에 추가합니다. 모델 턴이 종료될 때 (on_chat_model_end), endText는 열려 있는 파트를 닫고 text-end를 방출하며, 다음 토큰이 새로운 세그먼트를 열 수 있도록 식별자를 null로 재설정합니다. 동일한 핸들러가 이벤트에서 토큰 사용량 (token usage)을 추출하여 누적 합계에 추가하며, 이는 동반 기사에서 다룹니다. 도구가 시작될 때 (on_tool_start), 머신은 도구 입력 (tool-input) 파트들을 방출합니다. 도구가 종료될 때 (on_tool_end), 머신은 도구의 JSON 출력을 렌더러가 소스 (sources)로 보여줄 수 있는 구조화된 파트로 다시 파싱합니다.
모델은 필요한 도구가 실행되기 전에 자신의 턴을 마치기 때문에, on_chat_model_end는 항상 on_tool_start보다 먼저 발생합니다. 따라서 각 도구 호출은 닫힌 텍스트 세그먼트 사이에 깔끔하게 위치하며, 열려 있는 세그먼트 내부에는 절대 들어가지 않습니다. textSegment 카운터는 매번 증가하므로, 모델이 검색 결과(search results)를 읽은 후 작성하는 답변은 검색 전의 세그먼트를 다시 여는 대신 새로운 세그먼트로 열립니다. 렌더러는 자연스러운 텍스트, 그 다음 소스가 포함된 도구 호출, 그리고 다시 텍스트가 이어지는 깔끔한 시퀀스를 보게 됩니다.
도구 파트들은 dynamic으로 플래그가 지정됩니다. web_search 도구는 클라이언트에 스키마 (schema)와 함께 등록되어 있지 않으므로, AI SDK 입장에서는 그 형태를 미리 알 수 없는 동적 도구 (dynamic tool) 파트입니다. 서버에서 이를 dynamic으로 표시하면, 클라이언트가 해당 도구의 시그니처 (signature)를 전달받지 못했더라도 렌더러가 스위칭 (switch)할 수 있는 깔끔하게 타입이 지정된 파트로 조립할 수 있게 해줍니다.
대화 식별자 (conversation identifier)는 일시적인 데이터 파트 (transient data part)에 실려 전달됩니다. 완전히 새로운 대화에서 첫 번째 메시지를 보낼 때, 서버는 대화 행 (conversation row)을 생성하며, 클라이언트가 이를 가져오기 위해 두 번째 요청을 강제하지 않고도 식별자를 알려줄 필요가 있습니다. 따라서 스트림의 맨 윗부분에 일회성 data-conversation 파트를 작성합니다.
writer.write({ type: "start" });
writer.write({
type: "data-conversation",
...
클라이언트는 해당 파트를 읽어 식별자를 채택하고, URL을 업데이트하며, 사이드바에 새로운 대화를 표시합니다. 이 파트 자체는 스레드 내의 메시지가 되지 않습니다. transient는 이를 콘텐츠라기보다는 신호(signal)인 대역 외 (out-of-band) 데이터로 표시합니다. 추가적인 왕복 (round trip) 없이도, 첫 번째 토큰부터 대화에 접근할 수 있습니다.
사용자는 에이전트가 수행한 작업을 확인합니다
이러한 모든 배관 작업 (plumbing)의 결실은 화면에 나타납니다. 토큰은 도착하는 대로 애니메이션과 함께 나타나므로, 답변이 기다림 끝에 한 번에 나타나는 대신 눈앞에서 구축됩니다. 에이전트가 검색을 결정하면 "웹 검색 중 (Searching the web)"이라는 실시간 라인이 나타나며, 결과가 반환되면 도구 (tool)가 찾은 소스들의 확장 가능한 목록으로 접히며, 각 소스는 제목과 링크를 포함합니다.
이 마지막 동작은 제품 전체를 관통하는 원칙입니다. 모든 도구 호출 (tool call)은 가시적이며 검사 가능합니다. 사용자는 최종적으로 나온 다듬어진 답변뿐만 아니라, 에이전트가 자신을 대신해 무엇을 했는지 항상 볼 수 있어야 합니다. 이벤트 브릿지 (event bridge)가 이를 가능하게 하는데, 이는 도구 활동 (tool activity)을 모델의 턴 (turn) 내부에 묻어버리는 대신 일급 파트 (first-class parts)로서 전달하기 때문입니다.
이 왕복 과정(round trip)에 대해 주목해야 할 점이 한 가지 더 있으며, 이는 백본(backbone)의 후반부를 설정합니다. 메시지를 보낼 때, 클라이언트는 새로운 텍스트만을 전송합니다. 이전에 말한 모든 내용의 전사(transcript)를 다시 보내지는 않습니다. 클라이언트는 권위 있는 기록(authoritative history)을 보유하고 있지 않기 때문에 실제로 그렇게 할 수도 없습니다. 클라이언트는 단지 화면에 렌더링(render)된 내용만을 보유할 뿐입니다. 전체 대화는 다른 곳에 존재하며, 모델은 매 턴(turn)마다 그곳에서 대화를 읽어옵니다.
대화가 어디에 존재하는지, 그리고 왜 작은 정체성(identity) 결정 하나가 일주일 전의 대화를 다시 여는 것을 새로운 대화를 여는 것만큼이나 저렴하게 만드는지는 다음 글의 주제입니다. 에이전트는 그래프(graph)입니다. 대화는, 알고 보니, 데이터베이스(database)입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기