工具文档是写给 LLM 的,不是写给人的
你有没有写过这样的工具文档:
1@lc_tool 2def get_data(query: str) -> str: 3 """Get data.""" 4 ... 5
这对人类来说是糟糕的文档,对 LLM 来说更糟——它不知道这个工具做什么、什么时候调它、传什么参数。
工具设计有三条核心维度:描述质量(LLM 选不选你)、错误处理(出错时崩不崩)、粒度设计(参数好不好提取)。本文用实验数据说话。
Demo 1:描述质量——真正影响工具选择的条件
对比同一个天气工具的两个版本:
1# 版本 A:模糊 2@lc_tool 3def weather_vague(city: str) -> str: 4 """Get data.""" 5 ... 6 7# 版本 B:精准 8@lc_tool 9def weather_precise(city: str) -> str: 10 """Get current weather for a city. 11 12 Returns temperature (Celsius) and condition (sunny / cloudy / rainy / unknown). 13 Use this whenever the user asks about weather, temperature, or sky conditions 14 for a specific city. Pass the city name as a plain string, e.g. 'Beijing'. 15 """ 16 ... 17
用 5 条天气查询对比两个版本的工具调用率:
1Query Vague Precise 2------------------------------------------------ ---------- ---------- 3What's the weather in Beijing today? ✓ called ✓ called 4Is it raining in Shanghai right now? ✓ called ✓ called 5What temperature should I expect in Shenzhen? ✓ called ✓ called 6Should I bring an umbrella to Beijing? ✓ called ✓ called 7How's the sky in Shanghai? ✓ called ✓ called 8 9Tool call rate — Vague: 5/5 Precise: 5/5 10
两者都是 5/5。
这是一个反直觉的结果,背后有重要的前提条件:当 Agent 只有一个工具时,LLM 别无选择,无论描述多糟糕都会用它。 描述质量的差距只在 LLM 需要从多个工具中选择时才显现——这才是生产系统的常态。
一个 Agent 挂了 10 个工具,用户问"帮我查一下北京的天气",LLM 要在 10 个 docstring 里找出谁最匹配。此时精准描述的工具胜率远高于模糊工具。
描述质量的黄金格式:
1"""<一句话说做什么> 2 3返回:<返回值的格式和含义> 4使用时机:<什么类型的用户问题应该触发这个工具> 5参数说明:<参数名 + 传入格式示例> 6""" 7
Demo 2:错误处理——raise 还是 return?
两个版本的工具,逻辑相同,出错行为不同:
1# 抛出异常 ← 危险 2@lc_tool 3def weather_raises(city: str) -> str: 4 """Get current weather for a city.""" 5 if city.lower() not in MOCK_WEATHER: 6 raise ValueError(f"City '{city}' not found in database.") 7 ... 8 9# 返回错误字符串 ← 安全 10@lc_tool 11def weather_returns_error(city: str) -> str: 12 """Get current weather for a city. Returns error message if city not found.""" 13 data = MOCK_WEATHER.get(city.lower()) 14 if data is None: 15 return (f"City '{city}' not found. " 16 f"Available cities: {list(MOCK_WEATHER.keys())}. " 17 f"Please ask the user to confirm the city name.") 18 ... 19
三个测试用例:
已知城市(Beijing): 两者结果相同,正常返回天气。
未知城市(Atlantis):
1raises : [CRASHED] ValueError: City 'Atlantis' not found in database. 2returns: I'm sorry, but I couldn't find the weather information for Atlantis. 3 Please make sure the city name is correct... 4
weather_raises 直接崩溃,整个 Agent run 终止;weather_returns_error 的 LLM 读到错误字符串,组织了一条友好的回复。
拼写错误(Shanghia):
1raises : The current weather in Shanghai is cloudy with a temperature of 22°C. 2returns: The current weather in Shanghai is 22°C with a cloudy condition. 3
两者都正确——因为 LLM 在调工具之前就把 "Shanghia" 自动纠正成了 "Shanghai",工具接收到的是正确城市名。这说明 LLM 有一定的输入自愈能力,但不能依赖它。
结论:工具只应返回字符串,永远不抛出异常。 异常会跳出 Agent 的控制流,LLM 没有机会处理它。错误字符串则可以被 LLM 读取、理解、然后决定下一步(重试、告知用户、换一个工具)。
Demo 3:粒度设计——胖工具 vs 细粒度工具
胖工具: 一个工具包办所有事,传入自由文本。
1@lc_tool 2def omnibus_lookup(query: str) -> str: 3 """Look up weather, product info, or evaluate math. Pass the full user question.""" 4 q = query.lower() 5 for city in MOCK_WEATHER: 6 if city in q: 7 return json.dumps(MOCK_WEATHER[city]) 8 for name in MOCK_PRODUCTS: 9 if name in q: 10 return json.dumps(MOCK_PRODUCTS[name]) 11 # try math... 12
细粒度工具: 三个独立工具,各有精准类型参数。
四个测试用例的对比结果:
单步查询(天气、产品): 两者都能完成任务,差异不明显。
多步 — 天气 + 计算差值:
1Fat tools=['omnibus_lookup', 'omnibus_lookup'] 2 → The temperature difference is 3 degrees Celsius. 3 4Fine tools=['get_weather', 'get_weather', 'calculator'] 5 → The difference is 3°C. (3 separate calls) 6
Fat tool 调了两次,每次查一个城市,自己没法算差;Fine 工具调了两次天气 + 一次 calculator,明确分工。
多步 — 产品价格 + 年费计算:
1Fat tools=['omnibus_lookup'] (只调一次!) 2 → The monthly price is $299. The annual cost is $3588. 3 4Fine tools=['get_product_info', 'calculator'] (两次) 5 → The monthly price is $299. The annual cost is $3588. 6
这是最有趣的结果:Fat tool 只调了一次就答对了。因为 omnibus 工具内部发现价格是 299,LLM 在后续回答里直接做了 299×12 的心算,没有触发工具里的数学逻辑。
这说明 Fat 工具并不总是更差——但它的执行路径不透明,不可追踪,无法测试,不可维护。
何时用细粒度,何时允许合并:
1选细粒度: 2 - 多个工具会被不同查询分别触发 3 - 工具参数有明确的结构化类型(city: str, amount: float) 4 - 需要可观测性(每个工具单独计时、记录入参) 5 6允许合并: 7 - 两个操作总是一起出现,从不单独使用 8 - 合并后参数仍然是结构化的(不是自由文本) 9 - 例如:get_weather_and_unit(city, unit: Literal["C","F"]) 10
绝对不合并的情况: 合并后参数退化为自由文本 query: str——这把参数提取的负担推给了工具内部的文本解析,比让 LLM 提取结构化参数更脆。
五条工具设计黄金规则
1原则 错误示范 正确示范 2────────────────────────────────────────────────────────────────────── 3描述 "Get data." What + When + How + param format 4错误处理 raise ValueError(...) return "Error: city not found" 5粒度 omnibus(query: str) get_weather(city: str) 6参数命名 lookup(q: str) get_weather(city: str) 7返回格式 raw dict / None JSON string or error string 8
黄金规则:为 LLM 设计工具,不是为人类。
LLM 通过三个信息决定如何调工具:
- docstring:决定选不选这个工具
- 参数类型和名称:决定传什么值
- 返回值:决定下一步怎么做
这三个信息设计好,工具自然被正确使用。
设计 Checklist
Docstring
- 第一句话说清楚工具做什么(动词开头)
- 说明返回值格式(JSON / 纯文本 / 错误字符串)
- 说明使用时机("when the user asks about...")
- 给出参数示例(
e.g. 'Beijing',e.g. '299 * 12')
错误处理
- 工具只
return,永远不raise - 错误消息要有行动指南("city not found. Available: [...]")
- 区分"数据不存在"和"输入格式错误",给出不同提示
粒度
- 参数是结构化类型(
strwith clear semantics,int,float),不是自由文本 - 一个工具只做一件事——如果描述需要"以及"或"或者",考虑拆分
- 多个工具之间互斥:不同查询触发不同工具
返回格式
- 成功:JSON 字符串(方便 LLM 解析字段)
- 失败:
"Error: <原因>. <建议操作>"格式 - 不返回 None 或空字符串——LLM 不知道怎么处理空值
总结
五个核心结论:
- 描述质量的战场是多工具竞争:单工具时 LLM 别无选择,多工具时好 docstring 的工具胜率显著更高
- 工具只返回字符串,永远不抛异常:raise 让 Agent 崩溃,return 错误字符串让 LLM 有机会恢复
- LLM 有输入自愈能力,但不可依赖:"Shanghia" 被自动纠正为 "Shanghai",但这不是可靠的防线
- Fat 工具并不总是更差,但不可追踪:实测 omnibus 工具在某些场景只需一次调用就完成任务,但代价是执行路径不透明
- 参数类型决定参数质量:
city: str(明确语义)>q: str(自由文本)——参数类型越清晰,LLM 提取值越准确
下一篇:Agent 上下文工程进阶 —— 如何精确控制传给 LLM 的信息:系统提示词优化、few-shot 示例选择、动态上下文注入。
参考资料
- LangChain Tools 文档
- LangGraph ReAct Agent
- 本系列完整 Demo 代码:agent-15-tool-design
欢迎访问 PrimeSkills —— 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。
更多实用知识和有趣产品,欢迎访问我的个人主页
