레슨 4: 부수 효과와 멱등성: 재개 시 다시 실행해도 안전한 도구는 무엇인가
학습 목표:
- 재개 시의 리플레이가 기본적으로 at-least-once(최소 1회) 실행 시맨틱을 안겨 줄 뿐 결코 exactly-once가 되지 않는 이유를 설명하기
- 도구 조작이 멱등한지 판정하고, 두 번 실행되는 순간 실제 피해를 내는 부수 효과를 가려내기
tool_use_id를 키로 삼는 부수 효과 원장을 설계하고 구현해, 재개 시 허공에 뜬 호출이 실제로 실행할지 정하기 전에 원장을 먼저 확인하게 하기
전제: 레슨 2와 레슨 3을 읽었고 checkpoint.json에 남은 허공에 뜬 pendingToolUse의 정산 규칙(레슨 3)을 이해하고 있어야 합니다. 이 시리즈의 코스 7 "에이전트 하네스 기초: 루프와 통제"의 HIGH_IMPACT 도구 집합과 실행 전 승인 게이트를 알고 있어야 합니다 | 이전: 레슨 3 << | 다음: 레슨 5 >>
재개가 주는 것은 at-least-once: 레슨 3이 남긴 영향이 큰 도구
레슨 3은 checkpoint.json에서 pendingToolUse를 읽어 내, 허공에 뜬 호출 — 도구 실행과 원장 기록 사이에 크래시가 떨어진 그 호출 — 을 루프로 도로 끌어오는 법을 가르쳤습니다. 그때의 정산 규칙은 이랬습니다. 읽기 전용 도구는 그냥 다시 실행한다. 판단이 서지 않는 영향이 큰 도구에는 is_error tool_result를 덧붙여 루프가 막힌 자리에서 벗어나게 하고, 그 물음은 사람에게 되돌린다. 이것은 정직한 폴백이자, 동시에 풀리지 않은 문제이기도 합니다. '판단이 서지 않는다'는 그 작업이 스스로는 계속 갈 수 없다는 뜻이고, 그러므로 영향이 큰 도구에서 크래시가 날 때마다 누군가 지켜보고 있어야 합니다.
뿌리는 이것입니다. 재개는 그 본성상 at-least-once(최소 1회) 실행 시맨틱을 줍니다. 도구가 진짜로 성공한 뒤, 그러나 그 결과가 messages에 되돌아 쓰이거나 체크포인트에 커밋되기 전에 프로세스가 죽을 수 있고, 그 시점에서 '이 도구가 실행됐는가 아닌가'는 checkpoint.json 혼자서는 대답할 수 없는 물음입니다. read_file 같은 읽기 전용 도구라면 모르는 값이 아무 대가도 치르지 않습니다. 한 번 더 읽어도 결과는 같습니다. 그러나 send_email, create_ticket, 혹은 자금 이체라면 모른다는 것은 곧 사고입니다. 다시 실행한다는 것은 수신자가 똑같은 메일을 두 통 받을 수 있다는 뜻이고, 중복된 티켓이 난데없이 시스템에 나타날 수 있다는 뜻입니다.
새로운 문제가 아닙니다. 이 코스의 앞부분에서 우리는 에이전트가 상태를 가지며 에러가 누적된다1는 점을 확인했습니다. 한 번만 일어나야 했던 부수 효과를 두 번 실행하는 것은 그 '누적'이 취하는 구체적인 형태 하나입니다. 에러는 '한 번 더 돌렸다'에서 멈추지 않습니다. 그 여분의 부수 효과 위에 올라타 하류로 굴러갑니다. 이 레슨은 레슨 3이 열어 둔 채로 남긴 틈을 메웁니다. 먼저 멱등성이라는 개념을 들여오고, 그다음 재개 루프에 부수 효과 원장을 볼트로 조여, '판단이 서지 않는다'를 '판단이 선다'로 바꿉니다.
멱등이란 무엇인가: 한 번을 돌리든 열 번을 돌리든 같은 효과
멱등하다는 것은, 한 번 실행하든 여러 번 실행하든 같은 최종 효과를 내는 조작이라는 뜻입니다. 이것이 효과 — 그 조작이 바깥 세계(파일, 데이터베이스, 받은편지함)에 남기는 최종 상태 — 에 관한 이야기이지, 호출마다 문자 그대로 어떤 값을 돌려주는지에 관한 이야기가 아니라는 점에 주의하세요.
어떤 도구가 멱등한지 판정하는 데는 물음 하나면 충분합니다. "이 조작이 조용히 한 번 더 돌았다면, 바깥 세계에 무언가가 하나 더 생기거나 다른 상태로 끝날까?" 아래의 작은 예제들을 그 물음에 통과시켜 보면 차이가 곧바로 드러납니다.
readFileContent는 부수 효과가 아예 없기 때문에 본성상 멱등합니다 — '남는 것'이랄 게 없습니다. setLine도 멱등한데, 이쪽은 상태를 실제로 바꿉니다. 다만 바꾸는 방식이 덮어쓰기입니다. 한 번 부르면 42번 줄은 X이고, 열 번 불러도 42번 줄은 여전히 X입니다. 최종 상태가 호출 횟수에 따라 달라지지 않습니다. appendRow와 sendEmail은 멱등하지 않은데, 둘 다 이유는 같습니다. 그 효과가 누적적이라는 것 — 호출할 때마다 바깥 세계에 무언가가 진짜로 하나씩 더해지므로, 호출 횟수가 최종 상태에 그대로 드러납니다.
이 경계선을 붙들어 두세요. 덮어쓰기 방식의 쓰기는 대체로 멱등하고, 덧붙이기 방식의 쓰기는 대체로 멱등하지 않습니다. 읽기와 '먼저 확인하고 나서 움직일지 정하는' 조작은 대체로 멱등하고, 조건 없는 단순 삽입은 대체로 멱등하지 않습니다. 다음 절의 부수 효과 원장은 바로, 멱등하지 않으면서 설계를 바꿔서는 그것을 없앨 수 없는 조작들을 붙잡기 위해 존재합니다.
부수 효과 원장: 어떤 부수 효과가 이미 일어났는지 적어 두기
레슨 2는 루프의 실행 중인 장면 — messages, 각종 카운터, 아직 원장에 쓰이지 않은 도구 호출 — 을 체크포인트에 담아 크래시가 나도 그 자리에서 주워 올릴 수 있게 하는 법을 가르쳤습니다. 그러나 체크포인트가 대답하는 것은 '루프의 어느 단계까지 왔는가'이지 '그 단계의 부수 효과가 실제로 일어났는가'가 아닙니다. 정상 운행 중에는 둘이 거의 발을 맞춰 움직이지만, 그 사이의 틈에 크래시가 떨어지는 순간 둘은 어긋납니다. 레슨 3이 영향이 큰 도구의 정산을 풀지 못한 채 남겨 둘 수밖에 없었던 것이 바로 이 때문입니다.
그 틈을 메우려면 부수 효과 원장이 필요합니다. 어떤 부수 효과가 이미 일어났는지를 체크포인트와는 별도로 디스크에 적어 두는 것입니다. 구조는 단순합니다. tool_use_id를 키로 삼는 맵입니다.
원장을 쓰는 타이밍은 대단히 중요합니다. 도구 함수가 실제로 성공해 결과를 돌려준 바로 그 순간에 쓰고, 게다가 레슨 2의 '지점 B' 체크포인트(도구 결과가 messages에 들어간 뒤에 이루어지는 정형화된 쓰기)보다 한 박자 먼저 씁니다. 이유는 직접적입니다. '도구가 성공했다'와 '지점 B 체크포인트 쓰기가 끝났다' 사이의 좁은 창 안에 크래시가 떨어지면, 지점 B 체크포인트는 이 일이 일어났다는 것을 기록할 기회를 얻지 못합니다. 재개 시 진실을 알려 줄 수 있는 것은 더 먼저 쓰기를 마친 원장뿐입니다. 원장 쓰기 자체도 레슨 2와 레슨 3의 .tmp + rename 원자적 쓰기를 써야 하며, 이유는 같습니다. 반쯤 쓰인 원장 파일은 원장이 아예 없는 것보다 위험합니다. 실제로는 완료되지 않은 부수 효과를 여러분이 믿게 만들기 때문입니다.
원장이 손에 있으면, 재개 시의 정산 규칙은 레슨 3의 '판단이 서지 않는다'에서 '판단이 선다'로 격상됩니다. checkpoint.json에서 꺼낸 pendingToolUse의 id를 원장에서 찾아보는 것입니다. 히트라면 그 부수 효과는 정말로 일어난 것이므로, 원장에 저장된 result를 꺼내 tool_result를 채우고, 두 번 다시 실행하지 않습니다. 미스라면 이 호출은 시작조차 하지 않았거나 성공하지 못한 채 도중에 죽은 것이므로, 실행해도 안전합니다. 이 규칙은 모든 도구에 적용됩니다. 다만 멱등한 도구에는 이 조회가 어느 쪽으로 나오든 영향이 없다는 것뿐입니다. 실제로 이것에 기대는 것은 되풀이될 때 실제 피해를 내는 조작들입니다.
여기에는 따로 떼어 말할 값어치가 있는 통찰이 하나 있습니다. tool_use_id는 이미 멱등 키입니다. 모델이 도구를 지명할 때마다 그 호출은 "A unique identifier for this particular tool use block"2(이 특정 도구 사용 블록에 대한 고유 식별자)를 달고 옵니다. 공식 명세의 id 필드 정의 그대로입니다. 재개 시의 리플레이 때문에 같은 지명이 두 번째로 보이더라도 id는 바뀌지 않습니다. 원장이 '이 호출'과 '아까 그 호출'을 하나의 동일한 사건으로 알아볼 수 있는 것은 바로 그 덕분이며, 여러분이 따로 중복 제거 방식을 고안할 필요가 없습니다.
두 겹의 게이트: 승인은 '해야 하는가'를, 원장은 '이미 했는가'를 묻습니다
이 시리즈의 코스 7 "에이전트 하네스 기초: 루프와 통제"는 runToolUses에 승인 게이트를 달았습니다. 영향이 큰 도구가 실제로 돌기 전에 무슨 일이 벌어질지를 찍어 보여 주고, 사람이 확인해 줄 때까지 기다렸다가 통과시키는 것입니다3. 그 게이트는 '이것을 해야 하는가'라는 물음을 세웁니다. 이 레슨의 부수 효과 원장은 다른 물음을 세웁니다. '이것을 이미 했는가'. 두 게이트는 서로 다른 것을 묻지만 같은 자리에 앉습니다. 둘 다 모델이 도구를 지명한 뒤, 도구가 실제로 돌기 전의 그 순간에 끼워져 있습니다. 어느 쪽도 확인을 거치기 전에는 도구 함수가 실행되도록 두지 않습니다.
둘을 겹쳐 쌓으면 runToolUses는 이런 모양이 됩니다.
순서는 타협 대상이 아닙니다. 멱등성 게이트가 먼저 와야 합니다. 이유는 분명합니다. 이 호출이 이미 원장에 있다면 그 뒤에 '이것을 해야 하는가'를 묻는 것은 무의미합니다. 이미 끝난 일이기 때문이고, 다시 묻는 것은 대답하는 사람을 헷갈리게 할 뿐입니다. 시스템이 이것을 분명히 끝냈는데 왜 나에게 확인을 요구하지? 재개 시 허공에 뜬 호출에 대한 첫 물음은 언제나 '이것이 일어났는가'이며, 그것이 가려진 뒤에야 '이것을 해야 하는가'에 차례가 돌아옵니다.
도구 설계 층위의 멱등성: 뒷수습이 아니라 원인을 고치기
부수 효과 원장은 하네스 쪽의 최후 방어선입니다. 도구 자체가 멱등하게 설계되었든 아니든, 원장은 tool_use_id로 중복 실행을 막을 수 있습니다. 그러나 최후 방어선은 어디까지나 최후 방어선이고, 더 나은 투자는 원인을 고치는 쪽입니다. 도구를 바꿀 수 있는 곳이라면 본성상 멱등하게 설계해, 원장이 나설 일 자체를 없애는 것입니다.
그 변경의 가장 흔한 형태는 '생성'을 '있는지 보장'으로 바꾸는 것입니다.
ensureTicket을 한 번 부르든 열 번 부르든 시스템에는 그 제목에 해당하는 티켓이 정확히 하나만 남습니다. 최종 상태가 호출 횟수에 따라 달라지지 않는다는 것, 그것이 멱등의 정의입니다. 파일 쓰기에도 같은 사고가 적용됩니다. 파일 전체를 쓰는 write_file은 본성상 멱등해서 반복 호출해도 같은 내용이 남지만, 덧붙이는 append_file은 그렇지 않아서 파일이 호출마다 한 단락씩 자랍니다. 덮어쓰기를 고를 수 있다면 덧붙이기를 고르지 마십시오.
원인 고치기와 뒷수습은 둘 중 하나를 고르는 문제가 아니라 분업입니다. 멱등하게 설계할 수 있는 도구는 도구 층위에서 풀어, 모든 호출이 원장을 우회로로 거치는 수고를 덜어 주어야 합니다. 그리고 업무 논리상 정말로 '중복 제거해 합칠' 수 없는 조작 — 이를테면 서로 다른 시각에 실제로 일어난 두 건의 이체처럼, 서로 다른 두 사건으로 인식되어야 마땅하고 영리한 설계로 하나로 접을 수 없는 것 — 에는 원장이 유일한 최후 방어선입니다.
되짚기: 가드레일 하나 더, 그리고 그것이 값어치를 하는 때
이 코스는 신뢰성이 모델의 적응력과 재시도 로직·정기 체크포인트 같은 결정론적 안전장치를 짝지을 때 나온다1는 지점에서 출발했습니다. 부수 효과 원장은 그 가드레일 가운데 하나입니다. 그것은 '내가 이미 이것을 했나'를 모델에게 판정하라고 요구하지 않습니다 — 그것은 애초부터 모델이 감지할 수 있는 범위 밖이었습니다. 대신 하네스가 모델을 대신해, 디스크에 쓰인 확정적인 증거로 그 판정을 내리게 합니다.
균형 감각도 잃지 마십시오. 여러분의 에이전트가 쥔 도구가 전부 읽기 전용이라면 이 레슨의 원장은 아마 제 값을 하지 못할 것입니다. 부수 효과 원장 자체가 복잡도의 한 겹이며, 그것을 더할 만하게 만드는 것은 중복된 부수 효과라는 실제 위험을 정말로 막아 준다는 사실입니다. 복잡도는 결과를 눈에 띄게 개선함이 증명될 때에만 더하십시오3. 시험 방법은 지난 레슨과 같습니다. 여러분의 도구 집합에서 멱등하지 않은 영향이 큰 조작을 먼저 찾아보십시오. 있다면 게이트는 달 만합니다. 없다면 서둘러 쓰지 마십시오.
정리
- 재개는 at-least-once 실행 시맨틱을 안겨 준다. 도구가 진짜로 성공한 뒤 결과가 원장에 쓰이기 전에 프로세스가 죽을 수 있고, 체크포인트만으로는 그 허공에 뜬 호출이 돌았는지 알 수 없다. 레슨 3이 영향이 큰 도구의 정산을 풀지 못한 채 남겨 둘 수밖에 없었던 뿌리가 이것이다.
- 멱등의 정의: 한 번을 돌리든 여러 번을 돌리든 같은 최종 효과를 내는 조작. 덮어쓰기 방식의 쓰기(
set_config, write_file)는 대체로 멱등하고, 덧붙이기 방식의 쓰기(append_log, send_email)는 대체로 그렇지 않다.
- 부수 효과 원장은 어떤 부수 효과가 이미 일어났는지를
tool_use_id를 키로 디스크에 기록한다. 그 키는 모델이 도구를 지명할 때 달고 오는 고유 식별자이고2, 재개 시 같은 지명이 리플레이되어도 바뀌지 않으므로 본성상 멱등 키다. 도구가 성공한 그 순간에 .tmp + rename 원자적 쓰기로 써라.
- 재개 시의 정산 규칙은 이렇게 격상된다.
pendingToolUse.id가 원장에 히트하면 저장된 결과를 재사용하고 두 번 다시 실행하지 않는다. 미스라면 안전하게 실행한다.
- 승인 게이트는 '이것을 해야 하는가'를 묻고, 부수 효과 원장은 '이것을 이미 했는가'를 묻는다. 둘은 서로를 보완하며, 둘 다 실제 실행 앞에 앉고, 멱등성 게이트가 먼저 간다.
- 원인 고치기가 뒷수습을 이긴다. 도구를 본성상 멱등하게 설계하면('생성'보다 '있는지 보장', 덧붙이기보다 덮어쓰기) 모든 것에 원장이 필요하지는 않게 된다. 신뢰성은 모델의 적응력과 결정론적 안전장치가 짝지어질 때 나오지만1, 안전장치 역시 복잡도이며 결과를 눈에 띄게 개선함이 증명될 때에만 더해야 한다3.
>> 레슨 5: 되감기와 분기: 체크포인트의 두 번째 값어치