레슨 3: 체크포인트에서 재개하기: 루프를 다시 일으키기
학습 목표:
- 크래시 복구 장면에서 '허공에 뜬 호출'이 반드시 나타나는 이유와, 그것이 평범한 도구 실행 실패와 어떻게 다른 것인지 말하기
loadCheckpoint()에서 루프로 되돌아가는 완전한 재개 경로를, 버전 검사와 상태 재구성까지 포함해 쓰기- 허공에 뜬 호출을 맹목적으로 다시 실행하거나 맹목적으로 지우는 대신, 도구의 성질에 따라 정산하기 — 읽기 전용 도구는 그대로 다시 실행하고, 영향이 큰 도구는 먼저 폴백하기
전제: 레슨 2를 마치고
checkpoint.json의 필드와 두 저장 지점을 이해하고 있어야 합니다 | 이전: 레슨 2 << | 다음: 레슨 4 >>
재시작이 아니라 재개입니다
에이전트가 도중에 크래시하면 첫 반응은 대개 '한 번 더 돌리자'입니다. 하지만 이미 십몇 턴을 밀고 나갔고 도구를 여러 번 호출한 긴 작업에서 재시작은 나쁜 거래입니다. "restarts are expensive and frustrating for users"1(재시작은 비싸고 사용자를 좌절시킨다). 레슨 2는 실행 장면을 checkpoint.json에 써 냈습니다. version, task, turns, tokensUsed, messages, pendingToolUse를, 모델이 응답한 뒤(저장 지점 A)와 도구 결과가 기록된 뒤(저장 지점 B)에 한 번씩 떨어뜨리는 방식이었습니다. 이 레슨이 하는 일은 저장된 그 장면을 앞으로 나아갈 수 있는 루프로 되돌리는 것입니다. 매번 처음부터 다시 시작하는 대신 "resume from where the agent was when the errors occurred"1(에러가 발생했을 때 에이전트가 있던 지점에서 재개한다) 할 수 있는 시스템을 만드는 것입니다.
재개의 뼈대: 쉬운 절반
쉬운 쪽부터 시작합시다. 재개의 뼈대는 네 단계입니다. 파일을 읽고, JSON.parse하고, version을 검사하고, 필드들을 런타임 상태로 펼쳐 넣는 것입니다. 이 넷이 끝나면 runAgent는 초기 messages 배열을 다시 구성할 필요가 없습니다. 체크포인트가 이미 완전한 것을 담고 있으므로, 초기화를 건너뛰고 곧장 루프로 떨어집니다.
이 두 함수가 자리를 잡으면 runAgent의 첫머리는 단순한 분기가 됩니다.
재개한 뒤 루프가 가장 먼저 하는 일은 늘 하던 바로 그것입니다. state.messages를 집어 다음 client.messages.create()를 쏘는 것입니다. 모델이 보는 messages는 크래시 이전에 보던 것과 동일하며, 그 사이에 프로세스 재시작이 있었다는 것을 모델은 전혀 모릅니다. 레슨 2가 messages를 손대지 않은 채로 체크포인트에 넣으라고 고집한 이유가 이것입니다. 그 배열만 충실히 복원되면 재개는 모델에게 보이지 않습니다.
어려운 절반: 허공에 뜬 호출을 정산하기
진짜 골칫거리는 state.pendingToolUse가 null이 아닌 체크포인트입니다. 두 저장 지점이 어디에 있었는지 떠올려 보십시오. 지점 A는 모델 응답 뒤에 오고, 그 순간 pendingToolUse는 이번 응답의 {id, name, input}을 담고 있습니다. 지점 B는 도구 결과가 기록된 뒤에 오고, 거기서 pendingToolUse는 null로 지워집니다. 프로세스가 A와 B의 정확히 사이에서 죽으면 — 도구가 아직 실행되지 않았거나, 끝났지만 결과가 messages에 끝내 들어가지 못했다면 — 체크포인트가 간직하는 것은 null이 아닌 pendingToolUse입니다.
이제 messages의 끝은 tool_use 블록을 실은 assistant 메시지이고, 짝이 되는 tool_result가 없습니다. 이것은 끌고 나갈 수 있는 상태가 아닙니다. 프로토콜은 이렇게 요구합니다. "return one tool_result for each tool_use block, all together in the next user message"2(tool_use 블록 하나당 tool_result 하나를, 다음 user 메시지에 함께 모아 돌려주라). 그 결과 하나가 없으면 재개는 다음 호출을 아예 할 수 없습니다. 모델에게 보이는 것은 자기가 도구 호출을 일으켰는데 답은 영영 오지 않는, 반쯤 끝난 주고받기입니다. 이 허공에 뜬 호출은 루프에 재진입하기 전에 처리해야 합니다.
세 가지 수, 성립하는 것은 하나
허공에 뜬 이 assistant 메시지를 앞에 두면 떠오르는 수는 셋이지만, 실제로 성립하는 것은 하나뿐입니다.
첫 번째 수: messages에서 그 assistant 메시지를 지우고 없었던 일로 하기. 가장 깔끔해 보입니다. 재개된 대화에는 더 이상 빈틈이 없습니다. 하지만 대가는 두 겹으로 옵니다. 첫째, 모델은 자기가 이미 내린 판단을 잊어버리므로 같은 탐색을 처음부터 다시 밟으며 한 턴을 헛되이 태울 수 있습니다. 둘째, 그리고 더 위험하게는, 그 도구 호출이 실제로 이미 실행되었고 프로세스가 결과를 기록하기 직전에 죽은 것이라면, 메시지를 지운다고 해서 이미 일어난 부수 효과가 되돌려지지는 않습니다. 다만 모델과 이후의 모든 로그가 그 일이 있었다는 사실을 아예 모르게 될 뿐입니다. 삭제는 위험이 아니라 사실을 감춥니다.
두 번째 수: 그냥 도구를 다시 실행해서 결과를 tool_result에 채워 넣기. 읽기 전용 도구(read_file, grep 같은 것)라면 이것이 정확히 옳습니다. 두 번 읽는 것은 한 번 읽는 것과 다르지 않고 부수 효과는 0입니다. 영향이 큰 도구(메일 보내기, 데이터베이스에 쓰기)라면 위험합니다. 그 도구는 이미 한 번 실행되었을 가능성이 매우 높고, 조건 없이 다시 실행하는 것은 두 번째로 실행하는 일입니다. 이것이 바로 레슨 4가 통째로 다루는 멱등성 문제입니다. 지금 이 레슨은 당장 실행에 옮길 수 있는 규칙 하나를 세워 둡니다. 읽기 전용 도구는 그대로 다시 실행한다. 영향이 큰 도구는 다시 실행할지 정하기 전에 이미 실행되었는지부터 확인해야 한다.
세 번째 수: '실행 상태 불명, 다시 평가해 달라'고 말하는 is_error: true의 tool_result를 덧붙이고 판단을 모델에게 되돌려주기. 실행 여부를 가릴 수 없을 때 쓰는 보수적인 폴백입니다. is_error 필드는 바로 이것을 위해 있습니다. "Set to true if the tool execution resulted in an error"2(도구 실행이 에러로 끝났다면 true로 설정하라). 그리고 알고 보면 "letting the agent know when a tool is failing and letting it adapt works surprisingly well"1(도구가 실패하고 있음을 에이전트에게 알려 주고 스스로 적응하게 두는 것은 놀랄 만큼 잘 통한다). 모델은 컨텍스트를 다시 읽고 다른 방법으로 결과를 확인할지 결정하며, 소리 없이 되풀이되었을지 모를 행동에 데이지 않습니다.
셋을 나란히 놓으면 첫 번째 수는 탈락합니다. 두 번째와 세 번째는 각각 '가릴 수 있는' 경우와 '가릴 수 없는' 경우를 맡고, 둘이 함께여야 비로소 완전한 정산 규칙이 됩니다.
reconcile(cp): 정산을 코드로 옮기기
이 규칙을 함수로 만듭니다. 도구 이름에서 읽기 전용인지 판정하고, 그렇다면 다시 실행합니다. 그렇지 않다면 '부수 효과 원장'를 보러 가서 이 호출이 이미 실행되었는지 확인합니다. 이 레슨에는 아직 부수 효과 원장이 없으므로 주석이 대역을 서고, 실제 구현은 레슨 4가 줍니다. 가릴 수 없을 때는 is_error 폴백으로 떨어뜨립니다.
reconcile()이 끝나면 cp.messages의 끝에는 짝이 되는 tool_result가 채워지고 cp.pendingToolUse는 null로 돌아가 있습니다. 이제 이 cp는 저장 지점 B에서 정상적으로 착지한 체크포인트와 구별되지 않으며, 그대로 while 루프로 넘겨 이어 갈 수 있습니다.
재개한 뒤: turns와 tokensUsed 세기
재개 경로가 어긋내기 쉬운 카운터가 둘 있고, 따로 짚어 둘 가치가 있습니다.
turns는 재개할 때 초기화되지 않습니다. 이것은 '이번 프로세스 인스턴스가 몇 턴을 돌았는가'가 아니라 작업이 시작부터 지금까지 돈 총 턴 수를 셉니다. 체크포인트의 turns는 멈춘 자리에서 계속 올라가야 하며, 그래야만 레슨 2에서 설정한 MAX_TURNS 상한이 제 일을 계속합니다. 재개할 때 turns를 0으로 밀어 버리면, 크래시와 복구를 되풀이하는 작업은 턴 천장을 피해 영원히 돌 수 있게 됩니다.
tokensUsed도 같은 식으로 동작합니다. 다시 계산하는 것이 아니라 체크포인트에서 이어받습니다. "Context Engineering: Spending Finite Attention Where It Counts"가 컨텍스트 압축을 다룰 때 tokensUsed가 뜻하는 것은 '현재 윈도우의 사용량'이고, 체크포인트가 저장한 것이 바로 크래시 순간의 그 윈도우 사용량입니다. 둘은 같은 뜻을 지니므로, 재개할 때는 그것을 받아 이어 가면 되고 별도의 변환은 필요 없습니다.
정리
재개의 뼈대는 어렵지 않다. 체크포인트를 읽고, 버전을 검사하고, 필드를 런타임 상태로 펼쳐 넣고, 초기화를 건너뛰어 곧장 루프로 떨어지면 된다 — 그 사이에 크래시가 있었다는 것을 모델은 느끼지도 못한다. 실제로 설계를 요하는 것은 허공에 뜬 호출의 정산이다. 삭제는 판단을 잃게 하고 이미 일어난 부수 효과를 가린다. 읽기 전용 도구라면 마음 놓고 다시 실행할 수 있다. 그리고 이미 실행되었는지 가릴 수 없는 영향이 큰 도구에는, 맹목적인 재실행보다 is_error 폴백이 더 안전한 선택이다. 하지만 이 규칙은 아직 한 문제를 풀지 못한 채로 둔다. 영향이 큰 도구가 이미 실행되었는지를 실제로 어떻게 확인할 것인가? 이 레슨은 '가릴 수 없다'로 폴백했을 뿐이다. 정말로 가릴 수 있으려면 부수 효과 원장이 있어야 한다 — 그리고 그것이 바로 다음 레슨이 푸는 문제다.
>> 레슨 4: 부수 효과와 멱등성: 재개 시 다시 실행해도 안전한 도구는 무엇인가
Footnotes
-
How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system ↩ ↩2 ↩3
-
Handle tool calls — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls ↩ ↩2
연습
어느 운영 장애 보고서입니다. "send_email 도구 호출 뒤, 결과가 기록되기 전에 OOM 킬러가 프로세스를 죽이고 다시 띄웠다. 다시 뜬 하네스는 자동으로 --resume했고, 몇 분 뒤 사용자가 동일한 메일을 두 통 받았다고 신고했다."
레벨 2: 중복 메일의 근본 원인 찾기당시 프로덕션에서 돌던 reconcile()은 이랬습니다.
근본 원인을 짚어 내고, 이 reconcile()을 도구의 성질에 따라 갈라 보내는 버전으로 다시 쓰십시오(힌트: 이 레슨이 세운 규칙은 '읽기 전용은 그대로 다시 실행, 원장이 없는 영향이 큰 도구는 is_error로 폴백'입니다). 다시 쓴 뒤에는 node로 돌려, send_email 같은 영향이 큰 도구가 더 이상 executeTool()을 일으키지 않는지 확인하십시오.