LabHub
배우기 러닝패스 코스

AI 에이전트 — 모델이 아니라 그래프 · 상태 스키마와 리듀서 · 이론

노드는 일을 하고, 상태는 그 일을 기억한다

LabHub 에서 이어서 보기

한 줄 요약

에이전트에서 나중에 고치기 가장 비싼 결정은 상태 설계다. 그리고 상태의 절반은 열쇠 목록이 아니라 합치는 규칙(리듀서) 이다.

왜 이게 필요했나

에이전트를 그래프로 짜고 나면 제일 먼저 만나는 버릇 없는 버그가 있다. 노드를 여러 개 지났는데 지나온 길이 마지막 하나만 남아 있는 것이다.

class State(TypedDict):    trace: list          # 리듀서가 없다def intake(state):  return {"trace": ["intake"]}def lookup(state):  return {"trace": ["lookup"]}def finish(state):  return {"trace": ["finish"]}

세 노드를 차례로 지난 뒤 trace 를 열어 보면 ["finish"] 다. ["intake", "lookup", "finish"] 가 아니다.

오류는 나지 않는다. 그래서 이 버그는 "왜 로그가 안 남지" 하고 로그 코드를 의심하는 데서 반나절을 먹는다. 진짜 원인은 로그가 아니라 그 열쇠에 합치는 규칙이 없다는 것이다.

어떻게 동작하나

LangGraph 의 상태는 열쇠마다 채널이 하나씩 있는 구조다. 노드는 상태 전체가 아니라 바꾸고 싶은 열쇠만 담은 딕셔너리를 돌려주고, 그래프가 그것을 채널별로 적용한다. 손대지 않은 열쇠는 그대로 남는다 — 그래서 노드 하나가 {"answer": ...} 만 돌려줘도 question 은 사라지지 않는다.

적용하는 방법이 곧 리듀서다. [Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api) 가 정하는 규칙은 두 줄이다.

리듀서는 (지금까지의 값, 노드가 돌려준 값) 두 인자를 받아 새 값을 돌려주는 보통 함수다. 특별한 것이 없다. 그래서 "합치는 규칙" 을 코드 한 군데에 모아 둘 수 있고, 그 자리가 상태 정의다.

def merge_sources(old, new):    out = list(old or [])    for item in new or []:        if item not in out:            out.append(item)    return outclass State(TypedDict, total=False):    trace: Annotated[list, operator.add]       # 이어 붙인다    sources: Annotated[list, merge_sources]    # 중복 없이 이어 붙인다    used_calls: Annotated[int, operator.add]   # 더한다

여기서 실측으로 확인한 함정이 하나 있다. 내장 함수를 리듀서로 바로 줄 수 없다. Annotated[int, max] 라고 쓰면 그래프를 컴파일할 때 ValueError: no signature found for builtin max 로 죽는다. LangGraph 가 리듀서의 서명을 들여다보는데 파이썬 내장 함수에는 들여다볼 서명이 없기 때문이다. def keep_max(old, new): return new if new > old else old 처럼 한 겹 감싸면 된다.

상태는 자란다 — 그게 비용이다

리듀서를 붙이고 나면 반대쪽 문제가 온다. tracesourcesmessages끝없이 자란다.

자라는 것 자체는 괜찮지만, [체크포인터](https://docs.langchain.com/oss/python/langgraph/persistence)가 상태를 통째로 저장한다는 점이 문제다. 한 번 도는 동안 노드가 30번 돌면 체크포인트도 30개가 생기고, 그 하나하나가 그 시점의 상태 전체를 들고 있다. 상태가 열 배 커지면 저장량도 열 배가 된다.

그래서 자라는 열쇠에는 상한을 리듀서 안에 적어 둔다.

def keep_recent(old, new):    return (list(old or []) + list(new or []))[-3:]

이렇게 해 두면 노드는 그냥 쓰면 되고, 상한은 한 군데에서만 지킨다. 노드마다 "너무 길면 자르기" 를 흩어 두는 것보다 낫다 — 흩어 두면 한 곳만 빠뜨려도 조용히 새어 나간다.

입구와 출구를 따로 정한다

상태에는 바깥에 보이면 안 되는 것이 섞인다. 사용자가 올린 원문, 중간 메모, 내부 점수 같은 것이다. StateGraph(State, input=InputState, output=OutputState) 로 입력과 출력 스키마를 따로 주면, 노드는 전체 상태를 보고 바깥은 출력 스키마의 열쇠만 받는다.

이것을 안 하면 "응답에 왜 내부 메모가 붙어 나오나" 를 화면 쪽에서 지우게 된다. 지우는 코드는 새 열쇠가 늘 때마다 뒤처지고, 결국 새어 나간다. 나가는 것을 화이트리스트로 정하는 편이 늘 안전하다.

현장에서 만나는 모습

첫째, 지나온 길이 한 칸만 남는다. 위에서 본 그대로다. 리듀서를 빼먹은 것이고, 증상은 "로그가 안 남는다" 로 나타난다.

둘째, 같은 출처가 스무 번 적힌다. 루프를 도는 에이전트가 같은 문서를 계속 참조하면 sources 가 같은 값으로 채워진다. operator.add 대신 중복을 지우는 리듀서를 쓰면 이 자리에서 끝난다.

셋째, 예산이 노드마다 흩어져 있다. 호출 수를 세는 코드가 노드 세 곳에 있으면 한 곳을 고칠 때 나머지가 뒤처진다. used_calls: Annotated[int, operator.add] 로 두고 노드는 {"used_calls": 1} 만 돌려주게 하면 세는 규칙이 한 군데에 남는다.

넷째, 체크포인트가 커져서 재개가 느려진다. 상태에 원문을 통째로 담아 두고 수십 번 도는 그래프가 그렇게 된다. 원문은 바깥(파일·저장소)에 두고 상태에는 가리키는 값만 담는 편이 낫다.

실무에서 진짜 중요한 것

다음 실습에서 할 것

/root/work/agstate/state.py 를 한 단계씩 키운다. 먼저 리듀서 없는 열쇠가 덮어써지는 것을 자기 손으로 재현하고, 이어 붙이는 리듀서·중복을 지우는 리듀서·최댓값만 남기는 리듀서·창을 자르는 리듀서를 차례로 붙인다. 마지막에 입력과 출력 스키마를 나눠 바깥에 나갈 열쇠를 좁히고, 그렇게 정한 이유를 기록으로 남긴다. 채점기는 여러분의 모듈을 실제로 불러 실행하고, 매번 다른 이름과 숫자로 리듀서를 직접 두드려 본다.