Approval
사람의 역할: 실행 허가/거부
예시: "이 이메일을 고객에게 보낼까요?"분류: Layer 12 - AI 시스템 & LLM 애플리케이션 | 선수지식: L12-60 (Agent), L12-80 (Security), L12-110/120 (State/Durable workflow)
Human-in-the-loop(HITL, 실행 중 사람의 판단을 넣는 구조) AI 워크플로는 LLM(Large Language Model, 대규모 언어 모델)이나 agent가 모든 결정을 자동 실행하지 않고, 위험·불확실성·권한 경계가 있는 지점에서 **사람의 승인(Approval), 수정(Correction), 후보 선택(Selection), 상위 책임자 이관(Escalation)**을 받도록 설계한 실행 구조다. 핵심은 마지막 버튼이 아니라, 사람의 판단 전후로 실행을 멈추고 승인 대상·근거·권한·결과를 이어 붙이는 실행 신뢰 경계다.
완전 자동 agent는 낮은 위험의 반복 작업에서는 유용하지만, 외부 발송·삭제·결제·배포처럼 되돌리기 어려운 action에서는 모델의 confidence만으로 책임 경계를 세울 수 없다. 반대로 초기 HITL 구현처럼 마지막에 Approve / Reject 버튼만 붙이는 방식도 충분하지 않다. 버튼은 결정 입력 UI일 뿐이고, 그 버튼을 누를 수 있는 권한, 승인자가 실제로 본 대상, 수정 뒤 재승인, 대기 만료, 실행 결과 연결까지 보장하지 않는다.
그래서 Approval·Correction·Selection·Escalation은 서로 다른 버튼 집합보다 넓다. 이들은 각각 외부 효과 허가, 제안 내용 변경, 허용 후보 확정, 판단 권한과 책임의 이동을 뜻한다. 어느 개입이든 durable state(프로세스가 재시작돼도 복원되는 상태)를 바꾸며, 이후 실행이 참조할 결정 기록을 새로 만든다. 수정된 내용을 재승인하지 않거나, 에스컬레이션 뒤 이전 담당자의 권한으로 resume하면 사람을 넣었어도 신뢰 경계는 끊긴다.
LangGraph의 interrupt는 graph 실행 중 특정 지점에서 멈추고 persistence로 상태를 보존한 뒤, Command(resume=...)로 이어가는 패턴을 제공한다. 이때 resume은 interrupt 다음 줄에서 단순히 계속되는 것이 아니라 interrupt가 발생한 node를 처음부터 다시 실행한다. 따라서 node 안에서 interrupt보다 앞에 있는 코드는 다시 실행될 수 있다. 이메일 발송·결제 같은 side effect는 가능한 한 interrupt 뒤로 옮기고, 앞에 둘 수밖에 없다면 재실행돼도 결과가 하나로 수렴하도록 idempotent하게 만들어야 한다. 뒤의 adapter crash/retry 예제와 Silent Failure 절에서 이 경계를 다시 확인한다. 출처: LangGraph human-in-the-loop interrupts. Temporal은 실행 중인 Workflow에 쓰기 메시지를 전달하는 Signal과 Update를 제공한다. Signal은 비동기 쓰기 요청이고, Update는 결과나 오류를 기다릴 수 있는 추적 가능한 동기 쓰기 요청이다. 출처: Temporal Workflow message passing.
둘은 자동 연동되지 않는다. LangGraph interrupt를 Temporal Signal/Update가 알아서 resume하는 것이 아니라 application adapter가 둘을 연결한다. 이때 canonical payload는 schema에 따라 같은 의미를 항상 같은 bytes로 직렬화한 승인 대상이고, effective intent는 그 payload에 tenant·실행 주체/권한·action·resource·schema/policy version을 더한 실제 실행 의미다. **idempotency key(멱등성 키)**는 같은 business operation의 재시도를 식별해 하나의 결과에 연결하는 값이다. 한 프레임워크만 쓸 수도 있고 둘을 함께 쓸 수도 있지만, 어느 경우든 승인 대상 불변성, 권한 재검증, evidence 보존, message-to-resume 상관관계는 애플리케이션 계약이다.
사람의 역할: 실행 허가/거부
예시: "이 이메일을 고객에게 보낼까요?"사람의 역할: 모델 산출물 수정
예시: 요약 문장 직접 편집사람의 역할: 여러 후보 중 선택
예시: 검색 결과/계획 후보 선택사람의 역할: 더 높은 권한자에게 전달
예시: 환불 금액이 기준 초과실무 HITL은 modal 하나가 아니라 승인 큐로 운영된다. agent가 멈춘 workflow를 queue item으로 만들고, reviewer가 SLA(Service Level Agreement, 검토 기한에 대한 운영 약속) 안에 승인·수정·거절·에스컬레이션한다.
[Agent workflow] ↓ risk/confidence/policy decision[Approval Queue] ↓ assign reviewer + deadline + context package[Review UI] ↓ approve / edit / reject / escalate[Resume workflow] ↓ canonical payload + effective intent 재계산 비교[Execution result + audit log + gold dataset candidate]queue item에는 최소 필드가 필요하다.
| 필드 | 의미 |
|---|---|
workflow_id | resume할 durable workflow 식별자 |
risk_level | low/medium/high 또는 점수. SLA·권한·escalation 기준 |
approval_payload_ref | 승인자가 보는 canonical approval payload의 덮어쓰지 않는 durable artifact 참조 |
approval_payload_version | edit마다 증가하는 승인 대상 버전. 결정은 정확히 한 버전에만 유효 |
payload_hash | 정규화된 payload bytes의 digest(고정 길이 요약값). ref/version 비교를 보조하지만 단독 불변성 보장은 아님 |
effective_intent | tenant·요청 주체/권한·action·resource·schema/policy version을 묶은 실제 실행 의미 |
resource_version, precondition | 승인자가 본 mutable resource 버전과 실행 시 conditional write 조건 |
generation_provenance | 조립된 system/user input, sampling, tool/schema, provider route, response까지 잇는 생성 계보 |
context_summary | reviewer가 볼 결정 요약. 승인 근거 artifact의 대체물이 아님 |
evidence_artifacts[] | artifact_ref가 필수인 근거 묶음. 재현할 version과 digest를 함께 보존 |
trace_id | 진단용 trace와 연결하는 선택적 join key. 승인 근거 자체가 아님 |
deadline_at, expires_at | 검토 SLA 시각과 승인 후 설정하는 decision 효력 만료 시각 |
assignee_policy | owner, on-call, domain expert, manager 등 배정 규칙 |
review_event, decision_state | edit를 포함한 검토 event와 pending/approved/rejected/escalated/expired 최종 상태 |
calibration_outcome | reviewer 간 판정 일치도·품질 샘플링에 쓰는 후처리 라벨 |
운영 규칙:
risk_level, deadline_at, customer_tier, action_type으로 정렬한다. 단순 FIFO(First In, First Out, 먼저 들어온 항목부터 처리)면 고위험·SLA 임박 요청이 뒤로 밀린다.approval_payload_version을 올린 뒤 다시 waiting_for_review로 보낸다. 새 버전에 대한 명시적 승인 전에는 resume할 수 없다.payload_hash만으로는 승인 불변성이 부족한가hash는 어떤 bytes가 같은지는 말해도 그 bytes가 무엇을 뜻하는지까지 말하지 않는다. JSON key 순서, 기본값 채움, Unicode 정규화가 다르면 같은 의미가 다른 hash가 될 수 있고, 반대로 이메일 본문 bytes가 같아도 tenant나 수신자 resource, 실행 주체의 권한, 적용 정책 버전이 달라지면 실행 의미는 달라진다. payload_hash가 같다는 이유만으로 오래된 승인이나 다른 tenant의 승인을 재사용하면 안 된다.
승인 불변성은 다음 계약을 함께 만족할 때 성립한다.
approval_payload_ref와 approval_payload_version으로 고정한다.tenant_id, 요청 주체와 필요한 권한, action, 대상 resource, schema_version, policy_version을 승인 대상에 포함한다. “무엇을 누구 권한으로 어디에 실행하는가”를 고정하는 값이다.resource_version과 If-Match·row version 같은 precondition을 intent에 넣는다. 승인 뒤 대상이 바뀌면 같은 action 이름과 payload라도 같은 실행 의미가 아니다.expires_at이 지난 결정은 hash가 같아도 stale approval이다. 새 queue item과 현재 정책으로 다시 판단한다.반례를 보자. {"subject":"점검 안내","body":"..."}의 hash가 그대로여도 tenant A에서 만든 승인을 tenant B의 고객 목록 발송에 붙이면 같은 action이 아니다. 또 policy_version=7에서 허용된 발송이 version 8에서 법무 검토 대상으로 바뀌었다면 payload bytes가 같아도 이전 승인을 실행 근거로 쓸 수 없다.
모든 step을 사람에게 묻는 것은 자동화 실패다. gate는 risk에 따라 둔다. 아래 threshold는 제품에 그대로 복사할 보편 규칙이 아니라, 팀이 정책을 코드로 표현하는 모양을 보여주는 가상 정책 예시다.
if action.risk == "low" and confidence >= 0.9: auto_executeelif action.risk == "medium": ask_reviewerelse: require_owner_approvalrisk는 비용, irreversible(되돌리기 어려운) 여부, 개인정보, 외부 노출, 법적 영향으로 계산한다. 모델 confidence는 입력 신호 중 하나일 수 있지만 잘 보정되지 않은 confidence 하나로 auto-execute를 허용하지 않는다. action의 권한과 피해 범위처럼 모델 밖에서 검증 가능한 조건을 우선한다.
HITL gate는 사람이 바쁘면 곧바로 production bottleneck이 된다. 그래서 queue item마다 SLA와 escalation 정책을 먼저 정의한다. 아래 시간은 위험도별 정답이 아니라 가상 고객지원 시스템의 정책 예시다. 실제 값은 업무 시간, 규제, 되돌리기 가능성, 안전한 fallback 유무를 측정해 정한다.
| 위험도 | 예시 | 기본 SLA | 만료 시 동작 |
|---|---|---|---|
| Low | 내부 요약 수정, 태그 추천 | 24h | 자동 만료 또는 batch review로 이동 |
| Medium | 고객에게 보낼 초안, 환불 제안 | 4h | 팀 owner에게 escalate, agent는 대기 유지 |
| High | 결제·삭제·배포·법률 답변 | 30~60m | on-call/manager에게 escalate, 자동 실행 금지 |
| Critical | 보안 사고 대응, 대량 발송 | 15m 이하 | incident 채널 호출, 변경 freeze, 수동 runbook |
escalation은 단순 알림이 아니라 권한과 책임의 이동이다.
SLA는 시간표만 정해서 지켜지지 않는다. 가상으로 medium-risk 요청이 시간당 24건 들어오고 한 건의 실제 검토가 평균 6분이면 reviewer 한 명의 이론 처리량은 시간당 10건이다. 회의·휴식·에스컬레이션을 제외한 가용률을 70%로 잡으면 유효 처리량은 시간당 7건이므로, 4명은 28건/시간으로 유입 24건/시간을 감당하지만 이용률이 약 86%라 burst에 민감하다. 3명은 21건/시간이라 매시간 3건씩 backlog가 늘고 4시간 뒤 12건이 쌓인다. 그러나 backlog 12건의 단순 평균 대기시간은 12 / 21 ≈ 0.57시간, 약 34분이므로 12건이라는 숫자만으로 4시간 SLA 위험을 결론 내릴 수는 없다. 이 계산은 요청과 처리량이 시간 내내 균일하고 모든 item의 우선순위와 검토시간이 같다고 놓은 결정론적 평균 모델이다. 핵심 위험은 유입 24건/시간이 처리 21건/시간을 지속해서 넘으면 backlog와 대기시간이 끝없이 증가해 결국 SLA를 깨뜨린다는 점이다. 실제 queue에서는 burst, 검토시간 분산, 고위험 item 우선 처리, reviewer 교대가 tail latency를 더 악화시킬 수 있다. 따라서 SLA, 평균과 상위 백분위 검토시간, 시간대별 유입량, reviewer 유효 처리량을 함께 측정하고 인원·우선순위·저위험 자동화 범위를 정해야 한다.
HITL에는 최소 세 종류의 durable record가 필요하다. workflow의 중단 상태, 버전이 고정된 canonical approval payload, reviewer decision이다. evidence도 trace 안에만 두지 않고 독립 artifact로 보존한다.
{ "workflow_id": "wf_123", "blocked_state": "waiting_for_review", "approval_payload_ref": "artifact://approvals/wf_123/payload/v2", "approval_payload_version": 2, "payload_hash": "sha256:...", "effective_intent": { "tenant_id": "tenant_7", "principal_id": "support_agent_4", "required_permission": "customer_email.send", "action": "send_email", "resource": "customer:42/email:primary", "resource_version": 41, "precondition": "email_revision == 41", "schema_version": 3, "policy_version": 8 }, "evidence_artifacts": [ { "artifact_ref": "artifact://tickets/ticket_7/snapshot/19", "artifact_version": 19, "digest": "sha256:..." } ], "trace_id": "trace_diag_9", "deadline_at": "2026-07-11T05:00:00Z", "expires_at": "2026-07-11T06:00:00Z"}artifact_ref는 나중에도 승인자가 본 근거를 같은 version으로 읽을 수 있는 durable reference다. object storage의 versioned object, append-only document revision, immutable database row가 될 수 있다. trace_id는 해당 실행의 span을 찾는 진단용 join key다. trace는 sampling으로 빠질 수 있고 redaction으로 핵심 값이 제거될 수 있으므로, sampled/redacted trace만 남기고 이를 유일한 승인 근거로 삼으면 안 된다.
첫 독서에서는 blocked_state, 승인 대상의 ref/version, 실제 실행 의미인 effective_intent, 판단 근거인 evidence_artifacts, 검토·효력 기한인 deadline_at과 expires_at의 연결을 먼저 잡으면 된다. 모델이 어떤 조건에서 초안을 만들었는지까지 추적해야 하는 시스템은 아래 생성 계보를 같은 decision과 audit event에 연결한다.
{ "generation_provenance": { "model_id": "provider/model-family", "model_version": "model-snapshot-2026-06", "assembled_system_input": { "artifact_ref": "artifact://generations/gen_52/system/v1", "artifact_version": 1, "digest": "sha256:..." }, "assembled_user_input": { "artifact_ref": "artifact://generations/gen_52/user/v1", "artifact_version": 1, "digest": "sha256:..." }, "sampling": { "temperature": 0.2, "top_p": 0.95, "seed": null }, "prompt_template_ref": "artifact://prompts/support-email/v12", "prompt_template_version": 12, "tool_versions": { "ticket_lookup": "4.1.0" }, "tool_schema_version": 7, "provider_route": "provider-a", "deployment_route": "apne2/support-primary", "provider_response_id": "resp_01J...", "retrieval_artifact_refs": [ { "artifact_ref": "artifact://tickets/ticket_7/snapshot/19", "artifact_version": 19, "digest": "sha256:..." } ] }}generation provenance는 재현 추적과 출력 재생을 구분한다. 위 필드들은 어떤 입력·설정·도구·route에서 응답이 나왔는지 추적하고 원인을 비교하게 해 주지만, 같은 값을 다시 호출해도 비결정적인 provider가 같은 token 출력을 돌려준다고 보장하지 않는다. 정확한 출력 재생은 모델을 재호출하는 것이 아니라 저장한 response artifact와 provider_response_id를 읽어야 보장된다.
resume 시에는 같은 workflow_id만 확인하지 않는다. decision이 가리킨 approval_payload_ref/version을 읽어 canonical payload와 effective intent를 재계산하고, hash·권한·policy version·expiry를 모두 비교한다. mutable resource에는 승인 시점 resource_version을 precondition으로 건 conditional write를 사용한다. 실행 시 version이 달라졌다면 TOCTOU(Time Of Check To Time Of Use, 확인과 사용 사이에 상태가 바뀌는 경쟁 조건)로 보고 외부 effect를 만들지 않은 채 reapproval_required로 전환한다. 실행 adapter는 검증을 통과한 canonical artifact에서만 tool argument를 만들며, UI가 보낸 임의 payload를 곧바로 실행하지 않는다.
감사 로그에는 “승인했다”만으로 부족하다. 감사 기록은 UI 요약이나 sampled trace를 복사한 것이 아니라, 승인 대상과 근거 artifact, 결정, 실제 실행 결과를 잇는 append-only event여야 한다.
who: reviewer id / role / delegated authoritywhen: timestamp / decision expirywhat: approval payload ref / version / hash / effective intentwhy: reason / comment / policy versionevidence: durable artifact refs / versions / digestsdiagnostic_join: optional trace idresult: execution artifact ref / executed / canceled / failedpayload_hash는 canonical bytes의 변경 탐지를 돕는 안전장치다. 단독으로 승인 불변성을 증명하지 않으므로 audit log에는 canonical ref/version과 effective intent를 함께 남긴다.
추가로 운영 audit log에는 decision context와 queue lifecycle이 남아야 한다.
queue: created_at, assigned_at, payload_edited_at, deadline_at, escalated_at, resolved_atreview_event: viewed / edited / approve_requested / reject_requested / escalate_requesteddecision: decision_id, approved / rejected / escalated / expired, expires_atapproval_target: approval_payload_ref, approval_payload_version, payload_hash, effective_intentevidence: artifact_ref, artifact_version, digest; diagnostic trace_id(optional)generation_provenance: model/version, assembled system/user input refs, sampling, prompt/template, retrieval, tool/schema versions, provider/deployment route, response idreviewer_context: reviewer_id, role, delegation_chain, conflict_of_interest flagexecution: canonical_intent_digest, idempotency_scope/key, claim_status, execution_artifact_ref, result, rollback_reference감사 로그 설계 원칙:
workflow_id·decision_id·approval_payload_ref/version·idempotency_key로 join되어야 한다. execution span은 선택적 진단 연결이고, 실행 결과 artifact가 정본이다.두 runtime을 함께 쓸 때 이 문서는 다음처럼 좁은 adapter 경계를 택한다. Temporal 자체가 handler에서 Activity 실행을 금지해서가 아니라, 승인 message 처리와 graph resume의 소유권을 섞지 않기 위한 설계 선택이다.
decision_id와 approval_payload_version만 Temporal Signal/Update로 전달한다.resume_langgraph Activity를 예약한다. Activity 예약·재시도·완료는 Temporal Event History에 남는다.Command(resume=...)로 LangGraph를 이어간다. Activity는 새 checkpoint/result ref를 반환한다.정본(source of truth)도 역할별로 나눈다. Temporal Event History는 message 수신과 Activity 예약·재시도·결과 같은 orchestration의 정본이고, LangGraph checkpoint는 graph node/state와 interrupt/resume 진행의 정본이다. payload와 evidence 원문은 durable artifact store의 정본이다. Temporal history에 LangGraph 전체 state를 복제하거나 LangGraph checkpoint를 Temporal retry 상태의 정본으로 삼지 않는다.
Temporal Activity는 LangGraph resume을 성공시킨 직후 worker가 죽어 그 결과를 Temporal에 반환하지 못하면 재시도될 수 있다. 이때 Command(resume=...)를 다시 호출하지 않으려면 Activity attempt 번호가 아니라 (langgraph_thread_id, expected_checkpoint_id, interrupt_id, decision_id, approval_payload_version)을 resume request identity로 사용한다.
짧은 crash/retry 예시를 보자.
(thread-7, cp-41, int-9, decision-8, v2)를 claim하고 LangGraph를 resume한다.cp-42에 consumed_resume_contract_key=(thread-7, cp-41, int-9, decision-8, v2)와 result_ref=artifact://graph-runs/thread-7/cp-42를 저장한다.claim_or_get은 같은 binding과 cp-42의 소비 marker를 찾고 기존 result_ref를 반환한다. B는 Command(resume=...)를 두 번째로 호출하지 않는다.첫 독서의 결론은 adapter가 승인 message와 graph resume을 연결하되, retry가 같은 interrupt를 새 작업처럼 소비하지 않도록 기존 checkpoint/result를 먼저 찾는다는 것이다. interrupt 이전 코드의 재실행과 Activity 재시도가 겹칠 수 있으므로 side effect는 interrupt 뒤에 두거나 멱등하게 만든다. 아래 선택 심화는 여러 worker와 외부 provider까지 포함해 이 원칙을 구현하는 세부 프로토콜이며, 첫 독해에서는 건너뛰어도 된다.
이 선택 심화에서 사용하는 네 용어를 먼저 정의한다.
operation_id와 scope/key·intent의 결합을 최소 정보로 남기는 만료 기록이다. 오래된 key가 새 business operation에 다시 연결되는 것을 막는다.expected_checkpoint_id는 reviewer가 본 interrupt를 포함한 checkpoint이고, interrupt_id는 그 checkpoint 안에서 소비할 정확한 중단점이다. 병렬 interrupt가 없다면 구현에 따라 둘을 하나의 expected checkpoint/interrupt id로 표현할 수 있지만, 어떤 중단점을 소비하는지는 반드시 고정해야 한다. decision_id와 approval_payload_version은 같은 thread라도 다른 사람 판단이나 수정 전 payload가 resume 권한을 재사용하지 못하게 한다.
resume_contract_key = ( langgraph_thread_id, expected_checkpoint_id, interrupt_id, decision_id, approval_payload_version)resume gateway의 claim_or_get은 다음 계약을 한 저장 권한 아래에서 수행한다.
UNIQUE( langgraph_thread_id, expected_checkpoint_id, interrupt_id, decision_id, approval_payload_version)UNIQUE(langgraph_thread_id, expected_checkpoint_id, interrupt_id) # consumption slot
1. interrupt consumption binding을 조회한다.2. binding이 없으면 현재 checkpoint == expected_checkpoint_id이고 interrupt_id가 pending인지 확인한다.3. 확인과 같은 atomic conditional write에서 전체 resume_contract_key, decision/payload digest, lease, fencing_token을 bind한다.4. 같은 key가 이미 result_checkpoint_id/result_ref를 가지면 그 결과를 반환한다.5. 같은 key가 claimed 상태라면 LangGraph checkpoint metadata에서 consumed_resume_contract_key를 조회한다. - 같은 key로 checkpoint가 전진함: 기존 result를 복원·기록하고 반환 - interrupt가 여전히 pending이고 lease가 만료됨: token을 높여 takeover - 어느 쪽인지 판정 불가: resume_unknown으로 두고 재호출 금지6. 같은 interrupt slot이 다른 decision/version/digest에 bind되어 있으면 conflict로 거절한다.별도 resume ledger에서 claim만 하고 LangGraph를 무조건 다시 호출하면 3번과 5번 사이의 crash gap이 남는다. 따라서 application adapter는 LangGraph checkpointer 쪽에 consumed_resume_contract_key, fencing token, result_checkpoint_id/result_ref를 남기고, interrupt 소비와 checkpoint write에 compare-and-set 또는 같은 transaction 수준의 조건부 쓰기를 적용해야 한다. checkpointer와 resume ledger를 같은 transaction에 둘 수 없다면 두 저장소를 정확히 한 번이라고 부르면 안 된다. retry는 먼저 둘을 조회해 reconcile하고, 기존 소비를 확인할 수 없을 때는 resume_unknown에서 멈춰야 한다.
반대로 Activity attempt가 LangGraph checkpoint를 쓰기 전에 죽었다면, pending interrupt와 lease 만료가 모두 확인된 뒤 다음 attempt가 더 큰 fencing token으로 takeover한다. 이 계약의 권위 경계는 명확하다. Temporal Event History는 Activity를 다시 시도할지 결정하고, LangGraph checkpointer와 resume binding은 해당 interrupt가 이미 소비됐는지 판정하며, 외부 이메일·결제 effect는 뒤의 execution ledger·outbox·provider 조회가 판정한다. 그러므로 보장 목표는 end-to-end exactly-once가 아니라 중복 resume을 조회·조정 가능한 하나의 결과에 수렴시키고, 불명 상태에서는 새 effect를 만들지 않는 것이다.
HITL 구현 산출물은 상태 머신으로 남겨야 한다. 버튼 이름이 아니라 어떤 상태에서 어떤 이벤트가 허용되는지가 계약이다.
| 현재 상태 | 이벤트 | 다음 상태 | 검증/부수 효과 |
|---|---|---|---|
queued | assignee 결정 | assigned | reviewer role·conflict-of-interest 확인 |
assigned | reviewer가 열람 | waiting_for_review | approval/evidence ref/version과 durable generation provenance 기록 |
waiting_for_review | approve | approved | canonical payload·effective intent·resource precondition 재계산 비교, reviewer 권한 확인, expires_at 고정 |
waiting_for_review | edit | waiting_for_review | 새 artifact/version/hash와 diff 생성, 이전 decision 무효화, 새 버전 재승인 요구 |
waiting_for_review | reject | rejected | 실행 금지, 거절 사유 필수 |
waiting_for_review | escalate | escalated | assignee policy·필요 권한 재평가, deadline 재설정 |
waiting_for_review | deadline 초과 | escalated / expired | 정책에 따라 상위 권한자로 이관하거나 만료 후 safe fallback·취소. 자동 승인 금지 |
escalated | 새 assignee 결정 | assigned | 새 reviewer 권한·conflict-of-interest 확인, deadline 재설정 |
approved | LangGraph resume claim/get | resume_claimed / graph_resumed | expected checkpoint/interrupt와 decision/payload version을 원자 bind. 기존 result면 graph_resumed 반환 |
resume_claimed | checkpoint advance/result 기록 | graph_resumed | 같은 resume contract key와 최신 fencing token으로 interrupt 소비 marker·result checkpoint/ref 기록 |
graph_resumed | 외부 effect claim | execution_claimed | scoped key/intent에 대한 동시 실행 권한만 원자 획득하고 lease_owner, lease_expires_at, fencing_token 기록 |
execution_claimed | heartbeat | execution_claimed | 현재 fencing token의 owner만 lease 연장. 만료된 owner의 write·outbox dispatch는 거절 |
execution_claimed | stale claim takeover | execution_claimed | lease 만료 확인 후 새 owner가 claim하고 fencing token 증가. 이전 worker의 늦은 결과는 거절 |
execution_claimed | 같은 scope/key 재요청 | execution_claimed / executed | 같은 digest면 기존 in-progress/result 반환, 다른 payload·intent digest면 409 IdempotencyConflict로 거절 |
execution_claimed | resource precondition 불일치 | reapproval_required | resource version 검사와 outbox reservation을 같은 DB conditional write transaction으로 수행. 0건이면 재승인 |
reapproval_required | 새 payload version 생성 | waiting_for_review | 이전 decision·execution claim은 재사용하지 않고 새 version을 새 decision/key로 승인받음 |
execution_claimed | tool 실행 성공/명시적 실패 | executed / failed | provider 응답과 durable execution artifact를 audit에 연결 |
execution_claimed | provider timeout/연결 단절 | execution_unknown | 실패로 단정하지 않는다. provider에서 effect가 발생했지만 응답만 유실됐을 수 있으므로 즉시 새 key로 retry 금지 |
execution_unknown | reconcile | executed / retry_ready | response ID·operation key로 provider 상태 조회. effect 확인 시 결과 저장, 미발생이 확인된 경우만 같은 operation으로 retry |
retry_ready | retry | execution_claimed | 같은 operation/key로 새 lease와 더 큰 fencing token을 얻은 뒤 재시도. 새 business operation으로 rebinding 금지 |
idempotency key는 문자열일 뿐이다. worker A와 B가 동시에 key가 없다고 읽은 뒤 각각 provider를 호출하는 check-then-act 구조라면, 같은 key를 보냈어도 이메일은 두 번 발송될 수 있다. atomic claim이 보장하는 범위는 같은 시점에 실행 권한을 얻는 worker가 하나라는 것뿐이다. provider effect의 exactly-once, worker crash 뒤 복구, timeout 결과 판정까지 보장하지 않는다.
canonical_effective_intent_digest는 승인 payload ref/version/hash와 tenant, principal/permission, action, resource id/version, schema/policy version을 정규화한 뒤 만든다. execution ledger의 기본 계약은 다음과 같다.
UNIQUE(idempotency_scope, idempotency_key)
1. scope + key + canonical_effective_intent_digest를 execution_claim에 INSERT2. conflict면 저장된 digest를 비교 - 같음: 기존 in-progress 상태 또는 result_ref 반환 - 다름: 409 IdempotencyConflict, 실행 금지3. 내부 DB effect면 같은 transaction에서 resource_version precondition, business mutation, result_ref를 commit4. 외부 provider effect면 lease owner/expiry와 fencing token을 발급하고 outbox reservation으로 넘김외부 provider 호출은 로컬 DB transaction에 함께 commit할 수 없다. 이때 outbox는 DB transaction에서 발송할 operation을 먼저 기록하고 별도 dispatcher가 provider로 전달하는 테이블/큐다. 애플리케이션은 승인된 resource_version을 확인하는 conditional write와 immutable outbox reservation 생성을 같은 DB transaction으로 묶는다. 예를 들어 UPDATE customer_email SET reserved_by=:operation_id WHERE id=:id AND version=41 AND reserved_by IS NULL이 1건일 때만 같은 transaction에서 outbox row를 INSERT한다. 0건이면 commit하지 않고 reapproval_required로 간다. 이렇게 해야 version 확인 직후 resource가 바뀌고도 오래된 argument가 outbox에 들어가는 창이 닫힌다.
claim이 오래 걸리면 owner는 heartbeat로 lease를 연장한다. lease가 만료된 claim만 다른 worker가 takeover할 수 있고, takeover 때 fencing token을 증가시킨다. outbox dispatcher와 result write는 최신 token만 받아 늦게 깨어난 이전 worker를 차단한다. 그래도 provider가 같은 scope/key와 같은 요청은 동일 결과를 반환하고, 같은 key와 다른 payload는 거절하며, 약속된 retention 동안 concurrent request도 하나의 effect로 합치는 idempotency 계약을 제공해야 한다. provider timeout은 실패가 아니라 결과 불명일 수 있으므로 execution_unknown으로 두고 response ID나 operation key로 reconcile한다. effect가 없다고 확인된 뒤에만 같은 operation/key로 retry_ready에서 재시도한다. provider 계약이나 조회 API가 없다면 ledger와 fencing이 동시 호출은 줄여도 provider 성공 직후 응답 유실을 정확히 한 번으로 만들 수는 없다.
scope는 최소 environment + tenant_id + provider/action처럼 서로 다른 보안·실행 영역이 key를 공유하지 않게 정한다. retention은 client retry, workflow replay, delayed queue redelivery가 가능한 최대 기간보다 길어야 하고 provider의 보장 기간과 맞춰야 한다. active dedup row를 정리해도 operation_id ↔ scope/key ↔ canonical intent digest binding tombstone을 남기거나, 만료 operation의 scope/key를 조회해 거절하는 별도 인덱스를 유지해야 한다. 과거 key가 새 business operation에 binding되면 안 되며, 만료된 operation의 재요청은 410 OperationExpired처럼 거절하고 새 operation ID와 key를 발급한다. audit용 intent digest와 result ref 보존만으로는 이 재사용 거절 계약을 대신할 수 없다.
가상의 고객지원 정책에서 send_email은 medium risk이고 4시간 안에 검토해야 한다고 하자. 이 수치와 분류는 설명용이며, 실제 제품의 보편 SLA가 아니다.
artifact://tickets/t-7/snapshot/19를 근거로 이메일 초안을 만든다. workflow는 수신자·제목·본문뿐 아니라 tenant_id, customer_email.send 권한, customer resource와 resource_version=41, schema/policy version을 canonical payload v1에 저장하고 waiting_for_review에서 멈춘다. queue에는 model ID/version, prompt template v12의 durable ref, retrieval artifact ref를 generation_provenance로 함께 남긴다.artifact://approvals/wf-123/payload/v2를 만든다. queue가 가리키는 승인 대상은 v2로 바뀌고 v1에 대한 미완료 decision은 무효화되며, v2가 다시 waiting_for_review가 된다. edit 자체를 승인으로 간주하지 않는다.expires_at을 고정한다. 화면용 redacted trace가 없어도 durable artifact로 판단을 재현할 수 있어야 한다.resume_langgraph Activity는 (thread-123, cp-7, int-2, decision-8, v2)를 claim_or_get한다. 첫 attempt가 LangGraph의 새 checkpoint cp-8을 저장한 뒤 Temporal 결과 기록 전에 죽어도, retry는 cp-8의 소비 marker와 result ref를 반환하며 두 번째 Command(resume=...)를 호출하지 않는다.prod:tenant-7:send_email scope에서 operation key와 v2 canonical effective intent digest를 UNIQUE(scope, key)로 claim한다. 이어서 customer email version 41에 대한 conditional reservation과 승인된 수신자 argument의 immutable outbox INSERT를 같은 DB transaction으로 수행한다. version이 42이거나 이미 예약됐다면 둘 다 commit하지 않고 reapproval_required로 전환해 payload v3를 다시 승인받는다. graph resume 중복 방지와 provider effect 중복 방지는 서로 다른 권위 경계이며, 단순 재조회 뒤 outbox를 별도 저장하지 않으므로 확인과 사용 사이의 창이 닫힌다.artifact://executions/wf-123/send/1에 저장한다. audit event는 workflow_id, decision_id, approval payload v2, canonical intent digest, idempotency scope/key, generation provenance, execution artifact를 연결한다. 따라서 “어떤 생성 조건과 근거로 무엇을 만들고, 누가 어느 resource version을 승인했으며, 실제로 무엇이 발송됐는가”를 한 경로로 재구성할 수 있다.이 예제의 경계는 사람의 존재가 아니라 version이 고정된 판단과 실제 외부 효과의 연결이다. reviewer가 v1을 봤는데 v2가 발송되거나, 두 번째 resume이 새 발송을 만들거나, deadline 뒤 묵시적으로 승인되면 HITL UI가 있어도 실행 신뢰 경계는 실패한다.
사람에게 raw trace 전체를 던지면 안 된다. HITL UI는 다음을 보여준다.
reviewer 화면은 요청 요약 → 승인 payload 버전 → 근거 → 위험/정책 → 수정 가능 필드 → 결정 순서로 구성한다. 금지 필드는 raw prompt, 전체 trace dump, secret이 포함될 수 있는 tool argument 원문, 다른 reviewer의 사전 판단이다. 이런 필드는 판단 오염과 PII(Personally Identifiable Information, 개인 식별 정보) 장기 보관을 만들 수 있다. UI에는 허용된 view를 보여주되 결정 API는 durable approval_payload_ref/version과 필수 evidence artifact_ref/version/digest를 받는다. trace_id와 redacted diff는 진단 보조 정보다.
| Endpoint | 요청/응답 계약 | 실패 시 처리 |
|---|---|---|
GET /approval-items/:id | redacted view, payload ref/version/hash, effective intent, evidence artifact refs, expiry | 권한 없음·만료 item은 decision button 비활성화 |
POST /approval-items/:id/decision | decision, reason, payload ref/version; edit이면 새 canonical artifact 생성 | stale version·intent 불일치는 409 Conflict, edit은 새 버전 재검토로 전환 |
POST /workflows/:workflow_id/resume | thread id, expected checkpoint/interrupt id, decision id, payload version | resume key를 원자 bind하고 기존 checkpoint/result를 우선 반환. 다른 decision/version은 409, 판정 불가는 resume_unknown |
HITL 결과는 평가 시스템으로 되돌아간다.
human correction -> gold dataset candidate -> prompt/retrieval failure label -> regression eval -> gate threshold 조정사람 개입을 비용으로만 보지 말고 데이터 수집 지점으로 설계한다. 다만 모든 수정이 곧바로 정답은 아니다. correction과 결정 근거를 검수하고, 합의된 policy/rubric version과 함께 versioned gold dataset에 편입해야 한다.
Labeling은 답변 품질 점수, 오류 유형, 선호 쌍처럼 Feedback Loop에서 평가·학습 데이터를 수집하는 개입 유형이다. Approval·Correction·Selection·Escalation처럼 현재 workflow의 외부 effect를 허가하거나 막는 실행 gate로 분류하지 않는다. 같은 UI에서 승인과 label을 함께 받을 수는 있지만, approved decision과 quality_label event를 분리해야 label 수정이 실행 권한을 바꾸거나 승인 기록을 덮어쓰지 않는다.
reviewer도 하나의 평가 시스템이다. 사람마다 승인 기준이 다르면 HITL은 안전장치가 아니라 랜덤 gate가 된다. calibration은 reviewer 판정을 일관되게 만드는 운영 절차다. **rubber-stamp(고무도장을 찍듯 내용을 충분히 검토하지 않고 관성적으로 승인하는 행위)**가 반복되면 사람을 둔 것만으로는 안전 경계가 되지 않는다.
| Calibration 항목 | 측정 신호 | 조치 |
|---|---|---|
| Inter-reviewer agreement | 같은 샘플에 대한 reviewer 간 일치율 | rubric 보강, 예시 추가, 교육 |
| Override rate | 승인 후 agent 실행을 사람이 다시 되돌린 비율 | risk 기준 상향, reviewer 권한 재검토 |
| Rubber-stamp rate | 검토 시간이 비정상적으로 짧고 승인율이 높음 | batch 기준 재조정, 샘플 감사 확대 |
| Escalation accuracy | escalate된 케이스가 실제 high-risk였는지 | escalation threshold 조정 |
| Drift in decisions | 같은 유형의 승인/거절 비율이 시간에 따라 변함 | gold dataset 재평가, policy versioning |
고정 샘플의 크기나 dual review 비율에는 보편 정답이 없다. 예를 들어 한 팀이 50~200개 calibration sample로 시작할 수 있지만, 이는 가상 운영 예시일 뿐이다. 희귀한 high-risk 사례가 충분히 포함되는지, 불일치율이 안정적으로 보이는지에 따라 늘리거나 층화해야 한다. 사람이 수정했다는 사실만으로 gold label이 되는 것도 아니다. reviewer 간 합의와 적용된 rubric/policy version이 함께 남아야 versioned gold dataset으로 쓸 수 있다.
| 증상 | 깨진 경계 | 먼저 고칠 것 |
|---|---|---|
| 승인자가 rubber stamp | 너무 많은 저위험 요청이 사람 주의를 소진 | risk threshold 재평가, 가상 정책으로 허용한 저위험 batch 범위 검증 |
| hash는 같은데 다른 tenant에 실행 | payload bytes만 비교하고 effective intent를 누락 | tenant·권한·action·resource·schema/policy version 재계산 비교 |
| edit 직후 승인 없이 실행 | correction을 새 승인 대상으로 만들지 않음 | immutable payload version 생성 후 waiting_for_review로 전환 |
| workflow가 영원히 대기 | deadline·escalation 없음 | deadline timer, owner fallback, 만료 후 자동 실행 금지 |
| 승인 근거를 재현할 수 없음 | sampled/redacted trace를 유일한 evidence로 사용 | durable artifact_ref/version/digest를 queue·decision·audit에 필수화 |
| 선택 심화: Activity retry가 graph를 두 번 resume | LangGraph 성공 뒤 Temporal 결과 기록 전 crash를 새 요청으로 처리 | resume contract key로 claim_or_get하고 checkpoint의 기존 result를 반환 |
| 선택 심화: 같은 resume이 이메일을 두 번 발송 | key만 전달하고 concurrent claim을 원자화하지 않음 | scoped UNIQUE(scope, key) claim과 provider idempotency 계약 |
| 선택 심화: 같은 key로 다른 payload가 실행됨 | key conflict에서 canonical intent를 비교하지 않음 | 저장 digest와 요청 digest 비교, 불일치면 409와 audit event |
| 선택 심화: timeout 뒤 재시도로 중복 발송 | 결과 불명을 실패로 단정하고 새 key로 재호출 | execution_unknown에서 provider reconcile 후 같은 operation/key로만 retry |
| 선택 심화: 오래된 worker가 결과를 덮어씀 | lease takeover 뒤 이전 worker를 차단하지 않음 | heartbeat·lease expiry와 fencing token을 outbox/result write에서 검증 |
| 선택 심화: 정리한 key가 새 작업에 재사용됨 | active dedup row 삭제와 operation binding 삭제 | binding tombstone 또는 만료 key 거절 인덱스를 유지하고 새 operation/key 발급 |
| 승인 뒤 바뀐 고객 주소로 발송 | 승인 시 검증과 실행 사이 mutable resource 변경 | resource_version precondition, conditional write 실패 시 재승인 |
| 생성 조건을 재현할 수 없음 | model alias·trace만 남기고 provenance를 유실 | assembled input·sampling·tool/schema·route·response ID까지 durable하게 보존 |
| 수정 내용이 학습에 안 쓰임 | feedback pipeline과 검수 단계 없음 | correction을 versioned gold dataset 후보로 저장하고 합의된 label만 편입 |
| 승인 큐 backlog 폭증 | SLA·우선순위 정책 없음 | risk/deadline priority queue, auto-expire |
| reviewer마다 기준 다름 | calibration set·rubric version 없음 | gold dataset 기반 calibration, rubric versioning, high-risk dual review sample |
HITL을 넣을지는 “사람이 보면 더 안전하다”는 직감만으로 정하지 않는다. 사람이 실제로 판단할 정보와 권한이 있는지, 대기 비용보다 피해 감소가 큰지, 결정 뒤 실행을 기술적으로 묶을 수 있는지를 본다.
| 조건 | 우선할 개입 |
|---|---|
| 실행 내용은 확정됐고 외부 effect 허가만 필요 | Approval |
| 산출물의 일부를 사람이 고쳐야 함 | Correction 후 새 payload version에 대한 Approval |
| 허용된 여러 후보 중 맥락 판단이 필요 | Selection, 선택 결과를 canonical payload에 반영 |
| 현재 담당자의 권한·전문성으로 결정 불가 | Escalation, 새 assignee policy와 deadline 기록 |
| 낮은 위험이고 규칙으로 충분히 검증 가능 | 자동 실행. 불필요한 HITL은 rubber stamp와 backlog를 만든다 |
| 사람도 재현 가능한 근거를 받지 못함 | 승인 요청 전 evidence pipeline부터 보완 |
특히 “사람이 한 번 봤다”와 “실행이 승인됐다”를 구분한다. draft quality review만 통과했는데 실제 수신자·tenant·권한이 나중에 붙는다면, 품질 검수는 했어도 발송 action을 승인한 것은 아니다.
| 개념 A | 개념 B | 차이점 |
|---|---|---|
| HITL | Manual QA | HITL은 실행 중 decision gate, Manual QA는 별도 검수 단계 |
| Approval | Correction | 승인/거절 vs 내용을 직접 수정 |
| Interrupt | Breakpoint | interrupt는 운영 중 동적 중단, breakpoint는 개발/디버깅 개념 |
| Escalation | Retry | escalation은 사람/권한 변경, retry는 같은 작업 재시도 |
| Audit log | Application log | audit은 책임과 증거, app log는 디버깅 중심 |
본문의 계약을 이해하는 데 아래 절차가 필수는 아니다. 실제 queue를 도입할 때 순서가 필요하면 펼쳐 본다.
approval_payload_ref/version, effective intent와 resource_version precondition, evidence artifact_ref/version/digest, generation provenance, deadline/expiry를 넣는다. trace_id는 선택적 진단 필드로 분리한다.rubric_version과 policy_version을 남긴다. disagreement는 공통 기준을 갱신하되 바뀐 기준은 새 queue item부터 적용한다.human-in-the-loop, LangGraph interrupt, Temporal Signal, Temporal Update, application adapter, approval workflow, canonical effective intent, generation provenance, resource precondition, TOCTOU, idempotency scope, atomic execution claim, audit trail, durable artifact, AI governance, review queue, SLA, reviewer calibration, dual review, active learning
최종 수정: 2026-07-12