2026年3月2日月曜日

ローカル LLM でコード補完する Vim プラグインを作った

最近、ローカル LLM を使ってコード補完する Vim プラグインを作りました。以下はその概要と使用方法です。

前提

  • OS: Windows 11 Pro 25H2 ビルド 26200.7922
  • GPU: NVIDIA GeForce RTX 4070 Ti SUPER

Ollama のインストール

Download Ollama on Windows の手順通りにインストールします。

irm https://ollama.com/install.ps1 | iex

Qwen2.5-14B-Instruct のインストール

Ollama のライブラリに Qwen2.5-14B-Instruct があるので、以下のコマンドでインストール・実行します。

ollama run qwen2.5:14b-instruct

なんとこれだけ。

動作確認

実行で来たら対話を試みましょう。

>>> あなたが動いているプログラムと、自身のモデルを教えてください。
私はアリババクラウドによって作成された大規模な言語モデルです。私の名前はQwenで、ユーザーに多様な情報を提供し、さまざ
まな質問やタスクに対するサポートを提供することを目指しています。ただし、具体的なソフトウェアの実装詳細や内部構造につ
いては開示されていません。ユーザーの皆さまに対しては、幅広い情報提供とタスク支援を行うことを目的としています。

はい、動いていそうです。

run している間は API が立ち上がっているので、Vim プラグインからもアクセスできます。 そんなわけで、ローカル LLM を使ってコード補完する Vim プラグインを作ることにしました。

そうしてできたのが mikoto2000/ollama-codeassist.vim: ローカル LLM を用いたコード補完プラグイン です。 Vim でコード補完する際に、ローカル LLM を呼び出して補完候補を生成します。

mikoto2000/ollama-codeassist.vim

このプラグインは、Vim でコード補完する際に、ローカル LLM を呼び出して補完候補を生成します。 補完候補は、現在のカーソル行の前後のコードをコンテキストとして LLM に渡し、LLM が生成したコードを補完候補として挿入します。

基本的には、Vim meets Local LLM: Edit Text beyond the Speed of Thought - YouTube の前半部分を基に作成しました。

後半のバーチャルテキスト的なところは未実装です。

動作としてはこんな感じ。

とりあえず何らかのそれっぽいコードが挿入されているのがわかるかと思います。

主処理は 100 行にも満たない程度なので、どかっと現状のコードを張り付けておきます。

vim9script

var host = get(g:, 'ollama_codeassist_host', 'localhost')
var port = get(g:, 'ollama_codeassist_port', 11434)
var path = get(g:, 'ollama_codeassist_path', '/api/generate')
var model = get(g:, 'ollama_codeassist_model', 'qwen2.5-coder:14b')
# qwen2.5:14b-instruct
# codellama:13b

var endpoint_url = $"http://{host}:{port}{path}"

const AsyncHTTP = vital#ollamacodeassist#import("Web.AsyncHTTP")

const data_template = {
  "model": model,
  "prompt": null,
  "suffix": null,
  "stream": false,
  "options": {
    "stop": ["<|FIM_START|>", "<|FIM_STOP|>", "<|im_start|>", "<|im_end|>"],
  }
}
var data = copy(data_template)
var line = 0
var language = 'unknown'

def UserCb(response: any)
  if response.status == 200
    # コンテキストの行に、レスポンスの内容を挿入する
    var s = json_decode(response.content).response

    # もし \n が文字として入ってくる場合も吸収(保険)
    s = substitute(s, '\\n', "\n", 'g')

    # もし \u0000 が文字として入ってくる場合も吸収(保険)
    s = substitute(s, '\\u0000', "\%x00", 'g')

    # NUL を改行に変換
    s = substitute(s, "\%x00", "\n", 'g')

    # 改行で行分割して挿入
    var lines = split(s, '\r\?\n', 1)
    setline(line, lines[0])
    append(line, lines[1 : -1])
  else
    #echomsg response.status
  endif
enddef

# 現在のバッファからコンテキストを作成する関数
def CreateCurrentBufferContext()
  # 現在の行番号を取得
  line = line('.')

  # 現在のバッファから言語を推測
  if &filetype != ''
    language = &filetype
  endif

  # バッファ全体の内容を取得
  var buffer_prefix = $"// language: {language}\n" .. $"// Please only codes, and not output codeblock text.\n// Don't add closing brackets if they already exist in suffix.\n" .. join(getline(1, '.'), '\n')
  var buffer_suffix = '<|FIM_STOP|>' .. join(getline('.', '$'), '\n')

  data.prompt = buffer_prefix
  data.suffix = buffer_suffix
enddef

# コンテキストを基に、コード補完のリクエストを送る関数
def RequestInner()
  AsyncHTTP.request({
        \ 'method': 'POST',
        \ 'url': endpoint_url,
        \ 'data': json_encode(data),
        \ 'userCallback': function('UserCb'),
        \ })
enddef

export def Request()
  CreateCurrentBufferContext()
  RequestInner()
enddef

API リクエストは Vital.Web.AsyncHTTP に丸投げし、Ollama の API にリクエストを送っています。

ちょっと非同期リクエストを生かせない構造になっているのが課題ですが、まあとりあえず動いているのでいいかなと思っています。

以上。作ったモノの報告でした。

参考資料

0 件のコメント:

コメントを投稿