上一篇 讲 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 实现:
- 开发/测试 :
InMemoryBackend(backend_inmemory.go),文件存内存 map - 生产:本地文件系统 Backend
- 容器:Docker 容器文件系统 Backend
只要实现了 Backend 接口,就能切换。工具本身不关心底层存储是什么。
Middleware 注入
filesystem.go:354-397 的 NewTyped / New 创建的是 ChatModelAgentMiddleware:
go
func NewTyped[T filesystem.Backend](ctx context.Context, config *Config[T]) (ChatModelAgentMiddleware, error)
它做的事:
- 根据 Config 创建 7 个工具(
getFilesystemTools,line 427-532) - 包装为
ChatModelAgentMiddleware,注入到 ChatModelAgent 的工具列表
工具创建的 toolSpec 模式
filesystem.go:427-532 的 getFilesystemTools 使用统一的 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 包含 Path、IsDir、Size、ModifiedAt 四个字段。
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:接收 FilePath 和 Content,调用 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)有三个校验:
- OldString 不能为空
- OldString 必须在文件中存在
- 如果 ReplaceAll=false,OldString 必须唯一------防止 LLM 只想改一处但误改了多处
这个唯一性校验是 edit_file 的核心安全机制。LLM 说"把那行 fmt.Println 改成 log.Println",如果文件里有 5 处 fmt.Println,不指定 ReplaceAll 就会报错,强制 LLM 提供更精确的上下文。
5. glob:文件名模式匹配
filesystem.go:855-878:接收 Path 和 Pattern,调用 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-1019 的 newExecuteTool 是同步执行,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-785 的 newMultiModalReadFileTool:
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:命令在沙箱中执行,支持&&连接多条命令
(五)设计判断
-
Backend 接口是可插拔性的关键。 7 个工具不直接操作文件系统,而是通过
Backend接口。切换存储后端不需要改任何一个工具。 -
edit_file 的唯一性校验是安全机制。 LLM 不擅长精确匹配,
"把那个函数改一下"可能导致误改。唯一性校验强制 LLM 提供足够精确的上下文。 -
grep 的三种输出模式覆盖不同场景。
content看详情,files_with_matches缩小范围,count了解分布。LLM 可以先用count了解规模,再用files_with_matches定位文件,最后用content看具体行。 -
Large Tool Result Offloading 是优雅降级。 不丢数据,不撑爆上下文,而是存下来让 LLM 按需读取。
read_file的分页能力让这个方案可行。 -
MultiModalReader 的透明设计很巧妙。 同一个工具名
read_file,根据扩展名自动切换读取方式。LLM 不需要知道底层是图片还是文本------它只需要知道read_file能读文件。 -
所有 prompt 都来自 DeepAgents 项目。
prompt.go注释明确标注 "adapted from the DeepAgents project"。Eino 没有重新发明工具描述,而是复用了经过验证的 prompt。
下一篇(E95)讲 Eino 的 Callbacks 回调机制------如何在 Agent 执行的每个环节注入自定义逻辑。