為了學習 AI Agent,我做了一個 AI 閱讀器(已開源)

專案地址:GitHub - ReadAny

起因

去年 AI Agent 火起來的時候,我跟很多人一樣,瘋狂看文章、看影片。ReAct、RAG、Function Calling、LangChain……概念一個沒落下,每個都覺得「嗯嗯我懂了」。

但真要自己寫,一行程式碼都敲不出來。

這種「看懂了但寫不出來」的焦慮大家應該都有過吧。後來我想明白了一件事:光看永遠學不會,得做東西。

做什麼好呢?我平時看書比較多,手機上的閱讀器都沒有 AI 功能。市面上倒是有一些,但要麼只是傳統的閱讀器,要麼 AI 就是套了個殼,問什麼都是一本正經地胡說八道——明顯沒有真正去讀書裡的內容。

那就自己做一個吧。一個能真正「讀懂」書的 AI 閱讀器。

名字叫 ReadAny,取「什麼都能讀」的意思。技術棧是 Tauri 2 + React(桌面端)和 Expo(行動端),AI 這塊用的是 LangChain.js + LangGraph。做著做著從一個學習專案變成了一個還算完整的產品,macOS、Windows、Linux、iOS、Android 都能跑。

Clipboard_Screenshot_1774527655.png

今天主要聊聊做這個專案過程中 Agent 相關的實作,挑幾個我覺得比較有意思的點講講。


一、為什麼要用 ReAct Agent

最開始我也是走的最簡單的路:使用者問一句,把書的內容往 Prompt 裡一塞,讓 LLM 回答。

很快就翻車了。

第一個問題,塞不下。 一本書動輒幾十萬字,context window 再大也裝不下一整本書。

第二個問題,會編。 LLM 壓根沒讀過這本書,你問它「第三章講了什麼」,它能給你編得頭頭是道,但每個字都是瞎說的。

第三個問題,不靈活。 使用者可能問「這本書裡有沒有提到量子力學」,這種需要搜尋的問題,把內容硬塞進去是搞不定的。

所以我轉向了 ReAct 模式。核心思路很簡單:AI 不再是「你問我答」,而是變成了一個有手有腳的 Agent——你問它問題,它先想想「我需要什麼資訊才能回答這個問題」,然後自己決定去調工具查資料,拿到結果後繼續思考,覺得不夠還可以再查,直到能給出可靠的回答。

用一句話概括就是:讓 AI 先想再做,而不是張嘴就來。

Clipboard_Screenshot_1774527669.png


二、Agent 的具體實作

用 LangGraph 搭 ReAct 迴圈

一開始我試過自己手寫 tool-calling 迴圈——就是自己判斷 LLM 要不要調工具、調哪個、怎麼把結果餵回去。寫了兩天,各種邊界 case 搞得我頭大,最後老老實實用了 LangGraph 的 createReactAgent

核心程式碼說出來你可能不信,就幾行:

const agent = createReactAgent({
  llm: chatModel,
  tools: langchainTools,
  messageModifier: systemPrompt,
});

const stream = agent.streamEvents(
  { messages: processedMessages },
  { version: 'v2', recursionLimit: 200 }
);

recursionLimit: 200 看起來很大,但有些場景真的需要。比如使用者說「幫我逐章總結這本書」,一本 30 章的書,每一章 Agent 都要調一次工具取得內容再總結,輕輕鬆鬆就幾十輪。

Clipboard_Screenshot_1774527679.png

給 Agent 裝了 20 多個工具

光有腦子沒用,得給 Agent 配上「手腳」。我按場景把工具分成了五組:

  • 通用工具:列出所有書籍、搜尋全域高亮和筆記、產生心智圖、管理書籍標籤
  • 閱讀上下文:取得當前章節、取得使用者選中的文字、閱讀進度
  • RAG 檢索:語意搜尋書籍內容、查目錄、定位上下文
  • 內容分析:做摘要、擷取人物/概念、分析論證結構、找金句
  • 註釋管理:取得批註、加入精確引用

這裡有個我覺得挺重要的設計:工具是根據狀態動態註冊的,不是一股腦全部給 Agent。

比如使用者還沒打開任何書的時候,「取得當前章節」這種工具壓根不會出現在 Agent 的工具列表裡;書還沒做過向量化的時候,RAG 相關的工具也不會有。原因很簡單——工具太多 LLM 會選擇困難,給它太多選項反而會選錯。實測下來,動態註冊比全量註冊的工具呼叫準確率高不少。

一個值得展開講的細節:精確引用定位

我給 Agent 做了一個 addCitation 工具。AI 在書裡找到一段相關的文字之後,可以產生一個引用,使用者點這個引用就能跳轉到書中對應的原文位置。

但這裡有個技術難點:AI 拿到的定位資訊是 chunk 級別的(一個 chunk 大概 300 tokens),而引用的文字可能在 chunk 的中間某個位置。

我的解法:

  1. 每個 chunk 在分塊的時候就保存了段落級別的 CFI(Content Fragment Identifier)列表
  2. addCitation 拿到引用文字後,先在 chunk 的各個段落裡定位具體在哪個段落
  3. 如果沒有段落級 CFI(舊版本的資料),就用啟發式方法——文字在 chunk 前半段就用 startCfi,後半段就用 endCfi

不完美,但實際使用下來準確率還挺高的。使用者點引用基本都能跳到正確的位置。

Clipboard_Screenshot_1774527693.png


三、RAG——讓 AI 不再胡說八道

Agent 解決的是「怎麼調度」的問題,但 AI 回答靠不靠譜,最終取決於能不能從書裡找到正確的內容。這就是 RAG(檢索增強生成)要做的事。

做完這塊我最大的感受是:RAG 的檢索品質遠比 Agent 架構重要。 Agent 設計得再花俏,檢索出來的內容不對,最終回答就是垃圾。

Clipboard_Screenshot_1774527703.png

分塊策略

一本書不能整個塞給 LLM,得切成小塊。但怎麼切非常有講究。

我沒用 LangChain 自帶的 TextSplitter,因為它不認識電子書的段落結構,經常把一句話從中間切斷。而且我需要每個 chunk 都保留精確的位置資訊(CFI),後面做引用定位要用。

所以自己寫了一個 Segment-aware 的分塊器。每個 TextSegment 對應書中的一個自然段,切塊的時候按段落邊界來切,不會把句子劈成兩半。預設 300 tokens 一個 chunk,相鄰 chunk 之間有 20% 的重疊,防止關鍵資訊剛好被切在邊界上。

Embedding:本地優先

做閱讀器有個繞不開的問題:書是隱私資料。 使用者的閱讀內容不應該上傳到雲端。所以我做了兩條路:

本地模式:用 Transformers.js 在 Web Worker 裡跑模型,完全離線。內建了幾個輕量模型,英文書用 all-MiniLM-L6-v2(才 23MB),中文書用 bge-small-zh-v1.5(47MB)。

遠端模式:呼叫 OpenAI 或者 Ollama 的 API,適合追求更高精準度的場景。

向量儲存用的是 sqlite-vec,一個 SQLite 的向量搜尋擴充。桌面端透過 Tauri 的 Rust 後端呼叫,效能不錯。

混合檢索:BM25 + 向量

RAG 裡我投入時間最多的就是檢索這塊。

一開始只用向量搜尋,很快發現一個問題:對專有名詞不敏感。 你搜「哈利波特」,向量搜尋可能返回一堆「年輕的魔法師」之類的語意相近但沒有精確命中的結果。

反過來,純關鍵字搜尋(BM25)又不懂語意,你搜「主角的心理變化」它就傻了。

所以最終用了混合搜尋,向量和 BM25 兩路同時查,再用 RRF(Reciprocal Rank Fusion)把結果融合排序。說人話就是:兩個搜尋引擎各出一份排名,用一個公式綜合一下,兩邊都排前面的結果最終排名就會靠前。

BM25 這塊還有個小坑:中文分詞。中文不像英文有天然的空格分隔,直接按空格切分效果很差。我用了 CJK bigram(雙字組合)的方式處理,比如「量子力學」會被拆成「量子」「子力」「力學」三個 term,雖然粗暴但效果比不處理好太多了。


四、串流渲染——坑最多的地方

後端能跑了,但使用者看到的是介面,不是日誌。怎麼把 Agent 的整個思考過程即時、流暢地展示給使用者,這塊踩的坑比前面加起來都多。

不同 LLM 的串流行為差異巨大

Clipboard_Screenshot_1774527736.png

這是我最想吐槽的一點。你以為 LangChain 幫你抹平了各家 LLM 的差異?太天真了。

OpenAI:tool_call 的參數是一小塊一小塊流過來的(tool_call_chunks),你得自己拼接。

Anthropic Claude:支援 extended thinking,串流事件裡會多出一個 thinking 塊,要單獨處理。

DeepSeek:最坑。它的 reasoning_content 在多輪對話時,要求每條歷史 assistant 訊息都帶上之前的 reasoning_content,否則 API 直接報錯。但 @langchain/deepseek 這個套件在接收的時候把 reasoning_content 存到 additional_kwargs 裡,發送的時候卻不會把它塞回去。

沒辦法,我只能繼承 ChatDeepSeek 寫一個子類 ChatDeepSeekFixed,重寫了 _generate_streamResponseChunks,手動維護一個 _reasoningMap 來在每次請求時把 reasoning_content 注入回去。這個 bug 困擾了我好幾天,網路上也搜不到解法,最後只能啃原始碼才搞懂。

Tool Call 提前展示

正常流程是等到工具名稱和參數全部到齊了才告訴前端「Agent 在呼叫工具」。但參數可能要流好幾秒才拼完,這段時間使用者就乾看著一片空白。

我做了個優化:只要 tool_call_chunks 裡出現了工具名,就立刻 emit 一個 tool_call 事件給前端,參數先留空。前端收到後馬上顯示「正在呼叫 xxx 工具...」的提示。等參數到齊了再更新顯示。

改動不大,但體感差別很明顯。使用者能即時看到 AI 在幹什麼,不會覺得卡住了。

Part-based 訊息渲染

前端渲染這塊我借鑑了一個思路:每條訊息不是一坨文本,而是由多個 Part 組成的。

Clipboard_Screenshot_1774527748.png

一個 Agent 的回覆過程可能是這樣:先思考(ReasoningPart)→ 呼叫工具搜尋內容(ToolCallPart)→ 再呼叫一次工具(ToolCallPart)→ 開始回答(TextPart)→ 附上引用(CitationPart)。

每個 Part 獨立渲染、獨立更新狀態(pending → running → completed)。思考過程顯示為可收合的面板,工具呼叫顯示為帶狀態圖示的卡片(轉圈 → 打勾/叉),正文串流打字,引用可點擊跳轉。

這樣做的好處是 Agent 的每一步操作都清晰可見,使用者能看到 AI「想了什麼 → 查了什麼 → 怎麼得出結論的」,而不是等半天突然丟出一大段文字。

還有個小的效能優化:文字部分的更新做了 100ms 的節流。不然每個 token 到了都觸發一次 React re-render,一秒鐘幾十次重渲染,介面會明顯卡頓。

Clipboard_Screenshot_1774527762.png

Clipboard_Screenshot_1774527769.png

Clipboard_Screenshot_1774527779.png


五、System Prompt 的設計

這塊單獨拿出來說是因為,做了 Agent 之後我才真正意識到 System Prompt 有多重要。它不只是「你是一個 xxx 助手」這麼簡單。

我的 System Prompt 是動態拼裝的,分成 6 個部分:

  1. 角色設定:告訴 AI 你是個閱讀助理,強調「不能編造書中沒有的內容」
  2. 書籍上下文:目前打開的書的標題、作者、語言
  3. 語意閱讀上下文:使用者目前在看哪一章、最近高亮了什麼、周遭的文本是什麼
  4. 工具描述:動態生成的目前可用工具列表
  5. 行為規範:工具呼叫紀律、引用規則、防重複呼叫規則
  6. 回應約束:語言、格式要求、防劇透規則

其中「防劇透」是我自己加的一個功能。看小說的時候最怕被劇透,所以我在 Prompt 裡加了邏輯:根據使用者的閱讀進度,限制 AI 只能存取已讀章節的內容。 你問「後面會不會有反轉」,AI 會告訴你它不能透露後續內容。

還有一條「防重複呼叫」的規則也很關鍵。不加這條的話 Agent 有時候會反覆呼叫同一個工具,陷入死循環。加上之後基本沒再出現過。


六、一些工程上的取捨

Monorepo + 平台無關的 Core 層

專案結構是 pnpm monorepo,四個 package:

  • @readany/core:AI、RAG、工具、狀態管理、Hooks,全部平台無關的邏輯
  • packages/app:Tauri 桌面端
  • packages/app-expo:Expo 行動端
  • packages/foliate-js:電子書渲染引擎(fork 自 foliate-js)

核心邏輯全在 @readany/core 裡,桌面端和行動端只需要實作幾個平台介面(IVectorDBILocalEmbeddingEngine)。這意味著要加一個新平台(比如純 Web 版),成本很低,不需要重寫 AI 和 RAG 邏輯。


一些感受

做了一個月,從「想學 Agent」到現在,說幾點真實感受:

  1. Agent 不是銀彈。它最大的價值是讓 AI 自己決定該做什麼,但這也意味著不可控性增加了。有時候 AI 會選錯工具、反覆呼叫同一個工具、或者呼叫了不該呼叫的工具。System Prompt 裡的約束和工具的動態註冊非常重要,它們是你控制 Agent 行為的主要手段。
  2. 檢索品質 > Agent 架構。這點怎麼強調都不為過。Agent 架構設計得再漂亮,如果 RAG 檢索出來的內容不對,最終回答還是無用。花在分塊策略和混合檢索上的時間,報酬率遠高於花在 Agent 架構上的時間。
  3. 串流體驗決定使用者感知。同樣的回應速度和品質,串流展示和等半天一次性輸出,使用者的感受天差地別。讓使用者看到 AI「正在想 → 正在查 → 正在寫」的過程,比什麼都重要。
  4. LangChain 的抽象不完美。特別是多模型支援這塊,每家 Provider 的行為差異比你想像的大。LangChain 幫你屏蔽了大部分差異,但到了串流處理、thinking/reasoning 這些細節,該踩的坑一個也少不了。

最後

專案完全開源,感興趣的話歡迎 star,也歡迎提 issue 和 PR。

GitHub: ReadAny

Clipboard_Screenshot_1774527833.png

如果你也在學 AI Agent,建議找個自己有興趣的方向做個東西出來。不一定要多複雜,但一定要自己從頭寫一遍。看十篇文章不如寫一百行程式碼,這是真的。


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


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

共有 0 則留言


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