做過 Agent 產品的人大概都有過類似的體感:調通一個 demo 很快,但把它變成一個使用者每天敢用、能在自己機器上跑一整天不出岔子的東西,難的根本不是接模型,而是模型之外的那一整層。
那一層有個不太統一的叫法——Agent Harness。它夾在「大模型」和「業務功能」之間,是真正的運行時:負責把一次使用者請求翻譯成一輪又一輪和模型的對話,在中間穿插工具呼叫、把結果餵回去、在上下文要爆的時候做壓縮、在網路抖動時重試、在進程崩潰後還能把對話救回來。模型負責「想」,harness 負責讓這些「想」真正落地成一連串可靠的動作。
Orkas 是一個跑在使用者本機的桌面 Agent 應用,它的 harness 完全做在客戶端。這篇文章拆一下這一層是怎麼搭起來的:整體怎麼分層、運行循環長什麼樣、工具和模型怎麼抽象、記憶和會話又是怎麼處理的。代碼細節做了脫敏和泛化,但工程結構是真實的。
先看分層
把一個 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 } // terminalUI 層訂閱這個事件流,就能實時把模型的輸出和工具的執行過程畫到屏幕上。而非流式的呼叫入口,內部其實就是把這個流消費完、只取最後的 done——兩個入口共用一套邏輯,不存在兩份會跑偏的實現。
一個 turn 里發生了什麼
把循環展開,一輪(turn)大致是這樣:
- 把使用者消息(可能帶圖片)塞進會話歷史;
- 拼系統提示詞,這裡會注入當前可用的工具、技能索引等;
- 解析模型字符串,定位到具體的 Provider 和模型 ID;
- 把所有工具轉成模型能理解的定義,連同歷史一起發出去;
- 消費模型返回的流,逐字
yield文本,同時收集模型發起的工具呼叫; - 流結束後看模型的停止原因:
- 如果是
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 要麼拒絕、要麼掛住。
自愈邏輯在每次從磁盤加載會話時跑一遍,而且是冪等的:
- 掃所有助手消息,收集它們發起的工具呼叫 ID;
- 往後找對應的工具結果;
- 哪個呼叫沒有配對結果,就給它補一條合成的結果,內容標記為「已中斷」;
- 順手把結果順序對齊到呼叫的聲明順序,並丟掉那些找不到對應呼叫的孤兒結果。
跑完這一遍,會話一定處在符合 API 配對要求、可以安全發出去的狀態。這個機制看著不起眼,但它是「使用者的對話不會因為一次崩潰就徹底卡死」的兜底。
幾個回頭看挺關鍵的決定
把這套東西串起來,有幾個決定事後看價值很大。
用生成器做主介面。 流式和非流式共用一套邏輯,中間狀態天然透得出來,UI 想畫多細就畫多細。這比「先實現非流式、再單獨補一套流式」省掉了一整類不一致的 bug。
60% 就開始壓縮,而不是等撐滿。 給壓縮本身(它也要調一次模型)留了餘量,也避免在最後一刻手忙腳亂。
配對不變式貫穿始終。 從壓縮的切割點、到落盤、到加載自愈,所有改動會話的地方都守著同一條規則。規則統一了,各處就不用各自發明各自的修補邏輯。
髒活集中在 Provider 層。 跨廠商的所有彆扭——思考塊、緩存鍵、能力差異——都摁在這一層消化掉,換來上面 runner 的乾淨。哪天要加一家新模型,改動基本不外溢。
小結
Orkas 的 harness 沒有什麼驚人的算法。它的價值在於把「讓一個 agent 在真實環境里可靠地跑」這件事,拆成了一組邊界清楚、各管一段的模塊:runner 管循環和重試,工具管能力,Provider 管抹平多模型,記憶管檢索,會話管持久化和自愈。每一塊單看都不複雜,湊在一起才撐得住一個能天天用的東西。
真要總結點經驗:運行循環做成流式生成器,中間狀態好透出去得多;核心不變式(比如工具呼叫必須配對)一旦定了,就得在壓縮、落盤、加載每一處都一致地守,別讓任何一個角落例外;跨廠商的髒活越集中越好,滲進業務邏輯就再難收拾。還有一條最樸素的——預設你的進程會在最糟的時刻被殺掉,然後提前把自愈寫好。
下一篇接著講 Orkas 更有意思的一塊:這個 agent 是怎麼從自己的使用過程里學習、把經驗沈澱成可復用的技能,慢慢把自己變得更好用的。