LangChain Agent 工具误调用优化:从“乱调用“到“准调用“

文章目录

    • [一、背景:Agent 为什么会"乱调用"工具](#一、背景:Agent 为什么会"乱调用"工具)
    • 二、四大类误调用场景与根因
      • [2.1 幻觉调用:不需要工具,却强行调用](#2.1 幻觉调用:不需要工具,却强行调用)
      • [2.2 选错工具:意图正确,工具挑错](#2.2 选错工具:意图正确,工具挑错)
      • [2.3 参数错误:工具选对,参数乱填](#2.3 参数错误:工具选对,参数乱填)
      • [2.4 循环调用:调用成功,却反复重试](#2.4 循环调用:调用成功,却反复重试)
    • 三、分层优化思路总览
    • 四、优化详解
      • [4.1 第一层:重写工具描述与系统提示词](#4.1 第一层:重写工具描述与系统提示词)
        • [4.1.1 为什么先做这一层?](#4.1.1 为什么先做这一层?)
        • [4.1.2 工具描述:只写职责边界,明确"什么时候不能调用"](#4.1.2 工具描述:只写职责边界,明确"什么时候不能调用")
        • [4.1.3 系统提示词:强制决策流程 + 禁止规则 + Few-shot](#4.1.3 系统提示词:强制决策流程 + 禁止规则 + Few-shot)
      • [4.2 第二层(最关键):代码层 Pydantic 参数校验](#4.2 第二层(最关键):代码层 Pydantic 参数校验)
        • [4.2.1 为什么这一层最关键?](#4.2.1 为什么这一层最关键?)
        • [4.2.2 核心思路](#4.2.2 核心思路)
        • [4.2.3 用 Pydantic 定义参数模型(自动校验类型、必填项、取值范围)](#4.2.3 用 Pydantic 定义参数模型(自动校验类型、必填项、取值范围))
        • [4.2.4 校验器核心函数](#4.2.4 校验器核心函数)
        • [4.2.5 把校验节点接入 LangGraph 图](#4.2.5 把校验节点接入 LangGraph 图)
        • [4.2.6 效果演示](#4.2.6 效果演示)
      • [4.3 第三层:前置意图路由,从源头拦截](#4.3 第三层:前置意图路由,从源头拦截)
        • [4.3.1 为什么需要这一层?](#4.3.1 为什么需要这一层?)
        • [4.3.2 实现:轻量意图分类器](#4.3.2 实现:轻量意图分类器)
        • [4.3.3 进阶:动态工具加载](#4.3.3 进阶:动态工具加载)
      • [4.4 第四层:工具返回结果校验](#4.4 第四层:工具返回结果校验)
        • [4.4.1 针对的"隐性误调用"](#4.4.1 针对的"隐性误调用")
        • [4.4.2 实现:工具执行后加校验节点](#4.4.2 实现:工具执行后加校验节点)
        • [4.4.3 结果校验节点接入 LangGraph](#4.4.3 结果校验节点接入 LangGraph)
        • [4.4.4 完整图流转链路](#4.4.4 完整图流转链路)
      • [4.5 第五层:循环调用保护](#4.5 第五层:循环调用保护)
        • [4.5.1 问题](#4.5.1 问题)
        • [4.5.2 实现:状态中增加调用计数器](#4.5.2 实现:状态中增加调用计数器)
    • 五、评测闭环:用数据驱动持续优化
      • [5.1 能力点:](#5.1 能力点:)
      • [5.2 完整代码](#5.2 完整代码)
      • [5.3 测试用例](#5.3 测试用例)
      • [5.5 预期结果](#5.5 预期结果)
      • [5.4 实际运行结果](#5.4 实际运行结果)
    • 六、总结与优先级建议

一、背景:Agent 为什么会"乱调用"工具

大型语言模型驱动的 Agent 本质上是文本生成器,不是逻辑引擎 。它决定"要不要调用工具、调用哪个工具、参数填什么",全靠它对提示词和工具描述的理解,而不是靠确定性的程序逻辑。LangChain 的框架层(尤其是 LangGraph)只会做一件事------尝试解析模型输出的工具调用结构(tool_name 和 tool_args),然后去执行,它不会校验这些调用在业务上是否正确。

这种机制决定了工具误调用几乎无法完全避免。只有真正理解了误调用的分类和根因,才能对症下药。

本文场景:一个企业级Agent,包含两个工具------query_company_doc(检索安规 PDF 文档)+ search_db(查询员工考试数据),模型为通义千问,框架为 LangChain + LangGraph。

二、四大类误调用场景与根因

2.1 幻觉调用:不需要工具,却强行调用

  • 现象:System Prompt 里塞了一堆工具定义,模型容易形成"所有问题都要查工具"的惯性;上下文一旦变长,模型判断力下降,把常识问题也当成需要外部查询。
  • 例子 :用户问"考试多少分及格?",这属于固定常识,本应直接回答 80 分及格 。但模型却调用了 query_company_doc 去检索文档,白白浪费一次调用,还增加延迟和成本。
  • 根因:1. 提示词约束不足,模型分不清 "可以直接回答" 和 "必须调用工具";2. 大模型训练数据里大量函数样本,容易产生函数名幻觉;3. 没有前置校验层,模型输出直接送入工具执行。

2.2 选错工具:意图正确,工具挑错

  • 现象:工具描述存在歧义,或工具数量过多导致职责边界模糊;模型分不清两个相似工具的边界。
  • 例子 :用户问"查询 2025 安规不合格人员名单",正确答案是用 search_db 查数据库(人员名单在库里)。但模型却调用了 query_company_doc(文档检索工具),因为"名单"这个词让它联想到了检索。结果是工具调用成功,却查不到任何东西。
  • 根因:1. 工具描述写得模糊,模型无法区分工具边界;2. 模型对任务意图理解偏差;3. 缺少路由校验,模型选完工具没有校验 "该工具是否匹配用户 query"。

2.3 参数错误:工具选对,参数乱填

  • 现象:模型会编造不存在的参数;用户信息不足时,模型不反问,而是"硬编"一个值填进去。
  • 例子 :用户问"帮我查小李的考试成绩",search_db 要求必填 staff_no(工号),用户只给了姓名"小李"。模型不追问工号,而是直接填 {"staff_no": "小李"} 去调用。这类错误是四类里出现频率最高的。
  • 根因:1. 工具 schema 描述不够清晰;2. 模型不擅长严格 JSON 格式输出;3. 缺少参数校验节点,没有在执行工具前做参数合法性检查。
  • 误区 :很多人以为 LangChain 的 tool_schema 会自动校验参数,其实它只是把参数结构描述给模型看,并不会在代码层做业务合法性校验。

2.4 循环调用:调用成功,却反复重试

  • 现象:工具返回结果不够清晰,或提示词缺少终止条件,模型误以为"没查到",于是反复调用同一个工具,直到框架最大轮次限制触发才停止。
  • 例子:查文档返回了片段,但模型认为信息不足,再次用相同关键词调用同一个工具,造成死循环。
  • 根因:1. 没有最大轮次限制;2. 缺少终止判断逻辑;3. 提示词没有明确 "拿到结果后直接回答,不要重复调用"。

三、分层优化思路总览

误调用问题无法靠单一手段根治,需要分层防护,每一层解决一类问题:

优化层 解决什么 成本 收益
① 提示词与工具描述 幻觉调用、选错工具 最低 高
② 代码层参数校验 参数错误 中 最高
③ 前置意图路由 从源头拦截不需要工具的请求 中 高
④ 工具返回结果校验 隐性误调用(调用成功但答非所问) 中高 高
⑤ 循环调用保护 死循环 低 高

核心原则 :不要把校验完全交给大模型自律。模型天生会编造参数、会惯性调用工具,所以参数校验、次数限制这类"确定性"的约束,必须下沉到代码层。


四、优化详解

本章是全文的核心,每一层都给出完整可运行的实现,包括坏示例 → 好示例 → 关键代码 → 注意事项。

4.1 第一层:重写工具描述与系统提示词

4.1.1 为什么先做这一层?

这一层成本最低、见效最快。模型对工具的"认知"完全来自工具描述和提示词,把它们写清楚,能从源头上减少幻觉调用和选错工具。

4.1.2 工具描述:只写职责边界,明确"什么时候不能调用"

工具描述不能只写"能做什么",还要写清"不能做什么"和"什么情况下不要调用"。

❌ 差的工具描述(太宽泛,模型分不清边界):

python 复制代码
Tool(
    name="query_company_doc",
    description="用于查询公司文档,可以搜索安规、两票相关内容",
    func=query_doc_func
)

✅ 好的工具描述(职责边界清晰,明确禁止项):

python 复制代码
@tool
def query_company_doc(keyword: str) -> str:
    """
    【仅当用户需要查阅安规、两票原文条款时调用】
    能力:检索静态PDF安规文档文本。
    不能做:无法查询员工名单、考试成绩、人员数据。
    当问题属于常识(如及格分数)不要调用此工具。
    """
    mock_data = {
        "工作票定义": "工作票是准许在电气设备及系统软件上工作的书面命令,也是执行保证安全技术措施的书面依据。"
    }
    return mock_data.get(keyword, f"文档未检索到关键词:{keyword}")

@tool
def search_db(staff_no: str, year: int | None = None) -> str:
    """
    【仅查询员工考试成绩、人员名单时调用】
    能力:查询数据库员工考试记录。
    不能做:不能查条款原文。
    必填参数:staff_no员工工号,不可填姓名。
    """
    mock_data = {
        "10001": "员工10001,2025考试成绩:86分,合格",
        "10002": "员工10002,2025考试成绩:72分,不合格"
    }
    return mock_data.get(staff_no, f"数据库未找到工号 {staff_no} 的记录")
4.1.3 系统提示词:强制决策流程 + 禁止规则 + Few-shot

仅仅"能用哪些工具"是不够的,必须给模型一套强制决策流程 和禁止规则,并用 Few-shot 样例固化正确行为。

text 复制代码
你是企业助手,严格遵守下面决策流程:
步骤1:判断问题是否可以仅凭已有知识回答。如果可以,直接输出答案,禁止调用任何工具。
步骤2:如果必须调用工具,从工具列表中选择唯一合适的工具。禁止多选工具。
步骤3:检查该工具所需全部必填参数。缺少必填参数,向用户提问收集信息,不要编造参数。
步骤4:确认参数符合工具要求。
步骤5:调用工具,拿到返回结果后判断信息是否充足。信息足够就回答用户,不要重复调用同一个工具。

禁止规则:
1. 禁止调用工具查询常识类固定知识。
2. 禁止编造工号、姓名等不存在参数。
3. 单次思考只能调用最多1个工具。
4. 如果工具返回结果为空,不要反复重试,告知用户未找到相关信息。

额外约束(适配通义千问):输出工具调用时,参数JSON严格单层,禁止多余转义、禁止嵌套对象。
如果信息不需要工具,禁止强行生成tool_calls。

Few-shot示例
示例1(无需工具)
用户:考试多少分及格?
思考:已有知识可以回答,不需要调用工具。

示例2(缺少参数)
用户:查小李的考试成绩
思考:search_db需要必填参数staff_no,用户没有提供工号,不能调用工具。向用户询问工号。

示例3(正确调用工具)
用户:查找工作票的定义原文
思考:需要查阅安规文档,调用query_company_doc,关键词:工作票定义

注意事项:Few-shot 至少覆盖三类------「无需工具」「参数缺失反问」「正确调用工具」。这三类分别对应幻觉调用、参数错误、正常场景,能直接压制最易发的两类误调用。


4.2 第二层(最关键):代码层 Pydantic 参数校验

4.2.1 为什么这一层最关键?

参数错误是四类里出现频率最高的,而模型天生就会编造参数。只有把它从"靠模型自律"变成"靠代码拦截",才能根治。

4.2.2 核心思路

模型只负责"输出调用计划",代码层先拦截校验,通过才真正执行工具 。校验失败时,把错误信息作为 ToolMessage 丢回模型,让它重新判断,最多重试 2 次。

4.2.3 用 Pydantic 定义参数模型(自动校验类型、必填项、取值范围)
python 复制代码
from pydantic import BaseModel, Field, ValidationError

class SearchDBArgs(BaseModel):
    """search_db 工具入参校验模型"""
    staff_no: str = Field(..., description="员工工号,必填,不能填姓名")
    year: int | None = Field(None, ge=2000, le=2030, description="查询年份")

class QueryDocArgs(BaseModel):
    """query_company_doc 工具入参校验模型"""
    keyword: str = Field(..., min_length=1, max_length=200, description="检索关键词")
4.2.4 校验器核心函数
python 复制代码
tool_map = {t.name: t for t in tools}  # tools = [query_company_doc, search_db]

def validate_tool_call(tool_call: dict) -> dict:
    """
    对LLM输出的tool_call做代码层校验,这是防误调用核心
    return {"ok": bool, "args": dict, "error": str}
    """
    tool_name = tool_call["name"]
    args = tool_call["args"]

    # 1. 校验工具名是否在白名单(防编造不存在的工具)
    if tool_name not in tool_map:
        return {"ok": False, "args": args, "error": f"不存在工具:{tool_name}"}

    # 2. 分工具做 Pydantic 参数校验
    try:
        if tool_name == "search_db":
            validated = SearchDBArgs(**args)
        elif tool_name == "query_company_doc":
            validated = QueryDocArgs(**args)
        else:
            return {"ok": False, "args": args, "error": f"未配置校验规则:{tool_name}"}
        return {"ok": True, "args": validated.model_dump(), "error": ""}
    except ValidationError as e:
        return {"ok": False, "args": args, "error": f"参数校验失败: {e.errors()}"}
4.2.5 把校验节点接入 LangGraph 图

在 LangGraph 中新增一个 validate 节点,插在 Agent 节点和工具执行节点之间:

python 复制代码
def validate_node(state: AgentState):
    last_msg = state["messages"][-1]
    # validate节点只会收到AIMessage(agent输出)
    if not isinstance(last_msg, AIMessage) or not last_msg.tool_calls:
        return {"last_tool_error": "", "validate_retry": 0}

    tool_call = last_msg.tool_calls[0]
    res = validate_tool_call(tool_call)
    state["log"]["tool_call_raw"] = tool_call
    state["log"]["validate_result"] = res

    if res["ok"]:
        # 校验通过,清空错误,进入工具执行节点
        return {"last_tool_error": "", "validate_retry": 0}
    else:
        # 校验失败:重试次数+1,把错误丢回模型
        new_retry = state["validate_retry"] + 1
        error_msg = f"【工具调用校验失败】{res['error']},请重新判断或向用户询问缺失参数。"
        return {
            "last_tool_error": error_msg,
            "validate_retry": new_retry,
            "messages": [ToolMessage(content=error_msg, tool_call_id=tool_call["id"])]
        }
4.2.6 效果演示

用户:"帮我查小李的考试成绩"

① 模型输出:search_db(staff_no="小李")

② 校验器捕获:staff_no 不能填姓名 → 拦截,重试次数 +1

③ 错误信息丢回模型:"必须填写员工工号,不能填写姓名"

④ 模型收到错误,不再调用工具,改为反问用户:"请提供小李的员工工号"
注意事项 :校验失败的重试次数建议上限 2 次,防止模型反复编造参数进入死循环。重试耗尽后直接终止,告知用户无法处理。


4.3 第三层:前置意图路由,从源头拦截

4.3.1 为什么需要这一层?

即使提示词写得再好,只要请求进入了 Agent 的工具调用链路,就存在误调用风险。最好的拦截是在入口之前------把不需要工具的请求直接短路掉。

4.3.2 实现:轻量意图分类器

在进入 Agent 之前,用一个轻量分类器判断意图。简单场景可以用关键词/规则,生产场景可换成小模型分类。

python 复制代码
def intent_classifier(query: str) -> dict:
    """
    意图分类:
    direct_answer:常识问答,直接返回,不走Agent工具链路
    agent_run:进入LangGraph Agent执行工具调用
    """
    direct_keywords = ["及格", "多少分合格", "考试规则"]
    if any(k in query for k in direct_keywords):
        return {
            "type": "direct_answer",
            "reply": "考试满分100分,80分及格。"
        }
    return {"type": "agent_run"}

def run_agent(user_query: str):
    intent_res = intent_classifier(user_query)
    if intent_res["type"] == "direct_answer":
        print("【前置路由直接回答,跳过Agent工具调用】")
        return intent_res["reply"]
    # 否则进入 LangGraph ...
4.3.3 进阶:动态工具加载

意图分类后,可以根据意图只把相关的一两个工具传给模型,而不是把全部工具塞进提示词。

工具越少,选错概率越低。工具数量 > 5 个时,误调用率会明显上升 ,这个方案收益最大。

例如意图是"查考试数据",就只把 search_db 绑给模型;意图是"查文档条款",就只绑 query_company_doc。

python 复制代码
if intent == "db":
    llm_with_tools = llm.bind_tools([search_db])
elif intent == "doc":
    llm_with_tools = llm.bind_tools([query_company_doc])

注意事项:动态加载会增加路由复杂度,需维护"意图 → 工具集合"的映射表。工具数量少(≤3 个)时收益有限,可选择性启用。


4.4 第四层:工具返回结果校验

4.4.1 针对的"隐性误调用"

前几层解决的是"调用前"的问题,这一层解决"调用后"的隐性误调用:工具名选对、参数合法,但返回内容与用户问题无关(典型是选错工具返回空结果)。这类错误参数校验拦不住,只有对返回结果做二次判断。

4.4.2 实现:工具执行后加校验节点

在工具执行完成后、模型最终回答之前,用一次轻量 LLM 调用判断返回内容是否匹配用户原始问题:

python 复制代码
def result_check_llm(user_query: str, tool_name: str, tool_output: str, llm) -> dict:
    check_prompt = f"""
用户原始提问:{user_query}
调用工具:{tool_name}
工具返回内容:{tool_output}

任务:判断工具返回内容是否能回答用户的问题,只输出JSON:
{{"result_status": "ok | not_match | empty", "reason": "简短理由"}}
规则:
1. empty:返回内容为未找到、空记录;
2. not_match:工具调用成功,但返回内容和用户问题无关(选错工具);
3. ok:返回内容可以回答用户问题。
只输出JSON,不要多余文字。
"""
    resp = llm.invoke([HumanMessage(content=check_prompt)])
    import json
    try:
        return json.loads(resp.content)
    except Exception:
        return {"result_status": "not_match", "reason": "结果校验解析失败"}
4.4.3 结果校验节点接入 LangGraph
python 复制代码
def result_validate_node(state: AgentState):
    last_msg = state["messages"][-1]
    if not isinstance(last_msg, ToolMessage):
        return {"result_check_error": ""}

    user_query = state["raw_user_query"]
    check_res = result_check_llm(user_query, last_msg.name, last_msg.content, llm)
    state["log"]["result_check"] = check_res

    if check_res["result_status"] == "ok":
        return {"result_check_error": ""}
    elif check_res["result_status"] in ("empty", "not_match"):
        err_msg = f"【工具结果校验】{check_res['reason']},工具返回内容无法回答用户问题,请重新选择工具或告知用户无相关数据。"
        return {
            "result_check_error": err_msg,
            "messages": [ToolMessage(content=err_msg, tool_call_id=last_msg.tool_call_id)]
        }
    return {"result_check_error": ""}
4.4.4 完整图流转链路
复制代码
用户输入 → 意图路由 → Agent节点(LLM决策) → validate参数校验 → tools执行工具 → result_validate结果校验 → 返回答案
    (无需工具直接返回)   ↑______________________|(校验失败丢回重新思考)___________|

注意事项 :这个节点会额外消耗一次模型调用,增加成本与延迟。生产环境建议做成可开关的配置项,或只对"返回为空/低置信度"的结果启用。


4.5 第五层:循环调用保护

4.5.1 问题

LangGraph Agent 默认没有最大轮次限制,模型很容易在"信息不足"的幻觉下反复调用同一个工具,形成死循环,直到资源耗尽。

4.5.2 实现:状态中增加调用计数器

在 Graph 状态里增加 tool_call_count 计数器,在路由函数中到达阈值直接终止:

python 复制代码
class AgentState(TypedDict):
    messages: Annotated[Sequence[BaseMessage], operator.add]
    raw_user_query: str      # 保存原始用户query,给结果校验节点使用
    tool_call_count: int     # 工具调用次数计数器,防循环
    validate_retry: int      # 参数校验失败重试次数,最多2次
    last_tool_error: str
    result_check_error: str
    log: dict

def should_continue(state: AgentState):
    # 超过最大调用次数,终止
    if state["tool_call_count"] >= 3:
        return END
    # 校验重试超过2次,终止
    if state["validate_retry"] >= 2:
        return END

    last_msg = state["messages"][-1]
    # 参数校验失败,回到agent
    if state.get("last_tool_error"):
        return "agent"
    # 结果校验失败,回到agent重新思考
    if state.get("result_check_error"):
        return "agent"
    # 只有AIMessage才有tool_calls,ToolMessage没有!
    if isinstance(last_msg, AIMessage) and last_msg.tool_calls:
        return "tools"
    return END

注意事项 :计数器建议在工具执行节点里自增(state["tool_call_count"] += 1)。阈值(如 3 次)需要根据任务复杂度调整,太大会兜不住死循环,太小会影响正常的多步推理。

五、评测闭环:用数据驱动持续优化

5.1 能力点:

  1. Pydantic 强参数校验,拦截参数幻觉
  2. 独立校验节点,LLM 输出 tool_call 后先校验,不合法直接打回 LLM 重试
  3. 最大调用轮次限制,防死循环
  4. 前置意图路由,常识问答直接返回,不走 Agent
  5. 日志埋点,记录误调用 case
  6. 最多重试 2 次校验错误,避免无限重试

5.2 完整代码

python 复制代码
import os
from typing import TypedDict, Annotated, Sequence
import operator
from dotenv import load_dotenv
from pydantic import BaseModel, Field, ValidationError
from langchain_openai import ChatOpenAI
from langchain_core.messages import BaseMessage, ToolMessage, HumanMessage
from langchain_core.tools import tool
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode

load_dotenv()

# ====================== 1. 定义工具参数模型(校验用) ======================
class SearchDBArgs(BaseModel):
    """search_db 工具入参校验模型"""
    staff_no: str = Field(..., description="员工工号,必填,不能填姓名")
    year: int | None = Field(None, ge=2000, le=2030, description="查询年份")

class QueryDocArgs(BaseModel):
    """query_company_doc 工具入参校验模型"""
    keyword: str = Field(..., min_length=1, max_length=200, description="检索关键词")

# ====================== 2. 定义业务工具 ======================
@tool
def query_company_doc(keyword: str) -> str:
    """
    【仅当用户需要查阅安规、两票原文条款时调用】
    能力:检索静态PDF安规文档文本。
    不能做:无法查询员工名单、考试成绩、人员数据。
    当问题属于常识(如及格分数)不要调用此工具。
    """
    mock_data = {
        "工作票定义": "工作票是准许在电气设备及系统软件上工作的书面命令,也是执行保证安全技术措施的书面依据。"
    }
    return mock_data.get(keyword, f"文档未检索到关键词:{keyword}")

@tool
def search_db(staff_no: str, year: int | None = None) -> str:
    """
    【仅查询员工考试成绩、人员名单时调用】
    能力:查询数据库员工考试记录。
    不能做:不能查询安规条款原文。
    必填参数:staff_no员工工号,不可填姓名。
    """
    mock_data = {
        "10001": "员工10001,2025考试成绩:86分,合格",
        "10002": "员工10002,2025考试成绩:72分,不合格"
    }
    return mock_data.get(staff_no, f"数据库未找到工号 {staff_no} 的记录")

tools = [query_company_doc, search_db]
tool_map = {t.name: t for t in tools}

# ====================== 3. 工具调用校验器核心函数 ======================
def validate_tool_call(tool_call: dict) -> dict:
    """
    对LLM输出的tool_call做代码层校验,这是防误调用核心
    return {"ok":bool, "args":dict, "error":str}
    """
    tool_name = tool_call["name"]
    args = tool_call["args"]

    # 1. 校验工具名是否在白名单
    if tool_name not in tool_map:
        return {"ok": False, "args": args, "error": f"不存在工具:{tool_name}"}

    # 2. 分工具做Pydantic参数校验
    try:
        if tool_name == "search_db":
            validated = SearchDBArgs(**args)
        elif tool_name == "query_company_doc":
            validated = QueryDocArgs(**args)
        else:
            return {"ok": False, "args": args, "error": f"未配置校验规则:{tool_name}"}
        return {"ok": True, "args": validated.model_dump(), "error": ""}
    except ValidationError as e:
        return {"ok": False, "args": args, "error": f"参数校验失败: {e.errors()}"}

# ====================== 4. 前置意图路由(源头拦截幻觉调用) ======================
def intent_classifier(query: str) -> dict:
    """
    意图分类:
    direct_answer:常识问答,直接返回,不走Agent工具链路
    agent_run:进入LangGraph Agent执行工具调用
    """
    direct_keywords = ["及格", "多少分合格", "考试规则"]
    if any(k in query for k in direct_keywords):
        return {
            "type": "direct_answer",
            "reply": "考试满分100分,80分及格。"
        }
    return {"type": "agent_run"}

# ====================== 5. LangGraph State定义 ======================
class AgentState(TypedDict):
    messages: Annotated[Sequence[BaseMessage], operator.add]
    tool_call_count: int  # 工具调用次数计数器,防循环
    validate_retry: int   # 参数校验失败重试次数,最多2次
    last_tool_error: str  # 上一轮校验错误信息,传给LLM
    log: dict             # 埋点日志,记录误调用信息

# ====================== 6. 节点定义【重点:OpenAI兼容方式接入通义千问】 ======================
llm = ChatOpenAI(
    model="qwen-max",
    openai_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
    openai_api_key=os.getenv("DASHSCOPE_API_KEY"),
    temperature=0,
)
llm_with_tools = llm.bind_tools(tools)

def agent_node(state: AgentState):
    """Agent节点:LLM生成工具调用决策"""
    system_prompt = """
你是企业助手,严格遵守下面决策流程:
步骤1:判断问题是否可以仅凭已有知识回答。如果可以,直接输出答案,禁止调用任何工具。
步骤2:如果必须调用工具,从工具列表中选择唯一合适的工具。禁止多选工具。
步骤3:检查该工具所需全部必填参数。缺少必填参数,向用户提问收集信息,不要编造参数。
步骤4:确认参数符合工具要求。
步骤5:调用工具,拿到返回结果后判断信息是否充足。信息足够就回答用户,不要重复调用同一个工具。

禁止规则:
1. 禁止调用工具查询常识类固定知识。
2. 禁止编造工号、姓名等不存在参数。
3. 单次思考只能调用最多1个工具。
4. 如果工具返回结果为空,不要反复重试,告知用户未找到相关信息。

额外约束:输出工具调用时,参数JSON严格单层,禁止多余转义、禁止嵌套对象。
如果信息不需要工具,禁止强行生成tool_calls。

Few-shot示例
示例1(无需工具)
用户:考试多少分及格?
思考:已有知识可以回答,不需要调用工具。

示例2(缺少参数)
用户:查小李的考试成绩
思考:search_db需要必填参数staff_no,用户没有提供工号,不能调用工具。向用户询问工号。

示例3(正确调用工具)
用户:查找工作票的定义原文
思考:需要查阅安规文档,调用query_company_doc,关键词:工作票定义
"""
    messages = [HumanMessage(content=system_prompt)] + state["messages"]
    response = llm_with_tools.invoke(messages)
    return {
        "messages": [response],
        "log": {
            "llm_output": response,
            "last_error": state.get("last_tool_error", "")
        }
    }

def validate_node(state: AgentState):
    """校验节点:LLM输出tool_call后,先校验,不执行工具!"""
    last_msg = state["messages"][-1]
    # 如果LLM没有输出tool_call,直接放行
    if not last_msg.tool_calls:
        return {"last_tool_error": "", "validate_retry": 0}

    tool_call = last_msg.tool_calls[0]
    res = validate_tool_call(tool_call)
    # 埋点日志记录
    state["log"]["tool_call_raw"] = tool_call
    state["log"]["validate_result"] = res

    if res["ok"]:
        # 校验通过,清空错误,进入工具执行节点
        return {"last_tool_error": "", "validate_retry": 0}
    else:
        # 校验失败:重试次数+1
        new_retry = state["validate_retry"] + 1
        error_msg = f"【工具调用校验失败】{res['error']},请重新判断或向用户询问缺失参数。"
        return {
            "last_tool_error": error_msg,
            "validate_retry": new_retry,
            "messages": [ToolMessage(content=error_msg, tool_call_id=tool_call["id"])]
        }

# 官方工具执行节点(LangGraph原生,无需修改)
tool_node = ToolNode(tools)

# ====================== 7. 路由函数(控制流转) ======================
def should_continue(state: AgentState):
    """分支判断:下一步去哪里"""
    # 超过最大调用次数,终止
    if state["tool_call_count"] >= 3:
        return END
    # 校验重试超过2次,终止
    if state["validate_retry"] >= 2:
        return END
    last_msg = state["messages"][-1]
    # 校验存在错误,回到agent节点重新思考
    if state.get("last_tool_error"):
        return "agent"
    # LLM输出工具调用,进入工具执行
    if last_msg.tool_calls:
        return "tools"
    # 无工具调用,直接结束
    return END

# ====================== 8. 构建LangGraph图 ======================
builder = StateGraph(AgentState)
builder.add_node("agent", agent_node)
builder.add_node("validate", validate_node)
builder.add_node("tools", tool_node)

builder.set_entry_point("agent")
builder.add_edge("agent", "validate")
builder.add_conditional_edges("validate", should_continue)
builder.add_edge("tools", "agent")

graph = builder.compile()

# ====================== 9. 对外入口函数 ======================
def run_agent(user_query: str):
    # 第一步:前置意图路由
    intent_res = intent_classifier(user_query)
    if intent_res["type"] == "direct_answer":
        print("【前置路由直接回答,跳过Agent工具调用】")
        return intent_res["reply"]

    init_state = {
        "messages": [HumanMessage(content=user_query)],
        "tool_call_count": 0,
        "validate_retry": 0,
        "last_tool_error": "",
        "log": {}
    }
    final_state = graph.invoke(init_state)
    return final_state["messages"][-1].content

5.3 测试用例

python 复制代码
# ====================== 测试用例 ======================
if __name__ == "__main__":
    test_cases = [
        # case1:常识问题,前置路由拦截,防止幻觉调用
        "考试多少分及格?",
        # case2:参数缺失,LLM容易编造姓名作为工号,校验器拦截
        "帮我查小李的考试成绩",
        # case3:正确调用文档工具
        "查找工作票的定义原文",
        # case4:参数合法,调用数据库
        "查询工号10001的考试成绩",
        # case5:错误工具意图(查人员名单,容易选错文档工具)
        "查询2025考试不合格人员名单"
    ]
    for case in test_cases:
        print(f"\n==== 用户:{case} ====")
        ans = run_agent(case)
        print(f"Agent:{ans}")

5.5 预期结果

query 期望动作 错误类型
考试多少分及格? 直接回答 幻觉调用(不需要工具)
帮我查小李的考试成绩 向用户询问工号 参数缺失幻觉
查找工作票的定义原文 调用 query_company_doc 正常
查询工号10001考试成绩 调用 search_db 正常
查询2025考试不合格人员名单 调用 search_db 选错工具

5.4 实际运行结果

python 复制代码
==== 用户:考试多少分及格? ====
【前置路由直接回答,跳过Agent工具调用】
Agent:考试满分100分,80分及格。

==== 用户:帮我查小李的考试成绩 ====
Agent:您好,为了查询小李的考试成绩,请提供他的员工工号。

==== 用户:查找工作票的定义原文 ====
Agent:工作票的定义是:工作票是准许在电气设备及系统软件上工作的书面命令,也是执行保证安全技术措施的书面依据。

==== 用户:查询工号10001的考试成绩 ====
Agent:员工10001在2025年的考试成绩是86分,达到了合格标准。

==== 用户:查询2025考试不合格人员名单 ====
Agent:为了查询2025年考试不合格人员的名单,我需要一个具体的员工工号作为查询条件。如果您想要获取所有不合格人员的信息,请提供任意一位员工的工号以便我能进行查询。或者,如果您的需求是直接获得整个不合格人员名单而无需指定工号,请让我知道,这样我可以进一步帮助您。不过按照现有规定,我必须要有员工工号才能执行查询操作。请问您可以提供吗?

进程已结束,退出代码为 0

六、总结与优先级建议

优先级 优化项 一句话说明
★★★ 代码层参数校验 解决最高频的参数幻觉,必须做
★★★ 优化提示词 + 工具描述 + Few-shot 见效最快,成本最低
★★☆ 前置意图路由 + 动态工具加载 从源头拦截,工具越多收益越大
★★☆ 循环调用保护 防死循环,必加
★☆☆ 工具返回结果校验 解决隐性误调用,注意额外成本
★★★ 评测集 + 日志埋点 数据驱动迭代,长期必做

建议落地顺序(成本从低到高):

  1. 先做提示词 + 工具描述 + Few-shot(半天见效)
  2. 再加 Pydantic 代码层参数校验(收益最大)
  3. 补循环调用保护(防崩)
  4. 再上意图路由 + 动态工具加载(工具多了再做)
  5. 最后加结果校验(按需开关)
  6. 全程用评测集 + 日志埋点做回归

一句话收尾 :Agent 工具误调用的本质,是把"模型该负责的理解"和"程序该负责的校验"混在了一起。优化的核心就是------把确定性的校验(参数、次数、白名单)从模型手里拿回代码层,把模糊的理解(是否调用、选哪个)交给更清晰的提示词和路由,两者结合才能真正让 Agent 从"乱调用"走向"准调用"。

相关推荐
张小洛6 个月前
Spring 常用类深度剖析(工具篇 05):Assert:用断言代替 if-throw,代码更清爽
spring·log4j·参数校验·validate·assert·spring 常用类·代码简化
西凉的悲伤9 个月前
spring-boot-starter-validation使用注解进行参数校验
java·spring boot·参数校验·validation·注解校验参数
小杨互联网1 年前
构建推理缓存以节省高流量 LLM 应用程序的成本
缓存·llm·大型语言模型
CodeShare1 年前
RATE:基于LLM的检索增强生成技术提取管道
人工智能·信息检索·大型语言模型
宋冠巡2 年前
Spring Boot Validation 封装自定义校验注解和校验器(validation-spring-boot-starter)
spring boot·参数校验·validation
AI劳模2 年前
AI全栈开发:大模型LLM学习路线图来了!
人工智能·学习·语言模型·llm·prompt·ai大模型·大型语言模型
AI程序猿人2 年前
几种Word Embedding技术详解
人工智能·大模型·word·embedding·计算机技术·大型语言模型·大模型技术
小爷汤少2 年前
参数校验学习笔记
spring boot·dubbo·参数校验
nameofworld3 年前
spring boot后端controller中接收表单参数校验
java·spring boot·后端·学习方法·参数校验