Jev 正在大流行呢。

一邊覺得很厲害,一邊想說本地端也能做到嗎,結果已經有人試過了!
好快!!

因為原始碼已經公開,所以照著直接用就好,但難得有這個機會,我也順手研究了一下它到底是怎麼運作的。

這不是對きしださん公開的原始碼本身做解說,而是我讓 Fable 手把手教我之後得到的結果。實作細節也可能不完全一致。

環境

這次在 Mac 上進行。使用 llama.cpp。

brew install llama.cpp

先確認有沒有安裝成功。

llama-server --version
version: 0.4.1 (build 10964, commit b29c606e2)
built with AppleClang 21.0.0.21000334 for Darwin arm64

取得模型

這次使用的模型是 unsloth/Qwen3-VL-4B-Instruct-GGUF:Q8_0。(之後會說明為什麼選它)
下載模型。

llama download -hf unsloth/Qwen3-VL-4B-Instruct-GGUF:Q8_0

用下載好的模型啟動伺服器。

llama-server -hf unsloth/Qwen3-VL-4B-Instruct-GGUF:Q8_0 --port 8081 -c 8192 --jinja

-c 參數用來指定 context 長度。(如果不指定,會使用模型預設值;Qwen3-VL-4B 的預設是 262144)
--jinja 則是用來指定聊天模板。

另外開一個終端機來呼叫看看。(這裡有用 jq 格式化。)

curl -s http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"こんにちは"}]}' \
  | jq .
{
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "こんにちは! 😊  \n何かお手伝いできることがあ れば、いつでもお気軽にお尋ねくださいね。  \nどうぞ、お気軽にご相談ください!"
      }
    }
  ],
  "created": 1789910380,
  "model": "unsloth/Qwen3-VL-4B-Instruct-GGUF:Q8_0",
  "system_fingerprint": "b10964-b29c606e2",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 37,
    "prompt_tokens": 9,
    "total_tokens": 46,
    "prompt_tokens_details": {
      "cached_tokens": 8
    }
  },
  "id": "chatcmpl-zSVvLz9YuZHR9SfhiGpR0z995vqgj9ck",
  "timings": {
    "cache_n": 8,
    "prompt_n": 1,
    "prompt_ms": 45.207,
    "prompt_per_token_ms": 45.207,
    "prompt_per_second": 22.12046806910434,
    "predicted_n": 37,
    "predicted_ms": 1237.889,
    "predicted_per_token_ms": 34.38580555555555,
    "predicted_per_second": 29.081767428258917
  }
}

讓 Qwen3-VL-4B 變得像 Jev

接下來進入正題。

Jev 的特徵如下:

  • 不是生成 token,而是「一次推論就回傳各選項的機率分布」

依照きしださん的方法,是用以下限制來實現:

  • 只讓模型生成 1 個 token
  • 將選項對應到能用 1 個 token 表示的符號(例如 A~Z),並要求只用 1 個字元回答
  • 關閉思考 token
  • 取得第 1 個 token 各候選字元的機率,例如 A 是 0.xx、B 是 0.xx,全都拿出來
  • 再把這些機率在選項之間重新正規化後回傳

看到這篇真的嚇到我了

https://x.com/kis/status/2101356104118944229?s=20

太強了,聰明到誇張。

我們一步一步來。

將輸出限制為 1 token

例如:

提示詞

請隨機生成一個三位數字!只回答數字。

正常送出後會得到像這樣的結果:

742

把輸出限制為 1 token,指定 max_tokens 為 1。

curl -s http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"請隨機生成一個三位數字!只回答數字。"}],"max_tokens":1}' \
  | jq .

結果

{
  "choices": [
    {
      "finish_reason": "length",
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "7"
      }
    }
  ],
  "model": "unsloth/Qwen3-VL-4B-Instruct-GGUF:Q8_0",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 1,
    "prompt_tokens": 27,
    "total_tokens": 28
  },
  "timings": {
    "prompt_ms": 35.505,
    "predicted_n": 1,
    "predicted_ms": 0.001
  }
}

原本 742 的開頭 7 被回傳了。

而且這裡很重要:因為只生成 1 個 token,所以超級快

只生成 1 個 token,所以超級快!

很重要,所以講兩次。

雖然只是測了幾次,但從送出請求到回應大約只有 110ms。(如果讓它正常回答同樣的問題,它會開始長篇解釋理由,通常要 7~12 秒。)

輸出前 N 個 token 候選與機率

利用 logprobstop_logprobs 這兩個選項,就能取得 N 個 token 候選。

例如用剛剛同樣的提示詞,取前 5 名。top_logprobs 只有在 logprobs 啟用時才能使用。

curl -s http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"請隨機生成一個三位數字!只回答數字。"}],"max_tokens":1,"logprobs":true,"top_logprobs":5}' \
  | jq '[.choices[0].logprobs.content[0].top_logprobs[] | {token, logprob}]'

結果

[
  {
    "token": "7",
    "logprob": -0.09278639405965805
  },
  {
    "token": "4",
    "logprob": -2.517789363861084
  },
  {
    "token": "1",
    "logprob": -5.348824977874756
  },
  {
    "token": "5",
    "logprob": -6.274434566497803
  },
  {
    "token": "8",
    "logprob": -7.215909481048584
  }
]

可以看到,回傳 7 的背後,其實 41 也都是候選之一。logprob 是機率的對數,所以取 exp 之後就是機率。

curl -s http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"請隨機生成一個三位數字!只回答數字。"}],"max_tokens":1,"logprobs":true,"top_logprobs":5}' \
  | jq '[.choices[0].logprobs.content[0].top_logprobs[] | {token, prob: (.logprob | exp)}]'

候選 logprob 機率
7 -0.093 91.1%
4 -2.518 8.1%
1 -5.349 0.5%
5 -6.274 0.2%
8 -7.216 0.1%

明明說是隨機,結果第一位是 7 的機率竟然有 91%。先不管這個,總之先知道「可以取得下一個 token 的多個候選及其機率」就好。

透過提示詞提供選項

在提示詞中指定選項,以及每個選項的標籤。

例如像這樣:

以下這段文字出現時,最適合的氣溫是哪一個?請用一個英文字母回答。

今天即使穿短袖也覺得很舒適。

A: 18 度
B: 22 度
C: 26 度

將這個提示詞搭配前面的選項一起送出。

curl -s http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"以下這段文字出現時,最適合的氣溫是哪一個?請用一個英文字母回答。\n\n今天即使穿短袖也覺得很舒適。\n\nA: 18 度\nB: 22 度\nC: 26 度"}],"max_tokens":1,"logprobs":true,"top_logprobs":5}' \
  | jq '[.choices[0].logprobs.content[0].top_logprobs[] | {token, prob: (.logprob | exp)}]'

結果

[
  {
    "token": "B",
    "prob": 0.8598889753956429
  },
  {
    "token": "C",
    "prob": 0.14005314941782393
  },
  {
    "token": "A",
    "prob": 5.437325003931644e-05
  },
  {
    "token": "**",
    "prob": 3.282940730038489e-06
  },
  {
    "token": "D",
    "prob": 1.0997108153218875e-07
  }
]

將取得的值重新正規化

雖然只有 3 個選項,但這裡卻取了 5 個,所以第 4 名變成了 **,第 5 名變成了 D。把這些不需要的值排除後再做正規化。

  1. 先取 exp 轉成機率
  2. 只抽出可能的選項(這次是 A、B、C)
  3. 用這 3 個機率的總和去除,讓總和變成 1.0

最後就會變成這樣:

候選 機率 正規化後
B 0.8598889753956429 85.99%
C 0.1400531494178239 14.01%
A 0.0000543732500393 0.01%

以 Jev 格式回傳

既然需要的值都算出來了,那就用和 Jev 相同的格式回傳。

原版 Jev 有一個 POST /v1/systemone 的 endpoint,會接收這樣的 request。

請求

{
  "model": "jev-latest",
  "state": "今天即使穿短袖也覺得很舒適。",
  "questions": {
    "temperature": {
      "type": "choice",
      "instructions": "這段文字出現時,最適合的氣溫是哪一個?",
      "criteria": {
        "18度": null,
        "22度": null,
        "26度": null
      }
    }
  }
}

回應如下。

回應

{
  "model": "jev-latest",
  "answers": {
    "temperature": {
      "type": "choice",
      "choice": "22度",
      "probabilities": {
        "18度": 0.0001,
        "22度": 0.8599,
        "26度": 0.1401
      },
      "confidence": 0.6308
    }
  },
  "usage": {
    "input_tokens": 117,
    "output_tokens": 0
  }
}

這部分就隨便漂亮地做一下吧 w。confidence 大概也是算出來的。

Jev 的 API 裡有 choicescorenoul 這 3 種 type,但本質上做的事情都差不多。

Jev 尚未支援的多模態輸入也可以!

到這裡,山寨版 Jev API 就完成了。

原版 Jev 只支援文字輸入,但 Qwen3-VL-4B 也支援圖像輸入。
所以只要在提示詞中寫「圖片裡的是什麼?A: 狗、B: 貓、C: 大象」,就能用同樣的機制支援多模態!

太厲害了!!

jq -n --arg img "$(base64 -i cat.png | tr -d '\n')" '{
  messages: [{role:"user", content:[
    {type:"image_url", image_url:{url:("data:image/png;base64," + $img)}},
    {type:"text", text:"圖片裡的是什麼?請用一個英文字母回答。\nA: 狗\nB: 貓\nC: 象"}
  ]}],
  max_tokens:1, logprobs:true, top_logprobs:5
}' | curl -s http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" -d @- \
  | jq '[.choices[0].logprobs.content[0].top_logprobs[] | {token, prob: (.logprob | exp)}]'

結果

[
  {
    "token": "B",
    "prob": 1
  },
  {
    "token": "C",
    "prob": 5.0946682574252436e-08
  },
  {
    "token": "猫",
    "prob": 4.5441964347730576e-08
  },
  {
    "token": "B",
    "prob": 2.93381942924073e-08
  },
  {
    "token": "A",
    "prob": 1.6253244738724357e-09
  }
]

為什麼選 Qwen 3 而不是 Qwen 3.5

原版 Jev 的 API 可以同時送出多個問題,並一次回傳所有答案。這次這個仿 Jev 系統則是透過多次呼叫模型來一次產生所有回答。
(llama.cpp 也可以平行呼叫,但一旦平行化,快取反而失效變慢,所以這次沒有採用)

llama.cpp 預設似乎會啟用 prompt cache,所以當對同一張圖片或同一段文字重複提問時,快取能夠很好地發揮作用。

對同一張圖片問兩次試試看。第一次問「裡面有什麼?」第二次問「動物是什麼顏色?」。

第一次結果

{"prompt_tokens":213,"cached":0,"processed":213,"ms":611}

第二次結果(只改問題)

{"prompt_tokens":214,"cached":175,"processed":39,"ms":100}

第一次因為要首次處理圖片,所以大概要 0.6 秒;第二次因為圖片部分已經快取了,所以 0.1 秒就能回應。

為什麼選 Qwen 3 而不是 Qwen 3.5

不過,如果是 Qwen 3.5,似乎快取就不會生效。

這部分是 Claude 幫我解釋很多,但我還是沒完全懂 w。Claude 的說明如下!

一開始我用的是更新的 Qwen3.5-2B,它也支援圖片。

但前一章提到的快取完全沒有效果。
即使對同一張圖片只改問題重送,cached_tokens 也一直是 0。

實際測起來是這樣。注意第 2 次、第 3 次的快取欄位。

  Qwen3-VL-4B / 圖片   第 1 次 快取  0  第 2 次 快取174  第 3 次 快取174
  Qwen3.5-2B  / 圖片   第 1 次 快取  0  第 2 次 快取  0  第 3 次 快取  0

看伺服器日誌後,原因寫得很清楚。

  forcing full prompt re-processing due to lack of cache data
  (likely due to SWA or hybrid/recurrent memory)

意思是「因為沒有快取資料,所以強制重新處理整個 prompt」。

llama.cpp 的做法是,在已經算過的部分插入「書籤」,下次如果來了類似的 prompt,就能直接跳到那裡。

一般模型即使沒有書籤,也可以把中途算過的結果切掉,只保留共通部分。
但 Qwen3.5 是一種叫做「混合式」的新結構,沒辦法這樣切掉。只能回到保存下來的書籤位置。

而且書籤只會建立在前一次 prompt 的結尾。
當你改了問題,共通部分會在更前面結束,所以書籤派不上用場。
結果每次都得從零開始。

  checking checkpoint with [79, 79] against 56...
  → 書籤在 79 的位置,共通部分只到 56。不能用,所以得重來

如果只是文字,還算勉強可以接受;但一旦放進圖片,差距就很明顯了。
因為一張圖片就有 175 個 token,每次都得重新編碼一次。

Qwen3-VL 則是傳統架構,所以可以把包含圖片在內的前半段整個重用。
不是新的就一定比較快,這就是這個例子要說的事。

總結

之後 Jev 應該也會推出支援多模態的版本吧~好期待!


原文出處:https://qiita.com/moritalous/items/41c9402a5dd9d80fc7a9


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝64   💬1  
860
🥈
NewsData
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登