一、一个让我意识到问题所在的测试案例

上篇博客写完之后,我以为 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 要做两个决策:

  1. 要不要调用工具(还是直接回答)
  2. 调用哪个工具,传什么参数

这两个决策都是由 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 思考,也帮助开发者调试。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐