我給 AI 搭了個法庭:一個前端仔的 LangGraph 實戰全記錄

我用 LangGraph 從零搭了個「反謠言」搜尋引擎

一個前端背景的程式設計師,學習 Python + AI Agent 管線的全記錄。


靈感來自知乎「直答 Agent」

今年年初,知乎開放了他們的直答 Agent API

說白了就一件事:你提一個問題,它自動去搜尋引擎搜、讀網頁內容、整合分析、給你一個帶來源引用的回答。不需要自己搭知識庫,不需要折騰 RAG 管線——搜尋引擎就是你的「資料庫」,各大引擎的 API Key 一配就能跑。

它提供了三檔模式,挺聰明:

模式干什麼的耗時簡單模式快速直答,適合「今天天氣」這種問題秒級深度模式基於知乎知識庫的深度分析幾十秒DeepSearch即時多引擎搜尋 + 多源整合 + 綜合分析幾分鐘我當時就在想:這其實就是個搜尋類 Agent 啊。

LangChain 學了點、LangGraph 翻了三天文件沒看懂、Agent 概念模模糊糊。但你讓我直接上手寫一個?好像可以。因為搜尋類 Agent 有個天然優勢——資料不用操心

不做知識庫就不需要處理文件切分和 Embedding 品質;不做 RAG 就不需要調 chunk size 和檢索策略;不微調模型就不需要準備資料集。資料全在搜尋引擎裡,你要做的就是:搜 → 讀 → 分析 → 回答。鏈路清晰得跟流水線一樣。

對於想學 AI Agent 的新手來說,這是個絕佳的切入點——API 接入簡單、鏈路可見、效果立竿見影。於是我就動手了。


背景:AI 把職位卷沒了,我選擇主動擁抱它

2026 年 2 月,公司宣布我「畢業」了。

說好聽點叫優化,說難聽點就是被 AI 卷掉了。我寫了七八年 React,元件樹倒背如流,Webpack 配置閉眼調。但今年再刷招募市場,純前端職位縮水了不止一半。履歷投出去,回覆越來越像 ChatGPT 寫的——「你很優秀,但目前我們更需要具備 AI 工程化能力的全端工程師」。

焦慮是真的焦慮。身邊不少同行在抱怨「AI 搶飯碗」,但我覺得與其抱怨,不如動手學。以前是 AI 取代我,那我能不能反過來用它?把 AI 當工具,用它做出以前一個人根本做不出來的東西?

還沒緩過來,家裡事情就一件接一件。索性在家待著,一邊處理家事一邊把一直想搞的 AI 方向認真補了補。

說是「補」,其實就是硬啃。以前寫 React、調 Webpack,腦子裡全是元件樹和狀態管理。突然要理解 Embedding、RAG、Agent 循環、狀態機編排這些概念,坦白說中間卡了很多次。對著 LangGraph 文件看了三天沒看懂,最後是邊抄程式碼邊跑才慢慢明白的

這個 TruthSeeker 就是我在家鼓搗出來的。一個用 LangGraph 搭的深度研究引擎,前端、後端、模型呼叫、部署,全是我一個人弄的。說實話過程挺狼狽的——經常半夜對著一堆報錯發呆。但每解決一個問題,那種「原來是這樣」的感覺,比寫一百個 React 元件都爽。

這個專案有一個貫穿始終的硬約束:預算為零,Token 太貴了。

我沒有公司報銷 API 費用,用的都是自己充值的 DeepSeek 餘額。深度研究模式跑一輪,意圖分析 + 多引擎搜尋 + 原子聲明提取 + 信源畫像 + 三方共識 + 最終裁決,加起來可能呼叫十幾次 LLM。如果用 GPT-4o,一次深度研究的推理成本可能好幾塊錢。一個月跑幾百次,還沒找到工作就先破產了。

所以你要是看到後文各種「為什麼不那樣做」的決策,別覺得奇怪——它們背後都有一個統一的原因:窮。為什麼選 DeepSeek?便宜啊。為什麼搞兩級過濾?少餵 Token 給 LLM,省一分是一分。為什麼最多搜 3 輪?多一輪都是真金白銀。為什麼把模型切成「搜尋用便宜的、驗證用強的」?好鋼得用在刀刃上。這篇文章裡幾乎所有技術決策,都可以歸結為一句話:窮有窮的做法。

我不覺得這是「轉行」。前端工程師的核心能力從來不是寫 JSX,而是理解使用者需求、設計互動邏輯、工程化思維。這些能力換個語言、換個領域一樣能用。LangGraph 的狀態機不就是 Redux 換個馬甲嗎?管線的條件路由不就是 React Router 嗎?真正的壁壘不是語言,是你願不願意從頭開始。

這篇文章記錄的是搭建全過程:踩過的坑、做過的決策、以及 POC 階段來不及解決的妥協。不一定都對,但每一步都踩得真實。給同樣面臨焦慮、正在考慮轉型的前端同行一個參考。


一、TruthSeeker 到底做什麼?

先回答最直接的問題:這玩意兒能幹嘛?

簡單說,你提一個問題,它去全網搜,然後把不同來源的說法擺在一起對比,告訴你哪些是真的、哪些是矛盾的、哪些根本沒法驗證

不追求「給你一個答案」,而是追求「告訴你這個答案可信嗎」。

比如你問:「Neuralink 首例人體植入後受試者出現感染,真的假的?」它會:

  1. 把你的問題拆成幾個子問題:植入時間、受試者狀態、感染報告來源
  2. 同時去多個搜尋引擎搜(博查、Tavily、知乎)
  3. 把所有搜到的網頁裡的事實拆成「原子聲明」——比如「手術於 2024 年 1 月完成」
  4. 對每條聲明,檢查有多少來源在說同樣的事,這些來源靠不靠譜
  5. 最終給你一份報告:哪些事實被多個權威信源證實,哪些只有一個來源在傳,哪些來源之間互相矛盾

核心跟普通搜尋的區別在於那個交叉驗證環節——我後來管它叫「審判室」。

四個檔位,豐儉由人

不是所有問題都需要這麼重的流程,所以做了四個檔位:

模式適合場景耗時
极速快問簡單確認,比如查一個價格幾秒
專家搜尋深入了解一個主題幾十秒
深度研究複雜問題、需要多源驗證幾分鐘
智能模式讓 AI 自己判斷該用哪個自適應

這個設計思路跟知乎直答 Agent 的三檔模式很像——都是從「秒回」到「深度挖掘」的分層策略。但 TruthSeeker 比它多了一個關鍵環節:交叉驗證。不是搜完就直接回答,而是讓不同信源「對質」之後再下結論。


二、技術選型的酸甜苦辣

我一個寫了七八年 JavaScript 的人,突然要搭一個 Python 後端的 AI 專案,說實話一開始是有點抗拒的。但 LLM 生態最好的工具鏈全在 Python 側——LangChain、LangGraph、FastAPI——這是現實,沒什麼好糾結的。

整體架構

系統大概分三塊:使用者互動的前端、處理請求的後端、以及真正幹活的 Worker 行程。為什麼要把後端和 Worker 拆開?因為深度研究要跑幾分鐘,不能讓 HTTP 請求一直掛著等——後端收到請求就丟進佇列,Worker 在背景慢慢跑,結果透過 SSE 即時推回去

FastAPI vs Django:選輕的,別選全的

Django 太重了。這個專案需要的是:路由層、中介軟體、非同步支援、SSE 串流回應。不需要 ORM 自帶的後台管理、不需要樣板引擎、不需要表單驗證——這些我前端都自己做了。

FastAPI 的好處:原生 async/await、Pydantic 請求校驗、Swagger UI 自動生成、啟動快。但後悔的是: FastAPI 的依賴注入系統一開始用得很爽,專案大了以後到處 Depends() 讓呼叫鏈很難追蹤。如果有下次,我會把業務邏輯更多放在 service 層,路由只做參數校驗和轉發。

LangGraph vs 純 LangChain:狀態機才是正道

剛開始我用的是 LangChain 的 LLMChain,就是最簡單的「給一個 Prompt,拿一個回答」。很快發現兩個問題:

  1. 流程不可控。 研究需要多步走(意圖分析 → 搜尋 → 驗證 → 報告),不是一次 LLM 呼叫能搞定的。用 Chain 串聯雖然能做,但中間狀態斷了就全丟了。
  2. 需要循環。 驗證發現信源矛盾,得回頭重新搜尋。這種「條件路由 + 循環」在 LangChain 裡寫起來很痛苦。

LangGraph 解決的就是這兩個問題:它把整個流程定義成一個狀態機,每個節點是獨立的處理步驟,節點之間可以條件跳轉,而且每一步的狀態自動持久化——等於自帶斷點續傳。

當時沒對比的: 應該對比 LangFlow(視覺化編排)和 Haystack。但當時 LangGraph 文件最全、例子最多,就它了。這不算錯,但算偷懶。

SQLite → PostgreSQL:本地一時爽,生產火葬場

本地開發一直用 SQLite,不需要裝任何東西,pip install 完就能跑。但 SQLite 有個致命問題:並發寫入鎖。當 Worker 在寫研究結果、同時 API 在查歷史記錄,SQLite 的寫鎖會導致讀超時。生產環境果斷切到 PostgreSQL。

教訓: 如果一開始就知道要上生產,直接 PG 起步。

資料庫遷移工具用了 Alembic——SQLAlchemy 官方遷移工具,自動生成遷移腳本、版本管理和回滾。Docker Compose 啟動時先跑 alembic upgrade head,保證 schema 和程式碼永遠對齊。

Redis 扛三個角色:為什麼不用 RabbitMQ?

Redis 在這個專案裡幹了三件事,這是一個反覆糾結後做的取捨:

角色怎麼用的為什麼是 Redis
任務佇列 Worker 透過 BRPOP 拉取任務阻塞彈出天然支援優先級佇列
SSE 發布 / 訂閱 Worker 即時推送進度給 API PubSub 延遲接近零
快取 LLM 重複請求快取 內存讀寫比 PG 快兩個數量級

用 Redis 扛三個角色最大的好處是維運一致——Docker Compose 裡只多一個服務。代價是 Redis PubSub 不保證訊息送達(斷線就丟訊息),後面會用 Redis Stream 補救。

三層配置模型:想了最久的設計

使用者的 API Key、使用者的模型列表、使用者的研究策略——這三樣東西不能混在一起存:

比如:使用者配了 DeepSeek 的 API Key(憑證層),註冊了 deepseek-chatdeepseek-reasoner 兩個模型(資產層),然後建立一個 Preset 說「意圖分析用 chat 模型、驗證用 reasoner 模型」(策略層)。三層解耦後,換 API Key 不需要重配策略,加新模型也不需要改預設。


三、把研究拆成 6 個工人

最初的版本極其樸素:使用者提問 → 搜尋 → 把搜尋結果餵給 LLM → 生成回答。十幾個 LLMChain 串起來,跑得動,但問題一大堆:

  1. 中間結果丟了。 驗證到一半報錯——你得從頭再跑。
  2. 沒法回頭。 驗證發現某個維度證據不足,想追加搜尋?做不到。
  3. 一個模型幹所有事。 搜尋需要創意、驗證需要嚴謹,沒法區分。

所以後來徹底重構成了 LangGraph 的 StateGraph

現在的管線

我把整個研究拆成了這些節點,每個節點幹一件事:

幾個有意思的節點:

意圖分析:把模糊問題變具體。 使用者經常問得很籠統,比如「AI 對就業的影響」。這個節點把它拆成可搜尋的子問題:AI 取代了哪些職位?創造了哪些新職業?各國政府怎麼應對?拆完之後用向量相似度去重——防止「AI 取代職位」和「AI 導致失業」這種同義拆解浪費搜尋次數。

向量去重用的是阿里雲的通義 Embedding Vision Flash,256 維,中文語義理解很穩,跟 DeepSeek 統一在 DashScope 閘道下接入。超過 0.85 相似度的判定為同義重複,整個去重邏輯不超過二十行。

兩級過濾,先快後慢。 粗過濾基於規則(去重 URL、去低質域名),能砍掉六七成雜訊。LLM 精過濾再砍兩三成。最終進驗證環節的通常只有原始結果的 20% 左右,但資訊密度高得多。

循環:驗證不過關就回頭搜。 驗證子圖跑完之後,如果存在矛盾維度且未達最大輪數(3 輪),管線自動回到搜尋節點,針對矛盾點追加搜尋。

狀態持久化:關了瀏覽器也沒事

LangGraph 的 Checkpointer 會在每個節點執行後把整個 ResearchState 序列化存到 PostgreSQL:

ResearchState
├── context → 身份資訊(誰、哪個租戶、哪個預設)
├── control → 控制參數(速度檔位、模式)
├── memory → 中間記憶(歷史訊息、已證事實、摘要)
├── runtime → 執行時資料(搜尋快取、管線狀態)
└── output → 最終產出(報告、聲明列表、置信度)

這意味著:使用者提問後關了瀏覽器,過十分鐘再打開,研究進度還在。Worker 繼續跑,前端重新連上 SSE 就能看到中間結果。這個體驗說實話挺爽的——第一次實現了「無感斷線」。


四、我給 AI 搭了個法庭

這是整個系統最核心的模組。我決定不信任任何單次 LLM 輸出,而是:從多個信源提取事實,對比它們的一致性,給出置信度評分

這個模組叫「審判室」,四步流程:

第一步:Atomize——拆成「一句話事實」

把幾千字的新聞稿拆成原子聲明。比如這篇 Neuralink 報導:

"Neuralink 於 2024 年 1 月宣布完成首例人體腦機介面植入手術,受試者為一名因脊髓損傷而四肢癱瘓的患者..."

拆成:

聲明 1:Neuralink 於 2024 年 1 月完成首例人體植入 [primary]
聲明 2:受試者是脊髓損傷導致的四肢癱瘓患者 [secondary]
聲明 3:手術由史丹佛大學醫學中心執行 [secondary]
聲明 4:受試者術後出現感染症狀 [primary][爭議性聲明]

每條聲明標記重要級別,記錄來自哪個信源。拆解由 LLM 來做——因為同一事實在不同文章裡的表述可能完全不同。

這裡用的 LLM 是 DeepSeek。為什麼不是 GPT-4o?性價比——DeepSeek 價格大概是 GPT-4o 的 1/10,中文能力不輸甚至更好,API 完全相容 OpenAI SDK。一鍵切 base_url 就行。但英文信源推理確實不如 GPT-4o,所以我留了個開關:使用者可以在 Preset 裡給不同階段綁定不同模型,驗證階段用更強模型,搜尋階段用便宜的。

第二步:Profile——信源畫像

有了聲明列表,接下來判斷信源本身靠不靠譜:

評分維度看什麼內容品質 (0~1) 資訊密度、邏輯是否嚴密、有沒有資料支撐行銷傾向 (0~1) 是不是軟文、有沒有商業推廣意圖專家引用 (0~1) 有沒有引用權威機構或專家一個來自《自然》雜誌的報導,內容品質可能 0.9,行銷傾向 0.1。一個行銷號的文章,內容品質可能 0.2,行銷傾向 0.9。這些分數直接影響後面的裁決權重。

第三步:Tripartite——三方共識

對每一條聲明,從所有信源中找相關證據,判斷一致性:

比如「受試者術後出現感染」——如果 3 個不同信源都報導了且細節吻合,就是 Consistent。如果一個說「感染」、一個說「無異常」、一個說「輕微不適」,就是 Contradictory

第四步:Arbitrate——最終裁決

綜合所有聲明的驗證結果和信源權重,給出裁決:

  • Supported(證實):多源一致支持
  • Contradicted(證偽):多源一致否定
  • Unverifiable(無法核實):現有信源不足以判斷

全域置信度分五級:verified → likely_true → disputed → uncertain → unverifiable

靈感來源

說真的,這個四步流程沒多高深,就是照著法庭審判的套路來的:

法庭裡的角色 Verify Subgraph 裡的對應
收集證據 Atomize:把複雜資訊拆成原子事實
評估證人可信度 Profile:評估每個信源的品質
交叉質證 Tripartite:讓不同來源對同一個事實「對質」
法官裁決 Arbitrate:綜合證據和權重做最終判斷

我不是第一個拿法庭模型做資訊驗證的人,但你別說,每次跟別人解釋這個模組,一說「我給 AI 搭了個法庭」,對方表情立馬從「你說啥」變成「哦~懂了」。

核驗子圖的實現:LangGraph Subgraph

整個核驗流程作為獨立的 LangGraph Subgraph 嵌套在主管線裡。Subgraph 的好處:

  1. 狀態隔離。 內部狀態不污染主圖
  2. 可獨立測試。 可以單獨跑核驗子圖,餵搜尋結果看裁決品質
  3. 可替換。 換一套驗證邏輯只需要寫新 Subgraph,主管線一行不改

已知不足: 信源畫像用的是通用 LLM,沒有針對中文信源權威性做專門微調。「內容品質」打分有時偏高——有些 AI 摘要網站也被打高分,但其實是二手資訊。裁決邏輯目前是加權平均,沒有考慮信源之間的獨立性問題——兩個媒體可能引用了同一個原始採訪,但系統當成兩個獨立信源來計票。


五、三條佇列,別再堵了

一開始只有一條隊

最早版本特別天真,就一條 Redis 佇列,誰先來誰先走。結果快速問答經常排在深度研究屁股後面,得等三四分鐘。朋友試用完直接問我:「你是不是寫了個 Bug?查個天氣要等三分鐘?」

我趕緊去看日誌,一看就樂了——深度研究在前面吭哧吭哧跑兩三分鐘,後面堵了七八個快速問答。這體驗,就像超市只開一個收銀台,你買瓶水得等前面大媽結完一整車的年貨。

三條隊 + 權重

拆成三條佇列,加權輪詢:

佇列 對應模式 權重含義
ts:queue:fast 极速快問 4 每 7 次被消費 4 次
ts:queue:expert 專家搜尋 2 每 7 次 2 次
ts:queue:pipeline 深度研究 1 每 7 次 1 次

調度序列就是:fast ×4 → expert ×2 → pipeline ×1 → 迴圈。每次 BRPOP timeout 0.1s,一個迴圈總共 0.7 秒。即使在深度研究的高負載下,快速問答最多等零點幾秒。

那為啥不乾脆給快速佇列最高優先級呢?因為你想想,如果快速佇列只要有人排隊就打死不處理深度隊列,那深度研究可能一整天都排不上——這叫「飢餓」。加權輪詢的好處是每種任務都能被照顧到,只是頻率不一樣。而且你仔細品:選了深層研究的使用者,本來心裡就知道「這玩意兒得等幾分鐘」,多等一小會兒完全能接受。

並發控制

一開始我啥限制都沒加,覺得自己寫的是非同步程式碼嘛,怕啥。結果 LangGraph 的圖執行本身也是非同步的,十幾個協程同時搶事件迴圈,Worker CPU 直接飆到 90%。後來老老實實加了個 asyncio.Semaphore(2),單 Worker 最多同時跑 2 個任務。簡單粗暴,但從此 CPU 就乖了。

Auto-Scaler

Worker 內有個獨立協程,根據三個佇列的總深度調整輪詢頻率:少於 2 個任務休眠 5 秒省 CPU,超過 10 個任務休眠 1 秒快速消費。三段 if-else,沒什麼黑科技,但有效。

為什麼用 ARQ 做 Worker

ARQ 是 FastAPI 作者 Samuel Colvin 開發的非同步任務佇列庫,跟 FastAPI 和 Pydantic 同一人出品,生態相容性天然好。對比 Celery:

維度 ARQ Celery
非同步模型 原生 asyncio prefork/thread pool
配置複雜度 1 個 Worker 函式 + 1 行啟動 多檔案配置

選它的理由跟 FastAPI 一致:夠用且輕量。POC 階段不需要 Celery 的複雜功能。

已知不足: 目前只有單 Worker,沒法水平擴展。加權輪詢的權重是拍腦袋定的(4:2:1),沒有基於實際負載資料調優。取消信號依賴 Redis PubSub,但 PubSub 不保證送達——網路抖動時可能丟掉取消指令。


六、使用者的 API Key 存在我這,我比他還怕洩露

說實話,做這個系統最讓我睡不著覺的事,就是使用者的 API Key。人家的 DeepSeek Key、搜尋引擎 Key 都填在我這兒了,這要是漏了,我拿什麼賠?

資料隔離

所有 SQL 查詢都帶兩個條件:WHERE tenant_id = ? AND user_id = ?tenant_iduser_id 從 JWT 裡提取,API 中介軟體注入到請求上下文,不是靠前端傳參——後端解析 Token 綁定的,沒法偽造。

API Key 加密:存進去的是亂碼

用 Fernet 加密(AES-128-CBC + HMAC-SHA256 簽名):

明文 API Key → AES 加密 → HMAC 簽名 → base64 編碼 → 存庫
讀取時 → base64 解碼 → 驗證 HMAC(防竄改)→ AES 解密 → 明文使用

加了 HMAC 意味著:即使有人黑了資料庫、改了密文,解密時會因為簽名對不上而直接報錯。這不只是加密,這是防竄改

加密金鑰可以從 JWT 金鑰派生,也可以獨立設定環境變數,方便以後輪換。

SSRF 防護:Worker 不能存取內網

Worker 在跑研究時會去請求外部 URL。如果有人提交惡意 URL 指向 http://169.254.169.254/metadata(雲伺服器中繼資料介面),Worker 如果傻傻去請求,等於把伺服器敏感資訊送出去了。

我的防護是 DNS 層級:解析 URL 的所有 IP,逐個檢查是否為私有位址(127.0.0.0/810.0.0.0/8172.16.0.0/12192.168.0.0/16169.254.0.0/16)。還留了個特殊放行:198.18.0.0/15——Clash 代理用的保留網段,不放行的話 Worker 根本無法存取外部 API。

密碼儲存

PBKDF2-SHA256 雜湊,48 萬輪疊代。驗證密碼時用 hmac.compare_digest() 做常數時間比較——不管你輸的密碼對不對,比較時間一樣長,防止側通道攻擊。

身分驗證

支援兩種登入:傳統使用者名稱密碼 + Logto OIDC。OIDC 接入不複雜:後端從 Logto 的 JWKS 端點拿公鑰,校驗 RS256 簽章。兩種方式並存——我自己用密碼登入省事,給別人示範時用 OIDC 顯得正式。

已知不足: SSRF 防護是 DNS 層級的,有繞過可能(DNS Rebinding、HTTP 重新導向到內網)。生產級應該用代理隔離。日誌裡目前沒有脫敏,有些錯誤日誌可能把解密後的 API Key 印出來——這個必須修。多租戶隔離靠 WHERE 條件,沒有做 PG 原生 Row-Level Security,萬一有查詢忘了帶 WHERE 條件就跨租戶洩露了。


七、十分鐘加一個搜尋引擎的重構

起初我寫得很糙。博查、Tavily、知乎三個引擎各寫一套,到處散落著 if-else。想加個新的?得把四五個檔案翻一遍。

最初的醜程式大概長這樣

if engine == "bocha":
results = await bocha_search(query)
elif engine == "tavily":
results = await tavily_search(query)
elif engine == "zhihu":
results = await zhihu_search(query)

後來想加 Google Search 的時候終於受不了了——調度邏輯跟引擎邏輯攪成一鍋粥,改調度影響引擎,改引擎又影響調度。咬咬牙:拆。

外掛系統:三個元件

外掛註冊中心(Registry): 全域字典,所有外掛透過裝飾器自註冊,不用手動維護列表。

搜尋編排器(Orchestrator): 拿到啟用的外掛列表,asyncio.gather 並發呼叫,最後跨引擎去重。

外掛基底類(SearchPlugin): 所有引擎必須實作的抽象類,就三個方法:

class SearchPlugin(ABC):
@property
def name(self) -> str:
"""引擎名稱"""

@property  
def is_reader(self) -> bool:
    """是否內容讀取外掛"""
    return False

async def search(self, query, api_key, **kwargs):
    """執行搜尋,返回統一格式"""

加一個新引擎,三步

  1. 寫外掛類,加 @plugin_registry.register() 裝飾器
  2. VALID_SEARCH_ENGINES 裡加一行名字
  3. UI 設定頁面配 API Key

編排器和去重邏輯一行不用改。

並發調度的核心就這一行

results = await asyncio.gather(
*[plugin.search(query, api_key) for plugin in active_plugins],
return_exceptions=True # 某個引擎報錯不影響其他
)

return_exceptions=True 是關鍵——某個搜尋引擎逾時了,例外被包裝成 Exception 物件放在結果列表裡,不會影響其他引擎已返回的結果。

重構前 重構後
加引擎改 4-5 個檔案 加引擎改 2 個檔案
調度邏輯和引擎耦合 調度和引擎獨立,各自測試
引擎報錯影響全域 單引擎故障不阻塞
搜尋結果重複 跨引擎 URL 去重統一處理

已知不足: 外掛系統只接了三個引擎,沒做過不同搜尋引擎結果品質的 A/B 對比——博查的中文搜尋比 Tavily 好嗎?不知道。去重目前只靠 URL 精確匹配 + 標題相似度,兩個不同 URL 的網頁可能互相抄襲同一篇文章,這種內容級去重還沒做。


八、別讓使用者盯著白屏

第一版做完的時候我自己試了一下,差點把自己氣死。點「開始研究」→ 介面死了 → 過了三五分鐘 → 啪,糊一臉結果。

中間那幾分鐘,使用者就盯一個轉圈發呆。不知道系統掛沒掛、AI 在忙啥、還剩多久。我自己用了一次就想罵人。

從輪詢到 SSE

一開始圖省事,沒搞 SSE。就返回個 task_id,讓前端每兩秒輪詢一次查進度。結果呢?百分之九十的請求都是白打的,而且進度是跳著走的,卡一下突然蹦一截。

這才老老實實上了 SSE:

Worker 執行 LangGraph 圖
→ 每個節點產生事件
→ 發布到 Redis PubSub
→ API 行程訂閱 PubSub
→ 格式化為 SSE 推給瀏覽器

事件類型

事件 什麼時候發 前端幹嘛
step 進入新管線階段 更新進度條和思考鏈面板
model LLM 串流輸出 Token 追加訊息(打字機效果)
complete 研究完成 展示報告和聲明驗證卡片
error 任何節點報錯 顯示錯誤提示
sync 斷線重連時 恢復完整狀態

最大的坑:斷線重連

然後我就踩了整篇文章最大的一个坑:Redis PubSub 不存歷史。瀏覽器一斷線,掉線期間的事件全部蒸發了。你關了標籤頁重新打開,就看見進度條像個鬼一樣從 0% 直接跳到 80%,中間發生了什麼?不知道。這體驗比白屏還詭異。

後來想了個招——雙通道:

  • Redis PubSub:正常連線時用,零延遲即時推送
  • Redis Stream:當「歷史緩衝區」用,斷線重連時補全丟失的事件

重連流程:前端調 /api/v1/chat/resume → API 從 Redis Stream 讀歷史事件 → 推送重建完整狀態 → 繼續從 PubSub 消費新事件。

Nginx 必須配的幾個設定

proxy_buffering off; # 關緩衝,確保事件即時推送
proxy_read_timeout 86400s; # 長連線逾時 24h
proxy_cache off; # 禁用快取

第一次沒加 proxy_buffering off,前端收到的事件是「攢一波再推」——每隔 30 秒刷一大段,完全沒有即時感。排查了半天才發現是 Nginx 預設開了代理緩衝。

已知不足: SSE 斷線重連還沒大規模壓測。事件解析器目前靠一堆 if-else 匹配,隨著 LangGraph 版本升級事件格式可能變。最理想的是把 Parser 做成可配置的事件映射表。


九、一行命令跑起來

我對部署的要求很簡單:隨便誰 git clone 下來,敲一個命令,所有東西跑起來。不需要裝 Python、不需要裝 Node、不需要裝 PG。Docker 就夠了。

對外只暴露 80 連接埠,剩下全是容器間內部通訊。

為什麼不用 K8s?

一個人維運,Kubernetes 太重了。編寫 Deployment、Service、Ingress、ConfigMap、Secret 就得半天。Docker Compose 一條 docker compose up -d 搞定。但這個選擇有代價: 沒有健康檢查自動重啟、沒有滾動更新、沒有資源限制細粒度控制。POC 階段能忍,有真實使用者後必須補。

Nginx 的兩個坑

坑一:預設 60 秒逾時。 深度研究可能跑幾分鐘,但 Nginx 的 proxy_read_timeout 預設 60 秒。結果研究跑了一分鐘多連線斷了,前端收不到後續事件。修復:proxy_read_timeout 86400s

坑二:代理緩衝導致事件延遲。 proxy_buffering offproxy_cache off 必須加。

資料庫遷移時機

應用服務啟動前必須先跑完資料庫遷移。Docker Compose 用 depends_on + healthcheck 解決:

postgres:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U truthseeker"]
interval: 5s

backend:
depends_on:
postgres:
condition: service_healthy

Makefile:給自己省時間

部署命令記不住,所以寫了個 Makefile:

deploy:
docker compose up --build -d

down:
docker compose down

logs:
docker compose logs -f

clean:
docker compose down -v # ⚠️ 刪資料

make clean 尤其危險——加 -v 會刪掉所有資料卷。我自己踩過這個坑,想「清理一下」結果把測試資料全清掉了。

已知不足: 就單機 Docker Compose,沒有容器編排、監控告警、日誌聚合、灰度發布、自動備份。SSL 依賴外部 Cloudflare。健康檢查只有一個 /health 端點。這些都是 POC 階段故意欠的債——要加的話開發時間得翻倍。


結尾:不完美的誠實交代

前後斷斷續續寫了幾個月,目前跑在一台雲伺服器上,日常處理幾十次研究請求。TDD 寫了 30 多個測試檔案,配了 Logto OIDC 登入,搭了前後端全非同步鏈路。

全部已知不足彙總

  • 驗證環節: 信源權重沒有考慮獨立性,通用 LLM 打分偏高,沒有針對性的評估資料集
  • 管線: 條件路由閾值是拍腦袋硬編碼,節點串行執行未做並行最佳化
  • 調度: 單 Worker 無法水平擴展,權重未經資料調優,取消信號不保證送達
  • 安全: SSRF 是 DNS 層級可繞過,日誌未脫敏,多租戶未用 PG 原生 RLS
  • 搜尋外掛: 未做引擎品質 A/B 對比,內容級去重缺失
  • SSE: 斷線重連未大規模壓測,Parser 硬編碼不夠健壯
  • 部署: 單機無監控告警無備份,無滾動更新策略

這些都是我明知道該做、但 POC 階段來不及做的。寫出來不是示弱,是誠實——一個人做全端本身就到處妥協,關鍵是你得知道自己在妥協什麼。

下一步:往 Agent 方向深鑽

現在管線裡的 Agent 還比較「規矩」——能搜、能讀、能推理,但始終在一個預設的流程裡跑。我感興趣的方向:

  • 多 Agent 各自負責一個研究維度,然後互相 review
  • Agent 自己的長期記憶,不只是 LangGraph 的 thread checkpoint,而是跨會話的經驗累積
  • Agent 發現自己缺工具時,能不能自己寫程式造一個

找工作優先,但這幾個方向會陸續寫出來——不會再停在 tutorial 級別。


這個專案也是一個 vibe coding 的嘗試。以前拿到需求就開寫,現在習慣先用 AI 聊清楚產品設計、用 AI 過需求方案,把自己的角色從「悶頭寫程式」慢慢轉變成了「想清楚再寫、寫完再審」。程式碼量沒以前大了,但想的時間多了不少。

對 AI 工程化有興趣的話,歡迎聊聊。


原文出處:https://juejin.cn/post/7653442979387506726


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝16   💬1   ❤️1
510
🥈
我愛JS
📝1   ❤️1
32
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登