運作說明
一個問題在 agent 迴圈裡怎麼走
從你按下送出到看到回覆,中間發生的每一件事,照實際執行的順序走一遍。app 的側欄可以選 Gemma 4 E2B 或 Qwen3.5 0.8B,兩個模型跑的是同一個迴圈,不一樣的地方會並排說明。問題可以附上圖片、聲音或影片(第 7 段「圖片、聲音和影片:變成脈絡裡的 token」),打開「讓模型寫程式」時,模型還能在 Docker 沙箱裡執行 Python(第 16 段「環境:真的去執行工具」)。模型可以在這台電腦上用 transformers 跑,也可以交給一台 vLLM 伺服器(第 11 段「換一層跑模型:Backend 介面、本機 transformers 和 vLLM」)。每段都附上做這件事的程式碼;agent.py:98 這類標記是檔名和行號,滑過去會顯示那一行。行號是產生這一頁時從原始碼查出來的;每次推上 main,GitHub Actions 都會重新產生,程式改了也會跟著更新。
畫面
總覽:兩條 thread、一張事件列表
你送出一個問題就是一輪(一個 Run);一輪裡是一個迴圈,每一圈叫一個 step。
每個 step 做同一件事:harness 把整段對話 render 成一個字串(脈絡)→ 模型一次推論、一個 token 一個 token 生成 → harness 解析輸出。有工具呼叫,就驗證、(需要時問你)、在環境執行,把結果接回對話,進下一圈;沒有工具呼叫,就把回覆交給你,這一輪結束。
整個 app 有兩條 thread。Streamlit 的 script thread 負責畫面;agent 跑在另一條背景 thread(app/page.py:239)。兩邊只透過三樣東西溝通:
- agent 每走到一個階段,就把一個事件 append 到
events列表(harness/agent.py:95);畫面只負責把這張列表畫出來。 - 按「停止」時,畫面呼叫
Run.stop()(harness/agent.py:83)。 - 按「允許」或「拒絕」時,畫面呼叫
Run.decide()(harness/agent.py:88)。
專案分成兩塊。harness/ 跑一輪:迴圈、工具、模型,以及跑模型的那一層,它從不 import Streamlit。app/ 是你看到的東西,站在 harness 上面:開始一輪、把 harness 送出的事件畫出來、把你的點擊傳回去。根目錄的 streamlit_app.py 只負責啟動 app/page.py。harness/ 裡每個檔案的分工:
harness/agent.py:harness 的迴圈本體Run.run()(harness/agent.py:98)。harness/backends/backend.py:harness 和畫面對「跑模型的那一層」的全部要求,Backend(harness/backends/backend.py:63):render()、encode()、generate(),以及 token 事件裡哪些欄位一定有、哪些要 backend 知道才有。simple_backend.py的LocalLLM是其中一種,vllm_backend.py是另一種(第 11 段「換一層跑模型:Backend 介面、本機 transformers 和 vLLM」)。harness/backends/simple_backend.py:本機推論,自己寫的解碼迴圈generate()(harness/backends/simple_backend.py:283)和抽樣_sample();打開 MTP 時再加上一個草稿模型(第 10 段「MTP:草稿模型先猜,主模型一次驗證」)。harness/backends/vllm_backend.py:模型在 vLLM 伺服器上跑時用的Backend(harness/backends/vllm_backend.py:276),伺服器是VLLM_URL指的那台,或是畫面在 Docker 裡啟動的那台。prompt 照樣在這裡 render、切成 token,token id 送到 vLLM 伺服器,每個 token 帶著它的 log 機率和前 5 名候選回來;取樣時剃掉了哪些候選,留在伺服器上。harness/backends/vllm_docker.py:沒設VLLM_URL、側欄又選了 vLLM 時,照docker-compose/compose.yaml用 Docker Compose 啟動 vLLM 伺服器,等到它能回應(harness/backends/vllm_docker.py:170),在 GPU 上預設開 MTP 推測解碼;換回 transformers 時停掉它(第 11 段「換一層跑模型:Backend 介面、本機 transformers 和 vLLM」)。harness/families/:跟著模型走的東西。gemma4.py、qwen3_5.py各自負責讀懂自己模型的輸出,並說明它的特殊 token(第 2 段「模型家族:換模型只換這一層」)。harness/tools.py:每個工具的 schema(給模型看)和 Python 函式(給 harness 執行),全部列在TOOLS(harness/tools.py:336)。其中run_python在harness/sandbox/Dockerfile建的 Docker 容器裡跑模型寫的程式(第 16 段「環境:真的去執行工具」)。harness/media.py:問題附上的檔案:存進這段對話的資料夾、寫進使用者訊息,再解碼成模型的 processor 要的格式(第 7 段「圖片、聲音和影片:變成脈絡裡的 token」)。harness/i18n.py:頁面的兩種語言,繁體中文和英文;pick()照語言挑出其中一段文字(harness/i18n.py:14)。harness 也用它:模型讀到的錯誤訊息,用這一輪開始時頁面的語言寫。
app/ 裡:
app/page.py:版面、側欄設定、模型選單、背景 thread、執行中每 0.5 秒重畫一次。app/render.py:把事件列表折成 step(app/render.py:306),再畫成 HTML。app/guide.py:這一頁,從它引用的程式產生出來。
兩塊之外,models/ 放下載的模型,每個模型一個資料夾,git 不追蹤(第 3 段「模型資料夾:models/ 裡的每個檔案」);docker-compose/ 放畫面在 Docker 裡啟動 vLLM 用的設定(第 11 段「換一層跑模型:Backend 介面、本機 transformers 和 vLLM」)。
模型
模型家族:換模型只換這一層
harness、解碼迴圈和畫面都只寫一次;每個模型不一樣的地方,集中在一個 Family 物件裡。
兩個模型的迴圈完全一樣,不一樣的是它們寫字的格式:
- 回合:Gemma 用
<|turn>user…<turn|>;Qwen 用 ChatML 的<|im_start|>user…<|im_end|>。 - 工具呼叫:Gemma 寫
<|tool_call>call:名稱{key:<|"|>值<|"|>}<tool_call|>;Qwen 寫 XML:<tool_call>裡一個<function=名稱>,每個參數一段<parameter=名稱>,值一律是純文字。 - 工具結果:Gemma 的結果接在同一個 model turn 裡(
<|tool_response>…<tool_response|>);Qwen 的結果放進一則新的user訊息(<tool_response>…</tool_response>),再開一個新的 assistant turn。 - 停止 token:Gemma 回答完停在
<turn|>、要用工具時停在<|tool_response>,看停止 token 就知道是哪一種;Qwen 兩種都停在<|im_end|>,要看輸出裡有沒有工具呼叫才知道。 - 思考:Gemma 在 system turn 開頭放
<|think|>,推理寫在<|channel>thought到<channel|>之間;Qwen 由 chat template 在 assistant 訊息開頭先寫好<think>,推理寫到</think>。
這些差異全部寫進 Family,其他程式只問 family,沒有任何 if 是 Gemma 的分支:
processor_kwargs 裡給 processor 的影片設定;file_notes 和 drafter_file_notes 說明模型資料夾裡的每個檔案(第 3 段「模型資料夾:models/ 裡的每個檔案」)。71class Family: 72 key: str 73 name: str 74 model_types: tuple[str, ...] # config.json `model_type` values of this family 75 76 # Reading the model's output. 77 parse: Callable[..., dict] # (raw output, {tool name: parameters schema}, language of its errors) -> thinking, tool_calls, content 78 thought_prefix: str # a prompt that ends with this has the model start inside its thinking 79 channel_switch: dict[str, str] # tokens that switch what the model is writing: thought, tool_call, content 80 stop_tokens: dict[str, dict[str, str]] # tokens that end a generation -> language -> what stopping there means 81 82 # Writing the conversation back for the chat template. 83 reasoning_key: str # the assistant-message field the chat template reads earlier thinking from 84 text_with_calls: bool # whether text written before a tool call survives re-rendering the history 85 86 # Showing it on the page; the text is in both of the page's languages (i18n.py). 87 user_turn: str # where a user message starts in a prompt; the step card shows the newest one 88 special_groups: dict[str, list] # language -> (title, subtitle, ((token, name, what it is for), ...)) for each group of control tokens 89 thinking_help: dict[str, str] # what the thinking toggle does to the prompt 90 tools_note: dict[str, str] # how the tool schemas appear in the prompt 91 model_note: dict[str, str] = field(default_factory=dict) 92 sampling: dict | None = None # recommended temperature / top_p / top_k when a checkpoint ships none 93 byte_level: bool = False # the vocabulary spells bytes as characters, GPT-2 style (BYTE_OF); otherwise it is SentencePiece's 94 processor_kwargs: dict = field(default_factory=dict) # passed to the processor along with images, audio and video 95 file_notes: dict[str, FileNote] = field(default_factory=dict) # a file in the checkpoint's folder -> what it holds in this family 96 drafter_file_notes: dict[str, FileNote] = field(default_factory=dict) # the same for the folder of its MTP drafter (DRAFTERS) 97 98 def show_piece(self, piece: str) -> str: 99 """A vocabulary entry as the text it stands for, which is what a token's popover shows: ␣ for a space, 100 ↵ for a newline, and a byte that is only part of a character as <0xE5>, the way SentencePiece writes one.""" 101 if self.byte_level and all(c in BYTE_OF for c in piece): 102 # surrogateescape keeps each byte that is not whole UTF-8 as U+DC80–U+DCFF, so it can be written in hex. 103 text = bytes(BYTE_OF[c] for c in piece).decode("utf-8", errors="surrogateescape") 104 text = "".join(f"<0x{ord(c) - 0xDC00:02X}>" if "\udc80" <= c <= "\udcff" else c for c in text) 105 else: 106 text = piece.replace("▁", " ") # SentencePiece marks a leading space with ▁ 107 return text.replace(" ", "␣").replace("\n", "↵") 108 109 @cached_property 110 def special_tokens(self) -> dict[str, dict[str, tuple[str, str]]]: 111 """Control token -> language -> (name, what it is for).""" 112 out: dict[str, dict[str, tuple[str, str]]] = {} 113 for lang, groups in self.special_groups.items(): 114 for _, _, rows in groups: 115 for piece, name, desc in rows: 116 out.setdefault(piece, {})[lang] = (name, desc) 117 return out 118 119 @cached_property 120 def special_re(self) -> re.Pattern: 121 """Matches the control tokens in a prompt, longest first so <|image|> is not read as <|image>.""" 122 pieces = sorted(self.special_tokens, key=len, reverse=True) 123 return re.compile("|".join(re.escape(p) for p in pieces))
載入模型時,用 checkpoint 的 config.json 裡的 model_type 找出 family。停止 token 是 generation config 列的,再加上 family 宣告的:Qwen3.5 的 checkpoint 只列了 <|endoftext|>,沒有列每個回合結尾的 <|im_end|>,少了 family 這一層,模型回答完會繼續寫下去。Qwen 的 <think>、<tool_call> 在 tokenizer 裡也沒有標成 special,同樣由 family 補上。tokenizer 放在模型的 processor 裡;processor 也負責把圖片、聲音和影片變成模型的輸入,一個模型讀得懂哪幾種由 checkpoint 決定(第 7 段「圖片、聲音和影片:變成脈絡裡的 token」)。
144self.family = families.for_model_type(AutoConfig.from_pretrained(_download(model_id, "config.json")).model_type) 145files = self.folder = _download(model_id) 146# The processor holds the tokenizer and, for a model that reads more than text, what turns files into its inputs. 147self.processor = AutoProcessor.from_pretrained(files) 148self.tokenizer = getattr(self.processor, "tokenizer", self.processor) 149self.model = AutoModelForMultimodalLM.from_pretrained(files, dtype=self.dtype, device_map=self.device).eval() 150self.modalities = self._modalities() 151self.media_token_ids = {kind: getattr(self.model.config, f"{kind}_token_id", None) for kind in self.modalities} 152self.load_seconds = time.perf_counter() - start 153 154# The generation config's stop tokens, plus the family's: a checkpoint may list only some of them. 155# Qwen3.5 ships no generation_config.json, and its config.json names <|endoftext|> but not <|im_end|>, 156# which ends every chat turn. 157eos = self.model.generation_config.eos_token_id 158self.eos_ids = set(eos if isinstance(eos, list) else [eos]) | self.token_ids(self.family.stop_tokens) 159# Control tokens count as special even when the tokenizer does not mark them (Qwen's <think>). 160self.special_ids = set(self.tokenizer.all_special_ids) | self.eos_ids | self.token_ids(self.family.special_tokens)
要加模型時:同一個家族的另一個 checkpoint(例如更大的 Gemma 4),在 MODELS 加一行就好(harness/families/__init__.py:23)。新的家族,照 harness/families/qwen3_5.py 寫一個模組,內容是一個輸出的 parser 和特殊 token 的說明(中英文各一份),加進 FAMILIES(harness/families/__init__.py:129),model_type 對得上就會自動選到。
parse 是程式。268QWEN3_5 = Family( 269 key="qwen3_5", 270 name="Qwen3.5", 271 model_types=("qwen3_5",), 272 parse=parse_output, 273 thought_prefix="<think>\n", 274 channel_switch={"<think>": "thought", "</think>": "content", "<tool_call>": "tool_call", "</tool_call>": "content"}, 275 stop_tokens={ 276 "<|im_end|>": {"zh": "訊息結束;回覆和工具呼叫都停在這裡", "en": "end of message; replies and tool calls both stop here"}, 277 "<|endoftext|>": {"zh": "文字結束", "en": "end of text"}, 278 }, 279 reasoning_key="reasoning_content", 280 text_with_calls=True, # the template keeps a message's text in front of its <tool_call> 281 user_turn="<|im_start|>user", 282 special_groups=GROUPS, 283 thinking_help={ 284 "zh": "chat template 在 assistant 訊息開頭先寫好 `<think>`,模型先寫推理;關掉時寫一段空的 `<think></think>`。" 285 "0.8B 官方預設不開,開了容易一直想下去停不下來。", 286 "en": "The chat template opens the assistant message with `<think>`, so the model reasons first; off, it writes an empty `<think></think>`. " 287 "Off by default for the 0.8B model, which once it starts thinking often cannot stop.", 288 }, 289 tools_note={ 290 "zh": "chat template 把每個工具的 JSON Schema 原樣放進 system turn 的 `<tools>…</tools>`,後面附上 XML 呼叫格式的說明。", 291 "en": "The chat template puts each tool's JSON Schema as it is in `<tools>…</tools>` in the system turn, followed by how to write a call in XML.", 292 }, 293 model_note={ 294 "zh": "混合架構:24 層裡每 4 層有 3 層是 Gated DeltaNet(線性 attention,狀態大小固定,不隨脈絡變長),1 層是 gated attention。" 295 "在 transformers 裡沒裝 flash-linear-attention 時,它們用 PyTorch 參考實作,prefill 比較慢。", 296 "en": "A hybrid: of every 4 of its 24 layers, 3 are Gated DeltaNet (linear attention, whose state has a fixed size and does not grow with the context) and 1 is gated attention. " 297 "In transformers, without flash-linear-attention installed, they run the PyTorch reference implementation, so prefill is slower.", 298 }, 299 # The checkpoint ships no generation_config.json; these are the model card's non-thinking text settings. 300 sampling={"temperature": 1.0, "top_p": 1.0, "top_k": 20}, 301 byte_level=True, 302 # Cap each video frame at the pixels the reference implementation (qwen-vl-utils) allows; transformers asks 303 # for this to be said outright until it becomes the default. 304 processor_kwargs={"cap_pixels_per_frame": True}, 305 file_notes=FILES, 306)
模型
模型資料夾:models/ 裡的每個檔案
from_pretrained 載入的東西,全部來自 models/ 裡每個模型各自的資料夾;兩個模型和 Gemma 的草稿模型分別說明。
_download() 用 snapshot_download() 把模型下載到 MODELS_DIR(harness/backends/simple_backend.py:47):預設是專案裡的 models/,git 不會追蹤它;設定 AGENT_LOOP_MODELS 就換到別的地方(HF_HUB_CACHE 不行,因為它常是整台電腦所有專案共用的設定)。下載的是 REVISIONS 固定的 commit(harness/families/__init__.py:48),不是當下的 main,所以不管什麼時候下載,拿到的都是這一頁寫作和測試時用的檔案。已經下載過的檔案不會再抓,連不上 Hub 時直接用資料夾裡現有的。
50def _download(model_id: str, only: str | None = None) -> str: 51 """The model's folder in MODELS_DIR, after fetching the files it lacks (only `only`, if given), at the revision 52 families.REVISIONS pins it to. 53 54 from_pretrained(local_files_only=True) is no test of the cache: once config.json is cached, a tokenizer whose 55 own files are missing raises a ValueError (Gemma 4) or loads empty (Qwen3.5) rather than an OSError. 56 snapshot_download lists the model's files on the Hub and fetches only those the cache lacks; when the Hub 57 can't be reached it returns the cached folder, so a downloaded model still starts offline.""" 58 if os.path.isdir(model_id): # a checkpoint on disk rather than a Hugging Face id 59 return model_id 60 return snapshot_download(model_id, revision=families.REVISIONS.get(model_id), allow_patterns=only, cache_dir=MODELS_DIR)
models/ 裡是 Hugging Face 的快取格式:
models--google--gemma-4-E2B-it/:每個 repository 一個資料夾,id 裡的/換成--。refs/main:下載時main指向的 revision(一個 commit hash),只有照main下載時才會寫,例如沒加--revision的hf download;app 照REVISIONS的 commit 下載,用不到它。snapshots/<revision>/:照 repository 原樣排好的檔案,from_pretrained拿到的就是這個資料夾。裡面每個檔案都是連結(Windows 不能建連結時是複製)。blobs/:檔案本身,用 hash 命名,兩個 revision 共用的檔案只存一份。Hub 用 Xet 存的檔案(權重和tokenizer.json)放在models/最上層的blobs/,不同的 repository 也共用,模型自己的blobs/再連過去。trees/<revision>.json:那個 revision 有哪些檔案的清單,存下來備用。CACHEDIR.TAG:標明這是快取資料夾,備份工具可以跳過。
同一個 revision 在 macOS、Linux 加 CUDA 或只有 CPU 的電腦上,都是一模一樣的檔案。不同的是載入的方式:pick_device() 選裝置(harness/backends/simple_backend.py:109),權重在 GPU 上照檔案的 bfloat16 載入,在 CPU 上轉成 float32,記憶體是兩倍。
載入時,每個檔案由誰讀:
config.json:AutoConfig先讀它,model_type決定 family(harness/backends/simple_backend.py:144),模型分頁上方的規格也來自它。tokenizer.json、tokenizer_config.json、chat_template.jinja和 processor 的設定檔:AutoProcessor把它們讀成一個 processor(harness/backends/simple_backend.py:147);chat template 由render()執行(harness/backends/simple_backend.py:248)。- 權重:
AutoModelForMultimodalLM照config.json建好模型,再把.safetensors裡的 tensor 依名稱填進去(harness/backends/simple_backend.py:149)。 generation_config.json:停止 token(harness/backends/simple_backend.py:157)和建議的抽樣設定(harness/backends/simple_backend.py:206)。README.md、LICENSE、.gitattributes:給人看的模型卡、授權和 Hub 上 git 的設定,app 不讀。
Gemma 4 E2B(google/gemma-4-E2B-it,9 個檔案,約 10.3 GB):
config.json(5 kB):Gemma4ForConditionalGeneration,分三部分。text_config是 35 層的語言模型:28 層滑動視窗(只看最後 512 個 token)、7 層 full attention,隱藏層維度 1536,8 個注意力頭共用 1 組 key / value;最後 20 層(num_kv_shared_layers)不自己算 key / value,用前面的層算好的。vision_config是 16 層的視覺編碼器,一張圖最多變成 280 個 token;audio_config是 12 層的音訊編碼器。脈絡上限 131,072 個 token。generation_config.json(208 B):停止 token[1, 106, 50],也就是<eos>、<turn|>、<|tool_response>;建議 T 1.0、top-p 0.95、top-k 64。model.safetensors(10.2 GB):2,011 個 tensor、5.12B 個參數,全部 bfloat16。最大的是 Per-Layer Embeddings(embed_tokens_per_layer,2.35B,占 46%):35 層各替 262,144 個 token 存一個 256 維的向量,只查表、不做矩陣乘法,所以名字裡算的是有效參數(E2B)。其餘是 35 層本身 1.88B、輸入的 embedding 0.40B、音訊編碼器 0.30B、視覺編碼器 0.17B。tokenizer.json(32 MB):SentencePiece 風格的 BPE,262,144 個 token、514,906 條 merges;空白換成▁接在下一個詞前面,詞彙表沒有的字元拆成 UTF-8 byte。tokenizer_config.json(3 kB)替控制 token 取角色名稱(sot_token是<|turn>、stc_token是<|tool_call>等等)。chat_template.jinja(19 kB):寫出<|turn>system/user/model回合、思考的<|think|>、工具的<|tool>declaration:…<tool|>,以及附件的佔位 token(第 6 段「脈絡:把整段對話 render 成一個字串」)。processor_config.json(1.7 kB):一個檔案管三種輸入。圖片切成 16 像素的 patch、每 3×3 合併,最多 280 個 token;聲音取樣 16 kHz、128 個 mel 頻帶,每 40 ms 一個 token,最多 750 個(30 秒);影片最多 32 格,每格最多 70 個 token(第 7 段「圖片、聲音和影片:變成脈絡裡的 token」)。
Qwen3.5 0.8B(Qwen/Qwen3.5-0.8B,13 個檔案,約 1.8 GB):
config.json(2.9 kB):Qwen3_5ForConditionalGeneration。text_config是 24 層的語言模型,18 層 Gated DeltaNet(linear_attention)、6 層 full attention(每 4 層一層),隱藏層維度 1024,8 個注意力頭配 2 組 key / value,脈絡上限 262,144;vision_config是 12 層的視覺編碼器,圖片和影片共用;沒有聲音的編碼器。mtp_num_hidden_layers: 1說權重裡附了一層 MTP。- 沒有
generation_config.json:停止 token 只剩config.json的<|endoftext|>,所以 family 補上<|im_end|>;建議的抽樣設定(T 1.0、top-p 1.0、top-k 20)也由 family 提供(harness/families/qwen3_5.py:300)。 model.safetensors-00001-of-00001.safetensors(1.7 GB):只有一個分片,檔名照分片的格式取;model.safetensors.index.json記下 488 個 tensor 各在哪個分片。0.87B 個參數:24 層 0.50B、embedding 0.25B、視覺編碼器 0.10B,還有 20M 的 MTP 層(mtp.),transformers 載入時跳過。大多是 bfloat16,Gated DeltaNet 層的A_log和 norm 以 float32 存。tokenizer.json(12.8 MB):GPT-2 式的 byte-level BPE,248,044 個 token 加上 26 個 added tokens、247,587 條 merges,先做 NFC 正規化;每個 byte 寫成一個可印出的字元,空白是Ġ。vocab.json(6.7 MB)和merges.txt(3.4 MB)是同一份詞彙和 merges 的舊格式。tokenizer_config.json(16.7 kB)把<|im_end|>當eos_token,裡面還有一份和chat_template.jinja一樣的 chat template。chat_template.jinja(7.8 kB):ChatML 格式,工具 schema 放在<tools>…</tools>,工具結果是一則user訊息,思考寫在<think>裡(第 6 段「脈絡:把整段對話 render 成一個字串」)。preprocessor_config.json和video_preprocessor_config.json:圖片切成 16 像素的 patch,每 2×2 合成一個 token,所以一個 token 涵蓋 32×32 像素,一張圖介於 65,536 和 16,777,216 像素之間;影片每 2 格畫面合成一組 token(第 7 段「圖片、聲音和影片:變成脈絡裡的 token」)。
Gemma 4 E2B 的 MTP 草稿模型(google/gemma-4-E2B-it-assistant,7 個檔案,約 190 MB):
config.json:Gemma4AssistantForCausalLM,4 層(3 層滑動視窗、1 層 full attention),隱藏層維度 256,主模型是 1536(backbone_hidden_size);4 層都讀主模型的 key / value(num_kv_shared_layers: 4)。輸出用分群:262,144 個 token 分成 2,048 群、每群 128 個,先替各群打分,只對最高的 32 群、共 4,096 個 token 算 logits(第 10 段「MTP:草稿模型先猜,主模型一次驗證」)。generation_config.json:num_assistant_tokens: 6,每次猜 6 個 token(harness/backends/simple_backend.py:232)。model.safetensors(158 MB):77.7M 個參數。67M 是embed_tokens(262,144 個 token × 256 維),當作輸出的表;pre_projection把主模型的 token embedding 接上 hidden state(3,072 維)壓成 256 維,post_projection再放大回 1,536;4 層本身只有約 9M。masked_embedding.token_ordering是 262,144 個整數,依群列出 token。tokenizer.json、tokenizer_config.json:和主模型同一套詞彙,app 不載入,草稿模型讀寫的都是主模型的 token id。
模型分頁最下面的「模型資料夾」列出目前載入的模型的這些檔案:describe_folder() 讀出每個檔案的大小,以及 family 的說明提到的欄位在檔案裡的值(harness/backends/simple_backend.py:63)。說明本身寫在 family 裡(Gemma:harness/families/gemma4.py:418;Qwen:harness/families/qwen3_5.py:305),所以換成同一家族的另一個 checkpoint,數字會跟著它的檔案變。
62class FileNote: 63 """What one file in a checkpoint's folder holds in this family. The Model tab shows the note next to the file, 64 with the values in this checkpoint of `fields`, dotted paths into a JSON file (text_config.sliding_window).""" 65 66 text: dict[str, str] # language -> the note 67 fields: tuple[str, ...] = ()
畫面
送出問題:載入模型、開一條 thread 跑 agent
輸入框和範例按鈕都走 start_run():拿鎖、凍結設定、開 thread。
畫面每次重跑,先由 current_llm() 照側欄選的模型拿到 LocalLLM(模型在 vLLM 伺服器上跑時是 VLLMBackend);畫面的其他地方只把它當成一個 Backend(第 11 段「換一層跑模型:Backend 介面、本機 transformers 和 vLLM」)。load_llm() 用 st.cache_resource(max_entries=1) 快取,記憶體裡同時只留一個模型。換模型或換「用什麼跑模型」時,release_model() 先清掉快取、把記憶體還給系統,下一個模型才載入,兩個不會同時佔著記憶體。在 CUDA 上,還給系統之前要先放掉 cuBLAS 的工作區:它從第一次矩陣乘法起就一直留著,而且是切在 transformers 替權重預留的那一大塊記憶體裡,不放掉的話,那一整塊都還不回去(harness/backends/simple_backend.py:124)。Streamlit 的 fast reruns 也關掉了(.streamlit/config.toml):重跑會等正在跑的那一次到下一個 Streamlit 呼叫,而不是在新的 thread 裡和它同時跑,所以模型載入到一半時切換的設定,要等載入完才生效,不會在放掉之後才又載入完、留在記憶體裡。第一次用到某個模型時,把它建議的 temperature、top-p、top-k 寫進側欄;Qwen3.5 的 checkpoint 沒附 generation_config.json,建議值來自 family(harness/backends/simple_backend.py:206)。側欄的模型選單換了之後會開新對話(app/page.py:420),MTP 也回到關閉,只有 vLLM (Docker) 例外,那裡的 MTP 是伺服器啟動的方式;打開「MTP 推測解碼」時,current_llm() 第一次會順便載入草稿模型(harness/backends/simple_backend.py:224)。
133@st.cache_resource(max_entries=1, show_spinner=False) # one model in memory: switching models frees the last one 134def load_llm(model_id: str, url: str | None) -> Backend: 135 llm = vllm_backend.VLLMBackend(model_id, url) if url else LocalLLM(model_id) 136 llm.warmup() 137 return llm 138 139 140def release_model(): 141 """Let the model in memory go now, rather than once the next one has loaded beside it.""" 142 load_llm.clear() 143 gc.collect() 144 empty_cache()
current_llm() 在載入之前還做兩件事:剛換回 transformers 時先停掉 vLLM 的容器;側欄選了 vLLM(又沒設 VLLM_URL)時,先確定 Docker 裡的 vLLM 伺服器已經啟動,拿到它的網址(第 11 段「換一層跑模型:Backend 介面、本機 transformers 和 vLLM」)。「用什麼跑模型」也和記憶體裡的模型一樣,是所有分頁共用的一個選擇:另一個分頁啟動或停掉了 vLLM 的容器,這個分頁就跟著換,不會在旁邊再載入一份模型;新開的分頁或重新整理,也會選容器裡跑的那個模型(app/page.py:277)。MTP 開或關也跟著伺服器,不會用另一種方式把它重新啟動。只有剛選過的分頁例外,直到 current_llm() 啟動或停掉 vLLM 為止。
188def current_llm() -> Backend: 189 model_id = st.session_state.model_id 190 if st.session_state.pop("stop_vllm", False): # transformers was just picked again: vLLM's memory goes back first 191 with st.spinner(tr("停止 vLLM 的容器…", "Stopping vLLM's container…")): 192 vllm_docker.stop() 193 st.session_state.pop("engine_picked", None) # vLLM is stopped or about to start: from now on this tab follows it too 194 url = vllm_server(model_id)["url"] if docker_vllm() else VLLM_URL 195 if url: 196 loading = tr(f"連線到 {url} 的 vLLM 伺服器,載入 {model_id} 的 tokenizer…", f"Connecting to the vLLM server at {url} and loading {model_id}'s tokenizer…") 197 else: 198 loading = tr(f"載入 {model_id}(第一次要下載權重;之後約 10–20 秒)…這時候切換的設定,載入完才會生效。", 199 f"Loading {model_id} (the first time downloads the weights; after that it takes about 10–20 s)… What you switch meanwhile takes effect once it has loaded.") 200 with st.spinner(loading): 201 llm = load_llm(model_id, url) 202 if st.session_state.sampling_for != model_id: # a model just chosen starts from its own recommended decoding, MTP off 203 # but on vLLM in Docker, where MTP is how the server was just started 204 for key, value in {**llm.info["defaults"], **({} if docker_vllm() else {"mtp": False})}.items(): 205 st.session_state[key] = value 206 st.session_state.sampling_for = model_id 207 if st.session_state.mtp and llm.drafter_id and llm.drafter is None: 208 with st.spinner(tr(f"載入 MTP 草稿模型 {llm.drafter_id}(第一次要下載約 190 MB)…", f"Loading the MTP drafter {llm.drafter_id} (the first time downloads about 190 MB)…")): 209 llm.load_drafter() 210 return llm
輸入框(app/page.py:590)和範例按鈕都會呼叫 start_run()。輸入框可以附上檔案,模型讀得懂聲音時還能錄音;有些範例也會附上 app/assets/examples/ 裡的範例檔,輸入框上方也列出這些檔案,選了的會附在你自己打的問題裡。start_run() 先用不等待的方式拿一把全域的鎖(app/page.py:328):模型只有一份,所以不管開幾個分頁,同一時間只能有一個 agent 在跑,拿不到鎖就跳出提示。拿到鎖之後,才把檔案存進這段對話的資料夾(第 7 段「圖片、聲音和影片:變成脈絡裡的 token」)。
generation_slot() 裡的 threading.Lock,用 st.cache_resource 讓所有分頁共用。325def start_run(llm: Backend, text: str, uploads: list[tuple[str, bytes]] = ()) -> bool: 326 """Ask the question in a new run, with `uploads` (file name, contents) saved in the conversation's folder first.""" 327 slot = generation_slot() 328 if not slot.lock.acquire(blocking=False): 329 st.toast(tr("另一個 agent 正在執行(可能在別的分頁),請稍等。", "Another agent is running (maybe in another tab). Please wait.")) 330 return False 331 try: 332 attachments = [media.save(chat_folder(), name, data, llm.modalities) for name, data in uploads] 333 run = LiveRun(llm, st.session_state.history, text, attachments, chat_folder(), current_settings(llm), slot) 334 except Exception: 335 slot.lock.release() 336 raise 337 st.session_state.runs.append(run) 338 st.session_state.view = len(st.session_state.runs) - 1 339 return True
current_settings() 把側欄的值打包成 RunSettings,解碼參數(包括要不要用 MTP)包在裡面的 Sampling。這份設定這一輪從頭用到尾,執行中改側欄只會影響下一輪。它也記下頁面現在的語言:harness 和工具回給模型的錯誤,這一輪都用這個語言寫。tools 是這一輪提供給模型的工具:打開「讓模型寫程式」而且 Docker 準備好了,才有 run_python(第 16 段「環境:真的去執行工具」)。
292def current_settings(llm: Backend) -> RunSettings: 293 s = st.session_state 294 return RunSettings( 295 sampling=Sampling(s.temperature, s.top_p, s.top_k, s.max_new_tokens, mtp=s.mtp and (llm.drafter is not None or docker_vllm())), 296 max_steps=s.max_steps, 297 thinking=s.thinking, 298 use_tools=s.use_tools, 299 tools=offered_tools(), 300 approve_tools=s.approve_tools, 301 lang=s.lang, 302 )
LiveRun 建立 Run,把 self.events.append 當成 emit 傳進去,連同附件和這段對話的資料夾,然後開背景 thread。thread 跑完 run.run() 之後才在 finally 裡放開鎖,所以就算 agent 出錯,鎖也一定會還回去。
daemon=True:關掉 server 時不用等 agent 跑完。227class LiveRun: 228 """One user turn: the harness thread and the events it has emitted so far.""" 229 230 def __init__(self, llm: Backend, history: list[dict], text: str, attachments: list[media.Attachment], files: Path, 231 settings: RunSettings, slot: Slot): 232 self.text = text 233 self.attachments = attachments 234 self.events: list[dict] = [] 235 self.absorbed = False 236 self.run = Run(llm, history, text, settings, self.events.append, attachments, files) 237 self._slot = slot 238 slot.run = self 239 self.thread = threading.Thread(target=self._work, daemon=True) 240 self.thread.start() 241 242 def _work(self): 243 try: 244 self.run.run() 245 finally: 246 if self._slot.run is self: 247 self._slot.run = None # do not keep the model alive after a switch to another one 248 self._slot.lock.release() 249 250 @property 251 def live(self) -> bool: 252 return self.thread.is_alive() 253 254 def data(self) -> dict: 255 return render.build(self.events[:])
Harness
Run 準備:訊息、工具、system prompt
self.messages 是 agent 的全部記憶,之後每一步都把它整份重新 render。
Run.__init__ 把之前的對話歷史接上這次的問題,存成 self.messages(harness/agent.py:71)。之後的工具呼叫和工具結果都 append 在後面;模型本身不記得任何事,每一步看到的都是這份 list 重新 render 出來的字串。訊息用的是 Hugging Face 的通用格式(role、content、tool_calls),兩個家族都吃得下,格式差異交給各自的 chat template;附了檔案時,這則訊息的 content 是一串 part(第 7 段「圖片、聲音和影片:變成脈絡裡的 token」)。self.tools 是這一輪模型拿得到的工具,self.files 是這段對話的資料夾。_stop 和 _decided 是 UI thread 用來通知 agent 的兩個同步工具。
66def __init__(self, llm: Backend, history: list[dict], user_text: str, settings: RunSettings, emit: Callable[[dict], None], 67 attachments: list[media.Attachment] = (), files: Path | None = None): 68 self.id = uuid.uuid4().hex[:12] 69 self.llm = llm 70 self.settings = settings 71 self.messages = [*history, {"role": "user", "content": media.user_content(user_text, list(attachments))}] 72 self.files = files # the conversation's folder, which run_python's sandbox sees as /data 73 self.tools = {name: t for name, t in TOOLS.items() if name in settings.tools} if settings.use_tools else {} 74 self._emit = emit 75 self._start = time.perf_counter() 76 self._stop = threading.Event() 77 self._timed_out = False 78 self._decisions: dict[str, bool] = {} 79 self._decided = threading.Condition()
run() 一開始拿出這個模型的 family,決定這一輪模型看得到什麼:收集 self.tools 裡每個工具的 schema() 給 chat template(關掉「使用工具」時一個都沒有),另外留一份「工具名稱 → 參數 schema」給 parser(Qwen 的參數值是純文字,要靠 schema 才知道型別);system prompt 也分有工具(harness/agent.py:35)和沒工具兩個版本。第一個事件 run_start 帶著模型和 family,畫面就知道這一輪要用哪個模型的格式來畫。
stop_reason 的預設值是 max_steps:迴圈跑完所有步數都沒有 break,停止原因就是步數上限。98def run(self): 99 s = self.settings 100 family = self.llm.family # how this model writes its turns, thinking and tool calls 101 tools = [t.schema() for t in self.tools.values()] or None 102 schemas = {t.name: t.parameters for t in TOOLS.values()} 103 system = {"role": "system", "content": SYSTEM_WITH_TOOLS if tools else SYSTEM_NO_TOOLS} 104 self.emit( 105 "run_start", run_id=self.id, model=self.llm.model_id, family=family.key, 106 settings=_settings_dict(s), tools=list(self.tools), 107 ) 108 109 seen: list[tuple[str, str]] = [] 110 format_errors = 0 111 prev_sequence = "" 112 stop_reason, final = "max_steps", ""
工具的 schema 就是一份 JSON Schema,包成 Hugging Face chat template 認得的 function 格式:
83def schema(self) -> dict: 84 return { 85 "type": "function", 86 "function": {"name": self.name, "description": self.description, "parameters": self.parameters}, 87 }
脈絡
脈絡:把整段對話 render 成一個字串
chat template 把 system、工具、對話接成一長串;模型這一步能看到的就只有它。
每一圈從 for step in range(1, s.max_steps + 1) 開始,側欄的「步數上限」就是這個 range 的上界。第一件事是 render:
step_start 讓畫面開一張新的 step 卡片;context 帶著模型實際讀到的完整字串送出。115for step in range(1, s.max_steps + 1): 116 self.emit("step_start", step=step) 117 prompt = self.llm.render([system, *self.messages], tools, s.thinking) 118 encoded = self.llm.encode(prompt, media.parts(self.messages)) 119 # Media sit in user messages, all before what is new since the last step; so the new part is the same 120 # text in the prompt and in what the model reads, only further along by what the media expanded into. 121 added_from = len(encoded.text) - len(prompt) + common_prefix(prompt, prev_sequence) if prev_sequence else 0 122 self.emit( 123 "context", 124 step=step, 125 prompt=encoded.text, 126 n_tokens=encoded.n_tokens, 127 added_from=added_from, 128 media_tokens=encoded.media_tokens, 129 context_length=self.llm.context_length, 130 roles=_role_counts([system, *self.messages]), 131 )
render() 呼叫 tokenizer 自帶的 chat template。格式是模型自己的,harness 不用知道:
- Gemma 4:
<bos><|turn>system開頭;開了思考模式,system turn 最前面多一個<|think|>;每個工具寫成<|tool>declaration:名稱{…}<tool|>;每則訊息包在<|turn>角色…<turn|>裡。 - Qwen3.5:沒有
<bos>,<|im_start|>system開頭;工具 schema 原樣以 JSON 放進<tools>…</tools>,後面附上 XML 呼叫格式的說明;每則訊息包在<|im_start|>角色…<|im_end|>裡。
generation_prompt=True(add_generation_prompt)在最後打開模型的回合,請模型接著寫:Gemma 是 <|turn>model;Qwen 是 <|im_start|>assistant 加上 <think>(開思考模式)或一段空的 <think></think>(不開)。
248def render(self, messages: list[dict], tools: list[dict] | None, thinking: bool, generation_prompt: bool = True) -> str: 249 """The chat template turns messages and tool schemas into the one string the model reads. 250 With `generation_prompt` it also opens the model's turn, so the model writes next.""" 251 return self.tokenizer.apply_chat_template( 252 messages, tools=tools or None, tokenize=False, add_generation_prompt=generation_prompt, enable_thinking=thinking 253 )
接著 encode() 把字串變成模型真正讀的 token(harness/agent.py:118)。只有文字時,就是 tokenizer 切出來的 token;對話裡有圖片、聲音或影片時,chat template 在它們的位置只寫了一個佔位 token,encode() 把它展開成那個檔案佔的幾十到幾千個 token(第 7 段「圖片、聲音和影片:變成脈絡裡的 token」),所以 context 送出的是展開後的字串。n_tokens 是它的長度,畫面拿它和脈絡上限比,顯示用掉幾 %。added_from 是這一步新加入的內容從哪裡開始:先算 render 出來的字串和 prev_sequence 共同開頭的長度(第 14 段「把工具呼叫寫回對話」會說 prev_sequence 是什麼),再加上媒體展開多出來的長度:媒體都在使用者訊息裡,在新加入的內容前面。第 2 步以後,新加入的正好是工具結果。
57def common_prefix(a: str, b: str) -> int: 58 n = min(len(a), len(b)) 59 i = 0 60 while i < n and a[i] == b[i]: 61 i += 1 62 return i
畫面上的「脈絡」分頁就是把 context 事件裡的字串原樣印出來(app/render.py:955),added_from 之後的部分標成「這一步新加入」,family 列出的特殊 token 換成可以滑過看說明的深色標籤;一長串同一個特殊 token,例如一張圖片展開成的幾百個 <|image|>,畫成一個標籤加上個數。
脈絡
圖片、聲音和影片:變成脈絡裡的 token
附上的檔案存進對話的資料夾;模型讀得懂的,由 processor 展開成一串 token,prefill 時再換成編碼器的輸出。
一個模型讀得懂哪些東西,是載入時從 checkpoint 看出來的:config 裡要有那種編碼器,processor 也要有對應的部分。Gemma 4 E2B 有視覺和音訊編碼器,看得懂圖片、聽得懂聲音,也看得懂影片(一張張畫格);Qwen3.5 0.8B 只有視覺編碼器,看得懂圖片和影片。輸入框只收模型讀得懂的檔案,打開「讓模型寫程式」時再加上 CSV、Excel 這類資料檔。
195def _modalities(self) -> tuple[str, ...]: 196 """What the checkpoint reads besides text: each kind needs an encoder in the config and a part of the processor 197 to feed it. It is asked of the checkpoint, not the family, since checkpoints of one family can differ.""" 198 config, processor = self.model.config, self.processor 199 reads = { 200 "image": getattr(config, "vision_config", None) is not None and hasattr(processor, "image_processor"), 201 "audio": getattr(config, "audio_config", None) is not None and hasattr(processor, "feature_extractor"), 202 "video": getattr(config, "video_token_id", None) is not None and hasattr(processor, "video_processor"), 203 } 204 return tuple(kind for kind, ok in reads.items() if ok)
start_run() 把每個檔案存進這段對話的資料夾,名字只留一般字元,因為模型可能要在程式裡打出它(harness/media.py:61)。使用者訊息裡,模型讀得懂的檔案各是一個 part,排在文字前面;其他檔案模型看不到內容,只在文字後面多一段說明:run_python 在 /data 底下找得到它們,CSV、JSON 這類文字檔再附上第一行(通常就是欄位名稱)。少了這一行,小模型寫程式時會亂猜欄位,例如不存在的 Date,然後出錯;多給幾列資料也不行,Gemma 會把那幾列直接抄進程式,只算它們,不去讀檔案。這段說明是寫給模型看的,所以和 system prompt 一樣用英文。
96def user_content(text: str, attachments: list[Attachment]) -> str | list[dict]: 97 """The user's message: the text alone, or the media first (as their models are trained to read them), then the 98 text, and a note on the files only run_python can open: where they are and how a text file begins. The note is 99 for the model, so it is in English.""" 100 if not attachments: 101 return text 102 files = [a for a in attachments if a.kind == "file"] 103 described = [f"/data/{a.name} ({fmt_size(a.size)})" + (f", whose first line is: {head}" if (head := preview(a.path)) else "") for a in files] 104 note = "Attached for run_python, which reads them:\n" + "\n\n".join(described) if files else "" 105 words = "\n\n".join(t for t in (text, note) if t) 106 return [a.part() for a in attachments if a.kind != "file"] + ([{"type": "text", "text": words}] if words else [])
chat template 在每個 part 的位置寫一個佔位 token:Gemma 是 <|image|>、<|audio|>、<|video|>;Qwen 是 <|vision_start|><|image_pad|><|vision_end|> 和 <|video_pad|>。encode() 把對話裡所有媒體依出現的順序交給 processor,processor 把每個佔位 token 展開成那個檔案佔的 token:
- Gemma 4:一張圖片最多 280 個
<|image|>,包在<|image>和<image|>之間;影片平均取 32 張畫格(比這短的影片全部都用),每張前面寫上時間mm:ss,最多 70 個<|video|>;聲音每 40 ms 一個<|audio|>,最多 750 個,所以只聽得到前 30 秒。 - Qwen3.5:圖片越大 token 越多(640×480 約 300 個
<|image_pad|>);影片每秒取 2 張畫格,每 2 張一組,前面寫上時間,例如<0.3 seconds>。
media_tokens 數每種媒體佔了幾個 token,step 卡片的脈絡段會寫出來。255def encode(self, prompt: str, parts: list[dict]) -> Encoded: 256 """The prompt as the model reads it. Without media that is the prompt's tokens. With media (`parts`, the 257 conversation's media in the order of their placeholders), the processor replaces each placeholder with the 258 tokens that image, clip or video takes, and makes the inputs of the encoders whose output goes there.""" 259 if not parts: 260 ids = self.tokenizer(prompt, add_special_tokens=False, return_tensors="pt").input_ids 261 return Encoded(prompt, ids, ids.shape[-1]) 262 files = {kind: [p["path"] for p in parts if p["type"] == kind] for kind in media.MEDIA} 263 inputs = {} 264 if files["image"]: 265 inputs["images"] = [media.load_image(path) for path in files["image"]] 266 if files["audio"]: 267 inputs["audio"] = [media.load_audio(path, self.processor.feature_extractor.sampling_rate) for path in files["audio"]] 268 if files["video"]: 269 videos = [media.load_video(path) for path in files["video"]] 270 inputs["videos"] = [frames for frames, _ in videos] 271 inputs["videos_kwargs"] = {"video_metadata": [metadata for _, metadata in videos]} 272 # A processor that takes a set number of frames (Gemma 4: 32) refuses a video with fewer; it gets all there are. 273 wanted, fewest = getattr(self.processor.video_processor, "num_frames", None), min(len(frames) for frames, _ in videos) 274 if wanted and fewest < wanted: 275 inputs["videos_kwargs"]["num_frames"] = fewest 276 out = self.processor(text=prompt, return_tensors="pt", **inputs, **self.family.processor_kwargs) 277 ids = out.pop("input_ids") 278 out.pop("attention_mask", None) # one sequence, no padding: every position counts, as with text 279 counts = {kind: int((ids == token).sum()) for kind, token in self.media_token_ids.items() if token is not None} 280 text = self.tokenizer.decode(ids[0], skip_special_tokens=False) 281 return Encoded(text, ids, ids.shape[-1], dict(out.to(self.device, dtype=self.dtype)), {k: n for k, n in counts.items() if n})
processor 要的是解碼好的資料,由 media.py 準備:圖片轉成 RGB,照片照 EXIF 轉正;聲音用 PyAV 解碼成單聲道,取樣率改成模型的 feature extractor 要的 16 kHz;影片平均取最多 64 張畫格、縮小到長邊 768 像素,再附上取完之後的畫格率,processor 寫出的時間才對(harness/media.py:134)。每個檔案只解碼一次,之後每一步都重複使用。
prefill 時,編碼器的輸出換掉這些佔位 token 的 embedding。圖片和影片先跑一次視覺編碼器再交給 prefill,和 generate() 的做法一樣;直接交給 forward 的話,transformers 5.20 的 Gemma 4 會漏掉影片的畫格數。Qwen3.5 的位置編碼(M-RoPE)要知道圖片的長寬,所以除了像素以外的輸入都留著;它也會把上一個 prompt 的位置偏移記在模型上,每次 prefill 前要清掉。prefill 之後,媒體都已經在 KV cache 裡,解碼和純文字完全一樣。tests/test_real_model.py 確認附上圖片、影片和錄音時,greedy 的輸出也和 generate() 一致。
360def _encoded_media(self, inputs: dict) -> dict: 361 """Run the vision encoder over the prompt's images and videos before the prefill, as generate() does, and pass 362 its output in their place. Leaving it to the forward pass breaks videos on Gemma 4: transformers 5.20's 363 Gemma4ForConditionalGeneration takes num_frames_per_video but does not hand it on to the video encoder.""" 364 if not inputs or "mm_encoder_outputs" not in inspect.signature(self.model.forward).parameters: 365 return inputs 366 base, encoded = self.model.base_model, {} 367 for kind in ("image", "video"): 368 encoder = getattr(base, f"get_{kind}_features", None) 369 args = {k: inputs[k] for k in inspect.signature(encoder).parameters if k in inputs} if encoder else {} 370 if any(k.startswith("pixel_values") for k in args): 371 encoded[kind] = encoder(**args, return_dict=True) 372 if not encoded: 373 return inputs 374 # The pixels go; the rest stays, such as the grid sizes Qwen3.5's positions are computed from. 375 return {**{k: v for k, v in inputs.items() if not k.startswith("pixel_values")}, "mm_encoder_outputs": encoded}
模型
模型:prefill,然後一次一個 token
一次 forward 讀完整段 prompt,之後每生成一個 token 再 forward 一次。
harness 用一個 for 迴圈消費 generate() 產生的事件:prefill 和每個 token 都加上 step 編號轉送出去,最後的 done 留下來,改名成 model_end 送出。
self._stop.is_set 是一個函式:模型每生成一個 token 前都會呼叫它,問「要停了嗎?」。133done = None 134for ev in self.llm.generate(encoded, s.sampling, self._stop.is_set): 135 kind = ev.pop("type") 136 if kind == "done": 137 done = ev 138 else: 139 self.emit(kind, step=step, **ev) 140raw = done.pop("raw") 141self.emit("model_end", step=step, raw=raw, **done)
prefill:encode() 已經把整個 prompt 轉成 token id,一次 forward 算完;有媒體時,這次 forward 也把編碼器的輸出放進它們的位置(第 7 段「圖片、聲音和影片:變成脈絡裡的 token」)。use_cache=True 留下每一層的狀態(一般 attention 層是 key / value,Qwen3.5 的 Gated DeltaNet 層是一個固定大小的狀態),之後每個新 token 只需要算自己那一個位置;logits_to_keep=1 只取最後一個位置的 logits,因為只需要「下一個 token」的分布(harness/backends/simple_backend.py:353)。prefill 的時間主要看 prompt 有多長,模型分頁的表格裡有每一步的 prefill ms。
thought_prefix 結尾(Gemma 的 <|channel>thought、Qwen 的 <think>),模型一開始就在思考裡。283def generate(self, prompt: Encoded, sampling: Sampling, should_stop: Callable[[], bool]) -> Iterator[dict]: 284 """Yield a prefill event, one event per token, then a done event.""" 285 ids = prompt.ids.to(self.device) 286 channel = "thought" if prompt.text.endswith(self.family.thought_prefix) else "content" 287 drafting = sampling.mtp and self.drafter is not None 288 mtp = {"drafted": 0, "accepted": 0, "forwards": 0} if drafting else None 289 generated: list[int] = [] 290 text = "" 291 finish = "max_new_tokens" 292 293 with torch.inference_mode(): 294 # Models with multimodal RoPE (Qwen3.5) keep the position offset of the last prompt's images on the model 295 # and add it in every later forward pass; each prompt starts from its own. 296 if hasattr(self.model.base_model, "rope_deltas"): 297 self.model.base_model.rope_deltas = None 298 start = time.perf_counter() 299 out = self._forward(ids, None, 1, drafting, **self._encoded_media(prompt.media)) 300 prefill_ms = (time.perf_counter() - start) * 1000 301 yield {"type": "prefill", "n_tokens": ids.shape[-1], "ms": round(prefill_ms, 1)}
解碼迴圈從 tokens 一個一個拿抽好的 token:平常由 _one_at_a_time() 產生,打開 MTP 時改由 _drafted()(第 10 段「MTP:草稿模型先猜,主模型一次驗證」),迴圈本身不用知道是哪一種。每一圈:檢查停止 → 拿到一個 token → 判斷它是不是特殊 token、有沒有切換 channel:哪些 token 切到思考、工具呼叫或回覆,由 family 的 channel_switch 決定(Gemma:harness/families/gemma4.py:394;Qwen:harness/families/qwen3_5.py:274)→ 把目前為止的所有 token 整段 decode 再取差值,這樣被拆成好幾個 byte token 的中文字會等到湊齊才輸出 → 送出 token 事件。
303decode_start = time.perf_counter() 304tokens = self._drafted(out, sampling, mtp) if drafting else self._one_at_a_time(out, sampling) 305for token_id, details in tokens: 306 if should_stop(): 307 finish = "user_stopped" 308 break 309 generated.append(token_id) 310 311 piece = self.piece(token_id) 312 special = token_id in self.special_ids 313 if piece in self.family.channel_switch: 314 channel = self.family.channel_switch[piece] 315 token_channel = "control" 316 else: 317 token_channel = "control" if special else channel 318 319 # Decode the whole sequence and diff it, so characters split across byte tokens come out whole. 320 full = self.tokenizer.decode(generated, skip_special_tokens=False) 321 delta = "" if full.endswith("�") else full[len(text):] 322 if delta: 323 text = full 324 yield { 325 "type": "token", 326 "index": len(generated) - 1, 327 "id": token_id, 328 "piece": piece, 329 "text": delta, 330 "special": special, 331 "channel": token_channel, 332 **details, 333 }
生成到停止 token,或到了 max_new_tokens(側欄的「每步 token 上限」),就跳出迴圈。
335if token_id in self.eos_ids: 336 finish = "stop_token" 337 break 338if len(generated) == sampling.max_new_tokens: 339 break
還沒停,迴圈就再跟 tokens 要下一個。_one_at_a_time() 這時才把剛抽到的 token 餵回模型,帶著快取算出下一個位置的 logits;它是一個 generator,迴圈跳出之後就不會再多跑一次 forward。
377def _one_at_a_time(self, out, s: Sampling) -> Iterator[tuple[int, dict]]: 378 """Plain decoding: sample a token, feed it back with the cache for the next position's logits, repeat.""" 379 while True: 380 token_id, details = self._sample(out.logits[0, -1].float().cpu(), s) 381 yield token_id, details 382 out = self._forward(torch.tensor([[token_id]], device=self.device), out.past_key_values, 1)
最後的 done 告訴 harness 為什麼停(finish:stop_token、max_new_tokens 或 user_stopped)、停在哪個 token、生成速度,以及整段原始輸出 raw(保留特殊 token,因為 harness 要靠它們解析)。
341decode_s = time.perf_counter() - decode_start 342yield { 343 "type": "done", 344 "finish": finish, 345 "stop_piece": self.piece(generated[-1]) if finish == "stop_token" else None, 346 "n_tokens": len(generated), 347 "decode_ms": round(decode_s * 1000, 1), 348 "tok_per_s": round(len(generated) / decode_s, 1) if decode_s > 0 else None, 349 "mtp": mtp, 350 "raw": self.tokenizer.decode(generated, skip_special_tokens=False), 351}
模型
抽樣:每個 token 是怎麼選出來的
temperature → top-k → top-p → 抽一個;同時記下模型原本的分布,給浮窗用。
_sample() 每個 token 跑一次。先對原始 logits 做 softmax,得到模型自己的分布(T = 1、什麼都沒剃掉):token 浮窗裡的「模型給的機率」、排名和前 5 名候選都來自這個分布。
temperature ≤ 0 是 greedy,直接拿機率最高的;兩個 token 機率一樣時拿 id 小的,和 generate() 的 argmax 一樣(bfloat16 的 logits 打平比想像中常見)。否則依序:logits ÷ T → 只留前 k 個(top-k,0 = 不限)→ 重新 softmax → 從機率最高的往下累加,累積機率達到 top-p 為止(nucleus,至少留一個)→ 重新正規化 → torch.multinomial 抽一個。這段跟模型無關,兩個家族共用。
431def _sample(self, logits: torch.Tensor, s: Sampling, draft: int | None = None) -> tuple[int, dict]: 432 """Pick the next token and report the model's distribution and what the settings did to it. 433 A `draft` (the MTP drafter's guess) is kept with the probability the settings give it, which in greedy 434 decoding means when it is the top token; otherwise the pick comes from the other candidates. Either way 435 each token comes out exactly as often as without a drafter.""" 436 probs = torch.softmax(logits, -1) # the model's own distribution (T = 1, nothing cut off) 437 # Equally likely tokens go lowest id first, the one argmax and so generate() takes. topk leaves their order open 438 # (on the CPU it can list the higher id first), and in bfloat16 two logits are equal more often than one would think. 439 top = sorted(zip(*probs.topk(TOP_N)), key=lambda c: (-float(c[0]), int(c[1]))) 440 441 if s.temperature <= 0: # T -> 0 is greedy decoding 442 chosen = int(top[0][1]) 443 kept = 1 444 p_sampled = 1.0 445 else: 446 scaled = logits / s.temperature 447 if s.top_k > 0: 448 cand_logits, cand_ids = scaled.topk(min(s.top_k, scaled.numel())) 449 else: 450 cand_logits, cand_ids = scaled.sort(descending=True) 451 cand_probs = torch.softmax(cand_logits, -1) 452 # Nucleus: keep the smallest prefix whose probability reaches top_p (always at least one token). 453 kept = int(((cand_probs.cumsum(-1) - cand_probs) < s.top_p).sum()) 454 cand_ids, cand_probs = cand_ids[:kept], cand_probs[:kept] / cand_probs[:kept].sum() 455 weights = cand_probs 456 at = (cand_ids == draft).nonzero().flatten() if draft is not None else [] 457 if len(at) and float(torch.rand(())) < float(cand_probs[at[0]]): 458 pick = int(at[0]) 459 else: 460 if len(at): # the guess was turned down: draw from the other candidates 461 weights = cand_probs.clone() 462 weights[at[0]] = 0 463 pick = int(torch.multinomial(weights, 1)) 464 chosen = int(cand_ids[pick]) 465 p_sampled = float(cand_probs[pick]) 466 467 p = float(probs[chosen]) 468 # Candidates are a prefix of the tokens sorted by probability, so rank decides who survived. 469 return chosen, { 470 "p": p, 471 "logprob": float(torch.log(probs[chosen])) if p > 0 else None, 472 "rank": int((probs > probs[chosen]).sum()) + 1, 473 "p_sampled": p_sampled, 474 "kept": kept, 475 "top": [ 476 {"id": int(i), "piece": self.piece(int(i)), "p": float(v), "kept": rank < kept} 477 for rank, (v, i) in enumerate(top) 478 ], 479 }
回傳的資料就是浮窗裡的數字:p 是被選中的 token 在原始分布的機率;rank 是它的名次;kept 是剃完還剩幾個候選;p_sampled 是它在剃完、重新正規化後的分布裡的機率。step 卡片上 token 的底色深淺就是用 p 算的(app/render.py:612):越深,代表模型原本越不看好它,卻被抽中了。這些數字列在 backend.py 的 TOKEN_DETAILS(harness/backends/backend.py:59):別的 backend,例如推論伺服器,不一定知道,不知道的就不畫,不會用別的數字代替(第 11 段「換一層跑模型:Backend 介面、本機 transformers 和 vLLM」)。
模型
MTP:草稿模型先猜,主模型一次驗證
側欄打開「MTP 推測解碼」後,主模型的一次 forward 可以產生好幾個 token,而每個 token 的機率都不變。
一般的解碼每個 token 都要跑一次完整的 forward,而這段時間主要花在把幾 GB 的權重從記憶體讀出來;同一次 forward 多算幾個位置,幾乎不會更慢(實測 Gemma 4 E2B 在 Apple M5 上,1 個位置約 56 ms,7 個位置約 70 ms)。所以只要有人便宜地先猜好接下來幾個 token,主模型就能一次 forward 把它們全部驗證。這就是推測解碼(speculative decoding);MTP(multi-token prediction)是 Google 為 Gemma 4 訓練的猜法。
猜的是草稿模型 google/gemma-4-E2B-it-assistant(harness/families/__init__.py:31):約 78M 參數、4 層、隱藏層維度 256,主模型是 5.1B。它沒有自己的 KV cache:Gemma 4 E2B 的 35 層裡,後 20 層本來就不自己算 key / value,而是共用前面兩層(最後一個滑動視窗層和最後一個 full attention 層)算好的,草稿模型也讀這一份。第一次打開 MTP 時才載入,每次猜幾個 token 寫在它的 generation config(num_assistant_tokens = 6)。
224def load_drafter(self): 225 """Load the MTP drafter that goes with this model (families.DRAFTERS), if any, and warm it up.""" 226 with self._drafter_lock: 227 if self.drafter is not None or self.drafter_id is None: 228 return 229 start = time.perf_counter() 230 folder = _download(self.drafter_id) 231 self.drafter = AutoModelForCausalLM.from_pretrained(folder, dtype=self.dtype, device_map=self.device).eval() 232 self.draft_tokens = self.drafter.generation_config.num_assistant_tokens # guesses per forward pass 233 self.warmup(mtp=True) 234 cfg = self.drafter.config.get_text_config() 235 self.info["drafter"] = { 236 "model_id": self.drafter_id, 237 "parameters": self.drafter.num_parameters(), 238 "layers": cfg.num_hidden_layers, 239 "hidden_size": cfg.hidden_size, 240 "draft_tokens": self.draft_tokens, 241 "load_seconds": round(time.perf_counter() - start, 1), 242 "folder": describe_folder(folder, self.family.drafter_file_notes), 243 }
猜:輸入是「上一個 token 的 embedding」接上「主模型在上一個位置最後一層的 hidden state」,輸出下一個 token 的 logits,取機率最高的當作猜測。接著把猜到的 token 和草稿模型自己的 hidden state 當作下一次的輸入,再猜一個,最多 6 個。6 次都站在同一個位置,因為它看到的 key / value 只到主模型真正讀過的地方。
417def _draft(self, token_id: int, hidden: torch.Tensor, shared: dict, position: int) -> list[int]: 418 """The drafter's greedy guesses for the tokens after `token_id`. It has no cache of its own: it attends to the 419 model's keys and values, so every guess sits at the position of `token_id`, and what it has guessed so far 420 comes in through its input, the embedding of the last guess next to the drafter's last hidden state.""" 421 embed = self.model.get_input_embeddings() 422 positions = torch.tensor([[position]], device=self.device) 423 guesses = [] 424 while len(guesses) < self.draft_tokens and token_id not in self.eos_ids: 425 x = torch.cat([embed(torch.tensor([[token_id]], device=self.device)), hidden], -1) 426 out = self.drafter(inputs_embeds=x, position_ids=positions, shared_kv_states=shared) 427 token_id, hidden = int(out.logits[0, -1].argmax()), out.last_hidden_state 428 guesses.append(token_id) 429 return guesses
驗證:主模型對「剛確定的 token + 6 個猜測」做一次 forward,logits_to_keep=7 拿回 7 個位置的分布:第 i 個位置的分布,就是讀到第 i 個猜測之前時,下一個 token 的分布。從第一個猜測開始依序交給 _sample() 檢查:收下就是一個不用另外 forward 的 token;第一個被退回的猜測,由主模型自己在那個位置選的 token 取代,後面的猜測全部作廢;6 個都收下時,最後一個位置的分布還能多給一個 token。所以一次 forward 產生 1 到 7 個 token。
384def _drafted(self, out, s: Sampling, stats: dict) -> Iterator[tuple[int, dict]]: 385 """MTP decoding. After each token the drafter guesses the next few, and one forward pass over the token and the 386 guesses gives the model's distribution at every guessed position. _sample checks the guesses in order: each 387 one it keeps is a token for free, the first one it turns down is replaced by the model's own pick, and the 388 rest are dropped. When every guess holds, the last position gives one more token.""" 389 past = out.past_key_values 390 past.activate_past_recording() # so the sliding-window layers can still drop guesses that were turned down 391 hidden, shared = out.hidden_states[-1][:, -1:], out.shared_kv_states 392 token_id, details = self._sample(out.logits[0, -1].float().cpu(), s) 393 while True: 394 yield token_id, details 395 guesses = self._draft(token_id, hidden, shared, past.get_seq_length()) 396 out = self._forward(torch.tensor([[token_id, *guesses]], device=self.device), past, len(guesses) + 1, True) 397 logits = out.logits[0].float().cpu() 398 stats["forwards"] += 1 399 stats["drafted"] += len(guesses) 400 for n, guess in enumerate(guesses): 401 token_id, details = self._sample(logits[n], s, guess) 402 if token_id != guess: 403 details["draft"] = {"piece": self.piece(guess), "accepted": False} 404 break 405 stats["accepted"] += 1 406 yield token_id, {**details, "draft": {"piece": self.piece(guess), "accepted": True}} 407 else: 408 n = len(guesses) 409 token_id, details = self._sample(logits[n], s) 410 # Keep the cache and the drafter's inputs up to the last kept guess (or the token, when none held). 411 dropped = len(guesses) - n 412 past.crop(-dropped) 413 hidden = out.hidden_states[-1][:, n : n + 1] 414 shared = {k: (key[:, :, : key.shape[2] - dropped], value[:, :, : value.shape[2] - dropped]) 415 for k, (key, value) in out.shared_kv_states.items()}
作廢的猜測已經寫進了 KV cache,要用 past.crop() 拿掉;滑動視窗層平常會馬上丟掉太舊的 key / value,activate_past_recording() 讓它先留著,等 crop() 之後再縮回去。給草稿模型的那份 key / value 也同樣切掉作廢的部分。
為什麼輸出不變:_sample() 收到猜測時,greedy(T = 0)只在猜測剛好是第 1 名時收下,所以輸出和不開 MTP 一樣(一次算 7 個位置的數值和一次算 1 個會有極小的差異,兩個 token 機率幾乎相同時偶爾會選到另一個)。抽樣時,猜測被收下的機率就是 temperature、top-k、top-p 處理完之後它的機率 p;沒收下,就把它從候選裡拿掉,從其他候選抽。任何一個 token 最後出現的機率還是 p:草稿模型只決定要花幾次 forward,不決定寫出什麼。
456at = (cand_ids == draft).nonzero().flatten() if draft is not None else [] 457if len(at) and float(torch.rand(())) < float(cand_probs[at[0]]): 458 pick = int(at[0]) 459else: 460 if len(at): # the guess was turned down: draw from the other candidates 461 weights = cand_probs.clone() 462 weights[at[0]] = 0 463 pick = int(torch.multinomial(weights, 1))
畫面上:
- step 卡片裡被收下的猜測有底線;一串底線加上後面那個沒有底線的 token,就是主模型同一次 forward 的產出。
- token 浮窗會寫「草稿猜中」,或「草稿被退回」和草稿原本猜的是什麼。
- step 卡片的模型段多一行:草稿收下幾個、解碼 forward 跑了幾次、不開 MTP 要跑幾次;模型分頁的表格也有這兩欄。
收下的比例很看內容。在 Apple M5 上用 greedy 實測:工具呼叫幾乎都猜得中(35 個收下 34 個,約 15 → 43 tok/s),英文程式碼約一半,繁體中文的長回答只有一成左右,快不了多少。格式固定、可以預測的文字,草稿模型才猜得準。
模型
換一層跑模型:Backend 介面、本機 transformers 和 vLLM
harness 和畫面只認得 Backend:主要是 render、encode、generate 三個方法,和它送出的事件。上面幾段講的是本機那一個;同一個介面後面也可以是一台 vLLM 伺服器。
前面幾段的 prefill、解碼迴圈、抽樣和 MTP,都是 simple_backend.py 的 LocalLLM。可是 harness 和畫面從來不直接找 LocalLLM,只透過 Backend 用模型(harness/backends/backend.py:63):render() 把對話套進 chat template,encode() 把 prompt 變成模型讀的 token(媒體展開),generate() 依序送出 prefill、每個 token 一個 token、最後的 done;再加上畫面會讀的幾個屬性:family、模型讀得懂什麼、脈絡上限、模型分頁用的 info。誰有這些,誰就能跑模型。
Protocol 只是一個型別:LocalLLM 不必繼承它,有這些方法和屬性就算數。測試檢查 LocalLLM、VLLMBackend 和測試用的 FakeLLM 都是 Backend。63class Backend(Protocol): 64 """What runs the model. The page holds one at a time (app.page.current_llm) and runs one turn on it at a time.""" 65 66 model_id: str 67 family: families.Family # how the model writes its turns, thinking and tool calls (families/) 68 modalities: tuple[str, ...] # what it reads besides text: "image", "audio", "video" 69 context_length: int # the most tokens it reads at once 70 info: dict # what the Model tab shows, and in "defaults" the recommended temperature, top_p and top_k 71 drafter_id: str | None # the MTP drafter that goes with the model (families.DRAFTERS), if any 72 drafter: object | None # that drafter once load_drafter() has loaded it; Sampling.mtp turns it on only then 73 74 def warmup(self) -> None: 75 """Pay what the first question would otherwise wait for, such as compiling kernels.""" 76 77 def load_drafter(self) -> None: 78 """Load the MTP drafter, if the model has one and it is not loaded yet.""" 79 80 def render(self, messages: list[dict], tools: list[dict] | None, thinking: bool, generation_prompt: bool = True) -> str: 81 """The chat template's string for the messages and tool schemas, which is what the model reads; with 82 `generation_prompt` it also opens the model's turn. The harness finds what is new in a step by comparing 83 this string with the last step's, so it has to be the prompt itself, not something like it.""" 84 85 def encode(self, prompt: str, parts: list[dict]) -> Encoded: 86 """The prompt as the model reads it, with each image, clip or video in `parts` (media.parts) expanded into 87 the tokens it takes.""" 88 89 def generate(self, prompt: Encoded, sampling: Sampling, should_stop: Callable[[], bool]) -> Iterator[dict]: 90 """Generate the model's turn, asking should_stop() before every token. It yields, in order: 91 92 - {"type": "prefill", "n_tokens", "ms"}: the prompt has been read, in `ms` milliseconds; 93 - {"type": "token", ...} for each token, with TOKEN_FIELDS and the TOKEN_DETAILS the backend knows; 94 - {"type": "done", "finish", "stop_piece", "n_tokens", "decode_ms", "tok_per_s", "mtp", "raw"}: `finish` 95 is "stop_token", "max_new_tokens" or "user_stopped"; `stop_piece` is the stop token's piece; `mtp` is 96 {"drafted", "accepted", "forwards"} with MTP on (and "kept_at", "turned_down_at" from a backend that 97 learns the guesses only then), else None; and `raw` is the whole output, special tokens included, which 98 the family parses."""
畫面只在一個地方決定用哪一個:load_llm() 拿到伺服器的網址(VLLM_URL,或畫面在 Docker 裡啟動的伺服器)就用 vLLM,沒有就在這台電腦上跑(app/page.py:135)。之後 current_llm()、側欄和 Run(第 5 段「Run 準備:訊息、工具、system prompt」)拿到的都只是一個 Backend;它放在 st.cache_resource 裡,換模型或換「用什麼跑模型」才換掉(第 4 段「送出問題:載入模型、開一條 thread 跑 agent」)。
兩種 backend 對模型知道的不一樣多,差別全在 token 事件裡。TOKEN_FIELDS 每個 backend 都要送:位置、token 的 id 和 piece、它加上的文字、是不是特殊 token、正在寫思考、工具呼叫還是回覆。TOKEN_DETAILS 則是知道才送:機率、排名、取樣時的機率、剩下的候選、前 5 名,以及 MTP 草稿猜了什麼。
48TOKEN_FIELDS = ("index", "id", "piece", "text", "special", "channel") 49# What a token event carries only when the backend knows it: 50# p, logprob the token's probability in the model's own distribution (temperature 1, nothing cut off), and its log 51# rank the token's place in that distribution, 1 for the most likely 52# p_sampled its probability in the distribution it was drawn from, after temperature, top-k and top-p 53# kept how many candidates top-k and top-p left 54# top the 5 most likely tokens, each with id, piece, p, and kept (whether top-k and top-p left it) when known 55# draft with MTP, what the drafter guessed here and whether the model kept it; absent where it guessed nothing. 56# A backend that learns only at the end of a step which tokens were guesses (vllm_backend.py) says so in 57# done's mtp instead (kept_at, turned_down_at), and render.py marks those tokens; it may not know what a 58# turned-down guess was (piece None). 59TOKEN_DETAILS = ("p", "logprob", "rank", "p_sampled", "kept", "top", "draft")
畫面拿到什麼就畫什麼(app/render.py:698):沒回報的數字整列不畫,沒有機率就沒有熱度底色,沒說哪些候選被剃掉,前 5 名就不會變灰(app/render.py:672)。畫面寧可少畫,也不拿別的數字湊。
三個 backend:
LocalLLM(simple_backend.py):用 transformers 把權重載到這台電腦,自己寫的解碼迴圈每個 token 都看得到完整的分布,所以TOKEN_DETAILS每一項都有;MTP 的每個猜測收下或退回都知道(第 10 段「MTP:草稿模型先猜,主模型一次驗證」);greedy 的輸出和generate()一樣;checkpoint 有編碼器的圖片、聲音、影片都讀得懂。VLLMBackend(vllm_backend.py):權重在 vLLM 伺服器上。每個 token 回來時帶著它的 log 機率和最可能的幾個 token,其他的看伺服器怎麼啟動(見下面);模型讀得懂的媒體,是 checkpoint 有編碼器、而且伺服器收的那幾種(harness/backends/vllm_backend.py:326)。FakeLLM(tests/fakes.py):照劇本一個 token 一個 token 吐出寫好的輸出,不載入權重;大部分測試用它跑完整個 harness 和網頁(tests/fakes.py:147)。
vLLM 伺服器可以是 VLLM_URL 指的任何一台,也可以由畫面自己啟動:沒設 VLLM_URL 時,側欄「用什麼跑模型」選 vLLM,harness/backends/vllm_docker.py 就照 docker-compose/compose.yaml 用 Docker Compose 啟動它(harness/backends/vllm_docker.py:170)。權重先下載到這台電腦的 models/,容器唯讀掛上、自己不下載,所以伺服器載入的正是 REVISIONS 固定的那個 commit。Docker 有 NVIDIA 的 runtime 時,再疊上 compose.gpu.yaml 改在 GPU 上跑(KV cache 和 CPU 上一樣是 2 GiB,不是 vLLM 預設的 GPU 記憶體的 92%),否則在 CPU 上跑(harness/backends/vllm_docker.py:132),Mac 也一樣,在 Docker 的 Linux 虛擬機裡;CPU 版的 image 由 docker-compose/vllm-cpu/ 建,補上 vLLM 0.32.0 在 CPU 上缺的兩個修正。換到 vLLM 之前,畫面先放掉這台電腦上的模型(app/page.py:140);換回 transformers 或結束這個程式時,停掉容器(harness/backends/vllm_docker.py:237、harness/backends/vllm_docker.py:249)。
VLLMBackend 的 prompt 一樣精確。它把 checkpoint 除了權重以外的檔案下載到 models/,用的是 REVISIONS 固定的同一個 commit(harness/backends/vllm_backend.py:145),所以 render() 和 encode() 用的 tokenizer 和 chat template 跟 LocalLLM 一模一樣。送到伺服器的是 token id 而不是文字,伺服器讀到的就是脈絡分頁上那一串(第 6 段「脈絡:把整段對話 render 成一個字串」)。側欄的每個設定都照送,其他取樣設定都設成不起作用,免得伺服器從 checkpoint 帶來的預設值改變分布。
538def _sampling_params(self, prompt: Encoded, s: Sampling) -> dict: 539 """Each sidebar setting, and every other sampling setting set to do nothing, so that a default the server took 540 from the checkpoint (a repetition penalty, say) cannot change the distribution the page shows.""" 541 return { 542 "max_tokens": max(1, min(s.max_new_tokens, self.context_length - prompt.n_tokens)), # vLLM refuses a request that cannot fit 543 "temperature": max(s.temperature, 0.0), # 0 is greedy there as here 544 "top_p": s.top_p, 545 "top_k": s.top_k, # 0 is no limit there as here 546 "min_p": 0.0, 547 "repetition_penalty": 1.0, 548 "presence_penalty": 0.0, 549 "frequency_penalty": 0.0, 550 "stop_token_ids": sorted(self.eos_ids), 551 }
伺服器用 server-sent events 一段一段送回 token。一般的 vllm serve 只有 /v1/completions:每個 token 帶著它的 log 機率,以及那個位置機率最高的 5 個 token 和它們的 log 機率,key 寫成 token_id:<id>;排名只有在 token 落在前 5 名裡時才算得出來。vLLM 預設回傳的是模型原本的分布(--logprobs-mode raw_logprobs),和 LocalLLM 的 p 意思一樣。
598def _details(self, token_id: int, logprobs: dict, n: int, s: Sampling) -> dict: 599 """What /v1/completions said about the n-th token of a chunk: its log probability, and those of the 5 most likely 600 tokens, keyed "token_id:<id>". The token's rank is known only when it is one of those 5.""" 601 token_logprobs, top_logprobs = logprobs.get("token_logprobs") or [], logprobs.get("top_logprobs") or [] 602 if n >= len(token_logprobs) or token_logprobs[n] is None: 603 return {} 604 logprob = token_logprobs[n] 605 listed = top_logprobs[n] if n < len(top_logprobs) and top_logprobs[n] else {} 606 # The chosen token is listed too, even when it is not among the 5. 607 listed = {int(k.removeprefix("token_id:")): v for k, v in listed.items() if k.startswith("token_id:")} 608 top = sorted(listed.items(), key=lambda c: (-c[1], c[0]))[:TOP_N] 609 rank = 1 + sum(v > logprob for _, v in top) if any(i == token_id for i, _ in top) else None 610 return self._token_details(token_id, logprob, listed, rank, None, s)
可是伺服器也可能用別的模式啟動,而沒有哪條路會說是哪一種,所以載入時 backend 先問一個 token:temperature 1、top-k 2、要最可能的 5 個(harness/backends/vllm_backend.py:231)。processed 模式下,被 top-k 剃掉的 3 個會回 -9999;log 機率加起來最多是 1,logits 不會。processed_logprobs 是 token 被抽出來的那個分布,套過 temperature、top-k、top-p,所以選中的 token 的就是取樣時的機率,送成 p_sampled,不送 p 和前 5 名;列出來的有幾個被剃掉時,也知道剩下幾個。logits 不是機率,就不當成機率送。排名每種模式都一樣:temperature 不改順序,top-k、top-p 留下的是最可能的那幾個。
664def _token_details(self, token_id: int, logprob: float, listed: dict[int, float], rank: int | None, 665 mask: list[int] | None, s: Sampling) -> dict: 666 """The details of a token event, from what the server said about the token: its log probability, those of the 667 tokens listed with it, its rank and its sampling mask when it sent them. What the numbers may be shown as 668 depends on the server's --logprobs-mode. Only the model's own log probabilities are p, logprob and the top 5. 669 Processed ones are the distribution the token was drawn from, so the token's is its sampling probability, and 670 the tokens top-k and top-p cut come back as UNREPORTED. Logits are neither.""" 671 details = {"rank": rank} if rank else {} 672 if self.logprobs_mode == "raw_logprobs": 673 # Equally likely tokens go lowest id first, as in LocalLLM. 674 ranked = sorted(listed.items(), key=lambda c: (-c[1], c[0])) 675 if rank is not None and rank > TOP_N: # listed only because it was chosen 676 ranked = [c for c in ranked if c[0] != token_id] 677 top = [{"id": i, "piece": self.piece(i), "p": math.exp(v)} for i, v in ranked[:TOP_N]] 678 details.update(p=math.exp(logprob), logprob=logprob, top=top) 679 elif self.logprobs_mode == "processed_logprobs": 680 details["p_sampled"] = math.exp(logprob) 681 if mask is not None: 682 details["kept"] = len(set(mask)) 683 elif self.logprobs_mode.startswith("processed") and any(v <= UNREPORTED for v in listed.values()): 684 # Some of the most likely tokens were cut, and those left are more likely still, so all of them are listed. 685 details["kept"] = sum(v > UNREPORTED for v in listed.values()) 686 return self._greedy(details, token_id, s)
伺服器加上 --enable-scale-out 時,還有一條 /inference/v1/generate:一樣送 token id 進去、token id 出來,但說得更多。載入時 backend 用幾個只生成一個 token 的請求問伺服器能做什麼:有沒有這條路、有沒有送 sampling mask、checkpoint 讀得懂的媒體它收不收;不收的寫下原因(harness/backends/vllm_backend.py:387)。這條路帶來的:
- 每個 token 的排名,就算不在前 5 名也有。它的清單把選中的 token 放第一個,所以多要一個,再照排名排出前 5 名。vLLM 的排名把和選中的 token 同分的也算在前面,推測解碼時比的還是 logits,列出來的 log 機率可能在 logits 沒打平的地方打平。所以 backend 照清單上的 log 機率算排名,只數比選中的更可能的:同分第一就是 #1,和
LocalLLM一樣。 --return-sampling-mask:每個位置 top-k、top-p 留下了哪些 token,由此知道剩下幾個候選。vLLM 只在--logprobs-mode processed_logprobs下開它,所以有 mask 的伺服器送回的是抽樣分布:看得到取樣時的機率和剩下幾個候選,看不到模型給的機率、前 5 名,也看不到哪幾個被剃掉。這時它也不接受 temperature 0 和 top-k 0(mask 的大小要靠 top-k 限制),所以側欄的滑桿從 0.05 和 1 開始。沒有--enable-scale-out的伺服器看不到 mask,所以載入時也要一個 greedy 的 token,被拒絕就知道它開了 mask(harness/backends/vllm_backend.py:372)。greedy 不用問伺服器:只剩一個候選,抽中的機率是 1(harness/backends/vllm_backend.py:688)。- 圖片、聲音和影片:跟著 chat template 寫的佔位 token 一起送過去(圖片轉正存成 PNG、聲音解碼成 WAV、影片送原檔,
harness/backends/vllm_backend.py:202),伺服器把每個佔位 token 展開成那個檔案的 token。encode()先用一個只生成一個 token 的請求問伺服器怎麼展開,所以脈絡分頁上是伺服器讀到的 prompt(第 7 段「圖片、聲音和影片:變成脈絡裡的 token」);生成時伺服器若展開得不一樣,就當成錯誤。
449def encode(self, prompt: str, parts: list[dict]) -> Encoded: 450 """The prompt's token ids, which are what the server reads. With media (`parts`, in the order of their 451 placeholders), the server expands each placeholder into the tokens its file takes, so it is asked how first, with 452 a one-token request; the text and the count are of the prompt as the server reads it, and the ids stay the ones 453 the chat template wrote, which generate() sends again with the files.""" 454 ids = self.tokenizer(prompt, add_special_tokens=False).input_ids 455 if not parts: 456 return Encoded(prompt, ids, len(ids)) 457 if unread := {p["type"] for p in parts} - set(self.modalities): # media.save makes those plain files, so this is a bug 458 raise ValueError(f"The vLLM server takes no {', '.join(sorted(unread))} here, only {', '.join(self.modalities) or 'text'}.") 459 files = [content_part(p, self.audio_rate) for p in parts] 460 expanded = self._generate_once(ids, files, return_token_ids=True)["prompt_token_ids"] 461 counts = Counter(kind for token in expanded for kind, media_id in self.media_token_ids.items() if media_id is not None and token == media_id) 462 return Encoded(self.tokenizer.decode(expanded, skip_special_tokens=False), ids, len(expanded), 463 {"content_parts": files, "prompt_token_ids": expanded}, dict(counts))
推測解碼是伺服器啟動時決定的(--speculative-config)。VLLM_URL 的伺服器由它自己決定,側欄的 MTP 開關不能按。vLLM (Docker) 在 GPU 上用 families.VLLM_SPECULATIVE 裡這個模型的設定(harness/families/__init__.py:39):Gemma 4 用它的草稿模型,Qwen3.5 用它自己 checkpoint 裡的 MTP 層(transformers 用不了);預設開,切換開關就用另一種方式重新啟動伺服器(harness/backends/vllm_docker.py:116)。vLLM 0.32 的 CPU 版 image 兩個模型都不能推測解碼,所以在 CPU 上開關不能按。伺服器加上 --per-request-spec-decode-metrics summary(要和 --speculative-config 一起用)時,最後一段回應帶著這一步的統計,變成 step 卡片上的「草稿收下幾個、forward 幾次」。設成 detailed 還有每一次驗證收下幾個猜測:第一個 token 來自 prefill,之後每次驗證是收下的猜測,再加一個主模型自己的 token;收下的比猜的少,那個 token 就取代了被退回的猜測。伺服器不記沒有猜測的那幾步,所以只有這些數字剛好對得上每個 token 時,才標得出哪些是猜中的;對不上就只顯示統計。對得上時,收下的猜測只算到停止 token 為止:vLLM 是在停止 token 截斷那次驗證之前算的,停止 token 後面的猜測也會算進去。這要等這一步結束才知道,所以底線在 step 結束時才出現(app/render.py:354),而被退回的猜測原本是什麼,伺服器不說。
243def drafted_tokens(accepted: list[int], drafted: list[int], n_tokens: int) -> dict | None: 244 """Which of a step's tokens were guesses the model kept (kept_at) and which replace a guess it turned down 245 (turned_down_at), from vLLM's per-step counts (--per-request-spec-decode-metrics detailed). The first token comes 246 from the prefill; after it, each verify step gives the guesses it kept and then one token of the model's own, 247 which replaces the next guess when it kept fewer than were drafted. vLLM does not count a step without guesses, 248 so when the steps do not account for every token, nobody can tell which tokens were guessed: None.""" 249 kept, turned_down, at = [], [], 1 250 for n_kept, n_drafted in zip(accepted, drafted): 251 kept += range(at, at + n_kept) 252 if n_kept < n_drafted: 253 turned_down.append(at + n_kept) 254 at += n_kept + 1 255 last = accepted[-1] + 1 if accepted else 1 256 if not at - last < n_tokens <= at: # only a stop token or the token limit may cut the last step short 257 return None 258 return {"kept_at": [i for i in kept if i < n_tokens], "turned_down_at": [i for i in turned_down if i < n_tokens]}
畫面上還有這些不同:
- prefill 是等到第一個 token 的時間,包含網路。vLLM 會留著和上一個 prompt 相同開頭的 KV cache(prefix caching),第 2 步開始只讀和上一步不一樣的部分:chat template 重新寫過的模型回合,加上新的觀察;
LocalLLM每一步都從頭讀完整個 prompt(第 8 段「模型:prefill,然後一次一個 token」)。 - greedy 的輸出不一定和
generate()逐 token 相同:vLLM 會把請求合成一批跑,也有自己的 kernel。和generate()完全一樣,是LocalLLM才有的保證。 - 模型分頁寫出伺服器和它的版本,不寫裝置、精度和參數量;checkpoint 讀得懂但伺服器不收的媒體,連同原因列在那裡(
app/render.py:1038),輸入框上方也說哪些不能附上。模型資料夾把權重列成「只在伺服器上」(第 3 段「模型資料夾:models/ 裡的每個檔案」)。
要接 SGLang、Ollama 之類的另一層,就是再寫一個有這些方法和屬性的類別,送出一樣的事件,在 load_llm() 多一個選擇;TOKEN_DETAILS 裡它知道的才送。tests/test_vllm_backend.py 裡的假伺服器可以當寫測試的樣板。
Harness
Harness:把輸出拆成思考、工具呼叫、回覆
模型只會吐文字;哪段是工具呼叫,要 harness 用 family 的 parser 讀出來。
chat template 有時會在 prompt 結尾先打開思考(family 的 thought_prefix);這段在 prompt 裡、不在 raw 裡,所以先補回來,再交給 family 的 parse,s.lang 是它寫錯誤訊息用的語言。關掉「使用工具」時,就算模型寫了工具呼叫也當作沒有。
144prefix = family.thought_prefix if prompt.endswith(family.thought_prefix) else "" 145parsed = family.parse(prefix + raw, schemas, s.lang) 146calls = parsed["tool_calls"] if tools else [] 147self.emit("parsed", step=step, thinking=parsed["thinking"], content=parsed["content"], tool_calls=calls)
兩個 parser 回傳一樣的結構:thinking、tool_calls(每個有 name、arguments、error)、content,所以後面的 harness 不用管是哪個模型。
Gemma 4:(1) 切出 <|channel>thought 到 <channel|> 之間的思考;(2) 用正規表達式找出每一個 <|tool_call>call:名稱{…}<tool_call|>(harness/families/gemma4.py:18),參數交給小 parser;(3) 有開頭、沒有結尾的工具呼叫(例如被 token 上限截斷)記成格式錯誤。剩下的文字去掉停止 token,就是給使用者的回覆。
32def parse_output(raw: str, schemas: dict | None = None, lang: str = "zh") -> dict: 33 """Split one model turn into thinking, tool calls and visible content. Gemma marks each value's 34 type itself, so the tool schemas are not needed. Errors are worded in `lang`.""" 35 text = raw 36 thinking = None 37 if THOUGHT_OPEN in text: 38 before, _, rest = text.partition(THOUGHT_OPEN) 39 thought, closed, after = rest.partition(THOUGHT_CLOSE) 40 thinking = thought.strip() 41 text = before + (after if closed else "") 42 43 calls = [] 44 for m in CALL_RE.finditer(text): 45 call = {"name": m.group(1), "raw_args": m.group(2), "arguments": None, "error": None} 46 try: 47 call["arguments"] = parse_args(m.group(2)) 48 except ParseError as e: 49 call["error"] = e.text[lang] 50 calls.append(call) 51 52 rest = CALL_RE.sub("", text) 53 if CALL_OPEN in rest: 54 head, _, tail = rest.partition(CALL_OPEN) 55 name = re.match(r"call:(\w+)", tail) 56 calls.append({ 57 "name": name.group(1) if name else "?", 58 "raw_args": tail, 59 "arguments": None, 60 "error": pick(lang, "工具呼叫沒有結束標記 <tool_call|>", "The tool call has no closing <tool_call|>"), 61 }) 62 rest = head 63 64 for marker in END_MARKERS: 65 rest = rest.replace(marker, "") 66 return {"thinking": thinking, "tool_calls": calls, "content": rest.strip()}
Gemma 的參數不是 JSON:key 不加引號,字串用 <|"|> 這個特殊 token 包起來,例如 {timezone:<|"|>Asia/Taipei<|"|>}。_Parser 是遞迴下降 parser:看到 <|"|> 就一路讀到下一個 <|"|>,{ 讀物件、[ 讀陣列,其他的當作數字、true / false 或 null。
99def value(self): 100 self.skip_ws() 101 if self.text.startswith(STR, self.pos): 102 return self.delimited() 103 ch = self.peek() 104 if ch == "{": 105 return self.obj() 106 if ch == "[": 107 return self.arr() 108 if ch == '"': 109 return self.json_string() 110 return self.scalar()
Qwen3.5:思考在 </think> 之前;每個 <tool_call> 區塊裡找 <function=名稱> 和一段段 <parameter=名稱>。值都是純文字,所以要照工具的 schema 轉型別:schema 說是字串就保持原樣,說是數字、陣列或物件就用 JSON 讀,說是布林就看是不是 true / false;轉不過去就留著文字,讓下一關的 schema 驗證把型別錯誤回報給模型。
31def parse_output(raw: str, schemas: dict | None = None, lang: str = "zh") -> dict: 32 """Split one model turn into thinking, tool calls and visible content. `schemas` maps a tool 33 name to its parameters schema, which says what type each plain-text value is. Errors are worded in `lang`.""" 34 text = raw 35 thinking = None 36 if THINK_CLOSE in text: 37 thought, _, text = text.partition(THINK_CLOSE) 38 thinking = thought.split(THINK_OPEN)[-1].strip() 39 elif THINK_OPEN in text: # cut off while still thinking 40 text, _, thought = text.partition(THINK_OPEN) 41 thinking = thought.strip() 42 43 calls = [parse_call(m.group(1), schemas or {}, lang) for m in CALL_RE.finditer(text)] 44 rest = CALL_RE.sub("", text) 45 if CALL_OPEN in rest: 46 rest, _, tail = rest.partition(CALL_OPEN) 47 call = parse_call(tail, schemas or {}, lang) 48 call["arguments"], call["error"] = None, pick(lang, "工具呼叫沒有結束標記 </tool_call>", "The tool call has no closing </tool_call>") 49 calls.append(call) 50 51 for marker in END_MARKERS: 52 rest = rest.replace(marker, "") 53 return {"thinking": thinking, "tool_calls": calls, "content": rest.strip()}
76def convert(value: str, kind: str | None): 77 """Turn a plain-text value into the schema's type. If it does not fit, keep the text and let 78 the schema check report the wrong type to the model.""" 79 if kind in (None, "string"): 80 return value 81 if kind == "boolean": 82 word = value.strip().lower() 83 return word == "true" if word in ("true", "false") else value 84 try: 85 return json.loads(value) # integer, number, object, array 86 except ValueError: 87 return value
停止
要不要停:由 harness 決定
模型只是停止生成;這一輪要不要結束,是 harness 看完輸出才決定的。
解析完先檢查兩種馬上結束的情況:你按了停止;或這一步沒有任何工具呼叫。沒有工具呼叫,回覆就是最終答案,停止原因是 no_tool_call;如果是被 token 上限截斷而沒有工具呼叫,停止原因記成 max_new_tokens。兩種情況都把回覆存成 assistant 訊息,下一輪會當成歷史。
這裡看的是「解析出來有沒有工具呼叫」,不是「停在哪個 token」。Gemma 停在 <|tool_response> 就代表要用工具,但 Qwen 不管回答完或要用工具都停在 <|im_end|>;用解析結果判斷,兩個家族才能共用同一段程式。
149if done["finish"] == "user_stopped": 150 stop_reason, final = "user_stopped", parsed["content"] 151 self.messages.append({"role": "assistant", "content": final}) 152 break 153if not calls: 154 stop_reason = "max_new_tokens" if done["finish"] == "max_new_tokens" else "no_tool_call" 155 final = parsed["content"] 156 self.messages.append({"role": "assistant", "content": final}) 157 break
其他停止條件分散在迴圈各處:
- for 迴圈跑完所有步數都沒 break →
max_steps(第 5 段「Run 準備:訊息、工具、system prompt」設定的預設值)。 - 等待確認時被停止或逾時 →
user_stopped或approval_timeout(harness/agent.py:187)。 - 連續
MAX_FORMAT_ERRORS(3)次寫出無法解析的工具呼叫 →format_errors。 - 模型或工具丟出任何例外 →
error,錯誤訊息會顯示在畫面上,而不是讓 thread 默默死掉;backend 的錯誤(例如 vLLM 伺服器的)兩種語言都有,畫面顯示目前語言的那一個。
run_end,帶著停止原因、最終回覆和整份訊息列表。189 if format_errors >= MAX_FORMAT_ERRORS: 190 stop_reason = "format_errors" 191 break 192except Exception as e: # surface model or tool crashes in the UI instead of a silent hang 193 # A backend's error says what went wrong in both languages (vllm_backend.ServerError), and the page shows 194 # the one it is in; other errors are Python's own, in English. 195 both = getattr(e, "text", None) 196 texts = {"text": {lang: f"{type(e).__name__}: {t}" for lang, t in both.items()}} if isinstance(both, dict) else {} 197 self.emit("error", message=f"{type(e).__name__}: {e}", **texts) 198 stop_reason = "error" 199 200self.emit("run_end", stop_reason=stop_reason, final=final, messages=self.messages)
Harness
把工具呼叫寫回對話
工具呼叫先變成一則 assistant 訊息,下一步由 chat template 寫回模型自己的格式。
有工具呼叫時,harness 給每個呼叫一個 id(call_{step}_{i}),寫成一則 assistant 訊息,格式和 OpenAI / Hugging Face 的 tool_calls 一樣;下一步 chat template 會把它寫回 Gemma 的 <|tool_call>…<tool_call|> 或 Qwen 的 <tool_call>…</tool_call>。
兩個家族在這裡有兩點不同,都由 family 決定:模型在工具呼叫前寫的文字,Qwen 的模板會保留,Gemma 的模板會把它挪到工具結果後面並關掉回合,所以 Gemma 丟掉這段文字(text_with_calls);思考內容要存在哪個欄位,模板才讀得到,Gemma 是 reasoning、Qwen 是 reasoning_content(reasoning_key)。
接著先 render 一次「加上這則 assistant 訊息、但還沒有工具結果」的字串,存成 prev_sequence;generation_prompt=False,因為這時還輪不到模型寫。下一步的 prompt 和它比,多出來的就是工具結果。直接拿「上一步 prompt + 模型輸出」來比會不準,因為模板重新 render 時會改寫模型那一段(排序參數、換掉思考的標記)。
159ids = [f"call_{step}_{i}" for i in range(len(calls))] 160assistant = { 161 "role": "assistant", 162 # Some templates cannot keep text in front of a tool call (see Family.text_with_calls). 163 "content": parsed["content"] if family.text_with_calls else "", 164 "tool_calls": [ 165 {"id": cid, "type": "function", "function": {"name": c["name"], "arguments": c["arguments"] or {}}} 166 for cid, c in zip(ids, calls) 167 ], 168} 169if parsed["thinking"]: 170 assistant[family.reasoning_key] = parsed["thinking"] 171self.messages.append(assistant) 172# The template rewrites the model's turn when it re-renders history (sorted arguments, 173# its own thought markup), so compare the next prompt with this rendering, not prompt + raw. 174prev_sequence = self.llm.render([system, *self.messages], tools, s.thinking, generation_prompt=False)
Harness
驗證與權限:模型從不直接碰環境
每個呼叫要過三關:格式和 schema、重複檢查、(開著時)你的確認。
每個呼叫交給 _handle_call()。第一關是驗證:解析失敗、沒有這個工具(這一輪沒提供的也算,例如沒打開「讓模型寫程式」時的 run_python)、參數不符合 schema,都不會執行。
202def _handle_call(self, step: int, cid: str, call: dict, seen: list) -> str: 203 """Validate, approve and execute one tool call. Returns the observation text for the model.""" 204 lang = self.settings.lang 205 tool = self.tools.get(call["name"]) 206 args, errors = call["arguments"] or {}, [] 207 if call["error"]: 208 errors = [pick(lang, f"無法解析工具呼叫:{call['error']}", f"Could not parse the tool call: {call['error']}")] 209 elif tool is None: 210 known = ", ".join(self.tools) 211 errors = [pick(lang, f"沒有 {call['name']} 這個工具,可用的工具是 {known}", f"There is no tool {call['name']}; the tools are {known}")] 212 else: 213 args, errors = tool.validate(args, lang)
Tool.validate() 檢查必要參數有沒有給、有沒有多出不認得的參數、型別和 enum 對不對,然後補上預設值。錯誤訊息是完整的句子,用這一輪開始時頁面的語言寫,因為它會原封不動變成模型下一步讀到的觀察。
89def validate(self, args: dict, lang: str = "zh") -> tuple[dict, list[str]]: 90 """Check arguments against the schema; return them with defaults filled in, plus any errors in `lang`.""" 91 props = self.parameters.get("properties", {}) 92 errors = [pick(lang, f"缺少必要參數 {k}", f"Missing required argument {k}") for k in self.parameters.get("required", []) if k not in args] 93 for key, value in args.items(): 94 spec = props.get(key) 95 if spec is None: 96 errors.append(pick(lang, f"沒有 {key} 這個參數", f"There is no argument {key}")) 97 elif not TYPE_CHECKS[spec["type"]](value): 98 got = type(value).__name__ 99 errors.append(pick(lang, f"{key} 應該是 {spec['type']},收到 {got}", f"{key} should be {spec['type']}, got {got}")) 100 elif "enum" in spec and value not in spec["enum"]: 101 errors.append(pick(lang, f"{key} 只能是 {spec['enum']}", f"{key} must be one of {spec['enum']}")) 102 filled = {k: spec["default"] for k, spec in props.items() if "default" in spec} 103 filled.update(args) 104 return filled, errors
第二關記錄重複:把「工具名稱 + 排序後的參數 JSON」當成 key,跟這一輪之前的呼叫比;一模一樣就在 step 卡片標出「迴圈可能在打轉」。這只是提醒,不會擋。然後送出 tool_call 事件;有錯誤就直接把錯誤包成觀察回報,不進環境。
215key = (call["name"], json.dumps(args, sort_keys=True, ensure_ascii=False)) 216repeated = key in seen 217seen.append(key) 218self.emit( 219 "tool_call", 220 step=step, 221 call_id=cid, 222 name=call["name"], 223 arguments=args, 224 raw_args=call["raw_args"], 225 errors=errors, 226 repeated=repeated, 227 environment=tool.environment if tool else None, 228) 229if errors: 230 return self._observe(step, cid, call["name"], {"error": "; ".join(errors)}, ok=False, ms=0)
第三關是你。開著「執行工具前先問我」時,harness 送出 approval_request,然後停在 _wait_for_decision() 等。拒絕、停止、逾時都會變成一則錯誤觀察,模型下一步只看得到這段文字。
232if self.settings.approve_tools: 233 self.emit("approval_request", step=step, call_id=cid, name=call["name"], arguments=args) 234 approved = self._wait_for_decision(cid) 235 # Nobody answered when the wait timed out or the run was stopped; only then is it not a rejection. 236 stopped = cid not in self._decisions and not self._timed_out 237 self.emit("approval", step=step, call_id=cid, approved=approved, timed_out=self._timed_out, stopped=stopped) 238 if not approved: 239 if self._timed_out: 240 error = pick(lang, "等待使用者確認逾時,這個工具呼叫沒有執行", "No one confirmed the tool call in time, so it did not run") 241 elif stopped: 242 error = pick(lang, "使用者停止了這次執行,這個工具呼叫沒有執行", "The user stopped this run, so the tool call did not run") 243 else: 244 error = pick(lang, "使用者拒絕執行這個工具呼叫", "The user refused to run this tool call") 245 return self._observe(step, cid, call["name"], {"error": error}, ok=False, ms=0)
等待用的是 threading.Condition:每 0.5 秒醒來一次,直到你按下按鈕(decide() 會 notify_all)、按了停止,或超過 APPROVAL_TIMEOUT(harness/agent.py:33,5 分鐘)。逾時就當作拒絕並停止整輪,免得一個關掉的分頁把模型佔住。
267def _wait_for_decision(self, cid: str) -> bool: 268 deadline = time.monotonic() + APPROVAL_TIMEOUT 269 with self._decided: 270 while cid not in self._decisions and not self._stop.is_set(): 271 left = deadline - time.monotonic() 272 if left <= 0: # treat as rejected and end the run 273 self._timed_out = True 274 self._stop.set() 275 break 276 self._decided.wait(timeout=min(left, 0.5)) 277 return self._decisions.get(cid, False)
畫面這邊,等待中的呼叫會在聊天區顯示「允許/拒絕」按鈕,按下去就呼叫 run.run.decide();像 run_python 的程式碼這種好幾行的參數,另外用程式碼區塊顯示,看得清楚再決定。「先問我」寫在 harness 的程式裡,而不是寫在 prompt 裡請模型遵守,所以模型沒有辦法繞過。
671for call in render.pending_approvals(data): 672 with st.container(border=True): 673 # An argument over several lines (code) is shown below the call rather than squeezed into its JSON. 674 blocks = {k: v for k, v in call["arguments"].items() if isinstance(v, str) and "\n" in v.strip()} 675 args = json.dumps({k: "…" if k in blocks else v for k, v in call["arguments"].items()}, ensure_ascii=False) 676 st.markdown(tr("**Agent 想執行這個工具,要允許嗎?**", "**The agent wants to run this tool. Allow it?**") + f" \n`{call['name']}({args})`") 677 for code in blocks.values(): 678 st.code(code) 679 allow, reject = st.columns(2) 680 allow.button(tr("允許", "Allow"), key=f"allow-{i}-{call['call_id']}", type="primary", width="stretch", 681 on_click=run.run.decide, args=(call["call_id"], True)) 682 reject.button(tr("拒絕", "Reject"), key=f"reject-{i}-{call['call_id']}", width="stretch", 683 on_click=run.run.decide, args=(call["call_id"], False))
環境
環境:真的去執行工具
通過三關才會呼叫 Python 函式;錯誤也會變成觀察,不會讓迴圈當掉。
送出 tool_start 後呼叫工具的 Python 函式;takes_files 的工具(run_python)另外拿到這段對話的資料夾。工具自己丟的 ToolError 是可以預期的錯誤(時區打錯、找不到頁面、程式出錯),訊息用這一輪的語言直接給模型看;其他例外也會被接住,變成 {"error": …}。
247self.emit("tool_start", step=step, call_id=cid, name=call["name"], environment=tool.environment) 248start = time.perf_counter() 249try: 250 result, ok = tool.fn(**args, **({"files": self.files} if tool.takes_files else {})), True 251except ToolError as e: 252 result, ok = {"error": e.text[lang]}, False 253except Exception as e: 254 result, ok = {"error": f"{type(e).__name__}: {e}"}, False 255return self._observe(step, cid, call["name"], result, ok=ok, ms=(time.perf_counter() - start) * 1000)
五個工具各自連到不同的環境:
get_current_time:讀系統時鐘,用 IANA 時區名稱換算(harness/tools.py:179)。calculator:在 AST 白名單上計算,從不呼叫eval()。只允許數字、四則運算、次方和少數數學函式;算次方前先估計位數,round的位數參數也有上限(它在內部也會算次方),避免101010這種算式把 thread 卡死。search_wikipedia/read_wikipedia:用 httpx 呼叫 Wikipedia API(harness/tools.py:197),和 ReAct 論文在 HotpotQA 上用的 search / read 是同一組動作。run_python:在 Docker 容器裡跑模型寫的程式,把印出來的東西和畫出來的圖傳回來(harness/tools.py:305)。
ToolError。128def _eval(node): 129 if isinstance(node, ast.Expression): 130 return _eval(node.body) 131 if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool): 132 return node.value 133 if isinstance(node, ast.Name) and node.id in CONSTS: 134 return CONSTS[node.id] 135 if isinstance(node, ast.BinOp) and type(node.op) in BIN_OPS: 136 left, right = _eval(node.left), _eval(node.right) 137 # Check the size before computing: ((10**999)**999)**999 would otherwise hang. 138 if isinstance(node.op, ast.Pow) and right > 0 and _digits(left) * right > MAX_DIGITS: 139 raise ToolError(f"結果超過 {MAX_DIGITS} 位數", f"The result has more than {MAX_DIGITS} digits") 140 return BIN_OPS[type(node.op)](left, right) 141 if isinstance(node, ast.UnaryOp) and type(node.op) in UNARY_OPS: 142 return UNARY_OPS[type(node.op)](_eval(node.operand)) 143 if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in FUNCS and not node.keywords: 144 args = [_eval(a) for a in node.args] 145 if node.func.id == "factorial" and args and args[0] > 500: 146 raise ToolError("factorial 的參數太大", "The argument of factorial is too large") 147 # round(5, -10**9) computes 10 ** 10**9 inside int.__round__, where the check on powers above cannot see it. 148 if node.func.id == "round" and len(args) > 1 and abs(args[1]) > MAX_DIGITS: 149 raise ToolError("round 的位數參數太大", "The digits argument of round is too large") 150 return FUNCS[node.func.id](*args) 151 raise ToolError(f"不支援的運算:{ast.unparse(node)}", f"Unsupported operation: {ast.unparse(node)}")
calculator 只算得了一行算式;run_python 則讓模型寫完整的程式,所以環境要關得更緊。每次呼叫都從 harness/sandbox/Dockerfile 建的 image(Python 3.12 加上 numpy、pandas,讀 Excel 的 openpyxl、印表格的 tabulate,還有畫圖的 matplotlib 和一套中文字型)開一個新容器:沒有網路,檔案系統唯讀、只有 /tmp 能寫,記憶體 512 MB、一顆 CPU、最多 64 個 process,拿掉所有 Linux capability,用你自己的使用者執行、絕不用 root(harness/tools.py:233:在 Linux 上,掛進容器的資料夾保留原本的權限,換成別的使用者可能進不去);這段對話的資料夾唯讀掛在 /data。程式從 stdin 送進去,跑完容器就刪掉。
242def sandbox_command(name: str, files: Path | None) -> list[str]: 243 """docker run for one piece of code, read from stdin. The container has no network, a read-only file system 244 except a small /tmp, a cap on memory, CPU and processes, no Linux capabilities, the host's own user (never root), 245 and the conversation's files read-only at /data; --rm deletes it afterwards. Its charts come out on stdout, 246 after FIGURES_MARK.""" 247 cmd = [ 248 "docker", "run", "--rm", "-i", "--name", name, "--network", "none", 249 "--memory", "512m", "--memory-swap", "512m", "--cpus", "1", "--pids-limit", "64", 250 "--read-only", "--tmpfs", "/tmp:rw,size=64m", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", 251 "--user", sandbox_user(), "-e", "HOME=/tmp", "-e", f"AGENT_LOOP_FIGURES={FIGURES_MARK}", "-w", "/tmp", 252 ] 253 if files is not None: 254 cmd += ["-v", f"{Path(files).resolve()}:/data:ro"] 255 return cmd + [SANDBOX_IMAGE, "python", "-I", "-"]
容器寫不進任何頁面讀得到的地方,所以圖跟著印出來的東西從 stdout 出來:image 裡 matplotlib 的 backend(harness/sandbox/agent_loop_figures.py)在 plt.show() 或程式結束時把每張圖轉成 PNG,程式結束前接在印出來的東西後面:先一行 run_python 用 AGENT_LOOP_FIGURES 傳進去的記號,再每行一張 base64 的 PNG。run_python 把它們從模型要讀的輸出切掉,存進這段對話的資料夾,叫 figure-1.png、figure-2.png…(harness/tools.py:282),回傳一個 ToolResult:模型讀到的是 {"stdout": …, "figures": ["/data/figure-1.png"]},圖本身只給頁面,畫在聊天區和這一步的卡片上。
跑超過 30 秒就把容器停掉;結束代碼不是 0(例如丟出例外)、記憶體用太多或什麼都沒印也沒畫,都當成 ToolError,模型下一步讀到 traceback 或提醒,可以改了再跑。Docker 不是必要的:只有側欄打開「讓模型寫程式」時才會提供 run_python,頁面也是那時候才去找 Docker(harness/tools.py:258),沒裝、沒啟動、還沒建 image,或 image 比 harness/sandbox/Dockerfile 舊(看它的 agent-loop-sandbox.version label),就在側欄說明怎麼做,這一輪也不把 run_python 給模型。
脈絡
觀察接回脈絡,進下一圈
工具結果變成一則 tool 訊息;下一圈 render 時,它就出現在模型的工具呼叫後面。
工具結果(或錯誤)在 _observe() 裡轉成 JSON 字串(帶著圖的 ToolResult 先拆開:模型只讀它的資料,圖只放進事件給頁面),超過 MAX_RESULT_CHARS(2000 字元)就截斷並加上 …(truncated),免得一個長網頁塞爆脈絡;送出 tool_result 事件,再把字串回傳。
257def _observe(self, step: int, cid: str, name: str, result: dict | ToolResult, ok: bool, ms: float) -> str: 258 # The model reads a ToolResult's data; its images (run_python's charts) are only for the page to show. 259 result, images = (result.data, result.images) if isinstance(result, ToolResult) else (result, []) 260 text = json.dumps(result, ensure_ascii=False) 261 truncated = len(text) > MAX_RESULT_CHARS 262 if truncated: 263 text = text[:MAX_RESULT_CHARS] + "…(truncated)" 264 self.emit("tool_result", step=step, call_id=cid, name=name, ok=ok, result=text, truncated=truncated, ms=round(ms, 1), images=images) 265 return text
回傳的字串變成一則 role: tool 的訊息 append 到 self.messages。同時更新連續格式錯誤的次數:這次是解析錯誤就加一,正常就歸零。
176for cid, call in zip(ids, calls): 177 result = self._handle_call(step, cid, call, seen) 178 if call["error"]: 179 format_errors += 1 180 else: 181 format_errors = 0 182 self.messages.append({"role": "tool", "tool_call_id": cid, "name": call["name"], "content": result}) 183 if self._stop.is_set(): 184 break 185 186if self._stop.is_set(): 187 stop_reason = "approval_timeout" if self._timed_out else "user_stopped" 188 break 189if format_errors >= MAX_FORMAT_ERRORS: 190 stop_reason = "format_errors" 191 break
然後回到 for 迴圈開頭,下一圈 render 時 chat template 把工具結果寫進脈絡,這就是 step 卡片上「上一步的觀察接回脈絡」那一段:
- Gemma 4:
<|tool_response>response:名稱{value:<|"|>…<|"|>}<tool_response|>,緊接在模型的工具呼叫後面,還在同一個 model turn 裡,模型讀到結果後接著寫。 - Qwen3.5:
<|im_start|>user加上<tool_response>…</tool_response>,結果算是一則 user 訊息;後面再開一個新的<|im_start|>assistant,模型從新的回合開始寫。
不管哪一種,模型下一步可能再呼叫一次工具,也可能直接回答。
畫面
畫面:事件怎麼變成你看到的東西
背景 thread 只管 append 事件;畫面每 0.5 秒把整張列表重畫一次。
workspace 是一個 fragment:有 agent 在跑時,它每 0.5 秒自己重跑一次,只重畫聊天和右邊的檢視,不用整頁重跑。跑完的那一刻再整頁重跑一次,然後停止輪詢。Streamlit 會留著這個 fragment,好讓它自己重跑,所以它只用弱參照拿著模型:換模型或換「用什麼跑模型」時,release_model() 才放得掉它。
801@st.fragment(run_every=POLL_SECONDS if polling else None) 802def workspace(): 803 if (llm := model()) is None: # let go meanwhile: the full rerun that follows draws the next one 804 st.rerun() 805 absorb_finished() 806 live_run = next((run for run in st.session_state.runs if run.live), None) 807 other = other_tab_run() 808 if polling and live_run is None and other is None: 809 st.rerun() # the run just ended: redraw everything once and stop polling 810 with st.container(key="workspace"): 811 chat, viz = st.columns([5, 7], gap="large") 812 with chat: 813 chat_panel(llm, live_run, other) 814 with viz: 815 viz_panel(llm)
每次重畫,LiveRun.data() 複製一份目前的事件列表交給 render.build(),按順序折成一個個 step。run_start 告訴它這一輪是哪個模型、哪個 family,之後畫特殊 token、停止 token 和使用者訊息的位置都問這個 family;step_start 開一個新的 step,context、prefill、token、parsed 填進去,model_end 帶來的推測解碼結果標出哪些 token 是猜中的(伺服器到這時才說,第 11 段「換一層跑模型:Backend 介面、本機 transformers 和 vLLM」),工具相關的事件依 call_id 歸到各自的呼叫。之後所有 HTML 都是這個結構(和頁面語言)的純函式,所以執行到一半的任何時刻都畫得出來;切換語言也只是用另一種語言重畫同一份事件,跑完的對話也會跟著換。
306def build(events: list[dict]) -> dict: 307 data = {"model": None, "family": None, "settings": {}, "steps": [], "end": None, "error": None, "last": None, "t": 0} 308 for ev in events: 309 kind = ev["type"] 310 data["last"], data["t"] = kind, ev["t"] 311 if kind == "run_start": 312 data["model"], data["family"] = ev["model"], families.get(ev["family"]) 313 data["settings"] = ev["settings"] 314 elif kind == "step_start": 315 data["steps"].append({ 316 "n": ev["step"], "t0": ev["t"], "t1": ev["t"], "context": None, "prefill": None, 317 "tokens": [], "done": None, "parsed": None, "calls": {}, 318 }) 319 elif kind == "run_end": 320 data["end"] = ev 321 elif kind == "error": 322 data["error"] = ev.get("text") or ev["message"] 323 elif data["steps"]: 324 step = data["steps"][-1] 325 step["t1"] = ev["t"] 326 if kind == "token": 327 step["tokens"].append(ev) 328 elif kind in ("context", "prefill", "parsed"): 329 step[kind] = ev 330 elif kind == "model_end": 331 step["done"] = ev 332 step["tokens"] = with_drafts(step["tokens"], ev.get("mtp") or {}) 333 elif kind == "tool_call": 334 step["calls"][ev["call_id"]] = {**ev, "approval": None, "started": False, "result": None} 335 elif kind == "approval_request": 336 step["calls"][ev["call_id"]]["approval"] = "pending" 337 elif kind == "approval": 338 if ev["approved"]: 339 approval = "approved" 340 elif ev.get("timed_out"): 341 approval = "timeout" 342 elif ev.get("stopped"): 343 approval = "stopped" 344 else: 345 approval = "rejected" 346 step["calls"][ev["call_id"]]["approval"] = approval 347 elif kind == "tool_start": 348 step["calls"][ev["call_id"]]["started"] = True 349 elif kind == "tool_result": 350 step["calls"][ev["call_id"]]["result"] = ev 351 return data
迴圈圖哪一格亮、哪條箭頭在流動,由最後一個事件決定:
506state["active"], state["edge"] = { 507 "step_start": ("context", "env-context" if step and step["n"] > 1 else None), 508 "context": ("context", "context-model"), 509 "prefill": ("model", "context-model"), 510 "token": ("model", None), 511 "model_end": ("harness", "model-harness"), 512 "parsed": ("harness", "model-harness"), 513 "tool_call": ("harness", None), 514 "approval_request": ("harness", None), 515 "approval": ("harness", None), 516 "tool_start": ("env", "harness-env"), 517 "tool_result": ("env", "env-context") if started else ("harness", None), 518 "run_end": ("stop", "harness-stop"), 519 "error": ("stop", None), 520}.get(last, (None, None))
一輪結束後,absorb_finished() 把 run_end 帶回來的完整訊息列表存成 history。下一個問題就接在它後面,所以 agent 記得前面聊過什麼,包括之前的工具結果。
348def absorb_finished(): 349 """When a run ends, its full message list becomes the history for the next turn.""" 350 for run in st.session_state.runs: 351 if not run.live and not run.absorbed: 352 end = next((e for e in reversed(run.events) if e["type"] == "run_end"), None) 353 if end: 354 st.session_state.history = end["messages"] 355 run.absorbed = True
停止按鈕呼叫 Run.stop():設定 _stop 並叫醒正在等確認的 thread。解碼迴圈每個 token 前都會檢查 _stop,所以按下去之後最多再生成一個 token 就停。
83def stop(self): 84 self._stop.set() 85 with self._decided: 86 self._decided.notify_all() 87 88def decide(self, call_id: str, approved: bool): 89 with self._decided: 90 self._decisions[call_id] = approved 91 self._decided.notify_all()
參考
事件一覽
harness 每走到一個階段就 append 一個事件;畫面上的一切都是從這張列表畫出來的。依一輪裡出現的順序排列。
| 事件 | 在哪裡送出 | 帶了什麼 | 畫面上的用途 |
|---|---|---|---|
run_start | harness/agent.py:105 | 模型和 family、這一輪的設定、可用的工具 | 用哪個模型的格式畫這一輪;步數上限、解碼說明 |
step_start | harness/agent.py:116 | step 編號 | 開一張新的 step 卡片;迴圈圖亮起「脈絡」 |
context | harness/agent.py:123 | 模型讀到的完整 prompt(媒體已展開)、token 數、added_from、每種媒體佔的 token 數、脈絡上限、各角色訊息數 | step 卡片的脈絡段、「脈絡」分頁 |
prefill | harness/backends/simple_backend.py:301 | prompt 的 token 數、prefill 耗時 | 迴圈圖「模型」格、模型分頁的 prefill ms |
token | harness/backends/simple_backend.py:325 | id、piece、文字、channel、是否為特殊 token、機率、排名、前 5 名候選;開 MTP 時還有 draft(草稿猜了什麼、有沒有收下)。機率以後的欄位要 backend 知道才有:simple_backend.py 全都有,vLLM 伺服器有機率和前 5 名,其他看它加了哪些參數 | token 的熱度顏色、底線與浮窗、左邊逐字出現的回覆 |
model_end | harness/agent.py:141 | 停止原因、停止 token、token 數、tok/s、MTP 統計(vLLM 伺服器還可能說哪些 token 是猜中的)、原始輸出 | 「停在 …」、tok/s、MTP 收下幾個、模型分頁的表格 |
parsed | harness/agent.py:147 | 思考、回覆、工具呼叫 | Harness 段開始出現 |
tool_call | harness/agent.py:219 | 名稱、驗證後的參數、錯誤、是否重複、環境 | Harness 段的呼叫、驗證結果、重複警告 |
approval_request | harness/agent.py:233 | 等待確認的呼叫 | 聊天區的「允許/拒絕」按鈕;迴圈圖閃爍 |
approval | harness/agent.py:237 | 允許、拒絕、逾時或被停止 | Harness 段顯示你的決定 |
tool_start | harness/agent.py:247 | 工具名稱、環境 | 迴圈圖亮起「環境」 |
tool_result | harness/agent.py:264 | 結果字串、成功與否、是否截斷、耗時、給你看的圖 | 環境段的結果、聊天區的工具標籤;圖畫在兩邊 |
error | harness/agent.py:197 | 例外的類型和訊息;backend 的錯誤兩種語言都有(text) | 聊天區和停止卡片的錯誤訊息 |
run_end | harness/agent.py:200 | 停止原因、最終回覆、整份訊息列表 | 停止卡片;訊息列表成為下一輪的 history |