tools_handler 与缓存:让"同样的工具调用"不白花第二次钱
前几天 tools_handler 和"工具缓存"反复出现,今天专门拆它。agents/tools_handler.py 只有 50 行,配合 agents/cache/cache_handler.py 的读写锁缓存,构成一套"同工具 + 同输入 → 直接返回上次结果"的机制。看懂它,你就理解了 Day 07 那个 cache=True 到底在缓存什么、怎么保证线程安全、以及 cache_function 怎么让你精细控制"哪次该缓、哪次别缓"。
ToolsHandler 就是这个"前台 + 速查本"的组合:工具跑完,它把 问题(工具名+输入) → 答案(输出) 记进本子;下次遇到一模一样的问题,直接翻本子。省下的就是一次 API 调用的钱和时间。今天就看这本速查本怎么写、怎么查、怎么保证多人同时用不打架。痛点:Agent 反复调同一个工具,钱和时间都在漏
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 是"本子"。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 的后半段——参数不同,就是不同的调用,不能共用缓存。两个几乎一样的类ToolCalling 和 InstructorToolCalling 结构相同,区别在由谁生成:一个是框架内部解析出的,一个是 instructor 库(结构化输出)生成的。缓存逻辑对两者一视同仁。search("A") 和 search("B") 显然不能共用结果)。所以 key 必须同时包含调哪个工具和用什么参数——两者都一样,才算"同一次调用",才能复用结果。这是所有"记忆化(memoization)"缓存的通用规则:key = 所有影响输出的输入。on_tool_use:工具跑完,把结果记进速查本
ToolsHandler 的核心方法 on_tool_use(tools_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(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",否则缓存命中率会莫名很低。读源码时留意这类细节,能帮你在自己项目里避坑。CacheHandler:那本速查本,加了把读写锁
真正存数据的是 CacheHandler(cache/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(读锁)★读取时共享——多个线程可以同时读,互不阻塞。只有"要写"时才需要排他。Lock 简单粗暴:读也锁、写也锁,同一时刻只准一个线程碰缓存。但工具缓存是典型的"读多写少"——ReAct 循环里查缓存(读)远比写缓存频繁,多个并行工具(Day 08 的线程池)还会同时读。用普通锁的话,这些读会互相排队,白白串行化。读写锁允许"多个读并发、写时独占":读操作不互相阻塞,只有写来了才短暂排他。用一点实现复杂度,换来读密集场景的并发性能。代价是 RWLock 比 Lock 难写(要处理读写饥饿),但对高频缓存很值。执行前先查:缓存命中就不真跑工具
写缓存在 on_tool_use,那"读缓存"在哪触发?在工具真正执行前(tools/tool_usage.py,tool_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_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——那里的"三重门"之一就是它。所以工具的意愿最终生效。def cache_function(args, result): return "completed" in result—— 结果里含 "completed" 才缓存。同一个工具,根据具体的参数和结果,动态决定缓不缓,比"整个工具一刀切缓/不缓"精细得多。
cache_function 回调,还额外把实际结果也传给它(而不只是参数)——因为有时"能不能缓"要看结果内容(如上面的订单例子)。"把领域决策交给领域专家、框架只提供机制",是可扩展框架的核心设计哲学。CacheTools 边界 + 今日小结
还有个有趣的角色 CacheTools(tools/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)
calling.tool_name != CacheTools().name。为什么要特意不缓存"缓存工具"的调用?因为 CacheTools 的作用就是"去读缓存",它本身是缓存机制的一部分。如果连它的调用也缓存,就成了"缓存的缓存"——不仅无意义,还可能造成递归套娃或脏数据(缓存里存了一条"读缓存的结果",下次读缓存读到的是这条元数据而非真实工具结果)。基础设施组件要小心别把自己也卷进它管理的数据里——这是实现缓存/日志/代理这类"元层"组件时的经典陷阱。👶 小白:这个缓存是永久的吗?程序重启还在吗?
👨🏫 老师:不是永久的。CacheHandler._cache 就是一个内存里的字典(dict),程序一退出就没了,也不跨进程共享。它是"一次运行内"的加速,不是持久化存储。想跨运行、跨机器复用结果,那是阶段6"记忆与知识"要讲的持久化记忆/RAG 的活儿——工具缓存解决的是"这一次跑里别重复调",不是"永远记住"。两者层次不同,别混。
🧠 今天你应该能回答
- 工具缓存的 key 是什么?(
f"{工具名}-{输入串}")为什么要含参数? ToolsHandler和CacheHandler的分工?(前台 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\":\"别的\"}'))
"
lite_agent.py 的 LiteAgent 和它的输出 lite_agent_output.py:为什么要有一个"精简版"、它的 kickoff 循环和 Day 08 有何异同、LiteAgentOutput 怎么统一封装 raw/pydantic/usage 结果,以及什么时候该用轻量版。