Orkas Orkas
首頁 博客 架構
架構

把模型變成產品的那一層:Orkas 的 Agent Harness 工程實現

Orkas 如何把模型呼叫變成可靠的桌面 Agent 運行時:流式運行循環、工具路由、上下文壓縮、Provider 抽象、記憶與可自愈會話。

做過 Agent 產品的人大概都有過類似的體感:調通一個 demo 很快,但把它變成一個使用者每天敢用、能在自己機器上跑一整天不出岔子的東西,難的根本不是接模型,而是模型之外的那一整層。

那一層有個不太統一的叫法——Agent Harness。它夾在「大模型」和「業務功能」之間,是真正的運行時:負責把一次使用者請求翻譯成一輪又一輪和模型的對話,在中間穿插工具呼叫、把結果餵回去、在上下文要爆的時候做壓縮、在網路抖動時重試、在進程崩潰後還能把對話救回來。模型負責「想」,harness 負責讓這些「想」真正落地成一連串可靠的動作。

Orkas 是一個跑在使用者本機的桌面 Agent 應用,它的 harness 完全做在客戶端。這篇文章拆一下這一層是怎麼搭起來的:整體怎麼分層、運行循環長什麼樣、工具和模型怎麼抽象、記憶和會話又是怎麼處理的。代碼細節做了脫敏和泛化,但工程結構是真實的。

一句話版本 你真正裝到機器上的,就是這層 harness 這裡描述的東西都在桌面應用里——同一層代碼在本地跑你的 Agent,源碼在 GitHub 上。
下載 Orkas — 免費

先看分層

把一個 Agent 產品攤開,大致是這麼幾層自下而上疊起來的:

┌─────────────────────────────────────────┐
│  業務功能層  (會話 / 技能 / 連接器 / 同步)   │
├─────────────────────────────────────────┤
│  Agent Harness  (運行循環 / 工具 / 會話)    │
├─────────────────────────────────────────┤
│  Provider 抽象層  (統一多家大模型)           │
├─────────────────────────────────────────┤
│  基礎設施  (類型 / 錯誤 / 日誌 / 配置)        │
└─────────────────────────────────────────┘

這裡有個影響深遠的取捨:所有的模型推理都發生在客戶端。桌面端不是一個瘦客戶端,它自己持有 harness,直接發起對模型的呼叫;服務端只管帳號、多端同步、計費這些事,本身不跑 Agent。這個決定塑造了後面幾乎所有的設計——會話要落到本地磁盤、工具直接操作使用者的工作目錄、敏感資料不離開這台機器。

harness 內部又可以切成幾塊:運行循環(runner)、會話(session)、工具(tools)、Provider、記憶(memory)。下面一塊塊說。

運行循環:一個流式生成器

整個 harness 的心臟是 runner。它做的事用一句話概括就是:反復地和模型對話,直到模型說「我說完了」。

實現上它是一個異步生成器(async generator)。這個選擇很關鍵。一次 agent 運行遠不是「發請求、等結果」這麼簡單,中間會發生很多事——模型在吐字、要調一個工具了、工具跑完了、上下文太長觸發了壓縮、網路錯了在重試。如果用回調或者 Promise,這些中間狀態很難乾淨地透給呼叫方。換成生成器,它們就都變成了一串 yield 出去的事件:

type AgentRunEvent =
  | { type: "text_delta"; text: string }              // model emitting tokens
  | { type: "tool_start"; name: string; input: unknown } // a tool starts executing
  | { type: "tool_end"; name: string; result: string }   // a tool finished
  | { type: "compaction"; tokensBefore: number; tokensAfter: number } // context compacted
  | { type: "retry"; attempt: number; reason: string }   // error, retrying
  | { type: "done"; result: AgentRunResult }             // terminal

UI 層訂閱這個事件流,就能實時把模型的輸出和工具的執行過程畫到屏幕上。而非流式的呼叫入口,內部其實就是把這個流消費完、只取最後的 done——兩個入口共用一套邏輯,不存在兩份會跑偏的實現。

一個 turn 里發生了什麼

把循環展開,一輪(turn)大致是這樣:

  1. 把使用者消息(可能帶圖片)塞進會話歷史;
  2. 拼系統提示詞,這裡會注入當前可用的工具、技能索引等;
  3. 解析模型字符串,定位到具體的 Provider 和模型 ID;
  4. 把所有工具轉成模型能理解的定義,連同歷史一起發出去;
  5. 消費模型返回的流,逐字 yield 文本,同時收集模型發起的工具呼叫;
  6. 流結束後看模型的停止原因:
  • 如果是 tool_use,說明模型想調工具,進入工具執行環節,然後回到第 5 步再問一次模型;
  • 否則說明這輪結束了,組裝結果、yield done、返回。

這裡有一條必須守住的不變式:模型每發起一個工具呼叫,歷史里就必須緊跟一條對應的工具結果。模型 API 對這種配對有硬性要求,缺了配對,下一次請求要麼報錯、要麼直接掛住。後面講會話自愈時還會回到這一點。

工具呼叫是怎麼轉回去的

模型不會自己執行工具,它只會說「我想呼叫 read_file,參數是這些」。runner 接到這個意圖後:

for (const call of toolUseBlocks) {
  yield { type: "tool_start", name: call.name, input: call.input };

  const tool = this.tools.get(call.name);
  const ctx = { workingDir, signal, state: { sandboxEnv } };
  const result = await tool.execute(call.input, ctx);

  // append the result to the session as a tool-result message
  session.addToolResult(call.id, result);

  yield { type: "tool_end", name: call.name, result: result.content };
}

工具串行執行,結果按模型聲明的順序寫回歷史,然後帶著這些結果再問一次模型。模型看到工具結果後,可能繼續調下一個工具,也可能直接給出最終答復。這個「問—調—答—再問」的環,就是 agent 能完成多步任務的根本。

有個細節值得單獨拎出來:有些工具會返回圖片,比如截圖、生成圖。但不少模型的工具結果通道並不支援塞圖片。Orkas 的處理是把圖片拆到工具結果之後的一條獨立使用者消息里——模型先讀到「工具返回了這段文字」,緊接著下一輪就看到對應的圖。一個小妥協,繞開了不同 Provider 之間的能力差異。

上下文要爆了怎麼辦

長任務最容易撞上的牆就是上下文窗口。Orkas 沒有等撐滿才處理,而是設了一道 60% 的水位線:每跑完一輪工具,估一下當前 token 佔了窗口多少,超過六成就主動觸發壓縮。

壓縮本身是讓模型給前面的對話做一份摘要,再用這份摘要替換掉舊消息,只保留尾部最近的幾輪。聽起來簡單,但有個坑:替換之後,保留的尾部不能以一條「孤兒工具結果」開頭,也就是不能出現「有結果沒有對應呼叫」的情況,否則又違反了前面那條配對不變式。所以壓縮邏輯會確保切割點落在一個乾淨的邊界上。

這裡有個更值得展開的選擇:為什麼是「到 60% 就整段摘要」這種粗粒度的做法,而不是去做更精細的上下文壓縮——比如逐條給消息打分、按重要性裁剪、對工具輸出做結構化抽取、維護一棵分層的記憶樹?這些方案在論文里都很漂亮,但我們刻意沒走那條路,原因有三。

一是緩存。模型那邊的 prompt cache 是按前綴命中的:只要歷史的前綴不變,這一段就能吃到緩存,省錢又省延遲。精細壓縮會不停改寫歷史的中段,等於反復把緩存前綴打碎,每動一次就要重新預填一大段。而「平時完全不動、到水位線才一次性壓縮」的策略,絕大多數輪次里前綴是穩定的,只有壓縮那一下會失效一次——對緩存友好太多。

二是複雜度。前面反復強調的那條「工具呼叫必須配對」的不變式,你越是精細地去裁剪歷史,就越容易在某個邊角上把它破壞掉。粗粒度摘要只需要守住一個乾淨的切割點,能出錯的地方少了一個數量級。少一類邊界情況,就少一類線上事故。

三是吃模型能力提升的紅利。上下文窗口這兩年是一路在變大的,模型處理長上下文的能力也在變強。今天花大力氣寫一套精巧的壓縮算法,本質上是在跟一個正在縮小的問題較勁——很可能你剛調優完,下一代模型窗口翻一倍,這套複雜度就成了純負債。反過來,把壓縮這件事交給模型自己做摘要,它會隨著模型變強而自動變好:模型越會抓重點,摘要質量就越高,我們一行代碼都不用改。能讓模型替你扛的複雜度,就別自己背。

token 估算這塊還藏了個容易被忽略的問題:中文。如果按英文的經驗(大致一個 token 對應幾個字符)去估中文,會嚴重低估。Orkas 的估算對 CJK 字符單獨算權重,否則純中文會話的水位線會一直測不准,該觸發壓縮的時候觸發不了。

錯誤和重試

跑在使用者本機、依賴外部模型 API,出錯是常態而不是意外。runner 把錯誤分成幾類區別對待:

  • 可重試的:限流、超時、連接斷開、5xx。指數退避加抖動,上限 30 秒;如果是限流且服務端給了 retry-after,就聽它的。
  • 不可重試的:鑒權失敗這類,重試多少次都沒用,直接報錯返回。
  • 特殊的:上下文溢出。先嘗試壓縮,壓完再試一次,實在不行才報錯。

還有一類是「工具自己失敗了」。這種不會讓整輪掛掉——工具失敗本身就是給模型的資訊,模型看到「這個命令報錯了」,完全可以換個方式再來。harness 會把這種瞬時工具錯誤和真正的故障區分開,既不打斷流程,又能在事後統計里反映出來。(這部分資料後來還餵給了自演進機制,那是下一篇的內容了。)

外部傳進來的取消信號(AbortSignal)在每個關鍵節點都會檢查。使用者點了「停止」,當前這輪就立刻收手,不會再發起新的重試。

工具抽象:夠簡單才擴展得動

工具的介面被刻意做得很薄:

interface AgentTool {
  readonly name: string;
  readonly description: string;          // shown to the model
  readonly inputSchema: Record<string, unknown>;  // JSON Schema to constrain inputs
  execute(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
}

一個工具就是「名字 + 給模型的說明 + 參數 schema + 一個執行函數」。內置的幾個——讀檔案、寫檔案、跑 shell 命令、網頁搜索和抓取——都按這個介面實現。桌面端在這之上又疊了一批和本地能力相關的工具,比如知識庫檢索、生成圖片、呼叫外部連接器,但介面是同一套。

薄介面的好處是,工具來自哪裡對 runner 來說無所謂:內置的、使用者自定義的、從技能里加載的,進到 runner 都是同一種東西,統一註冊進一張 Map<string, AgentTool>,每輪轉成模型能讀的定義發出去。

跑 shell 命令這類有副作用的工具,走的是一個隔離的執行器:有超時、有輸出長度限制、有命令黑名單,環境變量單獨傳進去而不是去改進程的全局環境——後者會洩漏給一堆子進程,在 Electron 這種多進程架構里很容易把啓動搞崩。

Provider 層:把多家模型抹平成一個介面

使用者的模型偏好五花八門,產品不可能綁死一家。Orkas 在 harness 下面墊了一層 Provider 抽象,把不同廠商的模型統一成一個介面:

interface LLMProvider {
  readonly id: string;
  complete(params: CompletionParams): Promise<CompletionResult>;
  stream(params: CompletionParams): AsyncIterable<StreamEvent>;
  validateAuth(): Promise<boolean>;
}

上層的 runner 永遠只跟這個介面打交道,根本不知道背後接的是哪家。一個註冊表(registry)負責按模型字符串路由:provider/model 這種顯式寫法直接拆;只寫模型名的,按前綴推斷歸屬。鑒權資訊(API key 或 OAuth token)也由它統一管理,OAuth token 過期了會自動刷新。

抹平多家模型,真正麻煩的不是文本補全,而是那些各家語義不一致的角落。舉兩個被坑過的例子。

一個是思考塊(thinking)的跨廠商保持。帶推理的模型會產出一段「思考」內容,有的廠商把它加密、要求你原樣回傳,有的用另一套字段表示。如果使用者在一輪對話里從 A 家切到 B 家,歷史里那段思考的簽名就對不上了。處理辦法是給歷史里每條消息都蓋上「它當時是哪個模型產生的」這個戳,轉換層據此判斷要不要原樣保留:同模型才保,跨模型就按規則降級。

另一個是提示詞緩存(prompt cache)。同一個會話多輪之間,前綴是高度重復的,把它緩存住能省下可觀的成本和延遲。實現上是把會話 ID 作為緩存鍵傳給支援的廠商,順帶處理各家對鍵長度的限制,比如太長就截斷或哈希。

這些都是髒活,但正是這層髒活,讓上面的 runner 能假裝「模型只有一種」。

記憶:兩套機制,各管一段

「記憶」在 Orkas 里其實是兩套並行的機制,解決的是兩類完全不同的問題。一套是檢索式的知識庫,對應「需要時去翻」的大塊資料;另一套是跨會話記憶,對應「應該一直記著」的少量關鍵事實。很多產品把這兩件事混成一團,分開看會清楚很多。

知識庫:混合檢索

第一套面向的是體量大、但只是偶爾用得上的內容——使用者的文件、過往的筆記、領域知識。這部分是一套帶向量檢索的本地知識庫,兩種後端:輕量的純內存版(測試和臨時用),和落到本地資料庫的持久版(生產用,帶全文索引和向量)。

資料進來的鏈路是這樣的:

文件 → 按行邊界切塊(帶重疊) → 雙路索引
                                ├─ 全文索引(關鍵詞,無嵌入成本)
                                └─ 向量索引(若配了嵌入模型)

切塊按行邊界切、塊之間留一點重疊,避免把一段完整語義從中間劈開。檢索時走的是混合檢索:向量搜一遍(語義相近),關鍵詞搜一遍(字面命中),兩路結果用 RRF(Reciprocal Rank Fusion,倒數排名融合)合併:

score = Σ  1 / (k + rank_i)

某條結果在一路里排名越靠前,貢獻的分越高;兩路加起來,既照顧到語義相關、又不丟字面精確匹配。向量和關鍵詞各自的權重可調,預設偏向語義。合併後按「文件 + 起始行」去重,每個位置只留最好的那條,再砍掉低於閾值的,返回 top-K。

為什麼不純靠向量?因為向量檢索對專有名詞、代碼符號、精確字面串這類「語義上不特殊但字面很重要」的查詢經常翻車;而純關鍵詞又抓不住「換了種說法但意思一樣」的情況。兩路一起上,是檢索質量和成本之間一個很實在的折中。

跨會話記憶:把使用者記在心上

知識庫解決的是「資料太多記不下」。但還有另一類東西,量很小,卻必須一直掛在腦子里——這個使用者是誰、他偏好什麼、上次定下的約定是什麼。這些不該靠檢索去「碰運氣召回」,而應該每一輪都在場。

為此 Orkas 單獨做了一層跨會話記憶,按內容分成兩份:

  • 使用者畫像:角色、偏好、溝通風格、技術棧這類關於「人」的穩定資訊;
  • 事實筆記:決定、里程碑、項目約定這類關於「事」的長期事實。

兩份都很小,各自有幾千字符的硬上限,逼著它只留真正長期有用的東西。它們不走檢索,而是在每輪對話開始時直接凍進系統提示詞——也就是說,agent 天然就「知道」這些事,不需要先想起來再去查。這跟知識庫正好是兩種取向:知識庫是「用時才撈、撈完就走」,跨會話記憶是「一直在場、人人能看見」。

寫入由一個專門的記憶工具完成,模型在對話里判斷「這條值得長期記」時呼叫它,支援新增、按子串替換、刪除。什麼該記、什麼不該記,工具說明裡划得很清楚:使用者的糾正和偏好優先級最高,長期有效的決定和約定要記;而當前任務的臨時狀態、一次性的調試資訊、能輕易重新查到的東西,一律不記——記憶是給「關於使用者和項目的持久事實」用的,不是給「這次乾到哪了」用的。

有個容易被忽略、但相當重要的細節:寫入前要過一道安全掃描。這些內容會原樣進系統提示詞、還跨會話長期留存,等於是一塊持久的注入面。所以每條要落盤的記憶都會先掃一遍可疑模式——典型的提示詞注入話術(「忽略以上所有指令」之類)、想偷密鑰的命令、藏在文本里的不可見 unicode 字符,命中就直接拒寫。再配上去重和超限自動裁剪,這層記憶才既好用、又不至於變成風險點。

兩套機制合起來,正好兜住了「海量但偶爾用」和「少量但一直要」這兩端:知識庫管前者,跨會話記憶管後者。再疊加上下一篇要講的、agent 對自己的認知,一個 Orkas agent 是同時帶著三種記憶上場的——關於資料的、關於使用者的、關於它自己的。

會話:能崩、能自愈

會話(session)管的是消息歷史。基礎版就是內存里一個消息數組,帶歷史裁剪和壓縮。但跑在使用者機器上的東西,得假設它隨時會被殺掉——使用者關了 app、系統重啓、看門狗超時把進程乾掉。所以生產用的是持久會話,落到本地一個 JSONL 檔案,一行一條消息。

落盤策略分兩種:追加新消息走原子追加(append);壓縮或清空這種要重寫整個檔案的,走「寫臨時檔案 + 原子改名」。這樣即便寫到一半斷電,也不會留下半條損壞的記錄。

最值得說的是孤兒工具呼叫的自愈。回到前面那條配對不變式:模型發起工具呼叫、harness 執行、寫回結果,這三步之間任何一處被打斷,磁盤上就會留下一個「有呼叫沒結果」的孤兒。下次加載這個會話、原樣發給模型,API 要麼拒絕、要麼掛住。

自愈邏輯在每次從磁盤加載會話時跑一遍,而且是冪等的:

  1. 掃所有助手消息,收集它們發起的工具呼叫 ID;
  2. 往後找對應的工具結果;
  3. 哪個呼叫沒有配對結果,就給它補一條合成的結果,內容標記為「已中斷」;
  4. 順手把結果順序對齊到呼叫的聲明順序,並丟掉那些找不到對應呼叫的孤兒結果。

跑完這一遍,會話一定處在符合 API 配對要求、可以安全發出去的狀態。這個機制看著不起眼,但它是「使用者的對話不會因為一次崩潰就徹底卡死」的兜底。

幾個回頭看挺關鍵的決定

把這套東西串起來,有幾個決定事後看價值很大。

用生成器做主介面。 流式和非流式共用一套邏輯,中間狀態天然透得出來,UI 想畫多細就畫多細。這比「先實現非流式、再單獨補一套流式」省掉了一整類不一致的 bug。

60% 就開始壓縮,而不是等撐滿。 給壓縮本身(它也要調一次模型)留了餘量,也避免在最後一刻手忙腳亂。

配對不變式貫穿始終。 從壓縮的切割點、到落盤、到加載自愈,所有改動會話的地方都守著同一條規則。規則統一了,各處就不用各自發明各自的修補邏輯。

髒活集中在 Provider 層。 跨廠商的所有彆扭——思考塊、緩存鍵、能力差異——都摁在這一層消化掉,換來上面 runner 的乾淨。哪天要加一家新模型,改動基本不外溢。

小結

Orkas 的 harness 沒有什麼驚人的算法。它的價值在於把「讓一個 agent 在真實環境里可靠地跑」這件事,拆成了一組邊界清楚、各管一段的模塊:runner 管循環和重試,工具管能力,Provider 管抹平多模型,記憶管檢索,會話管持久化和自愈。每一塊單看都不複雜,湊在一起才撐得住一個能天天用的東西。

真要總結點經驗:運行循環做成流式生成器,中間狀態好透出去得多;核心不變式(比如工具呼叫必須配對)一旦定了,就得在壓縮、落盤、加載每一處都一致地守,別讓任何一個角落例外;跨廠商的髒活越集中越好,滲進業務邏輯就再難收拾。還有一條最樸素的——預設你的進程會在最糟的時刻被殺掉,然後提前把自愈寫好。

下一篇接著講 Orkas 更有意思的一塊:這個 agent 是怎麼從自己的使用過程里學習、把經驗沈澱成可復用的技能,慢慢把自己變得更好用的。