Page History
| Table of Contents | ||
|---|---|---|
|
1편의 물음표에서 시작한다 — “일반 LLM을 어떻게 일하는 에이전트로 바꿀 것인가?” ReAct 패턴과 Akka Become() 상태 머신이 만나는 자리PART 1에서 Akka 액터 트리로 AI 터미널의 질서를 세웠다면, 이번 편은 그 위에서 일반 LLM을 “일하는 에이전트”로 바꾸는 설계를 다룬다. 작성일: 2026-04-1314. 대상: 1편을 PART 1을 읽은 .NET / 서버 개발자.
0. 들어가며
...
1편에서 우리는 Akka.NET 액터 모델로 여러 AI CLI 터미널 사이의 통신 복잡성을 풀었다. 설계 → 구현 → 개선 → 테스트를 거치며 “사회자 LLM + 3개 Claude 터미널” 시나리오가 동작하는 것을 확인했다.
그러나 1편의 마지막은 물음표였다.
“Akka 스택은 설계 가능한 AI 에이전트에 도움이 될 것인가?”
이번 2편은 그 물음표를 느낌표로 바꾸려는 시도다. 핵심 질문은 이것이다:
| Info |
|---|
일반 LLM(펑션콜만 가능한 모델)을 “스스로 생각하고, 행동하고, 기다리고, 판단하는” 워크 에이전트로 변신시킬 수 있는가? |
답은 ReAct 패턴 + Akka Become() 상태 머신에 있다. 이 글에서는 그 설계를 기술적으로 깊이 파고든다.
1. 일반 LLM은 왜 에이전트가 아닌가
1.1 동기 루프의 한계
PART 1에서 우리는 관제실을 세웠다. StageActor, WorkspaceActor, TerminalActor, AgentBotActor가 자기 자리를 갖고, 여러 AI 터미널이 서로 안 부딪치게 만들었다. 그런데 여전히 한 가지가 부족했다. 사회자 LLM은 말은 잘하는데, 기다리지 못했다.
명령을 보내고 결과가 늦게 오면 조급하게 같은 도구를 다시 호출하고, 아직 끝나지 않은 작업을 다시 확인하고, 루프가 끝난 뒤에는 외부 완료 시그널을 아예 못 받았다. 즉 똑똑한 채터는 되었지만 아직 일하는 에이전트는 아니었다.
| Info |
|---|
이번 편의 핵심
|
1. 겨울왕국의 닫힌 문 — 동기 루프가 왜 막히는가
기존 RunFunctionCallLoopAsync는 얼핏 그럴듯해 보인다.현재 AgentWin의 RunFunctionCallLoopAsync는 다음과 같이 동작한다:
| Code Block | ||
|---|---|---|
| ||
사용자 입력 → RunFunctionCallLoopAsync-> (최대LLM 10라운드)호출 -> tool call 실행 -> 결과를 다시 LLM에 전달 -> 더 ├──할 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)을 반복하며 과제를 수행하는 구조다.
| Code Block | ||
|---|---|---|
| ||
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)을 방지한다.
| Code Block | ||
|---|---|---|
| ||
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 핵심: 액터는 죽지 않는다
동기 루프와 액터의 결정적 차이:
| Code Block | ||
|---|---|---|
| ||
동기 루프: for → return → 끝. 이후 메시지는 허공으로 사라진다.
액터: Become(Waiting) → 메시지 수신 대기. 언제든 깨어난다. |
이것이 “일반 LLM을 워크 에이전트로 변신시키는” 핵심 메커니즘이다. LLM 자체는 변하지 않는다. 변하는 것은 LLM을 감싸는 실행 환경이다. Akka 액터가 LLM에게 “기다림”과 “깨어남”이라는 두 가지 능력을 부여한다.
4. ReAct 액터 상태 머신 — 전체 설계
4.1 상태 전이 다이어그램
| Code Block | ||
|---|---|---|
| ||
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 (동기 루프):
| Code Block | ||
|---|---|---|
| ||
term_send("npm test") → "Sent to term-0" (즉시 반환)
→ LLM: "결과 확인해야지" → term_read × 12 (55초간 동일 데이터 반복)
→ 10라운드 소진 → 루프 종료 → 테스트 결과 누락 |
After (ReAct Actor):
| Code Block | ||
|---|---|---|
| ||
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 신규 메시지
| Code Block | ||
|---|---|---|
| ||
// ═══ 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 액터 계층 변경
| Code Block | ||
|---|---|---|
| ||
ActorSystem("AgentZero")
└── /user/stage (StageActor)
├── /bot (AgentBotActor)
│ └── /react (ReActActor) ← 신규: Bot의 자식
└── /ws-{name} (WorkspaceActor)
└── /term-{id} (TerminalActor) |
ReActActor를 Bot의 자식으로 두는 이유:
- Bot이
CompletionSignal을 포워딩하기 쉬움 - Bot의 세션 메모리에 직접 접근 (부모-자식 관계)
- Bot 모드 전환 시 ReActActor 생명주기 관리
5.3 비동기 도구 판별
ReActActor가 Acting → Waiting 전환 여부를 결정하는 기준:
도구 | 동작 | 판별 |
|---|---|---|
| 터미널에 명령 전송, 결과는 나중에 | 비동기 → Waiting |
| 버퍼 즉시 반환 | 동기 → Thinking |
| 액터 메시지 전송, 응답은 나중에 | 비동기 → Waiting |
| 파일 쓰기 후 완료 대기 | 비동기 → Waiting |
| 즉시 반환 | 동기 → Thinking |
6. 보조 장치 — 세션 메모리와 진단 로깅
ReAct 상태 머신만으로는 부족하다. 특히 온디바이스 소형 모델을 사회자로 쓸 때, 두 가지 보조 장치가 필수다.
6.1 세션 메모리 — 온디바이스 LLM의 기억 장치
온디바이스 모델(gemma-4-26b 등)은 각 요청을 독립 처리하며, 긴 히스토리에서 “지금 어디까지 했는지”를 추적하지 못한다. 실측: 동일 도구를 90회 반복 호출, 한 라운드에 81개 호출.
해결책: 액터 상태 기반 세션 메모리를 시스템 메시지로 주입
| Code Block | ||
|---|---|---|
| ||
== 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이 왜 그 행동을 선택했는지 추적하려면 전체 경로를 기록해야 한다.
로그 태그 | 기록 내용 | 용도 |
|---|---|---|
| LLM 요청 내용 + 응답 | 추론 트레이스 추적 |
| 도구 호출명 + 인자 + 결과 | 행동 검증 |
| 대기 시작 + 시그널 수신/타임아웃 | 비동기 흐름 추적 |
| 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 통합 |
| Note |
|---|
트레이드오프: Akka는 Python 스크립트 한 줄로 시작할 수 없다. 학습 곡선이 있고, 인프라 설정이 필요하다. 하지만 프로덕션급 에이전트 시스템 — 신뢰성, 동시성, 확장성이 필요한 환경 — 에서는 액터 모델이 Python async 프레임워크보다 견고한 기반이 된다. |
8. 안전장치 — 에이전트의 제어권
에이전트에게 자율성을 주면 반드시 고삐가 필요하다:
장치 | 설명 | 값 |
|---|---|---|
MaxRounds | ReAct 루프 최대 반복 | 10 |
ReceiveTimeout | Waiting 상태 타임아웃 | 30초 |
동일 호출 제한 | 같은 도구 연속 호출 차단 | 3회 |
MaxCallsPerRound | 라운드당 도구 호출 상한 | 30 |
CancelReAct | 사용자 수동 취소 | 즉시 중단 |
특히 ReceiveTimeout은 Akka에 내장된 기능이다. Waiting 상태에서 30초 내에 시그널이 없으면 자동으로 Thinking으로 돌아가 “타임아웃됐다, 현재 상태를 확인하겠다”는 판단을 LLM에게 넘긴다. 교착 상태가 구조적으로 불가능해진다.
9. 구현 로드맵 — Phase별 진행 계획
| Code Block | ||
|---|---|---|
| ||
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의 완료 기준:
- Messages.cs에 ReAct 메시지 추가
- ReActActor.cs — Thinking/Acting/Waiting/Complete 상태 전환
- AgentBotActor — ReActActor 자식 생성 + CompletionSignal 포워딩
- ReActActorTests — 최소 10개 테스트 (상태 전환, 시그널, 타임아웃, 취소)
- 빌드 성공 + 기존 47개 테스트 통과
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에게 네 가지를 가르친다:
- Thinking — 지금 무엇을 해야 하는지 스스로 판단한다
- Acting — 도구를 호출하고 결과를 기다린다
- Waiting — 비동기 작업이 끝날 때까지 잠들되, 시그널이 오면 깨어난다
- Complete — 모든 것이 끝났을 때 비로소 답한다
이것은 LLM 자체를 바꾸는 것이 아니다. LLM을 감싸는 실행 환경을 바꾸는 것이다. 마치 좋은 비서가 상사의 능력을 바꾸지 않으면서도, 적시에 메모를 건네고, 일정을 조율하고, 결과를 모아서 보고하듯이.
Akka 액터는 LLM에게 “기다림”이라는 새로운 동사를 가르친다. 그리고 기다릴 수 있는 LLM은 비로소 에이전트가 된다.
Phase 2 구현이 끝나면, 그 결과를 가지고 3편에서 만나자.
없으면 return |
문제는 여기서 문이 닫힌다는 점이다. 예를 들어 사회자가 npm test를 터미널에 보냈다고 해 보자.
| Code Block | ||
|---|---|---|
| ||
Action: term_send("npm test")
Observation: "Sent to term-0" |
여기까지만 보면 성공 같다. 그런데 실제 테스트는 아직 돌고 있다. 결과는 20초 뒤에 온다. 이 사이에 기존 루프는 선택지가 둘뿐이었다.
선택 | 왜 문제인가 |
|---|---|
폴링 | 같은 데이터를 반복 읽으며 컨텍스트를 낭비한다 |
종료 | 나중에 온 완료 시그널을 받을 방법이 없다 |
즉 동기 루프는 “지금 당장 답이 오는 세계”에는 맞지만, “나중에 다시 와서 알려줄게”가 많은 실제 도구 세계에는 약하다.
2. 인사이드 아웃의 관제실 — ReAct를 쉬운 말로 보면
ReAct는 Reasoning + Acting의 줄임말이지만, 어려운 논문 용어보다 먼저 장면으로 이해하는 편이 낫다. 인사이드 아웃 관제실을 떠올려 보자.
- 누군가는 지금 상황을 해석하고
- 누군가는 버튼을 누르고
- 누군가는 결과가 올 때까지 기다리고
- 다시 상황판을 보고 다음 행동을 정한다
이게 ReAct의 기본 리듬이다.
| Code Block | ||
|---|---|---|
| ||
Thought -> Action -> Observation -> Thought -> ... |
AgentZero 식으로 바꾸면 더 쉽다.
| Code Block | ||
|---|---|---|
| ||
생각한다
-> 도구를 부른다
-> 즉시 결과인지, 나중 결과인지 본다
-> 기다리거나 다시 생각한다
-> 끝나면 사용자에게 보고한다 |
논문 ReAct는 주로 관찰이 곧바로 돌아오는 장면을 다룬다. 하지만 실제 앱은 다르다. 테스트는 몇 초에서 몇 분이 걸릴 수 있고, 다른 AI 터미널의 응답은 언제 끝날지 모르며, 파일 작업이나 외부 호출도 비동기다. 그래서 AgentZero는 원본 ReAct에 Waiting이라는 현실적인 중간 상태를 덧붙였다.
3. 닥터 스트레인지의 시간 고리 — Become()이 루프를 상태 머신으로 바꾼다
여기서 Akka Become()이 빛난다. 같은 액터가 상태에 따라 다른 핸들러를 갖게 만들 수 있기 때문이다.
상태 | 하는 일 |
|---|---|
| 지금 뭘 해야 하는지 판단 |
| 도구 실행 |
| 비동기 결과를 기다림 |
| 최종 응답 정리 |
흐름은 단순하다.
| Code Block | ||
|---|---|---|
| ||
StartReAct
-> Thinking
-> Acting
-> Waiting
-> Thinking
-> Complete |
이 구조가 중요한 이유는 명확하다. 일반 루프는 return 하면 끝나지만, 액터는 Waiting 상태로 살아남는다. 외부 시그널이 오면 다시 Thinking으로 깨어난다. 즉 LLM 자체를 더 똑똑하게 만든 것이 아니라, LLM을 감싸는 실행 환경에 기다릴 줄 아는 몸을 붙여 준 것이다.
4. 주토피아 신호등 — Waiting 상태가 바꾸는 것
4.1 Before: 동기 루프
| Code Block | ||
|---|---|---|
| ||
term_send("npm test")
-> "sent"
-> term_read
-> term_read
-> term_read
-> ...
-> 10라운드 소진
-> 종료 |
4.2 After: ReActActor
| Code Block | ||
|---|---|---|
| ||
term_send("npm test")
-> "sent"
-> Become(Waiting)
-> 완료 시그널 수신
-> Become(Thinking)
-> "이제 결과를 읽자"
-> term_read
-> Complete |
이 차이를 표로 보면 더 쉽다.
장면 | 동기 루프 | ReActActor |
|---|---|---|
명령 전송 직후 | 계속 확인하려 듦 | 기다림으로 전환 |
비동기 완료 | 받을 통로가 약함 |
|
컨텍스트 사용량 | 폴링 때문에 커짐 | 필요한 순간만 다시 생각 |
실패 복구 | 루프 끝나면 끝 | 타임아웃 후 재판단 가능 |
Waiting은 그냥 잠깐 멈춤이 아니다. 에이전트가 조급함 대신 질서를 선택하게 만드는 장치다.
5. 토이 스토리 무전기 — 어떤 메시지가 오가야 하나
상태 머신은 메시지 계약이 분명해야 돌아간다. AgentZero는 ReAct용 메시지를 따로 정의했다.
메시지 | 역할 |
|---|---|
| 세션 시작 |
| UI에 중간 상태 전달 |
| 외부 완료 시그널 전달 |
| 사용자 취소 |
액터 트리에서도 자리 배치가 중요했다.
| Code Block | ||
|---|---|---|
| ||
ActorSystem("AgentZero")
└── /user/stage
├── /bot
│ └── /react <- ReActActor
└── /ws-{name}
└── /term-{id} |
왜 ReActActor를 bot의 자식으로 두었을까? 봇 세션 메모리와 가깝고, 터미널 완료 시그널을 포워딩하기 쉽고, UI와 진행 상태를 묶기 좋기 때문이다. 메시지 이름과 위치가 헷갈리면 에이전트도 금방 길을 잃는다.
6. 업(UP)의 메모책 — 세션 메모리와 로그가 같이 있어야 하는 이유
ReAct 상태 머신만 있다고 문제가 다 풀리진 않았다. 특히 온디바이스 LLM은 “지금 어디까지 했는지”를 자주 잊었다. 그래서 두 가지 보조 장치가 붙었다.
6.1 세션 메모리
| Code Block | ||
|---|---|---|
| ||
현재 상태: Waiting
완료된 작업: meeting_create, term_send("npm test")
대기 중: term-0의 테스트 결과
다음 예상: 결과 읽기 후 사용자 보고 |
이 메모리는 액터 상태에서 자동 생성되어 매 LLM 호출 앞에 붙는다. 다시 말해 에이전트는 매 턴마다 자기 수첩을 펼쳐 보고 일하는 셈이다.
6.2 진단 로그
로그 태그 | 의미 |
|---|---|
| 어떤 판단을 했는지 |
| 어떤 도구를 실행했는지 |
| 언제 기다리기 시작했고 무엇을 받았는지 |
| 어떤 상태로 넘어갔는지 |
메모리는 에이전트가 자기 일을 잊지 않게 하고, 로그는 개발자가 에이전트의 실수를 추적하게 한다. 하나는 에이전트를 위한 기억이고, 다른 하나는 사람을 위한 기억이다.
7. 마블 팀업 장면 — 왜 액터 모델이 에이전트 런타임에 잘 맞았나
2025~2026년 프레임워크는 많다. LangGraph, OpenAI Agents SDK, AutoGen, CrewAI, Semantic Kernel 모두 장점이 있다. 그런데 AgentZero는 왜 굳이 액터 모델을 붙잡았을까?
관점 | 일반적인 에이전트 프레임워크 | Akka 액터 |
|---|---|---|
상태 | dict, 히스토리, 러너 내부 상태 |
|
장애 복구 | 수동 재시도 중심 | 감독 전략 내장 |
동시성 | async 코드 설계에 크게 의존 | 액터 경계로 자연 격리 |
분산 확장 | 별도 설계 필요 | Akka 계열과 자연 연결 |
타임아웃/대기 | 개별 구현 필요 |
|
정리하면 이렇다. 일반 프레임워크가 에이전트를 빨리 시작하게 해 주는 도구라면, Akka는 에이전트가 오래 살아남는 구조를 잘 준다. 여러 터미널이 동시에 움직이고, 외부 완료 시그널이 언제 올지 모르고, 실패 복구와 상태 관찰이 중요한 환경에서는 이 차이가 크게 드러났다.
8. 닥터 스트레인지의 슬링 링 — 자율성에는 반드시 고삐가 필요하다
에이전트가 스스로 움직인다는 말은, 스스로 폭주할 수도 있다는 뜻이다. 그래서 안전장치가 함께 들어갔다.
장치 | 의미 | 값 |
|---|---|---|
| 무한 루프 방지 | 10 |
| 너무 오래 기다리면 재판단 | 30초 |
동일 호출 제한 | 같은 도구 반복 차단 | 3회 |
| 한 턴 폭주 방지 | 30 |
| 사용자 즉시 중단 | 즉시 |
중요한 건 자율성을 없애는 게 아니다. 자율성이 길을 벗어날 때 다시 트랙 위로 올리는 제어권을 남겨 두는 것이다. 나중에 PART 3에서 보게 되겠지만, 이 안전장치는 결국 DONE 핸드셰이크, _pendingDone, ESC 제어, UI 카드 같은 더 구체적인 형태로 확장된다.
9. 마무리 — PART 3 예고
이번 편에서 본 것은 화려한 구현이 아니라, 그 구현이 필요했던 이유다.
- 일반 LLM은 기다리지 못한다
- ReAct는 생각과 행동을 번갈아가며 정리한다
- Akka
Become()은 이 흐름을 상태 머신으로 붙잡아 준다 - Waiting, 메모리, 로그, 타임아웃이 에이전트를 실제 시스템으로 만든다
이제 질문은 자연스럽게 다음 회차로 넘어간다.
그 설계를 실제 코드에 넣고 보니, 어디서 깨졌고 무엇을 더 붙여야 했을까?
PART 3에서는 바로 그 장면이 열린다. ReActActor는 코드에서 어떻게 구현됐는가, 터미널 AI와는 어떤 프로토콜로 다시 연결됐는가, 왜 DONE(...) 핸드셰이크가 필요했는가, 왜 카드 UI와 ESC 제어, 큐잉 같은 장치가 추가됐는가를 본다.
| Info |
|---|
NEXT - PART 3 PART 2가 “사회자에게 기다리는 법을 가르치는 설계 편”이었다면, PART 3는 “그 사회자를 실제 무대에 올려 보니 무슨 일이 벌어졌는지 보는 구현 완성편”이다 |
| Info |
NEXT - PART 3 설계는 여기까지였다. 이제 실제 코드, DONE 핸드셰이크, ESC 제어, 카드 UI까지 닫힌 구현 편으로 이어진다. -> ReAct는 실제 코드에서 어떻게 살아 움직이는가 — AgentWin AgentZero 구현 완성편 [PART 3] |
| Info |
|---|
구현하고 나면 테스팅및 작동에 확신있는 클코드에게.. 정말 자신있어? |
...
...
참고
- ReAct: Synergizing Reasoning and Acting in Language Models — Yao et al. (2022)
- Tree of Thoughts: Deliberate Problem Solving with Large Language Models — Yao et al. (2023)
- LATS: Language Agent Tree Search — Zhou et al. (2023)Search
- Akka.NET — - Switchable Behaviors (Become)
- Akka.NET — - Finite State Machine
- LangGraph — GitHub
- OpenAI Agents SDK
- AgentWin Tech/DOC — ReAct-ActorPlanning.md, OnDevice-SessionMemory.md, AiMode-DiagnosticLogging.md
...












