第十章:Notebook 为什么必须结构化编辑
看 Claude Code 为什么明确不把 notebook 当普通 JSON 文本去改,而是按 cell 结构操作。
1. 为什么 notebook 必须单独看
如果把 .ipynb 当普通文本文件看,最直觉的做法其实是:
Read到 JSONEdit某几段字符串- 再写回去
但 Claude Code 明确没有这么干。
它专门做了一个 NotebookEditTool,这背后其实是一个很重要的设计判断:
notebook 不是普通文本文件,而是结构化文档对象。
这件事很关键,因为它意味着 Claude Code 在工具层已经承认:
- 有些文件虽然落盘是文本
- 但运行时不该把它们按文本处理
对你后面做 C# 版来说,这几乎就是对象模型分层的直接证据。
2. 先说结论
我对这份实现的判断是:
NotebookEditTool 不是 FileEditTool 的变种,而是“面向 cell 的 notebook 结构编辑协议”。
它的核心特点是:
- 明确只服务
.ipynb - 编辑目标是 cell,不是原始 JSON 片段
- 支持
replace / insert / delete - 仍然要求先
Read - 写入时会保留 notebook 文件的编码和换行
- 修改 code cell 时会清空
execution_count和outputs
这套设计很能说明 Claude Code 的一条原则:
不要让模型直接改序列化格式,尽量让模型改领域对象。
3. 源码锚点
建议先看这些位置:
src/tools/NotebookEditTool/NotebookEditTool.ts:90src/tools/NotebookEditTool/NotebookEditTool.ts:125src/tools/NotebookEditTool/NotebookEditTool.ts:133src/tools/NotebookEditTool/NotebookEditTool.ts:176src/tools/NotebookEditTool/NotebookEditTool.ts:219src/tools/NotebookEditTool/NotebookEditTool.ts:252src/tools/NotebookEditTool/NotebookEditTool.ts:295src/tools/NotebookEditTool/NotebookEditTool.ts:331src/tools/NotebookEditTool/NotebookEditTool.ts:372src/tools/NotebookEditTool/NotebookEditTool.ts:392src/tools/NotebookEditTool/NotebookEditTool.ts:431src/tools/NotebookEditTool/prompt.ts:2src/tools/NotebookEditTool/UI.tsx:72
4. 整体结构图
flowchart TD
A["模型发起 NotebookEdit(notebook_path, cell_id, new_source, cell_type, edit_mode)"] --> B["checkPermissions()<br/>write 权限"]
B --> C["validateInput()"]
C --> C1["必须是 .ipynb"]
C --> C2["edit_mode 合法"]
C --> C3["insert 时必须给 cell_type"]
C --> C4["必须先 Read"]
C --> C5["stale check"]
C --> C6["notebook JSON 合法"]
C --> C7["cell_id 或 cell index 可定位"]
C7 --> D["call()"]
D --> E["fileHistoryTrackEdit()"]
E --> F["readFileSyncWithMetadata()"]
F --> G["jsonParse() 非缓存版本"]
G --> H{"edit_mode"}
H --> I["delete cell"]
H --> J["insert cell"]
H --> K["replace cell source"]
K --> L["code cell 清 execution_count / outputs"]
I --> M["jsonStringify(notebook)"]
J --> M
L --> M
M --> N["writeTextContent()"]
N --> O["更新 readFileState"]
O --> P["tool_result"]
这里最重要的一点是:
Claude Code 对 notebook 的编辑粒度是 cell,而不是文本 diff。
5. Prompt 已经暴露了这不是文本编辑
prompt.ts 里的描述很直接:
- 编辑对象是 notebook cell
edit_mode=insert表示插入 celledit_mode=delete表示删除 cell
虽然 prompt 里还残留了 cell_number 这种旧说法,但真正的实现已经是:
cell_id- 或可解析成索引形式的
cell-N
这点反而挺有意思,它说明这里很可能经历过一版从“按索引编辑”向“按 cell ID 编辑”演化的过程。
6. 协议模型本身就是结构化的
6.1 输入不是“旧串 / 新串”
NotebookEditTool 的输入是:
notebook_pathcell_idnew_sourcecell_typeedit_mode
和 FileEditTool 相比,这已经完全不是一个层级的问题了。
FileEditTool 让模型描述:
- 哪段字符串要替换成什么
而 NotebookEditTool 让模型描述:
- 哪个 cell 要进行什么操作
这说明 Claude Code 不想让模型理解 notebook 的底层 JSON 细节,而是直接给它一个更稳定的领域接口。
6.2 输出也围绕 notebook 语义组织
输出里会包含:
cell_idcell_typelanguageedit_modeoriginal_fileupdated_file
也就是说,它不是单纯给一个“编辑成功”的字符串,而是保留了 notebook 级别的前后状态。
7. 校验阶段最重要的是三条边界
7.1 只能处理 .ipynb
如果扩展名不是 .ipynb,直接拒绝,并明确告诉模型:
- 其他文件请用
FileEditTool
这个边界很清楚:
- text file ->
FileEditTool - notebook ->
NotebookEditTool
7.2 insert 必须显式指定 cell_type
这条要求非常合理。
因为插入一个新 cell 时,系统没法从现有对象里推断它到底应该是:
codemarkdown
所以这里不让模型偷懒,而是要求把意图说完整。
7.3 一样要求先 Read
源码注释写得很直接:
- 如果不要求先读,模型就可能编辑一个从没看过的 notebook
- 或者基于过时内容修改,导致 silent data loss
这再次证明“先读后写”不是 text tool 的特殊要求,而是 Claude Code 本地修改工具的统一原则。
8. notebook 校验不是只看路径,还要看对象内容
8.1 必须是合法 JSON
它会先把文件内容做 safeParseJSON(...)。
如果根本不是合法 JSON,直接拒绝。
8.2 cell_id 支持两种定位方式
这里实现得挺细:
先尝试:
- 按 notebook 里真实的
cell.id查找
如果找不到,再尝试:
- 把
cell_id解析成索引形式
比如:
cell-3
这种双通道定位挺合理,因为 notebook 在不同版本、不同生成器里,对 cell 标识的稳定性并不完全一样。
8.3 没给 cell_id 也不是一概不行
如果是 insert,不提供 cell_id 也可以,默认从开头插。
但如果不是 insert,那就会直接拒绝。
这说明这里对“缺省行为”也很克制:
- 只在插入场景提供合理默认
- 替换和删除必须明确定位
9. call() 里的几个实现细节很有代表性
9.1 它用了非缓存版 jsonParse()
源码注释特别说明:
safeParseJSON()有缓存- 但
call()里会原地修改 notebook 对象 - 如果继续复用缓存对象,会污染后续调用
这很值得记一下,因为它说明作者不仅考虑“能不能 parse”,还考虑:
- 共享对象引用
- 缓存污染
- validate 和 call 之间的对象生命周期
这是很典型的 runtime 细节意识。
9.2 replace 有一个“越界自动转 insert”的语义
如果模型想 replace 的位置刚好是:
cellIndex === notebook.cells.length
实现会自动把它转成 insert。
而且如果没指定 cell_type,还会默认成 code。
这能看出 Claude Code 在结构化对象上更愿意做“温和纠偏”,而不是像 FileEditTool 那样在文本歧义上坚决拒绝。
9.3 新插入的 code cell 会自动带 notebook 语义字段
如果插入的是 code cell,会显式补:
execution_count: nulloutputs: []
这说明它创建的不是“看起来像 cell 的 JSON”,而是符合 notebook 语义的完整 cell 对象。
10. 修改 code cell 时,会主动清掉执行结果
这是这份实现里最值得学的一点之一。
当 replace 命中一个 code cell 时,它不仅会改 source,还会:
- 把
execution_count = null - 把
outputs = []
这个决策很对,因为一旦代码变了:
- 原有执行编号可能已经失真
- 原有输出大概率也失真
如果不清理,notebook 看起来就会处于一种“代码和结果不一致”的假状态。
这说明 Claude Code 对 notebook 的理解不是“文本容器”,而是“带执行语义的文档对象”。
11. 写回文件时,它保留了 notebook 文件本身的外壳风格
11.1 保留编码和换行
readFileSyncWithMetadata() 读出来的不只是内容,还有:
encodinglineEndings
最后写回时会用:
writeTextContent(fullPath, updatedContent, encoding, lineEndings)
这说明 notebook 虽然是结构化对象,但落盘外壳仍然被认真保留。
11.2 写回 JSON 时固定缩进
它用的是:
jsonStringify(notebook, null, 1)
也就是说,Claude Code 在 notebook 序列化层有自己稳定的格式选择。
这和 FileEditTool 的“尽量保留原文件文本风格”不一样,说明 notebook 在它这里更像数据结构,而不是纯文本文档。
12. readFileState 的更新细节也很讲究
写完以后它会更新:
contenttimestampoffset: undefinedlimit: undefined
源码注释特别点出一件事:
- 这样做是为了打断
FileReadTool的 dedup 命中 - 否则
Read -> NotebookEdit -> Read在同一毫秒里可能会错误返回file_unchanged
这说明不同工具之间并不是松散并列关系,而是共享同一套读取状态机。
也就是说,Claude Code 的工具层是一个相互感知的 runtime 网络。
13. 它和 FileEditTool / FileWriteTool 的差异,也很能说明设计思路
NotebookEditTool 有几件“没做”的事,反而很值得注意:
- 没有 LSP 通知
- 没有 VS Code diff 通知
- 没有 skill discovery
- 没有文本级 quote normalization
这不是缺功能,而是边界不同。
这说明 Claude Code 已经默认接受:
- notebook 不是 LSP 主战场
- notebook 编辑不是文本风格问题
- notebook 的关键是对象语义,而不是 patch 语义
这是一种很成熟的“按对象类型分 runtime 责任”的做法。
14. 对 C# 版的翻译建议
如果你后面做 C# 版,我建议不要让 notebook 复用 FileEditTool 的请求模型。
更稳的拆法是:
14.1 协议层
NotebookEditToolDefinitionNotebookEditRequestNotebookEditResultNotebookEditMode
14.2 notebook 对象层
NotebookDocumentNotebookCellCodeCellMarkdownCell
14.3 校验层
INotebookPathPolicyINotebookReadStateGuardINotebookCellLocatorINotebookSchemaValidator
14.4 编辑层
INotebookEditCoordinatorINotebookCellFactoryINotebookExecutionStateCleanerINotebookSerializer
这里我最建议你保留两件事:
- notebook 单独协议
- code cell 修改后清 outputs / execution_count
这两条非常关键,因为它们直接决定 notebook 编辑结果是不是语义一致。
15. 我对这套设计的评价
NotebookEditTool 最值得学的,不是它支持 insert / replace / delete,而是它把 notebook 从“文本文件”提升成了“领域对象”。
这件事看起来小,实际上非常值钱。
因为一旦你承认 notebook 是对象,不是文本:
- 你就会给它单独协议
- 你就会在 code cell 修改后清理执行结果
- 你就不会让模型去改一大坨 JSON
这正是 Claude Code 这套工具设计最有参考价值的地方:
不是所有东西都该走同一把锤子。