dodo-agent

智能体如何拥有操作系统的能力?

2026 年,AI Agent 迎来了真正的爆发。OpenClaw 这个开源项目,短短几个月内席卷全球,github star数直接飙升至第一。而 Claude Code 等编程Cli 也成为了开发者日常离不开的编程助手。它们有一个…

TL;DR

2026 年,AI Agent 迎来了真正的爆发。OpenClaw 这个开源项目,短短几个月内席卷全球,github star数直接飙升至第一。而 Claude Code 等编程Cli 也成为了开发者日常离不开的编程助手。它们有一个…

2026 年,AI Agent 迎来了真正的爆发。OpenClaw 这个开源项目,短短几个月内席卷全球,github star数直接飙升至第一。而 Claude Code 等编程Cli 也成为了开发者日常离不开的编程助手。它们有一个共同的特点:能直接在你的电脑上做任何事情——读取文件、编辑代码、执行脚本、搜索内容,甚至帮你装软件、跑命令。 为什么它们能直接操作你的电脑系统?本质上不是因为模型有多聪明,而是因为它们拥有一组原子化的工具——Read、Write、Edit、Bash、Grep、Glob。这些工具赋予了 LLM 操作系统的能力。模型负责思考和决策,工具负责执行和落地。 在上一节课中,我们实现了 Skills 系统,提升了 Agent 知道怎么解决某一类问题的能力。但光知道怎么做还不够,Agent 还得能动手做。比如用户说"帮我做一个 PPT",LLM 通过 Skills 知道了应该用 python-pptx 来生成,但谁来执行 Python 脚本?谁来把代码写到文件里?谁来读取文件内容进行编辑? 这就是这节课要解决的问题:如何给 Agent 装上一组通用工具,让它和 OpenClaw、Claude Code 一样,真正拥有操作系统的能力。

工具的来源与适配

dodo-agent 中的核心工具集包含三个部分:FileSystemTools(文件操作)、GrepTool(内容搜索)和 BashTool(Shell 命令执行),总共提供 8 个原子工具。 当然这些工具肯定不是从零手敲的,最初来自于 spring-ai-agent-utils 这个包之中,然后我在它的基础上,借助claude code 做了不少适配的工作,提升这些工具执行的成功率: - Windows 兼容性:原始实现主要针对 Unix/Linux 环境。我们增加了 Windows 平台的兼容处理,比如 BashTool 根据操作系统动态生成工具描述,提醒 LLM 不要在 Windows 上使用 mkdir -p、touch 等 Unix 命令 - 编码兼容性:read_file 和 GrepTool 都增加了 UTF-8 → GBK → ISO-8859-1 的三级编码回退,解决中文 Windows 环境下的文件编码问题 - Shell 会话持久化:BashTool 支持持久化 Shell 会话,工作目录和环境变量跨命令保持,LLM 可以用 cd 切换目录后继续执行命令 - 输出截断保护:BashTool 增加了行数和字节数的双重限制,防止命令输出过长导致 Token 爆炸 - 工具提示词优化:调整了工具的 description,明确告知 LLM 应该优先使用专用工具而非 Bash,提高工具调用成功率 接下来逐个介绍这三个核心工具。

FileSystemTools:文件操作

核心思路

FileSystemTools 是最基础也最常用的工具集,它把文件操作封装为 5 个独立的工具:

FileSystemTools
├── read_file      读取文件(支持分页)
├── write_file     创建新文件
├── edit_file      编辑已有文件(字符串替换)
├── list_files     列出目录内容
└── glob_files

这 5 个工具的设计思路和 Claude Code 的工具体系是对应的: read_file 对应 Claude Code 的 Read,edit_file 对应 Edit,write_file 对应 Write,list_files 和 glob_files 对应 Glob。

实现细节

FileSystemTools 使用 Spring AI 的 @Tool 注解将方法声明为 LLM 可调用的工具。以 read_file 为例:

@Tool
        读取文件系统中的文件内容。

        用法


public


}

工具的 description 非常关键——它就是 LLM 的"使用说明书"。description 写得好不好,直接影响 LLM 能否正确调用工具。我们在 description 中重点说明了以下几点: - 参数的用法和默认值 - 分页读取的建议(避免一次性读取大文件) - 使用前的建议(先用 list_files 确认路径) 编码回退机制 在实际使用中发现,中文 Windows 环境下的文件编码问题非常棘手。很多旧文件是 GBK 编码,直接用 UTF-8 读取会抛 MalformedInputException。为此实现了一个三级编码回退机制:

private


        logger


        logger


}

这个三级回退确保了几乎所有编码的文件都能被读取——即使显示乱码,也不会导致工具调用失败。这一点在中文 Windows 环境下尤为重要。 edit_file 的安全设计 edit_file 的设计思路是:编辑的本质就是字符串替换。

@Tool
        通过字符串替换编辑已有文件。

        用法


public

}

这里有一个关键的安全设计:非 replaceAll 模式下,如果 oldString 在文件中出现了多次,编辑会失败。这是为了防止 LLM 传了一个太短的字符串(比如一个空行),导致文件被错误替换。工具会返回错误信息,要求 LLM 提供更多上下文使其唯一。 路径安全检查 FileSystemTools 还做了路径安全检查:

private


}

在虚拟模式下(通常用于 Web 端),所有文件操作都被限制在一个根目录内,防止 LLM 通过路径穿越访问系统文件。

效果演示

列出桌面文件:当用户在对话框中输入"列出C:\Users\Lenovo\Desktop的文件有哪些?"时,LLM 会自动判断需要调用 list_files 工具,返回桌面上的文件列表。 编辑文件内容:用户输入"在这个目录下新建一个hello.txt,写一个笑话"。

GrepTool:内容搜索

核心思路

GrepTool 提供基于正则表达式的内容搜索能力,对标 Claude Code 的 Grep 工具。它的一个亮点设计是双引擎:优先使用 ripgrep,不可用时回退到 Java 原生实现。 为什么要这样设计?因为 ripgrep 是目前最快的文本搜索工具,在大型代码库中的搜索速度比 Java 原生实现快几个数量级。但 ripgrep 需要单独安装,不是所有环境都有。所以 GrepTool 在启动时自动检测:有 ripgrep 就用 ripgrep,没有就用 Java 原生实现,确保在任何环境下都能工作。

实现细节

public


        log

        log

}

工具描述中同样包含了优先级提示:

@Tool
        基于正则表达式的文件内容搜索工具。

        用法

注意第一句话就是:"始终优先使用本工具进行内容搜索,不要通过 bash 执行 grep 命令"。这是工具设计中的一个重要技巧:在 description 中明确告诉 LLM 这个工具的定位和使用优先级。LLM 的工具选择很大程度上依赖 description,如果你不在 description 中说清楚,LLM 可能会用 Bash 执行 grep 命令而不是调用 GrepTool。 编码回退机制 和 FileSystemTools 一样,GrepTool 的 Java 原生引擎也遇到了编码问题——搜索目录时遇到 GBK 编码的文件,Files.lines() 直接抛 MalformedInputException,一个文件的编码问题会导致整个搜索失败。解决方案和 read_file 一致,在 searchFile 方法中统一通过 readLinesWithFallback 读取文件内容,三级回退:UTF-8 → GBK → ISO-8859-1。这样即使目录中混合了不同编码的文件,也能逐个处理,不会因为一个坏文件炸掉整个搜索。 可选参数的空值保护 在实际使用中还发现一个问题:LLM 不一定会传所有可选参数。比如只传了 pattern 和 path,ignoreCase、headLimit 这些参数可能是 null。而方法内部直接对 Boolean 做 if (ignoreCase) 拆箱就会空指针。为此在 grepContent 入口处统一将所有可选参数从包装类转为带默认值的基本类型:

// 可选参数设置默认值,防止 LLM 不传时 NPE
if
if
boolean
int
int
int

下游两个搜索方法接收的就是基本类型,不需要再处理 null。这个模式适用于所有带有可选参数的工具。

效果演示

用户输入:C:\Users\Lenovo\Desktop\个人学习文档,用grep工具搜索这个里面哪个文件提到了“吕布”

BashTool:Shell 命令执行

核心思路

BashTool 是能力最强、风险也最高的工具。它让 LLM 可以执行任意 Shell 命令,相当于给了 LLM 一台电脑的终端。它的核心特性是持久化 Shell 会话——由 ShellSessionManager 管理,工作目录和环境变量跨命令保持。

实现细节

持久化 Shell 会话 为什么需要持久化会话?因为 LLM 的工具调用是无状态的——每次调用都是独立的。但在实际使用中,LLM 经常需要连续执行多个命令,后一个命令依赖前一个命令的结果。比如:

Round 1: cd /project && python -c "import sys; print(sys.version)"
Round 2: pip install requests    ← 需要在同一个目录下执行
Round 3: python script.py        ← 需要使用刚安装的包

ShellSessionManager 会追踪每个会话的工作目录,LLM 用 cd 切换目录后,后续命令自动在新的目录下执行:

public


}

动态工具描述 BashTool 的工具描述是动态生成的,也就是说:是根据当前操作系统生成不同的提示词:

private


        osNotes


        osNotes


            在持久化


            适用场景


            ⚠️ 工具优先级(重要)
            本工具是最后的手段,请优先使用专用工具


}

这一点非常重要。LLM 的训练数据中大部分是 Unix 命令,它默认会生成 ls、cat、mkdir -p 等 Unix 命令。在 Windows 环境下,这些命令要么不存在,要么行为不同。通过在工具描述中明确告知操作系统类型和对应的命令差异,可以大幅提高 LLM 生成命令的准确率。 输出截断保护 BashTool 还有一个容易忽视但非常重要的设计——输出截断:

private


}

如果 LLM 执行了一个输出量巨大的命令(比如 cat 一个大文件),不截断的话,巨大的输出会被塞进 LLM 的上下文中,直接导致 Token 爆炸,后续的推理质量急剧下降。默认限制是 10000 行或 100KB,超出部分会被截断并提示。

效果演示

工具注册与合并

三个工具类都提供了统一的 create() 静态方法来生成 ToolCallback[]:

// FileSystemTools: 5 个工具
public

}

// GrepTool: 1 个工具
public

}

// BashTool: 1 个工具(动态描述)
public


}

注意 BashTool 的 create() 方法和其他两个不同——它使用 FunctionToolCallback.builder() 而不是 ToolCallbacks.from(),因为 BashTool 的描述需要根据操作系统动态生成。在 AgentController 中,所有工具通过 ToolMergeUtils.mergeTools() 合并为一个统一的工具集:

ToolCallback
    webSearchToolCallbacks


)

这样,SkillsReactAgent 就拥有了搜索、文件操作、内容搜索、Shell 命令执行和 Skills 加载等多种能力。

为什么不直接用 Bash 做所有事?

看完上面三个工具的实现,你可能会问:Bash 可以做几乎所有事情。 读文件用 cat、写文件用 echo >、搜索用 grep、列目录用 ls——那为什么还要单独做 Read、Write、Grep 这些专用工具?直接给 LLM 一个 Bash 不就完了? 这个问题的答案,其实也是 Claude Code 工具体系演进的方向。Claude Code 早期确实更偏向 Bash,但随着实践积累,它逐渐演变为"专业工具做专业的事"。UCL 的研究论文 Dive into Claude Code: The Design Space of Today's and Future AI Agent Systems(https://arxiv.org/abs/2604.14228)对此做了深入分析,核心观点是: Claude Code gives the model maximum decision latitude within a rich operational harness. (Claude Code 在一个丰富的操作框架内,给模型最大的决策自由度。) 它的意思就是:不要替模型做决策,而是给它足够的工具,让它自己决定怎么做。 但是给足够的工具,并不等于只给一个万能工具: 第一,准确率更高。 Bash 工具的本质是:LLM 生成一段命令字符串 → 系统丢给 Shell 执行 → 返回结果。这里有一个巨大的风险点——LLM 生成的命令可能有语法错误。比如前面提到的 Windows 下 mkdir -p 会创建名为 -p 的文件夹,echo '中文' > file.py 会导致编码乱码。 而专用工具的参数是结构化的:文件路径是路径、内容是内容。LLM 不需要操心命令语法,只需要关心"写什么"和"写到哪",出错的概率大大降低。 第二,结果更可控。 Bash 命令的输出是自由文本,格式千差万别。而专用工具的输出是结构化的——read_file 返回带行号的内容,edit_file 返回替换了几处。这种确定性对 LLM 理解工具结果非常有帮助。 第三,安全性更好。 专用工具可以在内部做路径安全检查、文件大小限制、输出截断等保护。而 Bash 是"裸奔"的,LLM 如果执行了不可控的命令,后果难以预料。 所以在工具设计上,优先级非常明确:

专用工具(read_file, write_file, edit_file, glob_files, grep)> Bash 工具

LLM 应该优先使用 FileSystemTools 和 GrepTool,只有在确实需要执行 Shell 命令(如运行 Python 脚本、安装依赖、git 操作)时才使用 BashTool。这也是为什么我们在每个工具的 description 中都加入了优先级提示——BashTool 的描述末尾写着"本工具是最后的手段",GrepTool 的描述开头就说"始终优先使用本工具"。

总结

这节课的核心设计思想可以用一句话概括:给 LLM 一组专业的原子工具,让 LLM 自己决定用哪个。 三个工具的定位: - FileSystemTools:文件读写、编辑、查找,5 个工具,优先级最高。LLM 应该始终优先使用这些工具来操作文件,而不是通过 Bash 执行 cat、echo > 等命令 - GrepTool:正则内容搜索,1 个工具,优先级高。始终优先于 Bash 的 grep 命令 - BashTool:Shell 命令执行,1 个工具,优先级最低(兜底)。只有在确实需要执行 Shell 命令时才使用 工具设计的关键经验: 1. 工具描述是关键:LLM 靠 description 判断是否调用工具,description 中要明确说明适用场景、参数用法和优先级 2. 专业工具优于 Bash:结构化参数比自由文本命令更可靠,准确率更高 3. 防御性设计:输出截断、路径检查、编码回退,真实环境必须有这些保护 4. 平台适配:根据运行环境动态调整工具行为和描述,特别是 Windows 兼容性 这套工具系统让 dodo-agent 的 SkillsReactAgent 拥有了和 Claude Code 类似的操作系统能力。LLM 可以读取代码、搜索文件、编辑内容、执行脚本,结合 Skills 系统获取的专业知识,再加上我们的React循环机制,真正实现了知道怎么做 + 能实际去做的闭环。

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。