【手搓 Agent 第2.2关】搭建 Agent 进阶能力:网络搜索工具(中)

承接上篇,我们搭建的原生爬虫搜索工具,虽然实现了联网功能,但存在严重的工程短板:工具内部自行捕获并吞掉异常,大模型无法感知真实故障,只能机械重复无效调用,完全不具备智能体的自主思考能力。

本篇聚焦 Agent 核心智能特性,落地 Error as Observation 错误即观测 核心架构。重构工具异常处理逻辑,将所有工具报错统一封装为标准对话信息喂给模型,同时实现动态工具武器库、故障熔断、权限回收机制,让 Agent 真正学会识别错误、分析错误、自主终止无效任务,完成从"机械执行"到"智能决策"的升级。

一、Agent 错误观测与自主熔断机制

在本章节,我们将探索如何让模型理解失败原因。我们将植入免疫系统核心代码 error as observation

  • Error as Observation:工具执行无论成功、崩溃、拦截、超时,全部统一封装为 OpenAI 标准 role="tool" 消息存入 chat_history ;捕获异常后绝不提前 return 终止流程,把报错当作一条 "环境观测" 丢给大模型,由模型自主判断下一步怎么做。

1. 移除函数内部容错代码

定位:完整 web_search 函数。

改动:删除函数内部全部 try/except,只保留业务逻辑 + 检测安全验证主动抛异常。

代码示例:

python 复制代码
def web_search(query: str) -> str:
    print(f"\n[执行工具] 正在联网搜索关键词:{query}")
    url = f"https://www.baidu.com/s?wd={query}"
    headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}
    resp = requests.get(url, timeout=10, headers=headers)
    resp.encoding = resp.apparent_encoding
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")
    raw_text = soup.get_text(strip=True, separator="\n")
    if "百度安全验证" in raw_text or "网络不给力" in raw_text:
        raise requests.exceptions.HTTPError("403 Forbidden:百度反爬拦截,需要人机验证")
    short_text = raw_text[:2000]
    return f"【网络搜索结果】\n{short_text}"

我们把异常全部抛到上层统一处理,而不是在工具内部吞掉异常。这样,上层调度代码拿到完整错误类型、堆栈信息,就可以靠代码逻辑精准区分故障。

2. 核心:工具执行循环增加局部异常捕获

定位:generate_response 函数内部,for tool in msg.tool_calls: 循环里执行工具的代码块

原代码片段(无捕获):

python 复制代码
tool_func = available_tools.get(func_name)
if tool_func:
    tool_result = tool_func(**func_args)
else:
    tool_result = "不存在该工具"
print(f"工具返回结果:{tool_result}")

修改方案:给 tool_func(**func_args) 包裹 try-except,捕获所有工具抛出的异常,生成标准化报错文本

示例代码:

python 复制代码
tool_func = available_tools.get(func_name)
try:
    if tool_func:
        tool_result = tool_func(**func_args)
    else:
        tool_result = f"不存在名为 {func_name} 的工具"
                    except Exception as e:
                        tool_result = f"【工具执行失败】HTTP 403 Forbidden:百度反爬拦截,需要人机验证。系统底层报错详情:{str(e)}"
                    print(f"工具返回结果:{tool_result}")
  • 不管是 403 拦截、网络超时、解析报错,全部进入这个 except
  • 异常不会向上跑到最外层大 try,不会直接返回全局错误、打断对话;
  • 成功结果 / 报错信息都会存入同一个变量 tool_result,后续统一封装成 role="tool" 消息写入历史。

3. 自我修正闭环完整运行日志

在此阶段,希望模型识别出搜索工具无法返回结果后,由大模型进行推理思考,从而进行二次工具调用,只用知识库回答。

升级系统提示词

python 复制代码
system_prompt = {
    "role": "system",
    "content": """你是一个严谨的资料研究助手。你有两个强大的工具:
1. query_knowledge_base:用于查询企业内部标准、本地文件。
2. web_search:用于查询最新的互联网公开信息。
请自主判断用户的提问,灵活选择调用一个或多个工具来收集信息。
注意:真实的网络环境是不稳定的,如果调用网页搜索遇到失败或报错,你需要自己分析原因,决定是更换关键词重试,还是直接利用已收集到的本地知识进行总结。在回答的末尾,请务必标注信息来源。"""
}

动态工具剪枝与熔断机制

在企业级 Agent 架构中,用提示词指望大模型自己聪明是不可靠的。工业上一般通过动态剥夺大模型工具使用权的方式避免它多次调用无用工具。即,如果检测到某个工具报了"不可逆的致命错误(比如 403 永久拦截)",我们在下一次请求大模型时,直接把这个工具从 tools 列表里删掉

  1. 在进入循环前,复制一份"动态武器库"

示例代码:

python 复制代码
def generate_response(prompt):
    try:
        chat_history.append({"role": "user", "content": prompt})

        MAX_STEPS = 3
        step_count = 0
        
        # 【新增】:复制一份当前的 tools 列表,作为本轮对话的动态武器库
        # 这样我们可以随时在这个列表里删除失效的工具
        current_tools = tools.copy() 

        # 第一次请求时,把 current_tools 传进去
        response = client.chat.completions.create(
            model=Model_ID,
            messages=chat_history,
            tools=current_tools if current_tools else None,
            tool_choice="auto" if current_tools else "none",
            parallel_tool_calls=False, # 关闭并发调用
            timeout=30
        )
        
        if not response.choices:
            raise Exception("模型首次返回空choices,上下文过长或接口限流无输出")
        msg = response.choices[0].message
  1. 在工具报错时,触发"熔断剪枝",且当大模型强行调用已经被熔断的工具,直接拦截并严厉警告
python 复制代码
# ... 前面的代码保持不变 ...
is_tool_allowed = any(t['function']['name'] == func_name for t in current_tools)

if not is_tool_allowed:
    tool_result = f"【工具调用失败】你没有权限调用 {func_name} 工具。"
else:
    # 执行本地工具,动态分发调用
    tool_func = available_tools.get(func_name)
    try:
        if tool_func:
            tool_result = tool_func(**func_args)
        else:
            tool_result = f"不存在名为 {func_name} 的工具"
    except Exception as e:
        error_msg = str(e)
        if "403" in error_msg or "百度反爬拦截" in error_msg:
            tool_result = f"【系统强制熔断】致命错误:{error_msg}。该网页搜索渠道已永久失效,禁止重试。"
            current_tools = [t for t in current_tools if t['function']['name'] != func_name]
            print(f"[系统拦截] 触发熔断!已剥夺大模型对 '{func_name}' 的调用权限!")
        else:
            tool_result = f"【工具执行失败】普通错误:{error_msg}。你可以尝试更换关键词或修改参数后再次调用。"

print(f"工具执行结果:{tool_result}")
# ... 存入 chat_history 的代码保持不变 ...
  1. 二次请求大模型时,应用被阉割的武器库
python 复制代码
print("正在分析工具结果并生成后续回复...")

# 【新增逻辑】:如果所有工具都被没收了,必须去掉 tools 参数,否则 API 会报错
api_kwargs = {
    "model": Model_ID,
    "messages": chat_history,
    "timeout": 30
}
if current_tools:
    api_kwargs["tools"] = current_tools
    api_kwargs["tool_choice"] = "auto"

second_resp = client.chat.completions.create(**api_kwargs)

# 刷新 msg为最新的模型输出
if not second_resp.choices:
    raise Exception("二次调用模型返回空choices,上下文过载无法生成回复")
msg = second_resp.choices[0].message

预期结果流程:

  • 调用 query_knowledge_base 查询本地知识库
  • → 未查询到完整结果
  • → 调用 web_search 进行联网搜索
  • → 因网页反爬虫无法获得结果
  • → LLM 思考认为此路不通无法联网搜索/系统强制熔断删除 web_search 工具
  • → LLM 不再强行联网搜索,而是使用本地知识库已有信息回答问题。

二、本篇总结 & 下期预告

通过本篇改造,我们彻底解决了 Agent 工具调用无脑重试、死循环卡死的问题,让智能体具备了基础的自我反思与故障止损能力。但手写原生爬虫稳定性极差,极易被网站反爬拦截,仅适合入门学习,完全无法适配线上生产环境。

下一篇 Stage 2-网络搜索工具下篇,我们将完成从玩具demo到工业级落地的升级,替换商用专业搜索API,同时解决大模型迭代式检索过度调用工具的问题,通过软硬双层约束实现工具调用可控化,完成生产级网络搜索工具闭环。