1편의 물음표에서 시작한다 — “일반 LLM을 어떻게 일하는 에이전트로 바꿀 것인가?” ReAct 패턴과 Akka Become() 상태 머신이 만나는 자리. 작성일: 2026-04-13. 대상: 1편을 읽은 .NET / 서버 개발자.
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-13-react-part2-hero-llm-waiting.png](/download/attachments/125731546/2026-04-13-react-part2-hero-llm-waiting.png?version=1&modificationDate=1776088630316&api=v2)
1편에서 우리는 Akka.NET 액터 모델로 여러 AI CLI 터미널 사이의 통신 복잡성을 풀었다. 설계 → 구현 → 개선 → 테스트를 거치며 “사회자 LLM + 3개 Claude 터미널” 시나리오가 동작하는 것을 확인했다.
그러나 1편의 마지막은 물음표였다.
“Akka 스택은 설계 가능한 AI 에이전트에 도움이 될 것인가?”
이번 2편은 그 물음표를 느낌표로 바꾸려는 시도다. 핵심 질문은 이것이다:
일반 LLM(펑션콜만 가능한 모델)을 “스스로 생각하고, 행동하고, 기다리고, 판단하는” 워크 에이전트로 변신시킬 수 있는가? |
답은 ReAct 패턴 + Akka Become() 상태 머신에 있다. 이 글에서는 그 설계를 기술적으로 깊이 파고든다.
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-13-react-part2-sync-loop-problem.png](/download/attachments/125731546/2026-04-13-react-part2-sync-loop-problem.png?version=1&modificationDate=1776088631445&api=v2)
현재 AgentWin의 RunFunctionCallLoopAsync는 다음과 같이 동작한다:
사용자 입력 → RunFunctionCallLoopAsync (최대 10라운드)
├── LLM → tool call → 실행 → 결과 → LLM → ...
└── return ← LLM이 tool call을 멈추면 종료
이후 터미널에서 "작업 완료" 시그널 도착
→ 세션 메모리에만 기록
→ LLM은 다음 사용자 입력까지 비활성 |
문제의 핵심: LLM이 tool call을 멈추면 루프가 끝나고, 이후 외부 완료 시그널이 와도 LLM을 다시 깨울 방법이 없다.
증상 | 수치 | 근본 원인 |
|---|---|---|
term_read 폴링 폭주 | 55초간 12회 동일 데이터 | 비동기 완료를 기다릴 수 없어서 반복 읽기 |
턴 완료 시그널 미응답 → 사회자 교착 | E2E 미팅 교착 | 루프 종료 후 시그널 수신 불가 |
컨텍스트 폭증 | 15분에 10K→30K chars | 폴링 반복으로 히스토리 누적 |
잔여 작업 미진행 | 계획의 60%만 완료 | 루프 종료 = LLM 비활성 |
이 증상들은 모두 하나의 구조적 결함을 가리킨다: “완료 시그널 부재(Completion Signal Absence)”. LLM은 명령을 보내놓고 결과를 기다릴 수 없으며, 기다리려고 폴링하면 컨텍스트가 폭발한다.
특성 | 일반 LLM (펑션콜) | 워크 에이전트 |
|---|---|---|
실행 모델 | 동기 루프, 라운드 제한 | 이벤트 기반, 시그널 반응 |
비동기 작업 | 폴링으로 대체 | 완료 시그널 대기 |
실패 복구 | 재시도 없음 (라운드 소진) | 자동 재시도 / 에스컬레이션 |
상태 추적 | 히스토리 의존 (윈도우 제한) | 명시적 상태 머신 |
타임아웃 | 전체 루프 단위 | 단계별 세밀한 제어 |
결론: 일반 LLM에 “기다림”과 “깨어남”을 가르치는 것이 에이전트화의 핵심이다.
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-13-react-part2-react-pattern-cycle.png](/download/attachments/125731546/2026-04-13-react-part2-react-pattern-cycle.png?version=1&modificationDate=1776088632331&api=v2)
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 상태 머신이다.
패턴 | 전략 | 장점 | 한계 |
|---|---|---|---|
ReAct | 생각→행동→관찰 반복 | 관찰에 근거한 추론, 환각 감소 | 비동기 대기 미지원 (원본) |
Plan-and-Execute | 전체 계획 수립 후 순차 실행 | 예측 가능한 작업에 효율적 | 계획 수정이 어려움 |
Tree-of-Thought | 여러 추론 경로 탐색, 가지치기 | 복잡한 문제 해결력 | LLM 호출 비용 높음 |
LATS | 몬테카를로 트리 탐색 + LLM | 최적 경로 탐색 | 실시간 시스템에 부적합 |
Reflexion | 실패 후 자기 성찰, 교훈 기억 | 반복 학습 가능 | 추가 메모리 관리 필요 |
AgentWin이 ReAct를 선택한 이유: 실시간 도구 실행 + 비동기 환경에서는 “한 번에 전체 계획”보다 “한 걸음씩 관찰하며 진행”이 현실적이다. 다만 원본 ReAct에 없는 Waiting 상태를 추가해야 한다.
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 스키마 기반 도구 호출로 진화한 것이다.
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;
}
}
} |
관점 | switch/case FSM | Akka Become() |
|---|---|---|
상태 추가 | case 문 추가, 모든 메시지에 상태 체크 | 메서드 하나 추가, 해당 상태 메시지만 처리 |
스레드 안전 | lock 필요 | 액터 모델이 보장 (단일 스레드 처리) |
잘못된 상태의 메시지 | 무시 또는 예외 | Stash()로 보관, 나중에 Unstash() |
타임아웃 | 별도 타이머 관리 | ReceiveTimeout 내장 |
실패 복구 | try/catch 중첩 | Supervision Strategy |
테스트 | 전체 FSM 초기화 필요 | TestKit으로 상태별 메시지 주입 |
동기 루프와 액터의 결정적 차이:
동기 루프: for → return → 끝. 이후 메시지는 허공으로 사라진다. 액터: Become(Waiting) → 메시지 수신 대기. 언제든 깨어난다. |
이것이 “일반 LLM을 워크 에이전트로 변신시키는” 핵심 메커니즘이다. LLM 자체는 변하지 않는다. 변하는 것은 LLM을 감싸는 실행 환경이다. Akka 액터가 LLM에게 “기다림”과 “깨어남”이라는 두 가지 능력을 부여한다.
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-13-react-part2-state-machine-4states.png](/download/attachments/125731546/2026-04-13-react-part2-state-machine-4states.png?version=1&modificationDate=1776088633221&api=v2)
UserInput / CompletionSignal
│
┌──────▼──────┐
┌────────│ Thinking │←────────────┐
│ │ (LLM 호출) │ │
│ └──────┬──────┘ │
│ │ │
│ ToolCalls? │ No ToolCalls? │
│ ┌───────────┼───────────┐ │
│ ▼ ▼ │
┌────┴────┐ ┌───────────┐ │
│ Acting │ │ Complete │ │
│(도구실행) │ │ (최종응답) │ │
└────┬────┘ └───────────┘ │
│ │
│ 즉시 결과? 비동기 대기? │
├─────────────┬───────────────────────┤
▼ ▼ │
Become(Thinking) ┌──────────┐ │
│ Waiting │──completion──┘
│(시그널대기)│
└────┬─────┘
│ timeout (30s)
▼
Become(Thinking)
"타임아웃됨, 현재 상태 확인" |
상태 | 진입 조건 | 수신 메시지 | 전이 |
|---|---|---|---|
Thinking | StartReAct / CompletionSignal / 도구결과 / timeout | — | LLM 호출 → ToolCalls 유무로 분기 |
Acting | Thinking에서 ToolCalls 있음 | — | 도구 실행 → 즉시결과면 Thinking, 비동기면 Waiting |
Waiting | Acting에서 비동기 도구 | CompletionSignal, TaskComplete, ReceiveTimeout | 시그널 → Thinking, 타임아웃 → Thinking |
Complete | Thinking에서 ToolCalls 없음 | — | 부모에게 결과 전송, 액터 대기 |
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-13-react-part2-before-after-waiting.png](/download/attachments/125731546/2026-04-13-react-part2-before-after-waiting.png?version=1&modificationDate=1776088634128&api=v2)
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번의 시그널 수신으로 바뀐다. 컨텍스트 폭증 없이, 라운드 낭비 없이, 정확한 타이밍에 결과를 처리한다.
// ═══ 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; |
ActorSystem("AgentZero")
└── /user/stage (StageActor)
├── /bot (AgentBotActor)
│ └── /react (ReActActor) ← 신규: Bot의 자식
└── /ws-{name} (WorkspaceActor)
└── /term-{id} (TerminalActor) |
ReActActor를 Bot의 자식으로 두는 이유:
CompletionSignal을 포워딩하기 쉬움ReActActor가 Acting → Waiting 전환 여부를 결정하는 기준:
도구 | 동작 | 판별 |
|---|---|---|
| 터미널에 명령 전송, 결과는 나중에 | 비동기 → Waiting |
| 버퍼 즉시 반환 | 동기 → Thinking |
| 액터 메시지 전송, 응답은 나중에 | 비동기 → Waiting |
| 파일 쓰기 후 완료 대기 | 비동기 → Waiting |
| 즉시 반환 | 동기 → Thinking |
ReAct 상태 머신만으로는 부족하다. 특히 온디바이스 소형 모델을 사회자로 쓸 때, 두 가지 보조 장치가 필수다.
온디바이스 모델(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 호출(요약)이 필요 없고, 세션 격리도 자연스럽다.
ReAct 루프는 비결정적(non-deterministic)이다. LLM이 왜 그 행동을 선택했는지 추적하려면 전체 경로를 기록해야 한다.
로그 태그 | 기록 내용 | 용도 |
|---|---|---|
| LLM 요청 내용 + 응답 | 추론 트레이스 추적 |
| 도구 호출명 + 인자 + 결과 | 행동 검증 |
| 대기 시작 + 시그널 수신/타임아웃 | 비동기 흐름 추적 |
| Become() 전환 기록 | 상태 전이 검증 |
이 로그는 하네스(Harness) 평가에 그대로 연결된다. 비결정적 시스템에서 “관찰 가능성(observability)”은 선택이 아니라 필수다.
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 프레임워크보다 견고한 기반이 된다. |
에이전트에게 자율성을 주면 반드시 고삐가 필요하다:
장치 | 설명 | 값 |
|---|---|---|
MaxRounds | ReAct 루프 최대 반복 | 10 |
ReceiveTimeout | Waiting 상태 타임아웃 | 30초 |
동일 호출 제한 | 같은 도구 연속 호출 차단 | 3회 |
MaxCallsPerRound | 라운드당 도구 호출 상한 | 30 |
CancelReAct | 사용자 수동 취소 | 즉시 중단 |
특히 ReceiveTimeout은 Akka에 내장된 기능이다. Waiting 상태에서 30초 내에 시그널이 없으면 자동으로 Thinking으로 돌아가 “타임아웃됐다, 현재 상태를 확인하겠다”는 판단을 LLM에게 넘긴다. 교착 상태가 구조적으로 불가능해진다.
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와 자연 호환
└── 오프라인 환경에서 소형 모델 로컬 실행 |
Phase 2의 완료 기준:
ReActProgress 메시지를 UI에 실시간으로 표시한다. 사용자는 현재 에이전트가 Thinking / Acting / Waiting / Complete 중 어느 상태인지 시각적으로 확인할 수 있다. EnableReAct 토글로 기존 동기 루프와 선택적 전환이 가능하다.
LM-Kit.NET은 온디바이스 추론 엔진으로서, 오프라인/에어갭 환경에서 소형 모델(gemma-4-e4b ~4.8GB)을 로컬 실행한다. ILlmProvider 추상화를 통해 ReActActor와 자연스럽게 통합되며, 원격 API(Webnori 26B)와 병행 사용이 가능하다.
이번 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) 제어 | 향후 검토 |
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-13-react-part2-teaching-llm-to-wait.png](/download/attachments/125731546/2026-04-13-react-part2-teaching-llm-to-wait.png?version=1&modificationDate=1776088635064&api=v2)
1편에서는 Akka로 통신의 복잡성을 풀었다. 2편에서는 Akka로 에이전트의 자율성을 만든다.
일반 LLM은 물으면 답하고, 시키면 실행하지만, 기다리지 못한다. 동기 루프가 끝나면 세상이 꺼진다. ReAct 액터 상태 머신은 이 LLM에게 네 가지를 가르친다:
이것은 LLM 자체를 바꾸는 것이 아니다. LLM을 감싸는 실행 환경을 바꾸는 것이다. 마치 좋은 비서가 상사의 능력을 바꾸지 않으면서도, 적시에 메모를 건네고, 일정을 조율하고, 결과를 모아서 보고하듯이.
Akka 액터는 LLM에게 “기다림”이라는 새로운 동사를 가르친다. 그리고 기다릴 수 있는 LLM은 비로소 에이전트가 된다.
Phase 2 구현이 끝나면, 그 결과를 가지고 3편에서 만나자.
NEXT - PART 3 설계는 여기까지였다. 이제 실제 코드, DONE 핸드셰이크, ESC 제어, 카드 UI까지 닫힌 구현 편으로 이어진다. |
구현하고 나면 테스팅및 작동에 확신있는 클코드에게.. 정말 자신있어? |
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > image-2026-4-13_23-38-49.png](/download/attachments/125731546/image-2026-4-13_23-38-49.png?version=1&modificationDate=1776091130019&api=v2)
이 글의 설계와 코드는 모두 AgentWin(개발 중)의 실제 아키텍처 문서에서 나왔다. Phase 2 구현이 완료되면 실측 데이터와 함께 3편으로 이어진다.