레슨 6: 실습: 하네스를 작은 그래프로 끌어올리기
학습 목표:
- 앞선 다섯 레슨의 라우팅, 팬아웃, 병합, 리뷰 회로, 보고를
orchestrate.mjs 하나로 용접하기 — 계획은 코드에 살고, 각 노드는 여전히 코스 7(에이전트 하네스 기초: 루프와 통제)의 stop_reason 루프를 돌리며, 중간 결과는 스크립트 변수에 머문다
- 리뷰 회로를 실제로 돌려 보고, 그것이 멈추는 두 가지 방식을 함께 보기 — 게이트 리포트 하나로 고쳐지고 끝난 티켓 하나, 두 회차 연속 같은 리포트를 돌려주어 더는 진척이 없다고 판정되고 needs_human으로 표시된 티켓 하나
- 그래프 전체의 실행 자취를
run-state.json과 run.jsonl에 남기고, 실제 실행 요약표와 맞춰 보기 — 어느 노드가 얼마를 썼는지, 모델 호출 몇 번인지, 토큰 몇인지, 게이트 회차 몇인지
전제: 레슨 1~5를 마치고, 코스 7(에이전트 하네스 기초: 루프와 통제)의 하네스 루프를 돌릴 수 있어야 합니다 | 이전: 레슨 5 <<
먼저, 돌아가는 것을 봅니다
앞선 다섯 레슨은 부품을 하나씩 떼어 놓았습니다. 계획을 누가 쥐는가(레슨 1), 체이닝과 라우팅(레슨 2), 섹셔닝과 투표 그리고 상한이 있는 동시 실행 풀(레슨 3), 오케스트레이터-워커와 위임 프롬프트의 네 요소(레슨 4), 리뷰 회로와 이 패턴들을 레슨 5가 "그래프"라 부르는 것으로 조합하는 법(레슨 5). 이 레슨은 그것들을 한 파일로 용접합니다.
과제는 일부러 평범합니다. inbox/에 고객지원 티켓 6장이 있고, 할 일은 각각에 그대로 보낼 수 있는 회신을 쓰는 것입니다. 먼저, 끝났을 때의 모습입니다.
이 레슨의 모든 터미널 출력은 이 스크립트의 실제 실행에서 한 줄씩 옮겨 온 것입니다. 손으로 지어낸 예시는 한 줄도 없습니다. 실행할 때마다 바뀌는 것은 둘입니다. 밀리초 단위 시간, 그리고 run_id(36진수 타임스탬프입니다). 나머지는 전부 — 분류 결과, 호출 횟수, 토큰 수, 게이트 회차, 어느 티켓이 needs_human인지 — 못 박힌 상수입니다. 이유는 뒤의 "검증 세팅" 절에서 설명합니다.
먼저 들여다볼 만한 것은 마지막의 그 1입니다. 이것은 에러가 아니라 판정입니다. 티켓 6장 중 하나가 자동으로 끝나지 못했으므로 종료 코드가 0이 아닙니다. 이 그래프는 실행할 때마다 CI나 크론 잡이 파싱할 수 있는 결론을 냅니다. 로그 더미가 아닙니다.
그래프는 어떻게 생겼는가: 계획은 main()의 저 열몇 줄이다
스크립트의 뼈대부터 봅니다. "그래프"와 "노드"라는 말은 레슨 5가 도입한 어휘입니다. 이것은 우리 자신의 시각 체계이지 공식 개념이 아니며, 딱 하나의 1차 자료 닻 위에 서 있습니다. 워크플로 스크립트 자체가 루프, 분기, 중간 결과를 쥡니다1. 아래 조각은 그 진술을 글자 그대로 구현한 것입니다.
레슨 5는 조합된 그래프를 먼저 그렸습니다. 이 그래프는 그것의 변형이며 차이가 셋입니다. 레슨 5는 난이도로 "단순 / 복잡"을 나눴지만 여기서는 주제로 billing / bug / other를 나눕니다. 레슨 5의 팬아웃은 "복잡한 티켓 하나를 워커 셋에게 보낸 뒤 병합"이었지만 여기서는 섹셔닝입니다 — "티켓 여섯 장에 각각 처리자 하나". 레슨 5의 되돌아가는 엣지는 별도의 [초안] 노드로 돌아갔지만, 여기서는 원래의 워커로 돌아갑니다. 왜 이렇게 바꿨는지는 끝의 "정산표" 절에 모아 두었습니다.
routed, drafts, items — 이 const 선언 셋이 그래프 전체의 상태입니다. 평범한 JavaScript 변수이지 무슨 타입 붙은 상태 객체가 아니며, 병합 전략 같은 것도 없습니다. 중간 결과는 스크립트 변수에 머물고1, 노드는 함수 반환값으로 데이터를 넘깁니다. 전체 그림을 보는 모델은 없습니다. 라우팅 모델은 티켓 본문 여섯 개만 보고, 청구 워커는 배정된 티켓 하나만 보고, 리뷰 게이트는 회신 파일 하나만 봅니다.
이것이 워크플로와 에이전트의 아키텍처 구분이 코드에서 어떻게 생겼는지입니다. LLM과 도구가 미리 정의된 코드 경로를 통해 오케스트레이션되는 것이지2, 모델이 자기 프로세스를 자율적으로 지휘하는 것2이 아닙니다.
노드 다섯, 각각이 한 구간을 맡습니다.
다섯 노드 중 실제로 모델을 부르는 것은 둘뿐입니다. 모든 노드가 모델일 필요는 없습니다. 이것이 이 레슨에서 가장 값싸면서 가장 놓치기 쉬운 규칙입니다. merge와 report는 순수 함수이고, fanout의 other 범주는 문자열 템플릿을 쓰고, review의 첫 여과는 includes 몇 줄입니다. 결정적 코드가 같은 답을 낼 수 있는 자리라면, 모델 호출의 값과 지연을 치를 이유가 없습니다.
노드 내부: 여전히 코스 7의 그 루프
가장 안쪽 계층을 먼저 못 박아야 그래프가 이해됩니다. 각 모델 노드의 내부에서는 코스 7(에이전트 하네스 기초: 루프와 통제)의 stop_reason 루프가 그대로 돌아갑니다.
루프 본문의 네 단계 — assistant 밀어 넣기, 도구 실행, tool_result 밀어 넣기, response 재대입 — 는 코스 7의 레슨 6과 한 글자도 다르지 않고, 주석까지 그대로 옮겼습니다. 밸브 1(최대 턴 수)도 원래 자리에 있습니다. 루프 본문의 첫머리, turns++ 앞입니다. 루프에 최대 반복 횟수를 정지 조건으로 남겨 두는 것은 제어를 유지하는 표준적인 관행입니다2.
코스 7과 비교해 바뀐 것은 둘이며, 둘 다 루프 본문 바깥입니다. client와 system이 모듈 수준 상수에서 매개변수로 바뀌었고(역할 셋이 서로 다른 스텁과 서로 다른 시스템 프롬프트를 필요로 하므로 넘겨받아야 합니다), 토큰과 호출 계측이 루프 본문 안에서 클라이언트 바깥의 래퍼 계층으로 옮겨 갔습니다. 루프 내부는 그대로입니다.
이 변경에는 값이 있고 그것을 밝혀야 합니다. 코스 7의 밸브 2(토큰 예산)는 원래 루프 본문의 누적값에 기대고 있었는데, 그 누적기가 더는 루프 안에 없으므로 밸브 2도 함께 옮겨 오지 못했습니다. 이 그래프에서는 각 노드의 스텁 응답 큐가 고정 길이라 큐를 소진하면 곧바로 throw되고 폭주할 수 없습니다. 다만 스텁을 실제 클라이언트로 바꿀 때는 밸브 2를 되돌려 놓으십시오. metered가 예산을 넘길 때 throw하게 하거나, 계측을 루프 본문으로 되돌려 코스 7의 원래 형태를 복원하면 됩니다. 밸브 3(공회전 감지)과 밸브 4(사람 승인)도 마찬가지로 옮겨 오지 않았습니다. 이유는 뒤의 "정산표" 절에 적어 두었습니다.
도구 쪽 절반도 그대로 옮겼습니다. 한 턴의 응답에 tool_use 블록이 여러 개 있으면 그만큼의 tool_result 블록을 돌려주고, 도구가 throw하면 is_error: true로 감싸 모델에게 되돌려 줍니다. 프로세스 전체를 죽이지 않습니다.
노드 하나: 라우팅 — 값싼 호출 한 번, 그리고 출력 조이기
라우팅은 입력을 분류해 전문화된 후속 작업으로 보냅니다2. 그래프에서 가장 값싼 모델 호출입니다. 요청 한 번으로 여섯 장을 다 분류하고, 도구도 없고, 회신도 쓰지 않습니다.
핵심은 모델 호출이 아니라 가운데 열 줄입니다. 모델은 자유 텍스트를 돌려주고 하류의 모든 분기가 이 값에 기대므로, 하류로 들어가기 전에 세 개의 적법한 레이블 중 하나로 조여야 합니다. 형식에 맞지 않는 줄은 버리고, 화이트리스트에 없는 범주는 other로 떨어뜨리고, 매칭된 줄이 하나도 없는 티켓은 parsed.get(t.id) ?? "other"가 받아 냅니다.
마지막 티켓에 대해서는 스텁이 '불만'이라는 낱말을 돌려주도록 일부러 심어 두었습니다. 화이트리스트에 없는 값입니다. 실제 실행 로그에 이 조이기가 남습니다.
모델이 제멋대로 만든 레이블을 코드가 other로 되잡았고, 무엇이 되잡혔는지 기록을 남겼습니다. 하류 분기는 코드가 검증한 값만 인정합니다. 이것이 라우팅 노드와 "모델이 다음에 어디로 뛸지 직접 정하게 하는 것"의 실질적인 차이이고, 라우팅을 단위 테스트할 수 있는 이유입니다.
노드 둘: 팬아웃 — 워커 셋과 상한 있는 동시 실행 풀
팬아웃은 섹셔닝을 따릅니다. 작업을 서로 독립적인 하위 작업으로 쪼개 병렬로 돌리는 것입니다2. 여기서 "독립"은 자연스럽습니다. 티켓 여섯 장은 서로 아무 의존이 없고 순서도 상관없습니다.
범주 셋, 처리자 셋, 그중 모델은 둘뿐입니다.
동시 실행 풀은 레슨 3의 그 풀입니다(거기서는 pool, 여기서는 runPool). 작업이 커서 뒤에 늘어서 있고, limit개의 소비자를 띄워 집어 가게 하고, 다 떨어지면 끝납니다. 상한은 장식이 아니라 실제로 작동합니다. 1로 바꿔 다시 돌리면 fanout 줄의 시간이 눈에 띄게 길어집니다(호출 횟수와 토큰은 동일하고, 밀리초는 늘 그렇듯 흔들립니다).
507ms 대 262ms이고, 호출 횟수와 토큰은 동일합니다. 동시 실행은 실제 경과 시간을 사는 것이지 일을 줄이는 것이 아닙니다. 이 사실은 실제 API로 바꿔도 그대로이며, 다만 그때는 제공자의 레이트 리밋까지 함께 고려해야 해서 상한이 한층 더 필수적이 됩니다.
위임 프롬프트: 네 요소가 모두 있음
모델 역할 셋의 프롬프트는 모두 레슨 4의 네 요소를 따릅니다. 목표, 출력 형식, 도구 안내, 작업 경계입니다. 서브에이전트에는 목표, 출력 형식, 쓸 도구와 자료에 대한 안내, 그리고 명확한 작업 경계가 필요하며, 설명이 충분하지 않으면 워커들은 일을 중복하고, 빈틈을 남기고, 찾아야 할 것을 찾아내지 못합니다3. 청구 워커는 이렇습니다.
네 줄이 각자 제 몫을 합니다. 목표가 무엇을 쓸지를 정하고, 출력 형식이 하류 게이트에 검사할 거리를 주고("티켓 ID로 시작" 요구가 게이트의 첫 규칙에 그대로 대응합니다), 도구 안내가 "금액이 어디서 오는가"를 lookup_order에 못 박아 티켓 설명에서 숫자를 지어내는 길을 막고, 작업 경계가 범위 밖 행동을 막으면서 빈말까지 선제적으로 금지합니다.
버그 워커의 판본은 내용을 알려진 이슈 DB 확인, 이슈 번호 인용, 번호 지어내기 금지로 바꿉니다. 라우터의 "도구 안내"는 "이 단계에서는 도구를 주지 않으니 오직 티켓 본문만 보고 판단하라"라고 말하며, 코드에서 넘기는 빈 tools 배열과 맞아떨어집니다. 이 프롬프트 셋의 차이 자체가 라우팅이 벌어들인 배당입니다. 분류한 뒤에는 각자 자기 것을 쓰면 되고, 세 종류 일의 요구사항을 한 프롬프트에 욱여넣을 필요가 없습니다. 이것이 바로 라우팅이 가능하게 하는 관심사의 분리이자 더 전문화된 프롬프트입니다2.
노드 셋: 병합 — 페이로드가 아니라 참조를 넘긴다
merge는 순수 코드이고 모델 호출이 0입니다. 두 가지를 합니다. 각 초안을 out/에 쓰고, 하류를 위해 가벼운 목록을 모읍니다. {id, category, handler, file, oneLine} — 파일 경로 하나와 한 줄 요약이지, 회신 여섯 편 전문이 아닙니다. (동시에 run-state.json에 티켓마다 레코드도 만듭니다. 필드는 전체 코드의 9절에 있습니다.)
이것은 멀티 에이전트 시스템의 엔지니어링 조언을 단일 프로세스 스크립트로 가져온 것입니다. 전문 에이전트가 출력을 외부 시스템에 저장하게 하고, 조정자에게는 가벼운 참조만 넘기게 하십시오3. 그 회고에서 이 조언은 "모든 것을 리드 에이전트를 통해 중계"하는 데서 오는 컨텍스트 비대화를 해결했습니다. 여기서는 같은 것의 작은 규모 판본을 해결합니다. 리뷰 노드에 필요한 것은 "어느 파일을 검사해야 하는가"이지, 회신 여섯 편 전문이 변수 하나에 쌓여 돌아다니는 것이 아닙니다.
그래서 리뷰 노드의 첫 동작은 파일에서 내용을 다시 읽는 것입니다.
이 단계는 군더더기처럼 보입니다. 어차피 같은 프로세스 안인데 문자열을 그냥 넘기면 되지 않느냐는 것입니다. 그러나 이것이 둘을 사 줍니다. out/의 파일이 이 티켓의 유일한 진실 원천이 되어, 누가 그것을 고치든 리뷰가 검사하는 것은 그것입니다. 그리고 이 엣지가 프로세스나 기계를 넘어가야 할 때가 오면 readFileSync 이 한 줄만 바뀌고, 노드 사이의 계약은 꿈쩍도 하지 않습니다.
문제 하나
여기까지 그래프의 다섯 노드 중 셋이 완성됐습니다. 라우팅은 코드로 조여졌고, 병합은 순수 코드이며, 곧 나올 게이트도 순수 코드입니다. 이 대목에서 가장 자주 듣는 질문을 그대로 꺼내 볼 수 있습니다.
노드 넷: 리뷰 회로 — 게이트가 먼저 거르고, 실패한 것은 가마로 되돌아간다
리뷰 노드는 검사-수정-재검사를 합니다. 검사기를 돌리고, 실패한 것을 고치고, 통과하거나 더는 진척이 없을 때까지 되풀이합니다1. 이 그래프에서 "모델의 출력이 다시 쓰라고 되돌려지는" 유일한 자리입니다.
첫 여과는 결정적이며, includes 몇 줄로 끝납니다.
규칙 둘 다 코스 10(검증과 품질 보증: '맞아 보이는 것'을 통과시키지 않기)이 "결정적으로 판정할 수 있으면 심판에게 묻지 말라"고 한 그 종류입니다. 회신에는 티켓 ID가 들어 있어야 하고(고객지원 시스템이 그것을 키로 씁니다), '기다려 주세요', '조금만 더 기다려 주시기 바랍니다', '빠르게 처리하겠습니다'처럼 정보량이 없는 빈말이 들어 있으면 안 됩니다. 둘 다 의미 이해가 필요 없고 문자열 포함으로 충분하며, 결과가 매번 같고, 게다가 워커에게 그대로 돌려줄 수 있는 리포트 문자열까지 만들어 냅니다.
여기서 LLM 심판이라면 "회신의 어조가 적절한가", "사실이 도구가 돌려준 범위를 넘지 않는가" 같은 것 — includes로는 정말로 판정할 수 없는 것 — 을 맡을 수 있습니다. 다만 반드시 게이트 뒤에 서야 합니다. 게이트는 공짜이고 결정적이니 분명한 문제를 먼저 걸러 내게 하고, 남은 것이 호출 값을 치르고 심판에게 물어볼 값어치가 있는 것입니다. 이 그래프에는 게이트 계층만 달았습니다. 이 티켓 묶음의 합격 기준이 마침 규칙으로 표현 가능하기 때문입니다. 합격 기준에 "어조의 적절함" 같은 낱말이 들어갈 때는 코스 10의 계층적 판정 배분에 따라 심판 계층을 더하십시오.
루프 자체는 이렇게 생겼습니다.
break 셋이 멈추는 세 방식에 대응하며, 레슨 5가 선언한 것과 맞아떨어집니다. 통과(while 조건이 자연히 거짓이 됨), 더는 진척 없음, 최대 회차 도달입니다. 셋째 if는 덧댄 것입니다. other 범주의 회신은 순수 코드 템플릿이 생성하므로 되돌려 보낼 워커가 없고, 템플릿 자체가 망가졌다면 바로 사람에게 넘기는 수밖에 없습니다. 이번 실행에서는 걸리지 않았지만(템플릿은 상수이므로 반드시 게이트를 통과합니다), 누군가 템플릿 문자열을 망가뜨렸을 때 도는 루프를 보느니 no_rewriter 기록을 보는 편이 낫기에 남겨 두었습니다.
되돌릴 때 워커에게 먹이는 것은 단순합니다. 이전 판본 전문 + 게이트 리포트 + "리포트가 지목한 문제만 고쳐서 완결된 회신을 다시 쓰라"는 한 문장입니다(callWorker에서 조립합니다).
멈추는 두 방식이 이번 실행에서 실제로 다 일어났습니다
스텁에 각본 둘을 심어, 루프의 각 출구가 한 번씩 실행되게 했습니다.
T-1005: 제대로 고쳐지고 끝났습니다. 버그 워커의 첫 판본이 티켓 ID를 빠뜨려(첫 규칙 실패) 게이트가 missing_ticket_id를 돌려주고, 워커가 리포트대로 첫 줄을 붙였고, 둘째 판본이 통과합니다.
T-1004: 고쳐 쓰긴 했지만 낫지 않았고, 루프가 스스로 멈췄습니다. 청구 워커의 첫 판본이 '기다려 주세요'를 썼고 게이트가 filler_word:기다려 주세요를 돌려줍니다. 워커가 한 판본을 다시 썼고, 문장이 완전히 달라지고 길어지고 설명이 붙었지만, 그 낱말은 그대로 남았습니다. 둘째 회차의 리포트가 첫째 회차와 동일합니다.
이 순간 gate.report === lastReport가 성립하고, 루프는 더는 진척이 없다고 판정해 멈추고, 이 티켓을 needs_human으로 표시합니다. 원래 회차 예산이 두 번 더 남아 있었지만(MAX_REVIEW_ROUNDS는 3입니다), 그것을 쓴들 낭비였을 것입니다. 같은 리포트를 되먹이면 십중팔구 같은 회신이 돌아옵니다. "더는 진척 없음" 출구의 값어치가 여기에 있습니다. 최대 회차보다 일찍 손실을 끊고, 정보량 있는 결론을 줍니다. "세 번 해 봤는데 안 된다"가 아니라 "이 피드백을 이해하지 못한다"이며, 그것이야말로 사람에게 올릴 신호입니다.
두 출구의 차이는 데이터에서 곧바로 보입니다.
두 티켓의 gate_rounds가 모두 1이므로, 회차 수만으로는 성공과 실패를 가릴 수 없습니다. 가르는 선은 gate_reports의 길이입니다. 그것은 실패한 리포트를 하나도 빠짐없이, 정지를 부른 마지막 것까지 기록합니다. T-1005는 항목 하나만 남기고(둘째 판본이 통과해 두 번째 리포트가 생기지 않았습니다), T-1004는 내용이 동일한 항목 둘을 남기며, stop 필드가 결론을 곧바로 no_progress로 적습니다.
노드 다섯: 보고와 추적
마지막 노드도 순수 코드입니다. state.nodes와 티켓별 내역을 표 둘로 찍고, needs_human을 세고, 종료 코드를 정합니다. 모두 통과면 0, 사람이 필요한 것이 하나 있으면 1입니다.
추적은 파일 둘로 나뉘며 각자 목적이 있습니다. run.jsonl은 코스 11(관측 가능성과 디버깅: 에이전트가 밟는 모든 단계 들여다보기)의 구조화 로깅입니다. 한 줄에 JSON 이벤트 하나, 각각이 ts와 run_id를 달고 있어 나중에 grep할 수 있습니다. 이번 실행은 총 39줄이었고, 앞 절들의 발췌는 전부 거기서 그대로 grep해 온 것입니다.
run-state.json은 실행 자취를 기록하며("그래프의 상태 = 저 몇 개의 스크립트 변수"와는 별개입니다), 코스 9(상태 관리와 지속화: 긴 작업이 중단을 견디게 만들기)의 방식으로 씁니다. .tmp에 먼저 쓰고 rename으로 원자적으로 바꿔치기하므로, 어느 순간에 죽든 디스크에는 이전의 완전한 상태이거나 새로운 완전한 상태가 있지, 반토막 난 JSON은 결코 없습니다.
쓰는 시점은 "작은 단계마다 영속화"입니다. 노드가 끝날 때마다 한 번, 리뷰 노드 안에서는 티켓 하나를 판정할 때마다 또 한 번입니다. 레슨 5가 인용한 이유가 그대로입니다. 각 에이전트의 결과를 점진적으로 추적하는 것이 바로 같은 세션 안에서 실행을 재개할 수 있는 전제이고1, 일을 여러 작은 에이전트에게 펼쳐 내보내는 워크플로는 긴 에이전트 하나보다 진척을 더 많이 지켜 냅니다1. 이 그래프는 멀티 에이전트 런타임이 아니지만 같은 진술이 여기서도 성립합니다. 티켓 여섯 장은 독립적인 진척 단위 여섯 개이고, 리뷰 도중에 죽더라도 이미 영속화된 것이 함께 사라져서는 안 됩니다(팬아웃 단계는 아직 이것을 이루지 못했습니다 — 정산표 3번을 보십시오).
이 진술의 실제 효과를 보려면 STOP_AFTER=merge로 팬아웃 뒤, 리뷰 앞에서 프로세스를 멈춰 보십시오.
이 시점의 run-state.json입니다(발췌).
노드 셋의 장부가 들어와 있고, 티켓 여섯 장의 범주, 처리자, 출력 파일 경로가 들어와 있고, 초안 파일 여섯 개가 이미 out/에 영속화되어 있습니다. 잃은 것은 리뷰 구간뿐입니다. 모든 티켓이 status: "drafted", stop: null에 멈춰 있습니다. 이 상태만으로도 재개를 뒷받침하기에 충분합니다. out/에서 초안을 읽어 들여 리뷰 노드부터 바로 시작하면 됩니다. T-1005의 one_line이 마침 초안의 결함을 드러내고 있다는 데 주목하십시오. 첫머리에 티켓 ID가 없습니다. 리뷰가 아직 돌지 않았으므로 이 결함은 아직 잡히지 않았습니다.
(STOP_AFTER는 merge 하나만 값으로 인정하며, 코스 9의 통제된 크래시 지점을 단순화한 판본입니다. 종료 코드 0은 전부 통과, 1은 사람에게 넘길 티켓이 있음, 2는 일찍 멈춰 판정 없음, 3은 스크립트 자체가 죽은 것입니다. 넷이 겹치지 않으므로 CI가 "돌긴 했는데 일부를 넘겨야 함"과 "죽었음"을 한눈에 가릴 수 있습니다.)
완전한 orchestrate.mjs
아래가 전문이며 끊기지 않은 한 덩이입니다. 빈 디렉터리의 orchestrate.mjs에 붙여 넣고 node orchestrate.mjs를 하십시오. 의존성 0이라 npm i도, package.json도 필요 없고(.mjs 확장자가 이미 ES 모듈임을 선언합니다), API 키도 필요 없습니다. 모델 클라이언트가 스텁이기 때문입니다. 첫 실행이 inbox/, kb/, out/을 만들고 그 티켓 여섯 장을 씁니다.
총 679줄이며, 그중 190줄쯤이 스텁에 먹이는 데이터(SCRIPTS 표, 티켓 원문 여섯 개, 알려진 이슈 DB, 스텁 클라이언트)이고, 실제 오케스트레이션 로직 — 노드 다섯, 동시 실행 풀, 게이트, 진입점 — 이 250줄쯤이며, 관측 가능성과 상태 추적에 40줄쯤이 더 듭니다. 이 규모는 의도한 것입니다. 루프 하나에 패턴 몇 개는 정말로 코드 몇 줄로 구현할 수 있는 것입니다2.
검증 세팅
이 레슨의 모든 터미널 출력은 이 스크립트의 실제 실행에서 나왔습니다. "여러 번 돌려 보기 좋은 것을 고른" 것이 아니라, 비결정성의 두 원천을 미리 못 박아서입니다.
모델을 고정 큐를 재생하는 스텁으로 바꿉니다. SCRIPTS는 표이고, 키는 "티켓 id + 몇 번째 판본", 값은 미리 써 둔 응답 시퀀스입니다. messages.create 호출마다 다음 것을 순서대로 뱉고, 큐가 소진됐는데도 계속 부르면 곧바로 throw합니다. 이렇게 하면 "어느 티켓이 몇 회차에 어느 도구를 부르는지, 모델이 언제 끝내는지"가 전부 상수가 됩니다. 스텁은 단언도 하나 남겨 두었습니다. create에는 model과 max_tokens가 반드시 있어야 하고, 하나라도 빠지면 throw합니다. 실제 클라이언트가 이 두 매개변수를 요구하는데 스텁이 그것을 덮어 주지 않도록 해서, 실제 클라이언트로 바꾸는 날 그 구멍을 발견하는 일이 없게 한 것입니다. 이 방법은 코스 8의 실습부터 여기까지 쭉 써 왔고, 그래서 검증 대상은 그날 모델의 성능이 아니라 여러분의 제어 로직입니다(실제 모델은 비결정적이라 같은 입력에도 다른 응답을 낼 수 있습니다4).
스텁은 고정 60ms 지연도 더해 실제 네트워크 왕복을 대신합니다. 이것이 없으면 모든 노드가 0ms가 되어 동시 실행 풀의 효과가 요약표에 전혀 드러나지 않습니다. 위의 POOL_SIZE=1 비교(507ms 대 262ms)가 그것에 기대고 있습니다.
스텁에 루프 각본 둘을 심었습니다. 리뷰 회로가 실제로 돌려면 게이트를 실제로 실패하는 것이 있어야 합니다. 그래서 이렇게 했습니다.
T-1005#1(버그 워커의 첫 판본)은 일부러 티켓 ID를 빠뜨려 missing_ticket_id를 유발하고, T-1005#2가 첫 줄을 붙여 둘째 판본이 통과합니다. 이것이 "검사-수정-재검사"의 정상 완료 출구를 보여 줍니다.
T-1004#1과 T-1004#2(청구 워커의 두 판본)는 둘 다 '기다려 주세요'를 답니다. 두 판본의 문장은 완전히 다르고 길이도 다르지만 게이트가 보는 것은 그 낱말의 유무이므로, 두 회차의 리포트 문자열이 동일해져 "더는 진척 없음"을 유발합니다. 이것이 손실을 끊는 출구를 보여 줍니다.
두 각본을 쓰는 데는 요령이 있습니다. 둘째 판본이 첫째를 글자 그대로 되풀이하게 만들지 않고(그러면 사람 눈에도 데드 루프로 보입니다), "고쳐 쓰긴 했지만 제대로 고치지 못했다"로 만드는 것입니다. 이것이 실제 루프에서 가장 흔한 실패 유형이고, "두 회차 연속 동일한 리포트"라는 기준이 정확히 잡아내는 것입니다.
통제된 조기 정지. STOP_AFTER=merge는 팬아웃 뒤, 리뷰 앞에서 프로세스를 멈추고 종료 코드 2를 냅니다. 코스 9의 CRASH_AFTER를 단순화한 판본으로, "어느 단계에서 끊을지"를 운에 기대지 않고 정확히 지정 가능한 매개변수로 만든 것입니다. 위의 drafted 상태 run-state.json이 이 실행에서 나왔습니다.
정산표: 이 그래프가 앞선 레슨들에 진 빚을 한 줄씩 갚는다
코스가 마무리 실습에 이르렀을 때 가장 저지르기 쉬운 실수는, 앞서 세운 규칙을 슬그머니 뒤집는 것입니다. 그래서 여기서 한 줄씩 정산하고, 어긋난 것은 명시적으로 적습니다.
1. 루프 본문은 코스 7과 일치합니다. 루프 본문의 네 단계 — assistant 밀어 넣기, 도구 실행, tool_result 밀어 넣기, response 재대입 — 는 코스 7의 레슨 6과 한 글자도 다르지 않고 주석까지 그대로입니다. 밸브 1도 원래 자리입니다. 선언하는 차이: runAgent의 시그니처에 client와 system 두 매개변수가 늘었고(역할 셋이 서로 다른 스텁과 시스템 프롬프트를 필요로 합니다), create 호출에 system 필드가 늘었습니다. 토큰 계측이 루프 본문에서 metered 래퍼로 옮겨 갔고, 그래서 코스 7의 밸브 2(토큰 예산)가 따라오지 못했으며, 밸브 3(공회전 감지)과 밸브 4(사람 승인)도 옮겨 오지 않았습니다. 이 그래프의 도구는 파일 읽기와 주문 조회뿐이라 둘 다 읽기 전용이고, 승인이 필요한 큰 영향의 행동이 없습니다. 스텁 큐는 유한해서 공회전할 수도 없습니다. 실제 API에 붙이기 전에 이 세 밸브는 반드시 되돌려 놓아야 합니다.
2. 위임 프롬프트의 네 요소가 갖춰졌습니다(레슨 4). 라우터, 청구 워커, 버그 워커 세 프롬프트 각각에 목표, 출력 형식, 도구 안내, 작업 경계 네 항목을 한 줄씩 다 썼고, 한 줄씩 대조할 수 있습니다3.
3. 동시 실행 풀에 상한이 있고, 병합은 페이로드가 아니라 참조를 넘깁니다(레슨 3). runPool의 limit이 하드 상한이며, POOL_SIZE=1과 POOL_SIZE=2의 시간 차이로 이미 검증했습니다. merge 이후로는 하류에 {id, category, handler, file, oneLine}을 넘기고 전문은 out/에 남으며, 리뷰 노드가 파일에서 직접 읽어 옵니다3. 선언하는 차이: 레슨 3의 풀은 "같은 묶음의 하위 작업을 병렬로"였지만, 여기서 풀은 처리자 세 종류에 걸쳐 있습니다. 모델 워커 둘에 순수 코드 템플릿 하나이고, 템플릿이 풀에 들어가는 비용은 거의 0입니다. 풀의 의미는 그대로이지만(동시에 떠 있는 작업 수가 상한을 넘지 않음) 작업 자체가 이질적입니다. 또 레슨 3이 세웠지만 스크립트를 짧게 하려고 여기서 뺀 것이 하나 있습니다. 레슨 3은 레인마다 별도의 try/catch를 요구해 한 레인의 실패가 묶음 전체를 끌어내리지 않게 했는데, runPool에는 그 감싸기가 없습니다. 값은 이렇습니다. 팬아웃 단계에서 어느 한 레인이 throw하면 초안 묶음 전체가 영속화되지 않습니다. 실제 API에 붙이기 전에 반드시 더해야 합니다. 실제 네트워크에서 한 레인의 타임아웃은 일상입니다.
4. 게이트가 심판보다 앞에 있고, 루프의 정지 조건이 레슨 5와 일치합니다. 첫 여과는 모델이 아니라 결정적 코드입니다. 이 레슨은 LLM 심판 계층을 달지 않았습니다. 이 티켓 묶음의 합격 기준이 마침 규칙으로 표현 가능해서, 달았다면 돈 낭비였을 것입니다. 코스 10의 계층적 판정이 바로 이 순서입니다. 결정적으로 판정 가능한 것을 먼저 판정하고, 남은 것을 심판에게 묻습니다. 루프의 정지 조건은 세 종류입니다. 통과, 더는 진척 없음, 최대 회차 도달1 2이며, 개념은 레슨 5와 일대일로 맞아떨어집니다. 다만 필드와 값 이름이 바뀌었습니다. 레슨 5는 reason 필드에 passed/no-progress/max-rounds로 내려앉았지만, 여기서는 stop 필드에 gate_pass/no_progress/max_rounds로 내려앉습니다(기준이 심판에서 게이트로 바뀌었고, 하이픈도 이 레슨의 snake_case 관례에 따라 밑줄로 바뀌었습니다). 또 레슨 5의 rounds는 생성 횟수를 세어 초안이 1회차이지만, 이 레슨의 gate_rounds는 재작성 횟수를 세어 초안이 0회차입니다. 그래서 같은 티켓이라도 두 레슨의 회차 기산점이 하나 다릅니다. 선언하는 차이: 코드에는 넷째 출구 no_rewriter가 있습니다(순수 코드 템플릿은 되돌려 보낼 워커가 없습니다). 이것은 레슨 5가 빠뜨린 패턴이 아니라 이 그래프의 특수 사정입니다. 레슨 5의 루프는 "생산자가 모델"임을 전제했지만, 여기서는 생산자 한 종류가 템플릿입니다. 이번 실행은 이 분기에 걸리지 않았습니다.
5. "그래프"라는 표현이 레슨 5의 선언과 일치합니다. 전문의 "그래프"와 "노드"는 모두 이 레슨 자체의 엔지니어링 은유이며, 레슨 5가 이 시각 체계를 도입할 때 이미 명시했듯 어느 1차 자료의 공식 개념도 아닙니다. 그것이 딛고 설 수 있는 1차 자료 닻은 그 하나뿐입니다. 워크플로 스크립트 자체가 루프, 분기, 중간 결과를 쥡니다1. 이 레슨은 새 용어를 하나도 더하지 않았습니다. "상태 기계"도, "노드 사이를 오가는 상태 객체"도 쓰지 않았습니다. 레슨 5가 정의한 "엣지"(누구의 출력이 누구에게 들어가는가)는 merge → review의 데이터 흐름을 설명할 때 한 번 나왔을 뿐 새 어휘가 아닙니다. routed / drafts / items는 그저 평범한 지역 변수 셋입니다.
6. run-state.json의 원자적 쓰기가 코스 9와 일치합니다. .tmp에 먼저 쓰고 renameSync로 바꿔치기하며, 하나도 빠뜨리지 않았습니다. 쓰는 시점도 그 코스의 잣대 그대로입니다. 작은 단계가 끝날 때마다 한 번 영속화하지, 전체 실행이 끝난 뒤 한 번이 아닙니다.
7. 관측 가능성의 잣대는 코스 11과 같은 형태이되 알갱이가 굵습니다. 한 줄에 JSON 이벤트 하나, 각각이 ts와 run_id를 달고 있어 나중에 grep할 수 있습니다. 차이 넷: (a) 코스 11의 로거는 내용 요약(형태, 길이, 앞 몇 글자)을 기록하지만, 이 레슨은 id, 범주, 파일명, 리포트 문자열과 개수만 기록하고 회신 전문은 기록하지 않습니다 — 전문은 이미 out/에 있습니다. (b) 연관 필드를 코스 11은 trace_id라 부르지만 여기서는 run_id라 부릅니다. (c) 그 코스의 핵심은 span_id/parent_id로 트레이스 트리를 엮는 것인데, 이 그래프는 노드→워커→도구 세 계층으로 중첩되어 있으면서도 부모-자식 연결을 구현하지 않았으므로 트레이스 트리가 없습니다. (d) initLog()가 실행마다 run.jsonl을 비워 가장 최근 실행만 남기므로, 코스 11의 실행 간 비교(v-good 대 v-bug)를 하려면 run_id별 파일로 나눠 덧붙이는 방식으로 바꿔야 합니다. 이 그래프를 실제 트레이스 시스템에 물리려면 코스 11의 span 필드를 그 패턴대로 더해야 합니다.
8. 오케스트레이터-워커 패턴은 이 레슨이 일부러 구현하지 않았습니다(레슨 4). 레슨 4의 오케스트레이터-워커는 "몇 개를 보내고 각자 무엇을 하는지"를 모델이 입력을 보고 그때그때 정하는 것이 핵심입니다. 이 그래프는 그렇지 않습니다. 티켓 여섯 장이 어떻게 분류되는지, 각 범주가 어느 워커로 가는지가 코드 첫 줄을 쓰기 전에 CATEGORIES와 상수 프롬프트 셋에 못 박혔습니다. 이것이 바로 레슨 4의 "미리 정의할 수 있다면 동적으로 만들지 마라"를 그대로 적용한 것입니다. 이 일 묶음의 모양은 이미 알려져 있으므로 결정 권한을 모델에게 돌려주면 안 됩니다. 그러므로 엄밀히 말해 이 파일에 용접된 것은 네 패턴이고(체이닝, 라우팅, 병렬화-섹셔닝, 리뷰 회로), 투표는 레벨 2 연습이 다섯째로 보태며, 오케스트레이터-워커는 이 작업 묶음의 성격이 막아 세운 하나입니다.
경계
이 그래프가 다루는 것은 작습니다. 프로세스 하나, 티켓 한 묶음, 돌고 나면 끝납니다. 지을 값어치가 있는 이유는 "티켓이 들어온다 → 분류한다 → 범주별로 처리한다 → 검사한다 → 보고한다"라는 다섯 단계가 코드 첫 줄을 쓰기 전에 못 박혔기 때문입니다. 작업이 "이 고객이 지난 6개월간 실제로 무엇을 겪었는지 알아내라, 몇 단계가 필요한지는 네가 판단하라"가 된다면, 이 그래프는 잘못된 아키텍처입니다. 그런 종류의 열린 문제 — 단계를 미리 예측할 수 없고 고정된 경로를 하드코딩할 수 없는 — 는 본래 자율 루프에 속합니다2.
경계를 몇 개 명시해 둡니다.
팬아웃이 동기라서 규모가 커지면 아픕니다. fanoutNode의 풀은 묶음 전체가 끝나야 merge로 넘어갑니다. 이것이 바로 그 실제 프로덕션 시스템이 인정한 병목입니다. 동기 실행은 조율을 단순하게 만들지만 정보 흐름에 병목을 만들고, 서브에이전트 하나가 오래 끌면 시스템 전체가 기다립니다3. 티켓 여섯 장에 각각 최대 두 번의 호출이라면 이 병목은 전혀 아프지 않습니다. 티켓 600장에 각각 열 번의 호출이라면, "가장 느린 하나가 묶음 전체의 경과 시간을 정한다"가 됩니다. 비동기로 바꿀지는 값을 계산해 봐야 합니다. 비동기는 에이전트들이 동시에 일하고 필요할 때 새로 띄우게 해 주지만, 결과 조율, 상태 일관성, 서브에이전트 전반의 에러 전파에 어려움을 더합니다3. 이 셋은 동기 판본에는 없습니다. 순서를 코드가 정하기 때문입니다.
리뷰 회로의 두 규칙은 얕고 부서지기 쉽습니다. includes("기다려 주세요")는 '더 기다려 주세요라고 말씀드리지 않아도 되도록 이미 처리했습니다' 같은 문장까지 상투어로 잘못 잡습니다. 이것이 코스 10이 오래전부터 경고한 문제입니다. 지나치게 엄격한 결정적 검증기는 맞는 것을 틀렸다고 판정합니다. 실제 프로덕션이라면 이 두 규칙을 실제 회신 소량으로 보정하거나, "심판이 다시 볼 것으로 표시" 수준으로 낮추고 곧바로 되돌려 보내지는 않아야 합니다.
실제 API로 바꿀 때는 스텁만 바꾸고 구조는 그대로입니다. makeStubClient(queue)를 new Anthropic()으로 바꾸고 SCRIPTS 표 전체를 지우면, 나머지는 한 줄도 바뀌지 않습니다. runAgent는 처음부터 실제 API의 stop_reason / tool_use / tool_result 모양에 맞춰 쓰였고, model과 max_tokens도 늘 실려 있었습니다. 바꾼 뒤에는 셋이 달라집니다. 분류 결과가 흔들리고(같은 티켓이 두 번의 실행에서 다른 범주에 앉을 수 있습니다), 게이트 회차가 흔들리고, 토큰 수가 흔들립니다. 한 번 돌릴 때마다 돈과 시간이 듭니다. 그리고 코스 7의 옮겨 오지 않은 밸브 셋을 반드시 되돌려 놓아야 합니다.
복잡도를 한 겹 더할 때마다 "측정 가능하게 개선한다"는 관문을 통과해야 합니다. 이 그래프의 모든 패턴은 하나씩 떼어 낼 수 있습니다. 라우팅을 하지 않고 범용 프롬프트 하나로도 티켓에 회신할 수 있고, 팬아웃을 하지 않고 여섯 장을 직렬로 돌려도 끝나고, 리뷰 회로를 하지 않고 사람이 표본 검사해도 방법입니다. 떼어 낸 뒤 지표가 떨어지는지, 얼마나 떨어지는지는 시험해 봐야 압니다. 복잡도가 정말로 결과를 개선할 때에만 더할 값어치가 있습니다2.
💻 연습
정리
- 네 패턴이 한 파일로 용접됐고(투표는 연습이 다섯째로 보태며, 오케스트레이터-워커는 배분을 미리 정의할 수 있어 의도적으로 빠졌다), "계획이 코드에 있다"는 말이 구체적인 모양을 얻는다.
main()의 열몇 줄이 전부 제어 흐름이고, routed / drafts / items 세 평범한 변수가 전부 상태다. LLM과 도구가 미리 정의된 코드 경로를 통해 오케스트레이션되고2, 스크립트 자체가 루프, 분기, 중간 결과를 쥐며, 모델의 컨텍스트는 이번 단계에 필요한 것만 쥔다1
- 모든 노드가 모델일 필요는 없다. 다섯 노드 중 둘이 모델을 부르고,
merge와 report와 게이트의 첫 여과는 모두 순수 코드이며, other 범주는 문자열 템플릿으로 간다. 결정적 코드가 같은 답을 낼 수 있는 자리라면 호출 하나의 값과 지연을 치를 이유가 없다
- 라우팅의 값어치는 그 호출이 아니라 호출 뒤의 조이기 코드 열 줄에 있다. 모델의 자유 텍스트를 세 개의 적법한 레이블 중 하나로 눌러 넣고, 하류 분기는 코드가 검증한 값만 인정한다. 전문화된 프롬프트는 분류가 벌어들인 배당이다2
- 팬아웃의 동시 실행에는 반드시 상한이 있어야 하고, 병합은 페이로드가 아니라 참조를 넘겨야 한다 — 출력은 디스크에 내리고 하류로는 가벼운 참조만 넘기며3, 리뷰 노드가 파일에서 직접 읽는다. 동기 팬아웃은 이 규모에서는 아프지 않지만 규모가 커지면 병목이 된다3. 비동기로 바꾸려면 값 셋을 치러야 한다. 결과 조율, 상태 일관성, 서브에이전트 전반의 에러 전파다3
- 리뷰 회로는 검사-수정-재검사이며 통과하거나 더는 진척이 없을 때까지 돌고1, 여기에 최대 회차라는 안전망이 하나 더 붙는다2. 결정적 게이트는 심판보다 앞에 선다. "두 회차 연속 동일한 리포트"라는 기준은 최대 회차보다 일찍 손실을 끊고, 그것이 주는 결론이 더 정보량이 많다. "세 번 해 봤는데 안 된다"가 아니라 "이 피드백을 이해하지 못한다"이다
- 점진 추적이 복구 가능성을 데려온다. 노드가 끝날 때마다 한 번 영속화하는 것이 바로 같은 세션 안에서 실행을 이어 갈 수 있는 전제이며1(프로세스와 기계를 넘는 이어 가기는 상태를 디스크에 영속화한 뒤 이 레슨이 스스로 얹은 승격이다),
.tmp에 쓰고 rename하는 원자적 바꿔치기와 짝을 이루면 어느 순간에 죽어도 디스크에는 읽어 들일 수 있는 완전한 상태 하나가 있다
- 이 그래프가 다루는 것은 프로세스 하나, 티켓 한 묶음, 단계가 못 박힌 일이다. 단계를 예측할 수 없는 열린 문제는 자율 루프로 돌아가야 하고2, 복잡도를 한 겹 더할 때마다 "측정 가능하게 개선한다"는 관문을 통과해야 한다2
열두 레슨이 여기서 끝납니다.
돌아보면, 지금 여러분이 가진 것은 조각조각 쌓인 것입니다. 코스 1(Claude Code Skills: 나만의 AI 워크플로 만들기)에서 첫 프롬프트를 썼고 요구사항을 또렷하게 말하는 법을 배웠습니다. 그다음 도구 호출, 워크플로, 스킬, 멀티 에이전트 협업을 지나 코스 7까지 왔습니다. 그 코스는 여러분이 직접 루프를 쓰게 했습니다. while (response.stop_reason === "tool_use"). 그날부터 에이전트는 더 이상 여러분에게 블랙박스가 아니라 읽을 수 있는 코드 한 덩이가 되었습니다. 코스 8(컨텍스트 엔지니어링: 유한한 주의를 값어치 있는 곳에 쓰기)은 그 컨텍스트를 관리하고, 루프가 윈도우를 터뜨릴 때까지 돌지 않게 하는 법을 가르쳤습니다. 코스 9는 그것이 중단을 견디게, 죽어도 멈춘 자리에서 이어 가게 만드는 법을 가르쳤습니다. 코스 10은 그 출력을 검증하고 "끝난 것 같다"와 "끝났다"를 가르는 법을 가르쳤습니다. 코스 11은 그 과정을 들여다보고, 무언가 깨졌을 때 볼 로그와 트레이스를 갖추는 법을 가르쳤습니다. 이 코스는 여러 루프를 계획을 스스로 쥔 그래프 하나로 조합하는 법을 가르쳤습니다.
이 여섯은 한 가지의 여섯 면입니다. 여러분이 쓴 코드 안에서, 여러분은 비결정적인 것을 통제하고 있습니다. 루프는 여러분이 쓴 것이고, 컨텍스트는 여러분이 관리하는 것이고, 체크포인트는 여러분이 저장한 것이고, 합격 기준은 여러분이 정의한 것이고, 로그는 여러분이 찍은 것이고, 계획은 여러분이 배치한 것입니다. 모델은 매우 강력하지만, 그것은 여러분이 지은 이 제어 코드 안에서 일합니다.
마지막 걸음은 구체적인 행동에 내려앉습니다. orchestrate.mjs의 makeStubClient(queue)를 new Anthropic()으로 바꾸고, SCRIPTS 표를 지우고, 코스 7의 옮겨 오지 않은 밸브 셋을 되돌려 놓은 뒤, 여러분 일에 실제로 쌓여 있는 작업 묶음 — 진짜 티켓, 진짜 로그, 진짜 할 일 — 을 inbox/에 부어 넣고 처음으로 돌려 보십시오. 아마 몇 건이 needs_human에 내려앉을 것입니다. 그것이 바로 이 그래프의 마땅한 모습입니다.