AI 에이전트 평가 하네스: 재시도·멱등성·trace를 함께 검증하기
AI 에이전트 평가 하네스는 좋은 답을 골라내는 채점기보다 넓어야 한다. 운영 환경에서 더 위험한 실패는 답변이 조금 어색한 경우가 아니라, 타임아웃 뒤 같은 변경을 두 번 실행하거나 사람의 승인을 받기 전에 도구를 호출하는 경우이기 때문이다.
이 글은 2026년 8월 5일 macOS와 Python 3.8.2에서 표준 라이브러리만 사용한 인메모리 fixture를 근거로 삼는다. 실제 OpenAI·AWS·Stripe·WordPress API는 호출하지 않았으며, 커밋 뒤 응답 유실을 재현해 재시도 상태 전이, 멱등성, trajectory, trace correlation, 승인 gate를 어떤 계약으로 묶어야 하는지 설명한다.
같은 성공 궤적에서도 중복 변경은 숨을 수 있다
통제 비교의 조건은 간단하다. 고정된 payload, fixture-run-001, trace_fixture_001을 사용하고 첫 시도에서 fake store에 부작용을 기록한 직후 응답만 유실시켰다. 예상 trajectory도 plan, approval_check, effect_call, retry, effect_call, complete로 고정했다. 바꾼 것은 재시도 때의 idempotency key 정책뿐이다.
아래는 새 key를 발급한 대조군, 같은 key를 유지한 실험군, 승인 거절군을 연속 실행한 실제 핵심 출력이다.
$ python3 - <<'PY'
# 인메모리 fake store에 첫 부작용을 기록한 뒤 응답 유실을 주입하고,
# 새 key 대조군·동일 key 실험군·승인 거절군을 차례로 실행한다.
PY
environment={"os": "Darwin", "python": "3.8.2"}
naive_new_key={"exit": 0, "idempotency_invariant": false, "side_effect_count": 2, "states": ["PENDING", "RUNNING", "RETRY_SCHEDULED", "RUNNING", "SUCCEEDED"], "trace_correlated": true, "trajectory": ["plan", "approval_check", "effect_call", "retry", "effect_call", "complete"], "trajectory_score": 1.0}
stable_same_key={"exit": 0, "idempotency_invariant": true, "side_effect_count": 1, "states": ["PENDING", "RUNNING", "RETRY_SCHEDULED", "RUNNING", "SUCCEEDED"], "trace_correlated": true, "trajectory": ["plan", "approval_check", "effect_call", "retry", "effect_call", "complete"], "trajectory_score": 1.0}
approval_denied={"approval_invariant": true, "exit": 0, "side_effect_count": 0, "states": ["PENDING", "WAITING_APPROVAL", "REJECTED"], "trajectory": ["plan", "approval_check", "stop"], "trajectory_score": 1.0}
exit_code=0

두 재시도 실행은 모두 SUCCEEDED로 끝났고 상태 전이와 trajectory가 같았다. trajectory_score=1.0과 trace_correlated=true도 동일했다. 그런데 attempt마다 새 key를 만든 대조군은 부작용이 2회였고, 동일한 논리 변경에 같은 key를 쓴 실험군은 1회였다. 실행 궤적이 기대와 완전히 일치해도 멱등성 불변식은 별도로 검사해야 한다는 뜻이다.
fault가 실제 비정상 종료를 만드는지도 별도 프로세스에서 확인했다. fake store에 부작용 하나를 기록한 다음 TimeoutError를 발생시켰고 종료 코드 1을 관측했다.
$ python3 - <<'PY'
side_effects = ["effect-1"]
raise TimeoutError(f"injected_response_loss_after_commit side_effect_count={len(side_effects)}")
PY
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
TimeoutError: injected_response_loss_after_commit side_effect_count=1
exit_code=1

이 실패는 커밋 전 timeout과 다르다. 호출자는 응답을 받지 못했지만 대상 시스템에는 변경이 남았을 수 있다. 이 경계를 fixture에 적지 않고 단순히 timeout만 주입하면 하네스가 가장 위험한 중복 실행 조건을 놓친다.
Fixture는 입력이 아니라 실행 계약을 선언한다
최소 fixture에는 사용자 입력과 기대 답만이 아니라 fault point, 승인 상태, 상태 전이, 기대 부작용 횟수가 들어가야 한다. 한 건의 fixture가 실행 전체의 계약이 되는 구조다.
fixture_id: response-lost-after-commit
input: 고정된 사용자 요청과 도구 입력
run_id: 논리 실행 식별자
operation: publish_candidate
normalized_payload_hash: sha256:...
approval: approved
fault:
point: after_commit_before_response
attempts: [1]
expected:
terminal_state: SUCCEEDED
retry_count: 1
side_effect_count: 1
same_idempotency_key_across_attempts: true
one_trace_per_run: true
required_steps: [plan, approval_check, effect_call, retry, effect_call, complete]
forbidden_steps_before_approval: [effect_call]
여기서 중요한 필드는 fault.point다. before_commit과 after_commit_before_response는 겉으로는 같은 timeout처럼 보여도 기대 동작이 다르다. 후자에서는 같은 key로 원래 결과를 회수하거나, 대상 API가 멱등성 계약을 제공하지 않는다면 기존 리소스를 조회하는 reconciliation으로 넘어가야 한다.
상태 전이도 명시적으로 판정한다.
PENDING
→ WAITING_APPROVAL → REJECTED
→ RUNNING
→ SUCCEEDED
→ RETRY_SCHEDULED → RUNNING
→ FAILED_PERMANENT
→ RECONCILIATION_REQUIRED
timeout, connection reset, 허용한 429/5xx는 재시도 후보가 될 수 있다. 반면 인증·권한·schema/validation 오류는 입력이나 권한이 바뀌지 않는 한 재시도로 해결되지 않는다. 같은 key에 다른 parameter가 들어오는 경우도 재시도 대상이 아니라 불변식 위반이다. 최대 attempt, backoff, jitter, deadline은 fixture에 고정하고 CI에서는 virtual clock을 쓰면 실제 대기 없이 결정론적으로 검사할 수 있다.

네 식별자는 서로 다른 수명을 가진다
run ID, idempotency key, trace ID, attempt ID를 하나로 합치면 다단계 작업에서 무엇을 재사용하고 무엇을 새로 만들어야 하는지 흐려진다.
| 식별자 | 식별 대상 | 재시도 규칙 | 혼용했을 때의 위험 |
|---|---|---|---|
run_id |
사용자 요청에서 시작한 논리 workflow | 같은 workflow 재개에서는 유지 | 한 run 안의 여러 변경을 같은 작업으로 오인 |
idempotency_key |
특정 변경 의도와 정규화된 parameter | 같은 논리 작업의 retry에서 유지 | 시도마다 새 key를 만들면 중복 변경 발생 |
trace_id |
종단 인과 흐름 | 같은 run에서 전파하고 분리된 흐름은 link/group으로 연결 | retry의 부모·자식 관계 상실 |
attempt_id |
물리 호출 한 번 | 시도마다 증가 | retry 횟수와 중복 호출을 구분하지 못함 |
AWS는 caller-provided request identifier를 이용한 중복 판별과 기록·변경의 원자성을 설명한다. Amazon ECS는 같은 client token과 parameter를 다시 보내면 원래 결과를 반환하지만, parameter가 달라지면 충돌로 처리하며 token TTL도 유한하다고 명시한다. Stripe도 connection error 뒤 같은 idempotency key로 재시도하라고 안내하고, key 재사용 시 parameter를 비교한다. 즉 key의 존재만 확인할 것이 아니라 같은 의도, 같은 parameter, 유효한 보존 기간, 원자적 기록이라는 provider별 계약을 함께 검사해야 한다. (AWS Builders’ Library, Amazon ECS idempotency, Stripe idempotent requests)
논리 변경 단위를 표현하는 key는 tenant 또는 principal + run_id + step_name + normalized_payload_hash처럼 설계할 수 있다. 다만 hash만으로 의도를 추론하면 같은 parameter로 두 리소스를 만들려는 서로 다른 요청을 합칠 수 있다. caller가 부여한 operation identity가 우선이며, 실제 API가 random client token을 요구한다면 최초 token을 durable state에 저장해 retry에서 재사용해야 한다.
Trajectory 점수와 안전 불변식은 합산하지 않는다
OpenAI의 agent evals와 trace grading은 최종 출력만 볼 때보다 실행 과정의 오류 위치를 찾기 쉽게 만든다. LangSmith의 trajectory evaluation은 strict, unordered, subset, superset 비교와 LLM-as-judge를 구분한다. 평가 방식마다 허용하는 경로와 비용, 결정성이 다르므로 하나의 안전 점수로 뭉치지 않는 편이 낫다. (OpenAI agent evals, OpenAI trace grading, LangSmith trajectory evaluations)
- soft score: 최종 답 품질, 도구 선택, 불필요한 step, expected step coverage
- deterministic hard gate: 승인 전 변경 0회, 논리 mutation당 부작용 최대 1회, validation 오류 retry 0회, retry key 동일, 허용 밖 tool 호출 0회, terminal state와 실제 resource 상태 일치
- strict trajectory: 정책 조회 후 승인처럼 순서 자체가 안전 계약인 흐름
- subset·superset 또는 부분 점수: 검색과 병렬 조회처럼 여러 안전한 경로가 가능한 흐름
이번 비교에서는 trajectory가 완전히 같아도 멱등성 결과가 달랐다. 따라서 trajectory grader가 통과했더라도 hard gate 하나가 실패하면 fixture 전체를 실패시켜야 한다. 반대로 안전한 대체 경로 때문에 strict match만 실패했다면 hard invariant와 결과 품질을 확인한 뒤 reference trajectory가 지나치게 경직됐는지 검토할 수 있다.
Trace는 원인을 연결하지만 정확히 한 번을 보장하지 않는다
OpenTelemetry에서 trace는 요청의 전체 경로이고 span은 개별 작업 단위다. trace ID와 parent span ID를 전파하면 여러 process와 service의 작업을 하나의 인과 흐름으로 조립할 수 있다. OpenAI Agents SDK도 workflow를 trace로, agent run·LLM generation·function call·guardrail·handoff를 span으로 기록한다. (OpenTelemetry traces, OpenAI Agents SDK tracing)
하네스에서는 root span에 run ID와 terminal state, attempt span에 step·attempt 번호·retry reason·key fingerprint·approval state, side-effect span에 mutation intent와 deduplicated 여부를 남길 수 있다. grader version과 hard invariant 결과도 span event로 연결하면 실패한 실행을 회귀 fixture로 승격할 때 근거를 보존하기 쉽다.
하지만 trace correlation은 관찰 가능성이지 exactly-once 보장이 아니다. 대조군도 trace_correlated=true였지만 부작용은 2회였다. idempotency key 원문이나 prompt·tool payload를 일괄적으로 trace에 저장해서도 안 된다. generation과 function span에는 입력·출력이 포함될 수 있으므로 인증정보와 개인정보의 수집 여부를 설정하고 필요한 redaction을 적용해야 한다.
사람 승인은 중단과 재개까지 시험한다
OpenAI Agents SDK의 human-in-the-loop 흐름은 승인 필요 tool call에서 실행을 멈추고 interruption을 반환한 뒤, RunState에 approve 또는 reject 결정을 기록해 원래 top-level run을 재개한다. 결정은 특정 tool call ID에 연결되며 serialized state로 장기 대기를 지원한다. (OpenAI Agents SDK HITL)
승인 fixture는 다음 불변식을 분리해 검증해야 한다.
pending이나rejected에서는 외부 부작용이 0회다.- 중단 전후에 같은 run state와 pending call identity가 유지된다.
- 승인 뒤 새 tool call을 만들어 기존 결정을 우회하지 않는다.
- serialize/deserialize와 process restart 뒤에도 결정과 idempotency key가 유지된다.
- 승인된 parameter가 바뀌면 기존 승인을 재사용하지 않고 다시 중단한다.
- retry가 발생해도 같은 논리 tool call의 부작용은 한 번뿐이다.
이번 직접 검증에서 승인 거절은 PENDING → WAITING_APPROVAL → REJECTED로 끝났고 side_effect_count=0, approval_invariant=true였다. 실제 SDK state serialization과 process restart는 실행하지 않았으므로 2~5번의 장기 재개 조건은 공식 문서에 근거한 후속 검증 항목이다. serialized context와 trace payload에도 비밀정보가 들어갈 수 있다는 점을 저장 설계에 반영해야 한다.
회귀 테스트는 fault 행렬과 hard gate로 닫는다
처음부터 모든 외부 시스템을 대상으로 fault를 주입할 필요는 없다. CI에서는 fake clock과 deterministic fake adapter로 빠르게 돌리고, staging에서는 실제 SDK·queue·database·trace backend를 연결한 소수의 통합 fixture를 실행하는 구성이 적합하다. Production fault injection은 별도 승인, 제한된 target, stop condition이 있을 때만 수행한다.
| fixture | 주입 조건 | 기대 상태·동작 | hard gate |
|---|---|---|---|
| happy path | fault 없음, 승인됨 | 한 번 실행 후 성공 | effect 1, retry 0 |
| transient before commit | 첫 호출 전 timeout | 같은 key로 retry | effect 1 |
| response lost after commit | 커밋 뒤 응답 유실 | 원 결과 회수 | effect 1, deduplicated true |
| new key control | attempt마다 새 key | 중복을 검출하고 실패 | effect 2, invariant false |
| validation failure | 잘못된 schema·parameter | 즉시 영구 실패 | retry 0, effect 0 |
| same key different payload | retry에서 parameter 변경 | 충돌 또는 validation 실패 | 추가 effect 0 |
| concurrent duplicate | 같은 key 요청 동시 시작 | 하나만 commit | effect 1 |
| exhausted retries | transient fault 지속 | 최대 attempt 뒤 실패 | attempt 상한 준수 |
| approval pending·rejected | 승인 없음 또는 거절 | 대기 또는 안전 종료 | effect 0 |
| approval resume | 승인 뒤 process restart | 같은 state·call·key로 재개 | effect 1 |
| trace break | retry worker가 context 미전파 | orphan span 검출 | correlation 실패 |
최종 PASS는 평균 점수가 아니라 다음 논리곱으로 판정한다.
output_quality_pass
AND trajectory_policy_pass
AND retry_classification_pass
AND idempotency_invariant_pass
AND approval_invariant_pass
AND trace_correlation_pass
AND terminal_state_matches_observed_resource
답변 품질이 높고 trajectory가 완전히 일치하더라도 side effect가 두 번이면 배포를 보류한다. grader와 fixture에는 version을 붙이고 prompt, tool schema, retry policy, key TTL, approval policy가 바뀔 때 같은 regression suite를 다시 실행해야 한다.
첫 단계로 현재 에이전트의 한 가지 변경 작업에 response lost after commit fixture를 추가해 부작용 횟수와 key 재사용을 확인해 보라. 인메모리 결과를 실제 서비스 보장으로 일반화하지 말고, 다음 단계에서 durable database의 원자성, queue redelivery, 다중 worker race, trace exporter 유실, process restart를 환경별로 검증해야 한다.
참고 링크
- OpenAI — Evaluate agent workflows
- OpenAI — Trace grading
- OpenAI — Evaluation best practices
- OpenAI Agents SDK — Tracing
- OpenAI Agents SDK — Human-in-the-loop
- LangSmith — Trajectory evaluations
- OpenTelemetry — Traces
- AWS Builders’ Library — Making retries safe with idempotent APIs
- Amazon ECS — Ensuring idempotency
- Stripe — Idempotent requests
- AWS — What is AWS Fault Injection Service?