Agentic Research
首頁/實測/我們如何修好 OkHuman 的語義搜尋,再給它裝上網絡搜索

我們如何修好 OkHuman 的語義搜尋,再給它裝上網絡搜索

2026/10/0611 分鐘Bryan Chan最後更新 2026/10/06

一個 400 錯誤,藏在三層日誌底下。health 端點說一切正常,語義搜尋卻已經靜默降級成關鍵詞匹配。我們花了一個下午追蹤這條根因鏈,最後只用一行程式碼修好它。然後我們發現:修好之後,這個 agent 還是不能上網搜索。於是我們從零建了一個搜索插件,包含三個供應商、自動回退鏈、以及完整的端點實測。

OkHuman 是一個以 Go 標準庫從零實作的個人 Agent 運行時,主張「一個進程等於一個 agent」,整個專案零外部依賴,工程完成度遠超其社群規模。這篇文章記錄整個修復與插件開發的完整過程,以及我們帶走的可複製方法論。

症狀:health 說它很好,但它其實壞了

OkHuman 的 scout 插件負責兩件事:索引本機的插件與技能檔案,然後提供語義搜尋能力。它用 llama-server 跑一個 Qwen3-Embedding 模型,把檔案轉成向量,查詢時計算相似度打分。

問題是:語義搜尋從來沒有真正工作過,但沒有任何警報。

scout 的 health 端點回傳 {"model":"on"},運維者看到這個回應,直覺反應是「embedding 模型已載入,一切正常」。我們一開始也是這樣判斷的。畢竟 health 端點就是用來確認系統狀態的,它說正常,那應該就是正常了吧?

但我們決定多做一步。我們實際呼叫了 /v1/embeddings:

POST /v1/embeddings
→ 400 Bad Request
→ Pooling type 'none' is not OAI compatible.

embedding 一直在失敗。每一次索引、每一次查詢,embedding 全部回傳 400 錯誤。scout 的處理方式是:記錄一行日誌,然後靜默降級為純關鍵詞匹配。外觀上,搜尋功能還在運作;實際上,語義分量從頭到尾沒有參與過。

這就是問題最危險的地方:不是功能崩潰,而是功能靜默降級,看起來一切正常。

根因鏈:從 400 錯誤到 --pooling last

根因鏈:一個缺失的參數

根因只有一行。scout 在啟動 llama-server 時,傳了 --embedding 參數,但沒有指定 --pooling 類型。llama-server 的預設 pooling 是 none,而這個值不相容 OpenAI 格式的 /v1/embeddings 端點。

// 修復前(plugins/scout/main.go 約行 216-218)
"--embedding", "--no-webui",

// 修復後
"--embedding", "--no-webui", "--pooling", "last",

為什麼選 last?因為 Qwen3-Embedding 官方建議使用 last-token pooling。我們也實測了 mean,結果同樣可用,但 last 是文件推薦的預設值。

這裡有一條值得記住的教訓:health 端點說 model:on,只代表模型檔案已載入 GPU,不代表 embedding 端點可用。這兩件事是不同的。model:on 只是確認了模型權重已經載入記憶體,但 embedding 端點能否正常回傳向量,還取決於 pooling 配置是否正確。要確認 embedding 真的能工作,唯一的方法是實際呼叫 /v1/embeddings 並檢查回傳的向量維度。

我們自己也差點犯了和框架同樣的錯誤:只看了一個便宜的狀態指標就判定通過,沒有去測真正的功能。框架與運維者都會犯這個錯,所以框架應該主動防範,而不是把責任推給運維者的警覺性。

一行修復,然後驗證

加了 --pooling last 之後,重新編譯 scout,端到端驗證:

✅ POST /v1/embeddings → 回傳 1024 維真實向量(非退化)
✅ scout reindex → {"indexed":11,"model":"on"}
✅ 語義查詢 → score 0.6139(breakdown.sem=0.7697,語義分量確實參與計算)

三個檢查全部通過。語義搜尋回來了。

注意我們驗證的方式:不是看 health 端點,不是看啟動日誌,而是直接呼叫功能端點、檢查輸出數值。這是整篇文章最核心的方法論,我們會在最後再強調一次。

還缺一塊:agent 不知道自己能搜索

scout 修好之後,我們發現第二個問題。

OkHuman 原本完全沒有網絡搜索插件。整個倉庫裡 grep web_search|brave|tavily|serp 的結果是零命中。agent 被問到「幫我搜一下某個話題」時,會直接回答「我沒有網絡搜索能力」。

我們決定自建搜索插件。但在建插件之前,有一件更小的事要先做:在 prompts/02-directories.md 加了五行搜索指引,明確告訴 agent 搜索插件的位置與使用方式,並加了一條紅字提醒:「不要回答『我沒有網絡搜索能力』」。

這修的是「agent 不知道自己有搜索能力」的問題。插件建得再好,如果 agent 不知道去用它,等於沒有。

OkHuman 的提示詞系統採用分層設計:prompts/*.md 依檔名排序拼接為系統提示詞,目前只有兩個檔案:01-identity.md 定義 agent 身分,02-directories.md 告訴 agent 去哪裡找工具。我們在第二個檔案中加入搜索指引,確保 agent 在每一輪對話中都能看到這條提示。

端點實測:不靠假設,靠 curl

自建搜索插件的第一步是決定用哪個搜索供應商。我們選了三個:Tavily(專為 LLM agent 設計)、Brave(獨立索引)、以及 DuckDuckGo(不需要 API 密鑰)。

Tavily 和 Brave 都需要密鑰,這很直接。Tavily 是專為 LLM agent 設計的搜索 API,回傳結果已經做過摘要優化。Brave 則擁有自己的獨立索引,不依賴 Google 或 Bing。真正花時間的是 DuckDuckGo 的端點實測。我們沒有假設任何端點可用,而是逐個 curl:

lite.duckduckgo.com/lite/?q=   GET  → ❌ 被擋(14KB 幾乎全 blocked)
html.duckduckgo.com/html/      POST → ✅ 正常(result__a 命中,0 blocked)
api.duckduckgo.com             JSON → ⚠️ 只有 instant answer,覆蓋窄

lite 版本用 GET 請求會被擋,幾乎全部內容被過濾。html 版本用 POST form 提交則完全正常。api 版本返回 JSON,但只涵蓋 instant answer,搜索覆蓋面太窄。

最終選擇 POST html.duckduckgo.com/html/。還有一個細節:結果中的連結是 /l/?uddg=<encoded> 格式的重定向,需要對 uddg 參數做 URL 解碼才能拿到真實網址。

這些都不是看文件能知道的。只有實際呼叫才知道。

三供應商插件:自動回退設計

search 插件架構

插件的架構遵守 OkHuman 的解耦契約:獨立 Go 模組、獨立編譯、不 import 主程序、不共享進程或內存、自帶 config.json 管理密鑰。

供應商回退鏈

三個供應商的定位各不同:

Tavily 是專為 LLM agent 設計的搜索 API,回傳結果已經做過摘要優化,實測速度 2.2 秒。Brave 有自己的獨立索引,不依賴 Google 或 Bing。DuckDuckGo 透過 HTML 端點抓取,完全不需要密鑰,實測速度 1.1 秒,是三個裡面最快的。

回退鏈的設計是:預設走 Tavily,如果 Tavily 失敗(密鑰無效、超時、限流),自動改試 Brave,Brave 也失敗就落到 DuckDuckGo。付費供應商提供品質,免密鑰供應商兜底。

插件的使用方式:

search-bin "查詢字串"                     # 預設 tavily,5 筆
search-bin "查詢字串" --n 10              # 指定筆數
search-bin "查詢字串" --provider ddgs     # 免密鑰
search-bin "查詢字串" --json              # JSON 輸出
search-bin --doctor                       # 環境自檢

交付物包含六個檔案:search.go(356 行,三供應商加自動回退加正規化參數)、go.mod、config.json、README.md、meta.json、以及編譯好的 search-bin(9.4 MB)。

端到端證明:不告訴它路徑

插件建好之後,最關鍵的測試是:不告訴 agent 插件在哪裡,讓它自己找到並使用。

我們直接問:「幫我上網搜尋一下『Anthropic MCP』,然後告訴我前兩條結果的標題。」

agent 自行定位到 search 插件,呼叫 search-bin,13.5 秒後回傳了真實的搜索結果。標題正確,內容正確。

這個測試證明了三件事:搜索插件功能正常;回退鏈正常運作;提示詞中的五行指引足夠讓 agent 在不知道路徑的情況下自主找到插件。

值得一提的是,OkHuman 的工具模型是「唯一 bash 元工具」,所有外部操作都透過 bash 完成。這意味著 agent 找到插件後,是透過 bash 呼叫 search-bin 命令列來執行搜索的。整個鏈路從自然語言理解、到定位插件、到組裝命令列、到解析搜索結果,全程由一個 9B 的本地模型完成。

reindex 之後,scout 的語義索引也能正確索引到新的 search 插件。兩個修復形成閉環:scout 能索引新插件,新插件提供搜索能力,搜索結果又透過 scout 被索引。

帶走的方法論

整個過程可以提煉成四條原則。

第一,量測而非猜測。health 端點說 model:on,我們差點就信了。唯一可信的是實際呼叫功能端點、檢查輸出數值。任何「狀態正常」的宣告,如果沒有經過功能層級的驗證,都可能是錯的。

第二,根因鏈。從症狀到根因,中間可能隔了好幾層。400 錯誤的根因不是「embedding 壞了」,而是「啟動時缺了一個參數」。停在第一層會讓你修錯東西。每多問一層「為什麼」,就離真正的修復近一步。

第三,最小修復。最終的修復只有一行程式碼。好的修復不是加更多程式碼,而是找到那個最小的、精確的改動點。一行能解決的問題,不要用十行。

第四,端到端驗證。修復之後,不是跑一個 unit test 就結束。要從使用者的角度,走完整個流程,確認最終輸出正確。我們不告訴 agent 插件路徑,就是為了驗證端到端的真實行為,而不是驗證一個被繞過的捷徑。

這四條原則不只適用於 OkHuman。任何涉及多層抽象的系統,都會遇到「上層以為下層正常,但下層其實已經靜默失效」的問題。health 端點是一個抽象層,它告訴你「一切正常」,但這個正常可能只是部分正常。embedding 模型載入是正常,但 pooling 配置錯誤導致端點不可用。如果你只檢查上層,問題就會在下層靜默存在。

解法永遠是同一個:不要相信狀態宣告,去量測實際行為。從最底層的功能端點開始驗證,一層一層往上確認,直到使用者可見的最終輸出。只有這樣,你才能確定系統是真的正常,而不是「看起來正常」。

這條根因鏈的每一步都可獨立重現:先不帶 --pooling 啟動 llama-server,用 curl 打一次 /v1/embeddings 看它回 400,再加上 --pooling last 重試,兩次回應的差異就是最直接的證據。

下一步

這次修復讓我們對 OkHuman 的插件架構與解耦設計有了更深的理解。如果你對背後的架構細節感興趣,以下幾篇相關文章可以幫助你更完整地理解整個系統: