AI 에이전트 — 모델이 아니라 그래프 · 상태 스키마와 리듀서 · 실습
지나온 길이 한 칸만 남는다
목표
에이전트 상태를 열쇠 목록이 아니라 합치는 규칙으로 설계한다. 리듀서가 없을 때 무슨 일이 생기는지 직접 재현한 뒤, 이어 붙이기·중복 제거·최댓값·창 자르기 네 가지 리듀서를 만들고, 입력과 출력 스키마를 나눠 바깥에 나갈 열쇠를 좁힌다.
왜 중요한가
LangGraph 의 상태는 열쇠마다 채널이 하나씩 있는 구조다. 노드는 바꾸고 싶은 열쇠만 담은 딕셔너리를 돌려주고, 그래프가 채널별로 적용한다. 이때 리듀서를 안 적은 열쇠는 덮어쓴다 — 세 노드가 차례로 쓴 trace 가 마지막 하나만 남는 것이 그래서다. 오류가 나지 않아서 "로그가 안 남는다" 로 보이고, 로그 코드를 의심하다 시간을 버린다.
반대쪽 문제도 있다. 리듀서를 붙이면 그 열쇠는 끝없이 자라는데, 체크포인터는 상태를 통째로 저장한다. 노드가 서른 번 돌면 체크포인트도 서른 개고 하나하나가 그 시점의 상태 전체다. 그래서 자라는 열쇠에는 상한을 리듀서 안에 적어 둔다 — 노드마다 흩어 두면 한 곳만 빠뜨려도 조용히 새어 나간다.
마지막으로 상태에는 바깥에 보이면 안 되는 것이 섞인다. 출력 스키마로 나갈 열쇠를 화이트리스트로 정하면, 새 열쇠가 늘어도 저절로 막힌다.
채점기는 여러분이 적어 둔 설명을 믿지 않는다. 여러분의 모듈을 실제로 불러서 리듀서를 매번 다른 값으로 직접 두드려 보고, 그래프를 돌려 나온 상태를 채점기가 따로 계산한 값과 대조한다. 노드 이름과 숫자는 실행마다 바뀐다.
단계
1. /root/work/agstate/state.py 에 CATALOG·State·노드 셋(intake·lookup·finish)·build_graph() 를 만드세요. 노드는 바꾸는 열쇠만 돌려주고, 손대지 않은 열쇠는 그대로 남아야 합니다.
2. overwrite_demo(names) 를 더해 리듀서 없는 열쇠가 덮어써지는 것을 재현하게 하세요. 이름마다 노드를 하나씩 만들어 줄줄이 잇고, 노드마다 seen 에 자기 이름 하나를 씁니다.
3. State 의 trace 에 Annotated[list, operator.add] 를 붙여 세 노드의 발자국이 순서대로 쌓이게 하세요.
4. 리듀서 merge_sources(old, new) 를 만들고 sources 열쇠에 붙이세요. 같은 출처는 한 번만 남고 순서는 처음 본 순서를 지킵니다.
5. used_calls 에 operator.add 를, peak_ms 에 직접 만든 keep_max(old, new) 를 붙이세요.
6. RECENT_KEEP = 3 과 리듀서 keep_recent(old, new) 를 만들어 recent 열쇠가 뒤에서 세 개만 남게 하세요. finish 는 한 번에 두 개를 씁니다.
7. InputState·OutputState 를 만들고 build_public() 이 StateGraph(State, input=InputState, output=OutputState) 로 컴파일한 그래프를 돌려주게 하세요. 결과에는 answer 만 나와야 합니다.
8. /root/work/agstate/state_report.json 과 /root/work/agstate/state_report.md 에 상태 설계를 기록하세요.
참고
- 실행 계약: 채점기는
/root/work/agstate/state.py를 파이썬 모듈로 불러CATALOG·State·build_graph·overwrite_demo·merge_sources·keep_max·keep_recent·RECENT_KEEP·build_public을 직접 씁니다. 스크립트로 실행하지 않으므로if __name__ == "__main__"은 없어도 됩니다. build_graph()는 compile() 된 그래프를 돌려줍니다.StateGraph자체를 돌려주면.invoke()가 없습니다.- 노드 순서는
intake→lookup→finish입니다.trace에 쓰는 이름도 그 셋과 같게 쓰세요. intake는 질문에서CATALOG의 열쇠를 찾아target에 담고,lookup은found에 개수를(없으면 -1) 담고,finish는answer에"<이름> 재고는 <개수>개입니다"를 담습니다.- 리듀서는
(옛값, 새값)두 인자를 받는 보통 함수입니다. 내장 함수를 그대로 주면 안 됩니다 —Annotated[int, max]는 컴파일할 때ValueError: no signature found for builtin max로 죽습니다. sources에intake는["질문"],lookup은["질문", "재고목록"],finish는["재고목록"]을 씁니다. 리듀서가 제대로면 결과는 두 개뿐입니다.used_calls는 노드마다 1,peak_ms는intake4 ·lookup17 ·finish9 를 씁니다.recent에intake는["intake"],lookup은["lookup"],finish는["finish:작성", "finish:검토"]를 씁니다.- 이 파드에는 인터넷이 없습니다.
pip install은 되지 않습니다. langgraph 0.2.60 이 이미 들어 있습니다(python3 -c "import langgraph"). - 공식 문서: [Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api) · [Use the graph API](https://docs.langchain.com/oss/python/langgraph/use-graph-api) · [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence)
- 흔한 실수: 노드가 상태 전체를 돌려주기(바꾸는 열쇠만 돌려주세요),
Annotated를 빼고 리듀서 함수만 정의해 두기, 내장max를 리듀서로 주기, 창 자르기를 리듀서가 아니라 노드에 넣기.
단계 8개
- 노드는 바꾸는 열쇠만 돌려준다
- 리듀서가 없으면 마지막 하나만 남는다
- 발자국을 쌓는 리듀서
- 같은 출처를 두 번 적지 않는다
- 더할 것과 가장 큰 것만 남길 것
- 자라는 열쇠에 상한을 건다
- 나갈 것만 내보낸다
- 왜 그렇게 정했는지 남긴다