跳到主要内容

工具体系:注册表、Tool 抽象、执行器与内置工具

30 秒导读: 模型只会「说话」,不会真的改文件、跑命令。这一章讲 Trae Agent 怎么给模型装上手脚:每个能力被封装成一个 Tool 子类,它能把自己翻译成模型能看懂的 JSON 描述;一张注册表 tools_registry 按名字登记所有工具;ToolExecutor 负责把模型说出来的「调用哪个工具、传什么参数」精确落到真实工具上并执行。读完你能讲清:工具如何定义、如何被模型看见、如何被执行,以及内置的几把工具各自干什么。

本章属于 Trae Agent 系列的第 2 章。上一章 01-agent-loop.md 讲主循环怎么把「提示 → 工具调用 → 结果 → 再提示」转起来;本章聚焦其中「工具」这一环。工具产物如何被翻译进不同厂商的请求里,见 03-llm-providers.md;CKG 工具的内部建图细节见 04-ckg.md;危险工具如何被关进 Docker 见 05-docker-execution.md


1. 这是什么(零基础也能懂)

一句话定义: 「工具」是一段被包装成「模型可调用」形式的真实能力——比如「改文件」「跑 bash」「查 JSON」。

它解决什么问题? 大语言模型本身只能输出文本。你让它「把 config.json 里的端口改成 8080」,它只会「我应该把端口改成 8080」,但不会真的动那个文件。要让它真正干活,得给它一组可以调用的工具,并约定一套「模型怎么表达要调用哪个工具、传什么参数」的协议。这一层就是本章的主题。

它长什么样(一次工具调用的直觉):

模型输出(它"想"调用一个工具):
调用 str_replace_based_edit_tool
参数 { command: "str_replace",
path: "/repo/config.json",
old_str: "\"port\": 3000",
new_str: "\"port\": 8080" }


Trae Agent 收下这段"意图",找到对应工具、真的执行 →
文件被改,返回一段"改动后的片段"给模型看。

一句话类比:Tool 想成一件带说明书的电器——get_description() / get_parameters() 是贴在机身上给模型读的说明书,execute() 是按下开关后真正发生的事;ToolExecutor 则是那个「听模型报菜名、去把对应电器打开」的操作员。


2. 顶层全景(工具体系怎么转)

三个主角,各司其职:

部件干什么在哪个文件
Tool(抽象基类)定义每个工具的统一形状:名字 / 描述 / 参数 / 执行,并把自己翻译成模型看的 JSONtrae_agent/tools/base.py:74
tools_registry(注册表)一张「工具名 → 工具类」的字典,谁存在、叫什么由它说了算trae_agent/tools/__init__.py:27
ToolExecutor(执行器)拿到模型给的调用,按名字找到工具、执行、收集结果;支持串行/并行trae_agent/tools/base.py:182

主线走一遍(高层,不进代码):

① 建工具 ② 给模型看 ③ 模型点单 ④ 执行器落地
tools_registry[name]() 每个 tool.json_definition() 模型回一个 ToolCall ToolExecutor 按"归一化名字"
→ 一堆 Tool 实例 → 拼进 LLM 请求(见 03 章) (name + arguments) 找到 tool → tool.execute()
→ ToolResult 回给模型

其中 ② 的「翻译成 JSON」是 Tool.get_input_schema() 的活;④ 的「按名字找到工具」是 ToolExecutor._normalize_name 的活。下面逐个拆开。

主循环里在 ③④ 之间做「串行还是并行」的选择,不在执行器内部,而在 base_agent.py_tool_call_handler 里按配置决定(见 §5.3)。


3. 核心机制:Tool 抽象基类

3.1 它要解决的小问题

想让 N 种能力都能「被模型看见、被统一调用」,就得给它们规定同一个形状Tool 就是这个形状——一个抽象基类,强制每个工具回答四个问题:

抽象方法回答的问题位置
get_name()我叫什么base.py:101
get_description()我干什么(给模型读的自然语言说明书)base.py:106
get_parameters()我要哪些参数(每个参数的名字/类型/是否必填/枚举)base.py:111
execute(arguments)真正执行,返回 ToolExecResultbase.py:116

这四个都是 @abstractmethod(base.py:100-118),不实现就没法实例化。名字、描述、参数还包了一层 @cached_property(base.py:84-94),第一次取值后缓存,避免每次都重算。

3.2 教学示例:一个最小的 Tool 子类

这段演示「实现一个工具最少要写什么」。重点看:四个抽象方法各回答一件事,execute 返回 ToolExecResult

# 示意,非源码
from trae_agent.tools.base import Tool, ToolParameter, ToolExecResult

class EchoTool(Tool):
def get_name(self): # 我叫 echo
return "echo"
def get_description(self): # 给模型读的说明书
return "把传入的 text 原样返回。"
def get_parameters(self): # 只要一个必填字符串参数
return [ToolParameter(name="text", type="string",
description="要回显的文本", required=True)]
async def execute(self, arguments): # 真正干活
return ToolExecResult(output=str(arguments.get("text", "")))
# 重点看:你只写"这四件事",基类负责把它翻译成模型能看懂的 JSON。

3.3 关键细节:get_input_schema() 怎么把工具翻译给模型

模型请求里工具长这样:{name, description, parameters},由 json_definition()(base.py:120)拼出,其中 parameters 交给 get_input_schema()(base.py:127)生成一份标准 JSON Schema。

普通情况很直白:遍历 self.parameters,每个参数生成 {type, description},必填的追加进 required 列表,有 enum / items 就补上。真正微妙的是OpenAI 的 strict 模式,get_input_schema 为它做了三处特殊处理(base.py:144-173):

处理为什么代码
所有参数都进 requiredstrict 模式要求每个属性都在 required 里base.py:145-146
可选参数把类型标成 nullable(如 "string"["string","null"])既满足「必须 required」又表达「其实可以不给」——用 null 顶替base.py:147-151
顶层和嵌套 object 都加 additionalProperties: falsestrict 模式禁止 schema 外的额外字段base.py:162-163base.py:172-173

非 OpenAI 供应商则走普通分支:只有 param.required 为真才进 required(base.py:152-153)。是不是 OpenAI,由构造时传入的 model_provider 决定——同一个工具类,面向不同厂商会生成不同形状的 schema。这就是为什么工具实例化时都带 model_provider=provider(见 §4)。

顺带一提 close()(base.py:177):默认空实现,但持有资源的工具(如 bash 的子进程)会覆盖它,在任务收尾时释放资源。源码里特意 return None 而不是 pass,是为绕过 Ruff 的 B027 检查(注释见 base.py:179)。


4. 核心机制:注册表与默认工具集

4.1 tools_registry——名字到类的字典

所有内置工具在一处登记(tools/__init__.py:27-34):

tools_registry: dict[str, type[Tool]] = {
"bash": BashTool,
"str_replace_based_edit_tool": TextEditorTool,
"json_edit_tool": JSONEditTool,
"sequentialthinking": SequentialThinkingTool,
"task_done": TaskDoneTool,
"ckg": CKGTool,
}

注意键是字符串名字、值是类本身(不是实例)。这样配置文件里只需写工具名,运行时再 tools_registry[name](model_provider=...) 实例化。base_agent.py:42-45 就这么做:按 agent_config.tools 里的名字逐个查表实例化,并统一把当前 model_provider 传进去(呼应 §3.3)。

4.2 TraeAgentToolNames——不配置时的默认集

若任务没指定工具,TraeAgent 用一份默认清单(agent/trae_agent.py:21-27):

默认工具名注册表里对应的类
str_replace_based_edit_toolTextEditorTool
sequentialthinkingSequentialThinkingTool
json_edit_toolJSONEditTool
task_doneTaskDoneTool
bashBashTool

new_task 里:当 tool_names is None and len(self._tools) == 0 时,回落到 TraeAgentToolNames,再查表实例化(trae_agent.py:112-119)。注意这份默认集不含 ckg——CKG 是可选加挂的能力,注册表里有,但不进默认清单。


5. 核心机制:ToolExecutor

ToolExecutor(base.py:182)是「把模型说的调用落到真实工具」的操作员。构造时接一组 Tool 实例(base.py:185)。它做三件事:名字容错匹配、执行单个调用、按串行/并行批量执行。

5.1 _normalize_name——模糊匹配,容忍名字写法

模型不总是把工具名一字不差地复述回来——大小写、下划线常有出入。执行器的对策:把名字全转小写、去掉所有下划线再比(base.py:195-197):

def _normalize_name(self, name: str) -> str:
return name.lower().replace("_", "")

于是 str_replace_based_edit_toolSTR_REPLACE_BASED_EDIT_TOOLstrreplacebasededittool 都归一化成同一个键。tools 属性(base.py:199-203)正是用归一化后的名字建索引:{self._normalize_name(tool.name): tool for tool in self._tools},且惰性缓存进 _tool_map,只建一次。

怎么读这条降级链: 从模型给的原始名字出发,归一化后能匹配就命中,否则报「工具不存在」并列出可用工具名:

模型给的 name
│ _normalize_name(): 转小写 + 去下划线

"strreplacebasededittool"
│ 在 self.tools(同样归一化过的键)里查
├── 命中 → 拿到 Tool 实例,执行
└── 未命中 → ToolResult(success=False,
error="Tool '...' not found. Available tools: [...]")

5.2 execute_tool_call——执行单个调用

execute_tool_call(base.py:205)是核心:

  1. 归一化名字,不在表里就返回失败的 ToolResult,错误信息里附上所有可用工具名(base.py:207-215)。
  2. 命中则 await tool.execute(tool_call.arguments),把返回的 ToolExecResult 包成 ToolResult;successerror_code == 0 决定(base.py:220-228)。
  3. 整段包在 try/except 里——工具内部抛异常不会掀翻整个循环,而是转成一个 success=FalseToolResult(base.py:229-236)。

这层「异常 → 失败结果」的兜底很关键:模型给的参数可能是错的,工具执行可能失败,但这些都应作为「一条反馈」回给模型让它自我纠正,而不是让 agent 崩掉。

5.3 并行 vs 串行——由配置决定,在主循环处选择

执行器提供两种批量执行(base.py:238-244):

方法做法位置
parallel_tool_callasyncio.gather(*[...]) 一起跑,所有调用并发base.py:238-240
sequential_tool_call列表推导逐个 await,一个跑完才跑下一个base.py:242-244

选哪个不在执行器里决定,而在主循环的 _tool_call_handler(agent/base_agent.py:314)里按 self._model_config.parallel_tool_calls 分支(base_agent.py:331-334):

# base_agent.py:331-334
if self._model_config.parallel_tool_calls:
tool_results = await self._tool_caller.parallel_tool_call(tool_calls)
else:
tool_results = await self._tool_caller.sequential_tool_call(tool_calls)

该开关来自配置(utils/config.py:40parallel_tool_calls 字段;旧配置默认 False,见 legacy_config.py:117)。配置分层细节见 06-observability.md

5.4 close_tools——收尾释放资源

任务结束时,close_tools(base.py:189-193)对每个工具 await tool.close()asyncio.gather 一起等完。默认 close() 是空的(§3.3),但 bash 这类持有子进程的工具会在这里真正关掉进程。


6. 内置工具逐个看

先一句话过一遍,再挑几把细讲。

工具类注册名一句话职责
TextEditorToolstr_replace_based_edit_tool看 / 建 / 精确替换 / 插入文件内容——编码 agent 的主力手
BashToolbash在一个持久的 bash 会话里跑命令
JSONEditTooljson_edit_tool用 JSONPath 表达式精确改 JSON 文件
SequentialThinkingToolsequentialthinking让模型把复杂问题拆成可修正的思考步骤(纯记录,不动外部世界)
TaskDoneTooltask_done模型用它宣布「任务完成」,是主循环的收尾信号
CKGToolckg查代码知识图谱(按函数/类/方法名搜),内部见 04-ckg.md

6.1 TextEditorTool(重点)——精确、唯一的字符串替换

这是整套工具里工程含量最高的一把,edit_tool.py。它有四个子命令(edit_tool.py:18-23):view / create / str_replace / insert,由 command 参数分派(edit_tool.py:117-130match)。

四个子命令一句话:

子命令干什么实现
view文件则 cat -n 风格带行号显示(支持 view_range);目录则 find 列到 2 层深edit_tool.py:154(_view)
create新建文件——路径已存在则拒绝,不覆盖edit_tool.py:322(_create_handler)
str_replaceold_str 精确替换成 new_stredit_tool.py:197(str_replace)
insert在指定行之后插入新内容edit_tool.py:238(_insert)

最巧的是 str_replace 的「精确且唯一」匹配(edit_tool.py:197-236)。它不做模糊匹配,而是要求 old_str 在文件里恰好出现一次:

  • 读文件和 old_str 都先 .expandtabs()(edit_tool.py:200-202),把 tab 展开成空格再比,避免「看着一样、tab/空格不同」导致匹配不上。
  • 数出现次数(file_content.count(old_str),edit_tool.py:205):0 次报「没出现,未替换」(edit_tool.py:206-209);多于 1 次则报错并列出所有命中的行号,要求模型补足上下文让它唯一(edit_tool.py:210-215)。
  • 只有恰好 1 次才替换。

替换后回显一段「SNIPPET」上下文(edit_tool.py:223-232):算出替换发生在第几行,取前后各 SNIPPET_LINES=4 行(edit_tool.py:24),用 _make_output 带行号打印出来给模型确认。这让模型「看见」自己改动的效果,便于自查。

下面示意这个「唯一性」保护为什么重要:

# 示意,非源码
文件里有两处 "port": 3000
模型说:把 "port": 3000 → 8080
│ count(old_str) == 2

拒绝!报错:"Multiple occurrences ... in lines [4, 19]. Please ensure it is unique"
→ 逼模型带上足够上下文(如 "\"db\": { \"port\": 3000 }")让目标唯一

前置校验 validate_path(edit_tool.py:134-152)在执行前挡下常见错误:路径必须是绝对路径(否则提示补 /);非 create 命令要求路径存在;create 命令要求路径不存在(不覆盖);目录只能用 view

6.2 BashTool 与持久 session

BashTool(bash_tool.py:162)把命令送进一个长期存活的 bash 子进程 _BashSession(bash_tool.py:19),而非每条命令起一个新 shell。所以 cd、导出的环境变量在多次调用间保留——描述里也明说「State is persistent across command calls」(bash_tool.py:185)。

实现的巧点是用哨兵字符串探测命令结束:每条命令后追加 echo <哨兵>,哨兵里嵌了退出码占位;读输出直到看见哨兵,再从哨兵解析出 error code(bash_tool.py:108-141)。这样无需为每条命令重开进程,也能拿到退出码。restart 参数可重启会话,close()(bash_tool.py:240-247)覆盖基类,任务收尾时真的停掉子进程。持久 session 的更多细节这里点到为止。

6.3 SequentialThinkingTool——把「想」也变成一次调用

这把工具不碰外部世界,它只是给模型一个结构化的思考载体:每次调用记录一个 ThoughtData(sequential_thinking_tool.py:19-29),字段含 thought / thought_number / total_thoughts / next_thought_needed,还能标 is_revision(修正前面某步)、branch_from_thought(分支)。执行时把这次思考追加进 thought_history(sequential_thinking_tool.py:156),并做基本校验(_validate_thought_data,sequential_thinking_tool.py:163)。作用是让模型把复杂问题显式拆成可回溯、可修正的步骤,而不是一口气闷头输出。

6.4 TaskDoneTool——收尾信号

最简单的一把(task_done_tool.py):无参数(get_parameters 返回 [],task_done_tool.py:28-29),execute 永远返回 "Task done."(task_done_tool.py:32-33)。它的价值不在执行结果,而在语义:模型调用它就等于宣布任务完成,主循环据此结束(见 01-agent-loop.md)。描述里还特意叮嘱「验证之前不能调用它」(task_done_tool.py:25)。

6.5 JSONEditTool——JSONPath 精确改 JSON

json_edit_tool.pyjsonpath_ng 库,以 JSONPath 表达式定位 JSON 里的目标节点做四种操作(json_edit_tool.py:56-90):view / set / add / remove。JSONPath 例如 $.users[0].name$.config.database.host$.items[*].price(描述见 json_edit_tool.py:33-53)。相比用 str_replace 硬改 JSON 文本,它按结构定位,更不容易改坏格式;file_path 强制绝对路径(json_edit_tool.py:104-108)。

6.6 CKGTool——查代码知识图谱(一句话带过)

ckg_tool.py 提供三个搜索命令(ckg_tool.py:11):search_function / search_class / search_class_method,按标识符在代码知识图谱里查函数/类/方法,可选是否打印函数体(print_body)。它的 CKG 用 tree-sitter 建图、SQLite 存储、带快照缓存——内部实现见 04-ckg.md,本章不展开。


7. 巧妙之处(可带走的技术)

  • 同一个工具类,面向不同厂商生成不同 schema。 get_input_schema 里用 model_provider 分支,为 OpenAI strict 模式把可选参数标 nullable、加 additionalProperties: false,而其它供应商走普通 required 逻辑(base.py:144-173)。把「厂商差异」收在工具翻译层,上游无感。
  • 名字容错匹配。 _normalize_name 用「小写 + 去下划线」抹平模型复述工具名时的写法漂移,让 str_replace_based_edit_tool 这类长名字有很强容错(base.py:195-197)。
  • 编辑靠「唯一性」而非「模糊匹配」。 str_replace 要求 old_str 恰好出现一次,多于一次就报错并列出行号,逼模型补上下文——用「拒绝改错」换「不会改错地方」(edit_tool.py:205-215)。
  • 异常降级为反馈。 工具执行的任何异常都被 execute_tool_call 兜成 success=FalseToolResult 回给模型自纠,而非中断 agent(base.py:229-236)。
  • 持久 bash + 哨兵探测退出。 一个长活 shell 保留状态,靠追加 echo <哨兵> 解析退出码,免去每命令重开进程(bash_tool.py:108-141)。

8. 边界与局限(诚实)

  • str_replace 不做模糊匹配。 old_str 必须逐字符(tab 展开后)精确出现且唯一,写错一个空格就替换失败——这是刻意的安全取舍,不是 bug(edit_tool.py:206-215)。
  • create 不覆盖已存在文件。 描述里甚至提示「若已存在请先删再建」(edit_tool.py:46),validate_path 会硬拒(edit_tool.py:144-147)。
  • undo_edit 已被移除。 源码显式注明本版本不实现撤销(edit_tool.py:276),改错了只能再改回去。
  • 默认工具集不含 CKG。 TraeAgentToolNames 里没有 ckg(trae_agent.py:21-27),需要它得显式配置。
  • 并行/串行是全局开关。parallel_tool_calls 一个配置决定,不能按单个调用细分(base_agent.py:331-334)。

9. 横向对比(同系列其它章)

  • 工具产物如何被翻译进各家 LLM 请求、如何重试:03-llm-providers.md
  • ckg 工具背后的 tree-sitter 建图 + SQLite:04-ckg.md
  • bash / 编辑类工具在 Docker 里如何被拦截并做路径翻译(docker_tools=["bash","str_replace_based_edit_tool","json_edit_tool"],见 base_agent.py:68):05-docker-execution.md
  • parallel_tool_calls 等配置的分层来源:06-observability.md

10. 代码地图(导航索引)

主题文件路径符号名
Tool 抽象基类trae_agent/tools/base.pyTool
四个抽象方法trae_agent/tools/base.pyTool.get_name / get_description / get_parameters / execute
翻译成模型看的 JSONtrae_agent/tools/base.pyTool.json_definition / Tool.get_input_schema
OpenAI strict 模式 nullable + additionalPropertiestrae_agent/tools/base.pyTool.get_input_schema(model_provider == "openai" 分支)
参数定义trae_agent/tools/base.pyToolParameter
执行器trae_agent/tools/base.pyToolExecutor
名字模糊匹配trae_agent/tools/base.pyToolExecutor._normalize_name
执行单个调用trae_agent/tools/base.pyToolExecutor.execute_tool_call
并行 / 串行trae_agent/tools/base.pyToolExecutor.parallel_tool_call / sequential_tool_call
收尾释放资源trae_agent/tools/base.pyToolExecutor.close_tools / Tool.close
注册表trae_agent/tools/__init__.pytools_registry
默认工具集trae_agent/agent/trae_agent.pyTraeAgentToolNames
按配置选并行/串行trae_agent/agent/base_agent.pyBaseAgent._tool_call_handler
精确唯一替换trae_agent/tools/edit_tool.pyTextEditorTool.str_replace
路径前置校验trae_agent/tools/edit_tool.pyTextEditorTool.validate_path
持久 bash 会话trae_agent/tools/bash_tool.py_BashSession / BashTool
顺序思考trae_agent/tools/sequential_thinking_tool.pySequentialThinkingTool / ThoughtData
完成信号trae_agent/tools/task_done_tool.pyTaskDoneTool
JSONPath 编辑trae_agent/tools/json_edit_tool.pyJSONEditTool
代码知识图谱查询trae_agent/tools/ckg_tool.pyCKGTool