`co_writer` 的协作写作机制:623 行的包,与不在包里的那半套
翻一个项目的”协作写作”模块,最容易犯的错是:ls 一下目录,看见文件不多,就以为读完包就读完了功能。DeepTutor 的 deeptutor/co_writer/ 恰好是这种情况的反例——包很小,功能的另一半在别处。
以下所有行号、行数与字段名都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行号可能已经漂了,文件名与标识符是稳的,照着搜即可。本文只读源码文本,没有安装、部署或运行过这个项目。
一、先把这个包的体积摆出来
deeptutor/co_writer/ 下只有 2 个有内容的 Python 文件:edit_agent.py(373 行)与 storage.py(250 行),__init__.py 是 0 行。整个包按 find + wc -l 统计是 7 个文件、Python 共 623 行。
对照一下同一功能的 API 侧:deeptutor/api/routers/co_writer.py 有 618 行,暴露 12 个路由——/edit、/edit_react、/edit_react/stream、/automark、/history、/history/{operation_id}、/tool_calls/{operation_id},以及文档 CRUD 的 GET|POST /documents 与 GET|PUT|DELETE /documents/{doc_id}(api/routers/co_writer.py:379-604)。
一个 623 行的包配一个 618 行的路由文件,这个比例本身就是信号:路由层不只是转发。这一点下面会具体展开。
二、EditAgent:三个动作、两个来源、一个 operation_id
EditAgent 继承 BaseAgent,构造时钉死 module_name="co_writer"、agent_name="edit_agent",默认 enabled_tools=["rag", "web_search"](co_writer/edit_agent.py:113-120)。前两个名字决定提示词从哪个 YAML 加载,这是基类的约定。
对外的方法签名是 process(text, instruction, action, source, kb_name)(edit_agent.py:122-138):
| 参数 | 取值 | 含义 |
|---|---|---|
action | rewrite / shorten / expand | 改写、缩写、扩写三选一 |
source | rag / web / None | 参考资料从哪儿取,可以不取 |
kb_name | 字符串或空 | source=rag 时指定哪个知识库 |
返回值是 {edited_text, operation_id}。operation_id 的格式是 %Y%m%d_%H%M%S 时间戳加下划线再拼 uuid4() 十六进制串前 6 位(edit_agent.py:122-138),随 {edited_text, operation_id} 一起返回(edit_agent.py:212)。这个 id 不是装饰——/history/{operation_id} 和 /tool_calls/{operation_id} 两个路由都按它取数据,工具调用结果落盘的文件名也是 {call_id}_{tool_type}.json(edit_agent.py:84-91)。要复盘某一次改写到底喂进去了什么参考资料,落点就在这两个路由与那份单独落盘的 JSON 上。
三、gather_context 的降级是静默的
这是这个模块里第一处值得单独记的语义。
gather_context 返回一个二元组 (context, tool_call_file)(edit_agent.py:214-287),docstring 写得很直白:空 context 表示工具不可用或失败,调用方降级为普通编辑。
两条分支各自的实现:
- RAG 分支:调
rag_search(only_need_context=True),也就是只要检索到的上下文、不要它生成答案。 - Web 分支:用
asyncio.to_thread(web_search, query)包一层,代码注释写明理由是web_search是同步网络 I/O,放进线程池是为了不阻塞事件循环。
这两条分支需要如实说明一件事:它们会向外发起真实的网络请求或读取本机的知识库数据。Web 分支走的是外部搜索,RAG 分支读的是你自己灌进去的知识库。是否开启、开哪个 kb_name,属于你自己的数据边界问题,得自己评估。
回到”静默”这个词。工具不可用或检索失败时,gather_context 返回的是空 context,调用方据此降级为普通编辑,不抛错、不改返回结构(edit_agent.py:214-287)。对调用方来说,拿到的仍然是一次成功的改写结果,只是这次改写没有任何参考资料垫底。
判定动作很具体:看这一次 gather_context 有没有返回非空的 tool_call_file——落盘文件名的格式是 {call_id}_{tool_type}.json(edit_agent.py:84-91)。没有这个文件,说明这一次走的是无 context 的普通编辑。什么情况说明不是这个原因?如果文件在、context 也非空,那参考资料是取到了的,改写结果不符合预期就得往提示词与模型那一侧查,跟 gather_context 无关。
四、反直觉的那一处:选区 ReAct 编辑不在包里
如果你只读 deeptutor/co_writer/ 这个包,会得出”这个模块只能整篇改写 + 自动标注”的结论。但仓库里还有一套选区编辑,它的实现整个落在路由层。
请求模型是 ReactEditRequest(api/routers/co_writer.py:83-94),字段为 selected_text、instruction、mode ∈ {rewrite, shorten, expand, none}、tools ⊆ {rag, web}、kb_name。注意它和 EditAgent.process 的差别:多了一个 none 模式,tools 是列表而不是单选的 source。流程是先按 tools 的顺序取参考资料,再拼提示词做流式改写(api/routers/co_writer.py:232-288)——这整段逻辑写在路由文件里,co_writer/ 包内没有对应实现。
它的提示词是在路由里硬拼的字符串,有 4 条硬性要求(api/routers/co_writer.py:165-171):只输出改写后的 Markdown 片段,供直接替换;不要解释、标题、前缀、后缀或代码围栏;保持 Markdown 合法;给了参考资料就自然融入,且不得提及工具或来源。
两处入参校验会直接返回 HTTP 400:mode == "none" 且指令为空时(api/routers/co_writer.py:202-208),以及选区文本为空时(api/routers/co_writer.py:210-217)。前者的语义是”你既没选模式也没说要干什么”,这时候确实无从下手。
这处架构安排带来的实际后果只有一条,但很实在:做二次开发或读代码定位问题时,grep 范围不能只圈 deeptutor/co_writer/。选区改写的提示词、工具顺序、字符上限、错误码,全都要去 api/routers/co_writer.py 里找。我们只陈述这个分布事实,不推断为什么这么分。
顺带记一下路由层的三档载荷上限(api/routers/co_writer.py:63-67):文档 600000 字符、选区 120000 字符、指令 10000 字符。代码注释明说这些上限”存在只是为了挡住失控载荷(OOM / 意外 LLM 账单)“,不是用来约束正常文档的。这三个数是路由层的默认配置,不构成对你实际用量的任何保证。
五、auto_mark:只插标签,不动原文
auto_mark(text) 返回 {marked_text, operation_id}(edit_agent.py:289-333),做的是给文本加标注。它的约束全写在提示词里(co_writer/prompts/en/edit_agent.yaml),是本模块里少见的”配额制”设计:
| 标签 | 配额 | 内容长度 |
|---|---|---|
| circle | 每 100 字符最多 1 处 | ≤5 字符 |
| highlight | 每段最多 2 处 | 2-15 字符 |
| box | 每段最多 1 处 | ≤20 字符 |
| underline | 每段最多 1 处 | 5-30 字符 |
| bracket | 全文最多 1-2 处 | — |
除此之外还有两条总约束:总标注密度每段不超过全文 10%;绝不修改原文,只插入 HTML 标签。
后半句是这张表真正的重点。因为”只插标签不改原文”这条约束一旦被违反,你在文档里看到的就不是标注而是被悄悄改过的正文,且很难察觉。这条约束是写在提示词里的要求,不是代码层的强制校验——我们在这个模块里没有读到对模型输出做原文比对的逻辑,所以它属于”提示词层的约定”,落地效果取决于模型,我们没有核实。
需要补一句边界:这些配额数字与密度上限是该项目的实现选择,是提示词里的参数,不是经过验证的阅读或教学结论。本文只说这些值写在哪一行、怎么运作,不评价它们在学习效果上是否有效。
六、历史不是主存储,文档才是
第二处容易误会的地方在存储侧。
edit_agent.py:41-45 的注释把话说死了:历史是调试/审计轨迹,不是主存储。配套的两个常量是最多 200 条、单条文本超过 20000 字符截断,超限后只保留最后 200 条(edit_agent.py:75-81)。
也就是说,指望用 /history 做”撤销”或”版本回溯”是走错了门——它会截断、会滚动淘汰。真正的文档持久化在 co_writer/storage.py:布局是 data/user/workspace/co-writer/documents/doc_{doc_id}/manifest.json,模型 CoWriterDocument 只有 {id, title, content, created_at, updated_at} 五个字段(storage.py:7-14、storage.py:34-41)。一篇文档一个目录一个 manifest,没有版本链。
几个细节值得记:
- 文档 id 是
uuid4().hex[:12],且会循环重取直到不撞上已存在的目录(storage.py:168-171)。 - 标题是推导出来的。
_derive_title取第一个非空行,若以#开头就剥掉#,截断到 120 字符,都取不到时用"Untitled draft"(storage.py:70-83)。 - 标题同步有条件。
update_document只在标题为空、或标题仍是默认值时,才跟着首个标题走(storage.py:209-216)。换句话说,你一旦显式改过标题,正文首行再怎么变,标题也不会再自动跟。 - 列表视图的
CoWriterDocumentSummary带一个 160 字符的preview,按updated_at倒序(storage.py:86-93、storage.py:159)。
还有一处工程细节:路由层的 EditAgent 是按语言缓存的单例,每次取用都调一次 refresh_config(),目的是吃到 Settings 里改过的配置(api/routers/co_writer.py:45-60)。语言变了会重建实例,配置变了靠 refresh。
七、一处可核实的不一致
按纪律,只陈述差异、标明两处位置,不推断原因、不评价质量。
co_writer/prompts/en/narrator_agent.yaml 与 zh/narrator_agent.yaml 两个文件都在,各 88 行。但全仓 grep -rn "narrator_agent\|NarratorAgent" 只在 assets/releases/past_releases/ver0-3-0.md:65 命中一次,那里提到的是 co_writer/narrator_agent.py;而当前 deeptutor/co_writer/ 下 find 的结果只有 edit_agent.py、storage.py、__init__.py 和 4 个 YAML,没有该 py 文件。
以我们实读的仓库状态为准。说完就停。
八、你可以照着核的六步
wc -l deeptutor/co_writer/*.py与wc -l deeptutor/api/routers/co_writer.py,把 623 与 618 这两个数字对上。- 打开
edit_agent.py:122-138,确认action只有三个取值、source只有两个加一个空。 - 打开
edit_agent.py:214-287,确认 docstring 写明空 context 即降级为普通编辑。 - 打开
api/routers/co_writer.py:83-94与:165-171,确认选区 ReAct 编辑的请求模型与那 4 条提示词硬性要求确实写在路由文件里。 - 打开
edit_agent.py:41-45,确认历史被显式定位为审计轨迹,再看storage.py:7-14的文档布局,把两者分清。 grep -rn "narrator_agent" .,自己看一遍第七节那处差异。
最后交代边界:本文只读了 co_writer/ 包的两个 Python 文件、英文版 edit_agent.yaml,以及 api/routers/co_writer.py 中与选区编辑、载荷上限、单例缓存相关的片段,没有逐行读完 618 行的路由文件;前端如何渲染这些 rough-notation 标签我们完全没有看,所以本文提到的标注机制只反映后端与提示词这一侧。文中所有上限与配额都是源码与提示词里的默认配置,不是运行结果的保证。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。