通った道が一つしか残らない
한국어 원문으로 표시합니다.
목표
에이전트 상태를 열쇠 목록이 아니라 합치는 규칙으로 설계한다. 리듀서가 없을 때 무슨 일이 생기는지 직접 재현한 뒤, 이어 붙이기·중복 제거·최댓값·창 자르기 네 가지 리듀서를 만들고, 입력과 출력 스키마를 나눠 바깥에 나갈 열쇠를 좁힌다.
왜 중요한가
LangGraph 의 상태는 열쇠마다 채널이 하나씩 있는 구조다. 노드는 바꾸고 싶은 열쇠만 담은 딕셔너리를 돌려주고, 그래프가 채널별로 적용한다. 이때 리듀서를 안 적은 열쇠는 덮어쓴다 — 세 노드가 차례로 쓴 trace 가 마지막 하나만 남는 것이 그래서다. 오류가 나지 않아서 "로그가 안 남는다" 로 보이고, 로그 코드를 의심하다 시간을 버린다.
반대쪽 문제도 있다. 리듀서를 붙이면 그 열쇠는 끝없이 자라는데, 체크포인터는 상태를 통째로 저장한다. 노드가 서른 번 돌면 체크포인트도 서른 개고 하나하나가 그 시점의 상태 전체다. 그래서 자라는 열쇠에는 상한을 리듀서 안에 적어 둔다 — 노드마다 흩어 두면 한 곳만 빠뜨려도 조용히 새어 나간다.
마지막으로 상태에는 바깥에 보이면 안 되는 것이 섞인다. 출력 스키마로 나갈 열쇠를 화이트리스트로 정하면, 새 열쇠가 늘어도 저절로 막힌다.
채점기는 여러분이 적어 둔 설명을 믿지 않는다. 여러분의 모듈을 실제로 불러서 리듀서를 매번 다른 값으로 직접 두드려 보고, 그래프를 돌려 나온 상태를 채점기가 따로 계산한 값과 대조한다. 노드 이름과 숫자는 실행마다 바뀐다.
단계
- /root/work/agstate/state.py 에
CATALOG·State·노드 셋(intake·lookup·finish)·build_graph()를 만드세요. 노드는 바꾸는 열쇠만 돌려주고, 손대지 않은 열쇠는 그대로 남아야 합니다. overwrite_demo(names)를 더해 리듀서 없는 열쇠가 덮어써지는 것을 재현하게 하세요. 이름마다 노드를 하나씩 만들어 줄줄이 잇고, 노드마다seen에 자기 이름 하나를 씁니다.State의trace에Annotated[list, operator.add]를 붙여 세 노드의 발자국이 순서대로 쌓이게 하세요.- 리듀서
merge_sources(old, new)를 만들고sources열쇠에 붙이세요. 같은 출처는 한 번만 남고 순서는 처음 본 순서를 지킵니다. used_calls에operator.add를,peak_ms에 직접 만든keep_max(old, new)를 붙이세요.RECENT_KEEP = 3과 리듀서keep_recent(old, new)를 만들어recent열쇠가 뒤에서 세 개만 남게 하세요.finish는 한 번에 두 개를 씁니다.InputState·OutputState를 만들고build_public()이StateGraph(State, input=InputState, output=OutputState)로 컴파일한 그래프를 돌려주게 하세요. 결과에는answer만 나와야 합니다.- /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 · Use the graph API · Persistence
- 흔한 실수: 노드가 상태 전체를 돌려주기(바꾸는 열쇠만 돌려주세요),
Annotated를 빼고 리듀서 함수만 정의해 두기, 내장max를 리듀서로 주기, 창 자르기를 리듀서가 아니라 노드에 넣기.
노드는 바꾸는 열쇠만 돌려준다
/root/work/agstate/state.py 에 CATALOG·State·노드 셋(intake·lookup·finish)·build_graph() 를 만드세요. build_graph() 는 compile() 된 그래프를 돌려주고, 노드가 손대지 않은 열쇠는 그대로 남아야 합니다.
TypedDict 로 상태를 정의하고 StateGraph(State) 에 노드를 더한 뒤 START 에서 END 까지 잇습니다. 노드는 상태 전체가 아니라 바꾸고 싶은 열쇠만 담은 딕셔너리를 돌려줍니다 — 그래야 다른 노드가 넣어 둔 값이 지워지지 않습니다. total=False 를 붙이면 열쇠가 다 채워지지 않아도 됩니다.
리듀서가 없으면 마지막 하나만 남는다
overwrite_demo(names) 를 더하세요. names 의 이름마다 노드를 하나씩 만들어 줄줄이 잇고, 노드마다 리듀서 없는 열쇠 seen 에 자기 이름 하나를 씁니다. 돌려주는 값은 {"writes": [...], "state_after": [...]} 입니다.
노드를 반복문으로 만들 때 람다가 마지막 이름만 붙잡는 고전적인 함정을 조심하세요 — 기본 인자나 감싸는 함수로 이름을 묶어야 합니다. writes 에는 여러분이 만든 노드 이름을 순서대로, state_after 에는 그래프를 돌린 뒤 실제로 남은 seen 값을 담습니다. 이 단계는 일부러 잘못된 설계를 재현하는 것입니다.
발자국을 쌓는 리듀서
State 의 trace 를 Annotated[list, operator.add] 로 바꿔 세 노드의 발자국이 ["intake", "lookup", "finish"] 순서로 쌓이게 하세요.
from typing import Annotated 와 import operator 가 필요합니다. Annotated 의 두 번째 자리에 놓인 함수가 그 열쇠의 리듀서가 됩니다. 노드 쪽 코드는 그대로 두세요 — 바뀌는 것은 상태 정의 한 줄뿐입니다. 그게 이 설계의 요점입니다.
같은 출처를 두 번 적지 않는다
리듀서 merge_sources(old, new) 를 만들어 sources 열쇠에 붙이세요. 같은 값은 한 번만 남고 순서는 처음 본 순서를 지킵니다. 세 노드는 각각 ["질문"]·["질문", "재고목록"]·["재고목록"] 을 씁니다.
리듀서는 (옛값, 새값) 두 인자를 받아 새 값을 돌려주는 보통 함수입니다. set 으로 중복을 지우면 순서가 무너지니 순서를 지키면서 걸러야 합니다. 옛값이 아직 없을 수도 있으니 old or [] 처럼 빈 값을 견디게 쓰세요.
더할 것과 가장 큰 것만 남길 것
used_calls 에 operator.add 를, peak_ms 에 직접 만든 keep_max(old, new) 를 붙이세요. 노드마다 used_calls 는 1 을, peak_ms 는 intake 4 · lookup 17 · finish 9 를 씁니다.
숫자에도 리듀서를 붙일 수 있습니다. 세는 규칙을 노드마다 흩어 두지 말고 상태 정의 한 줄로 모으는 것이 요점입니다. 내장 max 를 리듀서로 그대로 주면 안 됩니다 — LangGraph 가 리듀서의 서명을 들여다보는데 내장 함수에는 서명이 없어 컴파일에서 죽습니다. 한 겹 감싸세요.
자라는 열쇠에 상한을 건다
RECENT_KEEP = 3 과 리듀서 keep_recent(old, new) 를 만들어 recent 열쇠가 뒤에서 세 개만 남게 하세요. intake 는 ["intake"], lookup 은 ["lookup"], finish 는 ["finish:작성", "finish:검토"] 를 씁니다.
네 개가 들어오고 세 개만 남아야 하니, 합친 뒤에 자르는 순서가 중요합니다. 상한을 노드에 넣지 말고 리듀서에 넣으세요 — 노드는 앞으로도 늘어날 텐데, 한 곳만 빠뜨려도 조용히 새어 나갑니다. 체크포인터가 상태를 통째로 저장한다는 점이 이 상한의 이유입니다.
나갈 것만 내보낸다
InputState(질문만)·OutputState(답만)를 만들고 build_public() 이 StateGraph(State, input=InputState, output=OutputState) 로 컴파일한 그래프를 돌려주게 하세요. {"question": ...} 만 넣어 돌린 결과에는 answer 열쇠 하나만 있어야 합니다.
노드는 그대로 전체 상태를 보고, 좁아지는 것은 입구와 출구뿐입니다. 노드를 잇는 코드를 함수로 빼 두면 build_graph() 와 build_public() 이 같은 배선을 나눠 쓸 수 있습니다. 나가는 것을 지우는 코드로 막으면 새 열쇠가 늘 때마다 뒤처집니다 — 화이트리스트가 저절로 막습니다.
왜 그렇게 정했는지 남긴다
/root/work/agstate/state_report.json 에 reducers·trace·sources·used_calls·peak_ms·recent_len·public_keys 를, /root/work/agstate/state_report.md 에 ## 무엇을 상태에 두었나 ## 어떤 리듀서를 왜 붙였나 ## 자라지 않게 막은 곳 ## 바깥에 내보내지 않는 것 네 절로 쓰세요.
숫자는 손으로 적지 말고 여러분의 그래프를 실제로 돌려 얻은 값으로 채우세요. reducers 는 열쇠 이름을 열쇠로, 붙인 리듀서 이름을 값으로 두는 객체입니다(예: operator.add). public_keys 는 build_public() 결과에 실제로 나온 열쇠 목록입니다. 채점기는 같은 것을 따로 계산해 대조합니다.