LangGraph 실행 모델 Q&A — State·도구 루프·체크포인터·승인 게이트 26문답
리듀서를 왜 채널마다 정해야 하는지, tool_calls가 찍힌 시점에 무엇이 아직 일어나지 않았는지, 기억이 어디에 붙어 있는지를 결론부터 정리한다.
그래프를 짜기 시작하면 질문의 성격이 바뀐다. "무엇을 쓸 것인가"에서 "왜 값이 사라지나", "왜 도구를 안 부르나", "왜 재시작하면 대화가 없어지나"로 옮겨 간다. 이 글은 그 질문들을 다룬다.
세 가지가 반복해서 사고를 만든다. 리듀서를 안 붙여 이력이 매 턴 초기화되는 것, tool_calls를 실행 결과로 착각하는 것, thread_id를 공유해 다른 사용자의 대화가 섞이는 것이다. 셋 다 문법 오류가 아니라 정상 동작이라 빌드도 테스트도 잡아 주지 않는다.
프레임워크 선택과 RAG 기본은 기본기 Q&A, 멀티에이전트와 제품 구현은 멀티에이전트 Q&A, 신뢰성·비용·안전은 운영 Q&A에 있다.
리듀서 카탈로그
채널마다 병합 규칙이 다르다. 어떤 리듀서를 고르느냐가 곧 팬아웃 설계다.
| 리듀서 | 동작 | 쓰는 곳 | 주의 |
|---|---|---|---|
| 없음(기본) | 덮어쓰기(last write wins) | 현재 단계·플래그·최종 답변 | 병렬 노드가 같은 키를 쓰면 충돌 |
operator.add | 리스트 연결 / 숫자 합산 | 로그 누적, 분석 결과 팬인 | 중복 제거 없음, 무한 누적 위험 |
add_messages | 누적 + id 기반 upsert + 타입 정규화 | 대화 이력 | LangChain 메시지 객체 전용 |
커스텀 merge_dicts | {**old, **new} | 키가 서로 다른 병렬 결과 | 키 충돌 시 덮어씀 |
Q. 에이전트 상태 설계에서 가장 중요한 결정은 무엇인가
필드 목록을 정하는 일이 아니라 필드별 병합 정책을 정하는 일이다.
기본 동작이 덮어쓰기라 대화 이력에 리듀서를 안 붙이면 매 턴 1개로 초기화된다. 반대로 현재 단계나 최종 답변처럼 최신값만 의미 있는 필드는 덮어쓰기가 맞다. 병렬 노드를 쓰는 순간 이 결정이 곧 정합성을 좌우한다.
자세한 것은 State와 Reducer가 정하는 것에 있다.
Q. 왜 Annotated를 쓰나
채널별 리듀서를 매다는 자리이기 때문이다.
타입 힌트를 예쁘게 쓰려는 것이 아니라, 그 자리가 아니면 "이 채널은 누적한다"를 선언할 곳이 없다. Annotated[list, add_messages]를 빼면 이력이 매 턴 1개로 초기화된다.
Q. add_messages가 단순 연결과 다른 점은
누적에 더해 id 기반 upsert와 타입 정규화를 한다.
| 동작 | 단순 연결 | add_messages |
|---|---|---|
| 새 메시지 | 뒤에 붙임 | 뒤에 붙임 |
| 같은 id 재전송 | 중복 누적 | 갱신 |
| dict/문자열 입력 | 그대로 | 메시지 객체로 정규화 |
id 갱신이 되기 때문에 스트리밍 중 부분 갱신과 메시지 수정이 성립한다.
Q. 병렬 노드가 같은 키에 쓰면 어떻게 되나
리듀서가 없으면 쓰기 충돌 에러다.
dict면 병합 함수를, list면 operator.add를 선언한다. 리듀서의 본질이 병렬 쓰기 충돌 해소라, 단일 쓰기 채널에는 애초에 필요 없다.
Q. dict 병합과 list 누적 중 무엇을 쓰나
키가 고정이면 dict, 분기 수가 가변이면 list다.
| 상황 | 선택 | 이유 |
|---|---|---|
| 서브테마명처럼 키가 도메인상 정해짐 | dict + 병합 함수 | 결과를 이름으로 찾을 수 있다 |
| 경쟁사·검색결과처럼 개수가 입력에 따라 변함 | list + operator.add | 인덱스가 아니라 원소 안의 type 메타데이터로 구분 |
Q. 노드는 상태 전체를 반환해야 하나
아니다. 바꾼 칸만 반환한다.
반환하지 않은 키는 그대로 유지된다. 그래서 여러 노드가 서로 다른 채널을 동시에 갱신해도 충돌 없이 병합된다. 상태 전체를 복사해 수정하고 돌려주는 습관이 남아 있으면 이 이점이 사라진다.
Q. 리듀서 없이도 카운터가 늘던데
리듀서 덕이 아니다. 노드가 직전 값을 읽어 +1 했을 뿐이다.
누적 규칙이 아니라 읽고-쓰기의 결과다. 리스트 채널에서 같은 방식을 쓰면 덮어쓰기가 그대로 드러난다. 이 차이를 모르면 병렬 노드를 붙이는 순간 값이 사라진다.
Q. tool_calls가 생기면 도구가 실행된 것인가
아니다. 그 시점에는 요청만 만들어졌다.
실행은 별도 노드가 하고 tool_call_id가 요청과 결과를 잇는다. 이 분리가 중요한 이유는 결정과 실행 사이에 사람을 세울 자리가 생기기 때문이다. 한 몸이면 승인 게이트를 놓을 곳이 없다.
Q. bind_tools와 ToolNode를 왜 둘 다 쓰나
전자는 LLM에게 무엇을 부를지 결정시키고, 후자가 실제로 실행한다.
| 단계 | 담당 | 산출 |
|---|---|---|
| 결정 | bind_tools로 묶인 LLM | tool_calls (구조화된 호출 요청) |
| 실행 | ToolNode | ToolMessage (결과) |
| 연결 | — | tool_call_id |
같은 도구 리스트가 서로 다른 두 곳에 들어간다. 상세는 bind_tools·ToolNode·ReAct 루프에 있다.
Q. 순환 그래프는 어떻게 만드나
배선 두 줄이면 ReAct 루프가 완성된다.
조건부 엣지로 chatbot → tools, 고정 엣지로 tools → chatbot. 되돌림 엣지 하나가 "도구를 몇 번 부를지 런타임에 정해진다"는 성질을 만든다.
Q. 도구를 만들었는데 LLM이 안 부른다
프롬프트가 아니라 docstring과 파라미터 이름을 고친다.
LLM이 보는 것은 함수명·시그니처·docstring 세 가지뿐이다. docstring은 주석이 아니라 라우팅 신호다. 도구 선택 정확도 문제의 상당수가 여기서 해결된다.
| 지점 | 왜 중요한가 |
|---|---|
| docstring | LLM이 보는 설명 전부. 스펙처럼 쓴다 |
| 타입 힌트 | function calling 스키마를 만든다. 없으면 인자 형식을 추측한다 |
| 호출 예시 | docstring에 실제 호출·응답 형태를 넣으면 인자 오류가 크게 준다 |
| 반환값 압축 | 원본 대신 필요한 지표만 정제해 반환. 툴 설계는 곧 컨텍스트 예산 관리 |
설계 원칙 전체는 에이전트 Tool 설계에 있다.
Q. 도구가 늘수록 좋은가
아니다. 도구 스키마가 매 호출 컨텍스트에 실린다.
개수가 늘면 토큰이 늘고 선택 정확도는 떨어진다. 도구를 추가하기 전에 기존 도구의 설명을 고쳐 해결되는지를 먼저 본다.
Q. 도구를 두 번 이상 이어 부르려면
tools → chatbot 되돌림 엣지가 그 일을 한다.
LLM이 결과를 보고 다음 도구를 정한다. 몇 바퀴 돌지는 사전에 정해지지 않는다 — 이것이 DAG로 표현되지 않는 이유이자 종료 조건을 따로 설계해야 하는 이유다.
Q. 라우팅을 LLM에 맡길지 UI에 맡길지 어떻게 정하나
사용자가 이미 정답을 아는 결정이면 UI 선택값이 낫다.
| LLM 라우팅 | UI 선택값 | |
|---|---|---|
| 결정론 | 없음 | 있음 |
| 비용 | 호출 1회 | 0 |
| 재현성 | 낮음 | 높음 |
| 대가 | — | 소스 혼합 불가 |
제품 화면의 선택지를 그래프 분기 키로 옮긴 사례는 Focus 라우팅과 인용 정합성에 있다.
Q. 에이전트의 기억은 어디에 저장되나
모델이나 그래프가 아니라 체크포인터에 저장되고, 조회 키는 thread_id다.
그래서 매 호출에 이력 전체를 다시 넣지 않는다. 새 발화 하나만 넣으면 체크포인터가 이전 상태를 복원하고 add_messages가 이어붙인다.
멀티유저에서 thread_id를 사용자·세션 단위로 발급하면 그것만으로 대화 격리가 완성된다. 반대로 공유하거나 하드코딩하면 다른 사용자의 대화가 섞여 나오는 사고로 직결된다.
Q. MemorySaver를 프로덕션에 써도 되나
안 된다. 프로세스 메모리라 재시작 시 소실되고 다중 인스턴스 공유가 불가능하다.
| 구현 | 저장소 | 용도 |
|---|---|---|
MemorySaver | 프로세스 메모리 | 학습·노트북 |
| SQLite 세이버 | 로컬 파일 | 단일 노드 데모·개인 도구 |
| PostgreSQL 세이버 | RDB | 다중 인스턴스 서비스 운영 |
교체 비용은 작다 — compile 인자 한 줄이다. 그래서 미루다가 그대로 배포되는 일이 잦다.
Q. 메시지를 잘라내면 기억이 지워지나
LLM 입력만 잘린다. 체크포인터에는 전량 남는다.
컨텍스트 관리와 데이터 보존은 별개 층이다. 자르면 비용만 줄고 데이터는 그대로다 — 반대로 말하면, 잘랐다고 개인정보가 지워진 것도 아니다.
| 전략 | 방식 | 트레이드오프 |
|---|---|---|
| 윈도우 슬라이싱 | 최근 N개만 전달 | 구현 1줄, 오래된 사실 유실 |
| 요약 압축 | 오래된 구간을 요약 1개로 치환 | 맥락 보존, 요약 호출 비용·왜곡 |
| 선별 검색 | 이력을 벡터 검색해 관련 대목만 주입 | 장기 기억에 강함, 인프라 필요 |
| 결과 압축 | 워커 산출물 중 마지막 메시지 1개만 상위로 | 컨텍스트 오염 차단, 중간 발견 소실 |
Q. 체크포인트에 무엇이 남나
상태 값, 다음 실행 노드, thread_id와 타임스탬프, 그리고 어느 노드가 무엇을 썼는지(writes)가 남는다.
마지막 항목이 사후 추적의 근거다. "왜 이 답이 나왔나"를 되짚을 때 필요한 것은 최종 상태가 아니라 각 스텝의 기록이다.
Q. Human-in-the-Loop은 어떻게 구현하나
compile(interrupt_before=["tools"]) 한 줄이면 지정한 노드 직전에 그래프가 멈춘다.
전제 조건은 체크포인터다. 멈춘 지점을 저장할 곳이 있어야 재개가 되기 때문이다.
멈춘 뒤에는 get_state로 다음 노드와 호출 인자를 검토하고, 그대로 재개하거나 update_state로 고쳐서 재개하거나 중단한다. 상세는 체크포인터·HITL 편에 있다.
Q. 체크포인터 없이 인터럽트만 걸면 어떻게 되나
성립하지 않는다. 멈춘 지점을 저장할 곳이 있어야 재개가 된다.
두 옵션이 별개로 보이지만 하나가 다른 하나의 전제다. 개발 중에도 MemorySaver를 지정해 두는 편이 낫다.
Q. Human-in-the-Loop의 조직적 의미는 무엇인가
정책 문서가 아니라 코드로 강제되는 게이트라는 점이다.
인터럽트 지점의 스냅샷이 그대로 승인 로그가 된다. "위험한 작업은 확인받는다"가 문서에만 있으면 지켜지는지 확인할 방법이 없지만, 그래프에 있으면 통과 기록이 남는다.
Q. 자동화 범위를 어떻게 넓히나
처음엔 전 도구를 승인 대상으로 두고, 신뢰가 쌓인 도구부터 게이트에서 뺀다.
점진 자동화가 통제를 잃지 않는 유일한 방법이다. 반대 순서 — 전부 자동으로 열어 두고 사고가 나면 닫는 방식 — 은 사고가 나야만 경계를 알게 된다.
Q. 사람이 고친 내용은 버리나
아니다. update_state 이력이 곧 모델 개선용 라벨 데이터다.
무엇을 어떻게 고쳤는지가 쌓이면 프롬프트를 어디서 손봐야 하는지가 드러난다. 승인 UI를 만들 때 수정 전후를 함께 저장하지 않으면 이 자산이 사라진다.
Q. 진행 상황은 어떻게 보여주나
노드 경계 스트리밍은 노드가 끝나야 값이 나온다.
토큰과 도구 시작·종료까지 보여주려면 노드 내부 이벤트 스트림이 필요하다. 두 층을 구분하지 않으면 "왜 한참 멈춰 있다가 한 번에 나오나"를 설명할 수 없다. 구현은 노트북을 서비스로에 있다.
Q. 진행 표시와 스트리밍은 같은 것인가
다르다. 단계 문구를 찍는 것과 그래프 이벤트를 구독하는 것은 별개다.
그리고 단계별 UX는 검색과 생성이 노드로 분리돼야 성립한다. 한 노드가 전부 하면 보여줄 중간 지점이 없다.
Q. UI에서 대화 상태는 어디에 두나
렌더링용 히스토리는 세션 저장소, 그래프 실행 상태는 **체크포인터 + thread_id**로 분리한다.
그래프 객체 자체는 캐싱해 재컴파일을 막는다. 셋을 한 곳에 두면 화면을 새로 그릴 때마다 그래프가 다시 만들어지거나, 반대로 대화가 남아 있는데 상태가 사라지는 일이 생긴다.
용어 정리
| 용어 | 뜻 |
|---|---|
| StateGraph | 상태 스키마를 받아 노드·엣지를 조립하는 그래프 빌더 |
| Node / Edge | 상태를 받아 부분 업데이트를 반환하는 함수 / 다음 노드로의 연결 |
| Conditional Edge | 함수 반환값으로 다음 노드를 고르는 분기. 사이클의 출발점 |
| Reducer | 기존 채널 값과 노드 반환값을 어떻게 합칠지 정하는 채널별 병합 규칙 |
add_messages | 메시지 전용 리듀서. 누적 + id 기반 upsert + 타입 정규화 |
Tool / bind_tools | LLM이 호출할 외부 기능 / LLM에게 도구 목록을 인지시키는 바인딩(결정) |
| ToolNode | tool_calls를 실제로 실행해 결과 메시지를 반환하는 노드(실행) |
tool_call_id | 도구 호출 요청과 그 결과를 잇는 식별자 |
| Checkpointer | 스텝별 상태를 저장·복원하는 지속성 계층. HITL의 전제 조건 |
thread_id | 대화 세션을 가르는 키. 잘못 공유하면 대화가 섞이는 사고 |
HITL / interrupt_before | 실행 도중 사람이 승인·수정하는 구조 / 지정 노드 직전에 정지시키는 옵션 |
| Structured Output | 출력을 스키마로 강제해 파싱 실패를 없애는 기능. 그래프 안정성의 전제 |
여기까지가 에이전트 하나를 제대로 돌리는 문제다. 다음 질문은 "언제 쪼개는가"로 넘어간다. 분할 경계, 토폴로지, 팬아웃, 그리고 실제 제품을 뜯어 만든 클론들은 멀티에이전트 Q&A에서 다룬다.