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
}
}
接下來進入正題。
Jev 的特徵如下:
依照きしださん的方法,是用以下限制來實現:
看到這篇真的嚇到我了
https://x.com/kis/status/2101356104118944229?s=20
太強了,聰明到誇張。
我們一步一步來。
例如:
提示詞
請隨機生成一個三位數字!只回答數字。
正常送出後會得到像這樣的結果:
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 秒。)
利用 logprobs 和 top_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 的背後,其實 4 和 1 也都是候選之一。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。把這些不需要的值排除後再做正規化。
exp 轉成機率最後就會變成這樣:
候選 機率 正規化後
B 0.8598889753956429 85.99%
C 0.1400531494178239 14.01%
A 0.0000543732500393 0.01%
既然需要的值都算出來了,那就用和 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 裡有 choice、score、noul 這 3 種 type,但本質上做的事情都差不多。
到這裡,山寨版 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
}
]
原版 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.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