「支援多家 LLM」聽起來像是一個 adapter pattern 練習題。 直到你發現:Anthropic 沒有 mid-thread system message、 Gemini 的 thought signature 必須原樣送回、 OpenAI 的 GPT-5.6 在 Chat Completions 上不准 tools 搭配 reasoning、 而使用者可以在對話中途從 Claude 換到 Ollama 上的 Llama。
本篇是 OpenWorker 深度解析系列
的第四篇,處理 coworker/providers/(4,507 行)與 coworker/compaction.py(561 行)。
一、為什麼不能只用 LiteLLM
多 provider 抽象的市場已經很成熟(LiteLLM、aisuite 自己、OpenRouter)。 OpenWorker 仍然自己寫了一層,理由可以歸納成四點:
┌──────────────────────────────────────────────────────────────────────────┐
│ ① 需要「能力查詢」而不只是「呼叫代理」 │
│ provider.capabilities(model) → tools / vision / pdf / │
│ parallel_tool_calls / streaming │
│ → 使用者上傳 PDF,模型不支援時要在本地抽文字,而不是直接 400 │
├──────────────────────────────────────────────────────────────────────────┤
│ ② 需要保存「provider 私有欄位」跨輪次 │
│ Gemini thought signature、OpenAI Responses 的 encrypted reasoning │
│ → 純代理層會把這些欄位丟掉,導致思考鏈中斷 │
├──────────────────────────────────────────────────────────────────────────┤
│ ③ 需要正規化的 token 計數(含快取拆分) │
│ input / output / cache_read / cache_write │
│ → 這是 context 壓縮的觸發訊號,也是成本顯示的來源 │
├──────────────────────────────────────────────────────────────────────────┤
│ ④ 需要「單次呼叫、不含迴圈」的契約 │
│ 因為迴圈裡要塞權限檢查、中斷、審計 — 那是 runtime 的職責 │
└──────────────────────────────────────────────────────────────────────────┘
二、ProviderClient:一個刻意樸素的契約
1class ProviderClient(ABC):
2 """Single-shot, provider-agnostic completion interface.
3
4 Deliberately blocking (the turn engine wraps it in `asyncio.to_thread`) and
5 deliberately without a `max_turns` loop — the runtime owns the agent loop.
6 """
7
8 @abstractmethod
9 def complete(self, *, model, messages, tools=None, **settings) -> AssistantTurn: ...
10
11 @abstractmethod
12 def capabilities(self, model: str) -> ModelCapabilities: ...
13
14 def stream(self, *, model, messages, tools=None, **settings):
15 """Yield StreamChunks. Default: no token streaming — one final chunk with the
16 full turn. Providers that support streaming (OpenAIProvider) override this."""
17 yield StreamChunk(
18 turn=self.complete(model=model, messages=messages, tools=tools, **settings)
19 )
三個設計決定:
| 決定 | 理由 |
|---|---|
| 同步(blocking) | 非同步橋接集中在 TurnEngine._astream() 一處處理,不要求 7 家 provider 都正確實作 async 取消語意 |
沒有 max_turns | 迴圈是 runtime 的職責。Provider 只負責「一次往返」 |
stream() 有預設實作 | 不支援串流的 provider(或不想實作的)自動退化成「一次吐完」,不會壞掉 |
2.1 AssistantTurn:一次回應的完整形狀
1@dataclass
2class AssistantTurn:
3 text: Optional[str] = None
4 tool_calls: list[ToolCall] = field(default_factory=list)
5 finish_reason: Optional[str] = None
6 raw: Any = field(default=None, repr=False, compare=False)
7 # 模型的思考文字(DeepSeek reasoning_content、Gemini thought summaries…)
8 # 顯示專用:存在 assistant 訊息的 `reasoning` 側車,但送 provider 前一律剝掉
9 reasoning: Optional[str] = None
10 # Provider 私有側車(底線開頭 key,例如 `_gemini` thought signature)
11 # 契約:擁有它的 provider 會重新附加,其他 provider 必須剝掉或忽略
12 extras: dict[str, Any] = field(default_factory=dict)
13 # 正規化的 token 計數。provider 沒回報時是 None — 絕不猜測
14 usage: Optional[TokenUsage] = None
「never guessed」 是重要的紀律:如果 provider 沒回報 usage,
就讓它是 None,由上層決定用估算值。不要在資料層填假數字。
2.2 TokenUsage:把快取拆出來
1@dataclass
2class TokenUsage:
3 """Normalized token counts for one model round-trip.
4
5 `input` counts only fresh (uncached) prompt tokens; cached prompt tokens are
6 split into `cache_read`/`cache_write`. Providers that don't report a cache
7 split (Ollama, most compat vendors) leave the cache fields at 0. `output`
8 includes thinking tokens where the vendor bills them as output (Gemini).
9 """
10 input: int = 0
11 output: int = 0
12 cache_read: int = 0
13 cache_write: int = 0
14
15 @property
16 def context_tokens(self) -> int:
17 """Prompt-side total — what actually occupied the context window."""
18 return self.input + self.cache_read + self.cache_write
context_tokens 這個 property 是關鍵:壓縮觸發要看的是「佔了多少 context」,
而不是「花了多少錢」。快取讀取的 token 只收 ~0.1 倍費用,
但它們照樣佔滿 context window。
Anthropic 的映射:
1return TokenUsage(
2 input=int(getattr(usage, "input_tokens", 0) or 0), # 不含快取
3 output=int(getattr(usage, "output_tokens", 0) or 0),
4 cache_read=int(getattr(usage, "cache_read_input_tokens", 0) or 0),
5 cache_write=int(getattr(usage, "cache_creation_input_tokens", 0) or 0),
6)
三、ProviderRouter:一行前綴決定一切
1"""ProviderRouter — one `ProviderClient` that dispatches by the `provider:` prefix of a model
2string to a per-provider client, built lazily from its SecretStore profile and cached.
3
4This is the single provider the `SessionManager` hands to every engine, so `complete()/stream()`
5(which already receive the full model string per-call) route themselves: `ollama:llama3.3` →
6the Ollama client (Ollama's OpenAI-compatible `/v1`), bare `gpt-5.5` → the default (OpenAI).
7"""
model 字串 路由結果
────────────────────────────────────────────────────────────────
"gpt-5.6-sol" → 預設 provider(openai)
"anthropic:claude-fable-5" → Anthropic 原生 client
"ollama:llama3.3" → Ollama(OpenAI 相容 /v1)
"together:zai-org/GLM-5.2" → Together 轉售
"qwen2.5-coder:32b" → ★ 預設 provider — `qwen2.5-coder` 不是已知
provider 名稱,這個冒號是版本標籤
最後一列是這段程式碼裡最容易被忽略的細節:
1@staticmethod
2def _bare(model: str) -> str:
3 """Strip a KNOWN provider prefix; the underlying SDK wants the bare model name. A model
4 whose first segment isn't a provider (e.g. `qwen2.5-coder:32b` — a version tag, not a
5 prefix) is returned unchanged, so the colon isn't mistaken for a provider separator."""
6 if ":" in model:
7 prefix, rest = model.split(":", 1)
8 if get_descriptor(prefix) is not None: # ← 必須是「已註冊的 provider」
9 return rest
10 return model
用 ":" in model 當判準會炸掉所有 Ollama 的 tag 語法。
判準必須是「前綴是不是已註冊的 provider 名稱」。
3.1 Lazy build + invalidate
1def _client_for(self, model: str) -> ProviderClient:
2 name = self._provider_name(model)
3 with self._lock:
4 client = self._clients.get(name)
5 if client is None:
6 profile = {}
7 if self._secrets is not None:
8 profile = self._secrets.get(f"provider:{name}") or {}
9 client = build_provider_client(name, profile, self._secrets)
10 self._clients[name] = client
11 return client
12
13def invalidate(self, name: Optional[str] = None) -> None:
14 """Drop cached client(s) so the next call rebuilds with fresh config."""
使用者在 Settings 換金鑰、改 Ollama URL → 呼叫 invalidate() →
既有的引擎不用重建就能拿到新設定。這跟 Part 3 提到的
「roots 持有參照」是同一個模式:共享可變狀態,每次讀取。
3.2 六個註冊的 provider descriptor
openai · anthropic · gemini · bedrock · vertex · ollama
看起來只有六個,但實際支援的模型來源遠不止:openai descriptor
在沒有自訂 base_url 時建 OpenAIResponsesProvider,
有自訂 base_url 時建 Chat Completions 版的 OpenAIProvider
—— 於是 Azure、vLLM、以及所有 OpenAI 相容的廠商(DeepSeek、Kimi、Qwen、MiniMax、
Mistral、Grok、GLM、Together、Fireworks、OpenRouter)全部走這條路。
┌──────────────────────────────┐
provider="openai" │ 有 custom base_url? │
──────────▶ │ 否 → OpenAIResponsesProvider│ /v1/responses
│ 是 → OpenAIProvider │ /v1/chat/completions
└──────────────────────────────┘
▲
所有 OpenAI 相容廠商都從這裡進來
為什麼要分兩條?openai_responses.py 的註解說明了:
1"""OpenAI Responses provider — native OpenAI models via `/v1/responses`.
2
3Chat Completions rejects function tools combined with any `reasoning_effort` other than
4`none` on GPT-5.6+ ("use /v1/responses"), which had reasoning pinned OFF for native OpenAI
5models. This provider is the Responses path: reasoning + tools at real effort levels,
6streamed reasoning summaries, and chain-of-thought continuity across tool round-trips via
7`store: false` + `include: ["reasoning.encrypted_content"]` — nothing retained server-side.
8"""
store: false + encrypted_content 這個組合很符合 local-first 的哲學:
拿到加密的推理內容自己保管、下一輪原樣送回,伺服器端不留任何東西。
四、能力矩陣與優雅降級
4.1 兩層查詢
1def capabilities_for(model: str) -> ModelCapabilities:
2 # Curated models answer from the matrix (exact full-id match — including reseller ids
3 # like `together:zai-org/GLM-5.2`, whose names defeat the prefix heuristics below).
4 # Custom user-added models fall through to the heuristics, at their own risk.
5 entry = entry_for(model)
6 if entry is not None:
7 return entry.caps
8 ... # 啟發式判斷
① 精選矩陣(matrix.py)— 完整 id 精確匹配
"gpt-5.6-sol": ModelEntry("GPT-5.6 Sol · OpenAI", _AGENTIC_VISION, 400_000)
└ UI 標籤 └ 能力 └ context window
② 啟發式(capabilities.py)— 依 provider 前綴保守推測
ollama:* → tools=True, vision=False, parallel_tool_calls=False
bedrock:claude/* → 全開
bedrock:其他 → 保守
anthropic/gemini → tools+vision+pdf+parallel
矩陣的定位寫得很清楚:
1"""The curated model matrix — the only models we actively suggest, label, and vouch for.
2
3Deliberately SMALL (owner call, 2026-07-04): current-generation, agent-capable (tool-calling)
4models only. It is not user-editable — users can still add any custom model string, which
5falls back to the conservative heuristics in ``capabilities.py`` at their own risk of
6degraded results.
7"""
「Deliberately SMALL」+「not user-editable」+「at their own risk」 —— 這是很成熟的產品判斷。精選清單代表「我們驗證過」, 自訂模型代表「你自己負責」。中間沒有模糊地帶。
context_window 欄位有個誠實的處理:
1# Entries where the vendor spec wasn't re-checked stay ``None`` — the meter simply hides
2# rather than showing a made-up denominator.
沒驗證過就顯示 None,UI 直接隱藏進度條,不編一個分母出來。
4.2 降級發生在 outbound 轉換,不在歷史裡
回顧 Part 2 的 _outbound_messages():
1# PDF attachments (stored as `file` parts) are adapted to the ACTIVE model right
2# here — never in the persisted history — so a mid-session model switch always
3# re-decides.
4if any(p.get("type") == "file" for msg in out ... ):
5 caps = self.provider.capabilities(self.model)
6 if not getattr(caps, "pdf", False):
7 from . import pdf_support
8 out = [{**msg, "content": pdf_support.adapt_content(msg["content"], caps)} ...]
9
10# 圖片同理
11if any(p.get("type") == "image_url" for msg in out ...):
12 caps = self.provider.capabilities(self.model)
13 if not getattr(caps, "vision", False):
14 placeholder = {"type": "text",
15 "text": "[image attachment — not viewable by this model]"}
持久化歷史(canonical)
┌──────────────────────────┐
│ user: [text, file(PDF)] │ ← 永遠保留原始 PDF
└────────────┬─────────────┘
│
┌─────────────┴──────────────┐
▼ ▼
模型 A(pdf=True) 模型 B(pdf=False)
┌────────────────┐ ┌───────────────────────────┐
│ 原檔直送 │ │ pdf_support.adapt_content │
│ │ │ → 文字抽取 或 頁面圖片 │
└────────────────┘ └───────────────────────────┘
★ 中途從 B 切回 A → 又拿到原檔。因為歷史從未被改寫。
而 switch_model() 會在切換時給出誠實的警告:
1if caps is not None and not getattr(caps, "vision", False) and self._history_has_images():
2 text += " — earlier images can't be read by this model"
4.3 錯誤訊息也是產品的一部分
providers/errors.py 只有 57 行,但很值得學:
1"""Friendly translation of model access + quota failures.
2
3Matching is on the error BODY text (error codes/types), not just HTTP status — a 404 also
4means "wrong base_url" and a 429 also means "slow down", and neither of those should be
5dressed up as an access problem.
6"""
7
8_NO_ACCESS = ("model_not_found", "does not exist or you do not have access",
9 "does not have access to model", "permission_error", "permission denied")
10_NO_QUOTA = ("insufficient_quota", "exceeded your current quota",
11 "credit balance is too low", "billing hard limit")
「Matching is on the error BODY text, not just HTTP status」 —— 用狀態碼判斷會誤傷:
404 也可能是 base_url 打錯,429 也可能只是要你慢一點。不確定就回 None,
讓原始錯誤原樣呈現。
Anthropic 的 404 還要求兩個條件同時成立:
1# Anthropic's 404 body is just "model: <id>" under type not_found_error; require both
2# halves so unrelated 404s (bad base_url, deleted resource) keep their raw message.
3if "not_found_error" in text and f"model: {model.split(':')[-1].lower()}" in text:
4 return no_access
五、Prompt caching:兩個斷點的標準 agent-loop 形狀
1def _add_cache_breakpoints(kwargs: dict[str, Any]) -> None:
2 """Opt the request into prompt caching (5-minute ephemeral, prefix-matched).
3
4 Two breakpoints, the standard agent-loop shape:
5 - last system block — caches tools + system together (tools render first);
6 - last content block of the final message — caches the whole conversation
7 prefix, so each request re-reads the previous turns' cache and writes only
8 the new tail (append-only history keeps the prefix byte-identical).
9
10 Outbound-only: the canonical history never carries `cache_control`.
11 Prefixes under the model's cacheable minimum silently don't cache; reads bill
12 ~0.1x and show up as `cache_read_input_tokens` (the metering's cache_read).
13 """
14 marker = {"type": "ephemeral"}
15 system = kwargs.get("system")
16 if isinstance(system, str) and system:
17 kwargs["system"] = [{"type": "text", "text": system, "cache_control": marker}]
18 messages = kwargs.get("messages") or []
19 if messages:
20 content = messages[-1].get("content")
21 if isinstance(content, list) and content:
22 content[-1] = {**content[-1], "cache_control": marker}
第 N 輪請求:
┌──────────────────────────────────────────────────────────────────┐
│ [tools 定義] [system prompt] ◀── 斷點 ① │
│ user_1 / assistant_1 / tool_1 / assistant_2 / … / tool_{N-1} │
│ assistant_N ◀── 斷點 ② │
└──────────────────────────────────────────────────────────────────┘
│
第 N+1 輪:前綴完全 byte-identical → cache_read
只有新增的尾巴 → cache_write
這個模式能成立的前提是「歷史是 append-only 的」。而這正是為什麼 Part 4 下半部要講的 compaction 必須格外小心 —— 壓縮會改寫前綴, 一改就會使快取全部失效。
六、Context 自動壓縮:561 行的完整演算法
6.1 設計聲明
1"""Auto-compaction of long session histories (OPE-27).
2
3When the outbound history approaches the model's context limit, the older portion of the
4*outbound* view is replaced with (a) an LLM-written structured summary and (b) mechanically
5extracted state — the recent turns and all user messages survive. The persisted transcript
6is never modified; only what is sent to the model.
7
8This module is pure functions + one dataclass; the engine owns *when* (its run loop) and
9*with what* (its provider/model), both injected here. That split keeps the engine.py
10footprint to a few lines and makes every policy testable without a provider.
11"""
「pure functions + one dataclass」 —— 整個壓縮策略不依賴 provider, 所以每一條規則都能單元測試。引擎只負責決定「何時」與「用哪個模型」。
6.2 觸發:兩個上限取小
1DEFAULT_THRESHOLD_PCT = 0.8
2DEFAULT_CAP_TOKENS = 250_000
3DEFAULT_CONTEXT_WINDOW = 128_000 # 矩陣裡沒有 context_window 時的預設
4KEEP_RECENT_FRACTION = 0.25
5SUMMARY_MAX_TOKENS = 3_000
6
7def trigger_tokens(context_window, *, threshold_pct=0.8, cap_tokens=250_000) -> int:
8 window = context_window or DEFAULT_CONTEXT_WINDOW
9 return min(int(threshold_pct * window), int(cap_tokens))
註解說明了 cap 存在的理由:
1# Trigger: min(threshold_pct × context_window, cap_tokens). The cap exists so 1M-context
2# models compact early — quality and latency degrade well before the nominal limit.
「quality and latency degrade well before the nominal limit」 —— 這是實務經驗。 1M context 的模型不代表你該塞 800K 進去;找出中間某個事實的準確率會明顯下降, 而且 TTFT 會爆炸。
實際數字:
| 模型 context window | 80% 門檻 | 實際觸發點(取 min) |
|---|---|---|
| 128,000 | 102,400 | 102,400 |
| 200,000 | 160,000 | 160,000 |
| 400,000(GPT-5.6) | 320,000 | 250,000(cap 生效) |
| 1,000,000 | 800,000 | 250,000(cap 生效) |
保留的近期片段預算 = 0.25 × trigger_tokens。以 400K 模型為例:
觸發於 250K,保留最近 62,500 tokens 逐字。
6.3 訊號:實測優先,估算墊底
1def _compaction_due(self) -> bool:
2 cfg = self._compaction_config()
3 if cfg.get("enabled") is False:
4 return False
5 signal = self._last_context_tokens or _compaction.estimate_tokens(
6 self._outbound_messages()
7 )
8 return _compaction.should_compact(signal, cfg.get("context_window"), ...)
1def estimate_tokens(messages: list[dict[str, Any]]) -> int:
2 """chars/4 over the serialized messages — the fallback signal for providers that
3 never report usage (documented in the metering code)."""
_last_context_tokens 來自上一輪的 turn.usage.context_tokens(真實值)。
只有在 provider 不回報 usage 時才用 chars/4 估算。估算不準會導致
真的 overflow,所以第三章講的 is_context_overflow 回退路徑是必要的配套。
6.4 邊界選擇:合法的後綴頭
這是壓縮裡最容易寫錯的部分。
1def pick_boundary(messages, *, keep_tokens: int) -> Optional[int]:
2 """The canonical index where the verbatim tail begins: the earliest turn start whose
3 suffix fits the keep budget. Prefers user-message boundaries; falls back to iteration
4 (assistant) boundaries when the newest turn alone exceeds the budget (a giant tool
5 loop). None when there is nothing meaningful to summarize."""
┌───────────────────────────────────────────────────────────────────────────┐
│ 規則一:boundary 之後的第一則訊息,必須是 user 或 assistant │
│ ★ 絕不能是 tool —— 孤兒 tool 結果會被 provider 拒絕 │
│ │
│ 規則二:優先選 user 訊息(=一整輪對話的起點) │
│ │
│ 規則三:從「最早」開始找 —— 保留越多逐字內容越好 │
│ for i in candidates: # earliest-first │
│ if estimate_tokens(messages[i:]) <= keep_tokens: return i │
│ │
│ 規則四:若「最新一輪 user turn」自己就超過預算(巨大的工具迴圈) │
│ → 退而求其次,在該輪內部的 assistant 邊界切 │
│ → 至少保留最近一個 assistant 步驟 │
└───────────────────────────────────────────────────────────────────────────┘
1def _fit(candidates: list[int]) -> Optional[int]:
2 for i in candidates: # earliest-first: keep as much verbatim as fits
3 if estimate_tokens(messages[i:]) <= keep_tokens:
4 return i
5 return None
6
7boundary = _fit(users)
8if boundary is None and users:
9 # The newest user turn alone blows the budget — cut inside it at an iteration
10 # boundary, keeping at least the most recent assistant step.
11 inside = [i for i in assistants if i > users[-1]]
12 boundary = _fit(inside)
13 if boundary is None:
14 boundary = inside[-1] if inside else users[-1]
6.5 摘要 = LLM 產出 + 機械抽取
這是整個設計最聰明的地方:不把所有東西都交給 LLM。
┌────────────────────────────────────────────────────────────────────────┐
│ <compacted-history> │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ A. LLM 摘要(8 個固定小節) │ │
│ │ 1. Primary request and intent │ │
│ │ 2. Key concepts and decisions(含 WHY) │ │
│ │ 3. Artifacts and files │ │
│ │ 4. Errors and fixes(含使用者的糾正) │ │
│ │ 5. All user messages │ │
│ │ 6. Pending tasks │ │
│ │ 7. Current work │ │
│ │ 8. Next step │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ B. 機械抽取(零幻覺風險 — 直接讀 tool_call 紀錄) │ │
│ │ · 寫過 / 編輯過的檔案(去重,最近 20 個) │ │
│ │ · 最近 10 條 shell 指令 + exit status │ │
│ │ · 產生的 artifact(最近 10 個) │ │
│ │ · 這段用過的所有工具名稱 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ C. 使用者訊息逐字保留(最多 40 則,每則 600 字) │ │
│ │ ★「user words are the ground truth of intent and must │ │
│ │ not depend on an LLM remembering to include them」 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ D. CONTINUATION_CONTRACT(接續契約) │
│ </compacted-history> │
└────────────────────────────────────────────────────────────────────────┘
B 和 C 是「防止 LLM 摘要出錯」的保險。摘要模型可能忘記提某個檔案、 可能改寫使用者的原話 —— 但 tool_call 紀錄和 user message 是可以機械抽取的事實。
6.6 摘要 prompt 裡的四條規則
1SUMMARY_SYSTEM_PROMPT = """You are compacting an AI coworker's session history so the coworker
2can continue working in a smaller context. Write a structured summary of the conversation below.
3It is the coworker's ONLY memory of these turns, so preserve everything load-bearing.
4...
51. **Primary request and intent** — … including standing constraints stated at any point
6 (e.g. "never send without my approval"). Constraints outlive the turns they were stated in.
72. **Key concepts and decisions** — … Include the WHY, not just the what — a decision without
8 its reason gets relitigated.
9...
10Rules:
11- Do NOT carry full file contents as truth. Note THAT a file was read/edited; the coworker
12 re-reads if it needs the content again. Stale memory of a file is worse than no memory.
13- Be concrete: paths, names, commands, ids — not vague references.
14- Output only the summary sections, no preamble."""
三句話值得單獨抄下來:
「Constraints outlive the turns they were stated in.」 使用者在第 3 輪說「沒有我批准不要發訊息」,這條限制到第 80 輪還有效。 摘要漏掉它 = 安全事故。
「a decision without its reason gets relitigated」 只寫「我們決定用 Redis」,模型在後面會重新提議用 Postgres。 必須寫「用 Redis,因為需要原生 TTL」。
「Stale memory of a file is worse than no memory.」 摘要裡不要塞完整檔案內容 —— 檔案會變,過時的記憶比沒有記憶更糟。 只記「讀過 / 改過這個檔案」,需要時重讀。
接續契約:
1CONTINUATION_CONTRACT = (
2 "Continue where you left off: pick up the current work and next step exactly as "
3 "described. Do not re-ask answered questions, do not recap, do not mention that the "
4 "context was compacted. If you need the contents of a file noted above, re-read it."
5)
「do not mention that the context was compacted」 —— 這是 UX 細節: 使用者不需要聽 agent 說「我剛剛壓縮了記憶」。
6.7 送給摘要模型的輸入也要控制
1_SPAN_TOOL_RESULT_CLIP = 400 # 每則工具結果最多 400 字
2_SPAN_BUDGET_CHARS = 400_000 # 整段 render 的上限
3
4def _render_span(span, *, budget_chars=_SPAN_BUDGET_CHARS) -> str:
5 """The summarized span as compact text for the summarizer. Tool results are clipped
6 hard (first casualty); if the whole render still exceeds the budget, oldest lines are
7 dropped — the newest context is the most load-bearing."""
「Tool results are clipped hard (first casualty)」 —— 40 輪前讀的檔案內容, 壓縮時不需要完整保留,因為摘要規則已經說了「需要就重讀」。
重複壓縮時,前一次的摘要會成為新 span 的第 0 則訊息:
1def summarizer_messages(span, *, prior_summary: str = "") -> list[dict]:
2 """On repeated compaction the previous summary is message zero of the new span —
3 summarized along with the turns since."""
4 body = _render_span(span)
5 if prior_summary:
6 body = ("[previous compaction summary — fold its still-relevant content into the new "
7 "summary]\n" + prior_summary + "\n\n[conversation since]\n" + body)
6.8 使用者訊息清單的上限問題
1_USER_MESSAGE_CLIP = 600
2_USER_MESSAGES_MAX = 40
3# User messages preserved mechanically in the compacted block ("trimmed of pasted bulk").
4# The list is capped to the newest N across repeated compactions — otherwise it appends
5# forever and the block slowly reclaims the window it freed. Dropped ones stay counted
6# (their intent lives in the summary, which is asked to list user messages too).
這是一個很細膩的 bug 預防:逐字保留使用者訊息聽起來很好, 但反覆壓縮下它會無限增長,最後把壓縮省下的空間又吃回去。 所以上限 40 則,被丟掉的計數保留:
1if state.user_messages_dropped:
2 parts += [f"({state.user_messages_dropped} earlier user messages omitted — "
3 "their intent is covered by the summary above)"]
6.9 失敗策略:兩次重試 → 分模式處理
_compact_now()
│
├─ 嘗試 build_state()(第一次 + 無條件重試一次)
│ │
│ ├─ 成功 → 更新 compaction_state,回傳 notice
│ │
│ └─ 失敗
│ │
│ ├─ attended(有人在)→ 問使用者:
│ │ 「Retry」or「Trim oldest 10%」
│ │ 選 Retry → 再試,可以無限循環
│ │
│ └─ unattended(沒人在)→ 直接 auto-trim
│ ★「never park a background run on internal bookkeeping」
│
└─ trim_state():無 LLM 的降級路徑
· 把 boundary 往前推 ~10%
· 沒有摘要,但機械抽取 + 使用者訊息清單照樣免費
· summary_text = "(Older turns were trimmed to fit the context window;
no summary is available for them. Re-read files and
re-run commands if earlier results are needed.)"
「never park a background run on internal bookkeeping」 —— 半夜跑的排程任務, 不該因為「摘要模型 API 掛了」就停下來等人回答一個技術性問題。降級後繼續跑。
6.10 套用到 outbound:三段式
1def apply_to_outbound(messages, state) -> list[dict]:
2 """The outbound view: [system?] + the compacted block (as a user message) + the
3 verbatim tail. Canonical history is untouched; provider-private sidecars in the
4 summarized span vanish with it (replay chains legally restart after a compaction
5 point). No-op when state is absent or stale."""
6 if state is None:
7 return messages
8 boundary = state.boundary_index
9 if boundary <= 0 or boundary >= len(messages):
10 return messages
11 head = []
12 if messages and messages[0].get("role") == "system":
13 head.append(messages[0])
14 head.append({"role": "user", "content": compacted_block(state)})
15 return head + messages[boundary:]
canonical: [system][u1][a1][t1][a2][u2][a3][t2][a4][u3][a5]...
▲
boundary_index
outbound: [system][user: <compacted-history>...][a4][u3][a5]...
└─ 一則 user 訊息 ─┘ └─ 逐字尾巴 ─┘
註解裡有一句容易漏掉但很重要的:
「provider-private sidecars in the summarized span vanish with it (replay chains legally restart after a compaction point)」
Gemini thought signature、OpenAI encrypted reasoning 這些 replay 鏈, 在壓縮邊界處合法地重新開始。不需要特別處理。
6.11 壓縮與 prompt cache 的關係
1# Auto-compaction (OPE-27): everything before the boundary is represented by the
2# compacted block. Outbound-only — the canonical history stays intact — and the
3# block+tail are byte-stable between turns, so prompt caching keeps working.
「the block+tail are byte-stable between turns」 —— 壓縮之後, 新的前綴(system + compacted block)在後續每一輪都是位元組相同的, 所以 prompt cache 在第二輪就重新命中。
代價是壓縮發生的那一輪會 cache miss 一次(整個前綴變了)。這是必然的。
七、為什麼選 X 不選 Y
| 決策 | 選 X 的理由 | 不選 Y 的理由 | 反轉條件 |
|---|---|---|---|
| 自寫 provider 層 vs 純 LiteLLM | 需要 capabilities() 驅動降級、需要 extras 保存 provider 私有欄位 | 純代理層會丟失能力資訊與 provider 私有欄位,中途換模型就壞 | 只支援 1–2 家且不做降級時 |
前綴路由(provider:model)vs 顯式 provider 參數 | 一個字串就能完整描述路由;session 只要存一個欄位 | 兩個欄位要同步,換模型時容易漏改一個 | 需要同一模型走不同端點時(要加 endpoint 維度) |
| 精選矩陣 + 保守啟發式 vs 全靠啟發式 / 全靠使用者填 | 精選代表「我們驗證過」;自訂代表「你自己負責」 | 全靠啟發式會誤判(reseller 的 ugly name);全靠使用者填則體驗差 | 有可靠的 capability discovery API 時 |
| 壓縮上限 250K vs 只用 80% 門檻 | 1M context 模型在 250K 之後品質與延遲都明顯劣化 | 塞到 800K 會讓「找出中間某個事實」的準確率崩掉 | 模型的 long-context 表現有實測改善時可調高 |
| 摘要 + 機械抽取雙軌 vs 只用 LLM 摘要 | tool_call 紀錄與 user message 是可機械抽取的事實,不該讓 LLM 記 | LLM 會漏檔案、會改寫使用者原話 | 沒有結構化的 tool_call 紀錄時 |
| 只改 outbound view vs 改寫真實歷史 | 使用者仍能翻閱完整 transcript;壓縮出錯可以清掉 state 重來 | 改寫歷史 = 不可逆,且會破壞 durable resume 的 tool_call 對應 | 儲存空間極度受限時 |
| unattended 壓縮失敗直接 trim vs 問人 | 背景任務不該卡在內務問題上 | 半夜的排程停下來等人回答技術問題 = 任務失敗 | 沒有 |
| usage 沒回報就是 None vs 填估算值 | 資料層不猜測;上層自己決定要不要估 | 假數字會流進成本統計與壓縮觸發,且無法區分「真的 0」 | 沒有 |
八、系列導航
- Part 1:架構全景 — 一個能交付成果的桌面 AI 同事
- Part 2:TurnEngine — Agent 迴圈的完整解剖
- Part 3:Harness — 權限模型、Inbox 與人機協作
- Part 4(本篇):LLM 層 — Provider 抽象、能力降級與 Context 自動壓縮
- Part 5:能力擴充 — Tools、Skills、Personas、MCP 與排程
本篇可以帶走的七個模式
- Provider 抽象要包含「能力查詢」,不能只有「呼叫代理」—— 否則無法做 vision / PDF 降級。
- 降級是 outbound-only 的:歷史保留最高保真度,每次呼叫依當前模型重新決定。
- 前綴路由的判準是「已註冊的 provider 名稱」,不是「有沒有冒號」。
- token 計數要拆出 cache_read/write:佔 context 的是總量,計費的是拆分後的量。
- 壓縮要有絕對上限,不能只看 context window 的百分比。
- 摘要不要全交給 LLM:能機械抽取的事實(檔案清單、指令、使用者原話)就機械抽取。
- 失敗路徑要分 attended / unattended:背景任務永遠不該卡在內務問題上。
本文分析基於 2026-08 的
main分支(commit01b6f83)。文中出現的模型 id 來自該版本的matrix.py,僅作為程式碼引用,不代表模型的實際可用性。
