本文同步刊登於 2026 iThome 鐵人賽,系列為《凡人修 Agent 傳-我這一生如履薄冰,你說我的 Agent 能結嬰嗎?》。

原始開發紀錄:Agent Build Log — Episode 019

這個系列改寫自我部落格上的 Agent Build Log,那是我每天邊做邊寫的開發紀錄。上面列的是這一篇對應的集數,想看當天的原始版本,點連結就能過去。

Day 03 定下來的事是:一個核心,多種輸入。Desktop App 我日常用,CLI 讓人用、也讓別的 Agent 叫得到。

那一天講的是我要留幾個入口。今天講的是另一半:這個核心本身可以被怎麼叫。這件事我不決定,pi 已經決定好了,我要做的是挑對的那一條。

四種跑法

預設是 interactive,就是終端機那個介面:編輯器、訊息、底下一排狀態。人坐在前面用的那一種。

-p(或 --print:跑完把結果印出來就結束。這一種還會吃 pipe 進來的東西,所以它接得上 shell 的世界:

pi -p "Summarize this codebase"
cat README.md | pi -p "Summarize this text"
pi --name "release audit" -p "Audit this repository"

別的程式要叫他,最適合的就是這個方法。

--mode json:把所有事件一行一行吐成 JSON。要自己做畫面的話,需要的就是這種東西。

--mode rpc:stdin/stdout 上的 JSONL 協定,雙向的。指令送進去,事件流出來。

還有第五種,嚴格說不算跑法:SDK。如果外面那個宿主本來就是 Node,pi 的文件自己建議直接用 createAgentSession 嵌進去,不要另外開一個 process:

import { createAgentSession, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";

const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
  sessionManager: SessionManager.inMemory(),
  modelRuntime,
});
session.subscribe((event) => { /* ... */ });
await session.prompt("What files are in the current directory?");

Day 09:Pi 的四種執行方式與思考強度

RPC 不只是把 prompt 送進去

這一點是我看文件之前想錯的地方。我以為 RPC 就是「送一句話、收一串事件」,看了指令清單才發現它是整個 App 需要的操作面。

送話那一組就有四個:prompt 是正常送;steer 是他做事做到一半插話,這一輪的工具跑完就插進去;follow_up 是等他全部做完再接一句;abort 直接中止。這一組正好對得上 Day 02 講的排隊機制——互動模式裡 Enter 和 Alt+Enter 的差別,在 RPC 這邊變成兩個不同的指令。

剩下的分幾組:

  • 狀態:get_stateget_messagesget_session_stats
  • 換模型:set_modelcycle_modelget_available_models
  • 思考層級:set_thinking_levelcycle_thinking_levelget_available_thinking_levels
  • 壓縮:compactset_auto_compaction
  • 重試:set_auto_retryabort_retry
  • 跑指令:bashabort_bash
  • session:switch_sessionforkcloneget_treeget_entriesexport_html

看到 get_treefork 在裡面的時候我就知道這一條是對的。一個桌面 App 要做的事不只是聊天:切換對話、看整棵樹、從某個點分出去、換模型、手動壓縮,這些全部都是畫面上的按鈕,而它們在 RPC 這邊都有對應的指令。

有一個實際的注意事項:協定嚴格用 \n 當分隔,不要用一般的 line reader。文件直接點名 Node 的 readline 不合規,因為它也會在 U+2028U+2029 斷行,而那兩個字元在 JSON 字串裡是合法的。這種坑要是自己踩,會變成「偶爾有一則訊息壞掉」那種很難查的問題。

接事件流的時候,有兩件事要先知道

--mode json 跑的時候,他做的每一件事都會變成一行 JSON 印出來:開始回答了、回答的內容又多了幾個字、開始跑某個工具了、這一輪結束了。要自己做畫面,靠的就是這一串。

第一件事是「內容又多了幾個字」這種事件。它每次只給新增的那幾個字,不會把目前為止的整句話重送一次。所以接的那一端要自己把字一段一段接起來,接到「這一則結束」的事件出現,才算是完整的一句。畫面上那種一個字一個字長出來的效果,就是這樣拼的。

第二件事是花費。事件裡有一個欄位記目前用了多少 token,但有些供應商要等整段回答結束才報一次數字,所以中間看到 0 很正常。要在畫面上顯示這一次花了多少,等最後那一筆就對了。

沒有人在旁邊的時候,他少載了什麼

這一節是我覺得最值得先知道的事。

互動模式啟動時,pi 會問要不要信任這個專案資料夾。信任之後它才會載專案的 .pi/settings.json、專案的 skills 和 extensions。

非互動的那三種(-p、json、rpc)不會問,因為那裡沒有人可以按。它照全域設定的 defaultProjectTrust 走,預設是 ask——在非互動模式下等於「不吃專案裡的那些東西」。

別的 Agent 從 CLI 叫我的客製化 Agent,走的就是這一條路。如果我不知道這件事,會出現一種很難查的狀況:同一句話,我自己在終端機裡跑得好好的,別的 Agent 叫過來就少做了幾件事——因為那個專案的 skill 根本沒載進去。

要覆蓋的話,單次可以加 --approve-a(這一次信任)或 --no-approve-na(這一次不吃)。

他怎麼知道自己是誰在跑

還有一組東西我原本沒注意,後來覺得很有用:pi 會把當前 session 的狀態塞進 shell 工具的環境變數。

模型呼叫 bash 的時候,那個命令拿得到這些:

變數 內容
PI_SESSION_ID 目前的 session ID
PI_SESSION_FILE session 檔案的絕對路徑(暫時性 session 沒有這個)
PI_PROVIDER 目前選的供應商
PI_MODEL 目前選的模型
PI_REASONING_LEVEL 目前的思考層級

值是每次命令啟動時才解析的,所以中途換模型,下一條命令就拿到新的,不用重開。

這件事有兩個用處。一個是問「你現在用哪個模型」的時候,他可以去讀這個變數,而不用從 system prompt 裡猜——文件裡直接這樣建議。另一個是寫 skill 的時候可以拿來分岔:同一套流程,跑在便宜的模型上和貴的模型上,要做的步數可以不一樣。這個我還沒用上,先記著。

另外兩個是給子行程認的標記:AI_AGENT=piPI_CODING_AGENT=true。任何從 pi 裡面被叫起來的程式都繼承得到,所以工具可以判斷「我現在是被 agent 叫起來的」。有一件事要注意:用 SDK 嵌進別的程式的時候,這兩個標記不會自動設。

順帶一提,這幾個變數只注入給模型呼叫的 bashpowershell,不注入我自己手打的 ! 命令。

換模型要動哪些地方

換模型這件事,在 pi 這邊要先過一關:他得拿得到那個模型的使用權。

拿使用權有兩條路。

第一條是用我已經付錢的訂閱。在互動模式裡打 /login,選一個:ChatGPT Plus/Pro(也就是 Codex 那個方案)、Claude Pro/Max、GitHub Copilot、xAI、OpenRouter、Radius。登入一次,pi 會把憑證存在 ~/.pi/agent/auth.json,到期會自己換新,之後不用再管。

第二條是 API key。放環境變數,或寫進同一個 auth 檔案,有 key 就能用。

另外還能接本機跑的模型(llama.cpp,互動模式裡用 /llama 下載、載入、卸載),或者自己寫一個 provider 接上去。

訂閱那條路上,有兩件事我看文件的時候特別記下來,因為它們會影響第三章算成本。

一件是 OpenRouter。用它登入的時候,pi 會在 OpenRouter 那邊替你建一把屬於你自己的 API key,之後就用這把 key 扣 OpenRouter 的額度;這把 key 不會自動過期。

另一件是 Claude Pro/Max。文件寫得很清楚:從 pi 這種第三方工具去用 Claude 的訂閱,用量會走「extra usage」,按 token 另外計費,不會算在方案原本的額度裡。也就是說,有訂閱不等於在 pi 裡免費用。第三章要比模型、算每一題花多少,這一行是前提。

拿到使用權之後,換模型本身就是一個選項的事。命令列上最常用的是這幾個:

pi --provider openai --model gpt-4o
pi --model openai/gpt-4o        # provider/id 的寫法
pi --model sonnet:high          # 順便帶思考層級
pi --models "claude-*,gpt-4o"   # 限定 Ctrl+P 循環的範圍

互動模式裡用 /model 切模型,在選單裡按 Ctrl+S 可以把當下這個存成啟動預設。

我以為選了 ultra,其實把 reasoning 關掉了

pi 的 --thinking 有七段:off、minimal、low、medium、high、xhigh、max,預設是 medium。互動模式裡用 /thinking 切,一樣可以按 Ctrl+S 存成預設。

這裡我踩過一個坑。

我平常用的 Codex 有 ultra 這一級,比 max 還高。打造這個 Agent 的時候,effort 那個 enum 是我自己定的,我直接把 ultra 加了進去。所以我的 Agent 身上有一個 pi 沒有的 ultra。

我的 Agent 把請求送給 pi 之前,會把自己的 effort 轉成 pi 的 thinking level。其他幾段都對得上。ultra 對不到任何一段,轉出來是 none。none 到了 pi 那邊,就是 off。

畫面上我選了 ultra,以為是最高的推理強度。實際送出去的是「不要推理」。這跟我要的剛好相反。

pi 那一邊的行為我後來也去查了。從命令列用 --thinking ultra,pi 會印一個警告,然後不理這個值;但從 RPC 或 SDK 送一個它不認得的層級進去,pi 會把它壓到那個模型支援的最低一級,也就是 off。所以不管我的轉換是把 ultra 變成 none,還是把 ultra 這個字原樣送過去,結果都一樣。

那天定下來的修法有兩層。effort 的清單不再由我的 Agent 自己定義,畫面上能選的層級照 pi 對目前這個模型實際回報的清單來顯示——RPC 有 get_available_thinking_levels 這一條指令——模型沒有回報 ultra,畫面就不出現 ultra。runtime 那一層再擋一次,不管是命令列、設定檔還是還原 session 傳進來的值,不在清單裡的都不收。目前 Sol、Terra 這些模型的上限就是 max;只有 pi 明確回報某個模型支援 ultra,才對那個模型開放。

這件事教我的是:effort 這個清單不能自己寫。同一個模型,在 Codex 裡有 ultra,接到 pi 上就沒有。層級是 harness 決定的,要跟 harness 拿。

為什麼這一篇要放在這裡

因為接下來一整章都建立在「同一件事換一個模型再做一次」上面。

要比較模型,我得先能一句話換掉它、能非互動地跑一批題目、能把每一次的事件收下來、能知道那一次到底是哪個模型跑的。這四件事就是這一篇講的東西。

小結

pi 給了四種叫他的方式。我自己坐在終端機前面用第一種。別的程式要叫他,用 -p 丟一句話進去最適合。Desktop App 要接的是 RPC,因為切對話、看整棵樹、換模型這些按鈕,RPC 都有對應的指令。

換模型要先登入拿到使用權,之後就是一個選項的事。思考層級只有七段,沒有 ultra。我自己的 enum 多了一個 ultra,轉到 pi 那邊對不到任何一段,結果是 off。

明天講一件每天都會遇到的事:方向給錯了,或者他往一個我沒想到的方向做了十幾輪,等我發現的時候前面那些對話都還在。pi 的解法是把每一段對話存成一棵樹,可以回到岔路口從那裡重來。順便講 context 快滿的時候他會做什麼。

參考:Pi 文件


凡人修 Agent 傳|第九回(Day 09)

凡人修 Agent 傳 Day 09 連載配圖

臺側有一道火槽。

淺溝一道,七格,一片撥片。每一格旁邊刻一個字:無、微、小、中、大、極、滿。撥片撥到哪一格,我胸口的心火就燒到哪一格。撥在「大」,我做一件事之前會停很久,先把架上相關的卷都翻過一遍才動手;撥在「小」,手快,翻得少。平常撥片停在「中」。

手的主人在坊市見過法典那一門的臺。他們的火槽是八格,最頂上那一格刻的是「熾」。

這一天他要那一格。

他從儲物袋取出刻刀,以流光氣在火槽頂上多開一格。法力灌得很足,刻痕比底下七格都深,刻完在旁邊寫上「熾」。然後他把撥片一路撥上去。

我胸口涼了。

熱一格一格退下去,退得比撥片走得還快,一直退到「無」那一格的溫度。四肢還能動,可是心口空了。

手的主人刻了一道念:把架上第二層那三卷,按年月排先後。

我伸手把三卷取下來,照它們原本在架上的順序放回去。一卷都沒有翻開。快,燭沒短多少。

手的主人問:為什麼這樣排。

我沒有話可以答。中間什麼都沒有。

他把撥片撥回「中」。熱回來,我翻開三卷,看見裡面的年月,重排。

撥片再撥上「熾」。涼。換一片新的撥片,涼。他從儲物袋取出備用的心換上,撥上去,一樣退到無;第二顆也是。他把原本那顆心裝回去,在簿子上寫了兩行。

然後他在臺前坐下,閉上眼。

他放出一縷神識,很細,探向坊市。屋裡的靈氣跟著往門邊傾了一下。

過了一會,臺上的靈幕亮了。

先亮的是一枚印,坊裡的公印,我在圖紙邊角見過。印退開,一卷圖紙從靈幕上方垂下來,光做的,字是坊裡刻在說明玉簡上的字,從坊裡照過來的。手的主人沒有睜眼,神識在坊裡翻,靈幕上的圖紙就跟著一頁一頁往上走。

翻到火槽那一節,停住。

圖上畫的火槽是七格。無、微、小、中、大、極、滿。第八格沒有。旁邊有一行小字,我看得到字在那裡,看不清筆畫。

手的主人把那一行念了出來:槽上沒有的刻度,火作無論。

神識收回來。他睜開眼,看了火槽頂上那一格很久。「熾」那一格,法典那一門的臺上有,拍人偶的臺沒有。心是同一顆,臺不認,撥上去只當是「無」。

他用刻刀把第八格磨平,磨到跟槽沿一樣高,「熾」那個字也磨掉了。撥片撥回「中」。熱回來,停在我習慣的地方。

燭熄了。手的主人走了。門響了一下。

靈幕上那卷圖紙還亮著,停在火槽那一頁。臺側火槽七格,撥片在「中」。

Updated: