Pyodide와 가상 파일 시스템, 내구성 실행을 바탕으로 에이전트가 일회성 코드를 생성·실행해 비(非)코딩 문제를 해결하도록 하는 방법과 설계 아이디어, 도구 연결 방식을 소개합니다.
2025년 10월 17일 작성
8월에 나는 MCP(Model Context Protocol)를 코드로 대체하는 실험에 대해 글을 썼다. 그 뒤로 Earendil에서 비(非)코딩 에이전트를 탐색하는 데 그 아이디어를 활용했다. 나만 그런 게 아니다! 그 사이 여러 사람이 이 영역을 시도했고, 업데이트된 발견들을 공유할 가치가 있다고 느꼈다. 기본 아이디어는 아주 단순하다. 에이전트는 코드를 정말 잘 쓰니, 차라리 코드와 상관없는 문제를 풀기 위해 일회성(throw-away) 코드를 쓰게 하면 어떨까?
내가 어떻게 하고 있고 왜 이게 생각보다 훨씬 단순한지, 그리고 무엇이 잘 작동하는지 아이디어를 몇 가지 보여주고 싶다.
먼저 알아둘 점은 Pyodide가 사실상 많은 에이전틱 상호작용에서 꽤 중요한 축이 되어가고 있다는 것이다. Pyodide가 뭘까? Pyodide는 WebAssembly 런타임 위에서 표준 Python 인터프리터를 사용할 수 있게 해주는 오픈 소스 프로젝트다. 여기에 멋진 점 하나가 더 있는데, PyPI에서 의존성을 설치할 수 있게 해주는 micropip이라는 설치 도구가 있다. 또 emscripten 런타임 환경을 타깃으로 하기 때문에, 인터프리터 주변에 꽤 그럴듯한 표준 유닉스 환경이 갖춰져 있고 이에 상호작용할 수 있다.
Node 환경이 있다면 Pyodide를 실행하는 건 놀라울 정도로 간단하다. npm에서 바로 설치할 수 있다. 더 좋은 점은 가상 파일 시스템과도 상호작용할 수 있다는 것이다. 덕분에 외부 세계와 상호작용하는 지속형 런타임 환경을 만들 수 있다. 요즘은 여러 스타트업이 호스팅된 Pyodide를 제공하기도 하지만, 원한다면 자신의 머신과 인프라에서 아주 쉽게 직접 돌릴 수도 있다.
내가 가장 잘 먹히는 방법을 찾은 것은, Pyodide를 웹 워커로 유배시키는 것이다. 이렇게 하면 시간 제한에 걸렸을 때 인터럽트할 수 있다.
Pyodide가 강력한 런타임인 또 하나의 큰 이유는, 파이썬이 모델들이 잘 아는 훌륭한 라이브러리 생태계를 갖추고 있기 때문이다. PDF나 워드 문서 조작부터 이미지 생성까지, 대부분 거기에 다 있다.
코드 인터프리터에서 또 하나의 핵심 재료는 파일 시스템이다.
그렇다고 아무 파일 시스템이나 되는 건 아니다. 나는 가상 파일 시스템을 구성한 뒤 이를 가로채서, 특정 파일 시스템 위치에서 원격 리소스에 접근할 수 있게 한다. 예를 들어 파일 시스템의 특정 폴더를, 백엔드 API에서 가져오는 리소스를 파일처럼 노출하는 영역으로 만들 수 있다. 그러면 에이전트가 그 파일을 읽기로 선택했을 때, 샌드박스 바깥에서 안전한 HTTP 요청으로 그 리소스를 가져와 사용할 수 있다. 샌드박스 자체에는 네트워크 접근 권한이 없고, 리소스 접근은 파일 시스템이 관문 역할을 한다.
파일 시스템이 좋은 이유는, 에이전트가 파일 시스템에 대해 너무도 많은 지식을 이미 갖고 있기 때문이다. 그리고 샌드박스 바깥의 외부 시스템을 통해 리소스에 대한 안전한 접근을 제공할 수 있다. 일부 리소스에는 읽기 전용, 다른 리소스에는 쓰기 권한을 제공하고, 생성된 산출물은 다시 바깥에서 접근하면 된다.
실제로 이걸 구현하는 건 약간 까다롭다. emscripten 파일 시스템은 동기(synchronous)인데, 흥미로운 작업의 상당수가 비동기(async)이기 때문이다. 내가 선택한 방식은 fetch 류의 비동기 로직을 또 다른 웹 워커로 옮기고, Atomics.wait를 이용해 블로킹하는 것이다. Pyodide 전체 런타임이 웹 워커 안에 있다면 보기보다 나쁘지 않다.
덧붙여 말하자면, emscripten 파일 시스템 API가 이런 걸 위해 스택 스위칭을 지원하도록 바뀌면 좋겠다. Pyodide 내부에서는 이제 call_sync로 비동기 프로미스를 동기 추상화 뒤에 숨길 수 있지만, 동일한 접근법이 emscripten의 자바스크립트 FS API에는 통하지 않는다.
이 글 맨 끝에 전체 예제가 있지만, 내가 최종적으로 사용한 단순화된 의사코드는 대략 다음과 같다:
js// 메인 스레드: 워커를 감싸서 fetch()가 동기처럼 보이게 만들기 fetch(url) { const signalBuffer = new SharedArrayBuffer(4); const signal = new Int32Array(signalBuffer); const { port1, port2 } = new MessageChannel(); this.worker.postMessage({url, signalBuffer, port: port2}, [port2]); Atomics.wait(signal, 0, 0); // 워커가 신호를 바꿀 때까지 대기 const message = receiveMessageOnPort(port1); // MessageChannel에서 페이로드 수신 port1.close(); if (message.message.status !== "ok") { throw new Error(message.message.error.message); } return message.message.data; } // 워커 스레드: 비동기 fetch 후, 메인 스레드를 깨우기 parentPort.on("message", async ({ url, signalBuffer, port }) => { const signal = new Int32Array(signalBuffer); try { const bytes = await fetch(url).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.arrayBuffer(); }); port.postMessage({ status: "ok", data: new Uint8Array(bytes) }); Atomics.store(signal, 0, 1); // 성공 표시 } catch (error) { port.postMessage({ status: "error", error: serialize(error) }); Atomics.store(signal, 0, -1); // 실패 표시 } finally { Atomics.notify(signal, 0); // 대기 중인 메인 스레드 깨우기 port.close(); } });
이제 에이전트를 돌리기 시작했다면, 정말로 필요한 건 내구성 실행이다. 내구성 실행은 복잡한 워크플로를, 진행 상황을 잃지 않고 안전하게 재시도할 수 있는 능력이라고 설명할 수 있다. 에이전트는 아주 오랜 시간이 걸릴 수 있고, 도중에 끊기더라도 원래 상태로 되돌릴 수 있어야 한다. 요즘 이건 꽤 뜨거운 주제이고, 이 영역의 스타트업도 많아 마음만 먹으면 기성 제품을 사서 쓸 수도 있다.
조금 아쉬운 점은, 정말 단순한 내구성 실행 시스템이 아직 없다는 것이다. 예를 들어 pgmq처럼 Postgres나 Redis 위에서 그냥 돌아가는 그런 단순함을 말한다.
직접 얼기설기 끼워 맞추는 가장 쉬운 방법은, 큐로 태스크를 재시작하고 실행 중의 중간 단계들을 캐시하는 것이다. 요지는 태스크를 여러 단계로 나누고 각 단계에 아주 단순한 캐시 키를 부여하는 것. 정말 이게 전부다:
jsfunction myAgenticLoop(taskID, initialState) { let stepCount = 0; let state = initialState; while (stepCount < MAX_STEPS) { let cacheKey = `${taskID}:${stepCount}`; let cachedState = loadStateFromCache(cacheKey); if (cachedState !== null) { state = cachedState.state; } else { state = runAgenticStep(state); storeStateInCache(cacheKey, state); } stepCount++; if (reachedEndCondition(state)) { break; } } return state; }
여기서 훨씬 개선할 여지가 많지만, 일반적인 아이디어는 이렇다. state는 기본적으로 대화 로그와 툴 실행에 유지해야 하는 모든 것(예: 파일 시스템에 떨어뜨린 것들)을 담는다.
에이전트가 코드 말고 어떤 도구가 필요할까? 코드는 뭔가 흥미로운 일을 할 수 있어야 하므로, 무엇인가에 대한 접근 권한을 줘야 한다. 가장 흥미로운 접근은 앞서 말했듯 파일 시스템을 통한 접근이다. 하지만 그 외에도 노출하고 싶은 도구들이 있을 수 있다. Cloudflare가 제안한 것은 MCP 서버에 연결하고, 그들의 툴을 코드 인터프리터에 노출하는 방식이다. 꽤 흥미로운 접근이고, 어느 정도는 그 방향이 맞기도 하다.
내가 흥미롭게 보는 도구 몇 가지:
Describe: 에이전트가 추가 추론을 수행하게 해주는 도구. 주로 코드 인터프리터가 생성한 파일을 대상으로 한다. 예를 들어 zip 파일이 있다면, 코드 인터프리터가 파이썬으로 이를 풀어내는 걸 보는 건 꽤 재밌다. 하지만 풀린 파일이 jpg라면, 그걸 이해하려면 다시 추론 단계로 돌아가야 한다.
Help: 말 그대로 … 도움말을 띄우는 도구. 기본 RAG 등으로 추론을 섞을 수도 있다. AI가 스스로 도움을 요청할 수 있게 하는 게 꽤 흥미로웠다. 예를 들어 “주어진 XLSX 파일로 차트를 만들려면 어떤 파이썬 코드를 써야 하지?” 같은 쿼리를 수용하는 매뉴얼 도구가 있으면 좋다. 반대로, 가상 파일 시스템 어딘가에 .md 파일로 몇 가지 지침을 저장해 두고 코드 인터프리터가 그걸 읽게 하는 것도 가능하다. 모두 선택지다.
이게 대략 어떤 모습인지 보고 싶다면, 대충 감으로 코딩해서 간단한 버전을 만들어 봤다. 만들어낸 예시는 가짜지만, 아주 제한적인 도구만 있는 샌드박스가 꽤 놀라운 결과를 만드는 모습을 보여준다: mitsuhiko/mini-agent.
실행하면, 특수한 네트워크 드라이브에서 현재 IP를 조회하는데, 이는 비동기 fetch를 트리거한다. 그런 다음 (보통은) pillow나 matplotlib를 써서 그 IP 주소 이미지를 만든다. 실용성은 별로지만, 꽤 재미있다!
에이전트 실행 예시
프롬프트: 현재 IP 주소를 알아내고, 그걸 이미지로 만들어줘.
단계 1: 정지 사유: tool_use 도구 호출: execute_python { code: '\n' + '# Read the current IP address\n' + "with open('/network/current-ip', 'r') as f:\n" + ' ip_address = f.read().strip()\n' + ' \n' + 'print(f"Current IP Address: {ip_address}")\n' } 도구 결과: { stdout: 'Current IP Address: 255.255.255.255', stderr: '', success: true } [Cache] Saved state to: agent-cache/task-0-step-1.json
단계 2: 정지 사유: tool_use 도구 호출: execute_python { code: '\n' + 'import matplotlib.pyplot as plt\n' + 'import matplotlib.patches as patches\n' + 'from matplotlib.patches import FancyBboxPatch\n' + 'import numpy as np\n' + '\n' + '# Create figure and axis\n' + 'fig, ax = plt.subplots(1, 1, figsize=(12, 8))\n' + 'ax.set_xlim(0, 10)\n' + 'ax.set_ylim(0, 10)\n' + "ax.axis('off')\n" + '\n' + '# Add background gradient\n' + 'gradient = np.linspace(0, 1, 256).reshape(1, -1)\n' + 'gradient = np.vstack([gradient] * 256)\n' + "ax.imshow(gradient, extent=[0, 10, 0, 10], aspect='auto', cmap='cool', alpha=0.3)\n" + '\n' + '# Title\n' + "ax.text(5, 9, 'Current IP Address', fontsize=28, weight='bold', \n" + " ha='center', va='top', color='#1a1a1a')\n" + '\n' + '# IP Address Box\n' + 'fancy_box = FancyBboxPatch((0.5, 4.5), 9, 2.5, \n' + ' boxstyle="round,pad=0.2", \n' + " edgecolor='#2196F3', facecolor='#E3F2FD',\n" + ' linewidth=3)\n' + 'ax.add_patch(fancy_box)\n' + '\n' + '# IP Address Text\n' + "ax.text(5, 5.75, ip_address, fontsize=48, weight='bold', \n" + " ha='center', va='center', color='#1976D2', family='monospace')\n" + '\n' + '# Decorative elements - network nodes\n' + 'circle_positions = [(2, 2), (8, 2), (2, 8), (8, 8), (5, 1), (5, 9)]\n' + 'for pos in circle_positions:\n' + " circle = patches.Circle(pos, 0.3, color='#FF5722', alpha=0.7)\n" + ' ax.add_patch(circle)\n' + '\n' + '# Connection lines\n' + 'for i in range(len(circle_positions)):\n' + ' for j in range(i+1, len(circle_positions)):\n' + ' x = [circle_positions[i][0], circle_positions[j][0]]\n' + ' y = [circle_positions[i][1], circle_positions[j][1]]\n' + " ax.plot(x, y, color='#2196F3', alpha=0.2, linewidth=1)\n" + '\n' + '# Info text\n' + "ax.text(5, 3.8, '🌐 Your Network Location 🌐', fontsize=14, \n" + " ha='center', va='top', color='#555', style='italic')\n" + '\n' + 'plt.tight_layout()\n' + "plt.savefig('/output/ip_address.png', dpi=150, bbox_inches='tight', facecolor='white')\n" + 'print("✅ Picture saved to /output/ip_address.png")\n' + 'plt.close()\n' + '\n' + 'print(f"\n📍 Your IP Address: {ip_address}")\n' } 도구 결과: { stdout: 'Loading Pillow, contourpy, cycler, fonttools, kiwisolver, matplotlib, numpy, packaging, pyparsing, python-dateutil, pytz, sixLoaded Pillow, contourpy, cycler, fonttools, kiwisolver, matplotlib, numpy, packaging, pyparsing, python-dateutil, pytz, six', stderr: 'Traceback (most recent call last):\n' + ' File "/lib/python313.zip/_pyodide/_base.py", line 597, in eval_code_async\n' + ' await CodeRunner(\n' + ' ...<9 lines>...\n' + ' .run_async(globals, locals)\n' + ' File "/lib/python313.zip/_pyodide/_base.py", line 411, in run_async\n' + ' coroutine = eval(self.code, globals, locals)\n' + ' File "<exec>", line 8, in <module>\n' + ' File "/lib/python3.13/site-packages/matplotlib/pyplot.py", line 1613, in subplots\n' + ' fig = figure(**fig_kw)\n' + ' File "/lib/python3.13/site-packages/matplotlib/pyplot.py", line 934, in figure\n' + ' manager = new_figure_manager(\n' + ' num, figsize=figsize, dpi=dpi,\n' + ' facecolor=facecolor, edgecolor=edgecolor, frameon=frameon,\n' + ' FigureClass=FigureClass, **kwargs)\n' + ' File "/lib/python3.13/site-packages/matplotlib/pyplot.py", line 464, in new_figure_manager\n' + ' _warn_if_gui_out_of_main_thread()\n' + ' ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^\n' + ' File "/lib/python3.13/site-packages/matplotlib/pyplot.py", line 441, in _warn_if_gui_out_of_main_thread\n' + ' canvas_class = cast(type[FigureCanvasBase], _get_backend_mod().FigureCanvas)\n' + ' ~~~~~~~~~~~~~~~~^^\n' + ' File "/lib/python3.13/site-packages/matplotlib/pyplot.py", line 280, in _get_backend_mod\n' + ' switch_backend(rcParams._get("backend")) # type: ignore[attr-defined]\n' + ' ~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^\n' + ' File "/lib/python3.13/site-packages/matplotlib/pyplot.py", line 342, in switch_backend\n' + ' module = importlib.import_module(cbook._backend_module_name(newbackend))\n' + ' File "/lib/python313.zip/importlib/init.py", line 88, in import_module\n' + ' return _bootstrap._gcd_import(name[level:], package, level)\n' + ' ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n' + ' File "<frozen importlib._bootstrap>", line 1387, in _gcd_import\n' + ' File "<frozen importlib._bootstrap>", line 1360, in _find_and_load\n' + ' File "<frozen importlib._bootstrap>", line 1331, in _find_and_load_unlocked\n' + ' File "<frozen importlib._bootstrap>", line 935, in _load_unlocked\n' + ' File "<frozen importlib._bootstrap_external>", line 1026, in exec_module\n' + ' File "<frozen importlib._bootstrap>", line 488, in _call_with_frames_removed\n' + ' File "/lib/python3.13/site-packages/matplotlib/backends/backend_webagg.py", line 23, in <module>\n' + " from js import document\n" + "ImportError: cannot import name 'document' from 'js' (unknown location)\n", success: false } [Cache] Saved state to: agent-cache/task-0-step-2.json
단계 3: 정지 사유: tool_use 도구 호출: execute_python { code: '\n' + 'from PIL import Image, ImageDraw, ImageFont\n' + 'import os\n' + '\n' + '# Read the current IP address\n' + "with open('/network/current-ip', 'r') as f:\n" + ' ip_address = f.read().strip()\n' + '\n' + '# Create a new image with a nice gradient-like background\n' + 'width, height = 1200, 800\n' + "image = Image.new('RGB', (width, height), color='white')\n" + 'draw = ImageDraw.Draw(image)\n' + '\n' + '# Draw background with gradient effect using rectangles\n' + 'for i in range(height):\n' + ' ratio = i / height\n' + ' r = int(30 + (100 * ratio))\n' + ' g = int(100 + (150 * ratio))\n' + ' b = int(200 + (55 * ratio))\n' + ' draw.rectangle([(0, i), (width, i+1)], fill=(r, g, b))\n' + '\n' + '# Draw main box\n' + 'box_margin = 100\n' + 'box_top = 250\n' + 'box_height = 300\n' + 'draw.rectangle(\n' + ' [(box_margin, box_top), (width - box_margin, box_top + box_height)],\n' + ' fill=(240, 248, 255),\n' + ' outline=(33, 150, 243),\n' + ' width=5\n' + ')\n' + '\n' + '# Draw rounded corners effect\n' + 'for i in range(20):\n' + ' draw.arc([(box_margin, box_top), (box_margin + 40, box_top + 40)], \n' + ' 0, 90, fill=(33, 150, 243), width=5)\n' + '\n' + '# Add decorative network nodes\n' + 'node_radius = 15\n' + 'nodes = [(150, 150), (1050, 150), (150, 650), (1050, 650), (600, 100), (600, 700)]\n' + 'for node in nodes:\n' + ' draw.ellipse(\n' + ' [(node[0] - node_radius, node[1] - node_radius),\n' + ' (node[0] + node_radius, node[1] + node_radius)],\n' + ' fill=(255, 87, 34),\n' + ' outline=(200, 50, 0),\n' + ' width=2\n' + ' )\n' + '\n' + '# Draw connection lines\n' + 'for i, node1 in enumerate(nodes):\n' + ' for node2 in nodes[i+1:]:\n' + ' draw.line([node1, node2], fill=(33, 150, 243, 50), width=1)\n' + '\n' + '# Try to use a large default font\n' + 'try:\n' + ' title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 60)\n' + ' ip_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuMono-Bold.ttf", 90)\n' + ' subtitle_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 30)\n' + 'except:\n' + ' title_font = ImageFont.load_default()\n' + ' ip_font = ImageFont.load_default()\n' + ' subtitle_font = ImageFont.load_default()\n' + '\n' + '# Draw title\n' + 'draw.text((width // 2, 80), "Current IP Address", font=title_font, \n' + ' fill=(255, 255, 255), anchor="mm")\n' + '\n' + '# Draw IP address\n' + 'draw.text((width // 2, box_top + box_height // 2), ip_address, font=ip_font, \n' + ' fill=(25, 118, 210), anchor="mm")\n' + '\n' + '# Draw subtitle\n' + 'draw.text((width // 2, box_top + box_height + 50), "🌐 Your Network Location 🌐", \n' + ' font=subtitle_font, fill=(100, 100, 100), anchor="mm")\n' + '\n' + '# Save the image\n' + "output_path = '/output/ip_address.png'\n" + 'image.save(output_path)\n' + 'print(f"✅ Picture saved to {output_path}")\n' + 'print(f"\n📍 Your IP Address: {ip_address}")\n' } 도구 결과: { stdout: 'Pillow already loaded from default channelNo new packages to load✅ Picture saved to /output/ip_address.png📍 Your IP Address: 255.255.255.255', stderr: '', success: true } [Cache] Saved state to: agent-cache/task-0-step-3.json
단계 4: 정지 사유: end_turn
최종 결과: 완벽합니다! 🎉 다음을 성공적으로 수행했어요:
이미지는 /output/ip_address.png 에 저장되어 다운로드할 준비가 되었습니다! [Cache] Saved state to: agent-cache/task-0-step-4.json
총 단계: 4
./output에 다음 파일 1개를 제공합니다: ✓ ip_address.png
같은 접근 방식은 Anthropic과 Cloudflare에서도 활용되고 있다. 더 많은 아이디어를 얻을 수 있는 읽을거리: