상태 머신으로 첨삭 흐름을 되돌림 가능한 단계로 분리
800줄 한 함수에 몰린 작문 첨삭 흐름을 상태 머신으로 나눠 되돌릴 수 있는 단계로 재설계한 실전기를 다룬다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
지난주 치밍 영어의 조교 "샤오E"가 작문 첨삭을 출시했는데, 첫날에 바로 사고가 두 건 났다.
한 건은 채점 인터페이스 타임아웃이었다. 첨삭 흐름은 "문제 읽기 → 문법 오류 찾기 → 어휘 문제 찾기 → 구조 보기 → 채점 → 평어 생성"인데, 앞의 네 단계는 모두 돌았지만 채점에서 막혔다. 이 버전을 맡은 학생이 전화로 나에게 한 말은 그대로 이랬다. "방법이 없어요, 함수 전체를 다시 돌려야 하는데, 앞 네 단계의 token은 그냥 헛날린 셈이에요."
다른 한 건은 더 억울했다. 제품 쪽에서 어휘 검사 판단을 한 줄 바꿔야 한다고 했는데, 그가 고친 뒤에는 바로 배포할 엄두를 내지 못했다. 그 한 줄 판단이 800줄짜리 review_once() 함수 중간에 파묻혀 있었고, 옆에는 채점 로직과 평어 조합이 있었기에, 그는 "어휘" 단계만 검증할 수 없었고, 그저 모든 과거 작문을 집어넣어 전체 링크 회귀 테스트를 돌려야 했고, 오후 내내 돌렸다.
나는 그 함수를 봤다. 여섯 단계 로직, 세 겹 중첩 루프, 열일곱 개의 중간 변수, 스무 줄이 넘는 print. 돌아가지 않는 게 아니라, 매 단계의 결과가 모두 지역 변수에 담겨 있어서, 문제가 생겨도 그 단계로 되돌릴 수도 없고 그 단계만 따로 다시 돌릴 수도 없었다.
이 글에서 하는 일은 딱 하나다. 이 파이프라인을 상태 기계에 맡기는 것이다. 같은 첨삭 흐름을 어떻게 다섯 개 노드로 쪼개는지, 어떻게 "점수가 부족하면 다시 되돌려 재수정"하는 루프를 연결하는지, 어떻게 루프가 통제를 벗어나 돈을 태우지 않게 보장하는지, 그리고 어떻게 중간에 멈췄다가 이어서 돌리는지를 보게 될 것이다.
一. 먼저 기초 다지기: 순서도와 상태 기계는 어디가 다른가
대부분은 순서도를 그려봤지만 상태 기계는 써본 적이 없어서, 둘을 쉽게 혼동한다.
순서도는 사람이 보기 위한 것이고, 상태 기계는 프로그램이 돌기 위한 것이다. 순서도는 그리고 나면 벽에 붙여두지만, 상태 기계는 실행 중에 정말로 "내가 지금 어느 상태에 있는지"를 보유하고 매 단계마다 이 상태를 바꾼다. 차이는 요구 사항이 바뀔 때 폭발한다. 제품 쪽에서 "채점이 75점 미만이면 되돌려 재수정"이라고 하면, 함수에서는 이것이 while을 한 겹 더 중첩하고, 플래그 몇 개를 더 판단하고, 중간 변수의 스코프가 서로 얽히는 것을 의미한다. 상태 기계에서는 이것이 간선 하나 추가하는 것이다. "채점" 노드에서 "어휘 검사" 노드로 선을 하나 당기면 끝이다.
LangGraph는 상태 기계를 세 가지 세트로 나누는데, 용어를 먼저 기억해야 한다.
개념 | 무엇인가 | 이 글에서 State | 그래프 안을 흐르는 그 데이터, 모든 노드가 공유하며 읽고 쓴다 | EssayState: 작문, 두 장의 문제 목록, 총평, 점수, 평어, 횟수 Node | 하나의 함수: State로 들어가 "부분 갱신"을 반환한다 | 다섯 개 노드: check_input / check_grammar / check_words / check_structure / score_and_feedback Edge | 노드 사이를 어떻게 가는가 | 고정 간선(반드시 감) + 조건부 간선(State를 읽은 뒤에 결정)
이 글의 두 가지 핵심 포인트:
조건부 간선(add_conditional_edges). 일반 간선은 "A가 끝나면 B로 간다"로 박혀 있다. 조건부 간선은 라우팅 함수를 하나 걸어두고, 매번 노드가 다 돌면 현재의 State를 가지고 그것에게 "다음은 어디로 가나"를 묻는다. 되돌려 재수정, 입구 단락도 모두 조건부 간선이다.
루프 + 횟수 상한. 조건부 간선이 있으면 고리를 그릴 수 있다. 점수가 75 미만이면 check_words로 돌아가 재수정한다. 그러나 고리에는 반드시 브레이크가 있어야 한다. 아무리 고쳐도 낙제인 작문 하나가 그래프를 영원히 돌게 만들 것이고, 매 바퀴가 다 진짜 돈이다. 그래서 라우팅 함수 안에는 두 조건이 있다. 점수가 부족하고 그리고 재수정 횟수가 상한에 닿지 않았을 때만 돌아가도록 허락한다.
조건부 간선에는 쉽게 지나치는 세부 사항이 하나 더 있다. add_conditional_edges의 세 번째 인자는 매핑 테이블인데, 라우팅 함수가 어떤 문자열을 반환하든 매핑 테이블에서 대응하는 곳으로 가고, 매핑 테이블에 등록되지 않은 반환값은 즉시 오류를 낸다. 이는 곧 "가능한 모든 목적지"를 명시적으로 한 번 나열하는 것과 같다. 함수에서는 if 분기에서 else 하나를 빠뜨리면 그저 조용히 넘어가지만, 그래프에서는 그 자리에서 폭발한다. 나는 후자를 더 좋아한다.
checkpoint(MemorySaver + thread_id). 그래프를 컴파일할 때 MemorySaver를 걸고, 매번 실행할 때 thread_id로 표시하면, LangGraph가 매 단계의 State를 자동으로 저장한다. 중간에 멈추고 싶으면? thread_id를 바꾸면 완전히 새로운 한 번이고, 같은 thread_id로 다시 invoke하면 지난번 상태에 이어서 돈다. 첨삭 도중 사람이 확인해야 하거나, 서비스 재시작 후 이어서 돌려야 할 때, 이것에 기대는 것이다.
한 가지 더 먼저 정리해야 한다. State는 "신(God) 변수"가 아니다. 신 변수는 누구나 쓸 수 있고 언제 바뀌었는지 아무도 모르는 큰 사전이다. State는 주인이 있는 계약이다. 각 필드에 어느 노드가 관리하는지, 새 값이 대체인지 추가인지가 명시되어 있다. 배분 원칙은 한 문장이다. 각 노드는 자기 몇 개 필드만 쓰고, 남의 것은 건드리지 않는다.
二. 프로젝트 디렉터리
시리즈의 프로젝트 구조를 그대로 따르며, settings.py와 llm.py는 1편과 완전히 같고 그대로 재사용한다.
06-LangGraph编排/ ├── src/ │ ├── settings.py # 1편 재사용 │ ├── llm.py # 1편 재사용: 실제 모델 / 오프라인 가짜 모델 두 갈래 경로 │ ├── state.py # EssayState: 그래프 전체의 유일한 데이터 계약 │ ├── nodes.py # 다섯 개 노드 순수 함수 + build_nodes(llm) 팩토리 │ ├── graph.py # 조립: 노드, 간선, 조건부 간선, 루프, mermaid 내보내기 │ └── run_graph.py # 실행 진입점: 결과 + 거쳐온 노드 순서 ├── tests/ # 40개 테스트 케이스, 오프라인 전부 통과 ├── demo.py # 세 경로를 각각 한 번씩 실행 └── pytest.ini / requirements.txt / README.md / .env.example / .gitignore
계층 구분은 한 문장으로 끝난다. state는 계약을 정하고, nodes는 일하고, graph는 경로를 정하며, run_graph는 결과와 경로를 함께 내놓는 일을 맡는다.
三. 먼저 계약 작성: state.py
그래프를 아직 세우지도 않았는데, 나는 먼저 State를 쓴다. 이것이 프로젝트 전체에서 가장 중요한 결정이다. 중간 결과가 더 이상 지역 변수에 숨지 않고, 모든 사람이 볼 수 있는 하나의 구조로 펼쳐진다. 800줄 함수를 고칠 수 없는 근본 원인이 여기에 있다. "어휘 문제가 어느 변수에 저장되는지"를 알고 싶다면 처음부터 끝까지 읽는 수밖에 없다.
class EssayState ( TypedDict ): """한 번의 작문 첨삭이 그래프 안에서 흘러가는 모든 데이터.""" # ── 입력 ── essay: str # ── 각 노드의 산출물(덮어쓰기 의미)── grammar_issues: list [ str ] # check_grammar 가 씀 word_issues: list [ str ] # check_words 가 씀 structure_comment: str # check_structure 가 씀 score: float # score_and_feedback 이 씀 feedback: str attempts: int # 채점을 몇 번 했는지, 루프의 브레이크 패드 error: str # 진입에서 판정해 버리는 하드 오류, 비어 있지 않으면 숏컷 # ── 누적 필드(Annotated + add)── errors: Annotated[ list [ str ], add] # 노드 내부 예외, 누가 사고 나면 누가 덧붙임 trace: Annotated[ list [ str ], add] # 지나온 노드 이름, 순서대로 누적
핵심은 덮어쓰기 vs 누적 이고, 이것이 LangGraph 상태 병합의 핵심 규칙이다:
- Annotated 를 쓰지 않은 필드는 일괄 덮어쓰기. 노드가 무엇을 반환하든 필드는 그것이 된다. score , feedback , word_issues 는 모두 이래야 한다——다시 돌려 재실행하면 완전히 새로운 결과를 얻어야 하고, 절대 이전 라운드의 옛 답안과 합쳐져서는 안 된다.
- Annotated[..., add] 를 쓴 필드는 reducer 에 따라 병합. errors 와 trace 는 반드시 누적되어야 한다: 각 노드가 자기 한 줄씩 덧붙이고, 나중에 실행된 노드가 먼저 실행된 기록을 지울 권한은 없다. 만약 덮어쓰기 의미를 쓰면, trace 안에는 영원히 마지막 노드 하나만 남는다는 것을 알게 될 것이다——로그가 없는 것과 같다.
짝을 이루는 empty_state() 팩토리도 있는데, 모든 필드에 안전한 초기값을 준다. 존재 이유는 작지만 아프다: 누적 필드의 초기값은 반드시 빈 리스트여야 한다——노드에서 trace 에 덧붙여야 하는데, 초기값이 None 이면 첫 번째 append 에서 곧바로 AttributeError 가 난다; 그리고 덮어쓰기 필드에 빈 값을 주는 것은, 노드에서 state.get("score", 0.0) 으로 얻는 것이 항상 숫자이고 None 이 아니게 하기 위해서다. 문자열 연결이 깊은 밤에 터져서 너를 보게 하지 말라.
필드 옆에는 전부 "누가 쓰는지"를 주석으로 선언해 두었다. 이것은 장식이 아니다——나중에 테스트에 정말로 "각 노드는 자기 필드 몇 개만 쓴다"는 단언이 하나 있었고, 바로 이 주석을 보고 작성한 것이다.
四, 다섯 개의 노드: nodes.py
노드에는 딱 세 가지 하드 규칙만 있다: State 를 받고, "부분 업데이트" dict 를 내놓는다; 내부에서 모델을 호출할 때는 반드시 try/except 로 errors 필드에 전환한다; 노드는 다음 단계가 누구인지 모른다.
먼저 가장 단순한 진입 노드를 보자:
def check_input ( state: EssayState ) -> dict : """진입 노드: 작문을 쓰지 않고 제출한 경우를 먼저 막아, 토큰 하나도 낭비하지 않는다.""" essay = (state.get( "essay" ) or "" ).strip() if not essay: return { "error" : "작문이 비어 있어 첨삭할 수 없습니다." , "trace" : [ "check_input" ]} if len (essay) < MIN_ESSAY_LEN: return { "error" : f"작문이 너무 짧습니다( { len (essay)} 자, 최소 {MIN_ESSAY_LEN} 자)." , "trace" : [ "check_input" ]} return { "error" : "" , "trace" : [ "check_input" ]}
설계 결정 두 가지. 첫째, 전체 State 가 아니라 dict 를 반환한다: 반환값에 "나는 error 와 trace 만 바꿨다"고 적혀 있어 한눈에 보이고, 다른 필드를 잘못 건드리지도 않는다. 둘째, 하드 오류는 그 자리에서 숏컷한다——빈 작문과 세 문장짜리 작문은 여섯 번의 모델 호출을 할 가치가 없으니, 여기서 판정해 버리면 이후 노드는 아예 실행되지 않는다.
다음은 핵심인 score_and_feedback 인데, 여기에는 틀리기 쉬운 점이 두 가지 있다:
def score_and_feedback ( state: EssayState ) -> dict : """점수 매기기 + 평가 작성, 동시에 attempts +1.""" human = ( f"글 원문:\n {state.get( 'essay' , '' )} \n\n" f"문법 문제:\n" + "\n" .join( f"- {item} " for item in grammar) + "\n\n" f"어휘 문제:\n" + "\n" .join( f"- {item} " for item in words) + "\n\n" f"구조 총평: {structure} " ) try : reply = llm.invoke([SystemMessage(SCORE_SYSTEM), HumanMessage(human)]) score = _parse_score( str (reply.content)) feedback = _split_feedback( str (reply.content)) except Exception as exc: # 점수 매기기 실패도 한 번의 시도로 친다: score는 비워 둔 채로 두고, 라우팅이 이를 재수정으로 보내도록 해서 # 점수가 없는 글을 "완료"로 넘겨버리는 일이 없게 한다. return { "trace" : [ "score_and_feedback" ], "attempts" : state.get( "attempts" , 0 ) + 1 , "errors" : [ f"score_and_feedback 호출 실패: { type (exc).__name__} : {exc} " ], } return { "trace" : [ "score_and_feedback" ], "score" : score, "feedback" : feedback, "attempts" : state.get( "attempts" , 0 ) + 1 , }
첫째, 예외를 필드로 바꾸고 그래프 밖으로 던지지 않는다. 모델 타임아웃, Key 무효화, 출력이 엉망인 것 모두 런타임의 일상이다. 예외를 밖으로 던지면 첨삭 체인 전체가 곧바로 무너져 앞의 세 단계가 헛돌고, errors 필드로 바꾸면 그래프가 계속 진행되어 마지막에 함께 결산한다. 800줄 함수 시절에는 하나의 try가 여섯 단계 전체를 감쌌기에, 오류가 나도 몇 번째 단계에서 터졌는지 전혀 알 수 없었다. 이제는 각 노드가 스스로를 감싼다.
둘째, 실패도 한 번의 시도로 친다. attempts + 1 을 except 분기에 쓴 것은 즉흥적인 처사가 아니다. 점수 매기기 실패를 횟수에 넣지 않으면 라우팅은 "아직 예산이 남았다"고 판단해, 영원히 점수가 나오지 않는 글을 무한히 재시도하게 된다.
한 가지 더 따로 짚을 만한 점이 있다. 재처리 회차의 check_words는 반드시 이전 회차의 평가를 지니고 모델에게 물어야 한다. 그렇지 않으면 똑같은 목록을 토해내서 루프가 헛돌고, 두 번 돌아 두 번의 비용을 쓰고도 결론은 전혀 달라지지 않는다.
if attempts > 0 : human = ( f"글 원문:\n {essay} \n\n" f"이전 회차 평가: {state.get( 'feedback' , '' )} \n" f"이전 회차 어휘 문제: {state.get( 'word_issues' , [])} \n" "이전 회차 평가를 바탕으로 어휘를 다시 점검하고, 다른 결론을 내놓으시오." )
또한 다섯 함수 모두 전역 모델을 바로 쓰지 않고 build_nodes(llm) 팩토리에 감싸여 있다. 테스트할 때는 가짜 모델을, 운영할 때는 진짜 모델을 넘기면 되고 노드 코드는 한 줄도 고치지 않는다. 이 습관은 2편부터 5편까지 이어져 왔고, 여기서도 그대로 통한다.
五、배선: graph.py
노드는 흩어져 있고, 그래프가 이들을 엮는다. 먼저 두 개의 라우팅 함수, 즉 조건부 엣지의 두뇌를 보자.
def route_after_check_input ( state: EssayState ) -> str : """입구 노드 이후의 라우팅: 글이 불합격이면 바로 끝낸다.""" return GO_ABORT if state.get( "error" ) else "check_grammar" def route_after_score ( state: EssayState ) -> str : """점수 매기기 이후의 라우팅: 점수가 부족하고 횟수도 다 쓰지 않았을 때만 check_words로 돌아간다.""" if state.get( "score" , 0.0 ) < PASS_SCORE and state.get( "attempts" , 0 ) < MAX_ATTEMPTS: return GO_RETRY return GO_FINISH
route_after_score 안의 두 조건은 하나도 빠져서는 안 된다. score < PASS_SCORE 는 "재수정이 필요한가"이고, attempts < MAX_ATTEMPTS 는 "아직 예산이 있는가"이다. 후자가 빠지면 아무리 고쳐도 낮은 점수인 글이 그래프를 영원히 돌게 만든다. 상한 MAX_ATTEMPTS = 2 는 상수로 state.py에 박아 두었고, 어느 노드도 손에 쥐고 있지 않다.
조립 본체:
builder = StateGraph(EssayState) for name in NODE_NAMES: builder.add_node(name, nodes[name]) builder.set_entry_point( "check_input" ) builder.add_conditional_edges( "check_input" , route_after_check_input, { "check_grammar" : "check_grammar" , GO_ABORT: END}, ) builder.add_edge( "check_grammar" , "check_words" ) builder.add_edge( "check_words" , "check_structure" ) builder.add_edge( "check_structure" , "score_and_feedback" ) builder.add_conditional_edges( "score_and_feedback" , route_after_score, {GO_RETRY: "check_words" , GO_FINISH: END}, ) graph = builder. compile ( checkpointer=checkpointer if checkpointer is not None else MemorySaver(), interrupt_before=interrupt_before, )
읽는 순서는 위에서 아래로다. add_node가 다섯 개 노드를 달고, set_entry_point가 진입점을 정한다. 줄기 세 단계는 일반 엣지로 한 줄로 이어 붙이고, 두 개의 조건 엣지가 각각 "입구 단락"과 "재투입 루프"를 맡는다. compile() 후에는 이 그래프를 invoke할 수 있다.
build_graph(llm)은 팩토리로 만들었고, 기본으로 MemorySaver 하나를 checkpointer로 넘긴다. 테스트 케이스마다 팩토리를 한 번 호출하면 완전히 새로운 그래프와 완전히 새로운 기억 저장소를 얻고, thread_id끼리 서로 섞이지 않는다. interrupt_before 파라미터도 남겨 두었다. 그래프가 특정 노드 전에 멈춰 사람의 확인을 기다리게 하고 싶으면 노드 이름 리스트를 넘기면 된다.
마지막으로 공짜 덤 하나:
def get_mermaid ( graph ) -> str : """mermaid 플로차트 텍스트를 내보낸다. Markdown에 그대로 붙여 렌더링할 수 있다.""" return graph.get_graph().draw_mermaid()
그래프를 고쳐도 문서를 손으로 그릴 필요가 없다. 한 줄의 코드로 현재 토폴로지를 mermaid 텍스트로 내보낸다. 아래 4절의 실제 출력에 그것이 만들어 낸 모습이 있다.
六、실행 진입점: run_graph.py
장애를 추적할 때 물어야 할 것은 결코 "마지막 점수가 얼마였나"가 아니라 "그것이 도대체 어느 단계들을 거쳤고, 어느 단계에서 방향을 틀었나"다. 그래서 run_review는 경로와 결과를 함께 내놓는다.
def run_review ( essay: str , llm, thread_id: str = "essay-default" ) -> dict : graph = build_graph(llm) config = { "configurable" : { "thread_id" : thread_id}} final = graph.invoke({ "essay" : essay}, config=config) return { "path" : list (final.get( "trace" , [])), # 거쳐 간 노드 순서 "score" : final.get( "score" , 0.0 ), "feedback" : final.get( "feedback" , "" ), "grammar_issues" : final.get( "grammar_issues" , []), "word_issues" : final.get( "word_issues" , []), "structure_comment" : final.get( "structure_comment" , "" ), "attempts" : final.get( "attempts" , 0 ), "error" : final.get( "error" , "" ), "errors" : final.get( "errors" , []), }
path의 세 가지 형태가 세 가지 결말에 대응한다. 다섯 노드가 한 줄로 늘어서면 정상 완주이고, 중간에 check_words → check_structure → score_and_feedback 구간이 하나 더 생기면 재투입을 거친 것이며, ["check_input"]만 있으면 입구 단락이다. 로그에서 한눈에 구분되니 더 이상 print를 발굴할 필요가 없다.
끝까지 돌려 결과를 받는 것 외에도, 실행하면서 들여다보는 창구 iter_steps가 있다. 그것은 graph.stream(stream_mode="updates")를 써서 각 노드가 방금 다시 써넣은 업데이트 조각을 하나씩 yield한다. demo에 있는 "단계별 상태 스냅샷"이 바로 그것이 찍는 것이다. 출력하는 일은 호출하는 쪽이 맡고 노드는 순수 함수를 유지한다. 다섯 노드마다 print를 하나씩 심어 두는 것보다 훨씬 깔끔하다.
七、돌려 보자: 실제 출력
python demo.py (오프라인 모드, 네트워크 접속 없음, Key 불필요, 모델 응답은 미리 준비된 스크립트):
────────────────────────────────────────────────── 사례 1 · 정상 채점: 한 번에 전체 흐름 통과 ────────────────────────────────────────────────── 작문 원문: Last weekend I go to the park with my friends. W... 【각 단계 상태 스냅샷】 ▸ check_input error =(비어 있음) ▸ check_grammar grammar_issues = 2건 ▸ check_words word_issues = 1건 ▸ check_structure structure_comment = 서두에 시간과 장소를 밝혀 주제를 분명히 드러냈다. 중간에 연결 문장이 빠졌다. ▸ score_and_feedback score = 82.0 , feedback=의미 전달은 완전하고 시제 오류는 첫 문장에 집중되어 있다., attempts= 1 【결과】 지나온 경로: check_input → check_grammar → check_words → check_structure → score_and_feedback 채점 횟수: 1 최종 점수: 82.0
사례 2, 세 문장짜리 작문은 입구에서 바로 판정 사망:
▸ check_input error =작문이 너무 짧습니다(3자, 최소 20자). 지나온 경로: check_input 거부 이유: 작문이 너무 짧습니다(3자, 최소 20자).
사례 3, 첫 라운드 52점으로 낙제, 재작성 후 다시 한 라운드 거쳐 61점——여전히 낙제지만, 재작성 예산을 다 써버려서 도식에서는 여기서 강제로 마무리한다:
▸ score_and_feedback score = 52.0 , feedback=문법 오류가 많으니 다시 작성한 뒤 제출하세요., attempts= 1 ▸ check_words word_issues = 1건 ▸ check_structure structure_comment = 문단 사이에 연결을 넣었지만 결말은 여전히 매듭짓지 못했다. ▸ score_and_feedback score = 61.0 , feedback=이전 라운드보다 발전했으나 시제와 어순은 여전히 중점 연습이 필요합니다., attempts= 2 지나온 경로: check_input → check_grammar → check_words → check_structure → score_and_feedback → check_words → check_structure → score_and_feedback 채점 횟수: 2
경로 끝부분의 그 되돌이 구간에 주목하라——그것은 바로 그 조건 간선이 실제로 존재했었다는 증거이며, 도식에 그려 놓고 보기 위한 것이 아니다.
八、실제 pytest 출력
tests/test_state.py::test_all_required_fields_exist PASSED tests/test_state.py::test_empty_state_has_no_none_values PASSED tests/test_state.py::test_errors_and_trace_are_annotated_for_accumulate PASSED tests/test_state.py::test_score_and_feedback_are_plain_overwrite PASSED tests/test_nodes.py::test_check_input_rejects_empty_essay PASSED tests/test_nodes.py::test_check_input_rejects_too_short_essay PASSED tests/test_nodes.py::test_check_grammar_parses_three_issues PASSED tests/test_nodes.py::test_check_words_retry_round_sends_previous_feedback PASSED tests/test_nodes.py::test_score_exception_still_counts_as_one_attempt PASSED tests/test_graph.py::test_happy_path_visits_every_node_once PASSED tests/test_graph.py::test_mermaid_contains_all_node_names PASSED tests/test_graph.py::test_short_essay_is_short_circuited_at_entry PASSED tests/test_graph.py::test_loop_never_exceeds_max_attempts PASSED tests/test_graph.py::test_same_thread_id_resumes_from_checkpoint PASSED tests/test_graph.py::test_interrupt_before_then_resume_with_same_thread PASSED tests/test_edges.py::test_score_branch_retries_when_score_is_low PASSED tests/test_edges.py::test_score_branch_finishes_when_budget_is_used_up PASSED tests/test_run_graph.py::test_run_review_reports_the_retry_in_path PASSED 40 passed in 0.81 s
40개 케이스 전부 통과, 전 과정 오프라인. 이 테스트 묶음에서 내가 가장 주목하라고 하고 싶은 세 가지는:
test_loop_never_exceeds_max_attempts: 스크립트가 모델에게 항상 낮은 점수를 주게 하고, 그래프가 반드시 멈추며 attempts가 2에서 멈추는지 단언한다. 무한 루프 방지는 말뿐인 약속이 아니라 빨갛게 뜨는 테스트다.
test_same_thread_id_resumes_from_checkpoint: 같은 thread_id로 연속 invoke를 두 번 하고, 두 번째 trace가 첫 번째보다 길며 prefix가 완전히 동일하다고 단언한다——상태가 정말 checkpoint에 기억되었고, thread_id를 바꾸면 서로 간섭하지 않는다는 뜻이다.
test_interrupt_before_then_resume_with_same_thread: 컴파일할 때 interrupt_before=["score_and_feedback"]를 전달하고, 첫 invoke는 채점 전에 멈춘다(앞 세 단계 결과는 모두 있고 점수는 아직 쓰이지 않음). 그다음 graph.invoke(None, config)로 None을 전달해 이어서 실행하면 점수가 채워진다. 중단 후 재개하는 네 줄의 코드를 테스트 하나로 검증한 셈이다.
남은 큰 부분은 단일 노드 테스트에 할애했다. check_input의 세 가지 입력, 체크리스트 파싱에서 번호 제거, "无明显语法错误"를 빈 체크리스트로 처리, 점수 파싱의 폴백과 클램핑, 네 노드 각각의 예외를 필드로 전환, 그리고 "노드는 입력 인자를 바꾸면 안 된다"는 순수 함수 단언(State를 깊은 복사해 전달하고, 실행 후 원래대로인지 비교)이다. 이 테스트들은 그래프도, 오케스트레이션도 필요 없이 build_nodes(가짜 모델)["check_grammar"](state) 한 줄로 호출할 수 있다——이렇게 테스트할 수 있다는 것은 노드 안에 전역 상태가 숨어 있지 않다는 뜻이며, 이는 llm을 팩토리 매개변수로 만든 데 대한 보답이다.
九、함정 목록
- 노드가 편의상 전체 State를 반환했다: 다른 노드가 쓴 필드까지 그대로 되돌려 보냈고, 나중에 실행되는 노드가 먼저 실행된 결과를 통째로 덮어써서 최종 평어에서 "문법 문제"가 엉뚱하게 사라졌다. LangGraph의 관례는 부분 업데이트다——반환값에는 자기가 바꾼 필드만 써야 한다.
- trace에 Annotated[..., add] 붙이는 것을 잊었다: 덮어쓰기 의미로 전체 체인을 실행하니 trace에는 score_and_feedback 노드명 하나만 남았고, 나는 그래프가 끝까지 가지 않았다고 생각했다. 30분을 디버깅한 끝에 상태 병합 의미를 잘못 선택한 문제였다. 이것이 내가 두 가지 병합 의미를 전용 테스트로 만든 이유이기도 하다.
- 라우팅 함수가 점수만 판단하고 횟수는 판단하지 않았다: 일부러 항상 낮은 점수를 주는 첫 번째 케이스를 작성했을 때 LangGraph가 곧바로 GraphRecursionError를 던졌다——기본 최대 25스텝이라 프레임워크가 대신 브레이크를 밟아 준 셈이다. 하지만 프로덕션에서는 이것에 기대면 안 된다: 25스텝에서 오류가 나는 것은 25라운드 분량의 토큰을 헛되이 태우는 것과 같으므로 attempts 상한은 반드시 자체 라우팅에 써야 한다.
- 재가공 라운드의 결론이 첫 라운드와 똑같았다: 루프는 돌아갔지만 점수는 한 점도 오르지 않았다. 원인은 check_words의 프롬프트가 두 라운드에서 완전히 같아서 모델이 당연히 같은 답을 준 것이다. 이전 라운드의 평어와 문제 목록을 두 번째 라운드 프롬프트에 붙여야 재수정이 진짜 "수정"이 된다.
- set_entry_point("check_input")을 손이 미끄러져 잘못 썼다: 잘못 쓴 문자열은 조립 단계에서도 오류가 나지 않고 compile()에서도 나지 않다가, invoke할 때에야 노드를 찾을 수 없다는 예외가 터졌다. 이후 모든 노드명을 NODE_NAMES 상수 튜플에 모으고 graph와 테스트가 그것을 참조하게 해서 문자열을 다시는 직접 쓰지 않았다.
- 중단 후 재개할 때 invoke에 새 입력을 전달했다: "계속 실행"하려면 {"essay": ...}를 다시 전달해야 한다고 생각했는데, 결과적으로 작문이 다시 처리되었다. 올바른 방법은 None을 전달하는 것이다——LangGraph가 checkpoint에서 지난번 상태를 꺼내 이어서 진행하며, thread_id가 바로 그 열쇠다.
十、이번 글 마무리
그 800줄짜리 review_once()를 돌아보자. 그것과 지금의 이 그래프가 하는 일은 똑같고, 차이는 두 가지뿐이다——단계가 지역 변수에서 명시적 State로 바뀌었고, 제어 흐름이 중첩 루프에서 엣지와 조건 엣지로 바뀌었다. 하지만 바로 이 두 변화 덕분에 "3단계의 판단 하나를 바꾸는 일"이 전체 회귀에서 check_words 단일 테스트 실행으로 바뀌고, 채점 타임아웃이 전체 재실행에서 checkpoint 이어 실행으로 바뀌었다.
다음 글에서는 또 다른 실행 능력 문제인 Function Calling 도구 호출을 해결한다. 앞서 나온 "小E"는 말만 할 줄 알고 일은 못 한다——수업 시간을 물으면 지어내기만 한다. 다음 글에서는 실제로 查询课时, 查订单 같은 함수를 호출하게 해서 "대화를 할 줄 아는" 상태를 "무언가를 조회할 수 있는" 상태로 바꾼다.
마지막 한마디: 상태 기계는 함수를 더 예쁘게 쓰는 것이 아니라, "어느 단계에서 잘못됐는가"를 수수께끼에서 한 줄 로그로 바꿔 준다——되돌릴 수 있는 흐름이라야 반복 개선에 걸맞다.
威哥爱编程
AI 풀스택, 公众号: 威哥爱编程
433
글
329k
읽음
570
팬