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

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

訊息、Transcript 與 Provider Adapter

理解 tiny-agent 如何藉由 transcript 延續工作,以及 provider wire format 為何不能直接進入核心。

約 18 分鐘 3 / 10

一句話掌握本章核心:tiny-agent 的 Chat Completions 配接器會由 Host 重送選入的 Transcript 以延續對話。其他 API 也可能提供 Server-managed State。無論狀態由誰管理,Provider 回傳的 Wire Format 都不應未經驗證地進入 Canonical Transcript:本專案以 正規化防線(Normalization Seam)只保留允許欄位。

狀態延續的本質:無對話記憶的模型與忠實的紀錄卷軸

對 tiny-agent 目前使用的 Chat Completions 模式,可想像模型每次通話都需要 Host 提供本輪有效 Context:System Instructions、選入的歷史、工具定義與結果。這個比喻不涵蓋所有 Endpoint。例如 OpenAI Responses API 可透過 previous_response_id 串接 Provider 管理的狀態。

在系統架構中,這個「依序重送歷程的紀錄卷軸」就是 Transcript。

Provider Wire Shape 經過 Normalization Seam 正規化為標準 Canonical Message 架構圖
圖 2.1:Provider Normalization Seam 防線架構——將外部雜訊隔離於 Durable WAL 之外。

四種訊息角色的嚴格語意

Transcript 絕不是隨意的自然語言文字串,而是具有嚴格型別定義與角色約束(Role Invariants)的時序陣列:

Role (角色) 系統語意與職責 信任邊界 核心約束條件 (Invariants)
system 宣告 Agent 身分、行為準則、專案規則與 Skill 定義 受信任(Trusted Host 注入) 通常置於 Transcript 頂端,不可被使用者隨意竄改
user 使用者指令、外部事件或歷史壓縮摘要 (Summary) 不受信任(外部輸入) 不可偽造成 tool 或 system 語意
assistant 模型產出的推理文字或發起的 Tool Call 請求 待驗證(模型生成) 包含 tool_calls 時,後續必須有嚴格對應的 tool 訊息
tool 外部能力執行後的真實觀測結果 (Observation) 受保護(Host Tool Adapter 產出) tool_call_id 必須與前一則 assistant 中的 ID 精準吻合

主流 LLM Provider 對 Message 序列的合法性有極為嚴格的校驗機制:

{"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":"# tiny-agent project..."}
常見錯誤序列 (Illegal Transcripts)引發後果tiny-agent 防禦機制
孤立的 Tool Call
發出 call_1 後行程崩潰,未寫入 tool 回傳即重啟
後續 Request 可能被 Provider 拒絕,也一定違反 tiny-agent 的 Transcript Invariant尚未開始的 Tool 可正常啟動。已開始的 Built-in Read 在宣告相符時可 Replay。不能安全 Replay 的 Pending Effect 才補 Synthetic Interrupted Result
ID 不吻合
tool_call_id 與 Assistant Tool Call 的 ID 不同
可能被 Provider 拒絕或使工具結果無法正確歸屬Reducer 驗證 ID、Tool Name 與 Reference 完整性
無故插入 Tool 訊息
沒有對應 Tool Call 時注入 role: "tool"
違反 Provider Protocol 與本專案的 Canonical Transcript Contract由 Agent Loop 與 Strict Reducer 控制合法狀態流轉

Provider Adapter 必須正規化(Normalization Seam)

核心踩坑警示:TypeScript 的 Type Assertion(如 response.message as Message)只存在於編譯時期,在 JavaScript Runtime 階段完全不會刪除任何欄位!

若直接將 OpenRouter / OpenAI 回傳的原始物件存入 Canonical Session,諸如 reasoning、refusal、annotations 等供應商專屬欄位會污染持久化格式。tiny-agent 的 Strict Reducer 會以 INVALID_FACT / Session Corruption 拒絕不符合 Exact-key Contract 的資料。另一種架構也可另外保存 Raw Events,但不可把未驗證的 Wire Shape 當成 Canonical Message。

// tiny-agent 實作的等價節錄。validateToolCall 代表原始碼內的 inline 欄位驗證
// 核心契約:先拒絕不合法資料,再以白名單重建 Canonical Object
function normalizeAssistantMessage(value: unknown): Message {
    // 1. Wire value 必須是非陣列物件,且 role 必須正確
    if (!value || typeof value !== "object" || Array.isArray(value)) {
        throw new Error("invalid assistant message");
    }
    const raw = value as Record<string, unknown>;
    if (raw.role !== "assistant") throw new Error("invalid assistant message role");

    // 2. content 僅接受 string | null。不合法時拒絕,不做寬鬆轉型
    if (raw.content !== null && typeof raw.content !== "string") {
        throw new Error("invalid assistant content");
    }
    const normalized: Message = { role: "assistant", content: raw.content };

    // 3. tool_calls 若存在,必須是非空 Array,且逐欄驗證 id/type/name/arguments
    if (raw.tool_calls === undefined || raw.tool_calls === null) return normalized;
    if (!Array.isArray(raw.tool_calls) || !raw.tool_calls.length) {
        throw new Error("invalid assistant tool_calls");
    }
    normalized.tool_calls = raw.tool_calls.map(validateToolCall);
    return normalized; // Provider-only 欄位未被複製
}
  1. provider wire shape rolecontenttool_callsreasoningrefusalannotations
  2. validate + normalize 逐欄檢查:role、content、每個 tool call 的 id / name / arguments。不合法 → throw
  3. canonical Message rolecontenttool_calls(provider 專屬欄位已丟棄)
  4. Agent / Session 只有乾淨的 canonical message 進入 transcript 與 durable log
動圖 2:Provider 回應通過 Normalization 防線時的欄位過濾流程(劃線淡出的標籤即為被剔除的專屬欄位)。

Finish Reason 狀態映射

TL;DR:tiny-agent 會交叉驗證 Provider 的 finish_reason 與 Canonical Message。tool_calls / function_call Reason 必須真的含 Calls。length 走截斷分支。而合法、缺省或 stop Reason 若仍帶 Calls,目前實作會依 Calls 正規化為內部 toolUse。

Wire / Internal 狀態模型或傳輸狀態tiny-agent 處置
Wire stop 或缺省 Reason,且沒有 Calls正常推理完畢要求非空 Content,寫入正常終局。空回覆則 Failed
Wire tool_calls / function_call。Internal toolUseProvider 宣告 Tool Call,或合法 Message 實際帶 CallsReason 宣告 Tool Call 卻沒有 Calls 時拒絕。否則進入派發流程
Wire lengthToken 上限觸發截斷若含 Calls,逐一寫入 truncated Synthetic Result 且不執行。若沒有 Calls,Model Step 直接 Failed
Wire content_filterProvider 終止生成保存 Usage 與失敗事件,不偽造 Assistant Completion
HTTP / Network / Parse ErrorTransport 或 Provider Request 根本未產生合法 Completion走 Exception / stepFailed 路徑。不要混稱為 Finish Reason

Provider-reported Token Usage 的標準化

在多輪對話中,保存每一次物理 Request 由 Provider 回報的 Token Usage,是成本控制與除錯的重要訊號。它不是 tiny-agent 以本機 Tokenizer 獨立重算的絕對量測。OpenRouter 說明 Token Counts 由實際路由模型的 Native Tokenizer 計算,明細欄位也可能依 Endpoint 而異:

type Usage = {
    input: number;      // 扣除快取後的純淨輸入 Token
    output: number;     // 本次生成的輸出 Token
    cacheRead: number;  // 命中 Provider Prompt Cache 的 Token 數
    cacheWrite: number; // 寫入 Provider Prompt Cache 的 Token 數
};

OpenAI / OpenRouter 風格的 prompt_tokens 可包含 Cache Read / Write Tokens。tiny-agent 依 Provider 回傳的 Optional Details 採用以下內部 Ledger 公式,缺少的明細以 0 處理:

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

動手驗證 (Hands-on Lab)

請執行既有的離線回歸測試,觀察 Provider 原始髒資料如何被精準清洗:

cd typescript
# 1. 驗證 Provider 專屬欄位被清洗,Session 僅保留 Canonical Message
node --import tsx --test --test-name-pattern="normalizes provider-only" tests/index.test.ts

# 2. 驗證畸形 Assistant Message 會被安全拒絕,不會引發未捕獲例外
node --import tsx --test --test-name-pattern="rejects malformed provider assistant" tests/index.test.ts

嚴格的 Transcript Contract 與正規化防線,使經過目前 Adapter 與 Reducer 接受的資料維持 Canonical 結構。Storage Corruption、未來 Schema 變更與實作 Bug 仍須 Fail-closed 處理。下一章將探討誰把這些 JSON 轉化為檔案讀寫、Shell 執行與背景行程:Tool 的設計哲學與執行守衛。