🤖

액터가 LLM을 Agent로 만든다 — AgentZero Lite 깊이 읽기 (Part 2)

📘
시리즈 안내 — 이 글은 두 부작 중 2편입니다.
Part 1 — 멀티 CLI · 온디바이스 LLM · 하이브리드 전략 (큰 그림) →
🪟
AgentZero Lite — 윈도우에 온디바이스 LLM과 멀티 CLI를 데려오다 (Part 1)
Part 2 (이 글) — 그 아래의 엔진룸Akka.NET 액터 모델, Gemma 4 + GBNF, AgentReactorActor FSM, STT/LLM/TTS 3계층 합주
질문 두 개를 들고 간다. 1. LLM은 텍스트 자동완성 엔진이다. 그런데 어떻게 Agent 가 되었는가? 2. 음성·LLM·TTS 가 각자 다른 모델·프로세스인데, 어떻게 OpenAI Realtime API 처럼 동시에 응답하는 것처럼 보이는가? 답은 같은 한 단어다 — 액터(Actor) 모델.

1. LLM은 천재인데, 사무실이 없다

작년 한 해 동안 우리는 LLM을 점점 더 똑똑한 모델로 만들어 왔다. 그런데 실제 일을 시키려고 하면 묘하게 자꾸 답답하다. GPT-OSS, Gemma 4, Nemotron Nano Omni — 다 좋은데 이 모델들에게 "터미널에 명령을 보내고, 결과를 기다렸다가, 다음 행동을 결정해" 라고 하면 그 자체로는 못 한다.
당연하다. LLM은 텍스트 자동완성 엔진이다. 입력 프롬프트가 들어오면 다음 토큰을 뽑는 게 전부다. 도구를 부를 줄도, 파일을 읽을 줄도, 어제 했던 대화를 기억할 줄도 모른다. 사무실 없는 천재 같은 거다 — 책상도, 전화도, 동료도 없다.
그럼 ChatGPT 의 o3 가 도구를 부르고, Claude Code 가 파일을 고치고, Gemini CLI 가 빌드를 돌리는 건 누가 시키는 건가? 답은 모델 바깥의 런타임이다. 그 런타임이 LLM 의 출력을 받아서 도구를 실행하고, 결과를 다시 LLM 컨텍스트로 밀어 넣고, 멈춰야 할 때 멈춘다.
이 런타임을 어떻게 짜야 하는가 — 거기서 액터 모델이 등장한다.

2. 액터 모델 30분 클래스 — 객체와 무엇이 다른가

액터(Actor) 라는 단어는 1973 년 칼 휴이트(Carl Hewitt) 가 만들었다. 결론부터 말하면 액터는 메일박스를 가진, 메시지로만 대화하는, 자기 상태를 혼자 들고 있는 작은 컴퓨터다. 객체와 비슷해 보이지만 결정적으로 다르다.
flowchart LR classDef obj fill:#1e293b,stroke:#06b6d4,color:#e2e8f0 classDef act fill:#312e81,stroke:#a855f7,color:#e9d5ff subgraph object["객체 (Object)"] direction TB Caller1["호출자"] Caller1 -->|"obj.foo() 직접 호출<br>동기 + lock"| Obj["📦 객체<br>(공유 상태)"]:::obj Obj -.공유 변수.-> Caller1 end subgraph actor["액터 (Actor)"] direction TB Caller2["발신자"] Caller2 -->|"actor.Tell(msg)<br>비동기, 즉시 반환"| MB["📬 메일박스"]:::act MB -->|"한 번에 하나"| Act["🤖 액터<br>(격리된 상태)"]:::act Act -.메시지로만 응답.-> Caller2 end
객체 vs 액터 — 여섯 축의 차이
  • 호출 방식 — 객체: obj.foo() 직접 호출. 액터: actor.Tell(msg) 메시지 전송.
  • 동기성 — 객체: 동기, 호출자가 결과 대기. 액터: 비동기, 메일박스에 넣고 즉시 반환.
  • 동시성 — 객체: lock / mutex 직접 관리. 액터: 한 번에 하나만 처리 — lock 불필요.
  • 상태 — 객체: 외부에서 직접 접근 가능. 액터: 내부에만 존재, 외부는 메시지로만 영향.
  • 실패 처리 — 객체: try-catch 스택 전파. 액터: 부모가 자식 감독 (Restart / Resume / Stop).
  • 위치 — 객체: 같은 프로세스 안만. 액터: 같은 머신 또는 원격 — 호출 코드 변경 없음.
핵심 한 줄로 압축하면 — 액터는 "공유 변경 가능한 상태가 없는 경량 프로세스" 다. 그래서 lock 을 안 잡는다. 그래서 동시 작업이 데드락 안 난다. 그래서 한 액터가 죽어도 옆 액터는 살아있다.
여기서 한 가지 더 — 액터는 현재 어떻게 행동할지 를 동적으로 바꿀 수 있다. Akka.NET 에서는 Become(state) 한 줄로 자기 자신을 다른 상태로 변신시킨다. 이게 곧 유한상태기계(FSM, Finite State Machine) 다. 대기 중 일 때와 추론 중 일 때 받는 메시지가 다르도록 만든다.
이 다섯 가지(메일박스, 메시지, 격리된 상태, supervision, Become) 가 모두 AI Agent 가 가져야 할 능력이다. 잠깐만 매핑 표를 보자.
Actor 본질 → AI Agent 능력 매핑
  • 메일박스 (한 번에 하나) → 한 번에 한 도구만 안전하게 실행.
  • 메시지 패싱 (비동기) → LLM 추론 / 도구 실행 / 결과 피드백을 멈춤 없이 합주.
  • 상태 격리 → 각 에이전트가 자기 KV cache · 대화 히스토리 · 페이지 컨텍스트 보유.
  • Supervision (Restart/Stop) → 모델 응답이 깨졌을 때 새 컨텍스트로 회복.
  • Become / FSM → 같은 에이전트가 대화 모드 → 도구 사용 모드 → 마무리 모드 로 전이.
  • Location transparency → 온디바이스 ↔ 클라우드 ↔ 다른 머신을 코드 변경 없이 라우팅.
이게 우연이 아니다. Akka.io 가 2025년 후반 "Akka Agents" 를 공식 발표하면서 "액터가 stateful AI agent 의 자연스러운 런타임" 이라고 명시했다 (Akka Agents 발표글). Aaron Stannard 도 "Actor 패턴과 agentic AI 사이엔 자연스러운 시너지가 있다" 고 정리한다 (Real-time Marketing Automation with Akka.NET). 이 합의는 우연히 만들어지는 게 아니라 — 두 모델이 같은 문제를 같은 방식으로 푸다.

3. AgentZero Lite의 실제 액터 토폴로지

이제 코드를 보자. AgentZero Lite 는 Akka.NET 위에 다음과 같은 트리를 띄운다.
flowchart TB classDef root fill:#1e3a8a,stroke:#3b82f6,color:#dbeafe classDef bot fill:#312e81,stroke:#a855f7,color:#e9d5ff classDef ws fill:#064e3b,stroke:#10b981,color:#a7f3d0 classDef voice fill:#7c2d12,stroke:#f97316,color:#fed7aa Stage["/user/stage<br>StageActor<br>(최상위 감독자)"]:::root Bot["/user/stage/bot<br>AgentBotActor<br>(Chat / Key / AI)"]:::bot Reactor["/user/stage/bot/reactor<br>AgentReactorActor<br>(AIMODE FSM)"]:::bot Voice["/user/stage/voice<br>VoiceStreamActor"]:::voice STT["STT pool<br>SmallestMailbox"]:::voice TTS["TTS pool"]:::voice WS["/user/stage/ws-{name}<br>WorkspaceActor"]:::ws Term["/ws-*/term-{id}<br>TerminalActor<br>(ConPTY 한 개)"]:::ws Stage --> Bot Bot --> Reactor Stage --> Voice Voice --> STT Voice --> TTS Stage --> WS WS --> Term
각 액터의 책임은 한 줄로 정리된다.
  • StageActor — 자식들의 라이프사이클 감독. supervision strategy 를 들고 있다.
  • AgentBotActor — 사용자 입력의 컨트롤러. Become() 으로 Chat ↔ Key ↔ AI 모드 전환.
  • AgentReactorActor — AIMODE 의 추론 FSM. 한 번에 한 사이클(send→wait→read→done) 만 돌린다.
  • VoiceStreamActor — Akka.Streams INPUT/OUTPUT 그래프 소유. STT/TTS 워커 풀 라우팅.
  • WorkspaceActor / TerminalActor — 진짜 ConPTY 터미널 세션 한 개씩 래핑.
그리고 supervision 전략이 흥미롭다. StageActor 의 코드 인용이다 (Project/ZeroCommon/Actors/StageActor.cs:132-143).
protected override SupervisorStrategy SupervisorStrategy() { return new OneForOneStrategy( maxNrOfRetries: 5, withinTimeRange: TimeSpan.FromMinutes(1), localOnlyDecider: ex => ex switch { ArgumentException => Directive.Resume, // 잘못된 메시지는 무시 NullReferenceException => Directive.Restart, // 일반적인 버그는 재시작 _ => Directive.Escalate // 모르겠으면 부모에게 }); }
이 한 블록이 곧 AI Agent 의 회복력 정책이다. 한 에이전트가 도구 호출에서 예외를 뽑아도 형제 에이전트는 멀쩡하다. 그리고 어떤 종류 예외에서 살리고 어떤 종류 예외에서 죽일지가 코드로 명시된다 — "AI 시스템이 조용히 망가진다" 의 대척점이다.

4. LLM이 Agent가 되는 4단계 — 코드로 따라가기

이제 진짜 본론이다. 텍스트 자동완성 엔진을 행동하는 Agent 로 바꾸려면 정확히 네 가지 일을 해야 한다.
flowchart LR classDef step fill:#1e293b,stroke:#06b6d4,color:#e2e8f0 S1["1. 출력 제약<br>(GBNF 강제)"]:::step S2["2. 도구 실행"]:::step S3["3. 결과 주입<br>(KV cache)"]:::step S4{{"done ?"}}:::step Out["사용자에게 응답"]:::step S1 --> S2 --> S3 --> S4 S4 -- "no, 계속" --> S1 S4 -- "yes" --> Out
이 루프가 생성 → 행동 → 관찰 의 사이클이고, 이걸 매 turn 정확히 한 바퀴씩 도는 게 Agent 다. AgentZero 는 이 네 단계를 각각 어떻게 구현했는지 보자.

4-1. 출력을 도구 호출로 강제하기 — GBNF

LLM 이 자유 산문을 뽑으면 호스트가 파싱할 수가 없다. "JSON 으로 답해" 라고 프롬프트로 부탁할 수도 있지만, 그건 프롬프트 엔지니어링이고 sampler 단계의 강제가 아니다. 모델이 변덕을 부리면 깨진다.
GBNF (GGML BNF) 는 이걸 샘플링 단계에서 차단한다. 토큰을 생성할 때마다, 다음에 올 수 있는 토큰의 확률을 grammar 가 허용하는 토큰들로만 마스킹한다. 자유 산문은 토큰 수준에서 불가능해진다.
flowchart LR classDef raw fill:#1e293b,stroke:#06b6d4,color:#e2e8f0 classDef mask fill:#7c2d12,stroke:#f97316,color:#fed7aa classDef out fill:#064e3b,stroke:#10b981,color:#a7f3d0 LLM["LLM logits<br>(전체 어휘 확률)"]:::raw Mask["GBNF grammar mask<br>(허용 토큰만 통과)"]:::mask Sample["샘플러"]:::raw Out["다음 토큰<br>(JSON 문법 보장)"]:::out X["✗ 자유 산문 토큰<br>(차단)"]:::mask LLM --> Mask --> Sample --> Out Mask -.차단.-> X
AgentZero 의 GBNF 전체는 AgentToolGrammar.cs:184-200 에 들어있다.
root ::= ws "{" ws "\"tool\"" ws ":" ws toolname ws "," ws "\"args\"" ws ":" ws args ws "}" ws toolname ::= "\"list_terminals\"" | "\"read_terminal\"" | "\"send_to_terminal\"" | "\"send_key\"" | "\"wait\"" | "\"done\"" args ::= "{" ws "}" | "{" ws kv (ws "," ws kv)* ws "}" kv ::= string ws ":" ws value value ::= string | integer | boolean
문법이 끝이다. Gemma 4 는 Llama-3.1 처럼 native tool-calling SFT 가 없기 때문에, GBNF 가 Gemma 를 문법 위반 없이 tool-call 형태로 강제하는 가장 안정적인 방법이다.
도구는 정확히 6 개다.
6개 도구 표면
  • list_terminals — 인자 없음 → 현재 워크스페이스의 탭 카탈로그 조회.
  • read_terminal{group, tab, last_n} → 탭 스크롤백 마지막 N 바이트.
  • send_to_terminal{group, tab, text} → 텍스트 전송.
  • send_key{group, tab, key} → 제어키 (cr/lf/esc/tab/ctrlc 등).
  • wait{seconds: 1..30} → 응답 대기.
  • done{message} → 루프 종료 + 사용자에게 최종 메시지.
이 6 개로 터미널 AI 를 부르는 모든 시나리오 가 표현된다. 도구 표면이 작을수록 LLM 이 헷갈릴 여지가 줄어든다.

4-2. 도구를 실행하기 — AgentToolLoop의 메인 루프

GBNF 가 보장하는 JSON 을 받으면, 그걸 파싱해서 진짜 도구를 부르는 게 AgentToolLoop.RunAsync 다 (Llm/Tools/AgentToolLoop.cs:66-168).
public async Task<AgentToolSession> RunAsync(string userRequest, CancellationToken ct) { for (var iter = 0; iter < _opts.MaxIterations; iter++) // 기본 12회 { ct.ThrowIfCancellationRequested(); // (1) 첫 turn 은 system prompt + user, 이후는 직전 tool_result 만 주입 var turnInput = (iter == 0) ? FormatFirstTurn(userRequest) : FormatToolResultTurn(turns[^1].ToolResult); // (2) GBNF 강제로 JSON 한 덩어리 생성 var rawJson = await GenerateOneTurnAsync(turnInput, ct); var call = ParseToolCall(rawJson); // (3) done 신호 → 루프 종료 if (call.Tool == "done") return new AgentToolSession(turns, call.Args["message"], true); // (4) 도구 실행 + 결과를 turn 기록에 추가 var toolResult = await ExecuteToolAsync(call, ct); turns.Add(new ToolTurn(call, toolResult)); _opts.OnTurnCompleted?.Invoke(turns[^1]); // ← UI 콜백 } return new AgentToolSession(turns, "max iterations", false); }
여기서 핵심 디테일 두 가지.
  • MaxIterations = 12. send → wait → read = 3 호출이라 약 4 라운드. LLM 이 같은 도구를 반복 호출하는 runaway 시나리오를 산술적으로 차단한다. 별도로 ToolLoopGuards 가 같은 호출 반복을 두 단계로 잡는다 — 1 단계에서 모델에 에러 피드백, 2 단계에서 hard stop.
  • OnTurnCompleted 콜백. 도구가 실행될 때마다 AgentReactorActor.Self.Tell(TurnCompletedInternal(turn)) 으로 액터에 다시 떨어뜨린다. 즉 루프가 액터의 메일박스로 진행 상황을 통보한다. UI 는 그 메시지를 듣고 진행률을 그린다.

4-3. 결과를 KV cache로 보존하기

"여러 turn 인데 매번 system prompt 를 다시 던지면 토큰이 폭발한다" — 맞다. 그래서 LLamaSharp 의 LLamaContext 를 한 번 만들고 루프 인스턴스 라이프사이클 동안 들고 있는다 (AgentToolLoop.cs:45-59).
var (weights, modelParams) = llm.GetInternals(); _context = weights.CreateContext(modelParams); // ← KV cache 보유 _executor = new InteractiveExecutor(_context); _grammar = new Grammar(AgentToolGrammar.Gbnf, "root");
_isFirstUserSend 플래그로 첫 turn 에만 system prompt 를 통째로 끼우고, 이후 turn 은 직전 도구 결과만 주입한다. KV cache 는 그 사이의 컨텍스트를 모두 들고 있다. 즉, 루프 한 번 = 한 사이클, 사이클 사이의 기억 은 KV cache 가 책임진다.

4-4. ONE CYCLE PER RUN — runaway 를 막는 철학

AgentZero 의 system prompt 가 누누이 강조하는 한 줄이 있다 (AgentToolGrammar.cs:97-101).
CRITICAL principle — ONE CYCLE PER RUN, BUT DO THE CYCLE. Each tool chain run = ONE complete round trip with the terminal AI: send_to_terminal → wait → read_terminal → react → done. Subsequent cycles are triggered by the user OR an arriving peer signal. The KV cache preserves history across runs.
해석하자면 — LLM 에게 5 turn 토론을 한 거대한 tool chain 으로 짜라고 하지 마라. 한 turn 은 한 사이클만 끝내고 멈춰라. 다음 사이클은 사용자가 한 번 더 누르거나, peer 가 신호를 보낼 때 트리거된다.
왜 이 규칙이 필요한가? 만약 한 run 에 N 사이클을 욱여넣으면 — LLM 이 응답 해석을 잘못하거나 같은 도구를 반복하기 시작하면 한 run 안에서 runaway 가 난다. 한 사이클로 끊어놓으면 다음 run 시작 시점이 자연스러운 손잡이 가 된다. 사용자가 끼어들 수 있고, 다른 peer 가 신호를 보낼 수도 있고, 단순히 그냥 멈춰 있을 수도 있다.
이게 곧 액터 모델의 본질이다 — "한 메시지 = 한 단위 작업". LLM 이 토큰을 무한히 뽑지 못하게 막는 가장 좋은 도구가 액터 메일박스다.

5. AgentReactorActor — FSM이 되는 LLM

AgentToolLoop 만으론 비동기로 진행되는 추론을 UI 와 합치기 어렵다. 토큰이 흐르는 동안 사용자에게 진행률을 그려야 하고, 사용자가 Cancel 을 누르면 즉시 멈춰야 한다. 이게 AgentReactorActor 가 하는 일이다.
이 액터는 두 상태짜리 FSM 이다 (Actors/AgentReactorActor.cs:50-226).
stateDiagram-v2 [*] --> Idle Idle --> Running: StartReactor(userRequest)<br>BecomeRunning() state Running { [*] --> Thinking Thinking --> Generating: prefill 종료 Generating --> Acting: tool_call 도착<br>OnTurnCompleted Acting --> Generating: tool_result 주입 } Running --> Idle: RunCompletedInternal<br>BecomeIdle() Running --> Idle: RunFailedInternal<br>BecomeIdle() Running --> Running: CancelReactor<br>(_cts.Cancel)
  • Idle: StartReactor(userRequest) 메시지를 기다린다. 받자마자 Task.Run(() => loop.RunAsync(...)).PipeTo(Self) 로 루프를 비동기로 띄우고 BecomeRunning().
  • Running: 루프가 OnGenerationProgress / OnTurnCompleted 콜백으로 액터에 내부 메시지 를 떨어뜨린다. 액터는 그걸 받아서 부모에게 ReactorProgress(Phase, Round, Tokens, ToolCall) 로 전달. 부모는 UI 로 그린다.
  • 종료: RunCompletedInternal(session) 도착 → BecomeIdle() 자동 복귀.
  • 취소: CancelReactor 메시지 → _cts.Cancel()loop.RunAsyncOperationCanceledException 으로 빠져나옴 → RunFailedInternalBecomeIdle().
핵심: 추론 자체는 별도 Task 에서 돌지만, 진행 상황과 종료 신호 는 모두 액터의 메일박스를 거친다. UI 코드는 액터에게 메시지를 받아 그릴 뿐이다 — 직접 토큰 스트림을 들고 있지 않는다.
Task.Run(...).PipeTo(Self) 한 줄이 굉장히 중요하다. C# 의 일반적인 await 는 호출자 쪽 SynchronizationContext 와 묶이는데, 액터 안에서 그러면 메일박스 처리가 막힌다. PipeTo 는 Task 결과를 메시지로 만들어 액터의 메일박스에 떨어뜨린다. 즉 완전히 비동기 분리 — 액터는 다른 메시지(예: Cancel)를 동시에 받을 수 있다.

6. STT × LLM × TTS 가 동시에 응답하는 것처럼 보이는 이유

이제 두 번째 질문 — OpenAI Realtime API 처럼 "듣는 동안 응답하는" 모습을 어떻게 따로 분리된 모델 3 개로 만드는가?
OpenAI Realtime API 는 한 개의 stateful WebSocket 위에서 오디오 in/out + function call 을 round-robin 으로 동시 발생시킨다. 모델이 통합 음성 모델이라 가능한 이야기다.
AgentZero 는 완전히 분리된 무료 모델 3 개를 쓴다.
  • LLM — Gemma 4 (LLamaSharp, 온디바이스)
  • TTS — OpenAI tts-1 / Windows SAPI (배관 준비, 출력 미완)
이 셋이 마치 한 시스템처럼 합주하는 비밀이 액터 + Akka.Streams 다.

6-1. VoiceStreamActor 가 Akka.Streams 그래프를 소유

flowchart TB classDef in fill:#064e3b,stroke:#10b981,color:#a7f3d0 classDef act fill:#312e81,stroke:#a855f7,color:#e9d5ff classDef out fill:#7c2d12,stroke:#f97316,color:#fed7aa Mic["🎤 마이크<br>NAudio MicFrame"]:::in Q["Source.Queue<br>(DropHead)"]:::in VAD["VoiceSegmenter<br>(VAD 분할)"]:::in STT["SttWorkerActor<br>SmallestMailboxPool"]:::in AB["AgentBotActor<br>(AIMODE 활성)"]:::act R["AgentReactorActor<br>(LLM 추론)"]:::act Tools["터미널 액터<br>(도구 실행)"]:::act TQ["token Source.Queue"]:::out Chunk["SentenceChunker"]:::out TTS["TtsWorkerActor pool"]:::out Spk["🔊 스피커"]:::out Mic --> Q --> VAD --> STT STT -->|"VoiceTranscriptReady"| AB AB -->|"StartReactor"| R R -->|"send_to_terminal"| Tools R -->|"final tokens"| TQ TQ --> Chunk --> TTS --> Spk
VoiceStreamActor 는 시작 시 두 개의 Akka.Streams 그래프를 띄운다 (Actors/VoiceStreamActor.cs:159-172).
// INPUT 그래프 — MicFrame → VAD → STT → 트랜스크립트 var materialized = Source.Queue<MicFrame>(cmd.MicBufferSize, OverflowStrategy.DropHead) .Via(VoiceSegmenterFlow.Create(vadCfg)) // VAD + 세그먼트 분할 .Async() .SelectAsync(parallelism, async (PcmSegment seg) => { var reply = await sttPool.Ask<TranscribeReply>( // ← STT 워커 풀에 위임 new TranscribeRequest(seg, language), TimeSpan.FromSeconds(120)); return new VoiceTranscriptReady(reply.Transcript, reply.DurationSeconds); }) .Where(t => !string.IsNullOrWhiteSpace(t.Transcript)) .ToMaterialized(sink, Keep.Left) .Run(_materializer);
이 그래프가 핵심을 다 한다.
  1. Source.Queue<MicFrame> 가 마이크에서 오디오 프레임을 fire-and-forget 으로 받는다 (mic 스레드는 절대 블로킹 안 됨).
  1. VoiceSegmenterFlow 가 VAD 로 말하는 구간만 잘라서 PCM segment 발행.
  1. SelectAsync(parallelism, ...) 가 segment 마다 STT 워커 풀에 비동기 Ask. 워커가 N 개 병렬 처리.
  1. STT poolSmallestMailboxPool 라우팅으로 가장 한가한 워커 에게 자동 분배 (Voice/Streams/SttWorkerActor.cs:78-80).
  1. 결과는 VoiceTranscriptReady 메시지로 다시 VoiceStreamActor 의 메일박스에 도착.
  1. Sink.ActorRefWithAck 프로토콜 — 메시지마다 VoiceFrameAck 가 흘러야 다음 segment 가 진입. 이게 backpressure 다.

6-2. STT → LLM → TTS 메시지 시퀀스

전체 시퀀스를 사람 입장에서 따라가면 이렇다.
sequenceDiagram autonumber actor U as 👤 User participant Mic as 🎤 participant V as VoiceStreamActor participant ST as STT pool participant B as AgentBotActor participant R as AgentReactorActor participant T as Terminal participant TT as TTS pool participant Sp as 🔊 U->>Mic: "오늘 PR 요약해줘" Mic->>V: MicFrame stream V->>ST: TranscribeRequest (VAD segment) ST-->>V: TranscribeReply V->>B: VoiceTranscriptReady("오늘 PR ...") B->>R: StartReactor("...") R->>T: send_to_terminal(tab=1, "summarize PRs") R->>R: wait(5) R->>T: read_terminal(tab=1) T-->>R: "PRs 요약 결과..." R->>R: done(message) R->>V: SpeakResponse(token stream) [P3] V->>TT: SynthesizeRequest (sentence chunks) TT-->>V: 오디오 청크 V->>Sp: 재생
각 단계의 블로킹이 아무 데도 없다. 마이크 스레드는 mic 만 본다. STT 워커는 transcribe 만 한다. Reactor 는 LLM 만 본다. TTS 워커는 합성만 한다. 모두 메시지로 연결되고, 메일박스가 backpressure 를 만든다.

6-3. Realtime API와의 비교 — 같은 결과, 다른 인프라

flowchart LR classDef api fill:#1e3a8a,stroke:#3b82f6,color:#dbeafe classDef ens fill:#312e81,stroke:#a855f7,color:#e9d5ff subgraph realtime["OpenAI Realtime API"] direction TB WS["1 stateful WebSocket"]:::api Omni["통합 omni 모델"]:::api WS <-->|"audio in/out + tool calls<br>round-robin"| Omni end subgraph actor1["AgentZero 액터 합주"] direction TB A1["STT 액터<br>📬 mailbox"]:::ens A2["LLM 액터<br>📬 mailbox"]:::ens A3["TTS 액터<br>📬 mailbox"]:::ens A1 -.message.-> A2 -.message.-> A3 end
OpenAI Realtime API vs AgentZero 액터 합주 — 7개 축 비교
  • 트랜스포트 — Realtime: 1개의 stateful WebSocket. AgentZero: 액터 메시지 + Akka.Streams.
  • 모델 — Realtime: 1개의 통합 음성 모델 (gpt-realtime). AgentZero: 분리된 STT/LLM/TTS 3종.
  • function call — Realtime: WebSocket 이벤트 (round-robin). AgentZero: OnTurnCompleted → 액터 메시지.
  • 비용 모델 — Realtime: 토큰 + 오디오 분당 과금 (벤더). AgentZero: 온디바이스 무료 + 선택적 OpenAI TTS.
  • 끌어들기 — Realtime: server VAD turn detection. AgentZero: BargeIn 메시지 → OUTPUT 그래프 취소.
  • 확장 — Realtime: 같은 socket 위에 새 이벤트 추가. AgentZero: 새 액터 + 메시지 추가.
  • 실패 격리 — Realtime: socket 끊기면 전체 끊김. AgentZero: 한 워커 죽어도 다른 워커 살아있음.
같은 사용자 경험("말하면 듣고 응답하는") 인데 완전히 다른 인프라다. Realtime API 가 모델 한 덩어리 로 푸는 문제를 액터는 조립 가능한 작은 모듈 로 푸다.
장단점이 명확하다.
  • Realtime API 의 강점 — latency 가 작다. 한 모델이 음성 의미까지 같이 본다.
  • 액터 합주의 강점조합 자유. 한국어가 강한 STT 모델, 추론이 강한 다른 LLM, 자연스러운 TTS 를 각각 골라서 끼울 수 있다. 그리고 벤더 한 곳이 정책을 바꿔도 다른 부분은 멀쩡하다. Part 1에서 다룬 벤더락 프리 의 인프라 답안이 정확히 이 합주다.

7. peer-signal 양방향 채널 — 액터가 원격을 위해 태어난 이유

마지막 한 가지 — Part 1 에서 잠깐 봤던 peer 터미널이 봇에 직접 회신하는 메커니즘을 코드로 마저 보자.
시나리오: AIMODE 가 Claude 탭에 "요약해줘" 를 보낸 뒤 wait + read 폴링 중이다. 그런데 Claude 가 자기 일이 끝났을 때 능동적으로 AgentBot 에 신호를 보내고 싶다. wait 가 끝나기 전에 바로 다음 사이클이 트리거됐으면 좋겠다.
방법은 이렇다 — Claude 탭이 다음 한 줄을 실행한다.
AgentZeroLite.exe -cli bot-chat "DONE(요약 끝)" --from Claude
이 한 줄이 액터 시스템 안으로 들어가는 경로는 다음과 같다 (CliHandler.cs:679-742 + MainWindow.xaml.cs:413-419, 796-819).
sequenceDiagram autonumber participant CT as Claude tab (외부 프로세스) participant CLI as CliHandler.BotChat() participant MW as MainWindow participant AB as AgentBotActor participant R as AgentReactorActor CT->>CLI: bot-chat "DONE(요약 끝)" --from Claude CLI->>MW: WM_COPYDATA(0x414C "AL") MW->>AB: ActorSelection("/user/stage/bot")<br>.Tell(TerminalSentToBot("Claude", "요약 끝")) AB->>AB: _activeConversations 확인 AB->>R: StartReactor("[from Claude] 요약 끝") Note over R: wait 중이라도 새 run 트리거<br>(같은 run 안에서 wait를 깨우는 게 아님)
이게 액터 모델이 원격을 위해 태어난 이유의 실전 예다. 외부 프로세스(Claude CLI) 에서 보내는 신호가 — 같은 프로세스 안의 객체 메서드를 직접 호출하는 게 아니라 — 메시지로 메일박스에 떨어진다. 그리고 액터는 어디에서 왔는지 알 필요가 없다. WM_COPYDATA 든, gRPC 든, Akka.Cluster 든 같은 액터 코드가 받는다.
Part 1 에서 짚은 AgentZeroRemote / AgentZeroCluster 로드맵이 자연스러워지는 이유가 여기에 있다. Akka 의 Tell 은 같은 프로세스든 다른 머신이든 코드 변경이 거의 없다. 액터로 짜 시스템은 분산으로 자라는 게 자연스럽다 — 단일 머신용 IDE 를 다중 머신용 AI 비서 클러스터로 옮기는 게 코드 재작성이 아니라 설정 변경에 가까워진다.

8. 그래서 — Akka가 AI Agent의 런타임이 된다는 가설

여기까지 따라온 분이라면 한 가지가 보일 거다. AI Agent 가 일반 코드와 다른 점은 거의 모두 Actor 가 이미 가지고 있다.
AI Agent 의 어려움 → Actor 가 이미 가진 답
  • LLM 응답이 깨질 수 있다 → Supervision (Restart / Resume / Stop).
  • 같은 도구를 무한 반복할 수 있다 → 메일박스 = 한 번에 하나 + Become 로 상태 전이.
  • 도구가 비동기로 끝난다 → Tell + PipeTo, 메일박스가 곧 큐.
  • 동시에 여러 에이전트가 일한다 → 액터가 본질적으로 동시성 단위.
  • 사용자 / peer / 자기 자신 어디서든 신호가 온다 → 메시지 = 출처 무관.
  • 같은 에이전트를 원격에 띄우고 싶다 → Location transparency.
이게 곧 Akka.io 가 "Akka Agents" 라는 별도 제품군을 띄우고, NVIDIA 가 WASM 위에서 agent sandbox 를 표준화 하는 흐름과 같은 방향이다. 모두 AI 모델 자체보다 그 위의 런타임 이 진짜 인프라라는 동일한 결론을 향한다.
🎯
그래서 AgentZero Lite 가 하는 일은?
Akka.NET 위에 LLM 한 마리(Gemma 4 + GBNF) 를 올리고, 그 액터에게 STT/TTS/터미널 액터들을 이웃 으로 붙여서, 한 데스크톱 안에서 우리만의 작은 Realtime API 를 만든 것이다. 토큰 비용은 0 원이고, 코드는 모두 공개되어 있다.
이게 앞으로 5 년 동안 진행될 진짜 AI 인프라 의 작은 미리보기다.

9. 마치며 — 두 가지 권유

  • 주니어 개발자에게. 액터를 한 번이라도 손으로 짜 본 사람과 안 짜 본 사람의 AI Agent 설계 감각이 점점 벌어진다. C# 이라면 Akka.NET 공식 튜토리얼 이 하루 코스다. 메시지를 던지고, 상태를 가두고, supervision 으로 회복시키는 패턴 한 번이 일반 OOP 한 학기 분량의 가치다.
  • 고급 엔지니어에게. 지금 회사에서 만들고 있는 AI Agent 의 런타임 부분을 한 번 들여다보시라. 만약 그게 한 함수 안의 거대한 while True 라면, 그 자리에 액터 + FSM + supervision 을 끼워 넣었을 때 코드가 어떻게 단순해지는지가 실험할 가치가 있는 한 시간짜리 프로토타이핑이 된다. AgentZero Lite 의 AgentReactorActor.cs 를 출발점으로 추천한다.

부록 — 참고 자료

  • AgentZero Lite — <https://github.com/psmon/AgentZeroLite>
    • Project/ZeroCommon/Actors/StageActor.cs — 액터 supervision
    • Project/ZeroCommon/Llm/Tools/AgentToolGrammar.cs — GBNF + system prompt
    • Project/ZeroCommon/Llm/Tools/AgentToolLoop.cs — 메인 루프 + KV cache
    • Project/ZeroCommon/Actors/AgentReactorActor.cs — FSM
    • Project/ZeroCommon/Actors/VoiceStreamActor.cs — STT/TTS Akka.Streams