Day 11 / 共 60 天 · 阶段2 Agent 深入

tools_handler 与缓存:让"同样的工具调用"不白花第二次钱

前几天 tools_handler 和"工具缓存"反复出现,今天专门拆它。agents/tools_handler.py 只有 50 行,配合 agents/cache/cache_handler.py 的读写锁缓存,构成一套"同工具 + 同输入 → 直接返回上次结果"的机制。看懂它,你就理解了 Day 07 那个 cache=True 到底在缓存什么、怎么保证线程安全、以及 cache_function 怎么让你精细控制"哪次该缓、哪次别缓"。

📍 你在 60 天里的位置(阶段2 Agent 深入 · 共 6 天)
阶段1 入门 D01-06 D07 全字段 D08 执行循环 D09 输出解析 D10 单步执行 D11 工具缓存 D12 LiteAgent 阶段3 Task
💡 先用一个类比兜住今天 工具缓存就像办公室里一本"常见问题速查本"。有人问"3 号楼 WiFi 密码多少",前台第一次真的跑去问了 IT,把答案记在速查本上;下次再有人问同样的问题,前台翻速查本直接答,不用再跑一趟。ToolsHandler 就是这个"前台 + 速查本"的组合:工具跑完,它把 问题(工具名+输入) → 答案(输出) 记进本子;下次遇到一模一样的问题,直接翻本子。省下的就是一次 API 调用的钱和时间。今天就看这本速查本怎么写、怎么查、怎么保证多人同时用不打架。
L01

痛点:Agent 反复调同一个工具,钱和时间都在漏

🤔 痛点ReAct 循环里 Agent 会反复调工具。想象一个循环转了 10 圈,其中 3 圈都调了 search("CrewAI 是什么")——同样的工具、同样的输入,结果必然一样,可每次都真去执行:如果是收费 API,就是白花 3 次钱;如果是慢查询,就是白等 3 次。更别说多个 Agent、多个任务里的重复调用。有没有办法"同样的调用只真跑一次,之后直接取结果"?
💡 一句话本质 工具缓存 = 一个 {"工具名-输入": 输出} 的字典。执行工具先按这个 key 查字典:命中就直接返回旧结果(不执行);没命中才真执行,再把结果写进字典。ToolsHandler 负责"写",CacheHandler 是那个字典本身(还带线程安全的读写锁)。一句话:拿"工具名+输入"当钥匙,缓存输出。

先看 ToolsHandler 的字段(tools_handler.py:15),麻雀虽小:

# tools_handler.py:15
class ToolsHandler(BaseModel):
    """Callback handler for tool usage."""
    cache: CacheHandler | None = Field(default=None)             # 那本"速查本"
    last_used_tool: ToolCalling | InstructorToolCalling | None = Field(default=None)  # 最近一次调用
大白话ToolsHandler 就两个属性:一个 cache(速查本,可能没有),一个 last_used_tool(记住"最近调的是哪个工具",用于训练/审计)。它自己不存数据,真正的字典在 CacheHandler 里。ToolsHandler 是"前台",CacheHandler 是"本子"。
L02

ToolCalling:一次工具调用长什么样

缓存的 key 从"一次调用"里提取,所以先看"一次调用"的数据结构(tools/tool_calling.py:11):

# tools/tool_calling.py:11
class ToolCalling(BaseModel):
    tool_name: str = Field(..., description="The name of the tool to be called.")
    arguments: dict[str, Any] | None = Field(
        ..., description="A dictionary of arguments to be passed to the tool.")

class InstructorToolCalling(PydanticBaseModel):
    tool_name: str = PydanticField(..., description="The name of the tool to be called.")
    arguments: dict[str, Any] | None = PydanticField(
        ..., description="A dictionary of arguments to be passed to the tool.")
tool_name调哪个工具。缓存 key 的前半段。
arguments (dict)传给工具的参数字典。缓存 key 的后半段——参数不同,就是不同的调用,不能共用缓存。
两个几乎一样的类ToolCallingInstructorToolCalling 结构相同,区别在由谁生成:一个是框架内部解析出的,一个是 instructor 库(结构化输出)生成的。缓存逻辑对两者一视同仁。
💡 为什么 key 要"工具名 + 参数"两部分?因为缓存的正确前提是"相同输入必然相同输出"。同一个工具,参数不同结果就不同(search("A")search("B") 显然不能共用结果)。所以 key 必须同时包含调哪个工具用什么参数——两者都一样,才算"同一次调用",才能复用结果。这是所有"记忆化(memoization)"缓存的通用规则:key = 所有影响输出的输入。
L03

on_tool_use:工具跑完,把结果记进速查本

ToolsHandler 的核心方法 on_tool_usetools_handler.py:26),在工具执行完被调用:

# tools_handler.py:26
def on_tool_use(self, calling, output, should_cache: bool = True) -> None:
    """Run when tool ends running."""
    self.last_used_tool = calling                       # ① 记住最近这次调用
    if self.cache and should_cache and calling.tool_name != CacheTools().name:  # ② 三个条件都满足才缓存
        input_str = ""
        if calling.arguments:
            if isinstance(calling.arguments, dict):
                input_str = json.dumps(calling.arguments)   # ③ 参数字典 → JSON 字符串当 key
            else:
                input_str = str(calling.arguments)
        self.cache.add(tool=calling.tool_name, input=input_str, output=output)  # ④ 写进缓存
① last_used_tool = calling无论缓不缓存,都先记下"最近调的是这个"。训练模式、日志、审计会用到。
② 三重门★要写缓存必须同时:有 cache 本子 + 本次 should_cache 允许 + 调的不是缓存工具自己CacheTools,否则会套娃)。缺一不缓。
③ json.dumps(arguments)把参数字典序列化成 JSON 字符串,作为 key 的输入部分。字典没法直接当 key,转成字符串才行。
④ cache.add(工具名, 输入串, 输出) 交给 CacheHandler 存起来。真正的存储在下一讲。
⚠️ 边界:json.dumps 的 key 顺序问题 这里用 json.dumps(arguments) 生成 key。但字典 {"a":1,"b":2}{"b":2,"a":1} 语义完全相同,json.dumps 默认按插入顺序输出,可能得到不同字符串 '{"a":1,"b":2}' vs '{"b":2,"a":1}'——于是同一个调用被当成两次,缓存白建。更严谨的做法是 json.dumps(arguments, sort_keys=True) 把键排序后再当 key。这是缓存实现里非常常见的隐蔽坑:"逻辑相同的输入"必须映射到"完全相同的 key",否则缓存命中率会莫名很低。读源码时留意这类细节,能帮你在自己项目里避坑。
L04

CacheHandler:那本速查本,加了把读写锁

真正存数据的是 CacheHandlercache/cache_handler.py:10):

# cache/cache_handler.py:10
class CacheHandler(BaseModel):
    """Handles caching of tool execution results.
    Provides thread-safe in-memory caching ... Uses a read-write lock to allow
    concurrent reads while ensuring exclusive write access."""
    _cache: dict[str, Any] = PrivateAttr(default_factory=dict)   # 真正的字典
    _lock: RWLock = PrivateAttr(default_factory=RWLock)          # 读写锁

    def add(self, tool: str, input: str, output: Any) -> None:   # :23
        with self._lock.w_locked():                              # 写:独占锁
            self._cache[f"{tool}-{input}"] = output              # ★key = "工具名-输入串"

    def read(self, tool: str, input: str) -> Any | None:        # :37
        with self._lock.r_locked():                              # 读:共享锁
            return self._cache.get(f"{tool}-{input}")
_cache = dict就是一个普通字典,key 是 f"{tool}-{input}" 拼出来的字符串,value 是工具输出。PrivateAttr 表示它是私有的、不进 JSON。
key 用 "-" 拼接f"{tool}-{input}":工具名和输入串用连字符连起来当 key。极简。
add 用 w_locked(写锁)写入时独占——同一时刻只允许一个写,防止两个线程同时改字典导致数据损坏。
read 用 r_locked(读锁)★读取时共享——多个线程可以同时读,互不阻塞。只有"要写"时才需要排他。
数据结构:缓存字典 + 读写锁 _cache: dict "search-{\"q\":\"CrewAI\"}" → "CrewAI 是..." "add-{\"a\":2,\"b\":3}" → "5" key = f"{工具名}-{输入串}" 读锁 r_locked:多线程可同时读 ✓✓✓ read() 走这里 写锁 w_locked:同时只允许一个写 ✓ add() 走这里
图注:读多写少的场景,读写锁让"查缓存"高度并发,只有"写缓存"才短暂互斥。
💡 设计取舍①:为什么用读写锁(RWLock)而不是普通互斥锁(Lock)? 普通锁 Lock 简单粗暴:读也锁、写也锁,同一时刻只准一个线程碰缓存。但工具缓存是典型的"读多写少"——ReAct 循环里查缓存(读)远比写缓存频繁,多个并行工具(Day 08 的线程池)还会同时读。用普通锁的话,这些读会互相排队,白白串行化。读写锁允许"多个读并发、写时独占":读操作不互相阻塞,只有写来了才短暂排他。用一点实现复杂度,换来读密集场景的并发性能。代价是 RWLock 比 Lock 难写(要处理读写饥饿),但对高频缓存很值。
L05

执行前先查:缓存命中就不真跑工具

写缓存在 on_tool_use,那"读缓存"在哪触发?在工具真正执行前(tools/tool_usage.pytool_usage.py:276):

# tools/tool_usage.py:276
from_cache = False
result = None
try:
    if self.tools_handler and self.tools_handler.cache:      # 有缓存本子
        input_str = ""
        if calling.arguments:
            input_str = json.dumps(calling.arguments) if isinstance(calling.arguments, dict) \
                        else str(calling.arguments)
        result = self.tools_handler.cache.read(              # ★执行前先查缓存
            tool=sanitize_tool_name(calling.tool_name), input=input_str)
        from_cache = result is not None                      # 查到了就标记"来自缓存"
    ...
    elif result is None:                                     # ★只有没命中缓存才真执行
        ...
        result = await tool.ainvoke(input=arguments, config=fingerprint_config)
cache.read(...)★用和写入时一样的 key(工具名 + json 参数)去查。查到 → result 有值;查不到 → None
from_cache = result is not None标记这次结果是"缓存来的"还是"真跑的"。事件/日志里会体现,方便你观察缓存命中率。
elif result is None★关键分支:只有缓存没命中(result 还是 None)才走 tool.ainvoke 真正执行工具。命中的话直接用旧结果,跳过执行。
sanitize_tool_name读写两侧都对工具名做同样的规范化(去空格、统一大小写等),保证 key 一致——否则写入用原名、读取用规范名,永远命不中。
控制流:一次工具调用的缓存路径 要调工具 cache.read 查缓存 命中 → 直接返回旧结果 未命中 → 真执行工具 on_tool_use 把结果写回缓存 下次同样调用 → 查缓存直接命中,不再执行
图注:查在前、执行在后、写在末尾。第二次同样调用直接从"命中"分支返回,省下一次真执行。
L06

cache_function:让工具自己决定"这次要不要缓"

不是所有结果都该缓存。执行完,源码给工具一个"投票权"(tool_usage.py:351):

# tools/tool_usage.py:351
if self.tools_handler:
    should_cache = True                                   # 默认缓
    original_tool = getattr(available_tool, "_original_tool", None)
    cache_func = None
    if original_tool and hasattr(original_tool, "cache_function"):
        cache_func = original_tool.cache_function
    elif hasattr(available_tool, "cache_function"):
        cache_func = available_tool.cache_function
    if cache_func:
        should_cache = cache_func(calling.arguments, result)   # ★工具自定义:看参数和结果决定缓不缓
    self.tools_handler.on_tool_use(
        calling=calling, output=result, should_cache=should_cache)   # 把决定传给写缓存
should_cache 默认 True不特别设置的话,所有工具结果都缓存(呼应 Day 07 的 cache=True)。
cache_function(arguments, result)★工具可以定义一个 cache_function(参数, 结果) -> bool:框架把这次的参数和结果都传给它,让它自己判断"这次值不值得缓"。返回 True 才缓。
传给 on_tool_use投票结果 should_cache 传进 L03 的 on_tool_use——那里的"三重门"之一就是它。所以工具的意愿最终生效。
📝 真实值:什么时候 cache_function 派上用场 一个"查订单状态"的工具:如果订单已完成(状态不会再变),结果可以缓;如果订单还在处理中(状态随时变),就别缓。于是可以写:
def cache_function(args, result): return "completed" in result
—— 结果里含 "completed" 才缓存。同一个工具,根据具体的参数和结果,动态决定缓不缓,比"整个工具一刀切缓/不缓"精细得多。
💡 设计取舍②:为什么把"缓不缓"的决定权交给工具,而不是框架统一定? 框架无法知道某个工具的结果"能不能安全缓存"——那取决于工具的业务语义(查天气不能缓、算加法能缓、查已完成订单能缓、查处理中订单不能缓)。硬要框架统一决定,要么太激进(缓了时效数据出 bug),要么太保守(啥都不缓、缓存形同虚设)。所以源码把决策权下放给最懂业务的工具作者,用一个可选的 cache_function 回调,还额外把实际结果也传给它(而不只是参数)——因为有时"能不能缓"要看结果内容(如上面的订单例子)。"把领域决策交给领域专家、框架只提供机制",是可扩展框架的核心设计哲学。
L07

CacheTools 边界 + 今日小结

还有个有趣的角色 CacheToolstools/cache_tools/cache_tools.py)——把"读缓存"本身也包装成一个工具:

# tools/cache_tools/cache_tools.py
class CacheTools(BaseModel):
    name: str = "Hit Cache"
    cache_handler: CacheHandler = Field(default_factory=CacheHandler)
    def hit_cache(self, key: str) -> str | None:
        split = key.split("tool:")
        tool = split[1].split("|input:")[0].strip()
        tool_input = split[1].split("|input:")[1].strip()
        return self.cache_handler.read(tool, tool_input)
⚠️ 边界:为什么 on_tool_use 要排除 CacheTools 自己? 回看 L03 的三重门:calling.tool_name != CacheTools().name。为什么要特意不缓存"缓存工具"的调用?因为 CacheTools 的作用就是"去读缓存",它本身是缓存机制的一部分。如果连它的调用也缓存,就成了"缓存的缓存"——不仅无意义,还可能造成递归套娃或脏数据(缓存里存了一条"读缓存的结果",下次读缓存读到的是这条元数据而非真实工具结果)。基础设施组件要小心别把自己也卷进它管理的数据里——这是实现缓存/日志/代理这类"元层"组件时的经典陷阱。

👶 小白:这个缓存是永久的吗?程序重启还在吗?

👨‍🏫 老师:不是永久的。CacheHandler._cache 就是一个内存里的字典dict),程序一退出就没了,也不跨进程共享。它是"一次运行内"的加速,不是持久化存储。想跨运行、跨机器复用结果,那是阶段6"记忆与知识"要讲的持久化记忆/RAG 的活儿——工具缓存解决的是"这一次跑里别重复调",不是"永远记住"。两者层次不同,别混。

🧠 今天你应该能回答

  • 工具缓存的 key 是什么?(f"{工具名}-{输入串}")为什么要含参数?
  • ToolsHandlerCacheHandler 的分工?(前台 vs 本子)
  • 缓存"读"在何时触发?"写"在何时触发?
  • 为什么用读写锁而不是普通互斥锁?
  • cache_function 解决什么问题?为什么要把结果也传给它?
  • 为什么 on_tool_use 要排除 CacheTools 自己?
  • json.dumps 当 key 有什么隐蔽坑?(键顺序)
  • 这个缓存是持久的吗?和记忆/RAG 有何区别?

✋ 10 分钟动手

P=lib/crewai/src/crewai
cat -n            $P/agents/tools_handler.py          # ToolsHandler(50 行全看)
cat -n            $P/agents/cache/cache_handler.py    # CacheHandler + 读写锁
sed -n '276,366p' $P/tools/tool_usage.py              # 执行前查缓存 + cache_function
sed -n '1,60p'    $P/tools/cache_tools/cache_tools.py # CacheTools
# 亲手验证缓存命中
python -c "
from crewai.agents.cache.cache_handler import CacheHandler
c = CacheHandler()
c.add(tool='search', input='{\"q\":\"CrewAI\"}', output='一个多智能体框架')
print('命中:', c.read('search', '{\"q\":\"CrewAI\"}'))
print('未命中:', c.read('search', '{\"q\":\"别的\"}'))
"
明日预告 · Day 12:阶段2 收官。前 5 天讲的都是"重型" Agent(Agent + CrewAgentExecutor 那一套)。明天看 CrewAI 的轻量级 Agent——lite_agent.pyLiteAgent 和它的输出 lite_agent_output.py:为什么要有一个"精简版"、它的 kickoff 循环和 Day 08 有何异同、LiteAgentOutput 怎么统一封装 raw/pydantic/usage 结果,以及什么时候该用轻量版。
← Day 10 单步执行 Day 12 · LiteAgent →