古いEmacs (26.3) を最新のローカルLLMサーバーと連携させる最短経路

ozy's labo.


古いEmacs (26.3) を最新のローカルLLMサーバーと連携させる最短経路

私は,ことサーバー運用に関しては保守的な人間です.
今回,Xubuntu 20.04環境のEmacs 26.3という「枯れた」環境から,常時稼働している llama.cppLM Studio のAPIを叩いて,執筆やコーディングを爆速にする仕組みを構築しました.

モダンなパッケージ(=gptel= 等)が依存関係で動かない環境でも,標準機能と curl だけで動作する「枯れた実装」の決定版です.

1. サーバー側:モデル選択・起動自動化スクリプト

サーバー側では,モデルファイルを自動スキャンし,ホスト名(CPU/GPU環境)に応じて適切なバイナリとコンテキスト長を選択するスクリプトを運用します.
--host 0.0.0.0 を指定することで,外部のEmacsからの接続を許可するのがポイントです.

#! /bin/bash

# --- LLM Config ---
LM_MODEL_DIR='/home/yourname/LLM/llama_models'

#サーバー名を見て,環境を切り替える
case $(hostname) in
    gate)       LM_HOME_DIR="/home/yourname/LLM/llama.cpp/build/bin"
		LM_NGL=0 # CPU
		;;
    powerfull)  LM_HOME_DIR="/home/yourname/LLM/llama.cpp/build-cuda/bin"
		LM_NGL=99 # GPU
		;;
esac

LM_N=1024 # 打ち止め
LM_TEMP=0.1 # 正確さ

function llama-run() {
    # 1. モデルファイルを自動スキャン (.ggufのみ)
    local FILES=($(ls "$LM_MODEL_DIR"/*.gguf 2>/dev/null | xargs -n 1 basename))

    if [ ${#FILES[@]} -eq 0 ]; then
	echo "Error: No .gguf files found in $LM_MODEL_DIR"
	return 1
    fi

    # 2. メニュー表示
    echo "--- Found Models in $LM_MODEL_DIR ---"
    for i in "${!FILES[@]}"; do
	echo "$((i+1))) ${FILES[$i]}"
    done
    read -p "Select number: " MODEL_CH

    # 3. 選択したモデルの設定
    local SELECTED_FILE="${FILES[$((MODEL_CH-1))]}"
    if [ -z "$SELECTED_FILE" ]; then echo "Invalid selection"; return 1; fi

    LM_MODEL="$SELECTED_FILE"

    # 4. VRAM容量に配慮した動的コンテキスト長設定
    if [[ "$LM_MODEL" =~ [7-9][Bb] ]]; then
	LM_CTX=1024
	echo "Notice: Large model detected. Setting CTX to $LM_CTX for VRAM safety."
    else
	LM_CTX=2048
    fi

    echo "Launching: $LM_MODEL (CTX: $LM_CTX)"
    read -p "Mode? [1: CLI (default), 2: Server]: " MODE_CH

    if [ "$MODE_CH" = "2" ]; then
	# ポート8765でAPIサーバーを起動
	"$LM_HOME_DIR/llama-server" -m "$LM_MODEL_DIR/$LM_MODEL" --host 0.0.0.0 --port 8765 -ngl $LM_NGL -c $LM_CTX
    else
	"$LM_HOME_DIR/llama-cli" -m "$LM_MODEL_DIR/$LM_MODEL" -c $LM_CTX -n $LM_N --temp $LM_TEMP -ngl $LM_NGL
    fi
}

llama-run

2. Emacs側:API連携設定 (init.el)

Emacs 26.3の動的スコープ環境でも,非同期でLLMの出力を受け取るための実装です.
.emacs などに保存して下さい.
文章(プロンプト)をリージョン選択した状態で M-[RET] でLLMに送信します.

(require 'json)

(defun my-llama-complete ()
  "LLMサーバー設定を一括管理し,選択範囲または直前のテキストを補完する."
  (interactive)
  (let* (;; === [設定項目] ここを変更してください ===
	 (server-ip   "127.0.0.1")       ; サーバーのIPアドレス
	 (server-port "8765")            ; サーバーのポート番号 (起動スクリプトと合わせる)
	 (max-gen     2000)               ; AIが返してくる最大文字数 (max_tokens)
	 (ctx-size    2000)              ; AIに送る過去の文脈文字数 (prompt length)
	 (timeout     300)                ; 応答を待つ秒数 (タイムアウト)
	 ;; ==========================================

	 ;; 接続先URLの構築
	 (server-url (format "http://%s:%s/v1/completions" server-ip server-port))
	 ;; 選択範囲があれば優先し,なければカーソル直前から指定文字数分を取得
	 (prompt (if (use-region-p)
		     (buffer-substring-no-properties (region-beginning) (region-end))
		   (buffer-substring-no-properties (max (point-min) (- (point) ctx-size)) (point))))
	 ;; 送信用JSONデータの構築
	 (json-data (json-encode `((prompt . ,prompt) 
				   (max_tokens . ,max-gen) 
				   (stream . :json-false))))
	 (target-buffer (current-buffer))
	 (output-buffer " *llama-temp*"))

    (message "Llama 推論中... (最大 %d トークン)" max-gen)
    (when (get-buffer output-buffer) (kill-buffer output-buffer))

    ;; 非同期プロセス(curl)の実行
    (let ((proc (start-process "llama-curl" output-buffer "curl"
			       "--max-time" (number-to-string timeout)
			       "-s" "-X" "POST"
			       "-H" "Content-Type: application/json"
			       "-d" json-data
			       server-url)))

      ;; 実行時のバッファ情報をプロセスに紐付け
      (process-put proc :target-buffer target-buffer)

      ;; プロセス終了時のコールバック
      (set-process-sentinel proc
	(lambda (p event)
	  (when (string-match-p "finished" event)
	    (let ((response (with-current-buffer (process-buffer p) (buffer-string)))
		  (dest-buffer (process-get p :target-buffer)))
	      (with-current-buffer dest-buffer
		(condition-case err
		    (let* ((json-object-type 'alist)
			   (decoded (json-read-from-string response))
			   (choices (cdr (assoc 'choices decoded)))
			   (first-choice (and choices (> (length choices) 0) (elt choices 0)))
			   (content (and first-choice (cdr (assoc 'text first-choice)))))
		      (if content
			  (progn 
			    (goto-char (point-max)) 
			    (insert content) 
			    (message "補完完了 (残りコンテキストに注意)"))
			(insert (format "\n[抽出失敗]: %s" response))))
		  (error (insert (format "\n[JSONエラー]: %S\n[生データ]: %s" err response))))))
	    (when (get-buffer (process-buffer p)) (kill-buffer (process-buffer p)))))))))

;; キーバインド
;; Alt + Enter に割り当てる(直感的)
(global-set-key (kbd "M-RET") 'my-llama-complete)

3. 工夫した点とハマりどころ

  • ポート番号の一致: 起動スクリプトで 8765 を指定した場合,Emacs側の server-url も合わせる必要があります.
  • 変数のロスト: 非同期 sentinel 内で変数が消失する問題に対し,=process-put= を使ってバッファ情報を保持.
  • OpenAI互換API: LM Studio 等との親和性を考え,階層の深い /v1/completions 形式をパース.

これで,手元のレガシーな Emacs が,最新の AI アシスタントを搭載した強力なエディタに進化しました.

…と,単純な用途ならこれで行けると思ったのですが,やっぱり使い辛いので根本的に設計変更したのが以下です.

Ver2 開発方針

  • 会話履歴なし(単発処理)
  • 軽量・高速
  • ローカル小型LLM前提
  • streaming対応
  • 最小キーバインド設計
  • 実用的なテキスト整形特化

最終的に「ローカルLLM用テキスト整形エンジン」として完成しました.


前提条件

  • 小型ローカルLLM(7B級・量子化モデル想定)
  • 推論能力は限定的
  • 長文生成や複雑な思考処理は不安定

そのため,以下を重視しました.

  1. 重い推論を避けること
  2. 出力は入力以下の長さに制限すること
  3. 明確な制約付きプロンプトを用いること
  4. ワンキー操作を基本とすること


設計思想

LLMの用途を整理すると,主に次の6種類に分けられます.

  1. 変換(Transform)
  2. 圧縮(Compress)
  3. 展開(Expand)
  4. 批評(Critique)
  5. 生成(Generate)
  6. 抽出(Extract)

ローカル小型モデルでは,

  • 変換
  • 圧縮
  • 抽出

に特化するのが最適だと判断しました.


UI設計

起動キー

M-RET を採用しました.

リージョン選択必須

バッファ全体ではなく,明示的に選択した範囲のみを処理対象としています.

メニュー方式

ミニバッファにワンキー選択メニューを表示します.

[local-model] [p] send [j] 日本語 [e] English [s] 要約 ...


機能セット(軽量最適化版)

最終的に以下の構成に落ち着きました.

キー 機能 特徴
p そのまま送信 基本動作
j 日本語翻訳 単純変換
e 英語翻訳 単純変換
s 要約(5行以内) 制限付き圧縮
f 校正 書き換えのみ
k 箇条書き 構造変換
w キーワード抽出 抽出処理
d デバッグ 原因+修正例(5行以内)

重い推論系機能は意図的に排除しました.


Streaming実装

curl と make-process を利用しています.

  • -N オプションでストリーミング維持
  • Accept: text/event-stream を指定
  • JSON chunk を filter で逐次パース
  • content をリアルタイムで追記

終了時には usage を取得し,トークン数を表示します.

Done (prompt:123 / completion:456 / total:579 tokens)


実行中制御

実行中のみ ESC を有効化しています.

  • overriding-terminal-local-map を使用
  • ESC 2回で強制kill
  • 通常時のESC挙動は保持

これにより,安全で直感的な停止操作を実現しました.


プロンプト最適化

ローカルLLMでは制約の明確化が重要です.

要約

「5行以内・各行1文」

キーワード

「10個以内・説明不要」

デバッグ

「原因説明+修正例・5行以内」

制約を強くすることで,

  • 出力の安定
  • トークン削減
  • 推論の浅化
  • 速度向上

を実現しています.


技術的課題と解決

サーバー未到達問題

–data-binary @- と make-process の相性問題が原因でした.
-d で直接JSONを渡し,-X POST を明示することで解決しました.

string-trim 未定義

(require 'subr-x) を追加しました.

ESC制御

global-set-key ではなく,
overriding-terminal-local-map を採用しました.


実コード

以下が最終完成版コードです.

(require 'json)
(require 'subr-x)

(defvar my-llama-endpoint "http://127.0.0.1:8080/v1/chat/completions")
(defvar my-llama-model "local-model")
(defvar my-llama-buffer "*llama-stream*")

(defvar my-llama-process nil)
(defvar my-llama-usage nil)
(defvar my-llama-esc-count 0)

(defun my-llama-build-prompt (mode text)
  (pcase mode
    (?p text)
    (?j (concat "次の文章を自然な日本語に翻訳してください:\n\n" text))
    (?e (concat "Translate the following text into natural English:\n\n" text))
    (?s (concat "次の文章を5行以内で簡潔に要約してください.各行は1文のみ.\n\n" text))
    (?f (concat "次の文章を校正してください.誤字脱字や不自然な表現のみ修正してください.\n\n" text))
    (?k (concat "次の文章の内容を箇条書きで整理してください.最大7項目.\n\n" text))
    (?w (concat "次の文章から重要なキーワードを10個以内で列挙してください.説明は不要です.\n\n" text))
    (?d (concat "次のコードまたはエラーメッセージの原因を簡潔に説明し,修正例を示してください.説明は5行以内.\n\n" text))))

(defun my-llama-esc ()
  (interactive)
  (setq my-llama-esc-count (1+ my-llama-esc-count))
  (when (>= my-llama-esc-count 2)
    (setq my-llama-esc-count 0)
    (when (process-live-p my-llama-process)
      (kill-process my-llama-process)
      (message "llama killed"))))

(defvar my-llama-running-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "ESC") #'my-llama-esc)
    map))

(defun my-llama-filter (_ chunk)
  (dolist (line (split-string chunk "\n"))
    (when (string-prefix-p "data: " line)
      (let ((payload (string-trim (substring line 6))))
	(unless (string= payload "[DONE]")
	  (condition-case nil
	      (let* ((json-object-type 'alist)
		     (json-array-type 'vector)
		     (json-key-type 'symbol)
		     (obj (json-read-from-string payload))
		     (choice (aref (alist-get 'choices obj) 0))
		     (delta (alist-get 'delta choice))
		     (content (alist-get 'content delta))
		     (usage (alist-get 'usage obj)))
		(when content
		  (with-current-buffer my-llama-buffer
		    (goto-char (point-max))
		    (insert content)))
		(when usage
		  (setq my-llama-usage usage)))
	    (error nil)))))))

(defun my-llama-sentinel (proc _)
  (when (memq (process-status proc) '(exit signal))
    (setq overriding-terminal-local-map nil)
    (setq my-llama-process nil)
    (setq my-llama-esc-count 0)
    (if my-llama-usage
	(message "Done (prompt:%s / completion:%s / total:%s tokens)"
		 (alist-get 'prompt_tokens my-llama-usage)
		 (alist-get 'completion_tokens my-llama-usage)
		 (alist-get 'total_tokens my-llama-usage))
      (message "Done"))))

(defun my-llama-execute-stream (mode start end)
  (when (process-live-p my-llama-process)
    (error "llama already running"))
  (let* ((prompt (my-llama-build-prompt
		  mode
		  (buffer-substring-no-properties start end)))
	 (json-data
	  (json-encode
	   `(("model" . ,my-llama-model)
	     ("stream" . t)
	     ("messages" .
	      [ (("role" . "user")
		 ("content" . ,prompt)) ])))))
    (setq my-llama-usage nil)
    (with-current-buffer (get-buffer-create my-llama-buffer)
      (erase-buffer)
      (display-buffer (current-buffer)))
    (setq overriding-terminal-local-map my-llama-running-map)
    (message "[%s] streaming... (ESC x2 to kill)" my-llama-model)
    (setq my-llama-process
	  (make-process
	   :name "llama-stream"
	   :buffer nil
	   :command
	   (list "curl"
		 "-s" "-N"
		 "-X" "POST"
		 "-H" "Content-Type: application/json"
		 "-H" "Accept: text/event-stream"
		 "-d" json-data
		 my-llama-endpoint)
	   :filter #'my-llama-filter
	   :sentinel #'my-llama-sentinel))))

(defun my-llama-menu (start end)
  (interactive "r")
  (unless (use-region-p)
    (error "Region required"))
  (let ((key
	 (read-key
	  (format "[%s] [p] send [j] 日本語 [e] English [s] 要約 [f] 校正 [k] 箇条書き [w] キーワード [d] デバッグ: "
		  my-llama-model))))
    (if (member key '(?p ?j ?e ?s ?f ?k ?w ?d))
	(my-llama-execute-stream key start end)
      (message "Canceled"))))

(global-set-key (kbd "M-<return>") #'my-llama-menu)


まとめ

ローカルLLMは万能ではありませんが,

  • 変換
  • 圧縮
  • 抽出

に用途を限定すれば,非常に強力な補助ツールになります.

本実装は,「小型モデルでも実用になる設計」を目指した一例です.

Emacs + ローカルLLMは,
チャットアプリではなく
“思考補助インフラ” になり得ると考えています.


Date: 2026-02-28

Author: ozyukiwo

Created: 2026-03-11 水 08:08

Emacs 26.3 (Org mode 9.1.9)

Validate