Deep Agent 文件系统工具链:ls/read/write/edit/glob/grep/shell 七个工具怎么设计(第94篇-E80)

上一篇 讲 Agent 间 Transfer 交接时提过一句:AgentTool 比 Transfer 好,因为独立 session 隔离性更好。但 AgentTool 也有一个限制------子 agent 看不到父 agent 的上下文。

那如果子 agent 需要自己查文件、搜代码、写文件怎么办?答案是给它一套文件系统工具。

Eino ADK 的 middlewares/filesystem 包提供了 7 个文件系统工具,可以作为 ChatModelAgentMiddleware 注入到任意 agent 中。这篇拆解这套工具链的设计。

工具全景

filesystem.go:40-47 定义了 7 个工具常量:

工具名 功能 类比
ls 列出目录内容 ls -la
read_file 读取文件(支持分页) cat + 行号
write_file 写入/覆盖文件 >
edit_file 精确字符串替换 sed 精确匹配
glob 文件模式匹配 find -name
grep 正则搜索文件内容 rg/grep -rn
execute 执行沙箱命令 sh -c

这 7 个工具覆盖了 Agent 操作文件系统的基本需求:查看目录结构、读文件、改文件、搜文件、执行命令。

(一)架构:Backend 接口 + Middleware 注入

可插拔的 Backend 接口

backend.go:243-283 定义了 Backend 接口:

go 复制代码
type Backend interface {
    LsInfo(ctx context.Context, req *LsInfoRequest) ([]FileInfo, error)
    Read(ctx context.Context, req *ReadRequest) (*FileContent, error)
    GrepRaw(ctx context.Context, req *GrepRequest) ([]GrepMatch, error)
    GlobInfo(ctx context.Context, req *GlobInfoRequest) ([]FileInfo, error)
    Write(ctx context.Context, req *WriteRequest) error
    Edit(ctx context.Context, req *EditRequest) error
}

6 个方法,每个对应一个文件操作。这个接口的设计意图是可插拔------不同环境用不同的 Backend 实现:

  • 开发/测试InMemoryBackendbackend_inmemory.go),文件存内存 map
  • 生产:本地文件系统 Backend
  • 容器:Docker 容器文件系统 Backend

只要实现了 Backend 接口,就能切换。工具本身不关心底层存储是什么。

Middleware 注入

filesystem.go:354-397NewTyped / New 创建的是 ChatModelAgentMiddleware

go 复制代码
func NewTyped[T filesystem.Backend](ctx context.Context, config *Config[T]) (ChatModelAgentMiddleware, error)

它做的事:

  1. 根据 Config 创建 7 个工具(getFilesystemTools,line 427-532)
  2. 包装为 ChatModelAgentMiddleware,注入到 ChatModelAgent 的工具列表

工具创建的 toolSpec 模式

filesystem.go:427-532getFilesystemTools 使用统一的 toolSpec 模式:

go 复制代码
type toolSpec struct {
    name       string
    desc       func(ctx context.Context) string
    createFunc func(ctx context.Context) (tool.BaseTool, error)
}

每个工具是一个 toolSpec,包含名称、描述生成函数、创建函数。不是写死一个工具列表,而是根据 Config 动态决定创建哪些工具。

(二)七个工具逐个看

1. ls:列出目录

filesystem.go:564-584

go 复制代码
func newLsTool(ctx context.Context, backend filesystem.Backend) (tool.BaseTool, error) {
    return tool.NewTool(
        func(ctx context.Context, input *struct{ Path string }) ([]filesystem.FileInfo, error) {
            return backend.LsInfo(ctx, &filesystem.LsInfoRequest{Path: input.Path})
        },
        tool.WithName(ToolNameLs),
        tool.WithDescription(lsToolDesc),
    )
}

非常简单:接收 Path 参数,调用 backend.LsInfo,返回 FileInfo 列表。FileInfo 包含 PathIsDirSizeModifiedAt 四个字段。

2. read_file:分页读取

filesystem.go:605-633

go 复制代码
func newReadFileTool(ctx context.Context, backend filesystem.Backend) (tool.BaseTool, error) {
    return tool.NewTool(
        func(ctx context.Context, input *struct {
            FilePath string
            Offset   int  // 起始行号,默认 1
            Limit    int  // 读取行数,默认 2000
        }) (*filesystem.FileContent, error) {
            return backend.Read(ctx, &filesystem.ReadRequest{
                FilePath: input.FilePath,
                Offset:   input.Offset,
                Limit:    input.Limit,
            })
        },
        ...
    )
}

支持分页:Offset 指定起始行号(默认 1),Limit 指定读取行数(默认 2000)。返回内容带行号(formatLineNumbers,line 638-649)。

这个设计很实用------大文件不用一次读完,LLM 可以先看前面,觉得不够再翻页。

3. write_file:覆盖写入

filesystem.go:795-811:接收 FilePathContent,调用 backend.Write。注意:必须先读已有文件再写,这是 prompt 中的要求(防止意外覆盖)。

4. edit_file:精确字符串替换

filesystem.go:827-845

go 复制代码
func newEditFileTool(ctx context.Context, backend filesystem.Backend) (tool.BaseTool, error) {
    return tool.NewTool(
        func(ctx context.Context, input *struct {
            FilePath   string
            OldString  string
            NewString  string
            ReplaceAll bool
        }) error {
            return backend.Edit(ctx, &filesystem.EditRequest{...})
        },
        ...
    )
}

三个关键参数:

  • OldString:要被替换的字符串,必须精确匹配
  • NewString:替换后的字符串
  • ReplaceAll:是否替换所有匹配

InMemoryBackend 的实现(backend_inmemory.go:644-687)有三个校验:

  1. OldString 不能为空
  2. OldString 必须在文件中存在
  3. 如果 ReplaceAll=false,OldString 必须唯一------防止 LLM 只想改一处但误改了多处

这个唯一性校验是 edit_file 的核心安全机制。LLM 说"把那行 fmt.Println 改成 log.Println",如果文件里有 5 处 fmt.Println,不指定 ReplaceAll 就会报错,强制 LLM 提供更精确的上下文。

5. glob:文件名模式匹配

filesystem.go:855-878:接收 PathPattern,调用 backend.GlobInfo。支持标准 glob 模式(*.go**/*.go 等)。

6. grep:正则搜索(三种输出模式)

filesystem.go:934-997

go 复制代码
func newGrepTool(ctx context.Context, backend filesystem.Backend) (tool.BaseTool, error) {
    return tool.NewTool(
        func(ctx context.Context, input *struct {
            Path            string
            Pattern         string
            OutputMode      string  // "content" | "files_with_matches" | "count"
            CaseInsensitive bool
            BeforeLines     int     // -B
            AfterLines      int     // -A
            Glob            string  // 文件名过滤
            FileType        string  // 文件类型过滤
        }) (string, error) {
            ...
        },
        ...
    )
}

grep 是 7 个工具中最复杂的。三个核心设计:

三种输出模式filesystem.go:1146-1225):

  • content:默认,输出 文件:行号:内容(像 grep -rn
  • files_with_matches:只输出匹配的文件名(像 grep -rl
  • count:输出每个文件的匹配计数(像 grep -rc

上下文行BeforeLines(-B)和 AfterLines(-A),让 LLM 看到匹配行周围的代码。

文件过滤Glob 按文件名过滤,FileType 按文件类型过滤(backend_inmemory.go:396-496 定义了 60+ 种文件类型映射)。

InMemoryBackend 的 grep 实现(backend_inmemory.go:200-244)还有并行优化:多文件时用 worker 池(最多 10 个 worker)并发搜索。

7. execute:命令执行

filesystem.go:1003-1019newExecuteTool 是同步执行,newStreamingExecuteTool(line 1021-1086)是流式执行。

流式执行的关键设计:用 goroutine 执行命令,通过 channel 把输出推给 schema.Pipe,LLM 可以实时看到命令输出。

(三)两个关键辅助机制

Large Tool Result Offloading

large_tool_result.go:103-136

go 复制代码
func (t *toolResultOffloading) handleResult(ctx context.Context, result string, input *compose.ToolInput) (string, error) {
    if len(result) > t.tokenLimit*4 {
        // 1. 把完整结果写入文件系统
        path, _ := t.pathGenerator(ctx, input)
        t.backend.Write(ctx, &WriteRequest{FilePath: path, Content: result})

        // 2. 返回前 10 行预览 + 文件路径提示
        nResult := formatToolMessage(result)  // 前 10 行
        msgTemplate := internal.SelectPrompt(...)  // 中英双语提示
        return pyfmt.Fmt(msgTemplate, map[string]any{
            "tool_call_id":   input.CallID,
            "file_path":      path,
            "content_sample": nResult,
        })
    }
    return result, nil
}

逻辑很直接:工具结果超过 tokenLimit * 4 字符(默认 20000 * 4 = 80KB),就把完整结果写入 /large_tool_result/{callID} 文件,然后给 LLM 返回一段提示:

Tool result was too large to fit in the context window. The result has been written to /large_tool_result/xxx. Preview of the tool result (first 10 lines): ...

LLM 拿到这个提示后,可以用 read_file 工具按需读取结果的任意部分。

这是一个优雅的降级策略------不丢弃数据,也不撑爆上下文窗口,而是把数据存下来,让 LLM 自己决定看哪些。

MultiModalReader:同一工具读不同格式

filesystem.go:678-785newMultiModalReadFileTool

go 复制代码
func newMultiModalReadFileTool(ctx context.Context, backend filesystem.Backend, mmr filesystem.MultiModalReader) (tool.BaseTool, error) {
    return tool.NewTool(
        func(ctx context.Context, input *struct {
            FilePath string
            Offset   int
            Limit    int
        }) (any, error) {
            ext := strings.ToLower(filepath.Ext(input.FilePath))
            switch ext {
            case ".jpg", ".jpeg", ".png", ".gif", ".webp":
                // 用 MultiModalReader 读取,作为 vision 消息返回
                return mmr.MultiModalRead(ctx, ...)
            case ".pdf":
                // PDF 作为文本提取
                return mmr.MultiModalRead(ctx, ...)
            default:
                // 普通文件走普通 read_file
                return backend.Read(ctx, ...)
            }
        },
        tool.WithName(ToolNameReadFile),  // 注意:同一个工具名
        tool.WithDescription(concatReadFileDescription(useMultiModalRead)),
    )
}

关键设计:同一个工具名 read_file ,根据文件扩展名自动选择读取方式。对 LLM 来说,它只需要知道 read_file 这个工具能读文件,不管是 .go 还是 .png 还是 .pdf。工具描述被扩展了(EnhancedReadFileDescSuffix),告诉 LLM 图片和 PDF 也能读。

(四)Prompt 设计:中英双语 + 使用规范

prompt.go 中每个工具的描述都是中英双语:

go 复制代码
var ReadFileToolDesc = `Reads a file from the local filesystem...
...`
var ReadFileToolChinese = `从本地文件系统读取文件...
...`

internal.SelectPrompt 根据上下文语言自动选择。

工具描述不只是说"这工具干什么",还包含使用规范

  • read_file:最多 2000 行,超出用 offset/limit 分页;图片文件作为 vision 消息读取
  • edit_file:必须先读文件,old_string 必须精确匹配,不唯一时报错
  • write_file:必须先读已有文件了解内容
  • grep:三种输出模式说明,-A/-B/-C 上下文
  • execute:命令在沙箱中执行,支持 && 连接多条命令

(五)设计判断

  1. Backend 接口是可插拔性的关键。 7 个工具不直接操作文件系统,而是通过 Backend 接口。切换存储后端不需要改任何一个工具。

  2. edit_file 的唯一性校验是安全机制。 LLM 不擅长精确匹配,"把那个函数改一下" 可能导致误改。唯一性校验强制 LLM 提供足够精确的上下文。

  3. grep 的三种输出模式覆盖不同场景。 content 看详情,files_with_matches 缩小范围,count 了解分布。LLM 可以先用 count 了解规模,再用 files_with_matches 定位文件,最后用 content 看具体行。

  4. Large Tool Result Offloading 是优雅降级。 不丢数据,不撑爆上下文,而是存下来让 LLM 按需读取。read_file 的分页能力让这个方案可行。

  5. MultiModalReader 的透明设计很巧妙。 同一个工具名 read_file,根据扩展名自动切换读取方式。LLM 不需要知道底层是图片还是文本------它只需要知道 read_file 能读文件。

  6. 所有 prompt 都来自 DeepAgents 项目。 prompt.go 注释明确标注 "adapted from the DeepAgents project"。Eino 没有重新发明工具描述,而是复用了经过验证的 prompt。

下一篇(E95)讲 Eino 的 Callbacks 回调机制------如何在 Agent 执行的每个环节注入自定义逻辑。

相关推荐
必须会一定会1 小时前
Agent Handoff M5 发布验收:`CHANGES.md`、`npm pack`、`release:check` 与干净环境安装验证
前端·人工智能·npm·node.js·ai编程
武子康1 小时前
我让 Qwen3.6-27B 真改了一次 Git 仓库:工具调用怎样形成 Agent 闭环
人工智能·后端·agent
AI工具测评家1 小时前
论文AI率和重复率双降怎么做?拆解AIGC降重底层技术与实操方法
人工智能·aigc·降重·ai检测·查重·降ai
程序员-李俞2 小时前
Coze 工作流调用异步 HTTP API 完整教程:任务 ID、循环轮询、状态判断与结果 URL 提取
网络·人工智能·网络协议·http·aigc·ai编程·ai写作
苏灿烤鱼2 小时前
九个编码 Agent 共用免费额度,本地代理是路由还是绕开?
python·agent·claude
copyer_xyf3 小时前
Neo4j:给 RAG 补上关系检索
python·agent
Setsuna_F_Seiei10 小时前
前端的 AI 学习之路 02 之 Provider 与 Structured Output - 规范化模型输入输出
人工智能·agent·ai编程
Setsuna_F_Seiei10 小时前
前端的 AI 学习之路 01 之 Agent API 调用 - 和 Agent 的基础对话
前端·人工智能·ai编程