Recipes

오프라인 리포지토리 도구용 단일 파일 로컬 LLM + 음성-텍스트 변환 러너

README 링크에서 발견 · 2 days ago

Research & KnowledgeSoftware & DevOps

로컬 모델과 whisperfile을 오프라인용 단일 파일 실행 파일로 패키징했습니다

What they did

그들은 Python 환경이나 CUDA 툴킷, 모델 다운로드 과정 없이 팀원이 실행할 수 있는 로컬 추론을 원했다. llamafile README에서 시작해, mozilla-ai Hugging Face 리포에서 작은 사전 빌드된 llamafile(Qwen3.5 0.8B Q8_0)을 내려받고, chmod +x로 실행 권한을 준 뒤 바로 실행하여 번들 서버가 뜨고 localhost에서 OpenAI 호환 엔드포인트를 제공하는지 확인했다. 그다음 음성-텍스트 변환을 위해 두 번째 단일 파일 바이너리인 whisperfile을 추가하여, 회의 녹음과 음성 메모를 같은 머신에서 동일한 무설치 방식으로 전사할 수 있게 했다. 리포에는 작은 scripts/ 디렉터리를 추가했다: llamafile과 whisperfile 아티팩트를 gitignore된 .local/ 폴더로 curl로 내려받는 fetch 스크립트, 고정 포트와 적당한 컨텍스트 크기로 llamafile 서버를 실행하는 run 스크립트, 오디오를 whisperfile로 파이프하여 소스 파일 옆에 평문 전사본을 작성하는 transcribe 스크립트다. 애플리케이션 코드는 더미 API 키와 함께 http://localhost:PORT/v1을 가리키므로, 백엔드가 로컬 llamafile이든 나중에 호스팅 모델이든 동일한 클라이언트가 작동한다. 그들은 Windows 주의사항(.exe로 이름 변경, 4GB 초과 실행 파일은 실행되지 않으므로 외부 GGUF 가중치와 함께 작은 llamafile 바이너리를 사용)을 문서화하고, 번들된 llama.cpp 버전을 알 수 있도록 0.10.* 빌드를 고정했다. 마지막으로 짧은 README 섹션과, 어떤 스크립트가 실행되기 전에 서버가 응답하는지 확인하는 스모크 테스트를 작성했다.

How it went

macOS와 Linux에서 엔드 투 엔드로 작동했습니다. 0.8B llamafile은 몇 초 만에 시작되어 로컬 엔드포인트를 통해 프롬프트에 응답했고, whisperfile은 짧은 오디오 클립에서 사용 가능한 트랜스크립트를 생성했습니다. 전체 설정은 몇 개의 셸 스크립트와 다운로드한 바이너리 두 개뿐이라, 새 머신 온보딩은 curl 한 번과 chmod 한 번이면 됩니다. 품질은 초안 작성, 요약, 트랜스크립션 트리아지에는 충분하지만 길거나 미묘한 작업에서는 호스팅된 프런티어 모델보다 눈에 띄게 약하며, 0.8B 모델은 일상용 드라이버라기보다 스모크 테스트 기본값으로 취급하는 것이 가장 좋습니다.

Worth knowing

Windows에서는 .exe 이름 변경이 필요하고 4GB를 초과하는 단일 파일 실행 파일은 실행할 수 없으므로, 더 큰 모델은 독립 실행형 llamafile 바이너리와 외부 GGUF 가중치가 필요합니다. 0.10.* 빌드 시스템은 더 최신이며 클래식 0.9.* 릴리스의 기능이 없을 수 있으므로 버전을 신중하게 고정하세요. 로컬 모델은 호스팅 모델보다 느리고 성능이 떨어지며, 실행하면 CPU와 RAM을 최대로 사용합니다. 여기에는 보안 경계가 전혀 없습니다. 로컬 서버에는 실제 인증이 없으므로 localhost에만 바인딩하세요. 모델 라이선스와 다운로드 크기는 아티팩트마다 다릅니다.

Wiring

순서: (1) curl로 아티팩트를 .local/에 가져오고, (2) llamafile과 whisperfile 바이너리에 chmod +x를 적용하고, (3) 고정 포트에서 llamafile 서버를 백그라운드로 시작하고, (4) 준비될 때까지 헬스 엔드포인트를 폴링하고, (5) 그 위에서 앱 코드나 트랜스크립션을 실행한다. 설정은 단일 .env 스타일 파일에 있다: MODEL_PATH, WHISPERFILE_PATH, LOCAL_PORT, CTX_SIZE, 그리고 더미 API 키와 함께 http://localhost:PORT/v1을 기본값으로 하는 BASE_URL. 앱은 OpenAI 호환 클라이언트를 사용하므로 base URL이 로컬과 호스팅 간의 유일한 전환점이다. 트랜스크립션은 파일당 별도의 원샷 프로세스로, 채팅 서버를 통하지 않고 스크립트로 호출된다. Makefile 타깃이 fetch, serve, 스모크 테스트를 연결하므로 새 클론은 명령 하나로 끝난다.

Try it yourself

# 오프라인 로컬 추론 설정 (llamafile + whisperfile)

## 1. `.gitignore` 업데이트

```gitignore
# Local model binaries and runtime artifacts
.local/
```

## 2. `scripts/fetch-local-models.sh`

```bash
#!/usr/bin/env bash
# Downloads a small pre-built llamafile and a whisperfile binary into .local/.
# Both are chmod +x'd so they can be executed directly.
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_DIR="${ROOT_DIR}/.local"
mkdir -p "${LOCAL_DIR}"

# --- llamafile (Qwen3.5 0.8B Q8_0) -----------------------------------------
# Hosted on the mozilla-ai Hugging Face repo. Small enough to run on CPU.
LLAMAFILE_NAME="Qwen3.5-0.8B-Q8_0.llamafile"
LLAMAFILE_URL="https://huggingface.co/mozilla-ai/Qwen3.5-0.8B-llamafile/resolve/main/${LLAMAFILE_NAME}"

# --- whisperfile -----------------------------------------------------------
# Pre-built whisperfile binary (tiny.en model baked in).
WHISPERFILE_NAME="whisperfile-tiny.en"
WHISPERFILE_URL="https://huggingface.co/mozilla-ai/whisperfile/resolve/main/whisperfile-tiny.en"

fetch() {
  local url="$1"
  local dest="$2"
  if [[ -f "${dest}" ]]; then
    echo "✓ already present: ${dest}"
    return
  fi
  echo "↓ downloading ${url}"
  curl -fL --retry 3 --retry-delay 2 -o "${dest}.part" "${url}"
  mv "${dest}.part" "${dest}"
}

fetch "${LLAMAFILE_URL}"   "${LOCAL_DIR}/${LLAMAFILE_NAME}"
fetch "${WHISPERFILE_URL}" "${LOCAL_DIR}/${WHISPERFILE_NAME}"

chmod +x "${LOCAL_DIR}/${LLAMAFILE_NAME}" "${LOCAL_DIR}/${WHISPERFILE_NAME}"

echo "Done. Binaries in ${LOCAL_DIR}:"
ls -lh "${LOCAL_DIR}"
```

## 3. `scripts/serve-local.sh`

```bash
#!/usr/bin/env bash
# Launches the llamafile server on a configurable port with a modest context window.
# Logs to .local/server.log.
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_DIR="${ROOT_DIR}/.local"
LOG_FILE="${LOCAL_DIR}/server.log"

PORT="${PORT:-8080}"
CTX_SIZE="${CTX_SIZE:-4096}"
LLAMAFILE="${LOCAL_DIR}/Qwen3.5-0.8B-Q8_0.llamafile"

if [[ ! -x "${LLAMAFILE}" ]]; then
  echo "llamafile not found at ${LLAMAFILE}. Run scripts/fetch-local-models.sh first." >&2
  exit 1
fi

mkdir -p "${LOCAL_DIR}"
echo "Starting llamafile server on port ${PORT} (ctx=${CTX_SIZE}) → ${LOG_FILE}"

exec "${LLAMAFILE}" \
  --server \
  --host 127.0.0.1 \
  --port "${PORT}" \
  --ctx-size "${CTX_SIZE}" \
  --nobrowser \
  >>"${LOG_FILE}" 2>&1
```

## 4. `scripts/transcribe.sh`

```bash
#!/usr/bin/env bash
# Runs whisperfile over an audio file and writes a .txt transcript beside it.
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_DIR="${ROOT_DIR}/.local"
WHISPERFILE="${LOCAL_DIR}/whisperfile-tiny.en"

if [[ $# -lt 1 ]]; then
  echo "Usage: $0 <audio-file>" >&2
  exit 1
fi

AUDIO="$1"
if [[ ! -f "${AUDIO}" ]]; then
  echo "Audio file not found: ${AUDIO}" >&2
  exit 1
fi
if [[ ! -x "${WHISPERFILE}" ]]; then
  echo "whisperfile not found at ${WHISPERFILE}. Run scripts/fetch-local-models.sh first." >&2
  exit 1
fi

OUT="${AUDIO%.*}.txt"
echo "Transcribing ${AUDIO} → ${OUT}"
"${WHISPERFILE}" -f "${AUDIO}" -otxt -of "${AUDIO%.*}"
echo "Done: ${OUT}"
```

## 5. 모델 클라이언트를 로컬 엔드포인트로 연결

앱의 모델 클라이언트가 OpenAI 호환 엔드포인트를 사용하도록 설정합니다. 환경 변수로 호스팅 백엔드로 전환할 수 있게 합니다.

```ts
// src/lib/model-client.ts
const BASE_URL = process.env.MODEL_BASE_URL ?? "http://localhost:8080/v1";
const API_KEY  = process.env.MODEL_API_KEY  ?? "local-no-key";

export const modelClient = {
  baseURL: BASE_URL,
  apiKey: API_KEY,
  model: process.env.MODEL_NAME ?? "local",
};
```

호스팅 백엔드로 전환하려면:

```bash
MODEL_BASE_URL=https://api.example.com/v1 \
MODEL_API_KEY=sk-... \
MODEL_NAME=some-hosted-model \
npm start
```

## 6. 스모크 테스트

서버가 응답할 때까지 대기한 뒤 테스트를 실행합니다.

```ts
// tests/smoke.local.test.ts
import { describe, it, expect, beforeAll } from "vitest";

const BASE_URL = process.env.MODEL_BASE_URL ?? "http://localhost:8080/v1";
const TIMEOUT_MS = 60_000;

async function waitForServer(url: string, timeoutMs: number) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    try {
      const res = await fetch(`${url}/models`);
      if (res.ok) return;
    } catch {
      // not up yet
    }
    await new Promise((r) => setTimeout(r, 1000));
  }
  throw new Error(`Server at ${url} did not respond within ${timeoutMs}ms`);
}

describe("local inference smoke test", () => {
  beforeAll(async () => {
    await waitForServer(BASE_URL, TIMEOUT_MS);
  }, TIMEOUT_MS);

  it("responds to a chat completion", async () => {
    const res = await fetch(`${BASE_URL}/chat/completions`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.MODEL_API_KEY ?? "local-no-key"}`,
      },
      body: JSON.stringify({
        model: process.env.MODEL_NAME ?? "local",
        messages: [{ role: "user", content: "Say hello in one word." }],
        max_tokens: 16,
      }),
    });
    expect(res.ok).toBe(true);
    const data = await res.json();
    expect(data.choices?.[0]?.message?.content).toBeTruthy();
  });
});
```

## 7. README 섹션

````markdown
## Offline local inference

This repo can run fully offline using [llamafile](https://github.com/Mozilla-Ocho/llamafile)
for chat and [whisperfile](https://github.com/Mozilla-Ocho/llamafile#whisperfile) for
speech-to-text. Binaries live in a gitignored `.local/` directory.

### Setup

```bash
./scripts/fetch-local-models.sh   # downloads llamafile + whisperfile into .local/
./scripts/serve-local.sh          # starts the server on :8080 (override with PORT=...)
```

Then point the app at the local endpoint (defaults already do this):

```bash
MODEL_BASE_URL=http://localhost:8080/v1 MODEL_API_KEY=local-no-key npm start
```

To switch to a hosted backend, override the env vars:

```bash
MODEL_BASE_URL=https://api.example.com/v1 MODEL_API_KEY=sk-... npm start
```

### Transcription

```bash
./scripts/transcribe.sh path/to/audio.wav   # writes path/to/audio.txt
```

### Windows notes

- **Rename the `.exe`.** On Windows, downloaded binaries arrive as
  `Qwen3.5-0.8B-Q8_0.llamafile.exe` and `whisperfile-tiny.en.exe`. Rename them to
  drop the `.exe` suffix (or update the scripts to match) so the launcher treats
  them as llamafiles rather than plain executables.
- **4 GB executable limit.** Windows cannot execute a single `.exe` larger than
  4 GB. The Qwen3.5 0.8B Q8_0 build is well under this limit, but if you swap in
  a larger model, either pick a smaller quantization or use the
  `llamafile` + separate `.gguf` split so the executable stays under 4 GB.

### Smoke test

```bash
./scripts/serve-local.sh &          # start the server
npm test -- tests/smoke.local.test.ts
```

The test waits up to 60s for the server to respond before running.
````

Source

English

Tools in this stack