nvimでローカルLLMと連携する

3/13追記: avante.nvimはollamaを1st class supportしたのでこの記事を読む必要はありません。

TL;DR avante.nvimからollamaに繋ぐときは、以下のIssue/PullReqに貼られているコードを使え

github.com

github.com


昨年の終わり頃にGPUをそこそこいいやつに買い替えたので1、今年に入ってからしばらくローカルLLM環境と格闘していた。LLM自体は普通にollamaを使って動かしている。 いくつか試しては微妙……となるのを繰り返していたが、最終的にavante.nvimに落ち着いた(後述するが、ここもあまり安住の地ではなさそうな雰囲気は若干ある)。

もともと、コード補完にはnvim-cmpを使っている。これの紹介は不要だろう。

github.com

最初、ローカルLLMを補完に使ってみようと思ってしばらくの間minuet-aiを使っていた。

github.com

これでqwen2.5-coder (14B, q4_M_K)2を使うと、結構補完の精度はよかった。のだが、補完候補が現れるまで1~2秒程度カーソルを動かさずに待たないといけない。カーソルを動かしたり文字を入力すると補完のコンテキストが変わってしまうからだ。 この1~2秒が結構長く感じる。しかしファンノイズなどでGPUの使用率が上がっているのを感じると、一応待ったほうがいいか?という気持ちになってしまう。 それで1行しか出なかったりしたら(補完結果が正しかったとしても)結構時間を無駄にしたような気持ちになって、若干ストレスがあった。 というか、1~2秒と出てきたものを確認して補完キーを押す時間を考えると、1行分のコードを自分で書くほうが速い可能性すらある。 もっとドバーッと出してほしいのだが……。とはいえ、文脈からは予測のつけようがない場合が多いので、長く出すにはもっと色々コメントなどを書かないと駄目なのだろう(適切な変数名があれば前後のコードから適切なコードを生やしてくれるが、その次に何をするかはそもそも予測しづらいようだ。そもそもそこは可能性が色々あるので当前だが……)。

というわけで、精度には結構満足するけどレイテンシがなあ……と思っていたので、openhandsのような自動で動いてくれるやつのほうがいいのだろうか、と思って試そうとしていたのだが、うまく行かなかった。 ローカルで動かしているモデルが小さい上に小さめに量子化しているせいでポンコツすぎるのか、それとも自分の設定が悪いのかはわからないが……。 検索して調べていても割とOpenHandsをローカルLLMで動かそうとして失敗しているブログとかは出てくるので、もともとローカルで動くサイズのLLMでやるようなものではないのかもしれない。

github.com

多額の料金を払ってでかいモデルを使った場合にどうなるかは知らない。金持ちではないので……。

とはいえ、今(表に出していないものを合わせると)一人で維持するには結構しんどい量の趣味プロジェクトを抱えている感はあるので、コードを書くのが簡単になるならできることはしたい。でも使った分だけ金を払うのは嫌(気がついたらすごい支払いになっていて卒倒しそうだから)。家のGPUでなんとかなるほうがいい。

実際、ローカルLLMによる補完でも精度は結構よかった。これが微妙だと感じるのは、待ち時間に比べて出てくる量が少ないからだ。 結果の精度は少なくとも短い出力ではそこそこいいので、一回で出力できる量が多くなれば、自分がキーボードを打つ速度よりもLLMが出すトークン数の方が多いはずなので、結果的に加速できるはずだ。 そしてあまり長いコードを出力してくれないのは、LLMには何をしたいと思っているのかがわからないからだ(これは、私がいる分野の(コードを公開する)人口が少ないからという可能性はある。人口が多いWeb系はもっと快適そうに見えて羨ましい)。 前後のコンテキストと変数名からわかる範囲のことは結構精度良く補完してくれている。 ということはやるべきことはわかる。LLMに何をしたいと思っているのかちゃんと伝えればよい。

というわけで、補完に頼るのではなくちゃんとプロンプトも書くか……と思い、avanteを試してみることにした。3

github.com

コードを範囲選択して、<Leader>aeでプロンプトを開き、「ここでは〇〇しているが、この△△を□□せよ」みたいなものを(モデル次第だが一応英語で)書いてCtrl-sすると、そのように編集してくれる。

らしいのだが……。最初、ollamaと連携してみると、動きはするもののあまりにも性能が悪い、というか、コードを読んでいないような挙動をしていた。 具体的には、C++のコードを渡して編集の指針を伝えたのにPythonのコードに変貌したりした。ポンコツか? と思ったものの、ほとんど同じモデルを補完に使った際には結構まともだったことを思い出し、これはプラグインの問題なのでは?と思ってIssueを見に行った。

avanteではollamaは第一級市民ではなく、OpenAI互換のAPIがあるんだからそれを使うとよいということになっている。実際最初私はこれを使っていた。

ただ、OpenAI互換のAPIではavanteの使用には向かない部分があるらしく、これは機能しないとIssueで報告されている。実際私の環境でも機能しなかった。なるほどね。 で、ollama APIをサポートするコードがPullReqに出されているのだが、OpenAI互換なんだからそれを使え、ということでrejectされていた。

github.com

github.com

こいつは動くのだろうか? と思って、試してみることに。このPullReqの内容をlazy.nvimの設定に書き写してそれを使うようにしてみる。 ollamaのAPIを叩く関数を定義して、avanteのオプションにその関数を渡すことで挙動を変えているようだ。

設定(長い)

-- Ollama API Documentation https://github.com/ollama/ollama/blob/main/docs/api.md#generate-a-completion
local role_map = {
    user = "user",
    assistant = "assistant",
    system = "system",
    tool = "tool",
}

---@param opts AvantePromptOptions
local parse_messages = function(self, opts)
    local messages = {}
    local has_images = opts.image_paths and #opts.image_paths > 0
    -- Ensure opts.messages is always a table
    local msg_list = opts.messages or {}
    -- Convert Avante messages to Ollama format
    for _, msg in ipairs(msg_list) do
        local role = role_map[msg.role] or "assistant"
        local content = msg.content or "" -- Default content to empty string
        -- Handle multimodal content if images are present
        -- *Experimental* not tested
        if has_images and role == "user" then
            local message_content = {
                role = role,
                content = content,
                images = {},
            }
            for _, image_path in ipairs(opts.image_paths) do
                local base64_content = vim.fn.system(string.format("base64 -w 0 %s", image_path)):gsub("\n", "")
                table.insert(message_content.images, "data:image/png;base64," .. base64_content)
            end
            table.insert(messages, message_content)
        else
            table.insert(messages, {
                role = role,
                content = content,
            })
        end
    end
    return messages
end

local function parse_curl_args(self, code_opts)
    -- Create the messages array starting with the system message
    local messages = {
        { role = "system", content = code_opts.system_prompt },
    }
    -- Extend messages with the parsed conversation messages
    vim.list_extend(messages, self:parse_messages(code_opts))
    -- Construct options separately for clarity
    local options = {
        num_ctx = (self.options and self.options.num_ctx) or 8192,
        temperature = code_opts.temperature or (self.options and self.options.temperature) or 0,
    }
    -- Check if tools table is empty
    local tools = (code_opts.tools and next(code_opts.tools)) and code_opts.tools or nil
    -- Return the final request table
    return {
        url = self.endpoint .. "/api/chat",
        headers = {
            Accept = "application/json",
            ["Content-Type"] = "application/json",
        },
        body = {
            model = self.model,
            messages = messages,
            options = options,
            -- tools = tools, -- Optional tool support
            stream = true, -- Keep streaming enabled
        },
    }
end

local function parse_stream_data(data, handler_opts)
    local json_data = vim.fn.json_decode(data)
    if json_data then
        if json_data.done then
            handler_opts.on_stop({ reason = json_data.done_reason or "stop" })
            return
        end
        if json_data.message then
            local content = json_data.message.content
            if content and content ~= "" then
                handler_opts.on_chunk(content)
            end
        end
        -- Handle tool calls if present
        if json_data.tool_calls then
            for _, tool in ipairs(json_data.tool_calls) do
                handler_opts.on_tool(tool)
            end
        end
    end
end

---@type AvanteProvider
local ollama = {
    api_key_name = "",
    endpoint = "http://localhost:11434",
    model = "qwen2.5-coder:14b",
    -- model = "deepseek-r1:14b",
    parse_messages = parse_messages,
    parse_curl_args = parse_curl_args,
    parse_stream_data = parse_stream_data,
}

return {
    {
        "yetone/avante.nvim",
        event = "VeryLazy",
        lazy = false,
        version = false,

        opts = {
            auto_suggestions_provider = "ollama",
            debug = true,
            provider = "ollama",
            vendors = {
                ---@type AvanteProvider
                ollama = ollama,
            },
        },

        -- if you want to build from source then do `make BUILD_FROM_SOURCE=true`
        build = "make",
        -- build = "powershell -ExecutionPolicy Bypass -File Build.ps1 -BuildFromSource false" -- for windows
        dependencies = {
            "nvim-treesitter/nvim-treesitter",
            "stevearc/dressing.nvim",
            "nvim-lua/plenary.nvim",
            "MunifTanjim/nui.nvim",
            --- The below dependencies are optional,
            "nvim-telescope/telescope.nvim", -- for file_selector provider telescope
            "hrsh7th/nvim-cmp", -- autocompletion for avante commands and mentions
            "nvim-tree/nvim-web-devicons", -- or echasnovski/mini.icons
        },
    },
}

こうするとまともに動いた。

まだ、たまにスタイルを微妙に変えてしまったりするというような若干のイラつきポイントはあるが、その値は普通のformatterでなんとかできる範囲なので、問題ない。 何も見せないまま「これをやって」というより、かなり似ているが細部が違うコードの細部だけ渡して書かせるとか、そういう感じで使うとかなりよく働いてくれる印象がある。

ところで上で紹介して私の環境に適用したPullReqは結局公式には取り込まれなかったので、いつまで使えるかはわからないが、今のLLMへの熱狂を考えるとavante公式がアップデートしてこれが動かなくなったら誰かがまたIssueに回避策を書くだろうし、そもそもavanteよりもollamaフレンドリーなプラグインが別に出て乗り換えることになるかもしれない。 とりあえず今はこのままで。斧を研ぐのもいいが結構切れるようになったら木も切らないといけない。

ところで、それなりのGPUを手に入れたのが嬉しくてローカルLLMでここに書いていることも書いていないことも色々やっていたら、電気代が月あたりで2万円を超えた。気づいたら利用料がすごい支払いになっていて卒倒するのが嫌で家のマシンで推論させていたのに、気づいたら電気代がすごい支払いになって卒倒した。皆さんも気をつけてください。


  1. 当時は(うーん5000番台発表直前に買うなんて馬鹿だよな〜でも何ヶ月も待ちたくないし、思い立ったときにやったほうがいいだろ!)と思って買ったのだが、蓋を空けてみると5000番台は手に入りそうにないし4000番台も終売してて結果的にファインプレーになった感がある(また数ヶ月したら落ち着いて5000番台も手に入るようになるかもだが)。全部モンハンが悪い。ところでモンハンのオープンベータをやってみたら、なんか何もしてないのに知らん人が参加してきてびっくりした。PSPで部活のあとに一狩り行ってた頃以降モンハンやってなかったので知らなかったんだけど、最近はそんな感じなんすか。時間泥棒になることが確定してるから製品版は買わないと思うけど……(内容が悪いというわけではない)。
  2. 実はもうちょっとでかいモデルでも行けそうではあるが、日和って小さめにしている。
  3. なんだかんだ全部をAIに任せられるような状況ではまだない(少なくとも私が書いているようなコードを使う分野では)ので、まだしばらくコードを人間が読む機会がなくなるとは思っていない(というか、正確性が要求される分野では形式検証が十分発達するまでは人間が読むのではなかろうか、書く方はともかく)。ので、コメントにプロンプトを全部書いて行数を爆発させる気はまだない。