Field Log · Entry
Durable Agent Execution: Checkpoint·Retry·Idempotency (7/10)
오늘의 결론
- Agent의 model call과 tool call 사이에서는 process crash와 network timeout이 정상적으로 일어납니다.
- Durable execution은 모든 것을 다시 실행하는 기능이 아니라, 결정은 replay하고 부작용은 receipt로 중복 방지하는 설계입니다.
- Retry는 error taxonomy, timeout, backoff, jitter, 최대 횟수와 전체 budget을 함께 가져야 합니다.
timeout은 실패를 뜻하지 않습니다. 상대 시스템에서 이미 성공했을 수 있으므로 idempotency key가 필요합니다.- Cancel은 실행 중단 신호이며 이미 끝난 부작용을 되돌리는 rollback과 같지 않습니다. 필요한 경우 별도 compensation을 실행합니다.
앞 글에서는 run을 넘어 재사용할 memory를 설계했습니다. 그런데 현재 run 자체가 중간에 죽는다면 어떨까요?
1. Agent가 “환불 승인” tool을 호출한다.
2. 결제 시스템은 환불을 완료한다.
3. 성공 응답이 돌아오기 직전 network가 끊긴다.
4. Agent는 timeout만 보고 같은 tool을 다시 호출한다.
두 번째 호출이 또 환불을 만들면 단순한 재시도가 금전 사고가 됩니다. 반대로 재시도를 전혀 하지 않으면 일시 장애 하나로 긴 작업 전체가 사라집니다. 핵심은 어디까지 확정되었는지와 외부 세계에 무엇이 실제로 반영되었는지를 분리해 기록하는 것입니다.
그림 1. Orchestrator의 state transition과 외부 side effect를 분리한다. 재개 시 checkpoint 이후 decision은 replay할 수 있지만 이미 실행된 effect는 같은 idempotency key로 조회·재사용한다.
Durable execution이 보장해야 하는 것
Durable execution은 긴 Agent run에 다음 성질을 부여하는 실행 방식입니다.
- process가 재시작되어도 마지막 확정 지점에서 이어 간다.
- 같은 event history로 state transition을 다시 계산할 수 있다.
- 이미 끝난 외부 부작용을 무심코 반복하지 않는다.
- 일시 오류만 제한적으로 재시도한다.
- deadline, 비용, step, token budget을 재시작 뒤에도 유지한다.
- cancel과 human approval 대기를 안전하게 지속한다.
- 어느 시도에서 무엇이 확정됐는지 감사할 수 있다.
여기서 중요한 단어는 확정입니다. 메모리에 있던 Python object나 log 한 줄은 확정된 state가 아닙니다. crash 뒤 복구 가능한 durable store에 원자적으로 기록되어야 합니다.
Workflow와 Activity를 분리하기
Durable runtime은 보통 두 종류의 작업을 구분합니다.
Workflow 또는 Orchestrator
다음 action을 고르고 state를 전이합니다.
route 선택
plan step 전환
retry 가능 여부 결정
approval 대기
budget 차감
종료 상태 결정
같은 history를 replay할 때 같은 결정을 내릴 수 있어야 합니다. 따라서 현재 시간, 임의 난수, network 응답처럼 바뀌는 값은 runtime이 event로 기록해 제공하거나 workflow 바깥으로 내보냅니다.
Activity 또는 Effect Executor
외부 세계와 통신하는 비결정적 작업입니다.
LLM API 호출
vector DB 검색
이메일 발송
환불 생성
Git commit·push
사내 API 변경
Activity는 실패하거나 중복 전달될 수 있다고 가정합니다. 그래서 timeout, retry policy, idempotency key, receipt가 필요합니다.
| 구분 | Workflow | Activity |
|---|---|---|
| 역할 | 순서·상태·정책 결정 | 외부 I/O·부작용 수행 |
| replay | 같은 history로 재계산 | 무조건 다시 실행하지 않음 |
| 결정성 | 요구됨 | 요구되지 않음 |
| 실패 처리 | state transition | timeout·retry·heartbeat |
| 기록 | event·checkpoint | attempt·request·receipt |
Event log와 Checkpoint
Event log
일어난 사실을 append-only record로 남깁니다.
{"seq": 41, "type": "ACTION_PROPOSED", "action_id": "a_12"}
{"seq": 42, "type": "BUDGET_RESERVED", "usd": 0.04}
{"seq": 43, "type": "ACTIVITY_STARTED", "attempt": 1}
{"seq": 44, "type": "ACTIVITY_TIMED_OUT", "kind": "start_to_close"}
{"seq": 45, "type": "ACTIVITY_STARTED", "attempt": 2}
{"seq": 46, "type": "ACTIVITY_SUCCEEDED", "receipt_id": "r_91"}
Event는 순서와 원인을 설명하기 좋지만 매번 처음부터 전부 fold하면 느립니다.
Checkpoint
특정 event까지 계산한 state snapshot입니다.
{
"run_id": "run_7f2",
"checkpoint_id": "cp_18",
"last_event_seq": 46,
"state_version": 9,
"status": "OBSERVED",
"completed_steps": ["route", "retrieve"],
"pending_action": null,
"receipts": ["r_91"],
"remaining_budget": {"steps": 5, "usd": 0.21},
"deadline_at": "2026-07-16T09:10:00Z"
}
복구 과정은 checkpoint load → 이후 event 적용 → 아직 확정되지 않은 work만 schedule입니다.
어디에서 checkpoint할까?
매 token마다 저장하면 비싸고, run 끝에서만 저장하면 복구 가치가 없습니다. 보통 다음 경계가 중요합니다.
- user request를 정규화한 직후
- route나 plan을 확정한 직후
- 비용이 큰 model call 직후
- tool effect를 실행하기 전과 receipt를 받은 직후
- human approval을 기다리기 직전
- retry·cancel·terminal state로 전이할 때
Checkpoint와 event append가 서로 어긋나지 않도록 transaction 또는 compare-and-swap을 사용합니다.
Replay는 “모든 호출 반복”이 아니다
Replay의 목적은 history에서 state를 다시 만드는 것입니다.
recorded event를 읽음
→ workflow decision을 같은 순서로 계산
→ 완료 event가 있는 activity 결과는 history에서 반환
→ 완료되지 않은 activity만 policy에 따라 다시 schedule
Model은 같은 prompt에서도 응답이 달라질 수 있습니다. 따라서 완료된 model call의 선택 결과나 raw response hash를 event로 고정하지 않고 replay 때 다시 호출하면 plan이 갈라질 수 있습니다.
재현에 필요한 최소 manifest:
model provider · model ID · decoding config
prompt/template version · tool schema version
retrieval index snapshot · policy version
input hash · output/observation hash
Delivery semantics를 현실적으로 이해하기
At-most-once
한 번만 시도합니다. 중복은 줄지만 timeout 뒤 성공 여부를 모르면 작업을 잃을 수 있습니다.
At-least-once
성공을 확인할 때까지 다시 전달할 수 있습니다. 작업은 사라지기 어렵지만 handler가 중복을 견뎌야 합니다.
Exactly-once라는 표현의 함정
여러 시스템을 건너는 network call 전체에서 마법처럼 정확히 한 번만 실행된다고 기대하기 어렵습니다. 실무에서는 다음 조합으로 관찰 가능한 효과를 한 번처럼 만듭니다.
at-least-once delivery
+ stable idempotency key
+ target-side deduplication
+ atomic receipt
+ reconciliation
Idempotency key를 Action의 정체성으로 만들기
같은 논리 action의 모든 attempt는 같은 key를 사용해야 합니다.
def make_idempotency_key(run_id: str, action_id: str) -> str:
# retry 때 새로 생성하지 않고 최초 proposal과 함께 checkpoint한다.
return f"agent:{run_id}:{action_id}"
나쁜 예:
# attempt마다 다른 값이므로 중복 방지가 되지 않는다.
key = uuid.uuid4().hex
좋은 request envelope:
{
"action_id": "refund_12",
"idempotency_key": "agent:run_7f2:refund_12",
"operation": "create_refund",
"target": "payment:pay_402",
"amount": 30000,
"currency": "KRW",
"input_hash": "sha256:...",
"precondition": {"payment_version": 7}
}
Target system은 key와 input hash를 함께 저장합니다.
- 같은 key + 같은 input: 최초 receipt를 반환
- 같은 key + 다른 input: 충돌로 거부
- 새 key + 충족된 precondition: 한 번 실행 후 receipt를 원자적으로 저장
- 이미 바뀐 target version: stale action으로 거부
Receipt 예:
{
"idempotency_key": "agent:run_7f2:refund_12",
"status": "COMMITTED",
"effect_id": "rf_901",
"input_hash": "sha256:...",
"committed_at": "2026-07-16T09:03:31Z"
}
Client-side cache만으로는 부족합니다. Client가 receipt를 쓰기 전에 죽을 수 있으므로 실제 effect와 가장 가까운 target이 deduplicate해야 합니다.
Timeout은 하나가 아니다
“30초 timeout” 하나만 두면 어느 구간이 멈췄는지 알 수 없습니다.
| Timeout | 제한하는 구간 | 질문 |
|---|---|---|
| connect | 연결 수립 | endpoint에 닿았는가? |
| schedule-to-start | queue 대기 | worker가 부족한가? |
| start-to-close | 한 attempt 실행 | handler가 너무 오래 걸리는가? |
| heartbeat | 진행 신호 간격 | 긴 작업이 살아 있는가? |
| schedule-to-close | 모든 retry 포함 activity 전체 | 이 activity를 언제 포기할까? |
| run deadline | Agent run 전체 | 사용자가 아직 결과를 기다릴 가치가 있는가? |
Timeout 뒤에는 결과가 세 가지일 수 있습니다.
실제로 실행되지 않음
실행 중임
이미 성공했지만 응답만 잃음
따라서 write tool은 timeout 뒤 즉시 다른 key로 재호출하지 말고 기존 key의 status/receipt를 조회합니다.
Retry 가능한 오류를 분류하기
RETRYABLE = {
"RATE_LIMITED",
"UPSTREAM_UNAVAILABLE",
"CONNECT_TIMEOUT",
"WORKER_LOST",
}
NON_RETRYABLE = {
"INVALID_ARGUMENT",
"AUTH_DENIED",
"POLICY_BLOCKED",
"SCHEMA_MISMATCH",
"STALE_APPROVAL",
"BUDGET_EXHAUSTED",
}
UNKNOWN을 무한 retry 가능한 범주로 두지 않습니다. 안전한 read라면 제한적으로 재시도하고, 비가역 write라면 reconciliation 또는 human review로 보냅니다.
Exponential backoff와 jitter
import random
def retry_delay(attempt: int, base: float = 0.5, cap: float = 30.0) -> float:
maximum = min(cap, base * (2 ** (attempt - 1)))
return random.uniform(0, maximum) # full jitter
모든 worker가 1초, 2초, 4초에 동시에 깨어나면 장애 중인 upstream을 다시 공격합니다. Jitter는 재시도 시점을 흩어 retry storm을 줄입니다. Provider가 Retry-After를 보내면 그 제한을 존중합니다.
Retry budget
개별 activity의 max_attempts=5만으로 부족합니다. Agent가 tool 열 개를 각각 다섯 번 호출하면 전체 run은 폭주할 수 있습니다.
{
"remaining_attempts": 8,
"remaining_wall_ms": 45000,
"remaining_usd": 0.20,
"per_tool": {"search": 3, "refund": 1}
}
각 attempt를 schedule하기 전에 budget을 reserve하고, 시작하지 못한 경우에만 명시적으로 반환합니다. 병렬 branch가 같은 남은 예산을 동시에 쓰지 못하도록 원자적 갱신이 필요합니다.
Heartbeat로 긴 Tool을 복구하기
파일 천 개를 처리하는 activity를 처음부터 재시작할 필요는 없습니다.
for index, item in enumerate(items):
process(item)
heartbeat({"next_index": index + 1})
Worker가 사라지면 다음 attempt가 마지막 heartbeat detail의 next_index부터 이어 갑니다. 단, 각 item 처리도 idempotent하거나 item-level receipt를 가져야 합니다.
Cancellation과 Compensation
Cancellation
앞으로 할 일을 멈추라는 신호입니다.
- 새 activity를 schedule하지 않는다.
- 실행 중 activity에 cancel token을 전달한다.
- cooperative cancellation point에서 종료한다.
- child run과 병렬 branch에 전파한다.
- terminal state와 reason을 checkpoint한다.
Compensation
이미 확정된 effect를 상쇄하는 새 business action입니다.
예약 생성 → 결제 승인 → 배송 요청
↑ 실패
compensation: 결제 취소 → 예약 해제
Database transaction의 rollback처럼 시간을 되감는 것이 아닙니다. 취소 수수료나 이미 발송된 이메일처럼 완전히 되돌릴 수 없는 효과도 있습니다. 그래서 compensation도 별도 authorization, idempotency key, receipt를 갖습니다.
Human interrupt도 Durable State다
승인 대기 중 process가 재시작돼도 question과 pending action이 바뀌면 안 됩니다.
{
"status": "WAITING_APPROVAL",
"interrupt_id": "int_44",
"action_hash": "sha256:...",
"question": "3만원 환불을 실행할까요?",
"expires_at": "2026-07-16T09:30:00Z"
}
Resume input은 같은 interrupt_id와 action_hash에 결합합니다. 승인 뒤 target이나 amount가 달라졌다면 새 승인을 받아야 합니다.
LangGraph의 persistence는 graph state를 checkpoint thread에 저장하고, interrupt는 실행을 멈춘 뒤 durable state에서 human input으로 재개하는 모델을 제공합니다. 특정 library를 쓰지 않더라도 pause가 memory sleep이 아니라 persisted state여야 한다는 원리는 같습니다.
Concurrency와 Lease
재시작 직후 old worker와 new worker가 동시에 살아 있을 수 있습니다. run_id만 믿고 둘 다 다음 step을 실행하면 중복이 생깁니다.
lease_owner · lease_epoch · expires_at
- worker는 짧은 lease를 얻고 heartbeat로 갱신한다.
- state update는 예상
state_version과lease_epoch가 맞을 때만 성공한다. - 만료된 worker의 늦은 write는 fencing token으로 거부한다.
- 외부 effect는 lease와 별개로 idempotency key를 사용한다.
Lease는 동시 orchestrator를 줄일 뿐 network 경계의 exactly-once를 보장하지 않습니다.
Workflow Versioning과 Replay
어제 시작한 run을 오늘 배포한 코드로 replay하면 분기가 달라질 수 있습니다.
if workflow_version("retrieval-policy", default=1) == 1:
route = old_router(state)
else:
route = new_router(state)
실제 runtime의 versioning API는 다르지만 원리는 같습니다.
- 실행 중 run은 기록된 code/policy version을 따른다.
- 새 run만 새 version을 기본으로 사용한다.
- schema migration은 replay compatibility를 검사한다.
- 오래된 version 제거 전 active history가 남았는지 확인한다.
최소 Durable Agent Skeleton
def resume_run(run_id: str) -> FinalState:
state, history = store.load_checkpoint_and_tail(run_id)
state = replay(state, history)
while not state.is_terminal:
assert_lease(state.run_id, state.lease_epoch)
assert_before_deadline(state.deadline_at)
action = decide_from_recorded_state(state)
state = append_and_reduce(state, proposed(action))
if action.requires_approval:
return persist_interrupt(state, action)
reserve_budget_atomically(state, action.estimated_cost)
key = state.idempotency_key_for(action.action_id)
receipt = effect_store.find(key)
if receipt is None:
try:
receipt = executor.run(action, idempotency_key=key)
except Exception as error:
failure = classify(error)
state = append_and_reduce(state, failed(action, failure))
if retry_policy.allows(failure, state.budget):
schedule_after(retry_policy.delay(state.attempt))
return state
return fail_or_compensate(state, failure)
state = append_and_reduce(state, observed(action, receipt))
store.checkpoint_compare_and_swap(state)
return state
코드보다 중요한 invariant는 다음입니다.
한 action의 모든 retry는 같은 key다.
receipt 없는 timeout은 success도 failure도 아니다.
budget·deadline·approval은 checkpoint에 있다.
state transition은 versioned compare-and-swap이다.
cancel과 compensation은 별개의 event다.
장애 주입으로 검증하기
Happy path만 테스트하면 durable execution을 검증할 수 없습니다.
Crash point matrix
각 경계에서 강제로 process를 죽입니다.
- action proposal 직후
- budget reserve 직후
- request 전송 직후
- target commit 직후, response 전
- receipt 수신 직후, checkpoint 전
- checkpoint 직후
검사할 invariant:
- 최종 effect가 중복되지 않는가?
- lost work가 있는가?
- budget이 이중 차감되거나 복원되는가?
- history와 target receipt가 reconcile되는가?
Retry test
- 429와
Retry-After - 일시적인 503
- malformed input 400
- auth 403
- response loss
- worker heartbeat loss
Non-retryable error가 즉시 멈추고 retryable error만 budget 안에서 backoff하는지 확인합니다.
Cancellation test
- parallel child 두 개 중 하나가 cancel을 놓치는가?
- 이미 commit된 effect에 잘못된 rollback을 시도하는가?
- compensation 자체가 retry될 때 중복되는가?
- cancel 뒤 늦게 도착한 success receipt를 어떻게 reconcile하는가?
흔한 실패 패턴
1. Timeout이면 실패로 기록
상대가 이미 commit했을 수 있습니다.
처방: UNKNOWN 상태와 key 기반 status 조회를 둡니다.
2. Retry마다 새 idempotency key
중복 방지 기능을 스스로 무효화합니다.
처방: action proposal 시 key를 한 번 만들고 checkpoint합니다.
3. Model call만 replay에서 다시 실행
다른 plan이 만들어져 과거 tool receipt와 state가 어긋납니다.
처방: 완료 observation과 decision을 history에 고정합니다.
4. 모든 error를 retry
Schema 오류, 권한 거부, budget 소진은 기다린다고 해결되지 않습니다.
처방: typed error taxonomy와 non-retryable 목록을 둡니다.
5. 개별 max attempts만 설정
병렬 tool이 전체 비용과 latency를 폭발시킵니다.
처방: run-level retry·time·cost budget을 원자적으로 reserve합니다.
6. Cancel을 rollback으로 오해
이미 일어난 외부 effect를 지우지 못합니다.
처방: cancel, reconcile, compensation을 다른 action으로 모델링합니다.
Production 체크리스트
- workflow decision과 external activity를 분리했다.
- append-only event와 versioned checkpoint가 있다.
- 완료된 model/tool observation을 replay 때 재사용한다.
- action마다 stable idempotency key와 input hash가 있다.
- target-side deduplication과 atomic receipt를 지원한다.
- timeout 종류와 deadline을 구분했다.
- retryable/non-retryable/unknown error taxonomy가 있다.
- exponential backoff, jitter,
Retry-After를 적용한다. - attempt·time·cost의 run-level retry budget이 있다.
- 긴 activity는 heartbeat와 progress detail을 기록한다.
- cancel을 child run까지 전파하고 compensation을 분리했다.
- approval interrupt와 action hash를 checkpoint한다.
- lease, fencing token, compare-and-swap으로 중복 worker를 막는다.
- workflow code와 state schema의 replay compatibility를 관리한다.
- crash point별 chaos test와 reconciliation test가 있다.
스스로 확인하기
- 환불 API가 timeout됐을 때 같은 request를 새 key로 다시 보내면 왜 위험한가?
- Event log와 checkpoint는 각각 어떤 문제를 해결하는가?
start-to-close와 run deadline은 어떻게 다른가?- Cancellation과 compensation이 별도 action이어야 하는 이유는 무엇인가?
- 여러분의 Agent에서 process를 강제로 죽여 봐야 할 경계 세 곳은 어디인가?
다음 글에서는 이렇게 기록된 run을 trace·span·event·artifact로 연결해 최초 실패 지점, 비용, latency, 보안 경계를 재현하는 관측성 체계를 만듭니다.