Day 32 / 共 60 天 · 阶段5 工具系统(收官)

自定义工具与最佳实践:从"读懂"到"写好"

阶段5 收官。前五天我们把工具系统的地基(BaseTool)、执行态(StructuredTool)、调用容错(tool_usage)、缓存(ToolsHandler)、远程(MCP)都拆完了。今天不引入新机制,而是把这些知识凝结成"怎么写一个好工具":两条自定义路线怎么选、拿源码里两个真实工具当范本逐行学、描述/schema/三个开关的实战写法、如何和 langchain 生态互通,以及最容易踩的反模式和一张上线前检查清单。

📍 你在 60 天里的位置(阶段5 工具系统 · 收官日)
D27 BaseTool D28 StructuredTool D29 工具调用 D30 缓存复用 D31 MCP 工具 D32 自定义与最佳实践 阶段6 记忆 D33-40
💡 先用一个类比兜住今天 写工具就像给一个能干但"只看说明书办事"的新员工设计岗位。他很强,但只会照你写的说明书理解任务。所以:说明书(description)要写清楚"什么时候找我干这活";表单(args_schema)要列明"需要你提供哪些信息、什么格式";返回的东西(结果)要让他看得懂、不会误解(出错就说人话,而不是抛一堆栈)。今天就是把前五天学的零件,组装成一份"好岗位说明书"的写法。
L01

回顾:一次工具调用穿过了哪些层

🤔 痛点前五天每天看一块,容易只见树木不见森林。你能一口气说出"Agent 说要用某工具"到"结果回到对话"中间穿过了哪些层吗?搞清这条链,才知道你写的工具在整台机器里处于什么位置、哪些行为是你能控制的。
💡 一句话本质 一次工具调用的完整链路:LLM 输出 →(D29)解析成 ToolCalling + 模糊匹配工具 + 修 JSON →(D30)查防重复/缓存/用量 → 命中就返回,否则(D28)CrewStructuredTool.invoke 解析参数 →(D27)_run 真正干活(本地)或(D31)远程 MCP 调用 → 结果经 result_schema 格式化 → 写回缓存 → 喂回 LLM。你写工具时,主要负责最内层的 _run/函数体和外层的配置(描述、schema、开关),其余全是框架兜底。
控制流:一次工具调用穿过五天的知识 LLM:我要用「搜索」 D29 解析+模糊匹配+修 JSON D30 防重复 / 缓存 / 用量上限 命中 → 直接返回缓存 D28 invoke → D27 _run / D31 MCP result_schema 格式化 + 写缓存 结果喂回 LLM
图注:五天的机制串成一条链。你写工具只管两头(配置 + _run),中间全是框架代劳。
L02

两条路:@tool 装饰器 vs 继承 BaseTool

自定义工具有两条路,先看它们在源码里的样子(base_tool.py:496Tool 就是 @tool 背后的类):

# 路线A:@tool 装饰器(base_tool.py:676)——包一个已有函数
from crewai.tools import tool
@tool("查天气")
def get_weather(city: str) -> str:
    """查询某城市当前天气。输入城市名,返回温度和天气描述。"""
    return call_weather_api(city)

# 路线B:继承 BaseTool(base_tool.py:103)——写一个有状态的类
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
class WeatherSchema(BaseModel):
    city: str = Field(..., description="城市名,如'上海'")
class WeatherTool(BaseTool):
    name: str = "查天气"
    description: str = "查询某城市当前天气,返回温度和描述"
    args_schema: type[BaseModel] = WeatherSchema
    def _run(self, city: str) -> str:
        return call_weather_api(city)
维度@tool 装饰器(路线A)继承 BaseTool(路线B)
代码量最少,一个带 docstring 的函数多,要写 Schema 类 + Tool 类
适合无状态、逻辑简单的工具有状态(连接池/配置/私有属性)、逻辑复杂
schema从函数签名自动推断可显式写 args_schema,更精细
私有状态难(函数没地方挂状态)易(PrivateAttr,如 ReadFileTool 的 _files
数据结构:两条路线最终都变成 BaseTool 路线A:@tool 函数 docstring→description 签名→args_schema · 造 Tool 路线B:继承 BaseTool 显式 name/description/schema PrivateAttr 存状态 · 实现 _run BaseTool 接口(统一) 执行循环只认 BaseTool → 两条路写法不同,用起来完全一样
图注:两条路线殊途同归——都归到 BaseTool 接口,这正是 Day 27 立基类的价值。
💡 设计取舍①:什么时候升级到路线B 默认用路线A(@tool)——90% 的工具都够用,写得快。什么时候必须路线B?当工具需要携带状态时。比如源码里的 ReadFileTool(L03)要持有"当前可读的文件字典",它用 PrivateAttr_files,还提供 set_files() 让框架在 kickoff 时注入——这是函数式的 @tool 做不到的。判断标准:"这个工具除了输入参数,还需要记住/依赖别的东西吗?"需要 → 路线B;纯输入进、结果出 → 路线A。
L03

范本一:ReadFileTool 逐行学(有状态工具)

源码自带的 ReadFileToolread_file_tool.py:24)是路线B 的典范:

# read_file_tool.py:18
class ReadFileToolSchema(BaseModel):
    file_name: str = Field(..., description="The name of the input file to read")

# read_file_tool.py:24
class ReadFileTool(BaseTool):
    name: str = "read_file"
    description: str = (
        "Read content from an input file by name. "
        "Returns file content as text for text files, or base64 for binary files.")
    args_schema: type[BaseModel] = ReadFileToolSchema
    _files: dict[str, FileInput] | None = PrivateAttr(default=None)   # ★私有状态

    def set_files(self, files) -> None:      # 框架在 kickoff 时注入文件
        self._files = files

    def _run(self, file_name: str, **kwargs) -> str:
        if not self._files:
            return "No input files available."                   # ★① 无文件:说人话
        if file_name not in self._files:
            available = ", ".join(self._files.keys())
            return f"File '{file_name}' not found. Available files: {available}"  # ★② 找不到:给可选项
        file_input = self._files[file_name]
        content = file_input.read()
        content_type = file_input.content_type
        ...
        if any(content_type.startswith(t) for t in text_types):
            return content.decode("utf-8")                       # 文本:直接返回
        encoded = base64.b64encode(content).decode("ascii")
        return f"[Binary file: {filename} ({content_type})]\nBase64: {encoded}"  # 二进制:base64
独立的 Schema 类参数 schema 单独定义一个类,每个字段带 description——这些描述会进 prompt 帮模型理解参数含义。
_files = PrivateAttr★工具的状态。它不是模型传的参数,而是运行环境注入的。PrivateAttr 让它不参与序列化。
★① / ★② 错误返回值无文件、找不到文件时,返回一句人话(还列出可用文件),而不是抛异常。回应 D29 的哲学:错误当返回值喂回 LLM,让它自己纠正("哦原来该读那个文件")。
按类型分别处理文本解码成 utf-8、二进制转 base64、PDF 抽文本(read_file_tool.py:84)——把"结果对 LLM 友好"做到位。
从这个范本学到的三点① 参数用带 description 的独立 Schema;② 状态用 PrivateAttr + 注入方法;③ 所有"异常情况"都返回描述性文字而非抛错——这是好工具和坏工具最大的分水岭。
L04

范本二:AddImageTool(返回结构化 + 特殊消息)

AddImageTooladd_image_tool.py:16)展示工具也能返回非字符串

# add_image_tool.py:9
class AddImageToolSchema(BaseModel):
    image_url: str = Field(..., description="The URL or path of the image to add")
    action: str | None = Field(default=None, description="Optional context or question about the image")

# add_image_tool.py:16
class AddImageTool(BaseTool):
    name: str = Field(default_factory=lambda: I18N_DEFAULT.tools("add_image")["name"])
    description: str = Field(default_factory=lambda: I18N_DEFAULT.tools("add_image")["description"])
    args_schema: type[BaseModel] = AddImageToolSchema

    def _run(self, image_url: str, action: str | None = None, **kwargs) -> dict:
        action = action or I18N_DEFAULT.tools("add_image")["default_action"]
        content = [
            {"type": "text", "text": action},
            {"type": "image_url", "image_url": {"url": image_url}},
        ]
        return {"role": "user", "content": content}     # ★返回一条"消息"结构,而非普通文本
可选参数 actionaction: str | None = Field(default=None)——有默认值就是可选参数。演示了"必填 vs 可选"怎么在 schema 里表达。
name/description 用 default_factory从国际化配置 I18N_DEFAULT 取名字和描述,支持多语言。展示 name/description 也能动态生成。
返回 dict(消息结构)★它返回的不是文本,而是一条多模态消息 {"role":"user","content":[...]}。这就是 Day 08 里执行循环对 add_image 做特判、直接把结果作为消息 append 的原因。
result_as_answer 未设它默认 False——图片是"喂给下一轮"的素材,不是最终答案。开关的默认值也要想清楚。
💡 从两个范本对比看"输出设计"ReadFileTool 返回文本(多数工具如此);AddImageTool 返回结构化消息(特殊多模态场景)。工具输出不必总是字符串,但你要清楚:非字符串输出需要执行循环/result_schema 配合处理(D28 的格式化、D08 的特判)。日常自定义工具,返回清晰的字符串是最省心的选择。
L05

描述与 schema:写给"只看说明书的员工"

🤔 痛点为什么我的工具 Agent 老是不用、或者用错?90% 是描述和 schema 没写好——LLM 完全靠这两样判断"要不要用、怎么用"。

回顾 Day 27 的 _generate_descriptionbase_tool.py:482),它把你的 schema 拼进最终描述:

# base_tool.py:482
def _generate_description(self) -> None:
    schema = generate_model_description(self.args_schema)
    args_json = json.dumps(schema["json_schema"]["schema"], indent=2)
    self.description = (
        f"Tool Name: {sanitize_tool_name(self.name)}\n"
        f"Tool Arguments: {args_json}\n"       # 你 Field 里的 description 会出现在这
        f"Tool Description: {self.description}")
description 写"何时用"不要只写"这是一个搜索工具",要写"当需要查询实时/最新信息时用这个搜索工具"。给模型判断"该不该用"的钩子。
每个字段都写 Field description因为它们会被拼进最终描述(上面代码)。写清参数含义、格式、示例,如 Field(..., description="城市名,如'上海'")
类型标注要精确Day 27 讲过:没标注 = Any = 无约束 = 模型乱传。该 intint,该枚举就用 Literal
名字要可辨识D29 的 _select_tool 靠相似度匹配名字。名字太泛("tool1")或太像别的工具,会导致误匹配。
⚠️ 反模式:描述含糊 + 无字段说明 @tool 写成 def search(q): """搜索"""——docstring 只有"搜索"两个字、参数 q 无类型无说明。后果:模型不知道该在什么情况用它、不知道 q 该传什么格式,于是要么不用,要么传一坨乱七八糟的东西触发 D29 的一路修复重试(费钱)。好的写法:docstring 写清用途和时机,参数有类型标注,复杂参数用独立 Schema 加 Field(description=...)描述是工具的"用户界面",值得花时间打磨。
L06

三个开关:result_as_answer / max_usage_count / cache_function

Day 27 见过的三个字段,这里讲实战怎么用(base_tool.py:176):

# base_tool.py:176(回顾三个开关)
cache_function: SerializableCallable = Field(default=_default_cache_function, ...)
result_as_answer: bool = Field(default=False, ...)
max_usage_count: int | None = Field(default=None, ...)

# 实战:一个"生成最终报告"的工具,结果就是交付物
@tool(result_as_answer=True)          # ★一返回就当最终答案,Agent 不再多想
def write_report(findings: str) -> str:
    """把调研发现整理成最终报告"""
    return format_report(findings)

# 实战:一个昂贵的付费搜索,限制最多用 3 次
@tool(max_usage_count=3)              # ★超 3 次返回"用到上限",逼模型收敛
def premium_search(query: str) -> str:
    """付费深度搜索(调用受限)"""
    return paid_api(query)

# 实战:实时数据工具,关掉缓存
weather_tool.cache_function = lambda args, result: False   # ★永不缓存,避免过期
result_as_answer=True适合"最终交付物"类工具(生成报告、写文件、给结论)。设了它,工具结果直接结束本 Agent 步(D30 L07、D08 都见过),省掉模型"再想一轮把结果复述一遍"。
max_usage_count给"昂贵/危险"的工具上闸。付费 API、发邮件、写数据库这类有副作用或花钱的工具,务必限次,防模型失控狂调。超限是返回提示不是崩(D27 L05)。
cache_function返回 True 缓存、False 不缓存。确定性结果(算数、格式转换)用默认 True;时效性结果(股价、天气、"当前时间")必须 False,否则返回过期值(D30 边界坑)。
组合使用三者可叠加。比如一个"生成报告"工具可能同时 result_as_answer=True + cache_function=False(每次内容不同不该缓存)。
💡 设计取舍②:默认值的哲学——"安全的懒惰" 三个开关的默认值很有讲究:result_as_answer=False(默认不抢答,让模型主导流程)、max_usage_count=None(默认不限次,别给用户添堵)、cache_function=返回True(默认缓存,普遍省钱)。默认值都选"对大多数工具无害且有益"的那个,让不懂这些开关的新手也能写出能用的工具(安全的懒惰);而进阶用户按需覆盖。好框架的默认值应该让"什么都不配"也是对的。但这也意味着——时效性工具的作者必须主动关缓存,因为默认帮不了他。
L07

生态互通:from_langchain 与 to_langchain

已有 langchain 工具不用重写,from_langchainbase_tool.py:410)能转进来:

# base_tool.py:410
@classmethod
def from_langchain(cls, tool: Any) -> BaseTool:
    if not hasattr(tool, "func") or not callable(tool.func):
        raise ValueError("The provided tool must have a callable 'func' attribute.")
    args_schema = getattr(tool, "args_schema", None)
    result_schema = getattr(tool, "result_schema", None)
    if result_schema is None:
        result_schema = _infer_result_schema_from_callable(tool.func)
    if args_schema is None:                      # 没 schema → 从 func 签名推断
        func_signature = signature(tool.func)
        fields = {}
        for name, param in func_signature.parameters.items():
            ...                                  # 同 Day 27 的签名推断套路
        args_schema = create_model(f"{sanitize_tool_name(tool.name)}_input", **fields)
    return cls(name=getattr(tool, "name", "Unnamed Tool"),
               description=getattr(tool, "description", ""),
               func=tool.func, args_schema=args_schema, result_schema=result_schema)

反向,把一批工具统一成执行态用 to_langchainbase_tool.py:641):

# base_tool.py:641
def to_langchain(tools: list[BaseTool | CrewStructuredTool]) -> list[CrewStructuredTool]:
    return [t.to_structured_tool() if isinstance(t, BaseTool) else t for t in tools]
鸭子类型检查不 import langchain、不判类型,只检查"有没有可调用的 func 属性"。有就当它是能转的工具——松耦合,不硬依赖 langchain。
schema 缺失就推断langchain 工具没带 schema 时,走 Day 27 同款的"从签名造 schema"逻辑。保证转进来后参数照样能校验。
to_langchain 归一一行列表推导:BaseTool 转执行态,已是执行态的原样放行。这就是执行循环拿到"混合工具列表"时的归一动作(呼应 D28)。
互通的意义langchain 生态有海量现成工具。能一键接入,等于站在巨人肩上——不用为每个工具重写。
L08

反模式 · 检查清单 · 阶段小结

⚠️ 五个高频反模式_run 里抛异常而不返回文字——一崩整个 Agent 步就废了,且模型没机会纠错。永远 return "错误说明"(学 ReadFileTool)。
描述含糊、字段无说明——模型不会用或用错(L05)。
时效工具用默认缓存——返回过期数据(D30 边界)。
昂贵/有副作用工具不设 max_usage_count——模型可能狂调烧钱或误操作。
返回超长原始数据(比如把整个网页 HTML 塞回去)——撑爆上下文窗口(Day 08 会触发压缩),该在工具里先摘要/裁剪再返回。

✅ 写工具上线前检查清单

  • □ description 写清了"什么时候用我",不只是"我是什么"?
  • □ 每个参数都有类型标注和 Field(description=...)
  • □ 所有异常/边界情况都 return 描述性文字,没有裸抛异常?
  • □ 时效性数据关了缓存(cache_function=lambda a,r: False)?
  • □ 昂贵/有副作用的工具设了 max_usage_count
  • □ 最终交付物类工具考虑 result_as_answer=True
  • □ 返回内容对 LLM 友好(不过长、格式清晰)?
  • □ 有状态才用路线B(继承),否则路线A(@tool)?

👶 小白:一句话总结,好工具的标准是什么?

👨‍🏫 老师:"让一个只看说明书、还会犯错的员工也能正确使用它。"——描述让他知道何时用、schema 让他知道传什么、友好的错误返回让他犯错也能自我纠正、开关让他不至于失控。你把这四件事做好,工具就稳了。源码里 ReadFileTool 就是活教材。

🧠 阶段5 工具系统 · 你已掌握

  • D27 BaseTool:工具的统一基类、字段、run 三关、@tool、用量锁
  • D28 CrewStructuredTool:执行态、参数解析、同步/异步、输出格式化
  • D29 工具调用:ToolCalling、三级降级、四招修 JSON、模糊匹配
  • D30 缓存:ToolsHandler/CacheHandler、读写锁、cache_function、防重复
  • D31 MCP:三种传输、发现包装、每次新连接、超时重试、过滤清理
  • D32 实践:两条路线、真实范本、描述/schema/开关、生态互通、反模式

✋ 10 分钟动手

P=lib/crewai/src/crewai/tools
sed -n '18,101p' $P/agent_tools/read_file_tool.py   # 有状态工具范本
sed -n '9,43p'   $P/agent_tools/add_image_tool.py    # 结构化返回范本
sed -n '410,456p' $P/base_tool.py                    # from_langchain
# 挑战:给下面这个工具补全"最佳实践"(描述、字段说明、错误返回、关缓存)
python -c "
from crewai.tools import tool
@tool
def now(tz: str='UTC') -> str:
    '''返回指定时区的当前时间。tz 为时区名,如 UTC/Asia/Shanghai。'''
    import datetime; return str(datetime.datetime.now())
now.cache_function = lambda a,r: False   # 时间是时效数据 → 关缓存
print(now.description); print(now.run(tz='UTC'))
"
明日预告 · Day 33(阶段6 开启):工具让 Agent"能干活",但它干完就忘。记忆让 Agent"记得住"。明天进入阶段6,从 memory/ 目录总览开始:短期/长期/实体记忆的分工、记忆如何在 kickoff 里被读写、和今天的工具缓存有什么本质区别。
← Day 31 MCP 工具 Day 33 · 记忆总览 →