为了学习 AI Agent,我做了一个 AI 阅读器(已开源)

0 阅读12分钟

项目地址: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,四个包:

  • @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,建议找个自己感兴趣的方向做个东西出来。不一定要多复杂,但一定要自己从头写一遍。看十篇文章不如写一百行代码,这是真的。