本文是「LangGraph 教程系列」第 5 篇。写作时基于 langgraph 1.2.10、langchain 1.3.14、langchain-openai 1.4.1、Python 3.12+。配套代码仓库 https://github.com/wxj006007/deep-research-assistant ,本篇对应 tag
v1.1。
上一篇 v1 把反思循环做出来了,检索不够就再查一轮。但那个检索是假的------FAKE_CORPUS 是写死的语料表,fake_search 是关键词匹配,跑起来干净、可离线、结果确定,就是不能真上网查信息。
给研究助手装上联网能力,它才是一个能真正解决现实问题的"助手"。这一篇就来接 Bing 搜索引擎 API,让 v1 从"纸上谈兵"变成"真枪实弹"。
一、v1 撞的墙,只会反思不会查
v1 有个隐蔽的限制:它的知识边界被 FAKE_CORPUS 锁死了。只有那 7 条语料里有的内容,助手才能回答。用户问一个昨天发生的新闻、一个最新的科技产品、一个实时股价,v1 只能老实说"我不知道"。
更本质的问题在于,v1 的检索是个纯函数:输入 query,输出 docs,没有副作用,也没有对外部世界的访问。真实世界的检索工具不是这样的。它要发 HTTP 请求、要鉴权、要解析返回的 JSON、要处理分页和错误。这些"副作用"怎么融入图框架?这就是工具调用的舞台。
二、v1.1 的图结构
#mermaid-svg-I5JYAJxDlSlTqaZy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-I5JYAJxDlSlTqaZy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-I5JYAJxDlSlTqaZy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-I5JYAJxDlSlTqaZy .error-icon{fill:#552222;}#mermaid-svg-I5JYAJxDlSlTqaZy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-I5JYAJxDlSlTqaZy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-I5JYAJxDlSlTqaZy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-I5JYAJxDlSlTqaZy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-I5JYAJxDlSlTqaZy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-I5JYAJxDlSlTqaZy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-I5JYAJxDlSlTqaZy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-I5JYAJxDlSlTqaZy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-I5JYAJxDlSlTqaZy .marker.cross{stroke:#333333;}#mermaid-svg-I5JYAJxDlSlTqaZy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-I5JYAJxDlSlTqaZy p{margin:0;}#mermaid-svg-I5JYAJxDlSlTqaZy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-I5JYAJxDlSlTqaZy .cluster-label text{fill:#333;}#mermaid-svg-I5JYAJxDlSlTqaZy .cluster-label span{color:#333;}#mermaid-svg-I5JYAJxDlSlTqaZy .cluster-label span p{background-color:transparent;}#mermaid-svg-I5JYAJxDlSlTqaZy .label text,#mermaid-svg-I5JYAJxDlSlTqaZy span{fill:#333;color:#333;}#mermaid-svg-I5JYAJxDlSlTqaZy .node rect,#mermaid-svg-I5JYAJxDlSlTqaZy .node circle,#mermaid-svg-I5JYAJxDlSlTqaZy .node ellipse,#mermaid-svg-I5JYAJxDlSlTqaZy .node polygon,#mermaid-svg-I5JYAJxDlSlTqaZy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-I5JYAJxDlSlTqaZy .rough-node .label text,#mermaid-svg-I5JYAJxDlSlTqaZy .node .label text,#mermaid-svg-I5JYAJxDlSlTqaZy .image-shape .label,#mermaid-svg-I5JYAJxDlSlTqaZy .icon-shape .label{text-anchor:middle;}#mermaid-svg-I5JYAJxDlSlTqaZy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-I5JYAJxDlSlTqaZy .rough-node .label,#mermaid-svg-I5JYAJxDlSlTqaZy .node .label,#mermaid-svg-I5JYAJxDlSlTqaZy .image-shape .label,#mermaid-svg-I5JYAJxDlSlTqaZy .icon-shape .label{text-align:center;}#mermaid-svg-I5JYAJxDlSlTqaZy .node.clickable{cursor:pointer;}#mermaid-svg-I5JYAJxDlSlTqaZy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-I5JYAJxDlSlTqaZy .arrowheadPath{fill:#333333;}#mermaid-svg-I5JYAJxDlSlTqaZy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-I5JYAJxDlSlTqaZy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-I5JYAJxDlSlTqaZy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-I5JYAJxDlSlTqaZy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-I5JYAJxDlSlTqaZy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-I5JYAJxDlSlTqaZy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-I5JYAJxDlSlTqaZy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-I5JYAJxDlSlTqaZy .cluster text{fill:#333;}#mermaid-svg-I5JYAJxDlSlTqaZy .cluster span{color:#333;}#mermaid-svg-I5JYAJxDlSlTqaZy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-I5JYAJxDlSlTqaZy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-I5JYAJxDlSlTqaZy rect.text{fill:none;stroke-width:0;}#mermaid-svg-I5JYAJxDlSlTqaZy .icon-shape,#mermaid-svg-I5JYAJxDlSlTqaZy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-I5JYAJxDlSlTqaZy .icon-shape p,#mermaid-svg-I5JYAJxDlSlTqaZy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-I5JYAJxDlSlTqaZy .icon-shape .label rect,#mermaid-svg-I5JYAJxDlSlTqaZy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-I5JYAJxDlSlTqaZy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-I5JYAJxDlSlTqaZy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-I5JYAJxDlSlTqaZy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} continue
finish
START
plan
规划首轮查询
search
调用搜索工具
evaluate
评估找缺口
synthesize
综合成稿
END
图结构没变!这才是关键。我们只做了一件事:把 search_node 的实现从 fake_search 换成 bing_search,Node 接口不变(query + already_have -> docs out),上游节点毫无感知。这是 LangGraph 的好处:工具的替换对图拓扑透明。
三、动手实现
3.1 环境准备
Bing Search API 通过 Azure Cognitive Services 提供。你需要:
- 订阅 Azure Cognitive Services 或 Azure Search subscription
- 创建 Bing Search API 资源,获取 endpoint 和 subscription key
- 在
.env里配置两样东西:
bash
BING_SEARCH_ENDPOINT=https://your-endpoint.cognitive.microsoft.com
BING_SEARCH_API_KEY=your-api-key
如果不想付费,可以先用免费版(配额有限,约 20 次/月)。代码会自动降级到 fake_search,保证离线也能跑。
3.2 State,和 v1 一模一样
python
class ResearchState(TypedDict):
question: str
current_query: str
executed_queries: Annotated[list[str], operator.add]
docs: Annotated[list[Doc], operator.add]
round: int
verdict: str
gap: str
answer: str
工具调用不改变状态结构,只改变 Node 的内部实现。这是一个好信号:工具的接入应该是对内的,不该迫使整个系统重构。
3.3 search_node,从假搜到真搜
先看 fake_search(v1 的版本):
python
def fake_search(query: str, already_have: list[Doc], top_k: int = 2) -> list[Doc]:
query_lower = query.lower()
have_titles = {doc["title"] for doc in already_have}
scored: list[tuple[int, dict]] = []
for entry in FAKE_CORPUS:
if entry["title"] in have_titles:
continue
score = sum(1 for kw in entry["keywords"] if kw.lower() in query_lower)
if score > 0:
scored.append((score, entry))
scored.sort(key=lambda pair: -pair[0])
return [
Doc(query=query, title=entry["title"], content=entry["content"])
for _, entry in scored[:top_k]
]
纯 Python 逻辑,确定性结果,没有 IO。现在看 bing_search:
python
import json
import os
from urllib.parse import urlencode
from urllib.request import Request, urlopen
def bing_search(query: str, already_have: list[Doc], top_k: int = 2) -> list[Doc]:
api_key = os.getenv("BING_SEARCH_API_KEY")
endpoint = os.getenv("BING_SEARCH_ENDPOINT")
# 兜底:没配密钥就用 fake_search
if not api_key or not endpoint:
return fake_search(query, already_have, top_k)
try:
url = f"{endpoint}?{urlencode({'q': query, 'count': min(top_k * 2, 10)})}"
request = Request(url)
request.headers["Ocp-Apim-Subscription-Key"] = api_key
with urlopen(request, timeout=10) as response:
data = json.loads(response.read().decode("utf-8"))
docs = []
have_titles = {doc["title"].lower() for doc in already_have}
web_pages = data.get("webPages", {}).get("value", [])
for item in web_pages:
title = item.get("name", "")
if title.lower() in have_titles:
continue
snippet = item.get("snippet", "")
docs.append(
Doc(query=query, title=title, content=snippet)
)
if len(docs) >= top_k:
break
return docs if docs else fake_search(query, already_have, top_k)
except Exception:
# 网络错误、超时、鉴权失败都降级到 fake_search
return fake_search(query, already_have, top_k)
两个关键点值得注意。
兜底策略。第一层是环境变量检查,没配密钥直接退化为 fake_search。第二层是异常捕获,HTTP 请求可能超时、可能 401、可能 429,任何异常都降级到 fake_search。这不是为了偷懒,而是为了保证教程的可复现性------你不能因为 API 配额用完了就无法运行代码。
增量去重。bing_search 仍然接收 already_have 参数,仍然排除已见过的 title。真实搜索引擎会返回重复结果,reducer 累积出来的是一堆重复文档之前先去掉,这一步和 fake_search 保持一致。
3.4 search_node 的调用不变
python
def search_node(state: ResearchState) -> dict:
query = state["current_query"]
new_docs = bing_search(query, state.get("docs", [])) # 只是实现换了
return {
"docs": new_docs,
"executed_queries": [query],
"round": state.get("round", 0) + 1,
}
真的,就这么简单。node 接口不变,上游节点不用改,图拓扑不用改,唯一变化的就只有搜索质量的量级提升。这就是好的抽象该带来的体验。
3.5 其他节点保持不变
plan_node、evaluate_node、synthesize_node 都没变。它们调用的是 LLM,不是搜索工具;它们关心的始终是 state 字段的读写,不是外部 IO。工具的接入应该是局部的,不该扩散成全局重构。
四、tool node 模式的通用性
这一篇用的是 Bing API,但 tool node 模式不依赖具体工具。将来你可以替换成:
- 维基百科 API:查百科知识
- Wikipedia 检索 + 全文阅读
- 股票行情 API:查实时股价
- AlphaVantage / Yahoo Finance
- GitHub API:查仓库信息
- requests + BeautifulSoup:自定义爬虫
- 任意 REST API:数据库查询、内部服务调用
只要满足一个契约:接收查询参数,返回结构化结果(Docs),就能无缝接入图框架。这是 LangGraph 对工具调用的设计哲学:工具即 Node,工具和 LLM 节点平等地参与图的调度。
更进一步,tool node 可以组合。比如 plan_node 不只是拆查询,还可以判断"用户要查天气还是查股票",路由到 weather_tool 或 stock_tool。评估节点可以决定"这个搜索结果质量太低,换一个工具再查"。工具之间的协作,是下一篇之后的故事了。
五、实践建议
5.1 错误处理分层
真实的工具调用必须考虑三层容错:
- 配置层:环境变量缺失 → 降级 fallback
- 网络层:超时、连接错误 → 重试或直接 fallback
- 业务层:API 返回空结果、格式错误 → 清洗数据或直接 fallback
v1.1 做了最简单的一层(配置层),后续版本可以加 retry 逻辑(httpx + backoff)、加数据校验(pydantic)、加日志记录(structured logging)。
5.2 结果缓存
工具调用有成本(API 配额、响应时间),可以考虑缓存。常见做法是:
- 内存缓存:lru_cache 装饰器,进程内生效
- Redis 缓存:跨进程共享,支持过期时间
- 本地文件缓存:JSON 文件存储,适合离线下演示
缓存键通常是 query + 参数哈希,缓存值存 API 返回的原始 JSON 或清洗后的 Docs。缓存命中时跳过网络请求,直接返回历史结果。
5.3 并发检索
v1.1 仍是串行执行:一轮查完再评估,再决定下一轮。真实场景可以优化为并发:
- plan_node 拆出 3 个子查询
- 三个 search 节点并行执行(加并行子图)
- 汇总结果后进入 evaluate
这涉及到 LangGraph 的超步(superstep)概念和并发控制,是后面"流式输出"和"多智能体"要讲的内容,这里先埋个伏笔。
六、跑起来
bash
# 安装依赖
pip install langgraph==1.2.10 langchain==1.3.14 langchain-openai==1.4.1 python-dotenv
# 配置.env
BING_SEARCH_ENDPOINT=https://your-region.cognitive.microsoft.com
BING_SEARCH_API_KEY=your-key
OPENAI_API_KEY=your-llm-key
LLM_MODEL=deepseek-chat
# 运行
python -m src.v11_tool
如果没配 BING 密钥,代码自动退化到 fake_search,你仍能复现循环流程;如果配了,就能看到真实联网的效果------第一次检索可能只查到 LangChain 官方文档,第二轮根据缺口补查微软发布说明,第三轮综合成稿。
七、本篇小结
回到"工具调用"这块,v1 撞的墙是假检索,解法是接入 Bing Search API 并封装成 bing_search。Node 接口不变(query + already_have -> docs out),唯一的差异是从本地打分换成 HTTP 请求 + JSON 解析。我们同时强调了几个工程要点:兜底策略(没密钥降级到 fake_search)、错误处理分层、增量去重、工具即 Node。
v1.1 能真上网查了,但它还有个问题------每次 invoke 都是从头跑到尾,中途没法暂停、没法恢复、没法看历史记录。如果检索到第 2 轮发现方向错了,能不能回滚到第 1 轮重新来?如果用户想查看"刚才查了几次、每轮的结果是什么",有没有办法获取中间状态?
答案是有,而且需要 checkpoint。下一篇,持久化登场。
赞或收藏 关注 我们下次再见