我为什么造了一个AI共读项目:Intertext的设计与实现
简介
背景
市面上的阅读工具很多,但即使有些引入了 AI 能力,也很难真正适配“共读”的需求。
- 传统电子书阅读器,如掌阅,已经比较成熟,但功能和使用方式相对固定,可替代性较高。
- 微信读书等带有或集成了 AI 功能的阅读器,生态比较丰富。但以微信读书发布的 Skill 为例,它只能返回部分上下文,AI 因此容易出现幻觉。
- 另一个比较接近“共读”的产品是 NotebookLM。它依靠系统提示词降低幻觉概率,但同时也限制了回答的开放性:文件中没有、但属于世界知识的内容,例如历史背景和文学常识,实测 AI 绝对不会主动补充。此外,它与 Gemini 强制绑定。
- Avid 支持跨书搜索,但聊天属性更强,不支持精准提问,并且与 iPad 绑定较深,生态单一。
Intertext 这个项目
Intertext,取“互文”之意。文本是引文的编织物:任何一本书都从未真正孤立存在,它回响着前人的句子,也等待着与后来者的对话。
Intertext 把这种“文本间性”变成可以实际操作的阅读体验:书与书之间可以互相引证,读者与 AI 之间可以就文本展开讨论,让这本书与万千文本彼此阅读。
- 内置全文搜索、跨书寻找:文本与文本的互文
- 接入可选 MCP:书本之外的世界,文本与非文本的互文
- 自定义不同 AI API:选择你的“共读伙伴”
- 自定义提示词:选择以怎样的姿态参与对话
我最担心的是把阅读变成一个问答框。问答通常以“得到一个答案”为终点,而共读更像是把一个问题放在文本旁边,允许它继续发散、被追问,再回到原文验证。因此,Intertext 的基本单位不是一次请求,而是“阅读现场”:当前书籍、选中的文字、读者留下的批注、已经展开的对话,以及对话过程中查到的其他文本。因为想要避免“代码编辑器”的感觉,所以设计前端页面的时候斟酌了很久
核心功能
三大模块:批注/摘录/笔记
阅读中的动作从选文开始。一段文字被选中之后,阅读会分出几条不同的路径:
- 批注:选中文本,添加批注。
- 共读:和 AI 一起讨论选中的文本和自己的批注。
- 摘录:把选中的原文保存下来,作为日后可以回看的收藏。
- 笔记则不依附于某一处选文。它可以关联某本书,也可以独立存在,用来记录更完整、相对独立的想法。
批注和摘录都从一段原文开始,必须保留它们在书中的位置;笔记则不需要强制绑定某段文本。
整体架构
Intertext 采用前后端分离的结构。前端使用 TypeScript 维护阅读器、选区、侧栏和对话状态;后端使用 FastAPI 提供书籍、批注、笔记和对话接口;PostgreSQL 保存业务数据,PGroonga 负责全文搜索。AI 服务和外部 MCP 服务都位于后端之外,通过统一的工具接口接入。
一次典型的共读请求大致经过下面几步:
- 前端记录当前阅读现场,包括书籍、章节、选中文本和关联的批注。
- 后端根据对话设置组装上下文,并决定哪些内置工具和 MCP 工具对本次请求可用。
- AI 适配层把统一格式转换成具体服务商的请求格式,接收流式响应和工具调用。
- 工具调用由后端执行。搜索结果会经过长度限制和字段清理,再作为新的上下文交给模型。
- 前端按事件更新回答、工具状态和消息记录;完整消息同时写入数据库,便于刷新页面后恢复。
我没有把所有检索工作都交给向量数据库。对于共读来说,原文中的人名、术语、句子和固定表达经常比“语义相似”更重要。PGroonga 能直接在 PostgreSQL 中完成中文全文检索,也让书籍、章节、摘录和对话可以使用同一套事务和权限逻辑。
AI:工具与上下文管理
工具决定它可以走向哪里,上下文决定它此刻看见什么。Intertext 将这两部分放在一起设计,让阅读保持开放,也让每一次回答都尽量贴近眼前的文字。
工具
- 内置全文搜索:可以在当前书籍中追索,也可以跨书搜索。跨书搜索让不同作品中的相似概念、相反观点和反复出现的意象彼此显现,读者也更容易找回自己曾经读过、却暂时想不起出处的内容。
- 外部 MCP 服务:把书本之外的资料带进来,让历史背景、文学常识或其他外部知识参与阅读。
上下文工程
一场共读所携带的上下文由这些部分组成:系统提示词、用户预设提示词、工具 schema、选中文本、其他关联文本(可选)、当前对话消息,以及工具调用结果。
这些内容不能简单地全部拼接到 prompt 里。系统提示词决定共读伙伴的边界,用户预设提示词决定它的语气和工作方式,选中文本则是当前问题的锚点。工具结果属于临时材料,应该只保留与当前问题有关的部分;历史对话也需要按照 token 预算裁剪,不能因为对话变长就让最重要的原文被挤出去。
因此,组装上下文时我使用了“固定层 + 动态层”的思路:固定层放系统规则和用户配置,动态层优先放选中文本与当前消息,再按需要加入关联文本、搜索结果和近期对话。每一层都有独立的长度上限,超过上限时优先压缩或移除可重新获取的工具结果。
工具也不是越多越好。工具 schema 本身会占用上下文,搜索范围过大还会带来大量噪声。因此内置搜索工具会明确区分当前书和跨书搜索,MCP 工具则按需启用。模型只有在需要时才调用工具。
开发过程遇到的问题
请求过于频繁
最先遇到的问题不是模型回答得不好(实际上这是模型端和提示词的问题!),而是请求太容易被重复发出。用户连续点击发送、切换页面后重复提交、上游返回 429 后立即重试,都会让同一场对话产生多条回答。流式请求尤其容易出现这种情况,因为请求已经发出时,界面上还没有最终消息,用户很难判断它是否正在处理中。
我的处理方式分成前端和后端两层。前端在一条消息进入生成状态后暂时锁定发送入口,同时保留取消生成的操作;搜索输入使用短暂的 debounce,避免每次键盘输入都发起查询。后端为一次生成请求分配 idempotency key,并给消息记录设置 pending、streaming、completed 和 failed 状态。收到相同 key 的请求时,直接返回已有的生成任务,而不是再次调用模型。
上游限流不能只靠前端按钮解决。AI 适配层会识别 429 和网络暂时不可用的错误,使用带上限的指数退避;已经产生部分内容的流式响应不会被静默拼接到下一次重试中,而是把当前消息标记为失败并保留已生成的内容。这样读者至少能看到失败发生前的回答,也可以明确地重新生成。
AI 消息的流式事件协议
不同 AI 服务商对流式响应的叫法和字段并不一致。有的服务只发送文本增量,有的会把工具调用拆成多段 JSON,还有的会在结束时才补充 usage 信息。如果前端直接依赖某一家服务商的响应格式,换 API 或接入 MCP 时就必须重写阅读器状态管理。
所以后端先把供应商协议转换成 Intertext 自己的事件协议,再通过 SSE 推送给前端。协议只描述“发生了什么”,不暴露供应商的字段。例如:
1 | {"type":"message_start","message_id":"m_123"} |
前端不再根据字符串内容猜测当前状态,而是用一个小型 reducer 处理事件:text_delta 只追加回答,工具事件更新工具调用列表,message_end 将消息从生成状态改为完成,error 则保留已有内容并显示重试入口。消息 id 必须贯穿整条链路,这一点很重要;没有稳定 id 时,网络重连或多个工具并行执行都可能把内容写进错误的消息。
让对话回到原来的阅读现场
共读的价值很大一部分来自“过几天还能接着读”。因此,保存消息时我不只保存最终文本,还保存它对应的书籍、章节、选中文本、用户批注和模型配置。再次打开对话时,阅读器可以恢复到当时的位置,读者也能看出一条回答对应了哪一段原文。
对话消息和阅读对象使用 id 关联,而不是把整本书内容复制进每条消息。这样可以避免重复存储,也能在书籍重新索引后继续使用原来的引用。工具结果则只保留必要的摘要和来源,完整搜索可以在需要时重新执行。这个取舍让数据库记录保持可读,也避免把一次临时检索变成永久的上下文负担。
另一个需要处理的细节是并发更新。流式输出期间,消息会被多次写入;如果用户同时打开两个页面,后到的更新不能覆盖更早的内容。为此,消息记录带有版本号,更新时检查版本并按事件顺序追加。即使浏览器断开,服务端也能根据最后一个事件编号判断哪些内容已经持久化,页面重新连接后再补发未读事件。
后续计划
下一步想做的是 桌面端应用 。桌面端能够更方便地管理本地书库,也更适合边读边查的长时间工作流。
除此之外,后续计划还包括:
- 改进导入和排版,保留章节、脚注、页码等原始结构。
- 增加可选的语义检索,让跨书搜索同时支持精确匹配和概念匹配。
- 为对话、摘录和笔记增加导出功能,避免阅读成果被锁在应用内。