일반 workflow engine
주 질문: 오래 걸리는 업무 절차를 어떻게 실행할까?
대표 도구/패턴: Temporal, Step Functions, Airflow분류: Layer 12 - AI 시스템 & LLM 애플리케이션 | 선수지식: L12-20 (Prompt), L12-40 (RAG), L12-45 (Fan-out), L12-50 (Tool calling)
에이전트 오케스트레이션은 LLM, 도구, 상태, 메모리, 종료 조건을 묶어 여러 단계의 작업을 제한된 자율성 안에서 실행하게 만드는 설계다. 핵심은 “모델이 알아서 다 하게 한다”가 아니라, 어떤 단계는 코드로 고정하고 어떤 단계만 LLM에게 선택하게 할지 정하는 것이다.
LLM 에이전트는 보통 LLM + tools + memory/state + planning + termination의 조합이다. 여기서 오케스트레이션은 각 부품을 이어 붙이는 배선이 아니라, 다음 행동을 누가 결정하는가, 중간 상태를 어디에 저장하는가, 실패했을 때 어디로 되돌아가는가, 언제 멈추는가를 정하는 실행 모델이다.
앞 문서들에서 배운 LLM API, prompt, RAG, fan-out, tool calling은 모두 중요한 부품이다. 특히 현재 의존 그래프는 Fan-out -> Tool calling -> Agent이므로 fan-out도 정상 읽기 경로에 포함된다. 다만 첫 회독에서는 독립 작업을 병렬로 실행하고 결과를 합친다는 핵심만 잡고, branch 상한·timeout·fan-in 같은 고급 분기 설계는 훑어봐도 된다. 제품의 실제 요청은 종종 한 번의 prompt나 한 번의 tool call로 끝나지 않는다.
예를 들어 “이 PR을 리뷰하고, 관련 문서를 찾아 근거를 붙이고, 테스트 실패 원인을 요약해줘”라는 요청은 최소한 다음 상태를 거친다.
요청 이해 -> 관련 파일/문서 검색 -> diff 읽기 -> 테스트 로그 확인 -> 문제 후보 정리 -> 필요하면 다시 검색 -> 최종 리뷰 작성이 흐름에서 RAG는 근거를 찾는 단계, tool calling은 파일·로그·테스트를 조회하는 단계다. 그러나 검색 결과가 비었을 때 다시 검색할지, 테스트가 실패했을 때 어느 파일을 볼지, 충분한 근거를 얻었는지, 비용 한도에 도달했는지는 별도의 실행 구조가 필요하다. 이 구조가 agent orchestration이다.
처음 공부할 때 붙잡을 질문은 다섯 가지다.
생각 -> 도구 호출 -> 관찰 -> 상태 갱신이 반복되므로 max step, budget, 중복 호출 방지가 필요하다.잘 설계된 오케스트레이션은 모델 능력을 키우는 장치가 아니라 불확실한 모델 행동을 제품 시스템 안에 가두는 장치다. 잘못 설계하면 비용·latency·권한 사고·무한 루프가 prompt 하나보다 훨씬 크게 터진다.
L12-20(Prompt), L12-40(RAG), L12-45(Fan-out), L12-50(Tool calling)의 패턴은 모두 LLM 호출의 입출력과 외부 연결을 강하게 만든다. 이 가운데 fan-out은 tool calling이 활용하는 병렬 실행 감각을 제공하므로 정상 선수 경로다. 다만 bounded branch·timeout·fan-in의 세부 설계는 첫 회독에서 전부 숙달하지 않아도 된다. 그래도 한계가 남는다. 이유는 이 패턴들이 주로 한 번의 요청을 어떻게 더 잘 처리할까에 초점을 두기 때문이다.
한계는 세 지점에서 드러난다.
retrieve -> answer다. 검색 결과가 비었거나 서로 충돌하거나 권한 필터에 걸렸을 때 query rewrite, 추가 source 검색, refusal 중 무엇을 할지 결정해야 한다.Agent orchestration은 이 문제를 네 가지 메커니즘으로 푼다.
while not done: thought → action → observation → state update)으로 단계 수를 LLM이 동적으로 결정 (§3.3).단, 이 자율성은 단계 수와 비용으로 직결된다. 가령 한 step마다 모델을 한 번 호출하는 가상 loop가 8 step을 돌면 모델 호출도 8회 누적된다. tool latency, trace, error handling도 각 step에 따라 늘어난다. 그래서 “workflow가 default, agent는 신중히”라는 권고가 중요하다. 출처: Anthropic, Building effective agents (2024-12).
| 용어 | 첫 정의 | 헷갈리기 쉬운 경계 |
|---|---|---|
| Orchestration | 여러 LLM/tool step을 상태와 조건에 따라 실행·분기·종료하는 설계 | 단순 SDK 호출 묶음이 아니라 실행 정책이다 |
| Workflow | 코드가 허용 node·edge·종료 경계를 미리 정의한 흐름 | LLM router가 허용된 branch를 골라도 제어 그래프가 코드에 닫혀 있으면 workflow다 |
| Agent | LLM이 관찰을 보고 허용 범위 안에서 다음 행동·tool을 동적으로 고르는 반복 실행자 | 권한·예산·종료 경계까지 LLM에게 맡긴다는 뜻은 아니다 |
| Planner | 목표를 하위 작업과 순서로 나누는 구성요소 | plan은 가설이므로 실행 중 관찰에 따라 바뀔 수 있다 |
| Executor | 계획 또는 LLM 행동을 실제 tool call로 옮기는 구성요소 | schema·권한·멱등성(idempotency, 같은 요청을 여러 번 처리해도 효과가 한 번과 같게 하는 성질)은 executor나 앱 레이어가 강제한다 |
| Tool loop | LLM decision -> tool call -> observation -> state update 반복 | tool calling 1회가 아니라 반복과 종료 조건까지 포함한다 |
| State | 현재 실행의 사실: step count, tool history, budget, status | 장기 memory와 다르다. state는 실행 제어용이고 더 엄격히 재현되어야 한다 |
| Memory | 세션 내/세션 간 기억: 대화 요약, 사용자 선호, 과거 결정 | 틀린 기억이 들어가면 다음 실행을 오염시키므로 검증·삭제 정책이 필요하다 |
| Handoff | 한 agent나 노드가 다른 agent·사람·workflow로 작업을 넘기는 행위 | ”대화만 넘김”이 아니라 goal, state, 권한, 완료 기준을 함께 넘겨야 한다 |
| Orchestration graph | node와 edge로 agent 실행 경로를 표현한 구조 | 그림이 아니라 복구·관측·테스트 가능한 실행 모델이어야 한다 |
Anthropic의 “Building effective agents”가 정착시킨 분류.
LLM과 tools를 코드가 사전 정의한 제어 그래프와 경계 안에서 오케스트레이션한다. routing처럼 LLM이 분기를 선택해도, 선택 가능한 branch, 각 branch의 tool 권한, 합류점, 종료 조건을 코드가 미리 제한하면 workflow다. 따라서 workflow는 “LLM이 결정을 전혀 하지 않는 흐름”이 아니라 LLM의 결정 공간이 코드로 닫힌 흐름이다.
여기서 말하는 workflow는 “LLM application pattern”이다. Temporal, Step Functions 같은 일반 workflow engine은 결제·승인·배치처럼 긴 비즈니스 절차를 실행하고 복구하는 인프라 계층이고, durable execution은 그 engine이 장애 후에도 이어 달릴 수 있게 만드는 실행 의미론이다. Agent orchestration은 그 위나 옆에서 LLM이 어떤 step·tool·worker를 선택할지를 다룬다. 즉 같은 “오케스트레이션”이라도 관심사가 다르다.
주 질문: 오래 걸리는 업무 절차를 어떻게 실행할까?
대표 도구/패턴: Temporal, Step Functions, Airflow주 질문: worker crash 후 어디서 어떻게 재개할까?
대표 도구/패턴: Temporal history/replay, checkpoint주 질문: 정해진 LLM/tool 단계를 어떻게 조합할까?
대표 도구/패턴: chaining, routing, evaluator loop주 질문: 다음 행동을 LLM이 언제 자율 결정하게 할까?
대표 도구/패턴: ReAct loop, planner, multi-agent분산 시스템 관점에서는 이 구분이 중요하다. workflow engine은 queue, retry, timer, idempotency, consistency 같은 L6~L9 문제를 숨기지 않고 실행 모델로 묶어준다. agent orchestration은 그 실행 모델 위에서 모델의 자율성을 얼마나 허용할지 정한다. CAP·일관성·네트워크 장애 직관은 L9 분산 시스템 기초를 참조하면 된다.
LLM이 관찰 결과에 따라 다음 행동이나 tool 사용 순서를 동적으로 결정한다. 코드가 여전히 tool allowlist, 예산, 승인, 종료 상한을 강제하지만, 정상 경로의 step 수와 행동 순서를 모든 입력에 대해 미리 열거하기 어렵다는 점이 workflow와 다르다.
Workflow가 적합:- 입력·출력과 허용 node·edge를 코드로 열거할 수 있음- LLM router가 골라도 선택지가 사전 정의된 branch 안에 닫힘- 결정성·디버깅 중요- 비용·latency 예측 필요
Agent가 적합:- 관찰 전에는 필요한 단계 수나 tool 순서를 열거하기 어려움- 새 관찰에 따라 다음 행동 후보 자체가 달라짐- 이 동적 판단의 가치가 추가 비용·실패면보다 큼- production에서는 sandboxing·human review 결합작업을 N개 step으로 분해, 각 step LLM 호출 결과를 다음 step 입력으로.
Step 1: 마케팅 카피 작성Step 2: 톤 검사 (gate) → 통과 못하면 Step 1로Step 3: 다국어 번역입력 분류 후 적절한 핸들러로.
사용자 질문 → Router (작은 LLM) ├─ 일반 → cheap model ├─ 코드 → code-specialized └─ 의료 → expert workflow여러 LLM 호출을 동시에 → 결과 합산.
검색 fan-out과 같은 계열이다. Fan-out 검색 패턴은 이 문서까지 이어지는 정상 선수 경로이며, 여기서는 독립 작업을 동시에 실행하고 결과를 합친다는 핵심을 사용한다. bounded branch, timeout, fan-in 같은 고급 분기 설계는 첫 회독에서 훑어보고 나중에 다시 확인해도 된다.
중앙 LLM(orchestrator)이 sub-task 동적 결정·worker LLM에 위임.
사용자 요청 → Orchestrator ├─ Plan: "검색 + 분석 + 글쓰기 필요" ├─ Worker A: 검색 ├─ Worker B: 분석 └─ Worker C: 글쓰기 → Orchestrator 통합한 LLM이 응답 생성, 다른 LLM이 평가·피드백 → 반복.
Evaluator-Optimizer는 넓게 보면 critic loop다. 초안 생성자(generator)가 산출물을 만들고, 비평자(critic/evaluator)가 기준에 맞춰 결함을 찾은 뒤, 수정자(optimizer/reviser)가 다시 고친다.
draft = Generator(input)for i in range(max_rounds): critique = Critic(draft, rubric) if critique.pass: break draft = Reviser(draft, critique)return draft대표 변형:
적용 전제는 “비평 기준이 코드나 체크리스트로 표현 가능해야 한다”는 점이다. 기준이 모호하면 critic은 실제 품질보다 말투·길이·자신감 같은 표면 신호에 끌린다.
Agent loop는 tool calling을 감싼 반복 실행 구조다. 모델은 한 번에 최종 답을 만들 수도 있고, 현재 state를 보고 tool을 호출하자고 제안할 수도 있다. 애플리케이션은 그 호출을 검증·실행하고, observation을 state에 붙여 다시 모델에게 준다.
while not done: decision = LLM(state) # 다음 행동 결정 append(state.messages, decision) calls = extract_tool_calls(decision) # final 판단보다 먼저 추출
if has_final_text(decision) and calls is empty: done = "normal" # final text만 있으면 정상 종료 break
# final text와 calls가 함께 있어도 final로 인정하지 않고 tool 경로로 보낸다. reason = duplicate_guard(calls, state) or cap_guard(state) if reason: for call in calls: state = append(state, matching_tool_response(call.id, skipped=reason)) state = append(state, final_assistant_without_tool_calls(reason)) done = reason # 모든 pending call을 닫은 뒤 guard 종료 break
actions = parse_actions(calls) observations = execute(actions) # 정상 tool 실행 state = update(state, actions, observations) continue # observation을 넣고 다음 decision으로작은 worked example을 보자. 사용자가 “이 장애 리포트를 보고 원인을 좁혀줘”라고 요청했고, agent에게 search_logs, get_deploys, read_runbook 세 tool만 허용했다고 하자.
| step | state에서 중요한 사실 | LLM이 고른 action | observation 이후 다음 판단 |
|---|---|---|---|
| 1 | goal=장애 원인 후보 찾기, step=0 | search_logs(service="checkout") | 에러가 14:05부터 늘었다 |
| 2 | error_start=14:05 | get_deploys(service="checkout") | 14:02 배포가 있었다 |
| 3 | deploy_id=d-42, error=timeout | read_runbook(topic="downstream timeout") | DB timeout과 외부 결제 API timeout을 구분해야 한다 |
| 4 | 이미 로그/배포/runbook 조회 완료 | final answer | 원인 후보, 근거, 다음 사람이 확인할 항목으로 종료한다 |
이 예제에서 agent의 가치는 tool을 많이 쓰는 데 있지 않다. 관찰값에 따라 다음 조회를 바꾸고, 이미 확인한 사실을 state에 남기고, 더 조회할 가치가 없을 때 멈추는 것이 가치다. 같은 search_logs를 같은 인자로 5번 반복한다면 agent가 아니라 실패한 loop다.
Memory를 보기 전에 state와 memory를 분리해야 한다. 둘 다 “기억”처럼 보이지만 운영 성격이 다르다.
| 구분 | 수명 | 예시 | 깨지면 보이는 증상 |
|---|---|---|---|
| Execution state | 한 요청 또는 한 workflow 실행 | step_count, 마지막 tool 결과, 사용한 budget, 현재 node | rollback·재시도·trace replay가 안 되고 같은 tool을 반복 |
| Short-term memory | 현재 대화/세션 | 최근 메시지, tool observation 요약, 현재 작업 맥락 | context가 길어져 비용이 늘고 오래된 오류를 계속 참조 |
| Long-term memory | 여러 세션 | 사용자 선호, 과거 승인, 프로젝트 규칙, 도메인 사실 | 틀린 기억이 계속 검색되어 다른 요청까지 오염 |
첫 구현에서는 long-term memory보다 execution state를 먼저 안정화하는 편이 낫다. state가 불안정하면 같은 요청 안에서도 디버깅이 안 되는데, 여기에 장기 memory까지 붙이면 실패 원인을 분리하기 어렵다.
현재 conversation·tool 호출 history. context window 안.
여러 session 사이에 유지되는 정보. 외부 store (vector DB, key-value store).
Planning은 “모델이 생각을 길게 한다”가 아니라 목표를 실행 가능한 하위 작업으로 나누고, 관찰 결과에 따라 계획을 갱신하는 정책이다. 구현에서는 planner와 executor를 분리해서 생각하면 이해가 쉽다.
Planner: goal을 읽고 하위 작업 후보를 만든다. 어떤 tool이나 worker가 필요한지 고른다. 성공 조건과 중단 조건을 제안한다.
Executor: planner가 제안한 step을 실제 tool call로 실행한다. schema, 권한, budget, idempotency를 검사한다. observation을 state에 기록하고 planner에게 돌려준다.Planner가 항상 별도 모델일 필요는 없다. 작은 agent에서는 같은 LLM 호출 안에서 다음 action을 고르는 것으로 충분하다. 중요한 경계는 “계획이 틀릴 수 있다”는 점이다. 따라서 좋은 planning은 처음 plan을 길게 쓰는 능력보다, 관찰 후 plan을 버리거나 줄이는 능력에 가깝다.
1. LLM이 전체 plan 생성 (high-level steps)2. 각 step을 sequential로 실행3. 실패 시 replanningPlan-and-Execute는 작업 목표가 비교적 선명하고, 실행 중 새 정보가 조금만 들어오는 경우에 좋다. 예를 들어 “주어진 문서 3개를 비교 요약”은 먼저 읽기 -> 공통점 추출 -> 차이점 추출 -> 표 작성으로 나눌 수 있다. 반대로 “테스트 실패 원인을 찾아 수정”처럼 중간 관찰이 다음 행동을 크게 바꾸는 작업은 plan이 자주 낡는다.
생각·행동·관찰 명시 루프.
ReAct는 plan을 한 번에 길게 쓰기보다 매 step마다 thought/action/observation을 반복한다. tool 결과가 중요한 작업에 적합하지만, 종료 조건과 중복 tool 방지가 없으면 같은 관찰을 계속 반복하기 쉽다.
여러 경로 동시 탐색·평가.
Tree-of-Thoughts는 여러 후보 경로를 만들고 평가하면서 탐색한다. 수학 퍼즐이나 설계 대안 비교처럼 후보 간 차이가 크고 평가 기준이 있을 때 의미가 있다. 단순 조회나 짧은 분류에 쓰면 비용만 늘어난다.
작업 후 자기 비판 → 개선 (Reflexion).
Self-Reflect는 산출물을 만든 뒤 “무엇이 부족했는가”를 다시 보게 하는 패턴이다. 코드 리뷰, 번역, 설명문 개선처럼 평가 기준이 있는 산출물에는 도움이 된다. 그러나 같은 모델이 같은 blind spot을 반복할 수 있으므로 고위험 판단에서는 독립 critic이나 human review가 필요하다.
| 작업 특징 | 먼저 시도할 전략 | 피해야 할 오해 |
|---|---|---|
| 단계가 고정되고 입력·출력이 명확함 | Workflow / prompt chaining | agent loop를 붙여도 품질보다 비용이 먼저 늘 수 있다 |
| 중간 tool observation이 다음 행동을 바꿈 | ReAct / state machine | observation을 state에 남기지 않으면 같은 실수를 반복 |
| 전체 목표는 명확하지만 하위 작업이 큼 | Plan-and-Execute | 첫 plan을 절대적인 계약으로 취급하지 않는다 |
| 후보 경로를 비교해야 함 | Tree-of-Thoughts / evaluator | 평가 기준 없이 후보를 늘리면 noise만 커진다 |
| 산출물 품질을 반복 개선해야 함 | Evaluator-Optimizer / Self-Reflect | critic도 평가 대상이며 종료 조건이 필요하다 |
Multi-agent는 “여러 모델을 쓰면 더 똑똑하다”가 아니라 작업을 여러 독립 역할로 나눠도 통합 비용보다 이득이 클 때 쓰는 패턴이다. 여기서 핵심 개념이 handoff다.
Handoff는 한 agent가 다른 agent에게 단순히 메시지를 전달하는 일이 아니다. 최소한 다음 다섯 가지가 같이 넘어가야 한다.
goal: 넘기는 작업의 목표context: 지금까지 확인한 근거와 제외한 후보state: step, budget, tool history, current artifactauthority: 다음 agent가 실행할 수 있는 tool과 금지된 actiondone criteria: 어떤 상태면 다시 supervisor나 사람에게 돌려줄지handoff가 부실하면 subagent는 같은 검색을 반복하거나, 이전 agent의 암묵적 결정을 모른 채 충돌하는 산출물을 만든다. 이 실패는 모델 성능 문제가 아니라 orchestration graph의 계약 문제다.
[Supervisor agent] ├─ [Researcher] (검색) ├─ [Coder] (코드 작성) └─ [Reviewer] (검토)여러 agent가 동등 관계로 자유 통신. 복잡·비용 ↑.
agent A → agent B → agent C 단방향. workflow에 가깝지만 각 agent가 자율 결정.
이 패턴은 handoff 계약이 가장 단순하다. researcher -> writer -> reviewer처럼 산출물이 한 방향으로 흐른다. 다만 reviewer가 문제를 발견했을 때 writer로 되돌아가는 edge가 있으면 이미 graph가 된다. 이때는 “몇 번 되돌릴지”, “무엇이 pass인지”, “비용 상한은 얼마인지”를 정해야 한다.
Anthropic의 공개 가이드와 multi-agent research system 사례는 “먼저 단순한 single agent 또는 workflow를 시도하고, 관측된 병목이 있을 때 multi-agent를 추가한다”는 방향을 보인다. 이는 모든 제품에 적용되는 규격이 아니라, communication overhead와 debugging difficulty가 생긴다는 구조적 경고로 읽는다.
다음 조건이 여러 개 동시에 맞을 때만 multi-agent를 고려한다.
반대로 다음 조건이면 쓰지 않는다.
Anthropic 자체 research system 운영 보고 (Claude Opus 4 lead + Sonnet 4 subagents):
“Flappy Bird clone 만들기”를 두 subagent로 분할했을 때 실제로 관찰된 실패:
원인: subagent들이 서로의 결정·암묵적 가정을 보지 못함. Cognition의 2025년 에세이는 이 사례를 바탕으로 single-threaded linear agent + 대화 압축 모델을 default로 권고하고, multi-agent는 question-answering처럼 좁고 잘 정의된 sub-task에 한정하자고 주장했다. 이는 한 조직의 경험적 주장이지 모든 agent 시스템의 보편 결론은 아니다. 출처: Cognition, Don’t Build Multi-Agents (2025).
이 두 보고가 §3.13·§3.14의 “Multi-agent: 통신 overhead 多 — single agent 우선” 권고의 정량적·사례적 근거다.
프레임워크 생태계는 빠르게 바뀐다. 교과서적으로 외울 대상은 이름 목록보다 선택 기준이다.
| 선택 기준 | 확인할 질문 |
|---|---|
| 명시적 상태·전이 | agent loop를 graph/state machine으로 볼 수 있는가 |
| Tool 권한·격리 | allowlist, confirmation, audit log를 넣기 쉬운가 |
| 관측성 | step trace, token, latency, retry를 남기는가 |
| UI·런타임 통합 | streaming, cancellation, human approval과 맞는가 |
| 모델·벤더 독립성 | provider 교체와 fallback이 가능한가 |
예시는 LangGraph, CrewAI, AutoGen, OpenAI/Claude SDK, Pydantic AI, Mastra, Vercel AI SDK, DSPy 등이지만 특정 이름은 참고일 뿐이다.
agent loop를 명시적 finite state machine으로 표현한다. 특정 프레임워크 이름보다 중요한 점은 상태와 전이를 코드로 볼 수 있어야 한다는 것이다.
States: [planning] → [tool_calling] → [observing] → [reflecting] → [done]Transitions: 명시적 조건Error: error state로 분기, retry/escalateOrchestration graph는 이 state machine을 node와 edge로 표현한 것이다.
[plan] -> [retrieve_docs] -> [call_tool] -> [judge_observation] ├─ enough evidence -> [final_answer] ├─ missing evidence -> [retrieve_docs] ├─ risky action -> [human_approval] └─ error/budget exceeded -> [fail_safe]graph로 만들면 좋은 이유는 “agent가 무엇을 했는지”가 아니라 무엇을 할 수 없게 막았는지가 보이기 때문이다. 예를 들어 human_approval edge 없이는 결제·이메일·배포 같은 비가역 action이 바로 실행될 수 있다. budget exceeded -> fail_safe edge 없이는 비용이 늘어도 멈출 경로가 없다. missing evidence -> retrieve_docs edge는 useful하지만, 횟수 제한이 없으면 stuck loop가 된다.
작은 상태 설계 예:
| state 필드 | 왜 필요한가 | 실패 신호 |
|---|---|---|
original_goal | goal drift를 감지하기 위한 기준점 | 최종 답이 초기 요청과 다른 문제를 풀고 있음 |
step_count | 종료 조건과 비용 상한 계산 | 같은 tool을 계속 호출하고도 멈추지 않음 |
tool_history | 중복 호출과 idempotency 확인 | 같은 인자로 같은 tool을 반복 |
evidence_ids | 답변 근거와 citation 추적 | 근거 없는 확신 또는 오래된 문서 인용 |
budget_remaining | 요청당 비용·시간 한도 관리 | 고가 모델·multi-agent 호출이 누적됨 |
approval_status | 비가역 action gate | 승인 전 이메일·DB write·배포가 실행됨 |
각 agent step을 이벤트(Kafka, NATS, AWS EventBridge)로 발행. 비동기·confirmable.
Event-driven 패턴은 agent step이 오래 걸리거나 외부 시스템 응답을 기다릴 때 유용하다. 다만 LLM이 만든 이벤트도 결국 시스템 이벤트이므로 idempotency key, retry policy, dead-letter queue(DLQ, 정해진 재시도 후에도 처리하지 못한 이벤트를 격리하는 큐) 같은 일반 분산 시스템 원칙이 필요하다. agent라는 이름이 붙어도 중복 결제, 중복 이메일, 순서 역전 문제는 사라지지 않는다.
비가역 action(이메일·결제) 전 사용자 승인.
Human-in-the-loop는 “모델이 불안하니 사람이 보자”보다 좁게 설계해야 한다. 좋은 승인 요청은 무엇을 하려는지, 왜 해야 한다고 판단했는지, 어떤 근거를 봤는지, 취소하면 어떤 대안이 있는지를 포함한다. 사람이 매번 모든 trace를 읽어야 한다면 approval UI가 아니라 책임 전가가 된다.
agent 권한은 “무엇을 할 수 있는가”보다 “실패했을 때 어디까지 피해가 번지는가”로 설계한다.
| 정책 영역 | 기본값 | 운영 기준 |
|---|---|---|
| Tool allowlist | 작업별 최소 tool만 노출 | read/write/delete·외부 발송 tool을 분리 |
| Confirmation | 비가역 action 전 승인 | 결제, 이메일, 배포, DB write는 human gate |
| Sandbox | 파일·네트워크·secret 범위 제한 | repo/path allowlist, 임시 credential, egress 제한 |
| Prompt/tool 방어 | untrusted input을 명령과 분리 | 검색 결과·웹 페이지·사용자 첨부는 tool 지시로 불인정 |
| Audit log | step·tool·argument·결과 기록 | rollback과 책임 추적이 필요한 action은 trace 필수 |
매 step state 저장. 실패 시 마지막 checkpoint부터 재개.
Checkpoint는 long-term memory가 아니다. checkpoint는 같은 실행을 복구하거나 재현하기 위한 스냅샷이고, memory는 다음 실행에서도 참고할 수 있는 지식이다. 둘을 섞으면 “오염된 실행 상태”가 장기 기억처럼 검색되어 다른 요청까지 망칠 수 있다.
agent loop에서는 **전체 작업량(total work)**과 **사용자가 기다리는 임계 경로(critical path)**를 구분해야 한다. 병렬 worker를 늘리면 wall-clock latency가 줄 수 있어도 모든 branch의 model·tool 호출은 비용에 남는다. 반대로 순차 tool loop, 재시도, 사람 승인은 그대로 critical path에 이어져 지연을 늘린다. 즉 “빨리 끝났다”와 “적은 일을 했다”는 같은 말이 아니다.
에이전트에서 비용과 지연이 특히 증폭되는 이유는 step 수를 입력 전에 확정하기 어렵고, 매 step에 이전 observation이 누적되며, 실패한 시도와 sub-agent 통신도 실행 그래프에 추가되기 때문이다. 일반 token·tool 비용식과 latency 계산법은 LLM 비용·지연을 기준으로 삼고, 이 문서에서는 아래 증폭 원인과 §3.14의 workflow-vs-agent 비교에 집중한다.
critic loop는 “평가자가 있으니 안전하다”가 아니라, 평가자 자체도 eval 대상이다.
아래 두 진단은 특정 framework API보다 증상과 저장 경계를 먼저 분리하는 법을 다룬다. 첫 회독에서는 오염된 memory는 다른 요청까지 망칠 수 있고, goal drift는 원래 목표를 실행 state에 고정하지 않을 때 생긴다는 차이를 잡는다.
Memory contamination은 검증되지 않은 과거 정보가 현재 요청의 사실처럼 재사용되는 실패다. 같은 thread에서 checkpoint를 이어 쓰는 것 자체는 정상이다. 문제는 namespace가 섞이거나, 오래된 실행 state와 long-term memory를 출처·유효기간 확인 없이 다시 읽는 데 있다.
| 관찰 증상 | 먼저 의심할 경계 | trace/checkpoint에서 찾을 증거 |
|---|---|---|
| 다른 사용자·세션의 이름이나 결정을 반복 인용한다 | tenant/user와 thread namespace 충돌, 장기 memory 검색 filter 누락 | 서로 다른 tenant가 같은 namespace를 썼는지, memory hit의 owner·source가 현재 요청과 같은지 |
| 이전 turn의 잘못된 tool 결과를 계속 사실로 쓴다 | checkpoint message 누적, 요약·검색 시 오래된 오류 재선택 | 잘못된 observation이 처음 들어온 step, 이후 model input마다 그 observation이 재주입되는지 |
| message 수가 매 turn 계속 늘고 오래된 오류가 사라지지 않는다 | 보존 state와 model input view를 같은 것으로 취급 | 저장된 전체 history와 실제 model에 전달된 bounded input의 길이 차이 |
Checkpoint는 같은 실행·대화를 재현하고 재개할 state snapshot이고, long-term memory는 다른 세션에서도 검색할 지식이다. checkpoint를 되돌려도 별도 memory store의 오염 항목은 남을 수 있고, memory 항목을 삭제해도 이미 저장된 checkpoint history는 바뀌지 않는다. 두 저장소는 출처, 보존 기간, 삭제 경로를 따로 가져야 한다.
모델 입력을 제한할 때는 임의의 최근 메시지 N개가 아니라 turn 경계를 단위로 자른다. 조립 결과에는 system message, 최근 완결 turn, 현재 active turn 전체가 모두 들어가야 한다.
bounded_input(system, completed_turns, active_turn, max_completed_turns): require max_completed_turns >= 0 require every completed turn is closed require active_turn starts with the current user message require every tool call in active_turn has its matching response at model boundary recent = last(completed_turns, max_completed_turns) return system + flatten(recent) + active_turn완결 turn은 user -> assistant tool call -> matching tool response(s) -> final assistant까지 닫힌 묶음이다. Active turn은 현재 user message부터 아직 final answer가 나오지 않은 지점까지이며, 다음 model 호출 시점에는 그 안의 모든 tool call이 matching response를 가져야 한다. Token budget이 부족하면 오래된 완결 turn부터 제외하고, active turn만으로 초과하면 tool output을 state에 넣기 전에 cap·요약하거나 실행을 중단한다. 완결 turn과 active turn 어느 쪽도 중간에서 잘라 AI tool call과 대응 response를 떼지 않는다. 이 방식은 checkpoint를 삭제하지 않고 모델이 보는 view만 제한한다는 점도 중요하다.
복구는 고정된 “세 번째 이전 checkpoint”를 고르는 일이 아니다.
주의할 점은 checkpoint replay/fork가 선택 지점 이후의 node를 다시 실행하므로 LLM·외부 API·write call도 반복될 수 있다는 것이다. Write node에는 tenant·resource·논리 작업을 묶은 business idempotency key를 강제하고, 되돌릴 수 없거나 고위험인 action은 새 human approval 뒤에서만 재개하며, 실제 반영 전 dry-run 경계를 둔다.
Goal drift는 agent가 실행은 계속하지만 처음 요청과 다른 문제를 풀기 시작하는 실패다. 긴 tool output이나 중간 가설이 새로운 목표처럼 취급될 때 나타난다.
| 관찰 증상 | trace에서 비교할 것 | 실패 판정 |
|---|---|---|
| 최종 답이 원본 질문과 무관한 주제로 수렴한다 | original_goal, 현재 plan, final answer | final answer의 완료 조건이 원래 요청의 완료 조건과 다름 |
| tool arguments가 초기 요청과 무관한 키워드로 바뀐다 | 직전 observation과 다음 tool call arguments | 근거 수집이 아니라 observation의 부수 주제를 새 목표로 추적 |
| step이 늘수록 “현재 작업” 설명이 달라진다 | 각 model input의 stable instruction과 current task | 원본 목표가 사라졌거나 중간 요약이 이를 덮어씀 |
Goal anchor 원칙은 원본 목표와 완료 기준을 original_goal 같은 불변 state로 보존하고, 다음 model input의 안정된 instruction 영역에서 다시 보여 주는 것이다. 기존 message list를 제자리에서 고치거나 pending tool call과 response 사이에 anchor를 끼워 넣지 않는다. N=5 step마다 재주입 같은 값은 보편 규칙이 아니라 trace에서 drift가 나타나는 길이와 token budget으로 조정할 예시 정책이다.
짧은 worked example을 보자. 목표는 “PR의 테스트 실패 원인을 근거와 함께 리뷰”인데, runbook tool이 반환한 긴 문서에 “클라우드 이전 계획”이 포함되어 다음 call이 migration 문서만 검색하기 시작했다면 drift다. 마지막으로 PR diff·테스트 로그를 다루던 checkpoint가 복구 후보이고, runbook 결과를 읽은 뒤라도 여전히 테스트 실패를 좁히는 call이었다면 더 뒤 checkpoint를 고를 수 있다.
on each model step: compare current_plan and proposed_action with original_goal + done_criteria if trace/eval marks drift: stop before executing the proposed action resume from the last closed checkpoint that still pursued original_goal inject original_goal as a stable instruction, then branch복구 성공 기준은 단순히 답의 주제가 돌아온 것이 아니다. 새 branch가 원래 완료 기준을 만족하고, drift 이후의 stale pending action을 실행하지 않았으며, trace에서 original_goal -> evidence -> final answer의 연결이 다시 보일 때 성공이다.
reasoning 계열 모델은 일반 chat 모델보다 긴 내부 추론을 쓰도록 설계된 경우가 많아 복잡한 planning에서 이득이 날 수 있다. 다만 모델명·가격·지원 기능은 빠르게 바뀌므로, 여기서는 특정 최신 제품 목록보다 비용 구조를 보는 법에 초점을 둔다.
| 기법 | 효과 발휘 범위 | 깨지는 조건 |
|---|---|---|
| Workflow | 명확한 입력·출력과 허용된 분기 | step 수가 가변이어도 bounded state machine으로 표현 가능하면 agent가 자동 선택은 아니다 |
| Agent (single) | 관찰에 따라 tool 선택이 달라지는 작업 | 자기 traffic의 step·비용 baseline 없이 loop를 열면 runaway 위험 |
| Multi-agent | 역할 경계가 명확하고 독립 병렬 탐색이 가능한 작업 | 토큰 ~15× (Anthropic 보고). 공유 컨텍스트·의존 多·코딩 작업에서 깨짐 (Flappy Bird 사례, Cognition) |
| ReAct | tool 자주 호출 | 단발 답변엔 오버헤드 |
| Plan-and-Execute | 사전 계획 가능 | 계획 변경 잦으면 replanning 비용 |
| Tree-of-Thoughts | 복잡 puzzle | 평가 기준 없이 후보를 늘리면 비용과 noise가 함께 증가 |
| Graph/state-machine framework | 상태·전이·checkpoint 명시가 필요한 작업 | 승인·재개·trace 요구를 작은 spike로 검증하지 않고 framework 이름만으로 선택하면 경계가 흐려짐 |
| Reasoning model 단발 | 복잡 reasoning | 호출당 비용·출력 길이가 커질 수 있음 — 단순 작업엔 일반 모델 (경험적 추정) |
| Iteration cap | 예상 step 분포를 trace로 아는 loop | 전역 “표준” 값은 없으며 task별 p95(측정값의 95%가 이 값 이하가 되도록 잡은 백분위수)와 budget에서 정해야 함 |
정량 수치 주석: agent와 multi-agent의 비용 배율은 tool 수, context 길이, 평가 방식에 따라 크게 달라진다. Anthropic의 2025년 research system 보고의 약 4×/15×는 그 시스템과 workload의 사례다. 다른 벤치마크나 블로그 수치를 일반 운영 임계값으로 옮기지 말고, 같은 task set에서
step 수,input/output token,tool 비용,성공률을 함께 재야 한다.
절대 금액은 모델 버전·가격표·컨텍스트 길이에 따라 쉽게 낡는다. 운영 판단은 아래 식과 실측 dashboard로 한다.
llm_cost_i = input_tokens_i × input_price_i + output_tokens_i × output_price_itotal_cost = Σ_all_attempts(llm_cost_i + tool/API charge_i)
total_work_time = Σ_all_attempts(node_duration_i)wall_clock_latency ~= critical_path(node_duration_i + human wait) + scheduling/communication overheadall_attempts에 한 번씩 넣는다. 이미 실제 token·tool 사용량을 합산했다면 별도 retry 항을 더하지 않는다.아래 값은 가격표 주장이 아니라 계산법을 익히기 위한 가상 시나리오다. 입력은 백만 token당 $2, 출력은 백만 token당 $8, 유료 tool은 호출당 $0.002라고 가정한다.
| 방식 | 실제로 센 시도 | token·tool 비용 계산 | total work와 critical path |
|---|---|---|---|
| Workflow | read-only 조회 3개 병렬 + LLM 2회 | (18,000 / 1M × $2) + (3,000 / 1M × $8) = $0.060 | 조회 0.4+0.8+0.5초와 LLM 1.2+1.5초의 total work는 4.4초; critical path는 max(0.4, 0.8, 0.5)+1.2+1.5=3.5초 |
| Single agent | LLM 4회 + 유료 tool 5회. LLM 4회에는 실패 후 다시 시도한 1회가 이미 포함됨 | (30,000 / 1M × $2) + (4,000 / 1M × $8) + (5 × $0.002) = $0.102 | 모두 순차라면 4×1.0 + 5×0.6 = 7.0초가 total work이자 critical path |
workflow의 두 LLM 호출은 각각 (1) diff·테스트 로그에서 리뷰 후보를 구조화하는 호출, (2) 후보를 정책 문서와 대조해 최종 리뷰를 합성하는 호출이다. agent가 항상 더 비싸거나 느리다는 결론은 아니다. 이 가정에서는 동적 탐색의 이점이 없는 고정 작업이라 workflow가 유리할 뿐이며, 실제 결정은 같은 labelled PR set의 성공률·token·tool 비용·p95 latency를 함께 비교해야 한다.
→ 결정: 입력·출력과 허용 분기가 안정적이면 workflow부터 측정한다. 관찰 결과에 따라 tool 선택을 동적으로 바꿔야 하고 그 추가 비용을 설명할 수 있을 때 reasoning model 또는 agent를 비교한다.
| 증상 | 정량 신호 | 원인 | 복구 |
|---|---|---|---|
| Stuck loop | 같은 tool·argument 반복 또는 step 수가 자기 baseline을 초과 | 종료 조건·중복 감지 부재 | step/budget cap, 호출 fingerprint 기록 |
| Goal drift | 답이 원본 query와 무관 | 컨텍스트 누적 | 매 N step마다 원본 query 재확인 |
| Memory contamination | 잘못된 정보 반복 | long-term memory 오염 | memory 검증·삭제 정책, fact-check |
| Cost runaway | 호출당 비용이 task baseline보다 급증 | budget 미설정·retry 누적 | per-request budget, alert |
| Observation overflow | tool output 매우 김 | summarization 누락 | tool output cap·summarize |
| Permission abuse | agent가 의도 외 작업 | allowlist 미적용 | tool allowlist + confirmation |
| Multi-agent stuck | 둘 사이 무한 대화 | termination 부재 | turn 한도, supervisor가 종료 결정 |
| Judge bias | critic이 그럴듯한 답만 통과 | rubric·gold dataset 부재 | blind eval, human spot check |
critic loop는 품질을 올리지만 종료 조건이 없으면 “더 비판 → 더 수정”만 반복한다. production에서는 아래 guardrail을 함께 둔다.
아래 수치는 계산 구조를 보여 주기 위한 가상 정책 예다.
max_rounds = 2max_cost_per_request = $0.20stop if score >= 0.85 and critical_issues == 0stop if score_delta < 0.02escalate if security_or_payment_action == trueLLM agent를 다른 시스템과 비교할 때는 구조적 유사성만 가져오고 운영 의미론이 같다고 보지 않아야 한다.
| Agent 구성요소 | 좁게 가져올 수 있는 직관 | 등가가 아닌 이유 |
|---|---|---|
| State machine (LangGraph) | 허용 상태와 전이를 명시하는 workflow | 일반 workflow engine의 durable execution을 자동 제공하지 않는다 |
| Agent loop | 결정 -> 행동 -> 관찰 -> 갱신의 bounded feedback loop | PID 제어처럼 오차 함수와 제어 법칙이 정의된 loop가 아니다 |
| Tool call | schema가 있는 request/response 경계 | service mesh는 서비스 간 네트워크 정책 계층이지 tool call이 아니다 |
| Memory (short/long) | session-scoped state와 persistent store를 분리하는 직관 | 기억의 신뢰도·삭제·검색 정책은 별도 agent 문제다 |
| Multi-agent supervisor | 작업 분배와 결과 통합을 맡는 coordinator | worker 간 의미 충돌을 자동 해결하지 않는다 |
| Checkpoint | 실행 상태를 다시 읽을 수 있는 snapshot | DB transaction의 원자성·격리·rollback과 같지 않다 |
| Trajectory eval | 여러 step의 인과 순서를 trace로 재생하는 관점 | 분산 trace만으로 답의 의미적 정확성을 판정할 수 없다 |
§3.15의 stuck loop처럼 같은 tool name과 arguments가 다시 제안되는 현상은 trace에서 반복 호출로 관찰할 수 있다. 다만 §9 선택 실습의 fingerprint 검사는 현재 thread 안에서 이미 실행한 호출의 반복을 막는 local repeat-call guard일 뿐 circuit breaker가 아니다.
가상 상황: GitHub PR 자동 리뷰 도입 검토. 평균 PR 5개/일이며, diff·테스트 로그·정책 문서를 읽는 허용 경로는 이미 정해져 있다.검증 집합: 사람이 finding을 판정한 labelled PR 50개. 성공률·input/output token·tool 비용·p95 latency를 같은 조건에서 기록한다.선택지: A. Workflow: read-only 조회 3개 → 후보 구조화 LLM → 정책 대조·최종 합성 LLM → comment - 두 LLM 호출과 허용 branch가 코드 그래프에 고정됨 - router가 정책 종류를 골라도 허용 정책 문서와 합류점은 코드가 제한함 B. Single agent + tools (read_file, run_test, search_docs): - 검색 결과에 따라 다음 조회를 바꿀 수 있지만, step cap와 중복 호출 검사가 필요 C. Multi-agent (reviewer + tester + security): - 세 역할의 독립 병렬 탐색 이득과 message·merge 비용을 함께 측정해야 함 D. Reasoning model (single shot): - 별도 loop를 줄일 수 있는지 같은 labelled PR set으로 확인해야 함
1차 선택: A (Workflow)를 baseline으로 측정.근거: - 현재 요구는 허용 control graph로 표현 가능하고 동적 tool 탐색이 필수가 아님 - §3.14의 두 LLM 호출과 read-only 조회를 trace에서 따로 계측할 수 있음대안 비선택: - B·C·D가 나빠서가 아니라, 추가 복잡도를 정당화하는 labelled 비교 결과가 아직 없음
전환 조건: - labelled PR에서 고정 workflow가 놓치는 finding이 "관찰 후 새 tool 선택"으로 반복 복구됨 - 그 성공률 개선이 token·tool 비용과 p95 latency 증가를 감수할 만큼 큼
silent failure (§3.15): - stuck loop, goal drift, cost runaway, multi-agent stuck§3.1 Workflow vs Agent + §3.13 깨지는 조건 + §3.14 cost 비교 + §3.15 silent failure 모두 적용.
엔지니어가 agent를 운영할 때 다음에 도움된다.
| 개념 A | 개념 B | 차이점 |
|---|---|---|
| Workflow | Agent | 코드가 허용 그래프를 닫고 그 안에서 분기 vs LLM이 관찰에 따라 다음 행동 순서를 동적으로 선택 |
| Workflow engine | Agent framework | engine은 실행·복구 인프라, framework는 LLM step·tool 선택 구조 |
| Durable execution | Agent checkpoint | durable execution은 history/replay 의미론, checkpoint는 agent state 저장 수단 |
| Prompt chaining | Routing | 순차 vs 분기 |
| Orchestrator-workers | Multi-agent | orchestrator-workers는 하나의 controller가 전문 worker에 일을 분배하는 구체 패턴, multi-agent는 supervisor·swarm·pipeline까지 포함하는 더 넓은 범주 |
| Single agent | Multi-agent | 1개 LLM 자율 vs N개 협업. multi는 비용·debug ↑ |
| Short-term mem | Long-term mem | context 안 vs 외부 store (Mem0, Letta, Zep) |
| Episodic | Semantic memory | 사건·시간 vs 추상화된 지식 |
| ReAct | Plan-and-Execute | step별 thought·action·obs vs plan 후 실행 |
| State machine | Free agent loop | 명시 transitions vs LLM 매번 결정 |
| Graph-oriented framework | Role-oriented framework | 상태·checkpoint 표현과 역할·handoff 표현 중 무엇이 핵심인지 같은 작은 작업으로 검증 |
| Reasoning model | 일반 + CoT(Chain of Thought, 단계적 추론 유도) | 모델의 내부 reasoning 예산 vs prompt로 유도한 다단계 출력 |
| Critic loop | Agent eval | 산출물 반복 개선 루프 vs 시스템 품질 측정 체계 |
| Critic | Judge | 결함 피드백 생성 vs 후보 간 판정·점수화 |
목표: 고정 workflow와 bounded agent loop를 같은 작은 작업에 적용해, 동적 행동 선택이 언제 추가 제어 비용을 만드는지 확인한다.
예상 관찰: workflow는 정해진 두 tool을 바로 실행하지만 agent는 다음 action과 종료 여부를 반복해서 결정한다. agent 경로에는 checkpoint, step cap, 중복 호출 방지, 정상·실패 종료 사유가 추가되며, 같은 출력이라도 더 많은 policy decision이 생길 수 있다.
성공 기준: 아래 명령과 코드를 건너뛰어도 normal 종료가 cap보다 우선해야 한다는 점, 미응답 tool call 없이 terminal history가 닫혀야 한다는 점, deterministic 비교에서 같은 결과에 agent만 세 번의 policy decision을 쓴다는 결론을 설명할 수 있으면 된다.
이 실습은 provider나 framework API를 외우는 대신 같은 fixture에서 제어 정책이 어떻게 달라지는지 확인한다. 외부 API와 write tool은 쓰지 않는다. get_weather(city)와 get_news(topic)은 입력에 대해 항상 같은 문자열을 돌려주는 deterministic tool이라고 가정한다.
먼저 다음 두 workflow를 코드가 허용한 node·edge 안에서 구성한다.
| 실습 | 고정할 제어 그래프 | 확인할 것 |
|---|---|---|
| Prompt chaining | 요청 정규화 -> 두 read tool -> 결과 합성 | 세 단계와 종료점이 입력마다 같고, 실패 branch를 코드로 열거할 수 있는가 |
| Routing | 분류 -> weather/news/both handler -> 합류 | LLM router가 분기를 골라도 허용 handler 밖의 tool을 만들 수 없는가 |
이 baseline은 agent보다 단순해서가 아니라, 동적 다음 행동이 필요하다는 증거를 찾기 위한 비교군이다.
아래 loop 하나면 hands-on의 핵심을 따라갈 수 있다. normal 판정을 step cap보다 먼저 두고, guard가 실행하지 않은 pending tool call도 같은 call id의 response로 닫는 순서에 주목한다.
run_turn(user_input, tenant_id, conversation_id): namespace = encode(tenant_id, conversation_id) require authenticated_tenant owns namespace
state = checkpoint.load(namespace) or {messages: []} state.original_goal = user_input state.guard = {step_count: 0, seen: set(), termination: "running"} append(state.messages, user(user_input))
while true: decision = model(bounded_input(state)) state.guard.step_count += 1 append(state.messages, decision)
calls = decision.tool_calls
if decision.has_final_answer and calls is empty: state.guard.termination = "normal" # normal이 cap보다 우선 checkpoint.save(namespace, state) return state
fingerprints = canonical(tool_name, arguments) for each call
if any fingerprint is already seen or duplicated in this batch: reason = "duplicate_tool_call" else if state.guard.step_count >= MAX_STEPS: reason = "step_limit" else: reason = null
if reason: for call in calls: append(state.messages, tool_response(call.id, skipped=reason)) append(state.messages, assistant_final(reason)) state.guard.termination = reason checkpoint.save(namespace, state) return state
add all fingerprints to state.guard.seen results = execute_and_correlate_by_call_id(calls) append(state.messages, results) checkpoint.save(namespace, state)Final text와 tool call이 한 decision에 함께 있으면 그 text를 terminal final로 인정하지 않는다. call을 실행할 수 있으면 matching response를 붙인 뒤 다음 model decision을 받고, cap·duplicate guard가 막으면 모든 call id에 skipped response를 붙인 뒤 guard가 만든 final assistant로 닫는다. 어느 경우든 AI(mixed decision)만 terminal history에 저장하거나 matching response 없이 종료해서는 안 된다.
Fingerprint는 tool name + canonical arguments로 만들고 매번 달라질 수 있는 call id는 넣지 않는다. 여기서 canonical arguments는 JSON object key를 재귀적으로 정렬하고 불필요한 공백을 제거하되, array 순서와 string·number·boolean 타입은 보존한 직렬화 결과다. 따라서 {"city":"서울","units":"metric"}과 key 순서만 바꾼 object는 같지만, array 순서나 단위·대소문자가 다르면 다른 인자로 취급한다. schema default 적용이나 Seoul과 서울 같은 도메인 동치화가 필요하면 fingerprint 전에 별도 정규화 정책을 명시해야 한다.
같은 batch 안의 중복과 이전 step의 반복을 모두 검사한다. 그러나 이것은 현재 turn의 반복 실행을 막는 local guard다. write tool의 재시도에는 사용자·tenant·resource·논리 작업을 묶은 business idempotency key가 별도로 필요하다.
| 상태 | 수명 | turn 시작 시 처리 | 검증 포인트 |
|---|---|---|---|
messages | conversation checkpoint | 이전 완결 history를 이어 받음 | pending call 없이 닫힌 history인가 |
original_goal | 현재 user turn | 새 입력으로 교체하고 실행 중 불변 | final answer가 같은 완료 기준을 만족하는가 |
step_count, seen, termination | 현재 user turn guard | 반드시 0, 빈 집합, running으로 reset | 이전 실패가 다음 turn을 즉시 막지 않는가 |
| checkpoint namespace | tenant + conversation | 인증된 tenant 범위에서 load | 같은 conversation id라도 tenant가 다르면 분리되는가 |
| long-term memory | 여러 conversation·process | 별도 검색·검증 정책 적용 | source·owner·유효기간·삭제 경로가 있는가 |
Namespace key가 다르다는 unit test만으로 authorization이 보장되지는 않는다. 실제 저장소 조회가 인증된 tenant filter를 강제하고, 다른 tenant의 같은 conversation id에 대한 read/write를 거절하는 integration test가 필요하다.
Provider 호출 없이 아래 case를 materialize한다. 각 행은 입력 state와 terminal history만으로 판정할 수 있어야 한다.
| Case | 입력 | 기대 결과 |
|---|---|---|
| 정상 완료와 cap 동시 도달 | step_count=MAX_STEPS에서 tool call 없는 final answer | termination=normal. 정상 완료가 cap보다 우선 |
| Final text + pending call | step_count=MAX_STEPS에서 text와 weather call이 함께 옴 | text만 저장해 정상 종료하지 않고, matching skipped response와 final assistant를 붙여 cap 종료 |
| Step cap + 두 pending call | 서로 다른 call id의 weather/news 요청 | 두 id 각각 정확히 한 matching response를 받고, 마지막 assistant는 tool call 없이 종료 |
| Duplicate call | 이전과 같은 tool name·canonical arguments, 새 call id | 실행하지 않고 duplicate_tool_call. call id가 달라도 중복으로 판정 |
| Batch 내부 duplicate | 한 decision에 같은 fingerprint 두 개 | 둘 다 실행하지 않고 각 call id를 response로 닫음 |
| 다음 turn reset | guarded failure 뒤 새 user input | 이전 messages만 계승하고 step_count=0, seen=[], termination=running에서 시작 |
| Namespace 파생 | tenant A/B가 같은 conversation id 사용 | 같은 입력에는 같은 namespace, tenant가 다르면 다른 namespace |
| Terminal history | 정상·cap·duplicate·두 pending 종료 | call id는 고유하고 각 call에 response가 정확히 하나이며, 마지막은 tool call 없는 assistant final |
Terminal history 검사는 전체 turn을 앞에서부터 읽는다. 새 user message는 직전 turn이 final assistant로 끝난 뒤에만 허용하고, assistant가 낸 call id의 batch 내부·history 전체 고유성을 먼저 확인한 뒤 pending 집합에 넣는다. 각 tool response는 pending id를 정확히 한 번만 제거해야 한다. 마지막 message까지 읽었을 때 pending이 비어 있어도 AI(tool call) -> Tool(result)로 끝났다면 닫힌 history가 아니다. 마지막은 반드시 tool call 없는 final assistant여야 한다.
아래 코드를 python3로 실행해 assertion이 없으면 표의 결정적 fixture만 통과한 것이다. tenant 접근 제어, 재시작 persistence, 실제 trace payload는 검사하지 않는다.
import json
MAX_STEPS = 4
def canonical_args(arguments): return json.dumps(arguments, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
def fingerprint(call): return f'{call["name"]}:{canonical_args(call["args"])}'
def has_duplicate(calls, seen): fingerprints = [fingerprint(call) for call in calls] return any(value in seen for value in fingerprints) or len(fingerprints) != len(set(fingerprints))
def namespace(tenant_id, conversation_id): return canonical_args([tenant_id, conversation_id])
def start_turn(messages, tenant_id, conversation_id): return {"messages": list(messages), "namespace": namespace(tenant_id, conversation_id), "guard": {"step_count": 0, "seen": [], "termination": "running"}}
def finish(step, *, final=False, calls=(), seen=()): call_ids = tuple(call["id"] for call in calls) history = [("user", None), ("assistant", call_ids)] if final and not calls: return "normal", history reason = "duplicate_tool_call" if has_duplicate(calls, set(seen)) else "step_limit" if step >= MAX_STEPS else "running" if reason != "running": history.extend(("tool", call_id) for call_id in call_ids) history.append(("assistant", ())) return reason, history
def is_closed(history): pending = set() issued = set() turn_open = False final_assistant = False for kind, value in history: if kind == "user": if pending or turn_open: return False turn_open, final_assistant = True, False elif kind == "assistant" and turn_open and not pending: call_ids = tuple(value) if len(call_ids) != len(set(call_ids)) or issued.intersection(call_ids): return False issued.update(call_ids) pending.update(call_ids) final_assistant = not call_ids if final_assistant: turn_open = False elif kind == "tool" and turn_open and value in pending: pending.remove(value) final_assistant = False else: return False return not pending and not turn_open and final_assistant
def call(call_id, name, args): return {"id": call_id, "name": name, "args": args}
old_call = call("old-1", "get_weather", {"city": "서울", "units": "metric"})new_id_same_args = call("new-1", "get_weather", {"units": "metric", "city": "서울"})batch_duplicate = (call("batch-1", "get_news", {"topic": "AI"}), call("batch-2", "get_news", {"topic": "AI"}))normal = finish(MAX_STEPS, final=True)mixed = finish(MAX_STEPS, final=True, calls=(call("mixed-1", "get_weather", {"city": "서울"}),))cap = finish(MAX_STEPS, calls=(call("cap-1", "get_weather", {"city": "서울"}),))duplicate = finish(MAX_STEPS, calls=(new_id_same_args,), seen=(fingerprint(old_call),))two_pending = finish(MAX_STEPS, calls=(call("weather-1", "get_weather", {"city": "서울"}), call("news-1", "get_news", {"topic": "AI"})))fixtures = (normal, cap, duplicate, two_pending)assert [reason for reason, _ in fixtures] == ["normal", "step_limit", "duplicate_tool_call", "step_limit"]assert all(is_closed(history) for _, history in fixtures)assert mixed[0] == "step_limit" and is_closed(mixed[1])assert has_duplicate((new_id_same_args,), {fingerprint(old_call)})assert has_duplicate(batch_duplicate, set())batch = finish(1, calls=batch_duplicate)assert batch[0] == "duplicate_tool_call" and is_closed(batch[1])
failed_guard = {"step_count": MAX_STEPS, "seen": [fingerprint(old_call)], "termination": "step_limit"}next_turn = start_turn(normal[1], "tenant-a", "conversation-1")assert failed_guard != next_turn["guard"]assert next_turn["guard"] == {"step_count": 0, "seen": [], "termination": "running"}assert next_turn["messages"] == normal[1]assert namespace("tenant-a", "conversation-1") == namespace("tenant-a", "conversation-1")assert namespace("tenant-a", "conversation-1") != namespace("tenant-b", "conversation-1")
assert is_closed([("user", None), ("assistant", ())])assert not is_closed([("user", None), ("assistant", ("missing",))])assert not is_closed([("user", None), ("tool", "orphan")])assert not is_closed([("user", None), ("assistant", ("x",)), ("tool", "wrong")])assert not is_closed([("user", None), ("assistant", ("x",)), ("tool", "x"), ("tool", "x"), ("assistant", ())])assert not is_closed([("user", None), ("assistant", ("same", "same")), ("tool", "same"), ("assistant", ())])assert not is_closed([("user", None), ("assistant", ("reused",)), ("tool", "reused"), ("assistant", ()), ("user", None), ("assistant", ("reused",)), ("tool", "reused"), ("assistant", ())])assert not is_closed([("user", None), ("assistant", ("x",)), ("tool", "x")])set은 pending lookup에만 쓰며, assistant call id를 넣기 전에 list 길이와 set 길이를 비교한다. 이 순서가 없으면 같은 id를 두 번 낸 잘못된 batch가 하나로 합쳐져 통과할 수 있다. issued는 이전 batch에서 이미 쓴 id의 재사용을 막고, pending에서 제거된 id에 다시 온 response는 duplicate 또는 unmatched response로 거부한다.
아래 항목은 실제 checkpointer·저장소·trace backend가 있어야 검증할 수 있으므로 위 snippet의 성공 범위와 분리한다.
| Case | Integration setup | 통과 조건 |
|---|---|---|
| Tenant 실제 접근 거부 | tenant A가 저장한 뒤 tenant B credential로 같은 id 조회 | namespace 문자열이 다른 데 그치지 않고 B의 read·write가 저장소 계층에서 거부됨 |
| Checkpointer 재시작 | process를 종료·재기동해 같은 namespace 조회 | in-memory 구현은 state 소실, persistent 구현은 retention 범위 안에서 closed state와 replay 유지 |
| Trace field 완전성 | 정상·cap·duplicate 실행을 trace backend로 전송 | goal, step, call id, fingerprint, result, token·cost·latency, termination reason을 연결해 조회 |
MAX_STEPS=4이고 네 번째 model decision을 받았다고 하자. cap 숫자는 같아도 decision의 모양에 따라 terminal history가 달라진다.
| 네 번째 decision | 올바른 route | 닫힌 history | 잘못된 구현이 만드는 실패 |
|---|---|---|---|
| tool call 없는 final answer | normal | AI(final) | cap을 먼저 검사해 이미 끝난 작업을 step_limit으로 오판 |
| weather와 news 두 call | step_limit | AI(calls) -> Tool(skipped weather) -> Tool(skipped news) -> AI(final) | 첫 call만 닫아 다음 turn에서 unresolved news call 발생 |
| 이전 step과 같은 weather arguments | duplicate_tool_call | AI(call) -> Tool(skipped duplicate) -> AI(final) | 새 call id만 보고 다시 실행해 stuck loop 지속 |
이 예제의 핵심은 종료 reason 문자열이 아니라 history가 다음 turn에 이어질 수 있는 상태인가다. Guard가 tool을 실행하지 않았더라도 모델이 이미 call을 제안했다면 각 call에 대응하는 관찰 결과를 남겨야 한다. 반대로 final answer에는 pending call이 없으므로 가짜 tool response를 만들지 않는다.
Fixture는 city=서울, topic=AI이고 두 방식 모두 weather와 news를 한 번씩 읽어 같은 결과를 만든다고 하자.
| 방식 | tool calls | policy decisions | 해석 |
|---|---|---|---|
| 고정 workflow | 2 | 0 | 두 tool과 합류점이 코드에 이미 정해져 있다 |
| scripted agent | 2 | 3 | weather 선택, news 선택, 종료 판단이 추가된다 |
결과와 tool 호출 수가 같은데 agent에만 세 번의 policy decision이 생긴다. 이 숫자는 외부 성능 주장이 아니라 fixture에서 결정적으로 나온다. 관찰에 따라 세 번째 tool이 필요해지는 labelled case가 반복되고, 그 성공률 증가가 token·tool 비용과 p95 latency 증가보다 클 때 agent의 동적 선택이 가치를 증명한다.
Checkpoint state를 이어 쓰는 두 번째 turn에서는 이전 messages가 남아도 guard는 새로 시작해야 한다. in-memory checkpointer는 process lifetime에만 존재하고, persistent checkpointer는 외부 storage와 retention 정책이 보장하는 범위에서 재시작 뒤에도 남는다. 어느 쪽도 사용자 선호나 도메인 사실의 정확성을 보장하는 long-term memory가 아니다.
| 관찰 목표 | 최소 evidence | 실패 신호 |
|---|---|---|
| 정상 replay | 같은 namespace의 closed checkpoint에서 같은 fixture 재개 | checkpoint 위치를 배열 index로 추측하거나 pending call 뒤에서 재개 |
| Memory contamination 복구 | 잘못된 fact가 들어오기 전 checkpoint와 별도 memory hit source | checkpoint만 되돌렸는데 오염된 long-term memory가 다시 검색됨 |
| Goal drift 복구 | 마지막 on-goal checkpoint, immutable original goal, 새 branch trace | drift 이후 pending action을 실행하거나 새 답이 원래 done criteria를 벗어남 |
| 비용·지연 관측 | step별 token, tool latency, retry, critical path | 평균만 보고 p95·실패 retry·병렬 total work를 누락 |
| Multi-agent 비교 | agent 간 message 수, merge 실패, 성공률 | 통신 비용만 늘고 single-agent baseline보다 품질이 낮음 |
같은 짧은 기술 설명과 사람이 판정한 작은 gold dataset을 사용한다. Critic loop는 Generator -> Critic -> Reviser를 최대 2회 실행하고, 정확성·누락·구체성·톤·위험한 주장 5개 항목을 고정한다.
| 검증 | 가상 정책 | 관찰 지표 |
|---|---|---|
| 품질 종료 | score >= 0.85이고 critical issue 0 | score before/after, human override |
| 수렴 종료 | score_delta < 0.02 | round별 개선 폭 |
| 비용 종료 | max_rounds=2, max_cost_per_request=$0.20 | round count, tokens/cost per round |
| Judge bias | same-model critic과 cross-model critic 비교 | disagreement rate, 길이·순서 편향 |
| 고위험 action | security/payment 판단 발생 | 반복 대신 human escalation |
| Reasoning 비교 | 일반 모델 multi-step vs reasoning 모델 single-step | 성공률, 총 token, step 수, p95 latency |
Critic이 pass했다는 사실만으로 정답은 아니다. Gold dataset과 human override에서 critic의 precision/recall을 확인하고, 두 critic이 충돌하면 반복을 늘리기보다 사람에게 넘긴다.
| 증상 | 먼저 확인할 계약 | 교정 방향 |
|---|---|---|
| loop가 멈추지 않는다 | normal/duplicate/cap route 순서, per-turn guard | 종료 reason을 terminal state와 trace에 함께 기록 |
| 필요한 tool이 호출되지 않는다 | 해당 fixture에 tool이 정말 필요한지, 노출 schema·description | labelled fixture와 tool 선택 eval을 먼저 고침 |
| 두 번째 turn이 즉시 cap에 걸린다 | guard reset과 checkpoint merge 규칙 | messages만 이어 받고 실행 guard는 reset |
| 두 번째 turn에 history가 없다 | namespace 변경, in-memory saver 재생성·process restart | persistence 요구에 맞는 checkpointer 선택 |
| multi-agent가 더 나쁘다 | 공유 context 의존, message·merge overhead | single agent나 고정 workflow로 축소 |
| cost runaway가 난다 | step, retry, tool 내부 비용, long context | 요청별 budget, 중복 guard, result 압축, smaller model routing |
실습 성공 기준은 두 층으로 나뉜다. 위 순수 snippet은 정상·중복·cap 우선순위, call-response 상관관계, canonical fingerprint, turn reset, namespace 파생 fixture만 통과시키면 된다. tenant 접근 거부·checkpointer 재시작 지속성·trace field 완전성은 실제 integration 환경에서 별도로 통과해야 한다. 그 결과를 바탕으로 같은 fixture의 workflow와 agent 제어 비용 차이, checkpoint state와 long-term memory의 차이를 설명할 수 있으면 된다.
최종 수정: 2026-07-10