NLWeb 的 ask 端點底層拆開看:文件寫 mode、api.py 讀 generate_mode,網站對 agent 開口多了什麼
一個網站要同時回答人和 agent,底層到底得多出什麼東西?
NLWeb 的位置很特別:網站自己是那台 MCP server,而不是去連別人的 server。README 第 3 行的說法是,它簡化網站做對話介面的流程,原生支援 MCP,同一組自然語言 API 同時服務人與 AI agent。這句話很容易讀過去,所以我決定從它底下的零件一個一個拆,拆完再回頭看這句話撐不撐得住。
先講我的立場,免得你讀到最後才發現:我把 NLWeb 當成一份寫得很誠實的示範,不會把它當成可以直接依賴的基礎設施。這個判斷會被哪些事改掉,放在文章後段。
第一個零件:它沒要你重做標記
README 第 5 行說,Schema.org 以及 RSS 這類半結構化格式,被超過一億個網站使用,已經是事實上的內容散布機制,也是網頁的一層語意。NLWeb 直接拿這層來用。「超過一億個網站」是 README 自己的宣稱,我沒有找獨立來源核對,所以這個數字只能當作它的說法。
這個選擇很省事。網站如果早就把商品、食譜、景點、評論標成結構化清單,就不用為 agent 重新標一次。README 的「How It Works」把整件事拆成兩塊:一個用自然語言跟網站互動、以 Schema.org 的 JSON 回應的簡單協定,加上一份直接利用網站既有標記的實作。
它同時很坦白地說,這份實作碼是概念驗證,展示的是一種可能做法,不是定案。這句話在後面會一直回來。
第二個零件:入口只有兩個,而且是同一組參數
REST 文件寫得很直接:目前支援兩個 API,端點是 /ask 和 /mcp,參數相同,大部分功能也相同。必填的只有 query。選填有 site、prev、decontextualized_query、streaming(文件寫預設為 true)、query_id、mode。
那對話脈絡放哪?文件說,這份實作沒有任何伺服器端狀態,所以之前聊了什麼,得由呼叫者在下一次請求裡自己帶回來。這代表網站不記得你,記得的是 client。對 agent 來說這反而順手,因為 agent 本來就握著自己的上下文。對一個想直接做成聊天視窗的前端來說,這一段就得自己接。
回傳的 JSON 有 query_id,以及一個結果陣列。每筆含 url、name、site、score、description(文件註明是 LLM 生成的)和 schema_object(資料庫裡那筆項目的 JSON)。文件也預告,陣列之後會換成更豐富的結構,起點是 Schema.org 的 ItemList。
一次查詢,背後不只一次 LLM
docs/nlweb-prompts.md 說,處理一次查詢,NLWeb 會做多次 LLM 呼叫。前段(Pre)包括判斷這個問題跟網站是否相關、從對話歷史組出一句脫離上下文的查詢、辨識問題是否提到了該記下來的事。然後是排序。最後可選的後段(Post)有摘要,以及「用排名最前面的結果試著回答使用者的問題」,文件自己說這比較接近傳統 RAG。提示詞放在 config/site_types.xml。
所以 /ask 看起來是一個端點,背後是一串模型呼叫。每次問答的延遲和費用怎麼算,是部署的人要自己量的事。文件沒有給這方面的數字,我也沒有數字可以給。
讓我意外的一格:mode 去哪了
REST 文件說,mode 的值有 list(預設)、summarize、generate。list 回傳後端最相關的前幾筆,generate 則比較像傳統 RAG。
接著我去讀 AskAgent/python/webserver/routes/api.py。第 112 到 113 行的註解與程式碼是這樣:依 generate_mode 決定用哪個 handler,然後 generate_mode = query_params.get('generate_mode', 'none')。而且只有值等於 generate 的時候才走 GenerateAnswer,其餘情況用 NLWebHandler。
同一個檔案的第 69 到 70 行,確實有把 prefer.mode 寫進 query_params['mode']。但那之後,我在這一段沒有看到有任何東西去讀它。
我要把這句話的邊界畫清楚:我只讀了 api.py 的這一段,沒有追進 NLWebHandler 看它裡面是不是另外讀 mode。所以我能說的只有「api.py 這一層的分派看的是 generate_mode」。照文件傳 mode=generate,會不會得到 generate 的行為,我沒有跑,不知道。
MCP 那邊倒是跟 api.py 一致。webserver/mcp_wrapper.py 註冊的 ask 工具,inputSchema 只要求 query,另外有 site(字串陣列)和 generate_mode,列舉值是 list、generate、summarize,預設 list。
| 地方 | 我讀到的參數名 | 條件 |
|---|---|---|
| REST 文件 | mode(list、summarize、generate) |
docs/nlweb-rest-api.md |
| routes/api.py 第 112 到 113 行 | generate_mode,只有 generate 走 GenerateAnswer |
只讀這一層,未追進 NLWebHandler |
mcp_wrapper.py 的 ask 工具 |
generate_mode,列舉 list、generate、summarize |
inputSchema |
三個地方,兩種名字。這種分歧不需要什麼惡意,一份自稱概念驗證的參考實作,文件和程式碼對不上並不稀奇。它真正教我的是讀順序:規格寫的欄位,要連原始碼的那一行一起讀,才知道參數有沒有真的被接上。
它到底在 MCP 上開了什麼口
拆到 MCP 這一層,工具清單比預期短。handle_tools_list 註冊的有:ask(說明寫著對設定好的資料來源做查詢與分析)、list_sites(列出所有可查詢的網站),還有 who。who 只有在 CONFIG.is_who_endpoint_enabled() 為真時才出現,說明是請 who 端點找出最適合回答這個問題的網站。檔案裡還留著一行註解:「TODO: Add additional NLWeb tools here when router integration is ready」。
HTTP 路由在 routes/api.py 第 22 到 28 行:/ask(GET、POST)、/who、/sites、/config(這三個是 GET)。另外 routes/mcp.py 的註解寫明,MCP 一律回一般的 JSON-RPC,不走 SSE,而且 MCP 裡的 streaming 參數是協定本身的事,不是 HTTP streaming。檔案裡還留著 handle_mcp_streaming 這個函式,但 mcp_handler 只呼叫 handle_mcp_regular。
serverInfo 在第 144 到 145 行:name 是 nlweb-mcp-server,version 是 1.0.0;協定版本常數在第 27 行,2024-11-05。
回頭測那句 HTML 對 HTTP
README 第 23 到 27 行有一句很漂亮的話:「In short, NLWeb is to MCP/A2A what HTML is to HTTP.」前面還有一句,每個 NLWeb 實例同時是 MCP server(以及即將支援的 A2A),並支援核心方法 ask。A2A 那一半,我沒有在倉庫樹內查證有沒有實作,README 自己也寫「soon」,所以這篇不談它。
那就拿能驗證的那一半來量。我讀到的工具只有 ask、list_sites、有條件才出現的 who,外加那條 TODO。HTML 之所以能放在 HTTP 上面,是因為有很多不同的瀏覽器、不同的伺服器各自照同一份規格去做。NLWeb 這邊,README 指向一份「NLWeb spec」,我去讀了,沒讀到。nlweb.ai/spec 在我這邊回的是憑證錯誤,主機名稱對不上憑證的 altnames,憑證是 *.github.io 的。所以規格全文我完全沒看過,規格怎麼定義 ask,這篇不能寫。
我的結論因此卡在中間:在我讀得到的範圍裡,只有這一份參考實作在發言,而它自己也說這是概念驗證。要說它像 HTML 對 HTTP,還缺別人家的實作,也缺那份我沒讀到的規格。
現況數字,和一條時間線
以下全是 2026-10-27 用 gh api repos/nlweb-ai/NLWeb 查到的:授權 MIT,沒有封存(archived = false),6256 顆星、695 個 fork、65 個 open issues,52 位 contributors,70 個分支。沒有 GitHub Release,也沒有 tag。舊網址 microsoft/NLWeb 會轉到 nlweb-ai/NLWeb,但 docs/nlweb-hello-world.md 第 18 行的 clone 指令還寫舊網址。
預設分支 main 的最後一筆 commit 是 2026-06-10,是 dependabot 的 Merge pull request #451;2026-06-11 之後,main 沒有新 commit。pushed_at 是 2026-08-11,因為其他分支有推送。為什麼會這樣,我沒有查,所以這裡不下「停止維護」之類的結論,只擺數字。
NLWeb 倉庫上讀得到的幾個日期
2025-04-28
倉庫建立(created_at)。
2025-06-01、2025-06-23、2025-07-29
docs/release_notes/ 底下只有這三份。最後一份標題是「Updates from July 9 - July 29, 2025」,結尾寫合併 15 個 PR、11 位開發者。
2026-06-10
main 的最後一筆 commit,dependabot 的 Merge pull request #451(b423f15)。
2026-08-11
pushed_at,任一分支的最近推送。
README 第 87 行還寫著,CI/CD pipeline 還沒包含在內,歡迎貢獻自動化測試或部署流程。倉庫模組方面,README 列了 AskAgent、AgentFinder(跨網站找 NLWeb agent 並路由)、DataFinder(把自然語言轉成對企業資料源的 SQL)、ModelRouter、NLWebScorer。我只拆了 AskAgent,另外四塊沒讀。
會讓我改變想法的條件
我現在的判斷是:這是值得讀的設計草圖,不是我會放進正式環境的依賴。理由全在上面這些:自稱概念驗證、沒有 release 與 tag、規格我讀不到、文件與原始碼的參數名對不上。
如果有一天我讀得到 nlweb.ai/spec,而且 ask 的參數在規格裡定義清楚,與原始碼互相對得上,我會調整。另外,如果出現第二份不是這個倉庫的獨立實作,HTML 對 HTTP 那個類比才算開始有證據。反過來說,如果我追進 NLWebHandler 發現它其實也吃 mode,那這篇「文件與原始碼對不上」的那一節就要改寫成「分派在更深一層」。
我讀了什麼、沒做什麼
只讀文件與原始碼:README、docs 底下的 REST API、hello world、prompts 三份文件、2025-07-29 的 release notes、routes/api.py、routes/mcp.py、webserver/mcp_wrapper.py,以及 gh api 查到的倉庫資料。沒有 clone、沒有安裝、沒有送過任何一個 /ask 或 /mcp 請求,也沒有拿 MCP client 實際連過它。
hello world 的流程我只能轉述文件:需要 Python 3.10 以上,進到 AskAgent/python 安裝 requirements,複製 .env.template 成 .env,改三個 yaml,用 python -m data_loading.db_load 灌入一個 RSS,再啟動 app-aiohttp.py 開 localhost:8000。預設 LLM 的 preferred_endpoint 是 azure_openai,embedding 預設 text-embedding-3-small。支援的向量庫列了 Qdrant、Snowflake、Milvus、Azure AI Search、Elasticsearch、Postgres、Cloudflare AutoRAG,LLM 列了 OpenAI、DeepSeek、Gemini、Anthropic、Inception、HuggingFace。
最後留一個我自己拆完還在想的問題:如果網站端的 ask 真的普及,每個網站背後都是一串 LLM 呼叫,那 agent 一次問十個網站,帳單是誰在付?










