🎁 新用戶 免費註冊,送 10 次呼叫,最高 $1,免綁卡。
打造 LLM 聊天機器人:串流、脈絡壓縮與記憶

打造 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/百萬
目錄
  1. MVP 聊天機器人必須做到什麼?
  2. 整體架構怎麼組成?
  3. 模型選擇器該放哪些模型?
  4. 模型每回合實際看到什麼?
  5. Prompt caching 怎麼運作?哪些模型支援?
  6. 怎麼知道快接近脈絡上限?
  7. 達到上限時會發生什麼?
  8. 記憶如何跨工作階段保留?
  9. 使用者按下停止後會發生什麼?
  10. 回合失敗時會怎麼處理?
  11. 不在程式中實作工具迴圈,網頁搜尋怎麼運作?
  12. Markdown 如何在串流時保持版面穩定?
  13. 一段對話要花多少錢?
  14. 所有調整項目放在哪裡?
  15. 省略了哪些功能?之後要接在哪裡?
  16. 延伸閱讀

本指南會帶你完成一套約兩分鐘即可在本機跑起來、半天內能讀完程式碼的聊天機器人:一個 FastAPI 伺服器、一個靜態頁面,沒有資料庫,也不需要建置步驟。內容涵蓋各子系統的設計、每個回合的執行順序,以及開發時實際遇到的失敗情境。完整原始碼位於 github.com/synthorai-io/use-caseschatbot/ 目錄。所有請求都走同一個相容 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

對話進行中的示範畫面:頂部有模型選擇器與網頁搜尋標籤,聊天區上方顯示工作階段總計和分段脈絡列,下方則有收合的活動軌跡、附來源標籤的 Markdown 回覆,以及對話側邊欄

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(預算按對話設定),再貼入幾則長訊息。頁首的脈絡列使用實測數字繪製,並按窗口內容分段顯示:角色設定、記憶、摘要與歷史紀錄。

頁首資訊列:顯示工作階段總計、快取占比與成本,以及相對於 16k 預算,按 system、記憶、摘要和歷史分段的脈絡列

達到上限時會發生什麼?

壓縮,不截斷。直接截斷會讓機器人忘記對話開頭,使用者感受到的就是機器人突然變笨。正確做法是把最近幾則訊息以外的內容(預設保留最後 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_searchsynthorai: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 筆未回報」,而不是當成零加入總計。否則,缺漏部分回合的數字看起來會像完整帳單,實際上卻只是下限。

頁首會顯示工作階段累計,包括輸入、輸出、快取占比、搜尋、擷取、成本與回合數;每則回覆也有自己的統計列。最值得觀察的是快取占比:它就是前述快取機制在你實際對話上的量測結果。

所有調整項目放在哪裡?

全部集中在同一個設定面板。最值得參考的設計決策是設定範圍:其中幾乎每個項目都只屬於單一對話。

設定面板:模型陣容以可移除標籤顯示,旁邊有新增欄位;下方是每段對話的脈絡窗口、位於可編輯 system prompt 上方的預設範本下拉選單,以及記憶區段

只有兩個項目是全域設定,因為它們描述的是使用者,而不是對話:模型陣容(可加入閘道提供的任何 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 檔案正是目前設計的目的。

延伸閱讀

← 使用場景