工具体系
工具(tool)是 Agent 的"双手"。今天讲一个工具由什么组成、LLM 怎么选中并调用它、几个核心工具的门道,以及 MCP 如何让 Agent 接入海量外部工具。昨天(Day 11)我们看清了 Agent 的 step 主循环——每一步都会"选一个工具来用";今天就把那个被选用的"工具"本身拆开看,为明天(Day 13)讲"工具被选中后到底在哪、由谁真正执行"埋好伏笔。
工具 = 三件套
openhands-tools==1.34.0。本课结合前端里的 Action/Observation 类型(工具的"输入输出契约")+ 通用工具设计讲清原理。任何一个工具,本质是三样东西:
| 组成 | 是什么 | 例(bash 工具) |
|---|---|---|
| 名字 + 描述 | 给 LLM 看的"这工具干嘛用" | execute_bash:"执行 bash 命令" |
| 参数 schema | 调用时要填哪些参数(JSON Schema) | command, is_input, timeout |
| 执行函数 | 真正干活的代码,产出 Observation | 在沙箱跑命令、返回输出 |
execute_bash 工具、填 command="ls" → SDK 生成一个 ExecuteBashAction{command:"ls"} → 执行函数在沙箱跑它 → 产出 ExecuteBashObservation。工具是"能力的定义",Action 是"一次具体调用",Observation 是"调用结果"。三位一体。参数 schema 是关键
{"name":"跑命令"} 就完事。真实的工具定义要长得多——多出来的每一段,都是为了让 LLM 少犯错。// 如果你只写这样(朴素版):
{"name": "bash"}
// 问题:LLM 不知道它干嘛、要填什么参数、哪个必填 → 乱调、填错
真实版(OpenAI function calling 格式,发给 LLM,即 Day 03 的 SystemPromptEvent.tools)把这些补齐:
{
"type": "function",
"function": {
"name": "execute_bash",
"description": "在沙箱里执行一条 bash 命令",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "要执行的命令"},
"is_input": {"type": "boolean", "description": "是否向运行中的进程输入"}
},
"required": ["command"]
}
}
}
LLM 如何"选中"工具
👨🏫 老师:不靠猜。每次请求,SDK 会把全部工具的"说明卡"(JSON 定义)连同对话历史一起递给它,相当于把整把军刀摊开摆它面前。
👶 小白:那它怎么"抽"出来?
👨🏫 老师:它不是真的抽——它在回复里结构化地写一段
tool_calls:「我要 name=execute_bash,command='ls'」。SDK 按这个 name 找到对应工具,把 arguments 解析成 Action。👶 小白:万一它写了个不存在的工具名?
👨🏫 老师:那就配不上任何工具、执行不了——所以说明卡(description/参数 schema)写得越清楚,模型越不会"抽错片"。
流程(Day 11 的 step 细化):① 把所有工具的 JSON 定义 + 当前对话历史发给 LLM;② LLM 回复里带一个(或多个)tool_calls,指明"调哪个工具、参数是什么";③ SDK 按 tool_call.function.name 找到对应工具,把 arguments 解析成 Action。
llm_response_id 字段——同一次 LLM 响应里的多个并行动作用它归组。比如 LLM 一次说"同时读这三个文件",就产生三个并行的 ActionEvent。并行工具调用能加速任务(一次 LLM 往返干多件事)。每个工具调用还带 tool_call_id,结果 Observation 用 action_id 配对回去(Day 03)。tool_calls:[{name:"execute_bash", arguments:{command:"ls"}}] → SDK 生成 ExecuteBashAction{command:"ls"} → 在沙箱执行 → 产出 ExecuteBashObservation{exit_code:0, output:"README.md src/"} → 用 action_id 配对追加回历史。一次"抽刀→用刀→看结果"就完成了。bash 工具(最核心)
对应 ExecuteBashAction(Day 02)。它不是"跑一次性命令"那么简单——它维护一个持久的终端会话:
- 命令在同一个 shell 会话里执行,所以
cd之后的路径、设的环境变量都保留(有状态)。 is_input=true:向正在运行的交互程序输入(如回答 y/n)。reset=true:终端卡死时重建会话。- 结果
ExecuteBashObservation带exit_code、输出内容、还从终端提示符 PS1 抓取当前工作目录等元数据。
cd project && source venv/bin/activate 之后,希望后续命令都在这个目录、这个虚拟环境里跑。如果每条命令都开一个全新 shell,这些状态就丢了。持久终端让 Agent 像人一样"在一个终端里连续操作"。从 PS1 抓工作目录这种细节,是为了让 Agent 始终知道"我现在在哪个目录"。文件编辑工具(str_replace 模式)
对应 FileEditorAction(Day 02 精读过)。5 个子命令:view(看)、create(建)、str_replace(查找替换)、insert(插入)、undo_edit(撤销)。结果 FileEditorObservation 带 old_content/new_content(改前改后对比)。
undo_edit 能靠它回滚。Day 02 讲过 str_replace 省 token、防误伤——今天补上:它的 Observation 设计还服务于"可视化 + 可核对 + 可撤销"。好工具不仅动作设计好,结果反馈也设计好。浏览器工具
对应 Browser*Action(10 个,如 navigate/click/type)。底层用 browsergym + playwright(真实浏览器自动化,见 pyproject.toml)。结果 BrowserObservation 带 output 和 screenshot_data(base64 网页截图)。
enable_browser 打开(Day 04)。MCP:接入海量外部工具
对应 MCPToolAction(Day 02)。MCP(Model Context Protocol)是一个开放协议,让工具/数据源用标准方式暴露给 AI。OpenHands 支持接入 MCP server(配置 [mcp] 段,支持 sse/shttp/stdio 三种传输)。
mcp__* 工具用的协议)都能直接变成你 OpenHands Agent 的工具,你几乎不用自己写。fastmcp+mcp 是依赖(pyproject.toml)。"定义标准接口 → 生态自动繁荣"——和 eino 的 eino-ext、组件接口是同一个哲学。接口标准化是生态的地基。今日小结 + 动手
🧠 今天你应该能回答
- 一个工具由哪三件套组成?和 Action/Observation 什么关系?
- 参数 schema 为什么关键?description 写好坏为什么影响大?
- LLM 怎么选中并调用工具?并行工具调用靠什么归组?
- bash 工具为什么要"持久终端"?文件工具的 Observation 为什么带改前改后?
- MCP 带来什么?和 eino-ext 哪里像?
✋ 动手
# 对照 Action 类型看工具契约
sed -n '25,214p' frontend/src/types/v1/core/base/action.ts | head -60
# MCP / 浏览器依赖
grep -iE 'mcp|browsergym|playwright' pyproject.toml
# 配置里的 MCP 段
sed -n '/\[mcp\]/,/^\[/p' config.template.toml | head -30