AgentScope第22式·袖里文件

AgentScope第22式·文件系统

Agent 能读文件不稀奇,读对文件才稀奇。

[读者] 已经把数字人 Demo 跑到能对话的后端工程师
[痛点] Agent 能读写文件了,但所有用户共用同一个工作目录
[现在读] 第22式要把文件链路按 user/session 切开
[读完] 能设计一套可切本地/远程、按隔离域寻址的文件抽象
[旧方案] 给 Agent 一个本地目录,路径在 Tool 里写死
    |
    v
[新需求] 多用户上传资料、Agent 生成产物、会话结束可追溯
    |
    v
[冲突] 路径是全局的,隔离域是缺失的,换后端要重写 Tool
    |
    v
[后果] 用户 A 能读到用户 B 的文件,换远程存储整层崩

我是老李,做数字人这条线已经有一阵子了。前面二十式,Demo 从“能对话”一路长到了“能编排、能记忆、能规划”。但有一件最土的事我一直拖着没动:文件。

我们的数字人项目里,用户是可以上传资料的。传一份产品手册,Agent 读完之后生成一段开场白;传一份访谈转写稿,Agent 读完产出一版问答对。资料要存下来,产物也要存下来。目前的做法非常粗糙——所有项目、所有用户、所有会话,文件全丢在同一个挂载目录下,靠文件名前缀区分。

这个做法在单机 Demo 里能跑。第 21 式把状态与会话补齐之后,问题开始显形:会话是有身份的,文件却没有。会话销毁了,它产生的文件还躺在原地;用户 A 和用户 B 的文件待在同一个目录里,任何一次 Agent 的目录列举都会把它们一起带进模型上下文。隔离做在内存里,文件隔离做在幻想里。

那文件到底应该由谁负责隔离?

按 user 切一层,再按 session 切一层,够不够?

如果哪天我们不想要本地磁盘了,要把产物直接落到对象存储上,Tool 层要不要重写一遍?

01、故事

先交代现场。运行页是 /run/{projectId},用户进页面之后有一段 LiveKit 实时会话,Realtime Bridge 把音频和消息转给 AgentScope Java,Agent 的回复再流回去。到第 21 式为止,Bridge 已经从“管道”长成了一个有状态的东西:它认识 session,认识当前用户,认识这个 session 挂在哪套记忆上。

任务是:把“用户上传资料 → Agent 阅读资料 → Agent 生成文件 → 产物落到项目里”这条链路补齐,并且保证用户 A 读不到用户 B 的文件。

最初的实现很直白。Spring Boot 侧收到 MultipartFile,存到 /data/dh/workspace/{projectId}/{filename};Agent 启动时,工具层把这个目录路径硬编码进去,Agent 用普通的文件读写能力去读、去写。听着没毛病。

问题出在第二天。测试同学开两个账号,A 传了一份内部报价单,B 传了一份公开宣传语。B 的会话里 Agent 生成产物时,顺手把 workspace 目录列了一遍,A 那份报价单的文件名就出现在 B 的模型上下文里了。文件名本身不致命,但顺着这个目录讲下去——如果 Agent 有能力读全文,这就是一次实打实的数据泄漏。

那天下午我做了两件事。第一件,把所有硬编码路径翻出来数了一遍,一共七处。第二件,去翻 agentscope-java v2.0.3 的 filesystem 包,以及配套的那份 Harness Filesystem 官方文档。翻完我确认了一件事:框架其实早就给好了答案,只是我们把它当成了一个“文件工具”在用。

文件无身份
路径即权限
先切隔离域
再谈读写权

02、问题

先明确旧做法到底做了什么。

上传落盘走 /data/dh/workspace/{projectId}/{filename};Agent 工具层拿到的是这整个目录加上一个文件名;读和写都以 java.io.File / Path 为参数在服务内部流转。这套东西在只有一个人用的时候完全没问题,问题在于它把三件事同时塞进了同一根字符串里:存储位置、数据归属、访问权限。

为什么不够?三条。

第一,隔离靠文件名前缀。前缀是约定,不是机制。约定可以被绕过,机制不能。只要有一个入口忘了加前缀,整条链路就漏了。

第二,路径是全局的,没有“谁在什么会话里读”的语义。同一个 Agent 实例、同一份记忆,服务不同用户时看到的是同一个目录,隔离在结构上就不存在。

第三,工具层直接持有本地文件系统的实现细节。换成远程存储或者沙箱存储,就得把所有调用点重写一遍。这不是重构量的问题,这是抽象层次错位的问题。

对应到业务上,代价也很具体:跨用户读到文件;产物归属说不清是哪个会话产生的;会话结束时不知道该清理哪些文件。

所以本篇的完成标准就三条,能验证的:

  1. 用户 A 的会话无法读取用户 B 的文件,无论用什么相对路径,包括带 .. 的路径。
  2. 本地存储切到远程存储时,Tool 层代码改动不超过一个 bean 定义。
  3. 隔离域(user / session)进入寻址链路,而不是进入“命名约定”。

单目录省事
多用户遭殃
隔离靠文件名
迟早要还债

03、原理

这一章只讲一个原理,因为它决定了后面架构、排查、优化三章的全部判断:文件系统的“隔离”和“路由”是两个正交的问题,把它们混在一起,是这类设计最容易踩的坑。

先给一句边界说明,避免后面的名词悬空:AgentScope Java 里的 Harness 是 Agent 与运行环境之间的适配层,io.agentscope.harness.agent.filesystem 是它下面的文件系统子包,提供文件读写能力的统一入口;它不负责会话管理,也不负责鉴权,那些是它上层的事。

反直觉判断:大多数人以为 Filesystem 抽象解决的是“存储介质可替换”,其实它最先要解决的是“寻址”。

为什么?因为存储可替换是一个可预见的需求,你完全可以在出事之后重构;而寻址错误是一个安全问题,它会在第一次跨用户请求时就直接暴露。前者是技术债,后者是事故。

顺着这个判断,可以把文件系统拆成三层来看:

接口层。 AbstractFilesystem 定义了一组动作——读、写、列举、存在性判断——它的输入输出是路径和内容,而不是 java.io.File。这一条看起来平淡,但它意味着上层 Tool 从此不再需要知道文件在哪台机器上。

实现层。 Local / Remote / Sandbox 是同一个接口的不同后端。它们之间的差异是“东西存在哪儿、怎么取回来”,不是“上层怎么调用”。

路由层。 Composite 把一个逻辑路径分发到多个后端,比如 /uploads 落在本地盘上(上传即用,要快),/artifacts 落到对象存储上(要长期保存)。

第二个反直觉判断,关于隔离:IsolationScope 不是文件系统的属性,而是调用上下文的属性。

这一点很容易搞错。很多人的第一反应是“给每个用户 new 一个 Filesystem 实例”,听起来很安全,实际上是资源灾难——你会在内存里攒下成百上千个文件系统对象,而且这些对象的生命周期会和会话生命周期纠缠在一起,谁先销毁谁后销毁变成一个没人说得清的问题。

正确的做法是:文件系统实例可以是单例,隔离域由每次调用携带。这样 Filesystem 只管“给定一个路径,怎么落到正确的后端上”,至于这个路径属于谁,由上层在调用前拼好并保证不可伪造。职责干净,对象可控。

第三个判断,关于 Composite:Composite 是路由,不是继承。 它不理解子文件系统的内部实现,也不应该去理解。它只做一件事——看路径前缀,决定转发给谁。一旦 Composite 开始“感知”某个后端的特性,它就退化成了一个巨大的 if-else。

这三个判断合起来,就是本篇要建的那套东西:一个不关心存储位置的上层调用方式,加上一个由调用上下文携带、在路径归一化阶段强制落地的隔离域。

抽象不在读
抽象在寻址
路径归一化
后端才可换

04、架构

[输入] 用户上传资料 / Agent 读写请求
    |
    v
[模块] Realtime Bridge → Tool 层 → AbstractFilesystem
    |
    v
[数据/状态] Workspace 根路径 + IsolationScope(user, session)
    |
    v
[处理] 路径归一化 → Composite 路由 → Local / Remote / Sandbox
    |
    v
[输出] 产物落盘 + 落库索引,上层只拿路径字符串

边界。 这一层只管“给定路径读写内容”,不管鉴权、不管会话生命周期、不管文件元数据落库。鉴权在 Spring Boot 侧完成,会话生命周期在 Bridge 侧完成,元数据索引也在 Bridge 侧完成——Filesystem 只负责最后一步:把字节放到对的地方,或者从对的地方取回来。

收益。 隔离从“约定”变成“结构”。只要所有入口都经过同一个归一化函数,就不存在“某个 Tool 忘了加前缀”这种漏洞。同时上层 Tool 只依赖抽象,后端换 Local 还是 Remote,对 Tool 是透明的。

代价。 多了一层寻址。所有路径都要经过一次归一化,调试时你得习惯多看一眼 scope 是什么。而且隔离域必须在每一次调用时都正确传递,一旦某处传了 null 或者传错了 session,问题不会当场炸,而是变成“读到了一个不该读到的空目录”,排查起来更绕。

适用条件。 多用户、多会话、需要产物长期保存、未来可能换存储后端的场景。数字人项目正好三条全中。

不适用条件。 单人单会话的脚本型场景、纯离线批处理、生命周期和进程一致的临时任务。这些场景下引入 IsolationScope 是纯粹的负担,你只是给自己加了一个必须处处传对的参数。

源码位置。 接口与实现的入口在 agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem,行为说明在 Harness Filesystem 文档。涉及的类名和方法名,请以 v2.0.3 这个固定 tag 下的源码为准,不要用 main 分支的现状去反推这一版的接口。

一图见边界
一域一命名
上层只说事
下层自安排

05、实战一次

这一节把链路跑通到“能读、能写、隔离生效”这三条上。

环境与版本。 教程基线是 AgentScope Java v2.0.3,JDK 17+,Maven。分支从第 21 式继续演进:chapter/22-filesystem。教程仓库在 agentscopractice。

依赖。 引入 agentscope-harness 模块(坐标以 v2.0.3 固定 tag 下的 pom 为准):

<dependency>
    <groupId>io.agentscope</groupId>
    <artifactId>agentscope-harness</artifactId>
    <version>2.0.3</version>
</dependency>

第一步:把路径归一化单独拎出来。 这是整章最该被反复看的一段代码,因为它是唯一的防线。

package com.example.dh.fs;

import java.util.ArrayDeque;
import java.util.Deque;

public final class WorkspacePaths {

    private WorkspacePaths() {
    }

    public static String scope(String userId, String sessionId, String relativePath) {
        if (userId == null || userId.isBlank()) {
            throw new IllegalArgumentException("userId is required");
        }
        if (sessionId == null || sessionId.isBlank()) {
            throw new IllegalArgumentException("sessionId is required");
        }
        return "/users/" + safe(userId) + "/sessions/" + safe(sessionId)
                + "/" + normalize(relativePath);
    }

    static String normalize(String path) {
        if (path == null || path.isBlank()) {
            throw new IllegalArgumentException("path is required");
        }
        String p = path.replace('\\', '/');
        while (p.startsWith("/")) {
            p = p.substring(1);
        }
        Deque<String> stack = new ArrayDeque<>();
        for (String seg : p.split("/")) {
            if (seg.isEmpty() || ".".equals(seg)) {
                continue;
            }
            if ("..".equals(seg)) {
                if (stack.isEmpty()) {
                    throw new IllegalArgumentException("path escapes workspace root: " + path);
                }
                stack.removeLast();
                continue;
            }
            stack.addLast(seg);
        }
        if (stack.isEmpty()) {
            throw new IllegalArgumentException("path resolves to empty: " + path);
        }
        return String.join("/", stack);
    }

    static String safe(String raw) {
        return raw.replaceAll("[^a-zA-Z0-9_-]", "_");
    }
}

关键点有三个:.. 一旦越出根就抛异常,而不是悄悄截断;用户名和会话 id 会被过滤成安全字符,避免有人用 ../ 这类字符去构造目录;归一化后的结果一定带 /users/.../sessions/.../ 前缀,不存在“忘了加”的可能。

第二步:让 Tool 只依赖抽象。

package com.example.dh.fs;

import io.agentscope.harness.agent.filesystem.AbstractFilesystem;

public class ProjectFileService {

    private final AbstractFilesystem filesystem;

    public ProjectFileService(AbstractFilesystem filesystem) {
        this.filesystem = filesystem;
    }

    public String read(String userId, String sessionId, String relativePath) {
        return filesystem.read(WorkspacePaths.scope(userId, sessionId, relativePath));
    }

    public void write(String userId, String sessionId, String relativePath, String content) {
        filesystem.write(WorkspacePaths.scope(userId, sessionId, relativePath), content);
    }

    public boolean exists(String userId, String sessionId, String relativePath) {
        return filesystem.exists(WorkspacePaths.scope(userId, sessionId, relativePath));
    }
}

这里调用的是 AbstractFilesystem 上的读、写、存在性判断。方法名以 v2.0.3 固定 tag 下的源码为准,本篇的重点不在这几个名字,而在于:ProjectFileService 从头到尾没有出现过 java.io.File,也没有出现过任何本地路径拼接。它只认识“隔离域 + 相对路径”这一对输入。

第三步:后端选择收敛到一个 bean。

package com.example.dh.fs;

import io.agentscope.harness.agent.filesystem.AbstractFilesystem;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FilesystemConfig {

    @Bean
    public AbstractFilesystem agentFilesystem(DhProperties props) {
        // 本地/远程/沙箱三选一,构造方式以 v2.0.3 源码中各实现的公开入口为准
        return FilesystemFactory.create(props.getFilesystemMode(), props.getWorkspaceRoot(),
                props.getRemoteEndpoint());
    }

    @Bean
    public ProjectFileService projectFileService(AbstractFilesystem filesystem) {
        return new ProjectFileService(filesystem);
    }
}

FilesystemFactory 是本章自己在应用侧加的一层,把 Local / Remote / Sandbox 的分支收敛到一个地方。它不属于框架,属于你。这样切后端只需要改配置,不需要改 Tool。

启动。

mvn -pl dh-server spring-boot:run

请求。 用户 A 上传一份手册:

curl -s -X POST http://localhost:8080/api/projects/proj-1/files \
  -H "X-User-Id: user-a" \
  -H "X-Session-Id: sess-1001" \
  -F "file=@handbook.pdf"

示例输出(未在当前环境实测,以下为预期结果):

{"path":"/users/user-a/sessions/sess-1001/handbook.pdf"}

验证。 用用户 B 的会话去读这个路径:

curl -s -X GET "http://localhost:8080/api/projects/proj-1/files?path=/users/user-a/sessions/sess-1001/handbook.pdf" \
  -H "X-User-Id: user-b" \
  -H "X-Session-Id: sess-2002"

预期结果是拒绝访问(403 或等价错误),而不是文件内容。同时用 path=../../../users/user-a/sessions/sess-1001/handbook.pdf 再试一次,预期结果同样是拒绝,且异常信息指向“路径越出工作区根”。

这两条验证必须同时通过,才算第 05 章跑通。只挡住第一条而放过第二条,等于什么都没做——因为 .. 才是最常见的绕过方式。

先本地跑通
再谈远程档
最小闭环成
才有资格改

06、排查

这一章给两条证据链。

证据链一:用户 B 看到了用户 A 的文件名。

现象:B 的会话里,Agent 在生成产物前列举了工作目录,返回结果中包含 A 上传的 handbook.pdf。

怀疑:第一反应是 Bridge 传错了 userId——毕竟会话身份是在 Bridge 侧注入的,如果注入错了,后面全都错。

检查:在 Tool 层入口把实际拿到的路径打出来。

证据:打印出来的路径是 /data/dh/workspace/proj-1/handbook.pdf。注意,这里根本没有 user 和 session 段。也就是说,问题不是“传错了 userId”,而是“这个链路里从来就没有 userId 这个东西”。

根因:文件系统调用完全没有携带隔离域。前二十式把身份做在了会话层和记忆层,但文件层是后来补的,它直接继承了最早那版单目录实现,而没有人回头去问“这个目录归谁”。

修复方向:把隔离域放进寻址链路,而不是放进鉴权链路。如果只在接口入口做一次 if (session.userId != request.userId) 判断,攻击面依然存在——因为 Agent 内部的工具调用是绕过 HTTP 层的。

证据链二:把本地换成远程之后,Tool 层直接崩了。

现象:配置里把 filesystemMode 从 local 改成 remote,启动时报错,提示找不到文件或者路径非法。

怀疑:Remote 实现有 bug,或者远程端点配置错了。

错误尝试: 我去翻 RemoteFilesystem 的源码,想把它的路径处理逻辑对齐到本地的行为上,甚至在业务代码里加了一段“如果是远程模式,就用另一套路径拼接方式”的分支。

为什么错:这个分支的存在本身就说明上层泄漏了底层实现细节。如果本地和远程需要不同的路径拼法,那 AbstractFilesystem 这层抽象就白设了。而且这个分支会随着后端数量增长而指数膨胀——本地、远程、沙箱,三种后端两两组合,你会写出三种拼法。

证据:回头看 ProjectFileService 里那些调用点,问题不在路径内容,而在类型签名。被传进来的实参是 java.io.File 类型,而 AbstractFilesystem 面向的是路径字符串加内容。是调用方硬把本地文件系统的概念塞进了下层的接口。

根因:测试期间为了图方便,Tool 直接用 File 做了临时构造,没有走 WorkspacePaths.scope()。换后端时这层伪装被撕掉,才暴露出来。

修复方向:所有调用点统一改成传归一化后的路径字符串,File 类型从 Tool 层彻底消失。

上游贡献判断。 这两条链路都是应用侧的实现问题,不是框架行为问题:第一条是隔离方案缺失,第二条是类型选择不当。v2.0.3 的 filesystem 包按官方描述提供的是一套统一的文件访问抽象,接口粒度上没有出现“本地必须用 File、远程必须用路径”这种不一致。因此本章未形成上游候选。如果你在核验过程中发现文档对 Composite 的路由优先级、或者对 IsolationScope 的取值语义描述不足,那属于文档缺口,可以先去官方 Issues / Discussions 搜一遍再决定要不要开 Issue——但不要为了“有产出”而硬造一个。

症状会骗人
证据不会骗
先复现一次
然后再判断

07、优化

第 06 章定位到的根因是:隔离域没有进入寻址链路,且上层泄漏了下层实现细节。 这一章的 V2 就针对这两点改。

修改一:所有文件访问强制经过 WorkspacePaths.scope()。

原因:寻址是唯一能同时约束“读哪”和“谁能读”的地方。把它做成一个纯函数、无副作用、抛异常而不是静默纠正,就能保证任何一条路径在进入下层之前已经被检查过。

新行为:任何带 .. 逃逸、任何空 userId、任何空 sessionId,都会在归一化阶段直接抛 IllegalArgumentException,根本到不了文件系统。

验证:单元测试覆盖 WorkspacePathsTest,至少四组用例——正常路径、含 ./ 的冗余路径、试图越根的 ../、以及用户名为空。

package com.example.dh.fs;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class WorkspacePathsTest {

    @Test
    void scopesUnderUserAndSession() {
        assertEquals("/users/u1/sessions/s1/a/b.txt",
                WorkspacePaths.scope("u1", "s1", "a/b.txt"));
    }

    @Test
    void collapsesRedundantSegments() {
        assertEquals("/users/u1/sessions/s1/a/b.txt",
                WorkspacePaths.scope("u1", "s1", "./a//b.txt"));
    }

    @Test
    void rejectsEscapeAttempt() {
        assertThrows(IllegalArgumentException.class,
                () -> WorkspacePaths.scope("u1", "s1", "../u2/secret.txt"));
    }

    @Test
    void rejectsBlankUser() {
        assertThrows(IllegalArgumentException.class,
                () -> WorkspacePaths.scope("  ", "s1", "a.txt"));
    }
}

修改二:Tool 层全部改成路径字符串,java.io.File 从业务代码中移除。

原因:抽象要生效,前提是上层真的只依赖抽象。留一个 File 参数,就等于给本地实现开了一扇后门,远程模式一开就会绕过去。

新行为:ProjectFileService 的三个方法签名里已经没有文件对象,只有 String。换后端时,改的是 FilesystemConfig 里那一行 FilesystemFactory.create(...)。

验证:把 filesystemMode 从 local 切到 remote,重启,重跑第 05 章两条 curl。预期结果是行为一致——A 能读自己的,B 读 A 的被拒,.. 被拒。这个验证的重点不在“远程能不能用”,而在于“上层代码有没有被改动”。如果切换后端时你动了 Tool 层任何一行,说明抽象漏了。

没有改动的东西,也值得记一笔。 我们没改数据库表、没改上传接口的签名、没改 LiveKit 侧的会话协议。文件链路的隔离是在寻址层解决的,不是在每一层加判断解决的。这是这次改动里最省事的部分,也是最容易被做错的部分——如果每个调用点都各自做一次前缀拼接,将来加一层隔离维度(比如租户),就要改 n 个地方。

根因在表层
修表不治本
改到抽象层
上层才不动

08、演进

[同一份上传请求]
    |
    +--[V1] 全局 workspace 目录,Tool 层硬编码路径
    |       代价:隔离靠文件名前缀,换后端要改所有调用点
    |
    +--[V2] IsolationScope + 归一化路径 + AbstractFilesystem
            代价:多一层寻址,scope 必须处处传对
    |
[Trade-off]
得到:隔离成为结构而不是约定;Local/Remote/Sandbox 可替换而不改 Tool
失去:路径不再能随手拼;调试时要多看一眼 scope 是什么
边界:单人单会话的脚本型场景,这套东西是纯负担

把两版放在六个维度上比一比。

正确性。 V1 的正确性依赖“所有调用点都记得加前缀”,属于人力保证;V2 的正确性由 WorkspacePaths.scope() 这个纯函数保证,属于代码保证。前者会随开发者更换而衰减,后者不会。

稳定性。 V1 在面对未知调用路径时行为不可预测;V2 对非法输入一律抛异常,宁可失败也不放行。对安全相关的代码,快速失败永远优于静默容错。

复杂度。 V1 的代码更短,但心智负担更重——每次写文件调用都要想一遍“前缀加对了吗”。V2 多了一层寻址,但这一层是收敛的、可测试的、只写一次。总的复杂度 V2 更低。

成本。 切换后端时,V1 要改所有调用点,成本随调用点数量线性增长;V2 只改一个 bean 定义,成本是常数。这是 V2 最直接的回报。

适用范围。 V2 适合多用户、多会话、产物需要长期保存的场景。反过来,如果你写的是一个本地跑的批处理脚本,一个会话用完即弃,那 V2 的所有成本都是白付的。

遗留问题。 三点。一是隔离域目前只有 user 和 session 两级,将来如果要加租户维度,WorkspacePaths.scope() 是唯一要改的地方——这是设计上的好事。二是产物索引目前只在文件系统里,数据库里没有记录,会话清理时只能靠路径前缀去扫,效率不高。三是远程后端的超时和重试策略还没做,这一条留到分布式的章节再补。

一行请求来
两条路径去
自由有代价
边界要写明

09、洞见

9.1 文件系统抽象的价值在寻址,不在存储

这一条是第 03 章那句反直觉判断的展开。如果你的 Filesystem 抽象只做到了“换个后端不改上层”,那你只拿到了一半收益,而且是比较不紧急的那一半。真正救命的是寻址:让每一次文件访问都必须回答“这是谁的、哪个会话的、相对于什么根”。回答了这三个问题,跨用户读取这种事故在设计上就不可能发生。

很多人做抽象的顺序是反的:先想“将来可能要换 S3”,再去做接口。正确的顺序是先想“这些文件归谁”,把身份做进寻址里,存储可替换是顺带拿到的。

9.2 隔离域是调用上下文的属性,不是文件系统的属性

反直觉判断:给每个用户 new 一个 Filesystem 实例,听起来更安全,实际上更难维护。

安全不是因为实例多,而是因为路径不可伪造。给每个用户一个独立实例,你会得到:更多的对象、更复杂的生命周期、以及一个更隐蔽的问题——当实例被缓存复用却没清空状态时,跨用户污染反而更难发现。而单例加调用时传 scope 的方案,隔离点是单一的、可测试的,出了问题只需要看一个函数。

反过来,什么情况下该给每个用户独立实例?当隔离不只是路径隔离、还包含配额、限流、独立凭据的时候。这时候实例本身就是资源边界,它就值得被单独创建。单纯的路径隔离,不值得。

9.3 Composite 是路由,不是继承

反直觉判断:Composite 用得越“聪明”,系统越难改。

一个健康的 Composite 只做一件事:拿路径前缀查一张路由表,转发给对应后端。它不应该知道 /uploads 下放的是图片还是文档,不应该根据文件大小做决策,更不应该缓存子后端的返回值。一旦它开始“理解”业务,它就从路由器变成了业务逻辑的第二个家,而第二个家意味着第二个必须同步维护的地方。

判断标准很简单:如果你要给 Composite 写一段超过十行的注释来解释它的转发规则,那说明路由规则本身该重新设计了,而不是 Composite 该变聪明。

9.4 什么时候不该用本章这套 Filesystem 抽象

这是本文最该被认真对待的一节。

单人单会话的脚本型场景,别用。 一个人跑、跑完就扔,IsolationScope 是纯粹的参数负担,你只是在给自己制造传错参数的机会。

纯内存中间产物,别用。 Agent 在推理过程中生成的临时文本,如果生命周期和一次调用一致,那它就是变量的责任,不是文件系统的责任。把中间状态写进文件再读出来,是性能灾难。

需要强一致事务的场景,别用。 文件系统不是事务性的。如果你需要“要么都写成功要么都不写”,那应该在数据库层解决,不要指望文件系统给你原子性。

隔离需求只是“用户看不到对方的 UI”,别用。 如果后端根本不需要跨用户读取,前端也不会展示别人的东西,那这一整套隔离域就是过度设计。判断标准是:Agent 有没有能力主动去读一个不属于当前用户的路径。 有能力,就必须隔离;没能力,就不必。

隔离是设计
不是打补丁
用之前先问
什么时候别用

10、系统落地

原来有什么。 到第 21 式为止,数字人 Demo 有会话身份、有状态、有记忆,能跑 LiveKit 实时链路,能驱动 Agent 做多轮对话。但文件层是缺失的:上传靠一个全局目录,产物靠约定命名,隔离靠人记得。

本篇新增什么。 一个完整的文件链路,三层:归一化层负责把“用户 + 会话 + 相对路径”变成不可伪造的绝对路径;抽象层负责把上层调用和后端存储解耦;配置层负责在 Local / Remote / Sandbox 之间切换。同时补上了对应的单元测试和两条隔离验证用例。

现在能做什么。 用户可以上传资料,Agent 可以读到自己会话范围内的文件并生成产物,产物按 user/session 归档,切换存储后端不需要改 Tool 层。验收标准里的两条——用户 A 读不到用户 B 的文件、切换后端 Tool 不改——都有了可执行的检查方式。

还缺什么。 产物索引还没有落库,会话结束时的清理只能靠扫目录;远程后端的超时重试没做;大文件上传没有分片;文件级的配额和审计还没影子。这些都不影响当前可用性,但都会在真实流量下变成问题。

下一步如何演进。 做完这一式,数字人的“文件读写”这条地基就算浇好了。往下可以往两个方向走:一是把产物元数据写进数据库,让会话清理变成一次 SQL;二是把 Workspace 的概念往上提一层,让它变成 Agent 的做事空间,而不只是一个存文件的地方。

开源参与点。 本章未发现明确上游问题。两条排查链路的根因都在应用侧实现,不是框架行为异常。如果你在做同样的改造时发现 AbstractFilesystem 的实现类之间在路径语义上存在不一致(例如对绝对路径和相对路径的处理不同),那是一个值得记录的上游候选,但记录之前请先做三件事:在教程最小 Demo 里复现、确认它在 v2.0.3 上确实存在、确认它在 main 上仍然存在。三条都满足,再去搜官方 Issues 有没有人报过;没有的话,非平凡改动先开 Issue,别直接提 PR。

底座厚一层
上层薄一分
留一个接口
换一种未来

11、小结

Q1 → 文件由谁负责隔离:由调用上下文携带的隔离域 + 强制归一化,不是目录命名约定
Q2 → 按 user 切再按 session 切够不够:够用作寻址,但必须配合路径归一化,否则可被 .. 绕过
Q3 → 换远程存储 Tool 层要不要重写:上层只依赖 AbstractFilesystem,就不需要重写
状态 → chapter/22-filesystem 上,数字人文件链路可按 user/session 隔离,本地/远程后端可替换

三条线对齐
一个状态收口
能交付之物
才算真做完

12、作业

12.1 理解题:为什么说 Composite Filesystem 是“路由”而不是“继承”?

参考答案:继承意味着子类共享父类语义,而 Composite 不共享任何子后端的语义——它只根据路径前缀把请求转发出去。一旦它开始感知子后端的特性(比如按文件类型选后端、按大小选后端),它就从路由器变成了业务逻辑的第二个落点,后续任何业务规则变化都要改两处。判断标准是:如果你需要一段长注释来解释它的决策逻辑,那说明路由规则该重设计了。

12.2 实战题:为你自己的项目补一条“路径逃逸必须被拒绝”的测试,并说明为什么不能在调用点做判断。

参考答案:参考第 07 章的四组用例,重点是 ../ 越根必须抛异常,而不是被截断成合法路径。不能在调用点做判断的原因有两条:一是调用点会随业务增长而增多,每一处都判等于每一处都可能漏;二是 Agent 内部的工具调用会绕过 HTTP 层,只在接口入口做检查根本拦不住。把判断放在归一化这种纯函数里,只写一次、只测一次、只维护一次。

12.3 排障题:切换到 Remote 后端后,Tool 层报“找不到文件”,你怀疑是 Remote 实现的 bug。请给出你的排查顺序。

参考答案:先别翻框架源码。第一步,打印 Tool 层实际传给文件系统的路径字符串,看它是不是本地路径格式——如果是 /data/... 这种绝对路径,说明是上层泄漏了本地实现细节,跟 Remote 无关。第二步,检查调用点有没有传 java.io.File 类型的对象。第三步,只有前两步都排除了,才去对比 v2.0.3 下 Local 和 Remote 两个实现对同一路径的语义是否一致。顺序错了的话,你会在框架源码里找一个根本不存在的问题。

12.4 架构判断题:打开 v2.0.3 的 filesystem 包,阅读 AbstractFilesystem 及其各实现类,回答:这套抽象里,隔离域是由接口参数承载的,还是由实例状态承载的?这个选择对上层意味着什么?再对照 Harness Filesystem 文档 核验一遍你的结论。

参考答案:需要你实际打开源码确认,这里不代替你读。但判断思路可以先给出来:如果接口方法是围绕路径和内容展开的、签名里不含用户或会话标识,那隔离域就应该由上层在调用前拼进路径里,实例可以是共享的;如果实现在构造时就绑定了根目录或凭据,那隔离域就更可能由实例承载,上层需要做实例管理和生命周期控制。这两者对上层的要求截然不同——前者要求你保证“处处传对 scope”,后者要求你保证“实例不串用”。确认结论之前,顺便看一眼 main 分支上这个包有没有变化,教程基线 v2.0.3 和当前最新版不一定是同一套接口。

题不在多寡
在能否跑通
答案不在背
在源码里面

13、思考

回到开头那个问题:文件到底应该由谁负责隔离?

答案是:由寻址负责,而不是由命名约定负责。 这句话听起来像绕口令,但它区分了两种工程态度——一种是“我们约定好都加前缀”,另一种是“路径只有一种合法形态”。前者在人员流动、需求变化、代码量增长之后一定会松,后者不会。

第 21 式把状态和会话的边界划清楚了,第 22 式把文件的边界划清楚。两件事看起来无关,其实指向同一个工程判断:隔离必须是结构,不能是习惯。 凡是靠“记得做某事”来保证的安全属性,都只是暂时的。

这一式的另一层意义,是把“存储可替换”和“寻址不可伪造”拆开了。很多人做抽象的时候,会下意识地把它们绑在一起——为了可替换所以在接口上再做一层,结果发现换后端时上层还是要改。原因就在于这一层抽象只解决了位置问题,没解决身份问题。位置可以事后补,身份补不了。

“会用框架”和“能参与框架演进”的差别,大概就在这里:会用的人知道它有哪些 API,能改的人知道它为什么把职责切在这个位置。前者照着文档写代码,后者在写之前先问一句——这个决定放在这一层,是不是唯一合理的位置。

会用是入门
能改是进阶
边界想清楚
贡献自然来

posted @ 2026-10-11 11:12  李福春  阅读(4)  评论(0)    收藏  举报