AgentWin 실사례로 풀어보는 Akka.NET 메시지 패턴 — 사회자 LLM과 3개의 Claude 터미널이 한 자리에 앉기까지. 작성일: 2026-04-13. 대상: Akka를 처음 보는 .NET / 서버 개발자.
![DevBegin > AI 터미널들은 어떻게 질서 있게 대화할까 — Akka 액터 모델 입문편 [PART 1] > 2026-04-13-akka-hero-ai-agent-cli.png](/download/attachments/125731527/2026-04-13-akka-hero-ai-agent-cli.png?version=1&modificationDate=1776033589238&api=v2)
요즘 데스크톱에 띄워두는 CLI는 더 이상 "사람이 명령을 치는 검은 창"이 아니다. claude, codex, gemini, copilot 같은 AI 에이전트 CLI들이 그 안에 들어 앉고, 사용자는 이 CLI들과 자연어로 대화한다.
문제는 한 단계 더 가고 싶을 때다. "여러 개의 AI 터미널을 동시에 띄우고, 그들 사이를 사회자 LLM이 중계하면 어떨까?"
AgentWin(개발 중인 데스크톱 도구)에서 이 시나리오를 구현하다 보니, "AI ↔ AI 사이의 통신 복잡도"가 일반 GUI 콜백 패턴으로는 감당이 안 된다는 결론에 이르렀고, 결국 Akka.NET(액터 모델)을 도입하게 됐다. 이 글은 그 도입 여정을 설계 → 구현 → 개선 → 테스트 주도 평가 순서로 풀어 본다.
한 줄 요약: Akka는 "메일박스가 달린 작은 객체(액터)들"이 메시지를 주고받는 동시성 프레임워크다.
Akka는 원래 Scala/Java 진영의 액터 모델 구현이고, Akka.NET은 그것의 .NET 포팅이다. .NET 진영에서는 Petabridge가 상업 지원과 부트캠프, OSS 거버넌스를 사실상 이끌고 있다.
액터 모델의 핵심 약속은 단순하다.
| 약속 | 의미 |
|---|---|
| 메일박스 + FIFO | 액터는 메시지를 큐에 받고 한 번에 하나씩 처리한다. → 액터 내부 상태는 자동으로 thread-safe. |
| 공유 메모리 금지 | 액터끼리 직접 변수를 만지지 않고 메시지로만 대화한다. → 락이 사라진다. |
| 위치 투명성 | 같은 프로세스 안의 액터든 다른 머신의 액터든 호출 코드는 똑같다. → 분산으로 자연스럽게 확장된다. |
| 감독자 트리 | 부모 액터가 자식의 장애를 책임진다 (Restart / Resume / Stop / Escalate). → "let it crash" 철학. |
처음 보면 "그냥 메시지 큐 아닌가?" 싶은데, 한 번 익히면 상태 + 동시성 + 장애 격리 + 라우팅을 한 가지 모델로 통일할 수 있다는 점에서 강력하다.
이 글 뒤에서 등장할 Akka 키워드 사전. 모르는 채로 읽어도 사례에서 자연스럽게 익혀진다.
AgentWin의 초기 구조는 흔한 WPF 코드비하인드였다. MainWindow(여러 터미널을 띄우는 워크스페이스)와 AgentBotWindow(사용자가 대화하는 챗봇 창)가 콜백 함수 4개로 손을 잡고 있었다.
MainWindow ──(콜백 4개)──→ AgentBotWindow _getActiveSession() _getSessionName() _getActiveDirectory() _getGroups() |
이 구조에서 다음 5가지가 한꺼번에 부딪혔다.
| 문제 | 콜백 구조에서의 한계 |
|---|---|
| 터미널 AI → 봇 통신 불가 | 콜백은 단방향. 터미널 안에서 돌고 있는 Claude가 봇에게 "방금 사용자 승인 받아주세요"를 보낼 경로가 없다. |
| 상태 모델 부재 | 터미널이 평범한 셸인지 AI 에이전트가 점유한 상태인지 구분할 자리가 없다. |
| 워크스페이스 격리 | 모든 터미널이 한 리스트에 평탄하게 들어 있어 그룹별 독립 제어가 어렵다. |
| 장애 전파 | 한 터미널이 죽으면 같은 UI 스레드를 공유해 다른 모든 것에 영향을 준다. |
| 시스템 전체 조회 | "지금 모든 워크스페이스/터미널 상태를 한 번에 알려줘"가 콜백 조합으로는 답이 나오지 않는다. |
여러 후보를 둘러봤지만, 위 다섯 문제를 하나의 모델로 동시에 풀어 줄 수 있는 건 액터 모델이었다.
| 요구 | Akka에서의 답 |
|---|---|
| 양방향 통신 | 두 액터가 서로의 IActorRef를 알면 끝. 굳이 콜백을 끼워 넣을 필요가 없다. |
| 상태 모델 | Become(PlainCli) ↔ Become(AiAgent)로 같은 액터가 다른 동작을 갖는다. |
| 워크스페이스 격리 | WorkspaceActor 자식 트리를 별도로 둔다. 그룹마다 독립 서브트리. |
| 장애 격리 | OneForOneStrategy로 자식 죽음이 부모/형제로 번지지 않는다. |
| 시스템 전체 조회 | Stage.Ask<StageStatusResponse>(QueryStageStatus) 한 줄. |
| 향후 분산 | 같은 메시지 프로토콜로 Akka.Remote/Cluster까지 자연스럽게 이어진다. |
여기서 또 한 가지 의도가 있었다. AgentWin은 결국 "AI 에이전트들을 설계 가능한 시스템 부품처럼 다루고 싶다"는 야심을 갖고 있었고, 그 부품들이 주고받는 채널은 처음부터 메시지 기반이어야 한다는 직관이 있었다. Akka는 그 직관을 언어적으로 강제해 준다.
Phase 1에서는 로직 없는 골격부터 그렸다. 아래 4개의 액터로 충분했다.
![DevBegin > AI 터미널들은 어떻게 질서 있게 대화할까 — Akka 액터 모델 입문편 [PART 1] > 2026-04-13-akka-actor-hierarchy.png](/download/attachments/125731527/2026-04-13-akka-actor-hierarchy.png?version=1&modificationDate=1776033590173&api=v2)
ActorSystem("AgentZero")
└── /user/stage (StageActor) — 최상위 감독자 + 메시지 브로커
├── /bot (AgentBotActor) — 사용자 대화 봇 (Become: Chat/Key/Ai)
├── /ws-proj1 (WorkspaceActor) — 워크스페이스
│ ├── /term-0 (TerminalActor) — Become: PlainCli/AiAgent
│ └── /term-1 (TerminalActor)
└── /ws-proj2 (WorkspaceActor)
└── /term-0 (TerminalActor) |
| 액터 | 한 줄 책임 |
|---|---|
| StageActor | 워크스페이스/봇 자식의 생명주기를 관리하고, 터미널 ↔ 봇 양방향 메시지를 라우팅한다. |
| AgentBotActor | 사용자가 대화하는 봇. Become으로 채팅/키 입력/AI 모드를 갈아탄다. |
| WorkspaceActor | 한 워크스페이스(폴더 한 개)에 속한 터미널들의 부모. 자식 생성/정리와 ID 기반 라우팅을 맡는다. |
| TerminalActor | ConPTY 세션 하나를 감싸는 액터. 평범한 셸일 땐 PlainCli, AI 프롬프트가 감지되면 AiAgent로 바뀐다. |
public sealed record SendToTerminal(string WorkspaceName, string TerminalId, string Text); public sealed record BotToTerminalMessage(string WorkspaceName, string TerminalId, string Text, BotMessageType Type); public sealed record QueryStageStatus; public sealed record StageStatusResponse(IReadOnlyList<WorkspaceInfo> Workspaces, bool BotRegistered); |
C#의 sealed record는 액터 메시지에 거의 완벽한 짝이다. 불변이라 액터 경계를 넘어 전달돼도 안전하고, 패턴 매칭이 자연스러우며(Receive<SendToTerminal>(msg => ...)), 메시지 이름만으로 방향과 의도를 짐작할 수 있다. 총 26개의 메시지 타입이 6개 카테고리(Stage / Bot / Workspace / Terminal / 양방향 / 생명주기)로 정리되었다. 이 메시지 카탈로그가 곧 AgentWin의 통신 명세서가 된다.
터미널은 두 모드 중 하나에 머문다. 핵심은 같은 메시지(BotToTerminalMessage)가 모드에 따라 다르게 처리된다는 것이다.
| 동작 | PlainCli | AiAgent |
|---|---|---|
| WriteToTerminal | 텍스트 전달 | 텍스트 전달 |
| BotToTerminalMessage | 무시 (대화 불가) | 처리 (대화 가능) |
| TerminalOutput | 단순 로그 | AI 패턴 분석 + 봇으로 전달 |
Become은 if-else로 흩어질 수밖에 없는 모드 분기 로직을 핸들러 한 묶음씩 통째로 교체할 수 있게 해 준다. AgentBotActor도 같은 패턴으로 Chat / Key / Ai 세 모드를 갖는다.
이게 콜백으로는 풀리지 않던 그 문제다.
![DevBegin > AI 터미널들은 어떻게 질서 있게 대화할까 — Akka 액터 모델 입문편 [PART 1] > 2026-04-13-akka-bidirectional-routing.png](/download/attachments/125731527/2026-04-13-akka-bidirectional-routing.png?version=1&modificationDate=1776033590949&api=v2)
터미널 AI → 봇 (예: Claude가 사용자에게 승인을 요청하는 패턴을 출력함)
TerminalActor(AiAgent) → WorkspaceActor → StageActor → AgentBotActor TerminalToBotMessage Forward Forward UI 콜백 → AgentBotWindow |
봇 → 터미널 AI (예: 봇이 AI 모드에서 펑션콜로 특정 터미널에 명령 전달)
AgentBotActor → StageActor → WorkspaceActor → TerminalActor(AiAgent) BotToTerminalMessage Forward Forward 세션에 Write |
여기서 Forward가 핵심이다. Tell은 새 메시지를 만드는 느낌이라면 Forward는 기존 메시지를 발신자 정보까지 보존하면서 다음 노드로 그대로 흘려보낸다. 라우팅이 아무리 깊어도 응답을 받을 사람이 누군지 잃지 않는다.
// Phase 1 (모든 예외 → Restart) ← 단순했지만 위험
localOnlyDecider: ex => Directive.Restart
// Phase 2 (예외별 세분화)
localOnlyDecider: ex => ex switch
{
ObjectDisposedException => Directive.Stop, // 파이프 해제됨 — 재시작 불가
IOException => Directive.Stop, // I/O 실패
_ => Directive.Restart // 그 외는 재시작
} |
ConPTY 파이프가 닫힌 상태에서 액터를 Restart해도 같은 예외만 반복된다. "복구 가능한 예외"와 "그 자체로 종료가 답인 예외"를 구분하는 것이 액터 시스템의 안정성을 크게 좌우한다. 이 한 줄 차이가 Phase 1 평가에서 받았던 가장 큰 지적이었다.
설계만 멋지면 곤란하다. 기존 콜백 코드가 멀쩡히 돌고 있는 와중에 액터 시스템을 끼워 넣어야 했다. 그래서 Phase 2는 7개의 작은 단계로 쪼갰고, 각 단계마다 빌드와 테스트를 다시 통과시켰다.
![DevBegin > AI 터미널들은 어떻게 질서 있게 대화할까 — Akka 액터 모델 입문편 [PART 1] > 2026-04-13-akka-7phase-incremental.png](/download/attachments/125731527/2026-04-13-akka-7phase-incremental.png?version=1&modificationDate=1776033591737&api=v2)
| Sub-Phase | 한 일 | 통과한 테스트 |
|---|---|---|
| 2-1 | ActorSystemManager를 App.xaml.cs에 통합 (시작/종료) | 47 |
| 2-2 | MainWindow에서 터미널 생성/종료 시 메시지 발행 | 47 |
| 2-3 | TerminalActor ↔ ITerminalSession 실제 바인딩 | 47 |
| 2-4 | AI 프롬프트 패턴 감지 → 자동 모드 전환 | 50 (+3) |
| 2-5 | AgentBotWindow ↔ AgentBotActor 브릿지 | 50 |
| 2-6 | 봇 AI의 펑션콜에서 Stage API 호출 (stage_status, stage_send) | 50 |
| 2-7 | 터미널 AI ↔ 봇 양방향 통신 E2E 검증 | 53 (+3) |
원칙은 두 가지였다. (1) 병행 운영: 새 메시지 경로는 기존 콜백과 동시에 살아 있는다. 액터가 아직 초기화되지 않았으면 메시지는 조용히 무시된다. 사용자 경험은 끊기지 않는다. (2) 빌드/테스트 연속성: 매 단계마다 dotnet build 에러 0, dotnet test 100% 통과를 확인하지 않으면 다음 단계로 가지 않는다.
AI 모드의 봇은 LLM에게 27개의 펑션콜 도구를 노출한다. 그중 두 개가 액터 시스템과 직접 대화한다.
// stage_status — LLM이 "지금 무슨 터미널들이 있냐?"를 묻는다
ReceiveAsync<QueryStageStatus>(async msg =>
{
var sender = Sender; // ⚠️ await 전에 Sender 캡처 (소실 방지)
var aggregated = new List<WorkspaceInfo>();
foreach (var ws in _workspaces)
{
try
{
var resp = await ws.Value.Ask<TerminalsResponse>(
new QueryTerminals(), TimeSpan.FromSeconds(3));
aggregated.Add(new WorkspaceInfo(ws.Key, resp.Terminals));
}
catch
{
// 한 워크스페이스 실패가 전체를 망가뜨리지 않게
aggregated.Add(new WorkspaceInfo(ws.Key, Array.Empty<TerminalInfo>()));
}
}
sender.Tell(new StageStatusResponse(aggregated, _botActor is not null));
}); |
이 짧은 코드에 액터 모델의 안전 패턴 두 개가 들어 있다. Sender 캡처 — await 너머로 Sender를 그대로 쓰면 그땐 다른 메시지의 sender로 바뀌어 있을 수 있다. 로컬 변수에 먼저 받아 둬야 한다. 개별 실패 격리 — 한 워크스페이스의 Ask가 타임아웃 나도 나머지는 정상 응답한다. 액터 하나의 사고가 시스템 전체 응답을 막지 않는다.
위의 §4.1은 코드 몇 줄로 끝나는 작은 사례처럼 보이지만, 사실 이 안에 최근 1~2년 사이에 일어난 큰 패러다임 전환이 겹쳐 있다. 이 글에서 한 번은 짚고 넘어가야 하는 지점이다.
예전의 그림은 이랬다. "자연어로 시스템을 제어하는 똑똑한 컨트롤러"가 필요하면, 사실상 선택지는 OpenAI/Anthropic/Google의 클라우드 API 하나뿐이었다. 모델이 크고 정교한 펑션콜(Tool Use)을 안정적으로 해낼 수 있는 곳이 거기밖에 없었기 때문이다. 결과적으로 "고차원 추론"과 "자잘한 기능 호출" 같은 결의 업무가 둘 다 같은 비싼 API 한 곳을 거쳐야 했다. 내부 도구 하나 호출할 때마다 토큰 비용이 붙고, rate limit이 붙고, 사용자 데이터가 밖으로 나가고, 네트워크 지연이 붙었다.
2025년 말~2026년에 이 전제가 무너졌다. 그 중심에 Google이 Apache 2.0으로 오픈한 Gemma 계열 모델들이 있다.
| 모델 | 역할 | 핵심 스펙 |
|---|---|---|
| Gemma 4 (E2B / E4B) | 경량 범용 온디바이스 모델. 펑션콜·구조화 JSON·시스템 프롬프트·멀티모달까지 네이티브 지원 | Apache 2.0 · 140개 이상 언어 · 128K 컨텍스트 · E2B는 일부 장치에서 1.5GB 메모리 미만으로 구동 |
| FunctionGemma (270M) | 펑션콜 전용으로 파인튜닝된 초경량 엣지 모델. 자연어 ↔ 구조화 함수 호출 변환에 특화 | Apache 2.0 (상업적 사용 가능) · 모바일/Jetson Nano급 디바이스 실행 · "unified action and chat" |
성능도 "장난감" 수준이 아니다. Google이 공개한 LiteRT-LM 벤치마크에 따르면 라즈베리 파이 5 CPU에서도 Gemma 4 E2B가 prefill 133 tokens/s, decode 7.6 tokens/s를 찍고, Qualcomm Dragonwing IQ8 NPU에서는 prefill 3,700 tokens/s, decode 31 tokens/s까지 올라간다 (Google Developers Blog). 4,000 입력 토큰을 2개 스킬에 분배하는 에이전트 시나리오가 GPU 가속 환경에서 3초 이내로 끝난다. 로컬에서 실용적 에이전트가 돌아갈 수 있다는 말이다.
이 변화가 AgentWin 같은 시스템에 주는 의미는 분명하다.
| 측면 | 클라우드 API 시대 | 온디바이스 Gemma 시대 |
|---|---|---|
| 비용 | 토큰당 과금, rate limit, 예산 천장 | 제로 API 비용, 하드웨어가 허락하는 만큼 무한 호출 |
| 프라이버시 | 사용자 입력·터미널 출력이 외부 서버 경유 | 데이터가 네트워크를 떠나지 않음 — "own the entire stack" |
| 응답 레이턴시 | 네트워크 RTT가 본격 병목 | 로컬 추론 — ms 단위 |
| 오프라인 동작 | 불가 | 가능 — 비행기, 망분리 환경 포함 |
| 상업적 사용 | 벤더 약관에 묶임 | Apache 2.0으로 자유 (사용 제한 조항 별도 확인) |
AgentWin이 지금까지 써 온 gemma-4-26b-a4b류 모델이 바로 이 흐름 위에 서 있다. 사회자 봇의 펑션콜 루프 — stage_status로 시스템 전체 상태를 읽고, memory_note로 계획을 쓰고, stage_send로 3개 터미널에 메시지를 뿌리는 — 이 모든 것이 사용자 PC의 로컬 모델만으로 닫혀 돌아간다. 앞서 §5에서 길게 얘기한 진단 로깅, 멤돔 가드, 세션 메모리, 함수 호출 오염 차단도 바로 이 "로컬 온디바이스 컨트롤러를 쓸 만하게 길들이는" 작업의 일부였다.
또 하나 주목할 만한 포지셔닝은 FunctionGemma가 Google 스스로 "intelligent traffic controller"라고 부르는 지점이다 (InfoQ, 2026-01). 쉬운 제어·필터링은 초경량 엣지 모델이 로컬에서 처리하고, 정말 추론이 필요한 요청만 큰 원격 모델로 라우팅하는 2단 구조 — 이건 AgentWin이 지금 봇(로컬 Gemma) + 터미널 AI(더 큰 Claude Code)로 풀고 있는 구조와 거의 같다. 업계가 그 패턴에 이제 이름을 붙이고 있는 중이다.
정리: "추론"과 "자잘한 기능 제어" 중 최소한 후자는 더 이상 비싼 클라우드에 의존할 필요가 없다. 똑똑한 컨트롤러를 직접 구축해서 자기 옆에 둘 수 있는 시대가 왔다는 것 — 그리고 그 컨트롤러를 Akka.NET 같은 메시지 기반 인프라에 꽂아 두면, 액터 트리 안의 모든 부품이 자연어로 제어 가능해진다. AgentWin의 §4.1 20줄짜리 펑션콜 핸들러는 바로 이 "제어기의 자가 보유"가 실제로 가능해진 순간의 증거다.
여기서부터가 사실 이 글에서 가장 재미있는 부분이다. 액터 시스템을 깔았다고 끝이 아니다. 진짜 전쟁은 그 위에서 돌아가는 LLM(특히 온디바이스 LLM)이 시스템을 어떻게 오용하느냐를 보고 다시 짜는 일이다.
개선 사이클은 모두 이렇게 돌아갔다.
앱 로그에서 이상 패턴 발견 → 원인 분석 → 코드 수정 + 다층 방어
→ 단위/E2E 테스트로 잠금 → 재실행 후 로그 재검증 → 테크 문서화 |
가장 먼저 한 일은 AI 모드 LLM 호출의 모든 단계를 태그가 있는 로그로 박은 것이다.
[AI-IN] user input len=78, mdFiles=0, totalContent=78, historyMsgs=1
[AI-REQ] mode=FnCall, model=gemma-4-26b-a4b, msgs=1, maxTokens=4096
[AI-FnCall] round=1, toolCalls=1, textLen=0
[AI-TOOL] >> meeting_create({"topic":"세계평화","participants":"Claude1, Claude2"})
[AI-TOOL] << meeting_create result_len=87, first80=Meeting created: C:\...
[AI-FnCall] round=2, toolCalls=2, textLen=0
[AI-TOOL] >> term_send({"group_index":0,"tab_index":0,"text":"/agent-zero ..."})
[AI-TOOL] << term_send result_len=52, first80=OK: sent to session frontend/CMD-1
[AI-RESP] final text, len=156, rounds=4 |
이렇게 하니까 "이전에는 보이지 않던 사고"가 한순간에 보였다. 뒤에 나오는 모든 개선은 이 로그가 없었다면 시작도 못 했다.
코드 한 줄 수정이 아니라, 테스트 철학을 바꾼 사건이다.
E2E 미팅 진행 중 봇이 stage_send({"terminal":"Claude1","text":"..."})을 호출했고, 로그에는 Sent to AgentWin/Claude1: ... 성공 메시지까지 찍혔는데, 실제 터미널에는 아무것도 안 들어갔다.
원인을 따라 들어가니 3곳에 TODO가 사슬처럼 걸려 있었다.
AgentToolbox.StageSendAsync — Tell 직후 "Sent to..."를 즉시 반환하는 거짓 성공.StageActor.HandleSendToTerminal — TerminalId를 떨어뜨리고 WriteToTerminal만 워크스페이스에 보냄.WorkspaceActor — 받은 메시지를 로그만 찍고 자식 터미널에 전달 안 함.53개 테스트는 전부 "메시지가 도착했는가"까지만 검증하고 있었다. 자식 액터가 받은 메시지를 정말 세션의 WriteAndSubmit까지 흘려 보냈는지를 확인하는 테스트가 한 개도 없었다. 이 빈틈이 액터 E2E 테스트의 전형적인 함정이다.
수정은 두 가지였다.
(1) 코드는 Forward로 — TerminalId를 잃지 않게
private void HandleSendToTerminal(SendToTerminal msg)
{
if (_workspaces.TryGetValue(msg.WorkspaceName, out var workspace))
workspace.Forward(msg); // 메시지 전체를 그대로 전달
else
_log.Warning("SendToTerminal: workspace not found: {0}", msg.WorkspaceName);
} |
(2) 테스트는 MockTerminalSession으로 — 사이드 이펙트까지 검증
public sealed class MockTerminalSession : ITerminalSession
{
public List<string> WriteAndSubmitCalls { get; } = new();
public void WriteAndSubmit(string text) => WriteAndSubmitCalls.Add(text);
// ...
}
[Fact]
public void SendToTerminal_should_reach_specific_terminal_and_invoke_session_write()
{
stage.Tell(new RegisterWorkspace("proj1", "/path"));
stage.Tell(new CreateTerminalInWorkspace("proj1", "Claude1", "s1"));
var mock1 = new MockTerminalSession("s1");
stage.Tell(new BindSessionInWorkspace("proj1", "Claude1", mock1));
stage.Tell(new SendToTerminal("proj1", "Claude1", "hello claude1"));
AwaitAssert(() =>
{
Assert.Contains("hello claude1", mock1.WriteAndSubmitCalls);
}, TimeSpan.FromSeconds(3));
} |
이 테스트는 모든 액터가 실제로 동작하면서, 끝단의 ITerminalSession.WriteAndSubmit이 호출되는지를 본다. 액터 E2E의 정공법이고, 한 번 이렇게 잠그면 라우팅 회귀를 즉시 잡을 수 있다. 이후 라우팅 관련 테스트는 4개 추가되어 53 → 57로 늘었다.
다음 사고는 더 미묘했다. 사회자 봇이 다중 AI 미팅을 진행하다가 갑자기 이런 호출을 했다.
term_send(tab_index=0, text="meeting_say(\"C:\\...\", \"Claude1\", \"...\")") |
번역하면 "Claude1 터미널에 meeting_say(...)라는 함수 호출 코드를 텍스트로 타이핑해라"이다. 이건 LLM이 도구의 경계를 혼동한 결과다. 사회자에게는 meeting_say가 있지만, 터미널 안의 Claude는 그 도구를 호출할 능력이 없다(텍스트 입력만 받는다). 그래서 Claude는 그 문자열을 입력창에 붙여 넣고 멍해진다. 사회자는 응답이 안 오니 같은 호출을 25회 반복한다.
3중 방어로 막았다. (1) 정규식으로 함수 호출 문법 감지 → 즉시 거부, (2) 도구 설명에 "NATURAL LANGUAGE, not function call" 명시 + 부정 예시, (3) 다중 라인/장문이면 150ms 후 명시적 Enter 추가 전송.
핵심은 에러 메시지가 LLM에게 교정 가이드 역할을 하도록 쓰는 것이었다.
Error: text must be natural language, not a function call. Detected 'meeting_say(...)' pattern. The terminal AI reads text as raw input — it cannot execute your tools. If you want to record to meeting log, use meeting_say() directly (it's YOUR tool). To instruct the terminal AI, write plain sentences like '의견을 말씀해주세요'. |
또 한 번은 한 라운드에 도구 호출 79개가 쏟아지는 사건이 있었다. 분석해 보니 LLM이 term_read(tab_index=0, 1, 2, 3, 4, ..., 67)까지 순차로 시도한 것이었다. 유효한 인덱스는 0~2뿐이었는데도. 여기서 깨달았다. 온디바이스 LLM은 에러를 학습하지 못한다. 같은 에러를 100번 받아도 다음에 같은 시도를 한다. 그러니 코드가 대신 학습해 줘야 한다.
![DevBegin > AI 터미널들은 어떻게 질서 있게 대화할까 — Akka 액터 모델 입문편 [PART 1] > 2026-04-13-akka-3guard-loop-prevention.png](/download/attachments/125731527/2026-04-13-akka-3guard-loop-prevention.png?version=1&modificationDate=1776033592444&api=v2)
가드 1 — 에러 메시지 안에 유효 목록과 STOP 명령
Error: invalid tab_index 3 in group 0 ('AgentWin').
Valid tab_indexes: [0='Claude1', 1='Claude2', 2='Claude3'].
STOP incrementing tab_index — these are the ONLY terminals.
Call term_list once and remember the result. |
가드 2 — 동일 호출 카운터 ((funcName + args) 조합이 3회 초과면 차단)
var callKey = $"{funcName}:{funcArgs}";
callCounts.TryGetValue(callKey, out var count);
if (count + 1 > MaxSameCallRepeats)
{
result = $"Error: This exact call was already made {count + 1} times. " +
$"DO NOT repeat. The result will be the same. Try a different approach.";
} |
가드 3 — 라운드당 호출 상한 (한 라운드에 30개 초과면 즉시 종료 + 사용자에게 경고)
이 세 개의 가드가 깔린 뒤로는 라운드당 평균 호출 수가 50~80에서 1~4로 약 20배 줄었다.
가드만으로는 부족했다. 더 근본적인 문제는 온디바이스 LLM이 "지금 작업의 어디까지 했는지"를 추적하지 못한다는 것이었다. 히스토리가 200개를 넘어가면 사실상 매 라운드를 처음 본 것처럼 행동했다.
답은 봇 액터 안에 작은 메모리를 두는 것이었다.
// AgentBotActor 내부
private const int MaxMemoryEntries = 30;
private readonly List<string> _sessionMemory = new();
Receive<RecordAction>(msg =>
{
_sessionMemory.Add($"[{DateTime.Now:HH:mm:ss}] {msg.Summary}");
while (_sessionMemory.Count > MaxMemoryEntries)
_sessionMemory.RemoveAt(0);
}); |
자동 기록과 명시적 기록을 둘 다 둔다. 자동: 사용자 입력, 도구 호출과 결과, 최종 응답을 줄 단위로 자동 누적. 명시: LLM에게 memory_note(note)라는 펑션콜 도구를 줘서, "지금 내가 어디까지 했는지"를 본인이 직접 적게 한다. 매 LLM 요청 직전, 봇 액터에서 메모리를 Ask로 가져와 시스템 메시지 맨 앞에 한 번만(첫 라운드만) 첨부한다.
왜 이게 액터의 책임이어야 하는가는 분명하다. 봇 액터의 생명주기 = 메모리의 생명주기. 봇이 죽으면 메모리도 같이 사라진다. UI 코드비하인드에 두면 액터 철학이 깨지고, 별도 메모리 액터를 두면 통신만 늘어난다. 상태는 그것을 가장 자주 쓰는 액터 안에 있어야 한다는 액터 모델의 일반 원칙이 그대로 적용된다.
이 모든 개선 끝에 다음 시나리오가 처음으로 깔끔하게 돌아갔다.
사용자: "AI를 이용한 개발생산성 3인 미팅 시작해"
| 시각 | 단계 | 도구 호출 | 결과 |
|---|---|---|---|
| 05:52:35 | 사용자 지시 | [AI-IN] len=43 | 요청 수신 |
| 05:52:43 | 미팅 생성 | meeting_create | 회의록 파일 생성 |
| 05:52:55 | 3인 초대 | stage_send × 3 | Claude1/2/3에 동시 공지 |
| 05:54:30 | 진행 규칙 명시 | memory_note + stage_send × 3 | 계획 기록 + 규칙 전달 |
| 05:54:39 | 응답 수집 | term_read × 3 | 각 의견 획득 |
| 05:56:36 | 회의록 기록 | meeting_say × 4 | 3인 의견 요약 + 종료 |
| 05:56:45 | 최종 응답 | [AI-RESP] len=655 | 사용자에게 요약 전달 |
핵심 지표 변화는 다음과 같다.
| 지표 | 이전 (v2.0.3) | 현재 (v2.0.9) |
|---|---|---|
| 라운드당 도구 호출 평균 | 50~80 | 1~4 |
| 멤돔 루프 | 빈번 | 0건 |
| stage_send 도달률 | 거짓 성공 (0%) | 100% |
| 미팅 완결성 | 불가 | 3인 의견 수집 → 회의록 → 요약 |
테스트도 47 → 50 → 53 → 57로 늘었고, 그동안 빌드는 한 번도 깨지지 않았다.
이 모든 패턴(액터 정의, 메시지 record, 감독 전략, TestKit 사용법)은 그 자체로 재사용 가능하다. 그래서 별도 저장소(skill-actor-model)에 언어 + 액터 플랫폼 조합별로 스킬을 정리해 두었다.
skill-maker/docs/actor/ ├── 00-actor-model-overview.md ├── 01-java-akka-classic ├── 02-kotlin-pekko-typed ├── 03-dotnet-akka-net ← AgentWin이 쓰는 그것 ├── 04-memorizer-ai-agent ← AI 에이전트 특화 패턴 (별도 구분) └── 05-cross-platform-comparison.md |
언어/플랫폼별로 같은 액터 모델이 어떻게 다르게 표현되는지 비교하는 것 자체가 액터 모델을 깊게 이해하는 좋은 길이다. 더 중요한 건, 한 번 정리해 두면 새 프로젝트에서 같은 패턴을 다시 발명하지 않게 된다는 것이다.
AgentWin은 카카시 하네스라는 자체 평가 도구를 끼고 있다. 변경된 코드를 도메인별 가상의 엔지니어(conpty-engineer, llm-engineer, tamer)에게 던져서 점검을 시키는 구조다. Phase 1과 Phase 2 모두 이 하네스를 거쳤다.
Phase 1 → Phase 2 사이에서 9개 평가 축 중 7개가 향상됐고, 가장 결정적인 향상은 conpty 코드 안전성이 B → A로 올라간 부분이었다. 이건 사람이 놓치기 쉬운 액터별 권고(ObjectDisposedException → Stop 분기, PostStop에서 Dispose 금지 등)를 자동으로 챙겨 준 결과다.
여기서 중요한 패러다임 하나.
복잡성을 다룬다는 것은 두 가지 능력을 동시에 요구한다. 하나는 그 도메인의 복잡성을 이해하는 능력, 다른 하나는 복잡성 자체를 추상화로 다루는 능력.
Akka는 첫 번째(동시성/상태/장애 격리/라우팅)를 한 모델로 묶어 줬고, 하네스는 두 번째(도메인별 평가 기준을 자동으로 적용)를 도구로 만들어 줬다. 둘 다 학습 곡선이 가파른데, 그 곡선을 대신 등반해 주는 도구가 있어야 사람이 본질에 집중할 수 있다. AgentWin의 시도는 결국 "복잡성 자체를 단순화하려는" 시도이고, 이게 액터 모델과 하네스를 같이 쓰게 된 가장 큰 이유다.
Phase 2 종료 시점에서 보이는 다음 숙제는 다음과 같다.
| # | 항목 | 왜 필요한가 |
|---|---|---|
| 1 | ANSI 버퍼 깨짐 개선 | term_read가 가끔 ANSI 제어 문자만 들고 오는 케이스 |
| 2 | Akka 로그 → AppLogger 브릿지 | 액터 내부 경고가 앱 로그에 안 찍혀 디버깅이 어렵다 |
| 3 | term_read 동기 대기 | "AI 응답 끝까지 대기" 옵션 (프롬프트 패턴 감지로) |
| 4 | LLM Provider Akka.DI 주입 | 지금은 정적 ActorSelection. DI로 정리 |
| 5 | 자동 미팅 진행 FSM | memory_note로 수동 계획 중. invite→collect→record→summarize FSM 내장 |
| 6 | 하네스에 actor-engineer 신설 | 액터 도메인 전담 가상 엔지니어가 아직 없다 |
그리고 마지막으로 — 이 글의 진짜 질문.
Akka 스택은 "설계 가능한 AI 에이전트"에 도움이 될 것인가?
지금까지의 경험으로 보면 답은 조건부 yes다. 액터 모델이 주는 것은 동시성/상태/장애의 통일된 모델이고, 이것은 AI 에이전트 시스템처럼 여러 비결정적 컴포넌트가 비동기로 협력하는 환경에서 분명한 가치가 있다. 콜백 기반 GUI 코드로는 풀리지 않던 문제(터미널 AI ↔ 봇 양방향, 시스템 전체 조회, 워크스페이스 격리)가 액터 메시지 한 묶음으로 풀린 것은 작은 사건이 아니었다.
그러나 액터 모델 자체가 정답인 건 아니다. 학습 곡선이 있고, 잘못 쓰면 메시지 폭주와 거짓 성공 같은 새로운 종류의 버그가 생긴다. 그리고 이번 여정에서 가장 많은 시간이 들어간 건 액터 자체가 아니라 그 위에 얹은 LLM이 액터 시스템을 어떻게 오용하는지를 풀어내는 일이었다. 가드, 메모리, 진단 로그, 거짓 성공 차단 — 결국 사람-LLM-액터 사이의 경계 모델을 새로 짜야 했다.
그러니 더 정확한 질문은 이렇게 바뀐다.
"우리가 다루는 복잡성을 줄이지 않은 채로 그대로 다루는 도구"가 필요할 때, Akka는 그 후보 중 하나다. 그리고 그 도구를 쓸 때조차, 복잡성 자체를 다루는 추상화 능력은 여전히 사람이(또는 사람을 돕는 메타 도구가) 해야 한다.
이 두 능력이 만나는 자리에서 다음 Phase가 시작된다. AgentWin의 다음 숙제는 위 6개 항목이지만, 그보다 더 큰 숙제는 이 패턴을 다른 프로젝트에서도 빠르게 다시 세울 수 있는 부품으로 다듬는 일이다. 액터 스킬과 하네스가 그 다듬는 작업의 양 날개다.
이 글을 마무리하는 시점에서 흥미로운 사실 하나. AgentWin이 Akka.NET 기본 액터 위에서 손으로 다시 발명한 부품들 — 봇 상태, 세션 메모리, 펑션콜 도구 테이블, 감독 전략 — 이 대부분 이미 이름이 붙어 패키지된 고수준 프레임워크가 공개돼 있었다. 글의 본문이 사례 중심으로 끝났기 때문에, 마지막에 "그럼 그 업계 지도는 어떻게 생겼는가"를 짧게 덧붙인다.
2026년 기준 akka.io/akka-agents가 공개돼 있다. 자신을 "AI 에이전트, MCP 도구, HTTP/gRPC API를 빠르게 구축하는 프레임워크"로 소개하며, 핵심은 5개의 선언적 컴포넌트다.
| 컴포넌트 | 역할 |
|---|---|
| Agent | 목표지향적 에이전트 기본 단위 |
| Memory | 세션 간 컨텍스트 유지를 위한 내장 메모리 |
| Tools | 로컬 함수 / 외부 API / MCP 도구 호출 |
| Prompts | 런타임 수정 가능한 동적 프롬프트 템플릿 |
| Endpoints | HTTP / gRPC / MCP 자동 노출 |
여기에 "well-defined lifecycle — from gathering context to reasoning to taking action"이라는 라이프사이클이 기본으로 딸려 오고, 이 모든 것이 long-lived, distributed services that persist state and survive crashes로 동작한다. 즉 Akka 액터 시스템의 지속성·확장성·장애 허용성이 에이전트 추상화 레벨에서 그대로 상속된다.
AgentWin이 Phase 2 동안 손으로 짠 것과 거의 그대로 매핑된다.
| AgentWin이 손으로 짠 것 | Akka Agents가 이미 이름붙인 것 |
|---|---|
| AgentBotActor + Become(Chat/Key/Ai) | Agent + lifecycle |
| _sessionMemory 30개 FIFO | Memory |
| 27개 펑션콜 도구 (stage_send, memory_note, …) | Tools |
| 시스템 프롬프트 + 메모리 첨부 로직 | Prompts |
| AgentToolbox, StageActor API | Endpoints (HTTP/gRPC/MCP) |
발견한 것: AgentWin이 밟은 길은 업계가 이미 패턴화한 길이었다. 손으로 다시 발명한 것 자체가 낭비는 아니다 — 경계를 직접 부딪히며 알게 된 것이 많다 — 하지만 다음 프로젝트부터는 이런 프레임워크 위에서 출발하는 것이 합리적이다.
한편 LLM 에이전트 시스템 자체에도 이미 합의된 표준 디자인 패턴이 있다. Microsoft Azure 아키텍처 센터와 2026년 여러 가이드에서 공통으로 언급되는 핵심 5개는 다음과 같다.
| 패턴 | 한 줄 설명 | AgentWin 현재 |
|---|---|---|
| Tool Use | LLM을 "도구 호출 엔진"으로 만드는 펑션콜 기반 구조 | ✓ (27개 stage_/term_/meeting_ 도구) |
| Reflection | 에이전트가 자기 결과를 스스로 비평하고 재시도 — HumanEval 벤치마크 80→91% 상승 사례 보고 | ✗ (없음 — 다음 Phase 후보) |
| Planning | 고성능 모델이 계획을 세우고 저렴한 모델이 실행 — 비용 최대 90% 절감 사례 | △ (memory_note로 수동 계획 중) |
| Multi-Agent Orchestration | 한 지휘자가 여러 전문가 에이전트를 조율 ("puppeteer" 패턴) | ✓ (사회자 봇 ↔ 3 Claude 터미널) |
| Human-in-the-Loop | 위험한 단계에서 사람 승인을 요청 | ✓ (Approval TerminalToBotMessage) |
Gartner는 Q1 2024 → Q2 2025 사이 multi-agent 관련 문의가 1,445% 증가했다고 보고했다. AgentWin이 붙잡고 있던 문제(봇 사회자 + 3개 터미널 AI의 협업)는 우리 프로젝트만의 이상한 문제가 아니라 시장 전체가 지금 막 이름을 붙여 가는 공통 문제였다는 뜻이다.
위 두 자료(Akka Agents 컴포넌트 × 에이전틱 패턴 5종)를 겹쳐 놓으면 AgentWin의 다음 숙제가 조금 더 선명해진다.
결국 이 글의 마지막 물음표("Akka 스택은 설계 가능한 AI 에이전트에 도움이 되는가")에 조건부 yes라는 답이 붙은 이유가 한 번 더 확인된다. 액터 모델은 기반이고, 그 위에 업계가 이미 이름 붙인 에이전트 부품(Agent/Memory/Tools/Prompts/Endpoints)과 에이전틱 설계 패턴(Reflection/Planning/Multi-Agent/Tool Use/HITL)이 얹힌다. 우리가 해야 할 일은 이 지도를 보고 다음엔 어느 산부터 오를지를 정하는 것뿐이다.
이 글의 사례는 모두 AgentWin(개발 중)의 실제 코드/로그/평가 보고서에서 나왔다. 다이어그램 안의 텍스트는 모두 영문으로 되어 있다 — 모델은 언어와 무관해야 하고, 개발자라면 언어와 무관하게 읽혀야 한다는 의도다.