《零基础 AI 入门》第 6 篇|作者:梯度下降的咖啡师 | 8 年 Java 后端(京东、字节)+ 2 年 AI 应用开发

前五篇写完,你手上有了一个手写的 Agent:while 循环里调模型、执行工具、压缩记忆,一百来行代码,改个 key 就能跑。上一篇我们给它做了生产体检,列了三个坑——持久化、多用户隔离、工具权限,还说了这些 LangGraph 都有现成答案。

今天就干一件事:把前面这一百行重写成 LangGraph 版。但不是推翻重来——你的工具函数一个字不改,你的 system prompt 不改,你对 Agent 循环的理解不改。只是把 while True 换成图,把全局 messages 换成带 thread_id 的状态。

01 你的 while 循环,其实就是一张图

回顾一下第三篇你写的 Agent,核心就是这段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
while True:
    response = client.chat.completions.create(messages=messages, tools=tools)
    messages.append(response.choices[0].message)

    if not response.choices[0].message.tool_calls:
        break

    for tc in response.choices[0].message.tool_calls:
        result = dispatch(tc)
        messages.append(result)

你仔细看这段代码:先调模型,然后判断有没有 tool_calls——有就执行工具再循环回去,没有就退出。这其实就是一个有循环、有分支的流程图。你手写的时候已经在画流程图了,只是用 while 和 if 表达的。

LangGraph 做的事,就是把这个流程图变成一等公民:你不再手写 while 和 if,而是声明"这里有个节点叫 agent,这里有个节点叫 tools,agent 执行完看情况决定下一步去哪"。

02 三个概念,没有第四个

别被 LangGraph 的文档吓到,入门只需要三个概念:

State:就是你的 messages 列表。你用 TypedDict 声明一下这个状态里有什么字段。和你之前的全局 messages 本质一样,只是现在框架帮你管它怎么更新。

Node:就是你的函数。接收当前 State,返回 State 要更新的部分。你的 agent_node 就是调模型,你的 tools_node 就是执行工具——和你之前写的函数一模一样。

Edge:就是你 while 循环里的 if 判断。声明"执行完这个节点后,下一步去哪个节点"。有固定边(无条件跳转)和条件边(根据 State 内容决定去哪)。

就这三个。没有 Chain、没有 Runnable、没有 LCEL。你在前面四篇手写的东西,全对应得上。

LangGraph 版的节点关系图:START→agent→条件边→tools/END

03 完整代码,一百行不到

先装依赖:

1
pip install langgraph langchain-openai

这里 LLM 客户端从之前的 OpenAI SDK 换成了 ChatOpenAI——别紧张,它就是个发 HTTP 请求的客户端,和你之前用 client.chat.completions.create 本质一样。区别只是它自动处理消息格式转换,省得你自己在 dict 和模型消息对象之间来回转。工具函数和 schema 直接从第三篇复制,一个字不改:

 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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
from typing import Annotated, TypedDict

from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import InMemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, ToolMessage

# ===== 工具函数:和第三篇一模一样 =====
def get_weather(city: str) -> str:
    return f"{city}今天晴,25度"

def get_aqi(city: str) -> str:
    return f"{city}AQI 50,优"

tool_map = {"get_weather": get_weather, "get_aqi": get_aqi}

# ===== 工具 schema:手写 JSON,不用装饰器 =====
tools = [
    {"type": "function", "function": {
        "name": "get_weather",
        "description": "查询城市天气",
        "parameters": {"type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"]}
    }},
    {"type": "function", "function": {
        "name": "get_aqi",
        "description": "查询城市空气质量",
        "parameters": {"type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"]}
    }},
]

# ===== State:就是你的 messages =====
class AgentState(TypedDict):
    messages: Annotated[list, add_messages]

# ===== 节点1:调模型 =====
model = ChatOpenAI(model="deepseek-v4-flash", api_key="你的DeepSeek API Key", base_url="https://api.deepseek.com")
model_with_tools = model.bind_tools(tools)

def agent_node(state: AgentState):
    response = model_with_tools.invoke(state["messages"])
    return {"messages": [response]}

# ===== 节点2:执行工具 =====
def tools_node(state: AgentState):
    last = state["messages"][-1]
    results = []
    for tc in last.tool_calls:
        try:
            result = tool_map[tc.name](**tc.args)
        except Exception as e:
            result = f"工具调用失败: {e}"
        results.append(ToolMessage(content=result, tool_call_id=tc.id))
    return {"messages": results}

# ===== 条件边:有 tool_calls 去 tools,否则结束 =====
def should_continue(state: AgentState):
    last = state["messages"][-1]
    if last.tool_calls:
        return "tools"
    return END

# ===== 拼图 =====
builder = StateGraph(AgentState)
builder.add_node("agent", agent_node)
builder.add_node("tools", tools_node)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
builder.add_edge("tools", "agent")

# ===== 加 checkpointer =====
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

# ===== 跑起来 =====
config = {"configurable": {"thread_id": "user-001"}}

graph.invoke({"messages": [HumanMessage(content="北京天气怎么样?")]}, config)
graph.invoke({"messages": [HumanMessage(content="那空气质量呢?")]}, config)

注意最后两行:同一个 thread_id 调了两次 invoke。第二次问"那空气质量呢",模型知道"那"指的是北京——因为 checkpointer 自动把第一次的 messages 存下来了,第二次 invoke 时按 thread_id 自动加载。你不需要自己 json.load、不需要自己用 dict 按 thread_id 存。

04 手写版 vs LangGraph 版,对照一下

把你手写时自己管的事,和 LangGraph 替你做的事拉个表:

你手写时自己管的事LangGraph 替你做了什么
while True 循环图的边自动流转,tools 节点执行完回 agent
if not tool_calls: break条件边 should_continue 返回 END 就停
messages.append(新消息)add_messages reducer 自动追加,不用手动 append
for tc in tool_calls: dispatchtools_node 里照样手写 dispatch,这部分没替你省
messages 存内存 dict,重启就丢checkpointer 自动存快照,传 thread_id 自动加载
多用户按 thread_id 手动隔离框架按 config 里的 thread_id 自动存取状态

看出来了吗?LangGraph 没有魔法。它只是把你手写时"本来就该这么做"的事,变成了框架的默认行为。你手写时最容易漏的边角情况——tool_call 没有对应 tool result 怎么办、不同 thread 的状态怎么隔离——框架替你兜住了。

有一点它没替你省:工具 dispatch 那段(for tc in tool_calls 然后调函数),还是你自己写的。因为工具逻辑是你自己的业务,框架不知道你要调什么函数。但 tc.args 已经是解析好的 dict 了——不用你自己 json.loads(tc.function.arguments)。这个 JSON 字符串解析和校验的活儿,如果你调试时需要看工具参数长什么样,可以丢到 ForJSON(forjson.com)里格式化一下,比在终端 print 清楚。

05 你现在还缺什么

跑通这段代码,你的 Agent 已经有了:图结构、自动状态管理、thread_id 隔离、内存级记忆。但上一篇体检说的三件事,你还只解决了两件半:

持久化:现在用的是 InMemorySaver,进程退了还是丢。下一篇换成 SqliteSaver,代码只改一行——checkpointer = SqliteSaver.from_conn_string(“session.db”)。

多用户隔离:thread_id 已经能用了,但你还没真把它包成 Web 服务试过并发。这个等部署篇再说。

工具权限:现在工具还是全暴露给模型,高风险操作没有人工审批。下一篇加 Interrupt——在 tools_node 之前设个断点,要发邮件、删数据之前暂停等人确认。这是 LangGraph 另一个核心能力。

06 写在最后

从第一篇"什么是 Function Calling"到现在,你手写了一个完整的 Agent,又用 LangGraph 把它重写了一遍。你不是在学一个框架 API,你是在看一个框架的每个设计决策,怎么对应你手写时踩过的坑。

这就是为什么我一直说别上来就用 LangChain——你没手写过,直接看 StateGraph、add_messages、reducer 这些词会觉得莫名其妙。但你手写了五篇之后再看,会发现它们每个都在解决你刚经历过的问题。

下一篇,给这个 LangGraph 版 Agent 加人工审批:遇到高风险操作自动暂停,等人点确认再继续。顺便把内存 checkpointer 换成 SQLite,真正做到重启不丢。

旧炉未冷,新火已燃。关注公众号「梯度下降的咖啡师」,或访问 gradcoffee.com。下一篇给 Agent 装个人工刹车,看看 Interrupt 到底怎么用。


聊聊两个问题:

① 你手写 while 循环时,最想吐槽 LangGraph 哪点——还是觉得它省得不够多?

② 你现在的 Agent 有几个工具?换图结构的时候哪个部分最绕?

咖啡续命,梯度下降。| 作者:梯度下降的咖啡师