Files
python-agent/docs/03-阶段三-主流Agent开发框架/07-阶段三综合实战-LangGraph完整Agent.md
T
2026-09-22 15:42:25 +08:00

183 lines
14 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 阶段三 · 综合实战:用 LangGraph + LangChain 搭一个完整 Agent
> 所属:阶段三 主流 Agent 开发框架(动手收尾)
> 定位:前 6 讲是"概念 + 片段代码",这一讲把它们**焊成一个能跑的真 Agent**。用 LangGraph 的 `StateGraph` 手搭 ReAct 循环,内置一个"剧本模型"离线驱动——不花一分钱、不需要 API Key,复制到本地就能看到完整的「思考→调工具→看结果→再思考→总结」循环。
## 配套文件
- 可运行代码:[`/workspace/code/阶段三/react_langgraph_agent.py`](../../code/阶段三/react_langgraph_agent.py)
- 前置依赖:
```bash
pip install langgraph langchain-openai langchain-community loguru
```
## 一次运行能学到什么
```bash
python3 code/阶段三/react_langgraph_agent.py
```
你会看到如下日志,这正是阶段三第 2 讲那张"图"的真实运行:
```
[think] 决定调用工具: ['get_weather'] # 第1轮思考→调天气
[think] 决定调用工具: ['calculator'] # 第2轮思考→调计算器
[think] 将给出最终答复: 北京今天晴... # 第3轮总结→结束
```
**问题只用了一句人话**("北京天气怎么样?再告诉我26加4的结果"),但 Agent 内部自动拆成了两步工具调用再加总结——这就是"循环"的价值。
## 代码结构(Part 1 ~ 5)
### Part 1:用 `@tool` 定义工具(对照第 1 讲)
```python
@tool
def get_weather(city: str) -> str:
"""查询指定城市的实时天气。当用户问天气时调用。"""
return {"北京": "晴 26°C"}.get(city, f"暂无{city}天气数据")
TOOLS = [get_weather, calculator]
```
- 一句 `docstring` 就是给模型的"使用说明",决定模型何时触发。
- 注意 `calculator` 把错误 `return` 成消息而非 `raise`——模型能看到错误自行换思路。
- LangGraph 的 `bind_tools(TOOLS)` 会自动从 `@tool` 提取 JSON Schema,**无需手写**。
### Part 2:离线"剧本模型"(关键设计)
```python
class ScriptedAgentModel(FakeListChatModel):
SCRIPT = [ "{"tool_calls": [...]}",
"{"tool_calls": [...]}",
"北京今天晴,气温26°C,高温加4后是30。" ]
```
- 用 LangChain 的 `FakeListChatModel` 按剧本顺序返回回复(离线演示,不依赖真实 API)。⚠️ 该模型已标记废弃,新代码建议改用 `GenericFakeChatModel`;示例仅演示离线流程,理解不受影响。
- `make_model()` 里再用一层包装,把剧本 JSON 翻译成真正的 `AIMessage(tool_calls=...)`,这样 LangGraph 的 `should_continue` 才能读到 `last.tool_calls` 并路由去 `ToolNode`。
> ⏳ **短期可不深究**:离线剧本里"剧本 JSON → `AIMessage(tool_calls=...)`"这一层转换的底层写法,以及 `FakeListChatModel` 已标记废弃、改用 `GenericFakeChatModel` 的细节,第一遍只需知道「它是用预置回复离线模拟真实模型」即可;生产用的是真模型,这段只服务"无 API Key 也能跑通循环"的演示目的。
- **为什么这么设计**:让循环在"不依赖真实 API"下完整跑起来。切真模型只需换 `build_agent(llm=真模型)`。
### Part 3:LangGraph 手搭 ReAct(核心,对照第 2 讲)
```python
graph = (
StateGraph(State)
.add_node("think", think) # LLM 思考节点
.add_node("tools", ToolNode(TOOLS)) # 预置工具执行节点
.add_edge(START, "think")
.add_conditional_edges("think", should_continue, {"tools": "tools", "end": END})
.add_edge("tools", "think") # ← 这一条边把工具接回思考 = 循环
.compile()
)
```
- `State`:`messages: Annotated[list, add_messages]` —— `add_messages` 让历史自动累加而非覆盖。
- `should_continue`:读最后一条 `tool_calls`,有就回 `tools`,否则去 `end`。
- **与阶段二手写版对照**:
- `think` 节点 = 手写版"调模型判断走 action 还是 content"。
- `ToolNode` = 手写版"解析 tool_calls + 执行 + id 配对回传"。
- `.add_edge("tools","think")` = 手写版的 `while` 循环 + `continue`。
> 🔬 **深度理解 · ReAct 循环的"图"实现机理**:
> 1. **本质**:循环不是"for / while",而是靠**一条把工具节点接回思考节点的边**(`.add_edge("tools", "think")`)+ 一个**决定何时退出循环的条件边**(`add_conditional_edges` 读 `last.tool_calls`)联合构造的。
> 2. **机制**:每次经过 `think`,模型要么返回 `tool_calls`(条件边路由到 `tools` 执行、把结果追加回 State,再沿 `tools→think` 回到思考节点),要么直接给答案(条件边路由到 `end`)。所以"循环 = 下游节点被一条边接回上游",而不是程序里的循环语句。
> 3. **为什么重要**:用图来表达循环,使每一轮都可插入任意节点(审批 / 日志 / 校验)、可观测、可受 `recursion_limit` 熔断约束——这正是"生产级 Agent"需要而裸链给不了的。
> 4. **易错点**:以为循环发生在 think 节点内部;其实离开 think 后由边决定走向——若条件边把"没有 tool_calls 的回复"也路由到 `tools`,会让 ToolNode 处理空调用而报错或卡死。
### Part 4:熔断 + 节点埋点(生产工程要点)
```python
result = graph.invoke(
{"messages": [...]},
{"recursion_limit": max_steps * 2}, # ★ 熔断: LangGraph 内部达到上限自动抛错
)
```
- **熔断**:LangGraph 默认**不限制循环轮数**。生产必须传 `recursion_limit`(相当于阶段二的 `max_turns`),防死循环烧钱。
- **节点埋点**:在 `think` 节点内打日志,打印"这一步决定调什么工具"——这就是 LangSmith 最小平替的雏形。
```python
# think 节点内的埋点
if getattr(resp, "tool_calls", None):
logger.info(f"[think] 决定调用工具: {[c['name'] for c in resp.tool_calls]}")
```
> 🔬 **深度理解 · recursion_limit 熔断的取舍**:
> 1. **本质**:它是 LangGraph 给单次执行设置的「节点总步数上限」,达到上限即抛错中止——是防 Agent 死循环烧钱的生命线。
> 2. **机制**:LangGraph 默认不限制循环轮数,`invoke(..., {"recursion_limit": N})` 传入步数上限;每执行一个节点算一步,超限即报 `GraphRecursionError`。这里用 `max_steps * 2` 是按"每轮约 2 步(think + tools)"做的粗估预留,并不精确对应业务语义。
> 3. **为什么重要**:设太小 → 正常多步任务没跑完就被截断;设太大 → 等于没熔断,模型死循环时白烧钱、浪费资源。必须结合任务复杂度与成本预算权衡。
> 4. **易错点**:以为它按"对话轮数"计(实际按"步数");也常忽略超限报错没有友好降级——生产里应捕获该异常并给出用户可读的兜底回复。
> ⏳ **短期可不深究**:把"节点里打日志"升级成一套完整自定义观测(结构化落盘、trace_id 关联、run 编号还原轨迹)的工程细节,第一遍只要领会「在 think 节点登记'这一步要调什么工具'就是最小可观测」即可;大规模上生产一般直接用 LangSmith 或 APM 中间件。
### Part 5:切真实模型
```python
# build_real_model 打开注释
from langchain_openai import ChatOpenAI
return ChatOpenAI(model="gpt-4o", temperature=0, api_key="sk-xxx")
# 换成 DeepSeek/Ollama: ChatDeepSeek(...) / ChatOllama(...) 仅改一处
```
- 从离线剧本切到真实模型,只需把 `build_agent()` 里的模型换成 `ChatOpenAI`(或 `ChatDeepSeek` / `ChatOllama`),其余图结构与工具代码**一行不改**。
> 🔬 **深度理解 · "换模型只改一行"背后的接口抽象**:
> 1. **本质**:离线剧本模型(`FakeListChatModel`)与 `ChatOpenAI` 能互换,是因为它们都实现了 LangChain 同一个 `BaseChatModel` 接口——图的节点只认接口,不认具体实现。
> 2. **机制**:图里的 `think` 节点只调用 `llm.invoke(...)` 与 `llm.bind_tools(...)`,而这些方法对一切 ChatModel 实现都成立;离线模型返回预置 AIMessage、真模型返回模型生成,在节点看来都是"一个会说话的对象"。
> 3. **为什么重要**:这正是 ChatModel 抽象的红利——把"模型"变成可替换的依赖(依赖倒置),让离线测试与真实上线共用同一套图与工具代码,显著降低测试与联调成本。
> 4. **易错点**:能换的只是"实现了同一接口"的模型;若某能力(如 tool_calls / 工具绑定)某个模型不支持,换过去会在运行时暴露——接口统一不等于能力统一。
## 进阶练习(把 Demo 改造成你的)
1. **加第三个工具**:往 `TOOLS` 加一个"查汇率",并在 `SCRIPT` 里加一段对应的工具调用剧本,看循环多一步。
2. **改剧本触发熔断**:把 `SCRIPT` 改成永远返回工具调用(永不给最终答案),观察 `recursion_limit` 如何把死循环截断。
3. **切真实模型**:装 `openai` 后打开 `build_real_model`,用一句真正需要两步工具的问题(先查天气再算温差)验证。
4. **加 State 业务字段**:在 `State` 里加一个 `tool_calls_counter`,在每个 think 节点自增,看它跨节点累加——体验"State 是全局背包"。
5. **对照两张图**:把本 Demo 生成的运行日志,和阶段三第 2 讲的"轨迹预览图"一条条对上(think→tools→think→tools→think→END)。
## 本节自检
- [ ] 能不看代码说出 LangGraph 版 ReAct 里"循环"是靠哪条边实现的
- [ ] 已跑通 `react_langgraph_agent.py`,并至少做对进阶练习的 1 和 2
- [ ] 能说清 `recursion_limit` 为什么是生产生命线、`ToolNode` 帮你省了哪些手写代码
## 本节配套思考题(快速入门的检验)
1. 把 `add_messages` 从 `State` 里删掉,循环会发生什么(历史覆盖导致模型失忆)?跑一下验证。
2. `should_continue` 读 `last.tool_calls`。如果真实模型既没调工具也没给答案,这里怎么兜底?
3. `recursion_limit` 设太小会怎样(任务做不完),设太大又会怎样(可能死循环烧钱)?你如何权衡?
4. 为什么这个 Demo 能把"换模型"收敛到只改一行?`FakeListChatModel` 和 `ChatOpenAI` 共享了哪个接口?(提示:`invoke` / `bind_tools`)
## 本节常见面试题(深度解析)
> 针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
### 面试题 1:LangGraph 版 ReAct 的"循环"到底靠什么实现?去掉 `tools→think` 那条边会发生什么?
- **面试官想考察**:是否真正理解"图上的边 + 条件路由"如何构成循环,而不是嘴上说"有个 while"。
- **专业作答(含深度)**:
1. 循环由两部分构成:`add_conditional_edges` 根据 `last.tool_calls` 决定"继续走 tools 还是结束",以及 `.add_edge("tools","think")` 把工具节点接回思考节点——后者让流程能"回到上一步"。
2. 机制:think 返回带 tool_calls 的调用 → 条件边路由到 tools 执行并回传 → tools 的边把控制权交回 think → 再判,形成闭环。
3. 若去掉 tools→think:执行完工具后流程没有回填路径,会被当作结束或直接抛错,Agent 做不了"调工具看结果再决策"的多步任务——这是"链"做不到而"图"能做到的本质。
4. 工程价值:这种循环可被可视化、可插入任意节点、可受 `recursion_limit` 熔断,可观测可控。
- **加分亮点 / 深度追问**:强调"循环 = 下游边接回上游"这一最小要件;追问常是"条件边不写映射会怎样"——答:路由函数返回的节点名必须在映射表里,否则等价于结束,静默丢任务。
### 面试题 2:`recursion_limit` 设太小 / 太大各自有什么后果?为什么说它是生产生命线?
- **面试官想考察**:对"熔断参数需要权衡"以及"死循环是 Agent 生产事故"的工程认知。
- **专业作答(含深度)**:
1. LangGraph 默认不限制循环轮数,必须显式传 `recursion_limit`(按节点步数计)作为上限,超限抛 `GraphRecursionError`。
2. 设太小:正常的多步任务没跑完就被截断,用户拿不到结果;设太大:等于没设熔断,模型陷入死循环时持续消耗 token 与算力、烧钱且不可控。
3. 权衡:结合业务任务的最大合理步数与成本预算来设,并留余量;对超时 / 超限统一做降级(给出兜底回复或重试)。
4. 配合手段:还可以用自定义计数节点、超时控制、幂等工具等手段,熔断只是兜底而非唯一防线。
- **加分亮点 / 深度追问**:提"它按步数不按轮数计"这个易错细节;追问常是"超限后怎么办"——答:捕获异常降级,必要时把已完成的部分结果回传给用户或升级人工处理。
### 面试题 3:为什么这个 Demo 能"换模型只改一行"?离线剧本模型有什么工程价值?
- **面试官想考察**:是否理解接口抽象(ChatModel)与"离线测试 / 真实上线共用代码"的设计价值。
- **专业作答(含深度)**:
1. 根因是依赖倒置:图与节点只依赖 `BaseChatModel` 接口(`invoke` / `bind_tools`),不依赖具体实现,`FakeListChatModel` 与 `ChatOpenAI` 都实现该接口,故可互换。
2. 离线剧本用预置 AIMessage 模拟"思考→调工具→总结",让循环在无 API Key 下也能完整跑通,可用于 CI、教学和确定性测试。
3. 上线时换 `ChatOpenAI`(或 `ChatDeepSeek` / `ChatOllama`)只改一处构造,图结构与工具代码一行不改。
4. 注意边界:接口统一不等于能力统一,若目标模型不支持 tool_calls 等能力,运行时才会暴露。
- **加分亮点 / 深度追问**:把"接口抽象 + 依赖注入"提升为一般软件设计原则;追问常是"为什么离线/真实要共用同一套图"——答:保证测试与生产行为一致,降低联调与回归成本。