專案地址: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 都能跑。

今天主要聊聊做這個專案過程中 Agent 相關的實作,挑幾個我覺得比較有意思的點講講。
最開始我也是走的最簡單的路:使用者問一句,把書的內容往 Prompt 裡一塞,讓 LLM 回答。
很快就翻車了。
第一個問題,塞不下。 一本書動輒幾十萬字,context window 再大也裝不下一整本書。
第二個問題,會編。 LLM 壓根沒讀過這本書,你問它「第三章講了什麼」,它能給你編得頭頭是道,但每個字都是瞎說的。
第三個問題,不靈活。 使用者可能問「這本書裡有沒有提到量子力學」,這種需要搜尋的問題,把內容硬塞進去是搞不定的。
所以我轉向了 ReAct 模式。核心思路很簡單:AI 不再是「你問我答」,而是變成了一個有手有腳的 Agent——你問它問題,它先想想「我需要什麼資訊才能回答這個問題」,然後自己決定去調工具查資料,拿到結果後繼續思考,覺得不夠還可以再查,直到能給出可靠的回答。
用一句話概括就是:讓 AI 先想再做,而不是張嘴就來。

一開始我試過自己手寫 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 都要調一次工具取得內容再總結,輕輕鬆鬆就幾十輪。

光有腦子沒用,得給 Agent 配上「手腳」。我按場景把工具分成了五組:
這裡有個我覺得挺重要的設計:工具是根據狀態動態註冊的,不是一股腦全部給 Agent。
比如使用者還沒打開任何書的時候,「取得當前章節」這種工具壓根不會出現在 Agent 的工具列表裡;書還沒做過向量化的時候,RAG 相關的工具也不會有。原因很簡單——工具太多 LLM 會選擇困難,給它太多選項反而會選錯。實測下來,動態註冊比全量註冊的工具呼叫準確率高不少。
我給 Agent 做了一個 addCitation 工具。AI 在書裡找到一段相關的文字之後,可以產生一個引用,使用者點這個引用就能跳轉到書中對應的原文位置。
但這裡有個技術難點:AI 拿到的定位資訊是 chunk 級別的(一個 chunk 大概 300 tokens),而引用的文字可能在 chunk 的中間某個位置。
我的解法:
addCitation 拿到引用文字後,先在 chunk 的各個段落裡定位具體在哪個段落 不完美,但實際使用下來準確率還挺高的。使用者點引用基本都能跳到正確的位置。

Agent 解決的是「怎麼調度」的問題,但 AI 回答靠不靠譜,最終取決於能不能從書裡找到正確的內容。這就是 RAG(檢索增強生成)要做的事。
做完這塊我最大的感受是:RAG 的檢索品質遠比 Agent 架構重要。 Agent 設計得再花俏,檢索出來的內容不對,最終回答就是垃圾。

一本書不能整個塞給 LLM,得切成小塊。但怎麼切非常有講究。
我沒用 LangChain 自帶的 TextSplitter,因為它不認識電子書的段落結構,經常把一句話從中間切斷。而且我需要每個 chunk 都保留精確的位置資訊(CFI),後面做引用定位要用。
所以自己寫了一個 Segment-aware 的分塊器。每個 TextSegment 對應書中的一個自然段,切塊的時候按段落邊界來切,不會把句子劈成兩半。預設 300 tokens 一個 chunk,相鄰 chunk 之間有 20% 的重疊,防止關鍵資訊剛好被切在邊界上。
做閱讀器有個繞不開的問題:書是隱私資料。 使用者的閱讀內容不應該上傳到雲端。所以我做了兩條路:
本地模式:用 Transformers.js 在 Web Worker 裡跑模型,完全離線。內建了幾個輕量模型,英文書用 all-MiniLM-L6-v2(才 23MB),中文書用 bge-small-zh-v1.5(47MB)。
遠端模式:呼叫 OpenAI 或者 Ollama 的 API,適合追求更高精準度的場景。
向量儲存用的是 sqlite-vec,一個 SQLite 的向量搜尋擴充。桌面端透過 Tauri 的 Rust 後端呼叫,效能不錯。
RAG 裡我投入時間最多的就是檢索這塊。
一開始只用向量搜尋,很快發現一個問題:對專有名詞不敏感。 你搜「哈利波特」,向量搜尋可能返回一堆「年輕的魔法師」之類的語意相近但沒有精確命中的結果。
反過來,純關鍵字搜尋(BM25)又不懂語意,你搜「主角的心理變化」它就傻了。
所以最終用了混合搜尋,向量和 BM25 兩路同時查,再用 RRF(Reciprocal Rank Fusion)把結果融合排序。說人話就是:兩個搜尋引擎各出一份排名,用一個公式綜合一下,兩邊都排前面的結果最終排名就會靠前。
BM25 這塊還有個小坑:中文分詞。中文不像英文有天然的空格分隔,直接按空格切分效果很差。我用了 CJK bigram(雙字組合)的方式處理,比如「量子力學」會被拆成「量子」「子力」「力學」三個 term,雖然粗暴但效果比不處理好太多了。
後端能跑了,但使用者看到的是介面,不是日誌。怎麼把 Agent 的整個思考過程即時、流暢地展示給使用者,這塊踩的坑比前面加起來都多。

這是我最想吐槽的一點。你以為 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 困擾了我好幾天,網路上也搜不到解法,最後只能啃原始碼才搞懂。
正常流程是等到工具名稱和參數全部到齊了才告訴前端「Agent 在呼叫工具」。但參數可能要流好幾秒才拼完,這段時間使用者就乾看著一片空白。
我做了個優化:只要 tool_call_chunks 裡出現了工具名,就立刻 emit 一個 tool_call 事件給前端,參數先留空。前端收到後馬上顯示「正在呼叫 xxx 工具...」的提示。等參數到齊了再更新顯示。
改動不大,但體感差別很明顯。使用者能即時看到 AI 在幹什麼,不會覺得卡住了。
前端渲染這塊我借鑑了一個思路:每條訊息不是一坨文本,而是由多個 Part 組成的。

一個 Agent 的回覆過程可能是這樣:先思考(ReasoningPart)→ 呼叫工具搜尋內容(ToolCallPart)→ 再呼叫一次工具(ToolCallPart)→ 開始回答(TextPart)→ 附上引用(CitationPart)。
每個 Part 獨立渲染、獨立更新狀態(pending → running → completed)。思考過程顯示為可收合的面板,工具呼叫顯示為帶狀態圖示的卡片(轉圈 → 打勾/叉),正文串流打字,引用可點擊跳轉。
這樣做的好處是 Agent 的每一步操作都清晰可見,使用者能看到 AI「想了什麼 → 查了什麼 → 怎麼得出結論的」,而不是等半天突然丟出一大段文字。
還有個小的效能優化:文字部分的更新做了 100ms 的節流。不然每個 token 到了都觸發一次 React re-render,一秒鐘幾十次重渲染,介面會明顯卡頓。



這塊單獨拿出來說是因為,做了 Agent 之後我才真正意識到 System Prompt 有多重要。它不只是「你是一個 xxx 助手」這麼簡單。
我的 System Prompt 是動態拼裝的,分成 6 個部分:
其中「防劇透」是我自己加的一個功能。看小說的時候最怕被劇透,所以我在 Prompt 裡加了邏輯:根據使用者的閱讀進度,限制 AI 只能存取已讀章節的內容。 你問「後面會不會有反轉」,AI 會告訴你它不能透露後續內容。
還有一條「防重複呼叫」的規則也很關鍵。不加這條的話 Agent 有時候會反覆呼叫同一個工具,陷入死循環。加上之後基本沒再出現過。
專案結構是 pnpm monorepo,四個 package:
@readany/core:AI、RAG、工具、狀態管理、Hooks,全部平台無關的邏輯 packages/app:Tauri 桌面端 packages/app-expo:Expo 行動端 packages/foliate-js:電子書渲染引擎(fork 自 foliate-js)核心邏輯全在 @readany/core 裡,桌面端和行動端只需要實作幾個平台介面(IVectorDB、ILocalEmbeddingEngine)。這意味著要加一個新平台(比如純 Web 版),成本很低,不需要重寫 AI 和 RAG 邏輯。
做了一個月,從「想學 Agent」到現在,說幾點真實感受:
專案完全開源,感興趣的話歡迎 star,也歡迎提 issue 和 PR。
GitHub: ReadAny

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