파이썬 에이전트 런타임을 스마트폰 APK에 탑재한 사례
완전한 파이썬 에이전트 커널을 업스트림 수정 없이 안드로이드 APK로 로컬 실행하며, 의존성 클로저와 직접 만든 Rust 휠, 실기기에서 겪은 문제들을 정리했다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
휴대폰 쪽은 그동안 표준 라이브러리만 쓰는 얇은 커널이었고, PC 업스트림과 이중으로 유지하다 보면 머지않아 드리프트가 생기기 마련이었다. 완전한 커널을 집어넣되 업스트림은 한 줄도 건드리지 않으려면, 실기 검증을 거쳐야만 결정을 내릴 수 있었다. 이 글은 실행 가능성 스파이크(spike)에서 실기 검수까지의 난관 공략, 그리고 실기에서 터져 나온 함정들을 기록한다.
TL;DR
- 무엇을 했나 : 완전한 Python Agent 커널(fastapi / uvicorn / openai-agents 그 진짜 본체, 잘라낸 버전 아님)을 APK에 집어넣고, 휴대폰 안에서 루프백 HTTP 서비스로 돌렸다. 업스트림 변경 제로.
- 결과 : 실기 검수 통과——루프백 chat 전 구간 작동(BYOK deepseek-v4.1-flash 실제 응답 + SSE 스트리밍), task 모드 agent 루프 실제 구동, 도구면 20개(휴대폰 GUI 원시 연산 9개 + 업스트림 core). Phase 0 실행 가능성 스파이크 전체 4.4s.
- 대가 : 의존성 클로저 공략——직접 빌드한 Rust wheel 5개 + 커뮤니티 기성 wheel 1개 + 의미론적 대체물 2개 + 공식 Android 저장소의 네이티브 패키지 2개.
- 아직 끝나지 않은 것(정직하게 나열) : 휴대폰 GateUI 부재, Phase 1은 security.mode=full_access로 임시 대체; 업스트림 utils/__init__의 Windows 전용 eager import가 여전히 ctypes.windll 스텁으로 버티는 중(이는 전 플랫폼 이식성 버그로, Linux/macOS에서도 똑같이 터짐, 업스트림의 3줄 지연화(lazy) 적용 후 철수 예정); 단일 arm64 실기에서만 검수 완료, 기기 간 확장은 미실시.
서론: 네 개의 층
- 무엇을 했나 : 휴대폰을 다루는 Agent가 완전한 Python Agent 커널 본체를 곧바로 APK에 집어넣고, 휴대폰 로컬 루프백 HTTP로 돌린다——루프, 도구, 기억, 오케스트레이션 전부 업스트림 것을 쓰고, 휴대폰 전용 사본은 만들지 않는다.
- 문제 : 얇은 커널은 PC 업스트림과 이중 유지해야 하고, 서서히 드리프트한다. fork 없이 완전한 커널을 직접 올리되, 업스트림은 한 줄도 건드리지 않을 수 있을까?
- 답 : 가능하다. 실기 검수 통과, 대가는 위에 나열한 그 의존성 클로저 공략이다.
- 한마디 규정 : 이는 「휴대폰에서 대규모 모델을 돌린다」가 아니라 「휴대폰에서 완전한 Agent 런타임(루프 + 도구 + 기억 + 오케스트레이션)을 돌린다」이며, 게다가 업스트림 변경 제로라는 것이 핵심 제약이다——업스트림을 한 줄 바꾸면 휴대폰 쪽은 빌드 시점에 자동으로 따라가니, 「동기화를 잊는」 일은 영원히 없다.
1. 목표와 관문: 우선 성공할 수 있음을 증명한다
휴대폰 쪽은 그동안 표준 라이브러리 얇은 커널(hachimi_kernel, 표준 라이브러리만 사용)이었다. 그 대가는 PC 업스트림과 이중 유지하며 서서히 드리프트하는 것이었다. 이번 목표는 명확하다: 업스트림 완전한 커널 본체를 APK에 집어넣고, 업스트림 변경 제로, 단일 진원——빌드 시점에 업스트림 저장소에서 소스 코드를 APK의 python 소스 집합으로 복사해 넣고, 업스트림이 바뀌면 즉시 반영된다.
하지만 이 일에는 사전에 장담할 수 없었던 몇 가지 지점이 있어, 먼저 실행 가능성 관문(Phase 0 spike)을 세웠고, 통과하지 못하면 되돌리기로 했다:
- 첫 번째이자 가장 단단한 관문 : pydantic-core 같은 바이너리 wheel은 Chaquopy 공식 저장소에 Android 버전이 없어, 플랫폼 태그를 다시 붙이거나 직접 빌드해야 한다;
- 업스트림 utils의 임포트 지뢰 : 패키지 임포트 시점에 Windows 전용 모듈(ctypes.windll + psutil)을 eager import 해서, Android에서는 import utils가 곧바로 터진다——게다가 이는 전 플랫폼 이식성 버그로, Linux/macOS에서도 똑같이 터진다;
- 또한 증명해야 함 : 완전한 SDK 임포트 체인 + uvicorn 기동 + 루프백 /health가 실기에서 작동함을.
결과: Phase 0의 다섯 관문 전부 통과, 스텁 없이 구동, 전체 4.4s. GATE_A는 pydantic-core 2.13.3 / core 2.46.3의 Rust 확장이 실기에서 로드됨; GATE_C는 완전한 업스트림 임포트 체인이 실제 SDK로 스텁 없이 통과(utils + omni_core + router_runtime + chat_runtime + audio_api); GATE_D는 루프백 uvicorn /health → 200. 관문을 통과했기에 비로소 Phase 1에 진짜로 착수할 수 있었다.
덤으로 하나의 아키텍처 결정: 로컬 커널 = 루프백 원격(127.0.0.1:<port>), 원격 PC 제어는 동일한 client에서 base_url만 바꾸는 것이며, Tier 1 / Tier 2는 동형이다. 이는 휴대폰을 「완전한 Python 서비스를 돌릴 수 있는 한 대의 기계」로 보는 것이지, 다운그레이드 방안이 아니다.
2. 의존성 클로저: 예상보다 훨씬 깊다(공략의 주전장)
완전한 커널을 집어넣을 때 첫 번째 관문은 코드가 아니라 의존성 클로저다. 순수 Python 부분(fastapi / uvicorn / openai / httpx / PyYAML / starlette)은 PyPI로 해결하면 그만이지만, 진짜 골칫거리는 그 컴파일형 확장들이다——그리고 그것들은 정적 감사보다 더 깊이 숨어 있다.
목록화 방법(미리 말해 두어 신비화로 오해받지 않도록)
레이어별로 `grep "^import ^from " + AST 파싱으로 최상위 import를 분석하여 「모듈 수준 하드 의존」과 「함수 내 지연 임포트」를 구분한다; 각 바이너리 패키지에 세 가지를 묻는다: 공식 Android wheel이 있나 → Chaquopy 저장소에 있나 → 커뮤니티 / 직접 빌드가 가능한가. **정적 감사로도 여전히 빠뜨린다**——utils/json_utils의 numpy는 세 번째로 놓친 것으로, 최종적으로는 실기에서 라운드마다 나오는 오류로 수렴하는 것을 기준으로 한다.
실측 클로저 시퀀스(가장 가치 있는 부분)
규칙 : LLM / Agent 생태계는 2025–2026년에 Rust화가 심각하여, 「순수 Python으로 충분할 것 같다」는 모든 판단을 pip 실측 클로저로 검증해야 한다——이는 스파이크 단계에서 거듭 뺨 맞으며 얻은 것이다.
최종 적용:
- 직접 빌드한 Rust wheel 5개 : jiter 0.18.0, rpds-py 0.30.0, ormsgpack 1.12.2, orjson 3.11.7, uuid-utils 0.14.1(abi3)
- 커뮤니티 기성 1개 : pydantic-core 2.46.3(파일명을 linux_aarch64에서 android_24_arm64_v8a로 바꿔야 pip가 받아들임)
- 공식 Android 저장소 2개: psutil 7.1.3, numpy 1.26.2
- 의미 대체품 2개: 아래 참조
의미 대체품: C 확장을 작성하는 것보다 절반의 수고를 덜어주는 정당한 수단
전제: 소비하는 쪽(consumer)이 API에 요구하는 의미(semantic) 조건이 비트 단위 호환보다 약할 때, 순수 Python 대체품 + 공식 스위치가 합법적인 해법이다.
- xxhash 3.6.0: langgraph는 xxh3_128_hexdigest만 소비한다(pregel 상태 버전 지문으로, 결정적인 128비트 요약값만 요구) → hashlib.blake2b(digest_size=16)로 구현, py3-none-any wheel. PC의 진짜 xxhash 요약값과는 상호 호환되지 않지만——기기 간 의미가 없으므로 영향도 없다.
- zstandard 0.25.0 stub: langsmith의 압축 임포트는 try/except ImportError 가드 + 공식 스위치 LANGSMITH_DISABLE_RUN_COMPRESSION=true로 되어 있다 —— stub은 pip 의존성 해석을 만족시키고, 가드는 자연히 False로 판정하니 두 겹의 보험이 된다.
판별법(이 단계는 생략할 수 없다): 대체하기 전에 소비하는 쪽의 실제 사용법을 grep한다. langgraph 전체 저장소에 from-import는 딱 한 곳뿐이고, langsmith는 가드 + 스위치가 있다. 그리고 교차 시스템 호환 의미가 없는지도 확인한다.
자작 wheel의 세 가지 핵심 함정(5단계법은 함정만 말할 뿐, 절차를 늘어놓지 않는다)
- pyo3의 android 대상 무조건 -lpython3.12: extension-module feature로도 없앨 수 없다(업스트림 build.rs가 android에 하드코딩해 두었다). 반드시 자사 APK에서 libpython3.12.so를 추출해 넣어야 하고, 산출물의 DT_NEEDED가 Chaquopy 런타임 라이브러리 soname과 글자 하나까지 일치해야 dlopen이 적중한다.
- 확장 파일명은 반드시 순수 .so여야 한다: <name>.cpython-312-aarch64-linux-android.so 같은 완전한 태그명은 Chaquopy의 EXTENSION_SUFFIXES가 인식하지 못해(import 시 No module named 보고) <name>.so로 바꾸면 즉시 보인다.
- maturin 1.16은 Windows에서 인터프리터 이름 해석 버그(.exe 접미사)가 있다: 우회하여 cargo로 직접 컴파일 + 수동으로 wheel 조립(공식 manylinux aarch64 wheel에서 python 파일과 메타데이터를 추출하고, 자작 .so를 넣고, WHEEL 태그를 cp312-cp312-android_24_arm64_v8a로 바꾸고, RECORD를 재계산).
경계: 이상은 NDK r27c + Chaquopy 17.2 + 대상 Python 3.12에 대한 것으로, 버전이 고정되어 있다(openai-agents==0.22.2 / openai==3.13.0 / mcp==2.2.0 …). NDK를 바꾸거나 Chaquopy를 올리면 한 번 더 처음부터 해야 할 수 있다.
3. 실기기에서 밟은 세 함정(현상 → 결과 → 고치는 법 → 교훈)
함정段落의 가치는 「내가 밟아봤다」에 있지 않고, 인과 사슬을 완전하게 서술하는 데 있다. 아래 세 가지는 모두 실기기에서 라운드마다 오류가 나오며 수렴해 낸 것으로, 설계 단계에서 예측할 수 있는 것이 아니다.
함정 1: SSL_CERT_FILE(이번에 가장 깊은 함정)
- 현상: agent 루프가 돌아간 후, LLM 호출이 3연패하여 APIConnectionError(스트리밍이 동기로 후퇴하는 로그에서 보인다)가 뜬다. 반면 순수 ssl.create_default_context()로 같은 엔드포인트에 직접 연결하면 TLS 핸드셰이크가 성공하고, httpx도 성공한다(401 = HTTP 정상).
- 결과: 「겉보기엔 뚫린 듯하지만 실제로는 호출이 안 되는」 링크라 매우 당혹스럽다——네트워크 설정이 맞다고 생각하지만, 실은 SDK 경로가 아닌 곳만 뚫린 것이다.
- 근본 원인: openai SDK는 httpx2를 쓰고, httpx2는 자체 ssl context를 만들어 「기본 검증 경로」를 탄다——기기에서 openssl_cafile=/usr/local/ssl/cert.pem이 존재하지 않음 → unable to get local issuer certificate. Chaquopy의 CA(cacert.pem)는 기본 경로에 없고, httpx2도 certifi를 자동으로 쓰지 않는다.
- 수정법: 환경 변수 SSL_CERT_FILE=certifi.where()(certifi는 AssetFinder에 실제 파일이 있다)를 prepare() 안에서 설정하며, 어떤 context가 생성되기 이전에 해야 한다.
- 진단법: 루프백 진단 라우트 /debug-net의 3계층 자체 점검(순수 ssl / httpx / httpx2 + 명시적 CA 대조)으로 한 번에 결론을 낸다. 해당 라우트는 유지한다(127.0.0.1만 접근 가능).
- 교훈: 크로스 컴파일 / 커스텀 런타임의 CA 신뢰 체인은 명시적으로 인수해야 하며, 「시스템에 기본 CA가 있을 것」이라 가정해서는 안 된다. Python 런타임이나 패키징 방식을 바꾸는 경우, TLS는 최우선 검증 항목이다.
함정 2: CWD / HOME 3종 세트(순서에 민감)
- 현상: import utils 하자마자 OSError: Read-only file system.
- 결과: 커널이 아예 뜨지 않아, 비즈니스 계층을 볼 겨를도 없이 오류부터 난다.
- 근본 원인: 업스트림 utils/logger가 import 시 makedirs('./temp')(상대 CWD)를 하는데, Android 프로세스의 CWD= /로 읽기 전용이다.
- 수정법: chdir은 어떤 omni/utils 임포트보다도 먼저 일어나야 한다. Kotlin 측 순서: prepare()(의존성 제로, 환경만 건드림) → bind 도구 모듈 → start; 도구 모듈 상단에서 다시 한 번 자체 보호(import 시 곧 chdir). HOME도 마찬가지로 App 비공개 디렉터리를 가리킨다.
- 교훈: 크로스 플랫폼 이식 시 먼저 「암묵적 CWD / HOME 의존」이 어디에 숨어 있는지 확인하라. 이런 의존은 평소 데스크톱에서는 잘 돌다가 커스텀 런타임에 올리면 터지는데, 그것도 가장 밑바닥의 가장 눈에 띄지 않는 곳에서 터진다.
함정 3: 서비스 준비 사칭
- 현상: omni_host가 kernel up을 보고하고, /health 활성 확인은 200을 반환하지만, 실제 포트에는 아무것도 없음.
- 결과: 활성 확인이 「통과」하여 커널이 떴다고 착각하게 만들지만, 실제 요청을 보내면 전부 비어 있음.
- 근본 원인: bind 실패(포트가 잔류 리스너에 점유됨) 후 uvicorn이 조용히 종료되는데, /health 활성 확인은 아직 살아 있는 옛 리스너에 꽂혔다 — 사칭(冒名顶替). Phase 0의 spike 자체 점검 서버(같은 포트) 잔류도 같은 유형에 부딪혔다: spike의 should_exit가 비동기로 발효되어 정식 서비스가 즉시 bind하면 EADDRINUSE가 나고, 활성 확인은 다시 spike에 꽂혀 거짓 성공이 됐다.
- 수정법: 준비 판정은 uvicorn Server.started 플래그 + 스레드 생존 검사로 하고, 활성 확인은 2차 확인으로만 쓴다. 검수 완료 후 테스트 훅을 제거한다.
- 교훈: 같은 포트에서 두 서비스가 전후 교대할 때 「활성 확인 통과」는 신・구 리스너를 구별하지 못한다. 준비 신호는 서비스 자체의 상태 머신에서 와야 하며, 「포트에 응답하는 누군가가 있다」에서 와서는 안 된다.
4. 도구면: @function_tool 직접 등록(AssetFinder 가상 경로)
- 현상: Chaquopy가 파이썬 소스를 assets/chaquopy/app.imy(zip)에 넣어 두어 import는 정상이지만 실제 파일 시스템에는 존재하지 않음 — 어떤 「디렉터리 스캔」 상류 로직(플러그인 loader의 plugins/ 디렉터리 스캔, Path(__file__).parent.glob)이든 기기에서 조용히 빈 결과가 됨.
- 결과: 상류 플러그인 loader가 휴대폰에서 플러그인을 하나도 스캔하지 못해 도구면이 비어 버림.
- 수정법: 도구 등록을 @function_tool import 즉시 등록(TOOL_REGISTRY 프로세스 내 테이블)으로 바꾸고, loader 디렉터리 스캔에 의존하지 않게 했다. 데이터 파일은 certifi.where() 등 런타임에 해석되는 실제 경로를 쓴다.
- 데이터: 휴대폰 도구면 20개 — 휴대폰 GUI 원시 연산 9개(observe / wait / tap_by_id / tap_xy / type_text / gesture / screenshot / launch_app / press) + 상류 core 도구. PC 전용 도구(예: shell_exec)는 휴대폰에서 등록되지만 실행은 실패한다 — 실패가 구조화되어 있어(agent가 자율적으로 우회) 루프를 터뜨리지 않으며, 이후 「도구면 = 등록 ∩ 통로 게이트」라는 규율에 따라 좁혀 갈 예정이다.
- 교훈: 커스텀 패키저는 「파일이 디스크에 있다」는 암묵적 전제를 바꿔 버린다. __file__ 상대 경로나 디렉터리 스캔에 의존하는 로직은 이식할 때 한 곳 한 곳 점검해야 한다.
5. 경계(정직하게 쓰고 과장하지 않는다)
- 기종 / 표본 수: 단일 arm64 실기(시리즈 앞 글과 동일 기기), Android, N=1 검수, 기기 간 일반화는 하지 않는다.
- Tier 경계: Phase 1은 본기 루프백(Tier 1) 위주, Tier 2 원격 PC 제어는 같은 wire contract를 재사용, mini_loop 폴백은 Phase 2에서 은퇴 예정.
- GateUI 부재: 휴대폰 승인면은 만들지 않았고, Phase 1은 security.mode=full_access로 명시적 전권으로 메우며, 승인면은 이후 별도로 만든다.
- 상류 이식성 버그는 임시로 스텁 사용: utils/__init__ 패키지가 import 시점에 window_finder(모듈 레벨 ctypes.windll + psutil)를 eager import — Android에서는 import utils만으로 터지며, 현재 ctypes.windll = SimpleNamespace(user32=None) 스텁으로 버티고 있다. 이는 전 플랫폼 버그(Linux/macOS도 동일하게 터짐)이고, 정답은 상류 3줄을 지연화하는 것이며, 반영되면 스텁을 철거한다.
- 네트워크는 독립된 환경 사실: 같은 BYOK 엔드포인트에 PC에서 닿는다 ≠ 휴대폰에서 닿는다(어떤 엔드포인트는 휴대폰 TCP 443이 간헐적 타임아웃이라 재시도 / 기기에서 닿는 provider로 전환이 필요). 또: gitclaw 게이트웨이의 비스트리밍 응답은 OpenAI 페이로드를 {"data":{...}}로 감싸고, 스트리밍은 표준 SSE로 감싸기가 없다 — 설정 페이지의 비스트리밍 테스트가 이 때문에 「응답이 OpenAI 호환 형식이 아니다」라고 오탐했지만 커널은 스트리밍을 쓰므로 영향이 없다. 테스트 스텁과 정식 링크의 전송 방식이 다르므로(stream vs non-stream) 결론이 반대로 나올 수 있다. 엔드포인트 검증은 반드시 스트리밍과 비스트리밍을 각각 한 번씩 테스트해야 한다.
글을 마치며
온전한 Agent 런타임을 휴대폰에 넣고 빌드 시점 동기화로 단일 진원(single source of truth)을 보장하는 일에서, 공학적으로 가장 무거운 것은 코드 작성이 아니라 위의 여러 겹에 걸친 의존성과 런타임의 난관이다 — 각 층 모두 실기에서 회차마다 오류를 보고 수렴시켜 낸 것이지, 설계 단계에서 전부 예측할 수 있는 것이 아니었다.
층별 증상 → 근본 원인 → 해법 → 정리된 규칙을 프로젝트 저장소의 doc/full_kernel_hosting_pitfalls_*.md 에 온전히 기록해 두었으니, 기기를 바꾸거나 엔드포인트를 바꿔도 그 최소 검수 순서대로 다시 돌리면 된다.
프로젝트 주소: github.com/momo-null/hachifish
vilya
9
글
1.6k
읽음
4
팔로워