乐于分享
好东西不私藏

怎样设计一个面向 Agent 的在线文档系统

怎样设计一个面向 Agent 的在线文档系统

Doco 传播系列 · 第 4 篇 · 技术稿

前面几篇讲了 Doco 能做什么、怎么用。这一篇讲它是怎么设计的,内容较长,也比较偏技术,读起来需要一点耐心。

2026 年,让 Agent 读写文档已经不新鲜。真正的问题是:把一个现成的文档系统直接开放给 Agent,往往很快就会出问题。原因不在 Agent,而在这些系统从一开始就不是为 Agent 设计的:

  • 段落没有地址。Agent 想改一句话,只能整篇读回来、整篇写回去;
  • 写入没有版本。两边同时改,一边静默覆盖另一边,两边还都显示成功;
  • 阅读没有预算。长文档整篇塞进上下文,token 烧完了,信息照样被截断;
  • 错误没有语义。该报冲突时返回 500,该让重试时返回 403,Agent 只能靠猜。

Doco 从第一天起就把「对 Agent 友好」列为一等设计目标。下面十个设计决定,每个都对应一个真实的问题。先看速查表:

Agent 的难处
Doco 的设计决定
段落没有地址,只能整篇改
稳定块 ID:block_<ULID>,位置无关
并发写入静默覆盖
版本指纹 + If-Match,冲突显式 409
API 写入和页面编辑是两套逻辑
写入走同一份 Y.Doc,浏览器实时可见
Markdown 没地址,JSON 太啰嗦
锚点往返:Markdown 只做交换格式
搜不到 / 不知搜没搜全
搜索结果是契约:带证据、新鲜度、完整性
长文档烧上下文
阅读有视图、有 token 预算
多处修改做成半成品
批量单事务 + 幂等键
多通道接口各说各话
REST / MCP / CLI 同一套契约
令牌权限一刀切
细粒度 scope + 限频 + 可撤销短效令牌
派生内容可能过期
完整性与新鲜度显式声明,绝不让猜

一、每个段落都有稳定的地址

传统文件范式里,内容的地址是「路径 + 行号」,内容一动地址就失效。Agent 因此只能做文件级操作:整篇读、整篇改、整篇写回——改动范围大、出错难定位,也没法精确引用某一段。

Doco 的做法:给每个顶层块一个稳定 ID block_<ULID>。关键在于 ID 的存放方式——它是节点属性,不是位置索引,存在 Yjs CRDT 里,跟着节点本身走。拖动、重排、折叠、跨章节移动,ID 都不变;复制块时会签发新 ID,不会出现两块同号。

ID 补全有三层兜底:

  • 浏览器端:一个 ProseMirror 插件监听所有编辑事务,自动给缺 ID 的块补上。补 ID 不进 undo 历史,用户按撤销时不会看到多余的操作;
  • 服务端:每次读写都校验格式与唯一性,非法 ID 返回 422 invalid_block_id,重复 ID 返回 422 duplicate_block_id——宁可拒绝,也不悄悄改写调用方提交的数据;
  • 旧文档:首次加载时做一次幂等迁移事务补齐,不做离线全量重写。

有了地址,后面的一切才成立:改「一句话」就是 PATCH 一个块;引用可以精确到 doco://doc/{id}#block={block_id};浏览器打开带 ?block= 的链接会直接滚动聚焦到那个块——人和 Agent 指向的是同一个位置。

二、版本指纹:让并发冲突显式暴露

并发写入最危险的不是报错,而是两边都显示成功,其中一边被静默覆盖。

Doco 采用乐观并发控制:每次读取正文都返回版本指纹(ETag),写入必须用 If-Match 把它带回来——

  • 没带指纹?返回 428 precondition_required,直接拒绝,不做 last-write-wins;
  • 指纹对不上?返回 409 document_version_conflict,响应里附上最新版本号,引导重读合并。

整篇替换、批量操作、版本回滚,一律强制带指纹。冲突在这里不是事故,而是需要显式处理的事件:系统宁可停下来报错,也不假装无事发生。

这个设计里有一个容易踩的坑:指纹到底哈希什么?直觉答案是哈希 Yjs 状态二进制,但 encodeStateAsUpdate 的字节会随 update 合并历史变化——两份内容完全相同的文档,一份一直在线、一份离线重建过,字节并不相同。直接哈希二进制,Agent 会在内容毫无变化时收到误报的 409。Doco 的版本指纹哈希的是「正文 + 表格数据」的规范化 JSON(键排序):内容相同,指纹必相同,与合并路径无关。

两个配套决定:

  • 同一文档的所有写入走进程内串行队列,从机制上排除竞态;
  • 每次写入前先存版本快照(默认保留最近 20 份)。Agent 写错了,有版本列表和回滚 API 兜底。

三、人和 Agent 写同一份文档

很多系统的 API 写入走影子存储:浏览器里看到的和 Agent 写入的不是同一份数据,两套存储迟早对不上。

Doco 的开放 API 不开第二个数据源。写入时如果文档正被浏览器打开,事务直接作用在 Hocuspocus 内存中那个 Y.Doc 上,Yjs 会把更新广播给所有连接的客户端——Agent 写下的内容,你能在浏览器里实时看到它出现。文档没人打开时写入 SQLite,下次打开自动读回;写入在响应前就完成持久化,服务端重启也不丢。每笔写入带 origin 标签(doco:open-api:batch、doco:open-api:block-update……),谁改的、走哪条通道,审计日志里查得到。审计还带 is_agent 列,能识别 claude-code、cursor、codex、doco-cli 这些 Agent 客户端——「知识库流量里 Agent 占多少」可以直接查出来。

反向链路同样打通了:你在浏览器里改正文,落库触发 SQLite 触发器,事件进入 SSE /events 流,订阅的 Agent 实时收到;配合块级 changes 接口,Agent 拿着基线游标查增量(增、删、改、移),不必每次重读整篇。这里有个细节:判断「哪些块算移动」时用最长递增子序列比对相对顺序——否则在文档头部插入一段,后面所有块都会被误报为 moved,增量同步立刻失效。

也需要说明:Agent 写入时向浏览器广播「正在修改第 N 段」的瞬时光标,目前还是待做的增强项。人和 Agent 的改动互相可见,但暂时还看不见对方的光标。

四、Markdown 是交换格式,不是存储格式

Markdown 是 Agent 最熟悉的格式,但它没有地址,也表达不了表格对齐、Callout、电子表格这些富信息。两个省事的方案都不成立:「让 Agent 直接写 JSON」太不友好,「只开放 Markdown」又会丢信息、丢地址。

Doco 的决定:Tiptap JSON 是无损的标准格式,Markdown 是便捷的交换格式,中间用锚点往返衔接:

  1. 导出时带 ?annotate=anchors,每个块前面插入一行独占的锚点注释 <!--@block=block_xxx-->;
  2. Agent(或你的编辑器)在 Markdown 里自由修改;
  3. 整篇写回时,解析器按代码围栏感知的方式切块,锚点归属其后第一个块——没动过的块凭锚点保住原 ID,新增内容签发新 ID。

这样,「整篇人性化编辑」和「块级稳定寻址」不再冲突。CLI 的 doco edit --heading 也靠它:把某一节截出来编辑、整篇写回,其他章节的块 ID 纹丝不动;中途撞了 409,上一轮编辑会被存成冲突副本,不会丢失。

五、搜索结果是契约,不只是提示

grep 只能回答「这些字出现在哪些文件里」。Agent 搜完真正需要的是一整套信息:哪篇文档、在哪个标题下、前后文是什么、结果新不新鲜、有没有搜全。

Doco 的搜索建在服务端(SQLite FTS5),命中落在块上,每条命中都带完整证据:

块 ID · 块类型 · 位置目录路径 / 标题路径上下文:上一块摘录 / 命中 / 下一块摘录命中次数 · 分数 + 分数解释正文版本 · 索引版本 · 新鲜度投影完整性:complete / 水位 / stale 文档清单

三个决定值得展开:

  • 分数可解释
    。score_explanation 拆出标题权重(8 倍)与正文频次各自贡献了多少。Agent 能判断命中是「标题命中」还是「正文恰好提到」,而不是只看一个黑盒数字;
  • 完整性可证明
    。穷举模式的游标里嵌着投影水位:翻页期间只要有任何文档的索引水位变化,游标立刻以 409 search_cursor_stale 作废,要求重新查询。「没搜到」和「没搜完」是两种不同的返回值;
  • 中文检索用直接的办法
    。FTS5 默认分词器不支持中文,Doco 入库时给每个汉字两侧插入空格,查询时转成逐字 phrase AND。不炫技,但语义正确,也不依赖任何外部分词服务。

六、阅读有视图,也有预算

Agent 消耗上下文最常见的方式,就是「先把整篇读进来再说」。

Doco 把「读」拆成几种方式:

  • 先看结构
    :outline 接口返回标题树,带 block_id、heading_path、章节块区间——先读目录,再决定深入哪里;
  • 按视图读
    :markdown 求快读,tiptap-json 求无损操作,plain-text 求纯文本,同一份内容按任务给不同投影;
  • 按预算读
    :read 接口支持 max_tokens 和 around 参数,围绕任意嵌套块读上下文。预算不够时,返回值明确标注 budget_exceeded,附 omitted_ranges 和版本绑定的续读游标——期间正文变了,游标以 409 作废,绝不会让你把两个版本的片段拼在一起当事实。

七、批量是原子的,重试是幂等的

一次任务改四处,前三条成功、第四条失败——半成品比失败更难收拾;网络抖一下重试,文档可能被创建两份。

批量:POST /batch 接受 1–100 个操作,insert / replace / delete 在同一个 Yjs 事务里执行,全有或全无,且强制带版本指纹——否则批量就退化成 last-write-wins。

幂等键:文档、批量、附件等创建型接口支持 Idempotency-Key。Agent 发起创建请求时带一个全局唯一的键,语义是「这个请求无论发送几次,都是同一笔操作」;服务端为每个处理过的键存一张回执存根——哪个令牌、哪个接口、请求体是什么、首次成功的响应是什么——保留 24 小时,重试时凭存根识别:

  • 同键同体重放 → 服务端认出这笔操作已经做过,不再重复创建,原样返回首次结果,重试没有副作用;
  • 同键异体 → 409 idempotency_key_conflict。重试逻辑悄悄改了请求内容?显式暴露,不代为执行;
  • 只缓存成功响应,失败不粘滞,改好了随时可以重来。

八、三条通道,一套契约

REST API、MCP server、CLI 不是三套接口,而是同一套契约的三个投影:

通道
定位
为 Agent 做的设计
REST
集成底座
OpenAPI 3.1 发布在 /api/openapi.json,62 个 path,契约测试与路由互相锁定
MCP
Claude Code / Cursor 等客户端
29 个工具刻意克制,概念与多语言按 action 聚合,避免工具爆炸;resources 可订阅
CLI
终端 / 脚本 / 调试
写入自动带版本指纹,409 自动重读重试;--json 全局生效,错误也输出机器可解析 JSON

几个细节:

  • MCP server 的 instructions 里直接写明黄金循环(读版本 → 带版本写 → 409 重读重试),写工具内部也替你做了;工具输出设 200k 字符软上限,超限时不返回原文,而是返回「缩小范围」的指引——像保护自己的上下文一样保护 Agent 的;
  • 错误响应是统一的结构化信封:error.type / code / message / details 加 request_id。409 附最新版本号,429 附 Retry-After,403 列出缺哪些 scope——每个错误都包含下一步该怎么做,而不是让人挠头的字符串;
  • CLI 登录走设备授权:和 GitHub CLI 一样在浏览器确认,网页上还能顺手把权限从读写降级为只读;签发的令牌默认 90 天有效,刻意不做永久 PAT;轮询换令牌时校验 IP 哈希绑定,device_code 就算从代理日志里泄漏,也换不走令牌;
  • 连用法都以 Skill 形式分发:doco skill install 把 Agent 操作手册装进 Claude Code / Codex——黄金循环、限频数字、已知坑位,全在里面。这可能是最「Agent 优先」的一处设计:文档的第一读者,就是 Agent。

九、只给 Agent 该给的权限

令牌权限一刀切,等于让 Agent 拿着超出需要的权限操作整个知识库。Doco 把权限拆细,每个令牌只能做明确授权的事:

  • 细粒度 scope
    :9 个权限点覆盖文档 / 知识库 / 附件 / 概念的读写与摘要生成,read_only 与 read_write 是两个预设组。scope 不足返回 403,details 里直接列出需要补哪些权限——错误信息告诉你怎么补救;
  • 令牌安全
    :明文只在签发时出现一次,服务端只存 SHA-256 哈希,恒定时间比较;网页上一键撤销,已撤销令牌的 SSE 长连接会被心跳检测踢下线;
  • 限频四层桶
    :SQLite 持久化的令牌桶,重启不清零,每个响应都带 X-RateLimit-* 余量头,超限返回 429 和 Retry-After。
桶
限额
保护什么
未认证 IP
30 次/分钟
防令牌猜测
每 Token 总请求
120 次/分钟
常规调用量
每 Token 写入
30 次/分钟
SQLite 与 Yjs 事务
每 Token 单文档写入
10 次/分钟
防高频覆盖与广播风暴

Agent 不用猜自己还剩多少额度——余量写在响应头里,也可以随时 GET /me/quota 自查。推荐的接入路径同样经过设计:先签发 read_only,让 Agent 用搜索和阅读证明自己的答案质量,再换读写权限。信任应该是逐步建立的。

十、贯穿一切的元原则:绝不让 Agent 猜

回头看这十个决定,贯穿其中的只有一句话:绝不让 Agent 猜。

  • 搜不全 → complete=false,并列出哪些文档的投影是 stale 的;
  • 增量窗口被压缩 → sync_required=true,要求全量重读;
  • 转换有损 → warnings[] 带 code;
  • 读取截断 → budget_exceeded 附省略范围;
  • 派生内容过期 → 显式标记 stale,绝不悄悄端上来。

这也决定了 Doco 的分层:正文与协同状态是唯一数据源;搜索索引、四层摘要、引用关系、概念图,全部是可重建的派生投影。投影更新失败不阻断正文读写,只标记 stale、启动时回填;每个派生结果都带来源块和来源版本,能回到原文、能重建、能审计。模型生成的摘要、抽取的概念也一样——先进候选池,人确认了才转正,从不悄悄混进权威正文。

设计原则速览

最后把 Doco 的 3条设计原则放在这里,给同样在为 Agent 造系统的同行参考:

  1. 内容存储优先于派生索引;
  2. 执行回执明确状态和完整性,绝不让 Agent 猜;
  3. 人类编辑和 Agent 操作共享同一个状态;

结语

Agent 读写文档,不是加一个 API 层就算「支持 Agent」。地址、版本、预算、契约、权限、可见性——它们虽然不在 API 文档模板里,却决定了一个 Agent 能不能安全、精确、可信地在你的知识库里工作。

相关学习资料