tiny-agent 從第一性原理打造可靠 Agent

第一部|最小閉環 · 第 2 章

訊息、Transcript 與 Provider Adapter

理解無狀態模型如何藉由 transcript 延續工作,以及 provider wire format 為何不能直接進入核心。

約 18 分鐘 2 / 8

想像模型是個每次通話都會失憶的人。你每次打電話給它,都要把「我是誰、我們講到哪、剛剛工具回傳了什麼」從頭讀一遍給它聽,它才能接著往下講。 Agent 能表現得像「記得上次對話」,唯一原因是 host 把整段對話錄下來(transcript),每次都重播一次給模型聽。

問題來了:OpenRouter 回傳的東西不是乾淨的錄音——它夾雜了 reasoningrefusal 這類 provider 專屬的雜訊。tiny-agent 曾經直接把 provider 回應當作系統內部的 Message 硬轉型(choice.message as Message),結果 strict reducer 直接拒收,因為那不是它認得的格式。這一章要講的就是:錄音帶進來之前,要先過一次「翻譯與檢查」。

四種角色

Role 用途 關鍵限制
system Agent 身分、規則、skills metadata 由 trusted host 組裝
user 目標、後續指令、compact summary 不可假裝成 tool result
assistant final text 或 tool calls 每個 tool call 必須有對應 result
tool 外部能力的觀察結果 tool_call_id 必須精確配對
{"role":"assistant","content":null,"tool_calls":[
  {"id":"call_1","type":"function","function":{"name":"read","arguments":"{\"path\":\"README.md\"}"}}
]}
{"role":"tool","tool_call_id":"call_1","content":"..."}

如果只保存 assistant tool call,卻在 crash 或取消時遺失 result,下一次 provider request 可能直接拒絕整段 transcript。這也是 tiny-agent 在 tool 中斷時,會替尚未開始或結果未知的 calls 補上固定 synthetic result 的原因。

Provider Adapter 必須正規化

前面提到的翻譯步驟,實際上長這樣:normalizeAssistantMessage 會逐欄檢查 provider 回來的東西——角色是不是 assistant、內容是不是字串、每個 tool call 有沒有合法的 id/function.name/arguments——只有通過檢查的欄位才會被放進乾淨的 Message,其他 provider 專屬欄位(reasoningrefusal 等)一律丟棄,不會流進 Session。

function normalizeAssistantMessage(value: unknown): Message {
    // 驗證 role、content 與每個 tool call
    // 只建立 canonical fields,不回傳 provider object
    return {
        role: "assistant",
        content,
        ...(toolCalls ? { tool_calls: toolCalls } : {}),
    };
}

Type assertion 只影響編譯器,不會移除 runtime 欄位。正確 seam 是:

provider wire shape
→ validate + normalize
→ canonical assistant message
→ Agent / Session

Stop Reason 也是狀態

不能只看「有沒有 tool calls」。Provider 的 finish_reason 至少要映射成:

  • stop:正常 final answer。
  • toolUse:tool calls 已完整產生,可進入 dispatch。
  • length:輸出被截斷;其中 tool arguments 不可執行。
  • provider error:保存 failure,不偽造 assistant completion。

length 伴隨 tool calls,Agent 會寫入固定的 truncated synthetic results;這保護 transcript,也避免執行不完整 JSON arguments。

Usage 是每次 Physical Request 的帳

type Usage = {
    input: number;
    output: number;
    cacheRead: number;
    cacheWrite: number;
};

OpenRouter 的 prompt tokens 可能已包含 cache tokens,因此 tiny-agent 正規化為:

input = prompt_tokens - cacheRead - cacheWrite
cacheHitRate = cacheRead / (input + cacheRead + cacheWrite)

model.completed.cacheHitRate 表示單次 request,可找出某一輪 cache 崩潰;run/session 的 rate 則由累積 counters 重算。不要把「最後一輪 rate」和「整段累積 tokens」混在一起。

親手驗證

用既有的 offline regression test 重播真實 provider wire shape;它會加入 reasoningrefusal 與額外 tool-call 欄位,然後確認 Session 只留下 canonical message:

npm --prefix typescript test -- \
  --test-name-pattern="normalizes provider-only"

npm --prefix typescript test -- \
  --test-name-pattern="rejects malformed provider assistant"

第一組應成功完成且可重新開啟 Session;第二組應保存一次 usage 與 failed outcome,而不是讓 INVALID_FACT 洩漏到 CLI。接著閱讀 test 中的 mock response,逐欄標出哪些屬於 provider wire、哪些能進入 canonical transcript。

合法 transcript 保證了資料「長什麼樣」,但沒保證「誰能把它變成真正的檔案讀寫或指令執行」——這是下一章的問題。