스파게티 와이어링 없이 11개의 도구 도메인을 하나의 PySide6 창에 담는 방법
요약
PySide6 기반의 로컬 AI 어시스턴트 Sentience v3를 개발하며 겪은 복잡한 도구 관리 문제를 해결하는 아키텍처 패턴을 소개합니다. 평면적인 레지스트리와 엄격한 실행기 시그니처를 통해 코드의 복잡성을 줄이고 확장성을 높이는 방법을 다룹니다.
핵심 포인트
- 평면적인 레지스트리 패턴을 통한 도구 관리 단순화
- 엄격한 실행기 시그니처로 디스패처의 일관성 확보
- 함수 참조를 직접 사용하여 정보 불일치(drift) 방지
- PySide6 환경에서 11개 도메인의 60개 도구를 효율적으로 통합
스파게티 와이어링 없이 11개의 도구 도메인을 하나의 PySide6 창에 담는 방법
Sentience v3는 100% 로컬 데스크톱 AI 어시스턴트입니다. "Cursor와 비슷하지만 이메일, 브라우저, 메모리, 그리고 음성까지 처리하는 도구"라고 생각하면 됩니다. 이 서비스는 PySide6 기반으로 실행되며, Groq / OpenAI / Anthropic / Ollama와 통신합니다. 또한 코드 에디터, 파일 브라우저, 통합 터미널, 브라우저 자동화, 메모리, RAG, 컨텍스트 압축 (Context Compression), 이메일, 음성, 로컬 호스팅, OAuth 등 11개 도메인에 걸쳐 약 60개의 도구를 제공합니다.
이렇게 많은 도구를 다룰 때 빠지기 쉬운 함정은 와이어링 (Wiring)입니다. 만약 각 도구가 if tool_name == "x"와 같은 일회성 분기문으로 처리된다면, 디스패처 (Dispatcher)는 400줄에 달하는 지옥 같은 switch 문이 되어버리고, 이름 하나에 오타가 나면 기능 하나가 조용히 비활성화됩니다. 저는 v2 버전에서 이 실수 때문에 이틀 밤을 허비했습니다. v3에서는 다음과 같은 패턴으로 이를 해결했습니다: 평면적인 레지스트리 (Flat Registry), 엄격한 실행기 시그니처 (Strict Executor Signature), 그리고 개별 도구에 대해 아무것도 알지 못하는 단 하나의 디스패처.
평면적인 레지스트리 (The flat registry)
모든 도구는 src/<domain>/ 하위의 모듈에 존재하며 *_TOOLS 리스트를 내보냅니다 (export). 최상위 TOOLS 리스트는 말 그대로 [*BROWSER_TOOLS, *EMAIL_TOOLS, *SKILLS_TOOLS, *MEMORY_TOOLS, *HOSTING_TOOLS, *VOICE_TOOLS, *COMPRESSION_TOOLS, *OAUTH_TOOLS, *TERMINAL_TOOLS, *EDITOR_TOOLS, *FILE_TOOLS]입니다.
# src/tools.py
from browser.tools import BROWSER_TOOLS
from email_agent.tools import EMAIL_TOOLS
...
이것이 리스트의 전부입니다. 중첩된 그룹도 없고, "우선순위가 있는 카테고리"도 없으며, 복잡한 병합 로직도 없습니다. 그저 11개의 평면적인 확산일 뿐입니다. 도구를 추가하려면 상수를 추가하면 되고, 도메인을 제거하려면 확산(spread) 하나를 삭제하면 됩니다. 그 외에 수정해야 할 곳은 없습니다.
엄격한 실행기 시그니처 (The strict executor signature)
모든 도구 함수는 (args: dict) -> dict 형식을 취하며 {"success": bool, "error": str | None, "result": Any}를 반환합니다. 항상 말이죠. 디스패처는 해당 도구가 Playwright 클릭인지 SMTP 전송인지 상관하지 않습니다. 항상 동일한 형태의 결과값을 받기 때문입니다.
# src/browser/tools.py
def browser_navigate(args: dict) -> dict:
url = args.get("url")
...
두 가지 주목할 점이 있습니다. 첫째, 디스패처(dispatcher)는 함수의 내부 구현을 절대 볼 수 없습니다 — 오직 .name, .description, .parameters, 그리고 .executor만을 읽습니다. 둘째, **익스큐터(executor)는 문자열이 아닌 실제 함수 참조(function reference)**입니다. 이는 GUI의 자동 완성(autocomplete), LLM의 도구 설명(tool description), 그리고 디스패처의 조회(lookup)가 모두 동일한 딕셔너리(dict)를 읽는다는 것을 의미하며, 이들 사이의 정보 불일치(drift)가 발생할 수 없음을 뜻합니다.
아무것도 모르는 디스패처
# src/agent.py
def execute_tool(name: str, args: dict) -> dict:
tool = TOOL_REGISTRY.get(name)
...
단 12줄입니다. 이 코드는 이름을 검증하고, 익스큐터를 호출하며, 익스큐터 자체의 try/except를 벗어나는 모든 예외를 포착하여(심층 방어, defence in depth), 다른 모든 도구가 반환하는 것과 동일한 딕셔너리 형태를 반환합니다. 에이전트 루프(agent loop)는 도구의 유형에 따라 분기하지 않습니다.
레지스트리(registry) 자체는 임포트(import) 시점에 평탄한 리스트(flat list)로부터 구축되므로, len(TOOL_REGISTRY)는 LLM에게 알려진 도구의 수이자, GUI가 자동 완성하는 도구의 수이며, 디스패처가 라우팅(route)할 수 있는 도구의 수입니다. 단일 진실 공급원(One source of truth)인 셈입니다.
이것이 실무에서 주는 이점
v3를 빌드하면서 v2에서는 느끼지 못했던 세 가지 점을 발견했습니다.
- 도구 추가는 단 하나의 파일만 수정하면 됩니다. 지난 주말에
terminal_run도구를 추가했는데, 다른 파일은 전혀 수정할 필요가 없었습니다. 디스패처, 레지스트리, LLM 도구 목록, 그리고 GUI 자동 완성이 모두 자동으로 이를 인식했습니다. - 도구 비활성화는 단 한 줄의 수정으로 가능합니다.
tools.py에서 해당 항목을 주석 처리하세요. 도구는 LLM의 사용 가능 목록에서 사라지고, GUI는 자동 완성을 중단하며, 만약 무언가가 여전히 해당 도구를 호출하더라도execute_tool은unknown tool을 반환합니다. 남겨진 경로(route)가 없습니다. - 리팩터링(Refactoring)이 안전합니다. 지난주에
browser_click을browser_press로 이름을 변경했습니다. 잠시 동안 LLM이 실패하기 시작했지만, 그 실패는 GUI 어딘가에서 발생한KeyError가 아니라 디스패처로부터 전달된 깔끔한unknown tool오류였습니다. 덕분에 이전 이름을grep으로 검색하여 모든 호출 지점(callsite)을 확인하고 각각 어떻게 처리할지 결정할 수 있었습니다.
솔직한 한계점
이러한 평면 리스트 (flat list) 방식은 비용이 따릅니다. 만약 서로 다른 도메인의 두 도구가 동일한 이름을 사용하고자 한다면, 나중에 펼쳐진(spread) 쪽이 경고 없이 조용히 승리하게 됩니다. 지난달에 저는 terminal_run이라는 중복된 이름을 거의 배포할 뻔했습니다. terminal/tools.py와 editor/tools.py 모두에 해당 이름의 함수를 내보내는(export) 로컬 헬퍼 모듈이 있었기 때문입니다. 둘 다 등록된 도구는 아니었지만, 임포트 충돌 (import collision)이 발생하여 간신히 잡아낼 수 있었습니다. 제대로 된 레지스트리 (registry)라면 아마 시작 시점에 이름 충돌을 확인해야 할 것입니다. 아직 그 기능은 추가하지 않았습니다.
직접 시도해 보세요
Sentience v3는 MIT 라이선스이며, 설치 방법은 pip install -r requirements.txt && playwright install chromium && python sentience_app.py입니다. 필요한 유일한 API 키는 무료 Groq 키입니다. 리포지토리 (repo), 빌드 스크립트, 그리고 v2→v3 변경 로그 (changelog)는 아래의 GitHub 링크에서 확인할 수 있습니다.
만약 여러분이 이와 다르게 동작하는 도구 디스패처 (tool dispatcher) — 즉, 즉시 그룹화 (eager grouping), 우선순위 라우팅 (priority routing), 동적 도구 로딩 (dynamic tool loading) — 를 구현하신다면, 무엇이 여러분을 평면적 접근 방식으로부터 멀어지게 했는지 진심으로 듣고 싶습니다. 저는 이 방식을 더 복잡하게 만들지 않기 위해 아슬아슬한 경계에 서 있습니다.
GitHub: https://github.com/AmSach/sentience-v2 (v3.0 빌드는 이 리포지토리의 master 브랜치에 있습니다. 로컬 워크스페이스에서는 디렉토리 이름이 Sentience-v3로 변경되었지만, 원격(remote)은 여전히 v2 리포지토리를 가리킵니다)
Stack: PySide6 · Playwright · APScheduler · SQLite (memory + RAG) · imaplib/smtplib · SpeechRecognition + pyttsx3 · Python 3.10+
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기