现在做 AI 应用的人,几乎都绕不开 LangChain 和 LangGraph 这两个名字。
大模型本身只会聊天,你问一句它答一句。要把这种能力变成能干活的应用,还得给它接上工具,查数据库、调接口、读文件,还要记住多轮对话里聊过什么。流程长一点,需要能在中间停下来等人确认;跑上十分钟的任务中途挂了,得从断点续上,而不是从头再来。这些事情每件都不难,凑在一起就是一套基础设施。
LangChain 和 LangGraph 就是这套基础设施。LangChain 把模型、消息、工具收拢成统一抽象,一个 create_agent 就能把模型和工具组装成会自己调用的智能体。LangGraph 负责编排,把执行过程变成一张有状态的图,每一步都存档,可以暂停、恢复、回放,也能把写死的流程和模型自己决定的步骤混在一张图里。Klarna、Uber、J.P. Morgan 已经在用 LangGraph 跑生产环境的智能体流程,这是官方文档列出来的客户,它不再是演示玩具。
这个系列我一共写 12 篇,前 10 篇循序渐进讲知识点,最后 2 篇各做一个完整项目收尾,一个个人知识库,一个个人旅游助手。每篇都带可运行的源码,源码用 uv 管理,测试通过后我才提交。
第一篇只做一件事,把项目骨架立起来,让后面每一篇都有一个能跑、能测、能继续加功能的位置。

先把生态看清楚
装包之前先花几分钟看清这四个产品的关系,不然后面很容易把框架和平台混为一谈。
LangChain 官方文档把产品分成四层。Deep Agents 是开箱即用的智能体外壳,自带上下文压缩、虚拟文件系统和子智能体能力,适合快速起步。LangChain 是智能体框架,入口是 create_agent 这一个函数,把模型、工具、提示和中间件组装成可定制的智能体。LangGraph 是底层编排运行时,负责有状态工作流的持久化执行、流式输出和人工介入。LangSmith 是商业平台,做追踪、评估和部署,跨框架可用,不用它也不影响框架本身工作。

我把系列主线定在 LangChain 加 LangGraph。Deep Agents 那些高级能力后面如果用到会单独讲,LangSmith 只在需要调试时提一句,不放进主线。原因很简单,这两个是开源库,代码就在你手里,讲清楚每一行做了什么,这个系列才算数。
2025 年 10 月 LangChain 发布了 1.0 版本,这次大改动把主包收窄到只剩 create_agent 一个高级抽象,以前版本里的 chains、retrievers 等旧 API 挪到了 langchain-classic 包。LangGraph 也在差不多时候发布了 1.0。网上还有大量 0.x 时代的教学文章, import 路径和写法对不上,照着抄会报一堆 ImportError。这个系列我从头就按 1.x 的写法来。
工具链和版本
这个系列我用 Python 3.14,本机实际版本 3.14.3,依赖管理用 uv。命令执行结果如下。
1
2
| uv --version
python --version
|
1
2
| uv 0.11.7 (9d177269e 2026-04-15 x86_64-pc-windows-msvc)
Python 3.14.3
|
源码我统一放在仓库的 source 目录,和 articles 平级。进去以后执行 uv init --bare,--bare 表示只生成项目描述文件,不要示例代码和 README。
1
2
3
| mkdir source
cd source
uv init --bare --python 3.14
|
1
| Initialized project `source`
|
uv 生成的项目名取自目录名,叫 source。这个名字没有信息量,我把它改成 xneng-langchain-langgraph,和仓库名保持一致,读者在 GitHub 上看到的名字和本地项目名对得上。
1
2
3
4
5
| [project]
name = "xneng-langchain-langgraph"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = []
|
接着装依赖。四个包,各有分工。
1
| uv add 'langchain==1.4.3' 'langgraph==1.2.13' 'langchain-openai==1.7.2' 'pytest==9.1.1'
|
langchain 是智能体框架本体,langgraph 是编排运行时,langchain-openai 是 OpenAI 模型的接入包,pytest 跑测试。这里我把版本锁到了小版本。我给教程系列定了条硬规矩,读者按命令执行时结果要稳定。只写 langchain>=1.4 的话,下个月一个新版本就可能让后面的代码和截图对不上。实际产品则应该跟踪安全更新,这两件事不冲突,教程追求可复现,产品追求可维护。
写入 pyproject.toml 的结果如下。
1
2
3
4
5
6
7
8
9
10
| [project]
name = "xneng-langchain-langgraph"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"langchain==1.4.3",
"langchain-openai==1.7.2",
"langgraph==1.2.13",
"pytest==9.1.1",
]
|
uv add 同时生成 uv.lock,这个阶段共锁定 51 个包。直接依赖只有上面四个,其余都是传递依赖。其中 langchain-core 锁定 1.6.12,LangChain 的消息、工具、模型基类都住在这里,后面写类型注解会经常 import 它。langsmith 锁定 0.14.11,LangChain 的追踪数据默认发给它,没有 API key 时只会在本地静默跳过,不影响运行。
检查锁文件和 pyproject.toml 是否一致,用这条命令。
1
| Resolved 51 packages in 1ms
|
uv.lock 比 requirements.txt 更适合当锁文件,因为它记录完整解析结果和哈希。以后升级依赖时,不要只看 pyproject.toml 的几行 diff,还要看 uv.lock 的 diff,一个看起来很小的声明变化可能带来一串传递依赖升级。
第一个 Agent
依赖装好后写第一个示例。LangChain 1.x 的入口就是 create_agent,它接收三样东西,模型、工具列表、系统提示,返回一个编译好的图。官方文档的快速示例是查询天气,我也从这里开始。
新建 hello_agent.py。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
| """第 1 篇示例:第一个 LangChain Agent。
用 create_agent 把「模型 + 工具 + 系统提示」组装成一个会调用工具的智能体。
模型来源:
- 设置环境变量 AGENT_MODEL(例如 openai:gpt-5.5)时使用真实模型,
需要对应提供商的 API key;
- 未设置时使用 ScriptedChatModel 在本地演示完整的工具调用循环,
不需要任何 API key。
"""
import os
from langchain.agents import create_agent
from langchain_core.messages import AIMessage
from fake_models import ScriptedChatModel
def get_weather(city: str) -> str:
"""查询指定城市的天气。"""
return f"{city}今天晴天,气温 26℃,适合出行。"
def build_agent():
"""组装 Agent:优先使用环境变量配置的真实模型。"""
model_name = os.environ.get("AGENT_MODEL")
if model_name:
from langchain.chat_models import init_chat_model
model = init_chat_model(model_name)
else:
# 未配置真实模型时,用脚本化模型演示完整循环:
# 第一轮模型决定调用工具,第二轮拿到工具结果后生成回答
model = ScriptedChatModel(
messages=iter(
[
AIMessage(
content="",
tool_calls=[
{"name": "get_weather", "args": {"city": "北京"}, "id": "call_1"}
],
),
AIMessage(content="北京今天晴天,气温 26℃,适合出行。"),
]
)
)
return create_agent(
model=model,
tools=[get_weather],
system_prompt="你是一个简洁的天气助手,回答不超过两句话。",
)
def main() -> None:
agent = build_agent()
result = agent.invoke({"messages": [{"role": "user", "content": "北京天气怎么样?"}]})
print("== 消息流 ==")
for message in result["messages"]:
print(f"[{message.type}] {message.content}")
for call in getattr(message, "tool_calls", None) or []:
print(f" -> 调用工具 {call['name']}({call['args']})")
if __name__ == "__main__":
main()
|
get_weather 就是一个普通 Python 函数。框架从函数签名读取参数,从 docstring 生成工具描述,模型看到的就是这份描述。工具函数写错类型注解,模型调用时就会收到校验错误,这份约束是实打实的。
运行这个示例。
1
| uv run python hello_agent.py
|
1
2
3
4
5
6
| == 消息流 ==
[human] 北京天气怎么样?
[ai]
-> 调用工具 get_weather({'city': '北京'})
[tool] 北京今天晴天,气温 26℃,适合出行。
[ai] 北京今天晴天,气温 26℃,适合出行。
|
四条消息走完了一个完整的智能体循环。用户提问后,第一轮模型没回答,输出了一次工具调用请求,参数是 {'city': '北京'}。框架执行 get_weather,把返回值作为 tool 消息追加进历史。第二轮模型读到工具结果,生成最终回答。

这个循环会一直转到模型不再请求工具为止。中间任何一轮模型觉得信息不够,可以再调一次工具,甚至换一个工具。这就是 Agent 和普通函数调用的区别,控制流由模型在运行时决定,不用写死在代码里。
create_agent 内部就是把 LangGraph 的图搭好了,模型节点和工具节点交替执行。所以它返回的对象可以直接 .invoke(),也可以用 LangGraph 的方式 .stream()。讲模型抽象时,会看到这个循环的每一步都可以单独配置。
模型从哪来
示例里我最想解释的是 ScriptedChatModel。没有它,读者没有 API key 就跑不了这个程序,我的教程会断在第一篇。
真实模型的接入方式只有一行。设置环境变量,模型名按 提供商:模型名 的格式传给 init_chat_model。
1
2
3
4
5
| # Linux / macOS
export AGENT_MODEL=openai:gpt-5.5
# Windows PowerShell
$env:AGENT_MODEL="openai:gpt-5.5"
|
OpenAI 的 API key 用 OPENAI_API_KEY 环境变量传入,langchain-openai 包会自动读取。Anthropic、Google 等提供商换成对应的接入包即可,官方集成列表里有上百个,接口都长一个样。
没有 key 的时候,示例需要一个本地替身。LangChain 自带的 GenericFakeChatModel 可以按脚本返回预设消息,但它没有实现 bind_tools,而 create_agent 内部一定会调用这个方法绑定工具,直接传会报 NotImplementedError。所以有了 fake_models.py,继承它补上 bind_tools。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| """脚本化聊天模型(测试替身)。
两个用途:
1. 本地没有模型 API key 时,完整演示 Agent 的工具调用循环;
2. 作为测试替身,让单元测试不依赖外部 API,随时可跑。
GenericFakeChatModel 会按预设顺序返回消息,但它没有实现 bind_tools,
而 create_agent 内部会调用 bind_tools 绑定工具,所以这里补上。
"""
from collections.abc import Sequence
from langchain_core.language_models import GenericFakeChatModel
from langchain_core.tools import BaseTool
class ScriptedChatModel(GenericFakeChatModel):
"""按脚本依次返回消息,并支持 bind_tools 的聊天模型。"""
def bind_tools(
self, tools: Sequence[BaseTool], **kwargs: object
) -> "ScriptedChatModel":
"""create_agent 会调用该方法绑定工具,脚本化模型直接返回自身。
是否触发工具调用由预设消息里的 tool_calls 决定,与工具绑定无关。
"""
return self
|
这个替身我后面每篇都会用到。测试里断言工具循环是否走通,靠的就是它预设的两条消息,第一条带 tool_calls,第二条是最终回答。真实模型的输出有随机性,写不进断言。
第一个 LangGraph 应用
Agent 示例里 LangGraph 藏在框架内部。现在直接用它写一个应用,把图的概念摆到台面上。
新建 hello_graph.py。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| """第 1 篇示例:第一个 LangGraph 应用。
用 StateGraph 定义一个只有单个节点的图:
START -> greet -> END
节点函数读取输入消息,返回一条 AI 消息;MessagesState 自带
add_messages 归约器,节点返回的消息会追加进消息历史。
"""
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import MessagesState
def build_graph():
"""构建并编译图:一个节点,一条直线。"""
def greet(state: MessagesState):
"""节点函数:根据用户输入生成一句问候。"""
user_text = state["messages"][-1].content
reply = f"你好,我是运行在图上的第一个节点,收到了:{user_text}"
return {"messages": [{"role": "ai", "content": reply}]}
graph = StateGraph(MessagesState)
graph.add_node("greet", greet)
graph.add_edge(START, "greet")
graph.add_edge("greet", END)
return graph.compile()
def main() -> None:
app = build_graph()
result = app.invoke({"messages": [{"role": "user", "content": "很高兴认识你"}]})
print("== 消息流 ==")
for message in result["messages"]:
print(f"[{message.type}] {message.content}")
if __name__ == "__main__":
main()
|
跑一下。
1
| uv run python hello_graph.py
|
1
2
3
| == 消息流 ==
[human] 很高兴认识你
[ai] 你好,我是运行在图上的第一个节点,收到了:很高兴认识你
|
这个图的全部内容就是三行结构加一个节点函数。StateGraph(MessagesState) 声明状态的类型,MessagesState 是框架内置的状态类,只有一个 messages 字段。add_node 注册节点,add_edge 连接边,compile() 之后才得到可执行的应用。

这里有两个细节。
节点函数收到的是完整状态,返回的是增量。greet 只返回一条新消息,没有返回整个历史。MessagesState 的 messages 字段自带 add_messages 归约器,节点返回的消息会追加到列表尾部,而不是覆盖。后面的篇章会频繁和归约器打交道,累加、覆盖、去重,都靠它。
START 和 END 是框架内置节点,不是字符串占位。每条边都必须是确定的目标,图编译时会检查所有节点可达。现在这张图只有一条路,第 8 篇讲条件路由时会看到从一个节点分出多条边,靠状态值决定走哪条。
用测试锁住骨架
项目刚建好,我想让测试回答三个问题。工具函数的行为是否符合预期,Agent 的工具循环是否走通,图是否从 START 走到 END。
新建 tests/test_hello_agent.py。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| """第 1 篇示例测试:Agent 的工具调用循环。"""
from hello_agent import build_agent, get_weather
def test_get_weather():
"""工具函数返回包含城市名与天气描述的字符串。"""
assert get_weather("北京") == "北京今天晴天,气温 26℃,适合出行。"
def test_agent_runs_full_tool_call_loop(monkeypatch):
"""无 AGENT_MODEL 时,Agent 用脚本化模型走完整循环:
模型请求调用工具 -> 工具执行 -> 模型生成最终回答。
"""
monkeypatch.delenv("AGENT_MODEL", raising=False)
agent = build_agent()
result = agent.invoke({"messages": [{"role": "user", "content": "北京天气怎么样?"}]})
# 工具执行结果进入消息历史
tool_messages = [m for m in result["messages"] if m.type == "tool"]
assert len(tool_messages) == 1
assert "晴天" in tool_messages[0].content
# 最后一条是模型读完工具结果后的回答
assert result["messages"][-1].type == "ai"
assert "适合出行" in result["messages"][-1].content
|
第二条测试是整个仓库的第一道保险。它不断言模型说了什么,那由真实模型决定,它断言的是结构,消息历史里必须出现一条 tool 消息,最后一条必须是 ai。哪天升级 LangChain 后工具循环的实现变了,这条测试会先失败。
monkeypatch.delenv 删掉 AGENT_MODEL,保证测试环境一定走脚本化模型。不然测试在配了 key 的机器上会去打真实 API,又慢又花钱。
再新建 tests/test_hello_graph.py。
1
2
3
4
5
6
7
8
9
10
11
12
13
| """第 1 篇示例测试:单节点图的执行。"""
from hello_graph import build_graph
def test_graph_runs_from_start_to_end():
"""图从 START 走到 END,用户消息在前,AI 回答在后。"""
app = build_graph()
result = app.invoke({"messages": [{"role": "user", "content": "很高兴认识你"}]})
assert result["messages"][0].type == "human"
assert result["messages"][-1].type == "ai"
assert "第一个节点" in result["messages"][-1].content
|
pytest 默认把测试文件所在目录加进导入路径,tests 不在 source 根下,from hello_agent import ... 会找不到模块。在 pyproject.toml 里加两行,把项目根加进 pythonpath。
1
2
| [tool.pytest.ini_options]
pythonpath = ["."]
|
现在跑测试。
1
2
3
4
5
6
7
8
9
10
11
| platform win32 -- Python 3.14.3, pytest-9.1.1, pluggy-1.7.0
rootdir: D:\projects\AI\xneng-langchain-langgraph\source
configfile: pyproject.toml
plugins: anyio-4.15.1, langsmith-0.14.11
collected 3 items
tests/test_hello_agent.py::test_get_weather PASSED [ 33%]
tests/test_hello_agent.py::test_agent_runs_full_tool_call_loop PASSED [ 66%]
tests/test_hello_graph.py::test_graph_runs_from_start_to_end PASSED [100%]
============================== 3 passed in 1.06s ==============================
|
三条全绿。我把这个阶段的验证命令整理成四条,你克隆仓库后依次执行,就能确认环境正常。
1
2
3
4
| uv sync
uv lock --check
uv run python hello_agent.py
uv run pytest
|
生成物不该进入 Git
source 目录里现在有 .venv 和 .uv-cache,运行测试还会生成 __pycache__ 和 .pytest_cache。这些对本机开发有用,对版本控制没有意义,.venv 里的文件带绝对路径,换台机器就不能用。
仓库根目录的 .gitignore 已经覆盖了这些情况。
1
2
3
4
5
6
7
8
| .uv-cache/
.uv-python/
.venv/
venv/
__pycache__/
.pytest_cache/
.env
.env.*
|
.env 一定要排掉。OSS 密钥、模型 API key 都在里面,误提交一次就得全部轮换。我放了一份 .env.example 在项目里,只留键名不留值,告诉读者需要配什么。
提交
仓库的第一笔提交是基线,放任务说明书、参考文章和辅助脚本。项目代码从第二笔提交开始单独推进,每篇对应一笔,读者看到某一篇时,能通过对应提交回到当时的源码状态。
1
2
3
| git status --short
git add source articles
git commit -m 'feat(stage-01): 01 项目初始化'
|
提交成功后,版本库里新增 9 个文件,文章、源码、锁定文件和两个测试文件都在里面。git log --oneline 能看到两笔提交,基线一笔,本篇一笔。
1
2
| feat(stage-01): 01 项目初始化
chore: 初始化仓库基线
|
现在 source 目录是这样。
1
2
3
4
5
6
7
8
9
10
| source/
├── fake_models.py # 脚本化模型,无 key 演示与测试用
├── hello_agent.py # 第一个 Agent
├── hello_graph.py # 第一个 LangGraph 应用
├── pyproject.toml # 项目与依赖声明
├── uv.lock # 锁定文件
├── README.md # 阶段说明
└── tests/
├── test_hello_agent.py
└── test_hello_graph.py
|
第一篇做完,仓库有了明确的运行时,Python 3.14、LangChain 1.4.3、LangGraph 1.2.13,依赖锁死,三条测试,两个能跑的示例。后面每加一个知识点,都可以重复这套流程,写示例,跑起来,加测试,提交。
最后补一句我对版本的取舍。教程锁小版本是为了可复现,实际项目应该跟着安全更新走,升级前读读 release notes,看看不兼容变化,再跑一遍这个仓库的测试。测试先绿,再考虑合并。
源码
https://github.com/yidao620c/xneng-langchain-langgraph
参考