第一部|最小閉環 · 第 2 章
訊息、Transcript 與 Provider Adapter
理解 tiny-agent 如何藉由 transcript 延續工作,以及 provider wire format 為何不能直接進入核心。
一句話掌握本章核心: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。
四種訊息角色的嚴格語意
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 精準吻合 |
合法 Transcript 是硬限制 (Strict Invariants)
主流 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 欄位未被複製
}
- provider wire shape rolecontenttool_callsreasoningrefusalannotations
- validate + normalize 逐欄檢查:role、content、每個 tool call 的 id / name / arguments。不合法 → throw
- canonical Message rolecontenttool_calls(provider 專屬欄位已丟棄)
- Agent / Session 只有乾淨的 canonical message 進入 transcript 與 durable log
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 toolUse | Provider 宣告 Tool Call,或合法 Message 實際帶 Calls | Reason 宣告 Tool Call 卻沒有 Calls 時拒絕。否則進入派發流程 |
Wire length | Token 上限觸發截斷 | 若含 Calls,逐一寫入 truncated Synthetic Result 且不執行。若沒有 Calls,Model Step 直接 Failed |
Wire content_filter | Provider 終止生成 | 保存 Usage 與失敗事件,不偽造 Assistant Completion |
| HTTP / Network / Parse Error | Transport 或 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 的設計哲學與執行守衛。