OMP 內部設計導讀系列第 16 篇。前面幾篇拆的是 loop、context、TUI 與工具邊界;這篇往下看一個常被簡化成「模型吐字」的介面:stream。
TL;DR
agent_start/agent_end描述一次完整 agent run;turn_start/turn_end描述其中一次 model call 加工具執行。message_update只代表 assistant 訊息的增量更新;工具進度是另一條tool_execution_update事件。AgentMessage可以包含 UI 或應用程式自訂訊息,呼叫模型前才由convertToLlm篩選、轉成 LLM 看得懂的訊息。streamMessage是串流中的 partial state,不等於已經寫進messages的完成訊息。- UI 只要對事件型別做明確分派,就能同時支援純文字回覆、工具呼叫、工具進度、錯誤與 retry。
先把四個層級分開
在 @oh-my-pi/pi-agent 裡,stream 不是單一資料型別,而是一組有層級的事件。最外層是 agent run:從 agent_start 開始,直到所有回覆與工具迴圈完成後發出 agent_end。
run 裡面有一個或多個 turn。一次 turn 包含一次模型呼叫,以及模型在這次回覆裡要求的工具執行。模型先回純文字,可能只有一個 turn;模型回 tool call 時,工具結果注入 context 後,agent 會再開下一個 turn,讓模型讀結果並繼續。
turn 裡面又有 message。user、assistant、tool result 都會經過 message_start 和 message_end。assistant 在中間還會收到 message_update,這就是 UI 顯示增量文字與增量 tool call 的位置。
最後才是 tool execution。工具有自己的 start、update、end,不應該被當成 assistant message 的文字片段。
agent run
├─ turn 1
│ ├─ user message
│ ├─ assistant message_update...
│ └─ tool execution
│ ├─ start
│ ├─ update...
│ └─ end → tool result message
└─ turn 2
├─ assistant message_update...
└─ assistant message_end
prompt 的事件順序
最小的 agent.prompt("Hello") 大致會走:
agent_start
turn_start
message_start user
message_end user
message_start assistant
message_update assistant partial
message_update assistant partial
message_end assistant complete
turn_end
agent_end
有工具時,assistant 的完成訊息不是 run 的終點:
message_end assistant with toolCall
tool_execution_start
tool_execution_update optional progress
tool_execution_end
message_start/end toolResult
turn_end
turn_start next model call
message_update...
這個差別很重要。若 UI 收到 assistant 的 message_end 就把整個 run 標記為完成,工具呼叫會被畫成「模型已經說完」,下一輪真正的回覆反而沒有地方顯示。正確做法是把 message 完成與 agent run 完成視為兩件事。
AgentMessage 不是直接送給模型的格式
OMP 允許 AgentMessage 放入應用程式自己的訊息型別,例如 UI 通知或 session 事件;LLM 則只理解 user、assistant、tool result 等標準訊息。兩者之間有一條明確的轉換邊界:
AgentMessage[]
→ transformContext() // 可選:裁剪、補外部 context
→ convertToLlm()
→ LLM Message[]
→ model stream
transformContext 解決「這次要讓模型看到哪些訊息」;convertToLlm 解決「自訂訊息要如何被過濾或改寫」。把這兩件事混在 UI 的 stream handler 裡,會讓顯示狀態和模型 context 互相污染。UI 可以顯示一個 notification,但不代表 notification 必須進 prompt。
partial state 與已提交訊息
AgentState 同時有 messages、isStreaming 和 streamMessage。串流期間,streamMessage 保存目前的 partial assistant message;完整訊息結束後,才由事件與狀態更新交給應用程式保存或渲染。
因此 UI 最好採用兩條路徑:
- 收到
message_update時,用 delta 或 partial state 更新正在畫的 assistant bubble。 - 收到
message_end時,將它視為可持久化的完整訊息;不要把每一個 delta 都 append 成獨立 message。
這也是為什麼 Agent 的 subscribe API 比定時讀取 state 更合適:事件告訴你「發生了什麼」,state 告訴你「現在長什麼樣」。前者適合增量渲染,後者適合重繪與恢復。
工具進度是另一種輸出
工具可以在 execute 裡透過 onUpdate 回報進度;README 的例子用它傳出「Reading...」和工具自訂的 details。這些資料適合顯示成可折疊的執行卡片,不要拼到 assistant 的自然語言回覆裡。
const readFileTool = {
name: "read_file",
label: "Read File",
parameters: type({ path: type("string") }),
execute: async (toolCallId, params, signal, onUpdate) => {
onUpdate?.({
content: [{ type: "text", text: "Reading..." }],
details: { path: params.path },
});
return { content: [{ type: "text", text: "..." }] };
},
};
一個實用的 UI reducer 可以只關心三件事:assistant 的文字增量累積到目前 bubble、tool update 更新工具卡片、end 事件把卡片鎖定。其他 lifecycle event 則負責 spinner、turn 分隔線與 retry 按鈕。這比從文字內容猜「模型是不是在叫工具」穩定得多。
低階 async iterator 與高階 Agent
高階 Agent 適合一般應用:設定 initial state、訂閱事件、呼叫 prompt()。需要自己驅動 context 或測試事件序列時,可以使用 低階 agentLoop 的 async iterator:
for await (const event of agentLoop([userMessage], context, config)) {
render(event);
}
這裡的取捨很清楚:高階 API 隱藏 queue、state 與 lifecycle 的管理;低階 API 把每個事件交給呼叫端,方便做 recorder、snapshot test 或自訂 transport。若只是想顯示文字,不需要自己重寫 agent loop;若要驗證「工具 timeout 後是否仍會產生下一個 turn」,低階 iterator 才提供足夠觀察力。
整體來說
OMP 的 stream 契約把「模型的增量輸出」放回更大的 agent lifecycle 裡。它不是單純的 token pipe,而是 UI、工具執行、context 轉換與錯誤處理共用的控制面。
真正要實作時,先畫出 agent/turn/message/tool 四層,再決定每個事件要更新哪個 state。這樣即使未來加入 steering、follow-up、工具 streaming 或 retry,UI 也不必靠字串猜測內部狀態。
參考資料
Loading...