
字节笔记本
2026年10月6日 · 约 9 分钟读完
DeepSeek Harness 文件系统:先读后写的设计
模型要真正替人干活,绕不开读写文件这一步,而文件操作恰恰是 agent 最容易闯祸的环节:该创建的新文件把已有文件覆盖了,该改一行的结果整篇重写,或者对着早已被外部删除的路径继续编辑。DeepSeek Harness 是 DeepSeek 开源的 agent 智能体框架,拆开它的文件系统子系统,能看到一套围绕「先读后写」组织起来的完整设计,很多思路值得自己动手写框架的人借鉴。

一、四个包,一条接缝
文件系统能力由四个包组成。dsh-fs 定义 FileSystem 抽象接缝与事件词汇,ctx.fs 就挂在这里;dsh-fs-local 实现本地磁盘后端;dsh-fs-observation-policy 是策略插件,负责记录观测到的存在或缺失状态,并通过事件给操作加上新鲜度规则;dsh-tool-fs 是执行器,直接承接面向模型的 read、write、edit 调用并渲染读取窗口。整块能力位于 agent 主循环之外,替换后端不会牵动策略和工具 schema。
没有策略插件时,这条接缝依然完整可用,只是行为不受约束:write 无条件创建或覆盖,edit 无条件替换字面文本。装上插件后,默认行为才变成先读后写。这也是官方建议的部署方式:加载 dsh-tool-fs 的同时应加载 dsh-fs-observation-policy。工具调用的是 ctx.fs 并分发事件,而不是调用策略方法,所以移除插件不会破坏工具本身。
二、目标标识:不透明的工牌
每个操作先把用户给的路径解析成后端目标 FsTarget,它带两个字段:targetKey 是品牌化的不透明 id,displayPath 是给人看、给模型看的路径。消费方可以展示 displayPath,但禁止解析 targetKey,也不能假设它是本地绝对路径,因为远端后端完全可能用 workspace URI 或文件 id 来实现它。
需要跨能力协作时走提供方给出的坐标:processPath 返回子进程可以打开的规范化绝对路径,fileUrl 返回采用后端平台语法的 file: URI,contains 判断两个目标的相等或包含关系。文件版本 token FsVersion 同样不透明,它是 write 和 edit 所守卫的新鲜度凭证,由后端从高精度 stat 信息推导,策略插件只记录、不解释。
三、读:窗口、上限与纯展示
stat 只返回元数据,从不返回内容,目标不存在时返回 undefined。type 让消费方在读取前就拒绝目录和特殊文件,size 用来选择整读 readText 还是流式 streamText,不必靠一次注定失败的探测去试。原始字节读取 readBytes 要求必填完整内容上限,超限直接以 FS_TOO_LARGE 失败,不会返回截断结果,也不会无界缓冲。lstat 是路径级、不跟随符号链接的原语:resolve 会有意跟随 symlink 以产生稳定标识,需要检查信任边界的消费方可以先调 lstat,在解析前拒绝可疑链接。listDir 按稳定名称顺序列出直接子项,只带元数据和已解析目标,禁止读取文件内容。
面向模型的 read 工具渲染出的行窗口纯粹是展示性的。文本读取受行窗口和字节上限约束,达到字节上限后扫描仍会继续,只是不再保留更多行,所以 totalLines 依然是精确值。更关键的是授权逻辑:授权不看读得全不全,只看新不新。工具读取时发出表示目标存在的 fs/observed 事件并携带 stat 的版本,任何窗口化读取在文件未变时都能授权后续的 write 和 edit。元数据未命中时,工具会在返回 FS_NOT_FOUND 之前先发出缺失观测,于是后续带守卫的写入可以重建被外部删除的文件,但编辑依然不会被放行。
四、写与改:两种守卫意图
writeText 的守卫叫 FsWriteIntent,只有两种形态。createIfAbsent 在目标缺失时创建,目标已存在时报 FS_NOT_OBSERVED,即使文件是在初始探测之后才出现的也照样拒绝,因为发布操作本身不得替换已有内容;replaceIfVersion 只在目标存在且版本匹配时替换,否则报 FS_STALE_VERSION。两个字段都不传,就是无条件的创建或覆盖。联合类型只包含这两种有守卫的意图,「无守卫」通过省略参数表达,write 和 edit 共用同一个可选的 expected 字段。
editText 不是在别处拼起来的先读加后写,而是提供方级别的原子变更:带守卫时先验证预期版本再执行字面匹配,对陈旧内容的编辑报 FS_STALE_VERSION,而不是对更新后的内容报匹配失败;不带守卫时直接编辑当前内容。无论哪条路径,匹配、行尾处理、陈旧检查和原子替换都在同一个变更临界区内完成,目标缺失时两条路径都报 FS_STALE_VERSION。

五、事件词汇与观测策略插件
dsh-fs 拥有三个事件,由工具分发、策略插件监听。两边共享同一套词汇却互不依赖,事件只携带 dsh-fs 的类型加一个不透明的 actor 对象,不含任何面向模型的概念。fs/write-intent 和 fs/edit-intent 是单槽决策的瀑布式事件(waterfall):工具分发时附带一个默认 thunk,返回 undefined 就走裸提供方的无条件操作;监听方完全决策而不调用 next,第一个返回守卫的监听者独占决定权。这个槽位按注册顺序先到先得,由策略插件占据是部署约定而非强制规则。fs/observed 则是即发即弃的记录事件,携带 FsObservation:要么存在于某个版本,要么确认缺失。它的监听器必须同步且只做副作用,因为工具不捕获这次 emit 抛出的异常,监听器抛错可能顶掉读取本来要返回的错误,或让已成功的变更返回失败结果。
策略插件内部就是一张按所有者组织的 WeakMap,记录每个目标的观测状态:没有条目算未见,absent 表示读取类命令发生元数据未命中从而确认缺失,present 表示 read、write 或 edit 观测到了该版本。写入决策把未见和缺失都映射到 createIfAbsent,把存在映射到 replaceIfVersion;编辑决策更严格:未见报 FS_NOT_OBSERVED,缺失报 FS_NOT_FOUND,只有存在才放行带版本守卫的编辑。所有者从事件 actor 推导,通常是 exec.agent.session,被当作不透明键,从不读取其字段;资源释放时整体丢弃,热更新安全,策略本身不做任何文件系统 IO。
六、错误码即接口
文件系统故障用一组稳定的 FsErrorCode 字符串表达,共十三个,从 FS_NOT_FOUND、FS_TOO_LARGE、FS_PERMISSION_DENIED 到 FS_AMBIGUOUS_EDIT、FS_EDIT_NOT_FOUND。工具注册表在错误结果上保留 name 和 code 两个字段,重试、权限和界面层按 code 分支即可,无需解析报错文本。两个拒绝码的区分很见功力:FS_SANDBOX_DENIED 是强制执行沙箱的后端按模式边界做出的策略拒绝,FS_PERMISSION_DENIED 才是宿主内核的拒绝。另外,新鲜度授权没有部分和完整之分,所以这套体系里不存在 FS_PARTIAL_OBSERVATION 这样的中间态错误码。
七、为什么偏偏不设超时
bash 和 web 这类由进程支撑的工具可以设置截止时间,因为超时真的能把工作终止掉,框架里对应工具的 timeoutMs 也确实由超时策略强制执行。本地系统调用则至多尽力中止:超时无法迫使进行中的 fsync 或 rename 停下来,硬塞一个 timeoutMs 只会制造一条接缝根本无法强制执行的假截止时间,而且恰好落在「显式优于隐式」原则禁止隐式默认值的位置。所以 read、write、edit 干脆不接受超时参数,取消依然通过工具执行的 signal 在系统调用边界尽力传播。
把这套设计合起来看,核心其实只有一句话:把「我看过它的哪个版本」变成一切写操作的前置条件,再把这条信息放进事件里,让机制与策略解耦。自己在做 harness 或文件类工具的人,哪怕只借走稳定错误码表和版本守卫这两样东西,也能少踩很多坑。



