Tool Calling 的实现细节——Agent 如何决定调用哪个工具
一、一个让我意识到问题所在的测试案例
上篇博客写完之后,我以为 GraphRAG 检索模块接入 Agent 的工作已经基本完成了——工具封装成了@tool,传参是list[str],返回的是序列化后的自然语言描述。逻辑上没有任何问题。
然后跑了一个测试:
用户输入:"我妈最近在吃华法林,昨天感冒了,我给她买了点芬必得,能一起吃吗?"
期望行为:Agent 提取出["华法林", "布洛芬"](芬必得应归一化为布洛芬),调用query_drug_graph,图谱返回 HIGH 级冲突,输出阻断结论。
实际发生的:Agent 在 Thought 阶段写了"我需要查询华法林和芬必得之间的冲突关系",然后触发了工具调用——但传入的参数是["华法林", "芬必得"],而不是["华法林", "布洛芬"]。
"芬必得"在图谱的别名索引里是有的,所以这次查询侥幸成功了。但这暴露了一个我没预料到的问题:工具调用的参数内容,完全由 LLM 在 Thought 阶段决定,而 LLM 的决策受工具描述、System Prompt 和上下文共同影响。任何一个环节写得不够严格,传进来的参数都可能是错的。
在普通的 QA 应用里,工具调用参数错了,最多是回答质量差一点。但在用药安全场景里,工具调用漏传了"妊娠期"这个状态标签,系统就会漏掉"妊娠期禁用布洛芬"这条规则——这不是体验问题,是安全问题。
二、Tool Calling 在 ReAct 里的角色
先理一下 Tool Calling 在我们系统里的具体位置。
ReAct 的每一轮循环是这样的:
Thought: [LLM 写出推理过程,决定下一步做什么] Action: [选择调用哪个工具,生成调用参数] Observation: [工具执行,返回结果] Thought: [基于结果决定是否继续调用工具,或输出最终结论]Tool Calling 发生在 Action 这一步。LLM 要做两个决策:
- 要不要调用工具(还是直接回答)
- 调用哪个工具,传什么参数
这两个决策都是由 LLM 根据上下文"推理"出来的,不是硬编码的。这就意味着它们都可能出错。
在目前的系统里只有一个工具——query_drug_graph,所以"调用哪个工具"这个问题暂时不存在,但"传什么参数"的问题非常真实。
三、工具描述(Docstring)是工具调用稳定性的第一道门
LangChain 的@tool装饰器会把函数的 docstring 作为工具描述暴露给 LLM。LLM 读这个描述来判断:这个工具适合解决什么问题、什么时候应该调用它、参数应该传什么。
我第一版的工具描述是这样的:
@tool def query_drug_graph(entities: list[str]) -> str: """ 查询医药知识图谱,检测给定药物之间是否存在冲突。 参数: entities: 需要检测的药物列表,例如 ["布洛芬", "华法林"] """这个描述有两个问题:
问题一:只说了"药物",没提患者状态和食物。LLM 读到"药物列表",就只会传药物,把"妊娠期"这样的患者状态标签完全漏掉。我在博客二里已经提到这个坑,但当时以为在 System Prompt 里加一条提醒就够了——实测下来不够。工具描述本身不说明,LLM 在 Thought 阶段就不会把状态标签纳入"该传什么"的考量。
问题二:示例参数太简单,没有覆盖患者状态的格式。["布洛芬", "华法林"]这个例子暗示了"只传药物名"的使用方式,强化了错误行为。
修改后的工具描述:
@tool def query_drug_graph(entities: list[str]) -> str: """ 查询医药知识图谱,检测给定实体(包括药物、患者状态标签、食物)之间是否存在 配伍禁忌、用药禁忌或药食冲突。 重要:必须同时传入以下三类已识别实体,不得遗漏: 1. 药物名称(通用名或已归一化的商品名),例如:"布洛芬"、"华法林" 2. 患者状态标签(从标准标签集合中选取),例如:"妊娠期"、"胃溃疡"、"肾功能不全" 3. 患者提到的相关食物,例如:"西柚" 遗漏患者状态标签会导致系统漏报人群禁忌,在医疗场景下属于严重错误。 参数: entities: 实体列表,例如 ["布洛芬", "华法林", "妊娠期", "西柚"] 返回: 结构化的冲突检测结果,包含风险等级、机制描述和处置建议 """ result = search_conflicts(entities) return serialize_for_llm(result)改完之后重跑之前那个测试用例,这次 Thought 阶段的推理变成了:"用户描述了患者在服用华法林,现在想加用芬必得(布洛芬)。我需要查询华法林和布洛芬之间的冲突,同时注意患者目前没有提到特殊状态标签……"
工具参数传入了["华法林", "布洛芬"](归一化正确),图谱返回了 HIGH 级 DDI 冲突,输出了阻断结论。这才是预期行为。
一个经验:工具描述里的措辞不是文档,是对 LLM 的指令。写工具描述要像写 System Prompt 一样认真,而不是像写注释一样随意。
四、System Prompt 的约束层——工具描述的兜底
工具描述能解决大部分问题,但不能解决所有问题。有些边界情况需要在 System Prompt 里做更细粒度的约束。
我目前 System Prompt 里与 Tool Calling 相关的部分是这样的:
## 工具调用规范 你被赋予了 query_drug_graph 工具,用于查询药物、患者状态和食物之间的冲突关系。 你必须严格遵守以下规则: ### 何时调用工具 - 用户提及任意药物名称时,必须调用 query_drug_graph,不得跳过 - 即使你认为自己"知道"答案,也必须通过工具调用来获取图谱的确定性依据 - 禁止仅凭训练数据中的药理知识直接回答用药安全问题 ### 传参规范 1. 药物名称必须使用通用名或已知商品名,禁止使用"某某类药物"等泛指描述 - 正确:["布洛芬", "华法林"] - 错误:["非甾体抗炎药", "抗凝药"] 2. 患者状态标签必须从标准集合中选取(见下方列表),不得自造标签 3. 若患者描述中有食物信息(如"吃了西柚"),必须一并传入 4. 若某实体无法归一化为标准名称,使用占位符 __UNRECOGNIZED__:{原始描述} ### 标准患者状态标签集合 胃溃疡 / 妊娠期 / 哺乳期 / 肾功能不全 / 肝功能不全 老年患者 / 儿童 / 磺胺类过敏 / 青霉素过敏 ### 工具调用失败时的处理 若工具返回报错或空结果,不得自行推断,必须输出: action_type: FALLBACK,并标注"图谱查询异常,建议咨询专业医师" ### 禁止行为 - 禁止在未调用工具的情况下输出任何用药建议或冲突判断 - 禁止将工具返回的"未发现冲突"解读为"用药安全"(图谱覆盖范围有限)这里有几个设计细节值得说明:
为什么要禁止"不调工具直接回答"?LLM 训练数据里有大量药理知识,它完全有能力直接回答"布洛芬和华法林有冲突"。但这违反了我们的核心架构原则——用药安全判断必须来自知识图谱,不能来自 LLM 的参数记忆。所以必须在 Prompt 层面明确禁止。
__UNRECOGNIZED__占位符是怎么回事?如果 LLM 碰到一个它无法归一化的药名(比如某个不常见的进口药),我需要它把这个信息传回来,而不是静默地丢弃。占位符格式让后续逻辑可以解析出哪些实体是未识别的,然后在unrecognized字段里返回给前端。
"未发现冲突 ≠ 用药安全"这条为什么要写进去?因为图谱覆盖范围有限,返回"未发现冲突"只意味着"图谱里没有记录这个冲突",不代表真的没有冲突。如果 LLM 把这两个意思等同,就会给出错误的安全保证。这条约束迫使 LLM 在输出"未发现冲突"时,措辞是"根据当前知识库未发现已知冲突",而不是"可以放心服用"。
五、调试 ReAct 循环:三类需要特别处理的失败模式
失败模式一:参数类型正确但语义错误
现象:工具调用触发了,参数格式也是list[str],但内容是泛化的类型描述而不是具体实体名。
复现用例:用户输入"我在吃降压药和血液稀释剂,可以一起用吗?"
实际参数:["降压药", "血液稀释剂"]
期望参数:这种情况 LLM 应该识别出信息不足,触发追问,而不是把"降压药"当成实体传入图谱(图谱里当然查不到这个词)。
根因:工具描述里没有明确说明"泛指描述"和"具体实体名"的区别,LLM 在信息不足时倾向于"尝试调用工具"而不是"先追问"。
解决方案:在 System Prompt 里加了这条:
若用户描述的药物是泛指类型(如"降压药"、"消炎药"、"血液稀释剂")而非具体药名, 禁止直接调用工具。应先输出追问: "请问具体是哪种[药物类型]?例如,常见的[药物类型]包括……"加了这条约束之后,LLM 在遇到"降压药 + 血液稀释剂"这类输入时,会先输出追问而不是尝试查图谱。
失败模式二:应该调工具但没调
现象:用户明确提到了药物,但 LLM 直接在 Thought 之后给出了结论,跳过了 Action 步骤。
复现用例:用户输入"感冒了,吃点泰诺行吗?"(只有一种药,没有明显的配伍冲突场景)
LLM 推理:"用户只提到了一种药物,未与其他药物联用,因此无需检索冲突……"然后直接给出了"泰诺是常用感冒药,正常剂量下通常是安全的"之类的回答。
问题:这违反了"任何药物查询都必须走图谱"的规则。用户没提其他药,但可能同时在吃其他药;用户没提患者状态,但可能有胃溃疡或肝病。跳过图谱查询就意味着跳过了这些隐患的检测。
解决方案:在 System Prompt 里把触发条件写得更宽:
只要用户输入中包含任意药物名称(包括仅提及单一药物的情况), 必须调用 query_drug_graph,同时传入已知的患者状态信息。 若用户未提供患者状态,仍需调用工具(传入已知药物), 并在结论中注明"未获得患者完整信息,以下判断基于有限数据"。失败模式三:循环次数失控
现象:Agent 在 Thought 阶段反复判断"信息还不够充分",触发多次工具调用,每次传入的参数有细微差异,最终延迟暴增,有时甚至超过前端超时限制。
复现用例:用户输入"我在吃好几种药,感觉有点不放心"(没有具体药名)。
LLM 行为序列:
Thought: 用户提到在吃多种药,但未说明具体药名,我需要追问 Action: [触发追问,但因输出格式问题追问没有正常传给前端] Thought: 没有收到用户回复,或许我可以先查询常见药物组合... Action: query_drug_graph(["某药A", "某药B"]) # 凭空猜测 Thought: 结果不够确定,再查一次...根因:追问失败(输出格式问题)导致 Agent 陷入了一个"信息不足 → 尝试行动 → 结果不满足 → 再次行动"的死循环。
解决方案有两层:
一是工程层面加硬限制——在 LangChain 的 Agent 配置里设置max_iterations=5,超过就强制终止并返回 FALLBACK 状态。
agent_executor = AgentExecutor( agent=agent, tools=tools, max_iterations=5, # 硬限制循环次数 max_execution_time=25, # 秒级超时(留出前端 30s timeout 的余量) handle_parsing_errors=True, # 输出格式解析失败时不崩溃 verbose=True # 开发阶段开启,生产阶段关闭 )六、一个有意思的发现:工具调用的"心理学"
在做了大量测试之后,我注意到一个有意思的规律:
LLM 在 Thought 阶段的措辞,能预测它即将调用工具的参数质量。
如果 Thought 里写的是:
"患者目前服用华法林,现在想加用布洛芬(芬必得),我需要查询这两种药物之间的相互作用, 同时需要考虑患者是否有特殊状态……"那么接下来的工具参数通常是准确的。
如果 Thought 里写的是:
"用户在问用药安全问题,我来查一下……"那么接下来的参数很可能是泛化的、不完整的。
这个规律让我想到一个调试思路:在开发阶段,可以把verbose=True,然后用 Thought 的质量来评估 System Prompt 的有效性——不用等到工具调用完成,看 Thought 阶段 LLM 的推理是否"抓住了问题的关键",就能大致判断这次工具调用会不会出问题。
这也验证了 ReAct 范式的一个优势:显式的推理链不只是帮助 LLM 思考,也帮助开发者调试。
