什么是AI Agent?从Function Calling到LangGraph
适合读者:有Python基础、用过前端框架、想搞懂AI Agent到底怎么"调用工具"的人。
本文目标:让你亲手跑通一个完整的AI Agent,从最底层的Function Calling原理,到手写ReAct循环,再到用LangGraph优雅实现,最后加上记忆持久化和错误处理。
一、先破除一个误解:LLM并不会真的"调用"你的函数
很多初学者第一次接触Agent都会困惑:
"我把Python函数交给大模型,它就能自己执行?"
答案是:不会。LLM本质上还是一个文本生成模型。所谓的Function Calling / Tool Use,其实是这样一个流程:
-
你告诉LLM:我有这些工具,它们的名字、参数格式、作用是什么(以JSON Schema的形式)。
-
用户提问,LLM判断"这个问题需要借助工具才能回答"。
-
LLM不直接执行函数,而是返回一段结构化的指令,例如:
jsonjson json {"name": "get_weather", "args": {"city": "北京"}} -
你的Python代码解析这段指令 → 真正去调用函数 → 把结果再塞回对话历史。
-
LLM拿到结果后继续推理,直到能回答用户的问题为止。
前端类比:
LLM就像前端页面,工具就像后端的API接口。tool_calls不是"已经调用完了",而是"前端决定要发请求"。真正发起请求、拿到响应、再更新状态(setState)的是你的Agent运行时代码。
二、用 @tool + bind_tools 定义工具(Function Calling入门)
我们先从最简单的开始:定义两个工具(计算器和查天气),然后让LLM学会"调用"它们。
安装依赖
bash
bash
pip install langchain-openai langchain-core langgraph langgraph-checkpoint-sqlite python-dotenv
配置 .env 文件
ini
env
env
DEEPSEEK_API_KEY=sk-你的key
MODEL_NAME=deepseek-v4-flash
DEEPSEEK_BASE_URL=https://api.deepseek.com
最小可运行示例
python
python
python
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
load_dotenv()
# 初始化LLM(使用DeepSeek,兼容OpenAI接口)
llm = ChatOpenAI(
model=os.getenv("MODEL_NAME", "deepseek-v4-flash"),
temperature=0,
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
)
# 定义两个工具
@tool
def calculator(a: int, b: int) -> int:
"""计算两个整数相乘的结果。"""
return a * b
@tool
def get_weather(city: str) -> str:
"""查询指定城市的天气情况。"""
weather_db = {
"北京": "晴,25度",
"上海": "多云,28度",
"广州": "雷阵雨,30度"
}
return f"{city} 天气:{weather_db.get(city, '未知城市')}"
tools = [calculator, get_weather]
llm_with_tools = llm.bind_tools(tools)
# 测试:同时问两个问题
msg = llm_with_tools.invoke([HumanMessage(content="北京天气怎么样?再算12乘以34")])
print("LLM回复内容:", msg.content)
print("工具调用请求:", msg.tool_calls)
输出示例:
css
纯文本
纯文本
LLM回复内容:
工具调用请求: [ {'name': 'get_weather', 'args': {'city': '北京'}, 'id': 'call_abc123', 'type': 'tool_call'}, {'name': 'calculator', 'args': {'a': 12, 'b': 34}, 'id': 'call_def456', 'type': 'tool_call'}]
关键点:
@tool装饰器会自动把函数名、参数类型、docstring转换成JSON Schema,发送给LLM。bind_tools并不是"注册回调函数",而是把工具的描述信息附加到每次LLM请求的prompt中。- LLM返回的
tool_calls只是一个"请求列表",真正的工具执行还需要我们自己完成。
三、手写ReAct循环:不依赖框架,看清Agent的本质
ReAct = Reason(推理) + Act(行动)。
核心就是一个循环:Thought → Action → Observation → 再Thought。
手写版ReAct
python
python
python
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
# 建立工具名称到函数的映射
tool_map = {t.name: t for t in tools}
def react(question: str, max_rounds: int = 5):
messages = [HumanMessage(content=question)]
for i in range(max_rounds):
print(f"\n=== 第 {i+1} 轮 ===")
ai_msg: AIMessage = llm_with_tools.invoke(messages)
messages.append(ai_msg)
# 如果没有工具调用,说明LLM已经给出了最终答案
if not ai_msg.tool_calls:
return ai_msg.content
# 逐个执行工具
for call in ai_msg.tool_calls:
name = call["name"]
args = call["args"]
call_id = call["id"]
result = tool_map[name].invoke(args)
print(f"执行工具 {name}({args}) => {result}")
# 将工具结果包装成ToolMessage,并关联对应的tool_call_id
messages.append(ToolMessage(content=str(result), tool_call_id=call_id))
return "超过最大轮数,未得出答案"
# 测试
print(react("北京天气怎么样?再算12乘以34"))
运行过程:
dart
纯文本
纯文本
=== 第 1 轮 ===
执行工具 get_weather({'city': '北京'}) => 北京 天气:晴,25度
执行工具 calculator({'a': 12, 'b': 34}) => 408
=== 第 2 轮 ===
LLM回复:北京天气晴朗,25度;12乘以34等于408。
核心要点:
- 消息历史必须完整:每次调用LLM都要带上全部历史消息,包括之前的提问、LLM的回复、工具的执行结果。
ToolMessage必须携带tool_call_id:这个ID必须和前面AIMessage.tool_calls中的id一一对应,否则LLM会报错"tool消息没有对应的tool_calls"。- 熔断机制 :
max_rounds限制最大循环次数,防止LLM陷入死循环(比如一直调用工具而不给出最终答案)。
四、用LangGraph把ReAct写成"图"
手写while循环虽然直观,但工程化时会有不少问题:节点难以复用、条件分支多了代码混乱、想要添加记忆持久化或人工审核会很麻烦。
LangGraph的思路:把"调用LLM"和"执行工具"设计成两个节点,用有向边来控制流程流转。
4.1 定义状态
python
python
python
from typing import TypedDict, Annotated, Literal
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
这里的 add_messages 是一个Reducer(归约器),它的作用是:每次节点返回 {"messages": [...]} 时,不是覆盖原有的消息列表,而是把新消息追加进去。
前端类比:就像Redux中的combineReducers,messages这个状态会通过concat的方式更新,而不是整体替换。
4.2 定义节点和条件边
python
python
python
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode
def call_model(state: AgentState):
"""LLM推理节点"""
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response]}
tool_node = ToolNode(tools) # 内置的ToolNode会自动执行工具并返回ToolMessage
def should_continue(state: AgentState) -> Literal["tools", END]:
"""条件判断:是否需要继续调用工具"""
last_message = state["messages"][-1]
if isinstance(last_message, AIMessage) and last_message.tool_calls:
return "tools"
return END
# 构建图
builder = StateGraph(AgentState)
builder.add_node("agent", call_model)
builder.add_node("tools", tool_node)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
builder.add_edge("tools", "agent")
app = builder.compile()
流程图:
sql
纯文本
纯文本
START → agent → 有tool_calls? → tools → agent → ...
|-> 无tool_calls → END
4.3 运行测试
css
python
python
from langchain_core.messages import HumanMessage
result = app.invoke({"messages": [HumanMessage(content="北京天气怎么样?再算12乘以34")]})
print(result["messages"][-1].content)
输出:
纯文本
纯文本
北京天气晴朗,25度;12乘以34等于408。
对比手写版:
- 手写版用
while循环 +if-else判断;LangGraph用StateGraph+conditional_edges。 - 手写版需要手动管理消息列表;LangGraph用
add_messages自动累加。 - 手写版需要手动执行工具并构造
ToolMessage;LangGraph的ToolNode自动完成。
五、增加记忆持久化:SqliteSaver
手写ReAct重启后就丢失了对话历史;LangGraph通过Checkpointer可以将每一步的状态保存到数据库,下次启动时还能恢复。
5.1 使用SqliteSaver
ini
python
python
from langgraph.checkpoint.sqlite import SqliteSaver
# 使用with语句(新版LangGraph要求)
with SqliteSaver.from_conn_string("checkpoints.db") as checkpointer:
checkpointer.setup() # 初始化数据库表
app = builder.compile(checkpointer=checkpointer)
# 第一次对话,会话ID为"session1"
config = {"configurable": {"thread_id": "session1"}}
app.invoke(
{"messages": [HumanMessage(content="我叫小明,帮我算12乘以34")]},
config
)
# 第二次对话,同一会话,Agent应该还记得用户的名字
result = app.invoke(
{"messages": [HumanMessage(content="你还记得我叫什么吗?")]},
config
)
print(result["messages"][-1].content) # 输出:你叫小明呀!
# 新会话,Agent应该忘记之前的信息
config2 = {"configurable": {"thread_id": "session2"}}
result2 = app.invoke(
{"messages": [HumanMessage(content="你还记得我叫什么吗?")]},
config2
)
print(result2["messages"][-1].content) # 输出:抱歉,我没有关于您名字的记忆...
关键点:
thread_id就是会话标识,不同用户/不同会话用不同的id,互相隔离。- 同一个
thread_id再次invoke时,LangGraph会自动从数据库加载历史消息,无需手动拼接。 from_conn_string在新版LangGraph中是一个上下文管理器(Context Manager),必须用with语句包裹,或者在外部用sqlite3.connect手动创建连接后传入。- 文件型SQLite重启不丢失数据;
:memory:模式重启即空。
5.2 加上错误处理和熔断
在生产环境中,我们还需要:
- 重试机制 :LLM调用可能因为网络波动失败,设置
max_retries。 - 熔断机制:限制最大推理轮数,防止无限循环。
完整的Agent节点可以这样写:
python
python
python
MAX_ITERATIONS = 5 # 最大推理轮数
def call_model(state: AgentState):
# 统计已有的AI消息数量,判断是否达到上限
ai_messages = [m for m in state["messages"] if isinstance(m, AIMessage)]
if len(ai_messages) >= MAX_ITERATIONS:
return {"messages": [AIMessage(content="❌ 已达到最大推理次数,请简化问题。")]}
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response]}
同时在初始化LLM时加上重试参数:
ini
python
python
llm = ChatOpenAI(
model=os.getenv("MODEL_NAME", "deepseek-v4-flash"),
temperature=0,
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
max_retries=3, # 网络错误时最多重试3次
timeout=30 # 每次请求超时30秒
)
六、整体对照:从裸LLM到Agent
| 能力 | 裸LLM | 手写ReAct | LangGraph ReAct |
|---|---|---|---|
| 调用工具 | 不支持 | tool_calls + 手动执行 |
ToolNode 自动执行 |
| 多轮推理 | 自己拼历史 | while + messages |
StateGraph 循环边 |
| 条件分支 | if/else |
if tool_calls |
conditional_edges |
| 消息累积 | 手动append | 手动append | add_messages reducer |
| 持久记忆 | 自己存库 | 自己写文件 | SqliteSaver / Postgres |
| 生产扩展 | 难 | 中等 | 高(可加HITL、重试、观测) |
结论:
- 学原理:先手写ReAct。
- 做项目:用LangGraph手工图;再简单可直接用
create_react_agent(LangGraph预置)。 - 上生产:checkpointer + 限轮 + 工具超时降级 + 日志追踪一起上。
七、常见踩坑记录(Week 9 实战经验)
- **工具返回结果后,模型报错
role='tool'没有对应tool_calls**
→ 历史中前一条必须是带有tool_calls的AIMessage;使用ToolMessage(tool_call_id=...)回传,不要用普通HumanMessage。

- LangGraph状态越跑越"失忆"
→ 状态定义中messages没有使用Annotated[list, add_messages],导致节点返回值覆盖了整个消息列表。节点只需返回新增的消息,不要返回全量替换。 - **SqliteSaver报错
contextmanager或Invalid checkpoint**
→ 新版LangGraph中from_conn_string返回的是一个上下文管理器,必须用with语句包裹;或者手动创建sqlite3.Connection再实例化SqliteSaver。

- DeepSeek不调用工具
→ 确认使用了OpenAI兼容接口ChatOpenAI(base_url=...);工具docstring写清楚参数含义;temperature调低(0或接近0);不要传递旧版教程中的method="function_calling"参数,新版bind_tools按当前langchain-openai默认即可。 - 工具结果太大撑爆上下文窗口
→ 天气、搜索等工具只返回核心字段;超长文本截断到1000~2000字符再回传。
八、小结
AI Agent不是"LLM变聪明了自己跑代码",而是:
模型负责决策"用哪个工具、传什么参数";运行时负责执行工具、回传结果;图框架负责把"决策---执行---再决策"这个循环管起来。
最小技术栈:
- 工具定义:
@tool - 模型绑定:
bind_tools - 决策输出:
AIMessage.tool_calls - 执行回传:
ToolNode或手工ToolMessage - 循环编排:手写
while/ LangGraphStateGraph - 记忆持久:
SqliteSaver(开发)、PostgresSaver(生产)
按这个顺序从Day1跑通Function Calling、Day3手写ReAct、Day4改LangGraph、Day5加SqliteSaver,Agent底层就不虚了。
附录:Week 9 完整代码仓库结构 源码地址
bash
纯文本
纯文本
week9/
├── .env # API密钥配置
├── week9_day1.py # Function Calling 原理 + 3个工具定义
├── week9_day2.py # get_weather 接入 wttr.in 真实API + 多工具并行测试
├── week9_day3.py # 纯Python手写ReAct循环
├── week9_day4.py # LangGraph StateGraph版ReAct
├── week9_day5.py # SqliteSaver记忆持久化 + 重试 + 熔断
└── checkpoints.db # SQLite持久化文件(运行后自动生成)
希望这篇博客能帮你从零搭建起对AI Agent的理解。如果你有任何疑问或踩了新的坑,欢迎留言交流!
**