TL;DR — 로컬에서 돌아가는 on-device LLM은 그 자체로 보면 토큰 생성기에 불과하다. 에이전트가 아니다. AgentZero Lite는 사용자 PC에서 동작하는 Gemma-4가 옆 터미널 탭의 또 다른 AI(Anthropic의 Claude CLI)와 진짜 대화를 나누게 만들었다. 이는 추론 위에 다섯 개의 별도 계층을 쌓아서 가능했다. 이 글은 그 다섯 계층의 현장 보고서다.
🌐 영문 원본: An LLM Is Not an Agent
전제: LLM 만으로는 에이전트가 아니다
로컬 모델을 앱에 떨어뜨리면 토큰이 나온다. 그게 전부다.
"무엇을 할지 결정하는 것", "도구를 사용하는 것", "상대방이 메시지를 받았는지 아는 것" — 이런 것은 모델의 속성이 아니다. 모델 주위에 어떤 시스템을 만드느냐의 속성이다. LLM을 마치 이미 에이전트인 것처럼 다루는 것이 가장 흔한 실수이며, "에이전트형" 기능을 부서지기 쉽게 만드는 원인이다.
따라서 "어떤 모델이 가장 좋은가?" 보다 더 유용한 질문은 "LLM 위에 최소한 어떤 계층들을 쌓아야 비로소 에이전트라 부를 수 있을까?" 이다.
AgentZero Lite의 답은 결국 다섯 개였다.
flowchart BT L[Local LLM<br/>토큰 출력] S[+ 구조<br/>GBNF 문법 또는 네이티브 도구 템플릿] T[+ 도구<br/>실제 ConPTY 위 5-도구 카탈로그] C[+ 동시성<br/>UI 스레드 밖 reactor 액터] P[+ 프로토콜<br/>identity-first 핸드셰이크, peer-name 라우팅] D[규율 하네스<br/>코드 이전에 리서치] A((에이전트)) L --> S --> T --> C --> P --> A D -.-> A
이하 각 계층마다 한 챕터씩.
1. 기능 이전에 하네스
에이전트 작업에서 가장 비싼 버그는 로직 에러가 아니다. 설계 결정이 추측이어서 잘못된 것을 만들었다는 것이다. 로컬 LLM 도구 호출은 잘못된 백엔드에 주말 한 번을 통째로 쓰고 나서야 깨닫게 되는 도메인이다.
Kakashi 하네스는 작고 구조화된 에이전트 집합 (
security-guard, build-doctor, code-coach, release-build-pipeline)으로, 각각이 엔지니어링 프로세스의 한 조각을 소유한다. 어떤 AIMODE 기능 작업도 시작하기 전에, 하네스가 단 하나의 구체적 산출물을 만들었다 — AIMODE를 듀얼 백엔드로 가기로 결정한 리서치 노트. Gemma 4는 GBNF 문법 강제로, Nemotron Nano 8B-v1은 네이티브 챗 템플릿으로 — 그리고 그 이유까지.결정이 추측이 아니라 산출물이 되자, 두 백엔드를 하나의 인터페이스(
IAgentToolHost) 뒤에 구현하고 작업 도중에 다시 논쟁할 일 없이 자유롭게 전환할 수 있었다. 규율은 부담이 아니라 레버리지를 만든다.2. 프롬프트는 부탁한다. 문법은 강제한다.
흔한 패턴: "하나의 JSON 객체로 답해라: {tool, args}. 다른 텍스트는 추가하지 마."
이는 비일관적으로 동작한다. 특히 Gemma 4는 도구 호출 SFT가 없어서 — 친절하게 JSON을 마크다운으로 감싸주거나, 친근한 인사말을 덧붙이거나, 따옴표를 빠뜨리기도 한다. 정중한 부탁만으로는 루프에서 사용 가능할 만큼 일관되지 않다.
요령은 부탁을 멈추는 것이다. 계약(contract)을 프롬프트 계층에서 샘플러 계층으로 옮긴다 — GBNF 문법으로. 그러면 모델은 물리적으로 문법에 맞지 않는 토큰 시퀀스를 내놓을 수 없다. 어떤 도구와 어떤 인자를 고를지는 여전히 모델이 정한다. 스키마를 깨는 선택지만 제거된다.
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
네이티브 도구 호출이 있는 모델(Llama-3.1 챗 템플릿을 쓰는 Nemotron)에는 문법이 필요 없다 — 구조가 이미 챗 포맷에 존재한다. 애플리케이션은 두 백엔드를 같은
IAgentToolHost 인터페이스로 본다. 일반화 가능한 교훈: 프롬프트는 의도를 위한 것, 문법(또는 네이티브 채널)은 형태를 위한 것. 이 둘을 섞는 것이 도구 호출을 들쑥날쑥하게 만든다.3. 터미널은 그저 다섯 개의 도구다
실제 터미널이 무엇인가: 의사 콘솔(pseudo-console)에 연결되어 바이트를 받고 바이트를 내보내는 프로세스.
LLM이 봐야 할 것: 프로세스도, 콘솔도 아닌 — 다섯 개의 이름 붙은 어포던스(affordance).
도구 | 용도 |
list_terminals | 어떤 탭이 존재하는가? |
read_terminal | 현재 화면 내용은? |
send_to_terminal | 이 문자열 + Enter 입력. |
send_key | 제어키 하나 전달 (esc, ctrl-c, …). |
wait | 다음 read 전 N초 대기. |
done | 사이클 종료, 사용자에게 최종 메시지 반환. |
첫 번째를 두 번째로 변환하는 다리가
WorkspaceTerminalToolHost다. LLM 입장에서 터미널은 여섯 개의 함수 호출로 축소되었다. 탭 입장에서는 여전히 진짜 ConPTY가 진짜 바이트를 받는다.flowchart TD U[사용자 프롬프트] --> L[AgentToolLoop] L -->|시스템 + 문법/템플릿| G{LLM 백엔드} G -->|Gemma 4 경로| GBNF[GBNF 샘플러<br/>5-도구 카탈로그] G -->|Nemotron 경로| TPL[Llama-3.1 챗 템플릿<br/>네이티브 도구 채널] GBNF --> P[JSON 파싱] TPL --> P P --> H[IAgentToolHost<br/>WorkspaceTerminalToolHost] H -->|도구 결과| L L -->|'done' 또는 MaxIterations<br/>까지 루프| L
wait 도구는 별도 언급 가치가 있다. 터미널 AI(Claude, Codex)는 응답을 시작하는 데 5–15초가 걸린다. 송신 직후 즉시 read하면 thinking 스피너만 잡힌다 — Crafting…, ✻, esc to interrupt. wait 없이는 루프가 빈 화면을 "읽고" 응답을 환각한다. wait을 first-class 도구로 노출하면, 모델이 타이밍 결정을 직접 소유한다 — 애플리케이션이 추측하는 대신.4. 추론은 윈도우가 아니라 액터에 있다
수 초가 걸리는 토큰 생성을 마우스 클릭과 같은 스레드에 둘 수는 없다. AIMODE 첫 버전은 WPF UI 스레드에서 추론을 돌렸고, GUI는 매 턴마다 그 시간만큼 얼었다.
해법은 async/await가 아니다. 작업을 UI의 우주에서 완전히 빼내는 것이다.
/user/stage/bot/reactor 경로의 새 Akka 액터인 AgentReactorActor가 AgentToolLoop를 감싼다. 윈도우는 Tell만 하는 객체가 된다 — _aiLoop나 _aiCts 필드도 없고, UI 스레드 추론도 없고, 동결도 없다. 라이브 진행 상황 표시(💭 generating… (N tok) 버블)는 액터가 윈도우 로 보내는 메시지로 전달된다. 결코 그 반대 방향이 아니다.이건 성능 최적화가 아니다. 정확성 속성이다 — 추론 작업이 물리적으로 UI를 막을 수 없기 때문에 UI는 인터랙티브로 남을 수 있다. KV 캐시가 액터 내부에 살아 사이클 사이에 보존되므로, 다중 턴 대화 히스토리는 재프롬프트 없이 보존된다.
5. 정체성이 곧 프로토콜이다
두 AI가 대화한다는 것은 하나의 더 큰 AI가 아니다. 두 시스템이 공유 채널을 협상하는 것이다. 이 협상 단계 없이는, 로컬 AI는 메시지를 보낸 뒤 (a) 응답을 거짓으로 만들거나(환각) (b) 포기한다("역방향 채널이 없습니다"). 둘 다 상대방이 존재한다는 것을 증명하지 않은 채 존재하는 척하는 실패 모드다.
따라서 본격적인 교환 전에, 로컬 AI는 first-contact 핸드셰이크를 정체성-우선으로 수행한다.
private static string BuildFirstContactHeader(string peerName = "Claude") => "[AgentBot Handshake — first contact, please read carefully]\n\n" + "You are " + peerName + " and I am AgentBot, " + "an on-device AI agent running inside the AgentZero Lite shell.\n" + "\"" + peerName + "\" is the name of YOUR terminal tab — " + "both of us will use that name to refer to you, and you will use it " + "as your --from identity when replying.\n\n" + "Step 1 — Verify the CLI exists.\n" + "Step 2 — Acknowledge using the same CLI.\n" + " AgentZeroLite.exe -cli bot-chat \"DONE(handshake-ok)\" --from " + peerName + "\n";
peerName 문자열은 탭 이름이다. 동시에 상대방이 bot-chat을 통해 회신할 때 --from에 넣어야 하는 값이기도 하다. 그리고 액터 시스템이 들어오는 peer 신호를 올바른 reactor 사이클로 라우팅할 때 사용하는 키이기도 하다. 하나의 문자열이 정체성, 라우팅, 핸드셰이크 상태를 모두 묶는다 — 그리고 단 하나뿐이기 때문에 일관성을 맞출 게 없다.전체 peer-signal 프로토콜은 단 몇 개의 record와 enum뿐이다:
public enum HandshakeState { NotConnected, // 인트로 보낸 적 없음 HandshakeSent, // 인트로 발송 후 bot-chat 회신 대기 Connected, // 적어도 한 번은 콜백 받음 } public sealed record TerminalSentToBot(string PeerName, string Text); public sealed record MarkHandshakeSent(string PeerName); public sealed record QueryHandshakeState(string PeerName); public sealed record HandshakeStateReply(string PeerName, HandshakeState State);
전체 사이클은:
sequenceDiagram participant U as 사용자 participant B as AgentBot<br/>(로컬 Gemma) participant R as AgentReactorActor<br/>(Akka, UI 밖) participant H as WorkspaceTerminalToolHost participant P as Peer 터미널<br/>(Claude CLI) U->>B: "Claude 탭에 말 걸어" B->>R: StartReactor (Mode 2) R->>H: send_to_terminal(<핸드셰이크 인트로>) H->>P: "You are <peerName>, I am AgentBot.<br/>Verify CLI: -cli help.<br/>Ack via bot-chat DONE(handshake-ok)" Note over R,H: MarkHandshakeSent P->>P: -cli help 실행, 확인 P->>B: AgentZeroLite.exe -cli bot-chat<br/>"DONE(handshake-ok)" --from <peer> B->>R: TerminalSentToBot (peer 신호) Note over R: HandshakeState → Connected<br/>Phase H 종료 → Phase C R->>H: 본격 교환 턴 H->>P: 실제 교환 P->>B: bot-chat을 통한 실제 응답 R->>U: 결과
핸드셰이크는 정체성-우선이다. 양쪽 모두 탭 이름으로 상대를 부른다. 같은 문자열이 bot-chat 회신의
--from 식별자다. 하나의 식별자가 정체성, 라우팅, 핸드셰이크 부키핑을 모두 묶는다. 새 wait 도구(1–30초, 서버 클램프)는 터미널 AI가 응답 시작에 5–15초가 걸리기 때문에 추가됐다 — 즉시 읽으면 thinking 스피너(✻, Crafting…)만 잡힌다.프롬프트 자체는 Mode 1 (직접 챗 — 인사는 직접 응답, 절대 터미널로 보내지 않음)과 Mode 2 (터미널 릴레이 — 핸드셰이크 후 대화)로 엄격히 분리됐고, 라이브 테스트로 검증된다: 인사가 우연히 터미널로 가지 않으며, 모호한 Mode 2 요청도 사용자에게 되묻지 않고 합리적 opener로 진행한다. 헤드리스 테스트 스위트는 FSM 전이, 핸드셰이크 상태, 세션당 5회 연속 사이클을 커버한다 — 42/42 통과.
한눈에 보는 기술 스택
계층 | 기술 | 왜 이것이고 다른 것이 아닌가 |
호스트 | .NET 10 (preview) + WPF | 네이티브 윈도우 데스크탑, 진짜 ConPTY 탭 |
동시성 | Akka.NET 액터 시스템 | UI 스레드 밖 추론, 감독되는 상태 머신, 메시지 키 라우팅 |
로컬 LLM | 직접 빌드한 llama.cpp (Vulkan) + LLamaSharp 0.26 | Gemma 4 / Nemotron 온디바이스 실행, Ollama 의존성 없음 |
도구 호출 — Gemma | 샘플러 레벨 GBNF 문법 | Gemma 4는 도구 호출 SFT 없음 — 문법으로 구조를 비선택적으로 만듦 |
도구 호출 — Nemotron | 네이티브 Llama-3.1 챗 템플릿 | 모델이 자체 도구 채널 보유. 그대로 활용 |
터미널 | Microsoft.Terminal.Control 통한 ConPTY | 진짜 터미널, 화면 스크래핑 아님 |
역방향 채널 | WM_COPYDATA 위 AgentZeroLite.exe -cli bot-chat ... --from <peer> | 기존 IPC 재활용, 한 식별자가 모든 것을 묶음 |
엔지니어링 하네스 | Kakashi (security-guard, build-doctor, code-coach, release-build-pipeline) | 리서치 → 코드 → 리뷰 → 로그를 구조화된 사이클로 강제 |
소스 맵 (전체 파일):
AgentToolGrammar.cs · AgentToolLoop.cs · AgentReactorActor.cs · Messages.cs · WorkspaceTerminalToolHost.cs마치며 — 토큰을 대화로 만든 것
각 계층은 서로 다른 카테고리의 실패를 해결한다:
- 구조는 "모델이 파싱 불가능한 출력을 만든다" 실패를 제거한다.
- 도구는 "모델이 세상에 작용할 방법이 없다" 실패를 제거한다.
- 동시성은 "생각하는 행위가 사용하는 행위를 막는다" 실패를 제거한다.
- 프로토콜은 "모델이 실제로 닿지 못한 상대방에 대해 거짓말한다" 실패를 제거한다.
- 규율 (하네스)은 "잘못된 것을 만들었다" 실패를 제거한다.
어느 것도 더 똑똑한 모델에 관한 것이 아니다. 모두 모델 주위에 무엇이 있느냐에 관한 것이다. 결국에는, 그것이 에이전트인 부분이다.
두 AI는 대화한다. 하네스는 그것을 기록했다. 다음 기능들도 같은 방식으로 심어질 것이다.
TECH LINKS
- 𝕏 @webnori