20 行 Python 给 LangChain 加网页搜索工具

LangChain 自带的搜索封装,把两个真正要紧的决定替你做了:结果以什么形状回来,以及 Agent 调了又扔掉的那些请求由谁买单。自己写这个工具只要二十行,而这两件事就重新回到你手里。
一句话总结
- •langchain_core 的 @tool 能把普通函数变成工具——docstring 就是模型读到的工具说明。
- •langgraph.prebuilt 的 create_react_agent 是当前的正路;initialize_agent 已是遗留写法。
- •返回一个精简的 dict 列表,不要把原始 API 负载直接丢回去——每个用不上的字段,每一步都在替你烧 token。
- •POST https://www.apipick.com/api/search/web,带 x-api-key 头:每次 15 额度,仅 HTTP 200 扣费。
- •再加一个读页面的工具(Extract,每个 URL 2 额度),让 Agent 能「搜得宽、读得窄」。
为什么要自己写,而不是 import 一个?
LangChain 为好几家搜索厂商提供了封装,做 demo 完全够用。但它们也替你做了两个决定,而这两个决定在 Agent 开始进循环的那一刻你就会想要回来:返回的形状,以及一次被丢弃的调用要花你多少钱。
ReAct Agent 每一步都会重读整个消息历史。一个返回臃肿负载的工具不是一次性成本,而是这一轮之后每一步的税。自己写这个工具只要二十行,而裁剪也就回到了它该在的位置。
最小可用的工具长什么样?
import os, httpx
from langchain_core.tools import tool
API_KEY = os.environ["APIPICK_KEY"]
@tool
def web_search(query: str) -> list[dict]:
"""搜索实时网络并返回排好序的结果。
只要答案依赖训练截止之后的信息、依赖当前价格、依赖新闻,
或者依赖任何一个正常人会打开浏览器去查的东西,就调用它。
查询词按普通人往搜索框里敲的方式写:大白话,不要操作符,
不要引号,不要 site: 过滤。
返回 {title, url, snippet} 的列表。
"""
r = httpx.post(
"https://www.apipick.com/api/search/web",
headers={"x-api-key": API_KEY},
json={"query": query, "max_num_results": 5},
timeout=20,
)
r.raise_for_status()
return [
{"title": x["title"], "url": x["url"], "snippet": x["snippet"]}
for x in r.json()["results"]
]怎么把它挂到 Agent 上?
langgraph.prebuilt 的 create_react_agent 是当前的正路。它返回一个图;你用一个消息列表调用它,工具循环由它来跑。
from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
llm = init_chat_model("anthropic:claude-sonnet-5")
agent = create_react_agent(
llm,
tools=[web_search],
prompt=(
"你是一名研究助理。每一个事实性断言都必须落在搜索结果上,"
"并给出 URL。如果搜索没返回有用的东西,就直说,不要猜。"
),
)
out = agent.invoke({"messages": [
{"role": "user", "content": "今年欧盟 AI 法案的时间表有什么变化?"}
]})
print(out["messages"][-1].content)多数老教程用的 initialize_agent 是遗留路径。如果你在抄一篇 2024 年的文章,这一行就是要换掉的那行。
怎么让 Agent 不只是搜,还会读?
摘要回答的是「哪个页面」,很少能回答「它到底说了什么」。管用的模式是搜得宽、读得窄:给 Agent 第二个工具,能对指定 URL 取回干净正文,再由它自己决定哪两三个值得打开。
@tool
def read_pages(urls: list[str]) -> list[dict]:
"""取回指定网页的干净正文。
在 web_search 之后、摘要不足以回答问题时调用。
只传真正相关的 URL——最多三个。
返回 {url, content} 的列表,导航和广告已去除。
"""
r = httpx.post(
"https://www.apipick.com/api/extract",
headers={"x-api-key": API_KEY},
json={"urls": urls[:3]},
timeout=60,
)
r.raise_for_status()
return [
{"url": x["url"], "content": x["content"][:6000]}
for x in r.json()["results"] if x["status"] == "ok"
]
agent = create_react_agent(llm, tools=[web_search, read_pages], prompt=...)注意那个 [:3] 和 [:6000]。两者都是护栏,防着模型一次要十二个页面然后把自己淹死。搜索每次 15 额度、抽取每个 URL 2 额度,这份克制两边成本都不高——稀缺的是上下文窗口。
跑一轮到底花多少钱?
- 一次搜索:15 额度 ≈ $0.015。
- 读三个页面:6 额度 ≈ $0.006。
- 一个典型的「搜三次、读两次」问题:工具调用约 $0.06,而且额度只在 HTTP 200 时扣。
最后那半句,才是真正改变你写 Agent 代码方式的东西。失败不要钱的时候,你可以放手让 Agent 去探——试探性的搜索、换个说法再来一次——而不必给每一条死路都开着计价器。完整论证见仅成功计费如何改变 Agent 设计。
上线前该检查什么?
- 每个工具都设超时。ReAct 循环里一个卡住的 HTTP 调用,看起来就是一个卡死的 Agent。
- 明确写上「不知道就说不知道」。没有这句,一组糟糕的搜索结果是模型自信编造的最常见触发点。
- 在源头限制条数,而不是在提示词里。模型会跟提示词讨价还价,但跟
max_num_results讨不了。 - 输出里要有引用。如果 Agent 说不出某个断言来自哪个 URL,那接地就没起作用。
把同一个工具接到别的框架,基本是翻译工作——见 CrewAI 版、Vercel AI SDK 版,以及裸 OpenAI 与 Claude 版。先拿一把免费 key:100 额度,不用卡。
常见问题
该用 @tool 还是继承 BaseTool?
用 @tool。它会注册函数名,把 docstring 当作模型读到的工具说明,并从类型标注推导参数 schema。只有在确实需要显式的 Pydantic args_schema、需要实例上的共享状态、或者需要分开的同步/异步实现时,继承 BaseTool 才划算——搜索工具很少属于这一类。
还该用 initialize_agent 来搭 Agent 吗?
不该。initialize_agent 是遗留路径。当前写法是 langgraph.prebuilt 的 create_react_agent,它返回一个图,你用一个 messages 列表去调用它。工具循环、消息状态和流式输出它都替你处理了,不用自己写 executor。
为什么 docstring 这么关键?
因为它就是模型看到的全部规格。@tool 会把 docstring 解析成工具说明,把类型标注解析成参数 schema。一个只写着「搜索网页」的 docstring,换来的是把用户原话整句拿去搜的 Agent;而一个写清了「什么时候用、查询词怎么写、会返回什么」的 docstring,换来的是像人一样搜索的 Agent。
怎么防止 Agent 把上下文烧在搜索结果上?
在工具里裁,不要在提示词里求。只返回 title、url、snippet 三个字段,并在源头限制条数——max_num_results 取值 1–5,默认 5。ReAct Agent 每一步都会重读完整消息历史,所以一个臃肿的工具返回不是只付一次费,而是这一轮后面每一步都在付。
Agent 重试失败的调用,账单会怎样?
如果服务方只在成功时计费,就什么都不会发生。额度只在 HTTP 200 时扣除,所以超时、key 写错的 401、参数畸形的 400,成本都是零。这一点在 Agent 代码里比在脚本里重要得多:ReAct 循环拿到工具报错,通常会换个说法再试一次,而不是直接停下。
本文涉及的 API
Sarah Choy 是 API Pick 的 CEO,专注于为 AI Agent 与 LLM 工作流构建可用于生产的 API。