打造 LLM 聊天機器人:串流、脈絡壓縮與記憶
逐步打造聊天機器人:加入模型選擇器、可將停止指令傳至模型供應商並真正中止請求的功能,依實測結果配置脈絡預算,在超出上限前將對話壓縮為可重用記憶,並透過閘道整合網頁搜尋,逐項說明從請求控制、脈絡管理到外部資訊擷取的實作流程。
我們的選擇
即時價格| 模型 | 結論 | 價格 |
|---|---|---|
| Gemini 3.6 Flash 速度快、具備真正的 1M 上下文(在 972K 仍可回想起關鍵資訊),且將推理設為 minimal 後,在聊天型單步任務中,實測每次呼叫成本降低 91-97%。 | 最佳預設選擇 | 低至 $1.5/百萬 |
| DeepSeek V4 Flash 本頁面中支援聊天且價格最低的選擇,並提供表格中幅度最大的快取讀取折扣,因此重新讀取較長的對話記錄幾乎不花成本。同時也是 MVP 的預設摘要模型。 | 預算選擇 | 低至 $0.138/百萬 |
| Claude Sonnet 5 四者中角色設定與寫作一致性最佳。預算注意事項:相同文字的權杖數比 Sonnet 4.6 多 41%,因此應比較權杖數,而非標示價格。 | 品質選擇 | 低至 $2/百萬 |
| GLM-5.2 實測一次暖啟動工具呼叫回合的成本為 $0.0009,而 claude-opus-4-8 為 $0.0051。暖啟動回合延遲中位數為 6.6s,因此僅適合用於使用者預期需要等待的流程。 | 低成本工具呼叫 | 低至 $1.4/百萬 |
目錄
本指南會帶你完成一套約兩分鐘即可在本機跑起來、半天內能讀完程式碼的聊天機器人:一個 FastAPI 伺服器、一個靜態頁面,沒有資料庫,也不需要建置步驟。內容涵蓋各子系統的設計、每個回合的執行順序,以及開發時實際遇到的失敗情境。完整原始碼位於 github.com/synthorai-io/use-cases 的 chatbot/ 目錄。所有請求都走同一個相容 OpenAI 的端點,因此模型選擇器只是下拉選單,不需要各自整合。
git clone https://github.com/synthorai-io/use-cases
cd use-cases
cp .env.example .env # put your API key in it
cd chatbot
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn server:app --reload

MVP 聊天機器人必須做到什麼?
共八項。每一項都對應到聊天機器人缺少該功能時會出現的具體問題:
| 功能 | 可避免的問題 | 所在位置 |
|---|---|---|
| 持久化對話(列表、搜尋、重新命名) | 重新載入後就忘光的聊天只是展示,不是工具 | storage.py |
| 可在對話中途切換模型 | 所有簡單問題都使用為最難問題定價的模型,會白白多花錢 | config.py |
| 可編輯且附預設範本的角色設定 | system prompt 就是產品本身;修改它不該需要重新部署 | presets/ |
| 串流,以及真正能中止供應商請求的停止功能 | 第一個 token 出現前毫無反應,使用者會以為壞了;假的停止按鈕仍會繼續計費 | server.py |
| 脈絡預算,以及達上限時的壓縮機制 | 每個模型都有脈絡窗口;默默撞上上限,機器人就會像是「突然變笨」 | context.py |
| 長期記憶 | 每個工作階段都得重新自我介紹,是最明顯的聊天機器人痛點 | context.py + .data/memory.md |
| 網頁搜尋與網頁擷取 | 模型知識停在訓練時間點,但聊天問題多半關乎最新資訊 | tools.py |
| 每回合與每工作階段的成本可見性 | 帳單會隨對話紀錄增長;看不到就無法調校 | server.py |
網頁搜尋與擷取值得多談,因為許多 MVP 功能清單最先刪掉的就是這兩項。聊天模型的知識停在訓練截止日,通常比今天早好幾個月;但聊天問題高度偏向即時資訊,例如價格、版本、發行內容,以及「X 現在支援 Y 了嗎」。如果聊天機器人直接用訓練資料回答,它不只是品質下降,而是會非常有把握地答錯,使用者也無從判斷哪些答案已過時。問題不只在於缺少事實。開發時我們發現,若沒有日期基準,模型會把自己的知識截止日當成「現在」,甚至會在搜尋查詢中加入過時年份,導致搜尋方向一開始就錯。檢索不是聊天機器人的加值功能;它決定了使用者面對的是一份靜態快照,還是一個助理。
為什麼需要兩個工具,而不是一個?因為搜尋與擷取回答的是不同問題。搜尋會回傳幾百個字元的摘要片段,而且各結果經常互相矛盾;它回答的是「外面有哪些資訊」。擷取則取得單一頁面的完整內容,回答「這個頁面實際寫了什麼」,適合確認精確數字。一個負責探索,另一個負責驗證。模型每回合自行判斷要不要使用,因此不需要工具的回合不會增加成本;使用次數上限則能控制需要工具的回合。
其他基本功能也包含在內:重新產生、不會在串流途中破版的 Markdown 顯示,以及針對不同失敗情境設計的明確復原路徑。以下大致依照請求流經程式碼的順序,逐項說明。
整體架構怎麼組成?
三個部分:一個靜態頁面、六個小型 Python 檔案,以及閘道。頁面送出訊息後,伺服器會組裝脈絡、視需要壓縮,再以 server-sent events 串流回覆,最後記錄實測用量。對話儲存為可直接用 cat 查看內容的 JSON 檔案。
static/index.html the chat UI (vanilla JS: picker, budget bar, markdown, activity trail)
server.py routes; the per-turn pipeline: project → compress → stream → record
context.py token budgeting, compression call, memory file, message assembly
tools.py the /v1/messages transport used by tool-enabled turns
storage.py one JSON file per conversation under .data/ (settings included)
config.py env-driven settings: model lineup, budgets, prompts
presets/ system-prompt presets, one .txt each
最值得掌握的是 server.py 裡的逐回合管線。使用者每送出一則訊息,都會依序經過相同的五個步驟;每一步都對應到下文的一個章節:
user message
│
▼
[1] project the next request's size last measured prompt_tokens
│ + estimate(new message, pessimistic)
│ + completion reserve
▼
[2] over budget? ──yes──▶ compress old turns ──▶ rolling summary
│ └───────▶ durable facts ──▶ memory.md
▼
[3] assemble and send [persona][memory][summary][date][history]
│ cache mark after the system blocks
▼
[4] stream SSE back to the page delta / reasoning / search / fetch /
│ compression notice / warning / error
▼
[5] record measured usage usage.prompt_tokens becomes step [1]'s
input on the next turn
這是一個閉環:步驟 5 的實測數字會成為下一回合步驟 1 的依據。因此,預算始終以 API 實際計算的數字為準,而不是依賴本機估算。
架構上有一個分支必須先說明:一般回合使用 /v1/chat/completions,啟用網頁搜尋或擷取時則改走 /v1/messages。這不是風格選擇;工具章節會說明,是哪些實測行為迫使我們採用這種分流。
模型選擇器該放哪些模型?
.env 內建七款模型,並在選擇器中按等級分組(快速低價、均衡、前沿)。你也可以從設定加入閘道提供的任何 id;新增的 id 會持久化到 .data/models.json。比起完整陣容,預設模型的選擇更重要。這個 MVP 預設使用 Gemini 3.6 Flash,並把推理強度調到最低。聊天回覆屬於單步工作;在這類任務中,使用 reasoning_effort: "minimal" 後,實測每次呼叫成本比預設值低 91-97%,而讀者看不出輸出品質差異。這項設定按模型放在 config 中,因為不是每款模型都接受這個參數:
# config.py — extra request params per model
MODEL_PARAMS: dict[str, dict] = {
"gemini-3.6-flash": {"reasoning_effort": "minimal"},
}
DeepSeek V4 Flash 在陣容中有兩個角色:模型選擇器裡的省成本選項,以及脈絡壓縮時的預設摘要模型。摘要同樣屬於單步、可容忍品質差異的工作。Claude Sonnet 5 適合把寫作品質當成產品核心的情境;預算應按 token 數估算,而不是只看標價,因為相同文字的 token 數會比 Sonnet 4.6 多 41%。GLM-5.2 則適合工具呼叫密集的流程:暖快取工具呼叫回合實測為 $0.0009,Claude Opus 4.8 則為 $0.0051。不過它的暖回合延遲中位數達 6.6s,在聊天視窗中會明顯感受到等待時間。
各模型的能力以實測為準,不靠假設。程式會繞過模型差異,而不是假裝差異不存在。在這組模型中,只有 DeepSeek 與 GLM 會透過 reasoning_content 串流輸出推理過程;即使設定 thinking 參數,Claude 模型也不會經由這個閘道回傳 thinking 區塊。因此,UI 只會在確實可能收到內容時顯示即時「思考中」面板。
對話中途切換模型不需要調整架構。歷史紀錄採用與供應商無關的 {"role", "content"} 訊息格式,所以同一段對話可以先用便宜模型,遇到難題時再切換到品質更高的模型。唯一真正的成本藏在背後:切換模型會放棄前一個模型的 prompt cache,因此切換後的第一回合必須按冷快取價格重新讀取完整脈絡。
模型每回合實際看到什麼?
模型會收到一組分層 prompt,由單一位置組裝,並刻意把最穩定的內容放在最前面。
| 層級 | 來源 | 變動頻率 |
|---|---|---|
| System prompt(角色設定) | presets/*.txt 或自由文字 | 除非編輯,否則不變 |
| 長期記憶 | .data/memory.md | 很少變動(附加事實時) |
| 滾動摘要 | 壓縮程序 | 只有觸發壓縮時才變 |
| 日期基準 | 伺服器時鐘 | 每日 |
| 工具指示 | 設定,僅工具回合 | 很少變動 |
| 近期回合 | 對話內容 | 每回合 |
排序原則是最穩定的內容優先,原因在於快取,下一節會詳述。角色設定不變,記憶很少變,摘要只在壓縮時改變,而歷史紀錄每回合都會增加。因此,每一層都放在變動頻率更低的內容後面。
日期基準值得放進 prompt,所在位置也很重要。沒有日期基準時,模型會把訓練截止日當成「現在」:搜尋查詢可能帶著過時年份,也可能把發行表中最新一列誤認為當前版本。不過這裡刻意使用日期,而不是時間戳記。日期位於 prompt 前綴,如果精確到日以下,每次請求都會破壞快取。它也放在角色設定、記憶與摘要之後,讓這些內容在跨日時仍能保留快取。
Prompt caching 怎麼運作?哪些模型支援?
原理很簡單:供應商會快取請求中逐位元組完全相同的前綴,之後以大幅折扣讀回;第一次寫入時則收取少量溢價。聊天非常適合這種機制,因為每次請求本來就是前一次請求再加上兩則訊息,整段歷史天然構成穩定前綴。寫入溢價到下一回合就能回本(Anthropic 模型的 5 分鐘 TTL 寫入費率為 1.25x,讀取約為 0.1x;這裡有寫入端的實測成本分析),而 cache_control 標記在三個 Claude 等級中實測可降低 88-89% 成本。當對話已包含實際記憶與摘要後,後續回合成本約為首次冷快取回合的十分之一。
問題在於,「快取」不是單一機制。供應商大致分成兩派,而這組模型兩種都有:
| 模型 | 快取方式 | 需要做什麼 | 命中回報欄位 |
|---|---|---|---|
| Claude 系列 | 明確指定:使用 cache_control 中斷點 | 放置標記 | cache_read_input_tokens |
| DeepSeek V4 Flash | 隱式:自動前綴快取 | 不需操作 | prompt_tokens_details.cached_tokens |
| GLM-5.2 | 隱式:自動前綴快取 | 不需操作 | prompt_tokens_details.cached_tokens |
| Gemini 3.6 Flash | 隱式;接受標記但會忽略 | 無法控制 | 不會透過這個閘道回報 |
明確快取(Anthropic 的做法)要求你指定中斷點,但也會清楚告訴你發生了什麼:寫入與讀取 token 分別回報,因此每回合都能核對折扣。隱式快取(OpenAI 風格)不需要標記,只要前綴重複就會自動發生;但是否快取完全由供應商決定,事後只能靠 cached_tokens 欄位判斷,而且不同供應商的命中穩定度差異很大。這個 MVP 同時兼容兩派:依穩定度由高到低排列 prompt,讓隱式快取能正常運作;同時一律送出明確標記,而隱式快取模型會無害地忽略它。使用同一種請求格式,每個模型都能獲得自己支援的快取能力。
明確標記的位置來自實測,不是文件。在這個閘道上,cache_control 只有放在 system 區塊時有效,放在其他位置會被靜默忽略。常見的多回合做法是標記最新一則使用者訊息,理論上能快取整段歷史;但實際讀回的快取 token 為零,仍按全額計費。因此,MVP 把標記放在 system 區段結尾,可快取的前綴就是角色設定、記憶與摘要。這會帶來兩個結果。第一,前綴必須超過模型的最低門檻(約 1,024 個 token)才會啟用快取,所以新對話不會快取,累積了記憶與摘要後才會完整快取。第二,編輯前綴都會產生成本:修改 system prompt 會從第一個位元組起變成冷快取;壓縮會重寫摘要,造成一次冷讀取;切換模型則完全放棄舊模型的快取。
TTL 在各家供應商上的設定邏輯相同:預設 5 分鐘快取可涵蓋進行中的對話;1 小時方案適合暫時離開後又回來的使用者,但寫入溢價會加倍。只要把設定改成 CACHE_TTL=1h;是否划算,取決於使用者是否真的會在一小時內回來。
怎麼知道快接近脈絡上限?
要量測,不要猜。對目前使用的模型而言,唯一可信的 token 數,就是 API 上一次請求回傳的 usage.prompt_tokens。各供應商的本機估算誤差很大,不能混用。即使同一家供應商的兩個模型,相同英文文字的 token 數也可能差 41%(我們實測過),跨供應商差距更大。MVP 只會在一個地方使用本機 token 計算:估算 API 尚未看過、正準備送出的訊息,而且刻意向上取整:
# context.py
def needs_compression(last_prompt_tokens, pending_text, message_count, budget=None):
if message_count <= config.KEEP_RECENT_MESSAGES:
return False # nothing old enough to fold away
projected = (
last_prompt_tokens # measured, last response
+ estimate_tokens(pending_text) # estimated, pessimistic
+ config.MAX_COMPLETION_TOKENS # worst-case reply
)
return projected > (budget or config.CONTEXT_BUDGET_TOKENS)
高估只會提早一回合觸發壓縮,代價是一次便宜的摘要呼叫。低估則可能超出窗口,造成請求失敗或被靜默截斷。兩者後果不對稱,因此估算必須向上取整。
「量測」本身也有陷阱:不同供應商對 prompt_tokens 是否已包含快取 token 的定義不一致。有些回報完整 prompt,有些只回報未快取的增量。這不是顯示上的小問題,因為該數字是預算的基準。若少算了整段快取大小,壓縮永遠不會觸發,最終就會靜默超出窗口。伺服器會先正規化數值:如果 prompt_tokens 小於回報的快取數量,它不可能代表完整 prompt,因此會把各部分加總;同時也用自己的估算值作為實測值的下限。各供應商的 usage 到底回報哪些內容是另一個完整主題;可參考 LLM Token 用量結構。
預設預算是 102,400 個 token,一般對話不容易達到。這是工作預算,不是模型上限;應設定在單一回合成本開始不值得的臨界點。若要觀察機制實際運作,可在設定中把某段對話的窗口降到幾千個 token(預算按對話設定),再貼入幾則長訊息。頁首的脈絡列使用實測數字繪製,並按窗口內容分段顯示:角色設定、記憶、摘要與歷史紀錄。

達到上限時會發生什麼?
壓縮,不截斷。直接截斷會讓機器人忘記對話開頭,使用者感受到的就是機器人突然變笨。正確做法是把最近幾則訊息以外的內容(預設保留最後 8 則)交給便宜的摘要模型,壓成滾動摘要,再把摘要作為 system 區塊一起傳送。近期窗口會逐字保留,因此機器人的短期語氣不會改變;只有久遠內容會有損壓縮。任何內容都不會默默消失。觸發壓縮時,對話紀錄會顯示通知,列出訊息數與摘要大小。
一次完整壓縮的流程如下:
before (projected next request 103.1k > 102.4k budget)
[persona][memory][date][ m1 ..................... m34 │ m35 ....... m42 ]
old enough to fold last 8, kept
one summary call to deepseek-v4-flash:
in: prior summary + m1 ... m34
out: {"summary": "one dense paragraph: topics, decisions,
open questions, promises",
"facts": ["prefers Python", "timezone is UTC+8"]}
after
[persona][memory + new facts][summary][date][ m35 ....... m42 ]
unchanged grows rarely replaced verbatim
壓縮呼叫能否可靠運作,主要取決於三個設計點:
- 新摘要會吸收舊摘要。 第二次壓縮會把第一次摘要連同新近變舊的訊息一起折疊,因此始終只有一段滾動摘要,不會無限累積成摘要的摘要鏈。
- 摘要失敗不會讓本回合失敗。 如果摘要呼叫出錯,伺服器會直接捨棄最舊的未摘要回合,明確告知使用者遺失了哪些內容,然後繼續回答。記憶不完整仍比聊天機器人完全無法回應好;展示時從不出現的錯誤路徑,往往正是正式環境中會讓值班工程師收到警報的路徑。
- 摘要 prompt 是可設定項目,但有一道防護。 摘要模型被要求保留什麼,就決定對話會記住什麼,因此每段對話都能編輯壓縮 prompt。若編輯後移除了
{transcript}預留位置,伺服器會拒絕儲存,因為那個 prompt 根本沒有內容可摘要,而且問題要到第一次超出窗口時才會浮現。COMPRESS_MAX_TOKENS也採用相同邏輯:輸出上限要高於 prompt 要求的內容,因為摘要若在句子中間被截斷,後續每個回合都會繼承這個殘缺結果。
對快取而言,每次壓縮只會產生一次冷讀取成本:摘要區塊改變後,記憶區塊之後的內容會在一個請求內變成冷快取,接著新的較小前綴就會再次被快取。完整成本就是一次摘要呼叫加一次冷讀取,換來後續每個回合都能在更小且已暖快取的脈絡上計費。
記憶如何跨工作階段保留?
拉高一層來看,機器人共有三種記憶儲存,各自的範圍與生命週期不同。本節的設計都由這種區分推導而來:
| 儲存 | 範圍 | 生命週期 | 傳輸大小 |
|---|---|---|---|
| 逐字保留的近期回合 | 目前對話 | 直到壓縮將其折疊 | 最後 8 則訊息的全文 |
| 滾動摘要 | 目前對話 | 隨對話刪除 | 一段文字,少於 200 字 |
memory.md 事實 | 所有對話 | 直到人工清理 | 數行文字 |
摘要和事實看起來相似,但有效期間不同,因此必須分開。滾動摘要以對話為單位,包含決策、未解問題,以及助理承諾要做的事;它隨對話結束而消失是合理的。使用者的長期事實,例如「偏好 Python」或「時區是 UTC+8」,下週仍然成立,若跟著對話消失就是浪費。
壓縮 prompt 會同時要求兩種輸出,並以 JSON 回傳:一個 summary 字串和一個 facts 陣列。事實會附加到 .data/memory.md,每段對話都會把這個檔案載入為 system 區塊。選擇在壓縮時擷取事實,而不是另跑一輪,原因在於成本:壓縮時模型本來就必須重新讀取舊回合,因此可順便從已付費的 token 中擷取資訊。一次呼叫,兩種輸出。
這個 MVP 只用完全相同的文字行去重,限制很快就會出現。一次壓縮可能儲存「使用者偏好 Python 而不是 Node.js」,下次又加入「該使用者較喜歡 Python,不喜歡 Node.js」。語意去重需要對每個候選事實做 embedding,或讓 LLM 與現有內容逐一比較。這是有實際成本的完整功能,因此 MVP 採用簡單做法,也明確說明限制。記憶檔刻意使用 Markdown,讓人可以直接閱讀與清理;設定頁也把它顯示為可編輯文字框,同時充當透明度面板:機器人知道你的哪些事,就是一個你能直接開啟的檔案。
使用者按下停止後會發生什麼?
供應商會停止產生內容。這句話本身就是功能重點,但多數聊天 UI 做不到。若停止按鈕只是把輸出藏起來,背後的 completion 仍持續執行,就還是在為沒人會讀的 token 付費。
整條鏈有三個環節。回覆串流期間,傳送按鈕會在同一位置變成停止按鈕,不再跳確認;點擊後會中止瀏覽器的 fetch。伺服器會在事件之間檢查連線是否中斷;偵測到後離開串流迴圈,關閉上游連線,讓供應商停止產生內容。已經抵達的文字仍會儲存,並標記為 interrupted,在對話紀錄中以虛線框顯示。分頁關閉或網路斷線時也會走同一條鏈,因為對伺服器而言,這些都是同一種事件。
這裡必須確保只持久化一次,因此程式明確處理三條路徑:串流正常完成時在迴圈內儲存;串流中斷時在例外處理器中儲存;硬中斷時則從 finally 區塊儲存,因為框架會取消 generator,迴圈後方的程式不會執行。該函式具備冪等性,因此最先執行的路徑會完成儲存,其他路徑不會重複寫入。
停止功能會搭配重新產生使用:中止不想要的回覆後,不必重打問題就能重做。重新產生會移除尾端的 assistant 回合,包括被中斷的回覆,讓對話重新停在使用者訊息,再用目前模型作答。由於模型下拉選單會套用到下一回合,因此也可以先停止、切換到更強模型,再重新產生同一題的答案。
回合失敗時會怎麼處理?
共有六種情況。每種都提供自己的復原方式,而不是統一顯示一條紅色錯誤訊息。server.py 中的 classify() 會把上游例外映射到失敗類型,再由 UI 把類型映射到操作:
| 失敗類型 | 使用者看到的內容 | 復原方式 |
|---|---|---|
| 網路錯誤 | 明確標示為網路錯誤 | 重試按鈕 |
| 速率限制 | 若有 retry-after,顯示等待時間 | 重試按鈕 |
| API key 錯誤 | 明確指出要修正的環境變數名稱 | 編輯 .env |
| 內容過濾 | 「使用相同文字重試仍會被拒絕」 | 編輯後重送 |
| 串流途中中斷 | 保留部分回覆並標記為中斷 | 重試 |
| 壓縮失敗 | 警告中列出捨棄的內容 | 不需要;本回合會繼續 |
兩項不變條件承擔了大部分工作。第一,使用者訊息絕不遺失。如果請求在第一個事件前失敗,伺服器會從對話中回滾該訊息,UI 再把草稿放回輸入框,因此重試不會重複送出。如果串流已回傳文字後才中斷,部分內容會保留並標記。第二,被拒絕的回覆應該使用 rewind,而不是 retry。伺服器會把使用者訊息從歷史紀錄中取回,放回輸入框供編輯,因為原封不動地重送給內容過濾器,只會永遠得到相同失敗結果。
這兩項背後還有一條較不明顯的儲存規則:絕不儲存空白 assistant 回合。把空白 assistant 訊息重播到上游,可能會破壞某些供應商要求的 user/assistant 交替順序,而且錯誤通常要到後續回合才出現,還會指向錯誤的訊息,極難除錯。MVP 會從傳輸格式中移除空內容,也不儲存沒有文字的回覆。唯一例外是該回合包含值得保留的搜尋或推理活動;這時會儲存活動,但送出時仍會移除空白文字。
不在程式中實作工具迴圈,網頁搜尋怎麼運作?
工具迴圈由閘道在伺服器端執行,因此工作分配不同。傳統 function calling 的迴圈由你的程式負責:模型回傳 tool_calls 區塊,你的程式執行工具、附加 tool_result 訊息,再重新送出整段對話;每次工具呼叫都要重複一次。伺服器端工具則把迴圈移到閘道。請求中宣告 synthorai:web_search 或 synthorai:web_fetch 後,閘道會位於 LLM 與工具供應商之間,把模型的工具呼叫轉送到第三方搜尋與擷取 API,再把結果放回模型脈絡。工具並沒有任何魔法:每次搜尋和擷取本身都會呼叫外部 API,這正是按次計費的原因。這份程式碼不需要進行 tool_result 往返,只要呈現流經的事件:
browser server (tools.py) Synthorai gateway LLM / tool providers
│ POST /chat │ │
├──────────────▶│ declare tools + │
│ │ budget note │
│ ├────────────────────▶│── question + tools ──▶ [LLM]
│ │ │◀── tool_use: search ── [LLM]
│ │ │── query ─────────────▶ [search API]
│ │ search results │◀── results ─────────── [search API]
│ SSE: search ◀─┤◀────────────────────┤── results ───────────▶ [LLM]
│ │ │◀── tool_use: fetch ─── [LLM]
│ │ │── URL ───────────────▶ [fetch API]
│ │ fetch result │◀── page text ───────── [fetch API]
│ SSE: fetch ◀──┤◀────────────────────┤── page text ─────────▶ [LLM]
│ SSE: delta ◀──┤◀────────────────────┤◀── answer tokens ───── [LLM]
│ SSE: done │ │
各自的職責很清楚:模型負責決策,包括是否搜尋、摘要片段是否足以回答,或是否需要擷取完整頁面,以及何時停止並開始作答;閘道負責執行,呼叫搜尋或擷取供應商,再把結果餵回模型;你的伺服器只需呈現串流經過的事件。兩個工具預設都開啟,不需要工具的回合不會增加成本,因此算術題仍然免費,而「目前最新的……」則會搜尋。閘道端的迴圈也有限制:如果伺服器端搜尋迴圈在自己的迭代上限暫停(pause_turn),傳輸層會把該回合原樣送回以繼續執行,最多三次。
開發時得到的四個經驗,比正常流程更值得注意:
- 端點決定你看得到什麼。 兩個端點都會執行搜尋並計費(於 2026-08-04 實測,Anthropic 與 Gemini 通道皆相同),但只有
/v1/messages會顯示搜尋過程:查詢與結果 URL 會以具型別區塊抵達,程式可以呈現並儲存。使用/v1/chat/completions時,相同搜尋會在完全不可見的情況下執行:HTTP 200,答案開頭寫著「根據搜尋結果……」,但串流與非串流路徑都沒有引用或註解。付了搜尋費用卻無法稽核,是最糟的靜默劣化:系統沒有報錯,只是來源憑證消失。這也是tools.py必須作為第二種傳輸存在,而不能只在一般請求上多加一個欄位的全部原因。 - 必須用文字告訴模型工具預算。 閘道會靜默強制執行上限(預設每回合 3 次搜尋、2 次擷取),若模型不知道限制,就會按工具無限使用來規劃,並在思考途中耗盡最後一次額度;回合可能停在「讓我搜尋一下……」而沒有答案。MVP 會注入一句話說明預算,而且措辭本身就是可量測的成本調節器。測試中,完全不提示會耗盡所有工具額度並停在半句;過度嚴格的提示會讓模型直接放棄,叫使用者自己閱讀頁面;目前採用的版本則完全跳過搜尋,直接擷取兩個權威頁面,成本也是三者最低。這段文字可在設定中編輯;修改後可直接觀察每回合成本。
- 次數上限是唯一的煞車。 兩種工具都按使用次數計費,而模型會自行決定呼叫幾輪。測試中,一個未設上限的回合在產生第一個輸出 token 前,就執行了三次搜尋與兩次擷取。
- 要降級,不要整回合失敗。 兩種失敗都採用相同處理:移除該工具、重試一次,再通知使用者。Web fetch 需要 API key 具備對應權限;若沒有,整個請求會以
web_fetch_not_enabled失敗,而不會自行降級。Gemini 則會接受工具宣告,但一旦真的呼叫就出錯(Function call is missing a thought_signature)。這兩種情況下,因為選用工具失敗就丟掉使用者整個回合,都不是合理取捨。
模型為得到答案所做的一切,包括思考、搜尋和閱讀,都會即時串流到回覆上方的活動軌跡。預設收合成一行淡色文字(「思考 5s · 搜尋網頁 · 閱讀 2 個頁面」),展開後可查看含查詢、結果網域與推理文字的時間軸。軌跡會和訊息一起儲存,這才是重點:重新載入後就消失的軌跡,無法拿來事後稽核答案。引用來源則以編號網域標籤顯示在回覆下方。

Markdown 如何在串流時保持版面穩定?
做法是把累積文字分成可安全呈現的穩定前綴,以及尚未穩定的尾端。若先渲染未閉合的語法結構,下一個 token 補上結尾時,版面就會跳動。因此,renderer 只切到最後一個完整行;如果開啟的 code fence 尚未關閉,從該 fence 起的內容會維持純文字,直到 fence 閉合。只有穩定前綴確實增加時,對話框才會重新渲染,因此不會每個 token 都重建 DOM。程式碼區塊附有複製按鈕;整個 renderer 只有約一百行 vanilla JS,沒有使用函式庫。這反映的是 MVP 真正需要什麼,而不是對 Markdown 函式庫的評價。
一段對話要花多少錢?
以閘道 usage 回報的數字為準,而 MVP 的責任是如實解讀。實作時有兩個正規化問題非常關鍵:
- 各供應商欄位名稱不同。 光是快取 token,Anthropic 模型回報
cache_read_input_tokens,DeepSeek 與 GLM 只回報prompt_tokens_details.cached_tokens,Gemini 則兩者都不回報。統計列會讀取實際存在的欄位,讓「已快取」在不同模型上維持相同含義。 - 沒有成本欄位不等於成本為零。 閘道有些回合會回報
cost,有些不會;在宣告工具但未實際使用的回合中可以穩定重現。缺少成本欄位的回合會被計數並顯示為「+N 筆未回報」,而不是當成零加入總計。否則,缺漏部分回合的數字看起來會像完整帳單,實際上卻只是下限。
頁首會顯示工作階段累計,包括輸入、輸出、快取占比、搜尋、擷取、成本與回合數;每則回覆也有自己的統計列。最值得觀察的是快取占比:它就是前述快取機制在你實際對話上的量測結果。
所有調整項目放在哪裡?
全部集中在同一個設定面板。最值得參考的設計決策是設定範圍:其中幾乎每個項目都只屬於單一對話。

只有兩個項目是全域設定,因為它們描述的是使用者,而不是對話:模型陣容(可加入閘道提供的任何 id,並持久化到 .data/models.json)和長期記憶檔案,後者以可編輯文字框顯示。其他設定都只套用到目前開啟的對話:system prompt(可從 presets/ 下拉選擇範本)、脈絡預算、網頁搜尋與擷取開關、工具指示,以及壓縮 prompt。因此,兩段對話可以在同一組模型上並行使用不同角色設定、預算與工具。你可以親自重現本指南的結論,不必只相信我們的說法。
每個欄位都有小型資訊標記,游標停留可預覽,點擊則可固定顯示,內容會說明修改該設定的成本。多數調整都有 UI 看不見的代價:編輯角色設定會從第一個位元組起使快取失效,因此會產生一次冷快取回合;改寫工具指示會改變每回合帳單;降低預算會讓壓縮更早觸發。頂部模型下拉選單是唯一會立即儲存的設定,因為在對話中途切換模型是一級操作,不是一般設定變更。
省略了哪些功能?之後要接在哪裡?
以下項目是刻意省略,每項都有清楚的擴充位置:
- 驗證與多使用者 — 在路由前加上一層工作階段機制。對話已經有 id,因此按使用者區隔只需替檔名加前綴,不必重新設計。加入這層之前,它只是 localhost 工具:每個請求都會花你的 API key 額度,不要直接暴露到公開網路。
- 速率限制 — 放在同一層,理由也相同:它是用來保護共用部署,而目前還不是共用服務。
- RAG — 檢索文件應放在摘要之後、近期訊息之前。容易變動的內容放在前綴後方,才不會讓已快取的角色設定和記憶區塊頻繁失效。模型方面,這正是具備 1M 脈絡窗口的選項發揮價值之處。
- 用戶端 function calling — 串流迴圈需要增加
tool_calls分支與執行器;GLM-5.2 分析 說明了擴充時會遇到的跨供應商合約差異。前述閘道端工具刻意避開這套迴圈。 - 語意記憶去重 — 對每個候選事實做 embedding,與現有儲存內容比較,只保留新資訊。記憶檔格式不需要改變。
- 語法醒目提示 — 真正常用的功能是複製按鈕;醒目提示留待正式前端選擇函式庫時處理。
- 正式資料庫 —
storage.py只有幾個函式,半天內就能移植到 SQLite。在真正有使用者之前,保留 JSON 檔案正是目前設計的目的。
延伸閱讀
- 依使用情境選擇最佳 LLM(2026):聊天、RAG 與 Agent 成本矩陣 — 本專案採用的成本公式,以及它在不同工作負載下的應用。
- Gemini 3.6 Flash:讓成本相差 30x 的思考強度設定 — 將推理強度固定為 minimal 的實測依據。
- Claude Sonnet 5 的 tokenizer — 為什麼跨模型比較時應看 token 數,而不是價格。
- LLM Token 用量結構 — 各供應商的
usage實際回報哪些內容。 - GLM 5.2 工具呼叫 — function calling 擴充會遇到的暖回合成本與合約差異。