1편의 물음표에서 시작한다 — “일반 LLM을 어떻게 일하는 에이전트로 바꿀 것인가?” ReAct 패턴과 Akka Become() 상태 머신이 만나는 자리. 작성일: 2026-04-13. 대상: 1편을 읽은 .NET / 서버 개발자.

0. 들어가며 — 1편의 물음표, 그 이후

1편에서 우리는 Akka.NET 액터 모델로 여러 AI CLI 터미널 사이의 통신 복잡성을 풀었다. 설계 → 구현 → 개선 → 테스트를 거치며 “사회자 LLM + 3개 Claude 터미널” 시나리오가 동작하는 것을 확인했다.

그러나 1편의 마지막은 물음표였다.

“Akka 스택은 설계 가능한 AI 에이전트에 도움이 될 것인가?”

이번 2편은 그 물음표를 느낌표로 바꾸려는 시도다. 핵심 질문은 이것이다:

일반 LLM(펑션콜만 가능한 모델)을 “스스로 생각하고, 행동하고, 기다리고, 판단하는” 워크 에이전트로 변신시킬 수 있는가?

답은 ReAct 패턴 + Akka Become() 상태 머신에 있다. 이 글에서는 그 설계를 기술적으로 깊이 파고든다.


1. 일반 LLM은 왜 에이전트가 아닌가

1.1 동기 루프의 한계

현재 AgentWin의 RunFunctionCallLoopAsync는 다음과 같이 동작한다:

사용자 입력 → RunFunctionCallLoopAsync (최대 10라운드)
               ├── LLM → tool call → 실행 → 결과 → LLM → ...
               └── return ← LLM이 tool call을 멈추면 종료

               이후 터미널에서 "작업 완료" 시그널 도착
               → 세션 메모리에만 기록
               → LLM은 다음 사용자 입력까지 비활성

문제의 핵심: LLM이 tool call을 멈추면 루프가 끝나고, 이후 외부 완료 시그널이 와도 LLM을 다시 깨울 방법이 없다.

1.2 실측 데이터 — 하네스 로그에서 확인된 증상

증상

수치

근본 원인

term_read 폴링 폭주

55초간 12회 동일 데이터

비동기 완료를 기다릴 수 없어서 반복 읽기

턴 완료 시그널 미응답 → 사회자 교착

E2E 미팅 교착

루프 종료 후 시그널 수신 불가

컨텍스트 폭증

15분에 10K→30K chars

폴링 반복으로 히스토리 누적

잔여 작업 미진행

계획의 60%만 완료

루프 종료 = LLM 비활성

이 증상들은 모두 하나의 구조적 결함을 가리킨다: “완료 시그널 부재(Completion Signal Absence)”. LLM은 명령을 보내놓고 결과를 기다릴 수 없으며, 기다리려고 폴링하면 컨텍스트가 폭발한다.

1.3 에이전트와 LLM의 차이

특성

일반 LLM (펑션콜)

워크 에이전트

실행 모델

동기 루프, 라운드 제한

이벤트 기반, 시그널 반응

비동기 작업

폴링으로 대체

완료 시그널 대기

실패 복구

재시도 없음 (라운드 소진)

자동 재시도 / 에스컬레이션

상태 추적

히스토리 의존 (윈도우 제한)

명시적 상태 머신

타임아웃

전체 루프 단위

단계별 세밀한 제어

결론: 일반 LLM에 “기다림”과 “깨어남”을 가르치는 것이 에이전트화의 핵심이다.


2. ReAct — 추론과 행동의 합주

2.1 ReAct란 무엇인가

ReAct(Reasoning + Acting)는 Yao et al.(2022)이 제안한 패턴으로, LLM이 생각(Thought) → 행동(Action) → 관찰(Observation)을 반복하며 과제를 수행하는 구조다.

Thought 1: 사용자가 npm test를 요청했다. 먼저 터미널에 명령을 보내야 한다.
Action 1:  term_send("npm test")
Observation 1: "Sent to term-0" (즉시 반환)

Thought 2: 테스트가 실행 중이다. 결과가 나올 때까지 기다려야 한다.
Action 2:  [대기 — 완료 시그널 수신]
Observation 2: "TaskComplete: 15 passed, 2 failed"

Thought 3: 2개 실패했다. 실패 로그를 확인하자.
Action 3:  term_read("term-0")
Observation 3: "FAIL: auth.test.js — Expected 200, got 401..."

Thought 4: 인증 테스트 실패다. 결과를 사용자에게 보고한다.
Action 4:  [최종 응답 생성]

핵심은 Action 2의 “대기”다. 기존 동기 루프에서는 이 대기가 불가능하다. ReAct의 논문 원문은 관찰이 즉시 반환되는 것을 가정하지만, 실제 시스템에서는 npm test가 30초 걸릴 수 있다. 이 간극을 메우는 것이 Akka 상태 머신이다.

2.2 다른 플래닝 패턴과의 비교

패턴

전략

장점

한계

ReAct

생각→행동→관찰 반복

관찰에 근거한 추론, 환각 감소

비동기 대기 미지원 (원본)

Plan-and-Execute

전체 계획 수립 후 순차 실행

예측 가능한 작업에 효율적

계획 수정이 어려움

Tree-of-Thought

여러 추론 경로 탐색, 가지치기

복잡한 문제 해결력

LLM 호출 비용 높음

LATS

몬테카를로 트리 탐색 + LLM

최적 경로 탐색

실시간 시스템에 부적합

Reflexion

실패 후 자기 성찰, 교훈 기억

반복 학습 가능

추가 메모리 관리 필요

AgentWin이 ReAct를 선택한 이유: 실시간 도구 실행 + 비동기 환경에서는 “한 번에 전체 계획”보다 “한 걸음씩 관찰하며 진행”이 현실적이다. 다만 원본 ReAct에 없는 Waiting 상태를 추가해야 한다.

2.3 ReAct의 학술적 배경

ReAct 논문(Yao et al., 2022, arXiv:2210.03629)은 HotpotQA와 FEVER 벤치마크에서 chain-of-thought 대비 높은 정확도를 보였다. 핵심 기여는 추론 트레이스(reasoning trace)를 행동과 인터리브함으로써 LLM이 환각에 빠지지 않고 실제 관찰에 근거하여 다음 단계를 결정하도록 한 것이다.

2025–2026년 현재, ReAct는 도구 사용 에이전트의 사실상 표준(de facto standard)이 되었다. OpenAI Agents SDK, Anthropic Claude의 tool-use, Google Gemini의 function-calling 모두 ReAct의 변형을 내부적으로 구현한다. 차이점은 자유 텍스트 파싱에서 구조화된 JSON 스키마 기반 도구 호출로 진화한 것이다.


3. Akka Become() — 상태 머신을 코드로 빚다

3.1 Become 패턴의 원리

Akka의 Become()은 액터의 메시지 핸들러를 런타임에 교체하는 메커니즘이다. 전통적인 switch/case 상태 머신과 달리, 각 상태가 독립된 메서드로 분리되어 상태 폭발(state explosion)을 방지한다.

public class ReActActor : UntypedActor
{
    private int _round = 0;
    private const int MaxRounds = 10;

    // 초기 상태: Thinking
    protected override void OnReceive(object message) => Thinking(message);

    private void Thinking(object msg)
    {
        switch (msg)
        {
            case StartReAct start:
            case CompletionSignal signal:
                _round++;
                // LLM 호출 → tool calls 유무로 분기
                var response = CallLlm(msg);
                if (response.HasToolCalls)
                    Become(Acting);  // → Acting 상태로 전환
                else
                    Become(Complete); // → 최종 응답
                break;
        }
    }

    private void Acting(object msg)
    {
        // 도구 실행
        var result = ExecuteTool(msg);
        if (result.IsAsync)
        {
            SetReceiveTimeout(TimeSpan.FromSeconds(30));
            Become(Waiting);   // → 비동기 대기
        }
        else
            Become(Thinking);  // → 즉시 결과, 다시 생각
    }

    private void Waiting(object msg)
    {
        switch (msg)
        {
            case CompletionSignal signal:
                SetReceiveTimeout(null); // 타임아웃 해제
                Become(Thinking);        // → 시그널 도착, 다시 생각
                break;
            case ReceiveTimeout:
                Become(Thinking);        // → 타임아웃, 현재 상태 확인
                break;
        }
    }
}

3.2 왜 switch/case가 아닌 Become인가

관점

switch/case FSM

Akka Become()

상태 추가

case 문 추가, 모든 메시지에 상태 체크

메서드 하나 추가, 해당 상태 메시지만 처리

스레드 안전

lock 필요

액터 모델이 보장 (단일 스레드 처리)

잘못된 상태의 메시지

무시 또는 예외

Stash()로 보관, 나중에 Unstash()

타임아웃

별도 타이머 관리

ReceiveTimeout 내장

실패 복구

try/catch 중첩

Supervision Strategy

테스트

전체 FSM 초기화 필요

TestKit으로 상태별 메시지 주입

3.3 핵심: 액터는 죽지 않는다

동기 루프와 액터의 결정적 차이:

동기 루프:  for → return → 끝. 이후 메시지는 허공으로 사라진다.
액터:       Become(Waiting) → 메시지 수신 대기. 언제든 깨어난다.

이것이 “일반 LLM을 워크 에이전트로 변신시키는” 핵심 메커니즘이다. LLM 자체는 변하지 않는다. 변하는 것은 LLM을 감싸는 실행 환경이다. Akka 액터가 LLM에게 “기다림”과 “깨어남”이라는 두 가지 능력을 부여한다.


4. ReAct 액터 상태 머신 — 전체 설계

4.1 상태 전이 다이어그램

                    UserInput / CompletionSignal
                           │
                    ┌──────▼──────┐
          ┌────────│   Thinking   │←────────────┐
          │        │  (LLM 호출)  │              │
          │        └──────┬──────┘              │
          │               │                     │
          │    ToolCalls?  │  No ToolCalls?      │
          │    ┌───────────┼───────────┐        │
          │    ▼                       ▼        │
     ┌────┴────┐              ┌───────────┐    │
     │ Acting   │              │  Complete  │    │
     │(도구실행) │              │ (최종응답)  │    │
     └────┬────┘              └───────────┘    │
          │                                     │
          │ 즉시 결과?       비동기 대기?         │
          ├─────────────┬───────────────────────┤
          ▼             ▼                       │
   Become(Thinking)  ┌──────────┐              │
                     │ Waiting  │──completion──┘
                     │(시그널대기)│
                     └────┬─────┘
                          │ timeout (30s)
                          ▼
                   Become(Thinking)
                   "타임아웃됨, 현재 상태 확인"

4.2 상태별 동작 명세

상태

진입 조건

수신 메시지

전이

Thinking

StartReAct / CompletionSignal / 도구결과 / timeout

LLM 호출 → ToolCalls 유무로 분기

Acting

Thinking에서 ToolCalls 있음

도구 실행 → 즉시결과면 Thinking, 비동기면 Waiting

Waiting

Acting에서 비동기 도구

CompletionSignal, TaskComplete, ReceiveTimeout

시그널 → Thinking, 타임아웃 → Thinking

Complete

Thinking에서 ToolCalls 없음

부모에게 결과 전송, 액터 대기

4.3 Waiting 상태가 풀어내는 것

Before (동기 루프):

term_send("npm test") → "Sent to term-0" (즉시 반환)
→ LLM: "결과 확인해야지" → term_read × 12 (55초간 동일 데이터 반복)
→ 10라운드 소진 → 루프 종료 → 테스트 결과 누락

After (ReAct Actor):

term_send("npm test") → "Sent to term-0" (즉시 반환)
→ ReActActor: Become(Waiting) + ReceiveTimeout(30s)
→ 터미널에서 테스트 완료 → TerminalToBotMessage(TaskComplete)
→ Become(Thinking): "테스트 끝남, term_read로 결과 확인"
→ term_read → 최종 요약 → Complete

12번의 무의미한 폴링이 1번의 시그널 수신으로 바뀐다. 컨텍스트 폭증 없이, 라운드 낭비 없이, 정확한 타이밍에 결과를 처리한다.


5. 메시지 프로토콜과 액터 계층

5.1 신규 메시지

// ═══ ReAct 메시지 ═══

/// ReAct 세션 시작 (UI → ReActActor)
public sealed record StartReAct(
    string UserPrompt,
    ILlmProvider Provider,
    string Model,
    int MaxTokens,
    IAgentToolbox Toolbox);

/// ReAct 진행 상태 → UI 스트리밍
public sealed record ReActProgress(
    ReActPhase Phase,   // Thinking | Acting | Waiting | Complete | Error
    string Text,
    int Round);

/// 외부 완료 시그널 (터미널/타이머 → ReActActor)
public sealed record CompletionSignal(
    string Source,      // "terminal:term-0" / "timeout" / "user"
    string Summary);

/// ReAct 취소 (UI → ReActActor)
public sealed record CancelReAct;

5.2 액터 계층 변경

ActorSystem("AgentZero")
└── /user/stage (StageActor)
    ├── /bot (AgentBotActor)
    │   └── /react (ReActActor)    ← 신규: Bot의 자식
    └── /ws-{name} (WorkspaceActor)
        └── /term-{id} (TerminalActor)

ReActActor를 Bot의 자식으로 두는 이유:

5.3 비동기 도구 판별

ReActActor가 Acting → Waiting 전환 여부를 결정하는 기준:

도구

동작

판별

term_send

터미널에 명령 전송, 결과는 나중에

비동기 → Waiting

term_read

버퍼 즉시 반환

동기 → Thinking

stage_send

액터 메시지 전송, 응답은 나중에

비동기 → Waiting

meeting_say

파일 쓰기 후 완료 대기

비동기 → Waiting

win_screenshot, term_list

즉시 반환

동기 → Thinking


6. 보조 장치 — 세션 메모리와 진단 로깅

ReAct 상태 머신만으로는 부족하다. 특히 온디바이스 소형 모델을 사회자로 쓸 때, 두 가지 보조 장치가 필수다.

6.1 세션 메모리 — 온디바이스 LLM의 기억 장치

온디바이스 모델(gemma-4-26b 등)은 각 요청을 독립 처리하며, 긴 히스토리에서 “지금 어디까지 했는지”를 추적하지 못한다. 실측: 동일 도구를 90회 반복 호출, 한 라운드에 81개 호출.

해결책: 액터 상태 기반 세션 메모리를 시스템 메시지로 주입

== Session Memory ==
현재 상태: Waiting (npm test 완료 대기 중)
라운드: 3/10
완료된 작업: [1] meeting_create 성공, [2] term_send("npm test") 전송
대기 중: term-0에서 테스트 결과
다음 예상: 테스트 결과 확인 후 meeting_say로 보고

이 메모리는 매 LLM 호출 시 시스템 메시지에 삽입된다. 액터 내부 상태에서 자동 생성되므로 추가 LLM 호출(요약)이 필요 없고, 세션 격리도 자연스럽다.

6.2 진단 로깅 — 증거 기반 디버깅

ReAct 루프는 비결정적(non-deterministic)이다. LLM이 왜 그 행동을 선택했는지 추적하려면 전체 경로를 기록해야 한다.

로그 태그

기록 내용

용도

[REACT-THINK]

LLM 요청 내용 + 응답

추론 트레이스 추적

[REACT-ACT]

도구 호출명 + 인자 + 결과

행동 검증

[REACT-WAIT]

대기 시작 + 시그널 수신/타임아웃

비동기 흐름 추적

[REACT-STATE]

Become() 전환 기록

상태 전이 검증

이 로그는 하네스(Harness) 평가에 그대로 연결된다. 비결정적 시스템에서 “관찰 가능성(observability)”은 선택이 아니라 필수다.


7. 에이전트 프레임워크 비교 — 왜 액터 모델인가

2025–2026년 현재, 주요 LLM 에이전트 프레임워크들은 다음과 같이 플래닝과 상태를 관리한다:

프레임워크

플래닝 방식

상태 관리

비동기 모델

장애 복구

LangGraph

그래프 기반 워크플로우

체크포인트된 dict

Python asyncio

수동 재시도

OpenAI Agents SDK

에이전트 간 핸드오프

Runner 내부 관리

Python async

제한적

CrewAI

역할 기반 멀티에이전트

공유 메모리

프로세스 기반

없음

AutoGen

멀티에이전트 대화

대화 히스토리

Python async

제한적

Semantic Kernel

Planner + Plugins

Kernel 컨텍스트

C#/.NET async

제한적

그리고 Akka 액터 모델:

역량

대부분의 프레임워크

Akka 액터

타입 안전 상태 전이

dict 기반, 런타임 에러

Become() — 컴파일 타임 메서드 분리

장애 복구

try/catch, 수동 재시도

Supervision Strategy 자동 복구

동시성

스레드/async 직접 관리

액터 = 단일 스레드 보장, 자연 격리

분산 실행

별도 인프라 구축

Akka.Cluster 내장

이벤트 소싱

없음 (외부 DB)

Akka.Persistence 내장

역압(Backpressure)

없음

Akka Streams 통합

트레이드오프: Akka는 Python 스크립트 한 줄로 시작할 수 없다. 학습 곡선이 있고, 인프라 설정이 필요하다. 하지만 프로덕션급 에이전트 시스템 — 신뢰성, 동시성, 확장성이 필요한 환경 — 에서는 액터 모델이 Python async 프레임워크보다 견고한 기반이 된다.


8. 안전장치 — 에이전트의 제어권

에이전트에게 자율성을 주면 반드시 고삐가 필요하다:

장치

설명

MaxRounds

ReAct 루프 최대 반복

10

ReceiveTimeout

Waiting 상태 타임아웃

30초

동일 호출 제한

같은 도구 연속 호출 차단

3회

MaxCallsPerRound

라운드당 도구 호출 상한

30

CancelReAct

사용자 수동 취소

즉시 중단

특히 ReceiveTimeout은 Akka에 내장된 기능이다. Waiting 상태에서 30초 내에 시그널이 없으면 자동으로 Thinking으로 돌아가 “타임아웃됐다, 현재 상태를 확인하겠다”는 판단을 LLM에게 넘긴다. 교착 상태가 구조적으로 불가능해진다.


9. 구현 로드맵 — Phase별 진행 계획

Phase 1 (NuGet + Bootstrap)          ✅ 완료
  │
  ├── Phase 2 (ReActActor 구현)      ← 현재 단계, 이 글의 핵심
  │     ├── Messages.cs — ReAct 메시지 4개 추가
  │     ├── ReActActor.cs — 상태 머신 구현
  │     ├── AgentBotActor — 자식 생성 + 시그널 포워딩
  │     └── ReActActorTests — 최소 10개 테스트
  │           └── Phase 3 (UI 통합)
  │                 ├── RunFunctionCallLoopAsync에 ReAct 분기
  │                 ├── ReActProgress → UI 실시간 표시
  │                 └── Settings에 EnableReAct 토글
  │
  └── Phase 4 (LmKitProvider 온디바이스)  ← 독립 진행
        ├── LM-Kit.NET 2026.4.2 통합
        ├── ILlmProvider 추상화로 ReActActor와 자연 호환
        └── 오프라인 환경에서 소형 모델 로컬 실행

9.1 Phase 2 — ReActActor 구현 (핵심)

Phase 2의 완료 기준:

9.2 Phase 3 — UI 통합

ReActProgress 메시지를 UI에 실시간으로 표시한다. 사용자는 현재 에이전트가 Thinking / Acting / Waiting / Complete 중 어느 상태인지 시각적으로 확인할 수 있다. EnableReAct 토글로 기존 동기 루프와 선택적 전환이 가능하다.

9.3 Phase 4 — LM-Kit.NET 온디바이스

LM-Kit.NET은 온디바이스 추론 엔진으로서, 오프라인/에어갭 환경에서 소형 모델(gemma-4-e4b ~4.8GB)을 로컬 실행한다. ILlmProvider 추상화를 통해 ReActActor와 자연스럽게 통합되며, 원격 API(Webnori 26B)와 병행 사용이 가능하다.


10. 활용 기술 요약

이번 2편에서 다룬 핵심 기술과, 이후 활용될 기술을 정리한다:

기술

역할

상태

ReAct Pattern

LLM의 추론-행동-관찰 루프 구조화

설계 완료, Phase 2 구현 예정

Akka.NET Become()

상태 머신 런타임 전환, 스레드 안전

Phase 2 핵심

Akka ReceiveTimeout

비동기 대기 타임아웃, 교착 방지

Phase 2 핵심

Akka Supervision

자식 액터 장애 자동 복구

기존 적용, 확장 예정

세션 메모리 (Actor State)

온디바이스 모델의 컨텍스트 보존

설계 완료, Phase 2와 통합

LM-Kit.NET

온디바이스 추론 엔진 (gemma-4)

NuGet 설치 완료, Phase 4

ILlmProvider 추상화

원격/온디바이스 LLM 전환 투명화

Phase 4

Akka.Persistence

이벤트 소싱으로 상태 재생/복구

향후 검토

Akka Streams

LLM API 역압(backpressure) 제어

향후 검토


11. 마무리 — LLM에게 기다림을 가르치다

1편에서는 Akka로 통신의 복잡성을 풀었다. 2편에서는 Akka로 에이전트의 자율성을 만든다.

일반 LLM은 물으면 답하고, 시키면 실행하지만, 기다리지 못한다. 동기 루프가 끝나면 세상이 꺼진다. ReAct 액터 상태 머신은 이 LLM에게 네 가지를 가르친다:

  1. Thinking — 지금 무엇을 해야 하는지 스스로 판단한다
  2. Acting — 도구를 호출하고 결과를 기다린다
  3. Waiting — 비동기 작업이 끝날 때까지 잠들되, 시그널이 오면 깨어난다
  4. Complete — 모든 것이 끝났을 때 비로소 답한다

이것은 LLM 자체를 바꾸는 것이 아니다. LLM을 감싸는 실행 환경을 바꾸는 것이다. 마치 좋은 비서가 상사의 능력을 바꾸지 않으면서도, 적시에 메모를 건네고, 일정을 조율하고, 결과를 모아서 보고하듯이.

Akka 액터는 LLM에게 “기다림”이라는 새로운 동사를 가르친다. 그리고 기다릴 수 있는 LLM은 비로소 에이전트가 된다.

Phase 2 구현이 끝나면, 그 결과를 가지고 3편에서 만나자.


참고

이 글의 설계와 코드는 모두 AgentWin(개발 중)의 실제 아키텍처 문서에서 나왔다. Phase 2 구현이 완료되면 실측 데이터와 함께 3편으로 이어진다.