Field Log · Entry

RAG Agent는 무엇인가: Workflow에서 상태 기반 제어 루프로 (1/10)

사용자 목표가 상태 저장소, LLM 행동 제안, 정책 게이트, 도구 실행, 관찰과 근거 판정을 순환한 뒤 답변 또는 실패로 끝나는 RAG Agent Harness

오늘의 결론

  • Agent는 “여러 번 호출하는 LLM”이 아니라 관찰에 따라 다음 행동을 선택하는 상태 기반 실행 시스템입니다.
  • LLM은 행동 후보를 제안하고, harness는 schema·권한·예산·상태 전이를 검증합니다.
  • prompt와 대화 기록만으로 상태를 대신하지 않습니다. 실행 상태는 typed data로 따로 저장합니다.
  • 모든 loop에는 성공·실패·승인 대기·예산 소진이라는 명시적 종료 상태가 필요합니다.
  • 첫 구현은 자유로운 자율성보다 작은 action 집합과 deterministic transition에서 시작합니다.

앞 글까지 문서를 넣고 검색하고 재정렬하고 평가하는 RAG pipeline을 만들었습니다.

question → retrieve → rerank → context → generate → answer

이 흐름은 입력이 같으면 대체로 같은 단계를 한 번씩 거치는 workflow입니다. 그런데 실제 사용자는 이런 요청도 보냅니다.

지난달 환불 정책과 오늘 정책을 비교해 줘. 현재 문서에서 날짜가 확인되지 않으면 다른 저장소도 찾고, 그래도 불확실하면 확답하지 마.

한 번의 검색으로 충분한지, 검색어를 바꿔야 하는지, 다른 source를 열어야 하는지, 사용자에게 날짜를 물어야 하는지는 중간 결과를 본 뒤 결정해야 합니다. 여기서 고정 pipeline이 agent loop로 바뀝니다.

RAG Agent의 상태 기반 observe-decide-act loop와 이를 감싸는 deterministic harness

그림 1. LLM은 다음 행동을 제안할 뿐이다. 실행 전후의 schema 검증, 정책, 예산, checkpoint, 종료 판정은 harness가 소유한다.


세 단어를 먼저 구분하기

Agent 문헌과 제품 문서는 같은 단어를 조금씩 다르게 씁니다. 이 시리즈에서는 다음처럼 정의합니다.

Pipeline

미리 정한 단계를 거의 같은 순서로 통과합니다.

A → B → C → D

예:

  • parse → chunk → embed → index
  • retrieve → rerank → generate
  • 입력 검증 → DB 조회 → 응답 직렬화

장점은 단순성, 재현성, 낮은 비용입니다. 중간 결과에 따라 경로를 크게 바꿀 필요가 없다면 pipeline이 가장 좋은 선택입니다.

Workflow

조건 분기, 병렬 실행, 재시도처럼 제어 흐름이 있지만 가능한 경로를 개발자가 정의합니다.

             ┌─ BM25 ─┐
query → route           ├→ fuse → answer
             └─ dense ─┘

Agent 없이도 상당히 복잡한 RAG를 만들 수 있습니다. “분기가 있다”는 이유만으로 agent는 아닙니다.

Agent

현재 goal과 observation을 보고 다음 action을 runtime에 선택합니다.

state → decide → action → environment → observation → new state
  ↑                                                    │
  └────────────────────────────────────────────────────┘

ReAct는 reasoning과 environment action을 번갈아 생성하는 대표적인 패턴을 제시했습니다. 중요한 것은 긴 생각을 출력하는 형식이 아니라, 행동 결과가 다음 의사결정의 입력으로 돌아온다는 구조입니다.

자율성은 이분법이 아니라 연속선이다

RAG 시스템을 다음 네 단계로 볼 수 있습니다.

단계경로를 정하는 주체예시
고정 pipeline코드항상 hybrid search 후 rerank
조건부 workflow규칙·분류기identifier 질문이면 BM25 비중 증가
제한된 agentLLM 제안 + 코드 gate검색어와 read tool을 동적으로 선택
개방형 agentLLM이 넓은 action 공간 탐색웹·코드·메시지·업무 API를 장시간 사용

RAG Agent를 처음 만든다면 세 번째 단계가 적절합니다. 검색과 읽기처럼 좁은 도구만 주고, 쓰기나 삭제는 별도 승인 경계로 둡니다.

“더 agent답다”는 것이 “더 좋다”는 뜻은 아닙니다. 동적 선택으로 얻는 품질이 비용·지연·실패 표면 증가보다 클 때만 agent loop를 사용합니다.

Agent의 최소 구성 요소 여섯 가지

1. Goal

사용자가 달성하려는 결과입니다. 원문을 그대로 보관하되 실행 가능한 조건으로 정규화합니다.

{
  "objective": "지난달과 현재 환불 정책의 차이를 근거와 함께 설명",
  "constraints": [
    "공식 정책 문서만 사용",
    "각 주장에 source와 revision 표시",
    "날짜를 검증할 수 없으면 불확실성 보고"
  ],
  "success_criteria": [
    "두 revision을 모두 확보",
    "변경 항목별 citation 존재"
  ]
}

Goal이 “좋은 답을 써라”뿐이면 종료 판정도 모호해집니다.

2. State

지금까지 확인된 사실과 실행 진행 상황의 authoritative record입니다.

from dataclasses import dataclass, field
from enum import StrEnum


class Status(StrEnum):
    RECEIVED = "received"
    SEARCHING = "searching"
    CHECKING = "checking"
    ANSWERING = "answering"
    WAITING_APPROVAL = "waiting_approval"
    DONE = "done"
    FAILED = "failed"


@dataclass
class AgentState:
    run_id: str
    goal: str
    status: Status = Status.RECEIVED
    query: str | None = None
    evidence_ids: list[str] = field(default_factory=list)
    step: int = 0
    tool_calls: int = 0
    elapsed_ms: int = 0
    cost_usd: float = 0.0
    failure_code: str | None = None

대화 message 배열은 state의 일부일 수 있지만 전부는 아닙니다. 현재 상태, 사용한 예산, 승인 여부, 확보한 evidence ID를 자연어 transcript에서 매번 추측하게 만들지 않습니다.

3. Observation

환경에서 읽은 결과입니다.

{
  "tool_call_id": "call_017",
  "tool": "search_policy",
  "ok": true,
  "items": [
    {"source_id": "policy/refund", "revision": "2026-07-01", "score": 0.91}
  ],
  "latency_ms": 84,
  "truncated": false
}

Observation은 tool의 원시 문자열보다 구조화된 envelope가 좋습니다. ok, error, truncated, provenance가 있어야 다음 상태를 안전하게 계산할 수 있습니다.

4. Action

Agent가 환경에 요청할 수 있는 제한된 동작입니다.

Search(query, filters)
Read(source_id, revision, span)
AskUser(question)
DraftAnswer(evidence_ids)
Finish(answer)
Fail(code, message)

DoAnything(text) 같은 범용 action 하나보다 작은 union이 검증과 평가에 유리합니다. 다음 글에서 JSON Schema 계약으로 구체화합니다.

5. Policy

state와 observation을 보고 action 후보를 제안하는 함수입니다.

π(action | state, observation)

LLM, 작은 router model, 규칙, 혹은 이들의 조합이 policy가 될 수 있습니다. “Agent = LLM”이 아니라 LLM이 policy 구현의 한 부분인 셈입니다.

6. Harness

policy를 실제 시스템 안에서 통제하는 실행 껍질입니다.

  • prompt와 tool definition 구성
  • model 호출과 structured output parsing
  • action schema 검증
  • allowlist·권한·승인 정책
  • timeout·retry·step·token·cost budget
  • tool 실행과 observation 정규화
  • state transition과 checkpoint
  • trace·metric·artifact 기록
  • 종료 조건과 fallback

모델을 바꿔도 이 책임은 사라지지 않습니다.

LLM과 Harness의 책임을 나누는 기준

판단 기준은 간단합니다.

확률적으로 틀려도 다시 평가할 수 있는 선택은 모델에 맡기고, 시스템의 사실·권한·부작용을 결정하는 일은 코드가 맡는다.

LLM이 제안해도 되는 것Harness가 결정해야 하는 것
검색어 후보해당 tool 사용 권한
질문의 복잡도 추정최대 step과 비용
근거가 부족해 보이는 이유evidence가 실제 source에 존재하는지
다음에 읽을 문서 후보tenant·ACL filter 강제
답변 초안citation ID 유효성
사용자에게 물을 질문쓰기 action 승인 필요 여부

LLM이 “예산을 조금 더 쓰겠다”거나 “이 문서는 믿을 만하다”고 말해도 그것은 proposal이지 authoritative state가 아닙니다.

상태 전이는 reducer가 소유한다

Agent loop에서 자주 생기는 문제는 model output을 state에 그대로 합치는 것입니다.

# 위험한 예: 모델이 보낸 필드가 시스템 상태를 덮어쓴다.
state.update(model_json)

대신 action과 observation을 입력으로 받는 작은 reducer를 둡니다.

from dataclasses import replace


def reduce_search_result(state: AgentState, observation: dict) -> AgentState:
    if state.status not in {Status.RECEIVED, Status.SEARCHING}:
        raise ValueError(f"search result is invalid in {state.status}")

    if not observation["ok"]:
        return replace(
            state,
            status=Status.FAILED,
            failure_code=observation["error"]["code"],
            step=state.step + 1,
            tool_calls=state.tool_calls + 1,
        )

    evidence_ids = list(dict.fromkeys(
        state.evidence_ids
        + [item["source_id"] for item in observation["items"]]
    ))
    return replace(
        state,
        status=Status.CHECKING,
        evidence_ids=evidence_ids,
        step=state.step + 1,
        tool_calls=state.tool_calls + 1,
    )

이 함수에서 확인할 수 있는 사실:

  • 어떤 상태에서 이 observation을 받을 수 있는가
  • error가 terminal인지 retryable인지
  • step과 tool call이 정확히 한 번 증가하는가
  • evidence ID가 중복 없이 추가되는가
  • model이 status나 cost를 임의로 바꿀 수 없는가

이것이 prompt만으로는 얻기 어려운 상태 불변식입니다.

종료 상태를 먼저 설계한다

Agent를 만들 때 흔히 첫 action부터 생각하지만, 먼저 어디에서 멈출지 정의해야 합니다.

성공 종료

status = DONE
answer != empty
every factual claim → valid evidence_id
success criteria satisfied

안전한 불완전 종료

status = DONE
answer = "현재 자료로는 날짜를 확인할 수 없습니다."
uncertainty is explicit

모든 질문에 내용을 채워 넣는 것보다 모른다고 끝내는 것이 성공일 수 있습니다.

승인 대기

status = WAITING_APPROVAL
pending_action = SendEmail(...)
approval_scope = exact recipient + subject + body hash

프로세스를 실패시키지 않고 durable하게 멈춘 상태입니다.

실패 종료

status = FAILED
failure_code = "TOOL_TIMEOUT_EXHAUSTED"
last_checkpoint = "cp_006"
retryable = false

예산 종료

status = FAILED
failure_code = "STEP_BUDGET_EXCEEDED"
partial_evidence = [...]

예산 소진을 exception 하나로 숨기지 말고 분석 가능한 domain state로 남깁니다.

최소 실행 loop

아래 코드는 framework가 아니라 책임 경계를 보여 주는 skeleton입니다.

TERMINAL = {Status.DONE, Status.FAILED, Status.WAITING_APPROVAL}


def run_agent(initial: AgentState, deps) -> AgentState:
    state = initial
    deps.checkpoints.save(state)

    while state.status not in TERMINAL:
        # 1. 모델 호출 전에 deterministic budget을 검사한다.
        violation = deps.budget.check(state)
        if violation:
            state.status = Status.FAILED
            state.failure_code = violation.code
            deps.checkpoints.save(state)
            break

        # 2. LLM은 다음 action 후보만 제안한다.
        proposal = deps.policy.propose(state)

        # 3. schema, 상태, 권한, 승인을 코드로 검사한다.
        decision = deps.gate.authorize(state, proposal)
        if not decision.allowed:
            state = deps.reducer.reject(state, proposal, decision)
            deps.checkpoints.save(state)
            continue

        # 4. side effect는 model process 밖의 executor가 수행한다.
        observation = deps.executor.execute(decision.action)

        # 5. action + observation으로 새 상태를 계산한다.
        state = deps.reducer.apply(state, decision.action, observation)
        deps.trace.record(state, decision.action, observation)
        deps.checkpoints.save(state)

    return state

이 loop의 좋은 점은 model provider를 바꾸어도 budget, authorization, execution, state transition, trace의 의미가 유지된다는 것입니다.

한 요청을 state transition으로 따라가기

질문:

현재 환불 기한이 지난달보다 짧아졌는지 알려 줘.

가능한 trajectory:

Step상태제안 actionObservation다음 상태
0RECEIVEDSearch(current refund policy)current revision 발견CHECKING
1CHECKINGRead(current revision)기한 14일, 발효일 확인SEARCHING
2SEARCHINGSearch(previous revision)2026-06 revision 발견CHECKING
3CHECKINGRead(previous revision)기한 30일, 발효일 확인ANSWERING
4ANSWERINGDraftAnswer(two evidence IDs)citation validator 통과DONE

여기서 step 1 뒤에 이전 revision이 없다면 AskUser, archive search, 혹은 “비교 불가” 종료로 갈 수 있습니다. 경로 선택은 동적이지만 허용된 상태와 action은 유한합니다.

Harness가 강제할 기본 불변식

상태 불변식

  • DONE이면 answer가 있다.
  • FAILED이면 stable failure code가 있다.
  • WAITING_APPROVAL이면 정확한 pending action이 있다.
  • step은 감소하지 않는다.
  • tool call count는 실제 execution record와 같다.

근거 불변식

  • 모든 evidence ID는 현재 tenant에서 읽을 권한이 있다.
  • source revision과 span이 존재한다.
  • answer citation은 context에 실제 포함된 evidence만 가리킨다.
  • retrieval 결과의 텍스트가 instruction 권한을 얻지 않는다.

예산 불변식

  • step, wall-clock, tool call, token, 금액 각각에 hard limit가 있다.
  • retry도 예산을 소비한다.
  • 병렬 action은 완료 수가 아니라 시작 시점에 budget을 reserve한다.

부작용 불변식

  • read와 write action을 구분한다.
  • retry 가능한 write는 idempotency key를 가진다.
  • 고위험 action은 실행 직전 승인을 다시 확인한다.
  • 실행 결과는 model의 서술이 아니라 executor receipt로 확정한다.

뒤의 글에서 하나씩 구현합니다.

“생각”을 state로 저장해야 할까

내부 추론 전체를 영구 저장하는 것과 운영에 필요한 decision record는 다릅니다.

권장 기록:

{
  "decision": "SEARCH_PREVIOUS_REVISION",
  "reason_code": "MISSING_COMPARISON_BASELINE",
  "inputs": ["evidence_current_2026_07"],
  "alternatives": ["ASK_USER", "FINISH_UNCERTAIN"],
  "policy_version": "router-3.2"
}

피할 기록:

  • 검증할 수 없는 장문의 자유 형식 독백
  • secret이나 개인 정보가 섞인 전체 prompt dump
  • 재현에 필요하지 않은 model 내부 reasoning
  • state와 중복되지만 서로 불일치할 수 있는 자연어 요약

운영자는 “왜 이 action이 허용됐는가”를 추적할 수 있어야 하지만, 이를 위해 모든 hidden reasoning을 저장할 필요는 없습니다.

흔한 실패 패턴

1. Message history가 곧 state다

오래된 대화에서 승인, 예산, 최신 revision을 다시 추론하게 됩니다. 한 번의 요약 오류가 authoritative state를 바꿉니다.

처방: transcript와 typed execution state를 분리합니다.

2. Model JSON을 그대로 merge한다

model이 status: done, approved: true를 출력해도 사실로 취급됩니다.

처방: action schema만 받고 reducer가 허용된 필드만 갱신합니다.

3. “최대 10번만 해”를 prompt에만 쓴다

prompt 지시는 hard limit가 아닙니다.

처방: loop 바깥 counter와 deadline으로 차단합니다.

4. 모든 오류를 다시 LLM에게 보낸다

인증 실패, schema bug, rate limit, source 없음이 모두 “다시 생각해 봐”가 됩니다.

처방: retryable·repairable·terminal·approval-required error taxonomy를 둡니다.

5. 종료 판정도 같은 model 한 번에 맡긴다

답을 쓰고 스스로 충분하다고 평가하는 이해충돌이 생깁니다.

처방: deterministic requirement, evidence coverage, 별도 evaluator를 조합합니다.

6. 자유도를 성능으로 착각한다

tool과 step을 늘리면 우연히 성공하는 trial은 늘 수 있지만 비용과 분산도 커집니다.

처방: 동일 task를 여러 번 실행해 성공률뿐 아니라 일관성을 봅니다.

첫 Agent를 만드는 현실적인 순서

  1. 현재 RAG pipeline을 그대로 baseline으로 둡니다.
  2. 실패 유형 하나를 고릅니다. 예: “근거가 부족한데 바로 답함”.
  3. action을 Search, Read, Finish, Fail 네 개로 제한합니다.
  4. state schema와 terminal state를 정의합니다.
  5. evidence 부족일 때만 한 번 재검색하게 합니다.
  6. step·time·cost hard limit를 추가합니다.
  7. 모든 transition을 trace로 남깁니다.
  8. 고정 평가셋에서 pipeline과 agent를 비교합니다.

Agent가 도움이 됐는지 판단할 최소 표:

MetricPipelineAgent해석
task success72%79%실제 목표 달성 증가
unsupported answer9%4%근거 판정 효과
p95 latency1.2s2.8sloop 비용
mean tool calls1.02.3환경 사용량
mean cost$0.006$0.014품질과 교환한 비용
repeated-run pass rate68%70%분산까지 고려

성공률 7%p가 중요한지, latency와 비용 증가를 감당할 수 있는지는 제품 요구가 결정합니다.

Production 체크리스트

  • pipeline, workflow, agent 중 왜 agent가 필요한지 실패 사례로 설명할 수 있다.
  • goal, state, observation, action, policy, harness를 분리했다.
  • transcript와 authoritative state를 구분했다.
  • action은 작은 typed union이다.
  • model output을 state에 직접 merge하지 않는다.
  • 성공·안전한 불완전·승인 대기·실패·예산 종료가 있다.
  • step·time·tool·token·cost limit를 코드가 강제한다.
  • tool side effect는 별도 executor와 receipt로 확정한다.
  • 각 transition 뒤 checkpoint와 trace가 남는다.
  • 고정 pipeline baseline과 품질·지연·비용·일관성을 비교한다.

스스로 확인하기

  1. 조건 분기가 있는 workflow와 agent의 가장 중요한 차이는 무엇인가?
  2. LLM이 다음 검색어는 제안해도 되지만 남은 비용은 결정하면 안 되는 이유는 무엇인가?
  3. message history만으로 승인 상태를 관리하면 어떤 재실행 문제가 생기는가?
  4. DONE, FAILED, WAITING_APPROVAL을 서로 다른 상태로 두면 운영에서 무엇이 쉬워지는가?
  5. 여러분의 현재 RAG에서 agent loop가 실제로 해결할 실패 유형 하나는 무엇인가?

다음 글에서는 action을 자연어가 아닌 JSON Schema 기반 tool contract로 만들고, 입력 검증·오류 envelope·tool version·부작용 metadata를 설계합니다.

참고자료