Skip to content
Charles Shao
Go back

LangGraph:用 StateGraph 把手搓 while 画成图

创建于:
v1.0.0
–views

前两篇先把这一套拆开:LangGraph 是运行时,create_agent 是日常入口;再划清了什么时候不必上框架。这篇从底下看起,把手搓系列里那个 while 画成图。

Agent 调度循环一篇里,循环是:带 tools 问模型,有 tool_calls 就执行、回灌,再问,直到没有 tool_calls 给出终答。报表走本地模拟的报表服务(广告平台的 Marketing API 都要鉴权,没有免费公开接口,这里用本地模拟代替),拆成 search_campaign 与 get_campaign_report,campaign_id 不能编。追投对上工具设计一篇,四个工具挂同一张图。目标是先看「双11-美妆拉新」的 ROI,再按激进档追投 11 月 1 日起两天。模型看见的还是 schema,只是这份 JSON 从 @tool 推出来,不是手写。这篇还没接持久化和人确认,跑完就结束。

Table of contents

Open Table of contents

一、两个节点对上那个 while

循环只做两件事:问模型;模型要工具时,执行并回灌。还要工具就再问一轮,不要了,问到的就是终答。手搓把这两步写在同一个 for 里,这边拆成两个节点。

agent 只问,对照手搓里的 create(..., tools=...),不查报表。tools 只执行并按 id 回灌,对照 dispatch,不再问。节点之间传一份 State,这篇只有 messages。

红框是两个节点:agent 问模型,tools 执行回灌;有 tool_calls 就去 tools,再回 agent;没有就到 END。

先把图里的四个词对上,后面几篇就不会把它们混在一起:

名字在这张最小图里是什么负责什么
Statemessages保存当前执行快照,沿节点继续传递
节点(node)agent、tools读取 State,完成一次计算,返回局部更新
边(edge)START → agent、tools → agent决定节点执行完以后去哪里
superstep同一轮可以并行执行的一批节点任务等这一批更新合并后,再进入下一轮

这张图每个 superstep 只有一个节点,所以看起来仍像串行的 while:先执行 agent,合并它返回的 AIMessage;下一个 superstep 执行 tools,合并 ToolMessage;再回到 agent。等后面用 Send 同时展开多个计划时,一个 superstep 才会出现多份并行任务,但「先执行本轮、合并 State、再进入下一轮」的节奏不变。

二、图上这几个调用

自己画图时,就是下面这段:

# StateGraph 用来把那两件事画成图,按「问 → 执行 → 再问」串起来,对照手搓里写在 while / for 里的顺序。
builder = StateGraph(State)  # 就是先立一张空白图,并规定节点之间传这种 State

# add_node 是在图上创建一个节点,指定这个节点执行哪个函数
builder.add_node("agent", agent)  # 创建节点名叫 agent,跑问模型那个函数。对照手搓里 create(..., tools=...)
builder.add_node("tools", ToolNode(TOOLS))  # 创建节点名叫 tools,跑执行并按 id 回灌,对照手搓里的 dispatch

# add_edge 是在两个节点之间的连线:START 不是你写的节点,是图的入口
builder.add_edge(START, "agent")  # 一开始一定先问模型

# add_conditional_edges 是一条活线,下一站由函数当场决定
# agent 问完模型之后,用 tools_condition 看出这轮有没有 tool_calls
builder.add_conditional_edges("agent", tools_condition)  # 有 tool_calls → tools;没有 → END,对照手搓里 if not tool_calls: return

# add_edge 是一条死线:tools 做完,一定回到 agent,对照手搓里执行完继续 for
builder.add_edge("tools", "agent")

# compile() 把上面的节点和连线收成一张能跑的图
graph = builder.compile()

# invoke 对照手搓里的 run():把初始 messages 丢进图,从 START 走到 END。
# 每到一个节点就跑一次,tools 回来再进 agent,直到没有 tool_calls。
# 返回的是走完之后的整份 State,轨迹都在 result["messages"] 里。
def main() -> None:
    result = graph.invoke(
        {
            "messages": [
                SystemMessage(content=SYSTEM),
                HumanMessage(content=GOAL),
            ]
        }
    )

compile 之后图才能跑。invoke 把初始 messages 从 START 送到 END,对照手搓里的 run()。返回的是整份 State,轨迹在 result["messages"] 里。

tools_condition 本身不会再问一次模型,它只是查看最后一条 AIMessage:存在 tool_calls 就返回 "tools",否则返回 END。真正执行函数的是 ToolNode;它按工具名找到 Python 函数,再用每个 tool_call_id 把结果包装成对应的 ToolMessage。这个 id 不能丢,否则下一轮模型无法判断哪份结果回应了哪次调用。

还要注意,节点通常不返回一份手动复制的完整 State。agent 只返回新生成的消息,tools 也只返回工具结果;LangGraph 再通过 messages 通道上的 add_messages 把局部更新并入现有历史。为什么这不是普通的列表赋值、并行写入时又为什么必须声明 reducer,留到状态契约篇展开。

这篇只有两个节点、三条边,是最小的一张。后面要加能力,步骤不变,多的是节点和边:

立图、加节点、连线、compile、invoke,是图 API 的常规顺序。日常循环不必从空白图画起,官方建议使用 create_agent,问和执行已经画好。@task / @entrypoint 是另一种函数式写法,这篇用不到。要对上手搓里的 while,需要把图画开,所以从这张最小的图写起。

三、@tool 怎么变成那份 schema

四个工具都挂了 @tool。装饰器做的事只有一件:根据函数,自动生成手搓里那段 TOOL_SCHEMAS。发给模型的不是 Python,是推出来的 JSON。

查计划和报表只有 name: str、campaign_id: str 和一句 docstring,对照看不出来。追投把三项都对上:docstring 对照 description,Literal[...] 对照 enum,类型和必填从签名来。

@tool
def search_adgroups(campaign_id: str, start_date: str) -> dict:
    """按计划 id 和追投开始日期搜索可追投的广告组,返回 id、可用档位和日预算。
    用户只给了计划名、还没有 adgroup_id 时先调它。不要用它下单。
    campaign_id 来自 search_campaign;start_date 格式 YYYY-MM-DD,例如 2026-11-01。"""

@tool
def boost_adgroup(
    adgroup_id: str,
    tier: Literal["conservative", "standard", "aggressive"],
    start_date: str,
    days: int,
) -> dict:
    """用 search_adgroups 返回的 adgroup_id 下追投单,提交即从账户余额预付扣款。
    没有 adgroup_id 时不要编一个,先搜。
    用户只是询问能追投多少或要花多少钱时不要调用。"""
你写的模型看见的
函数名 boost_adgroup"name": "boost_adgroup"
三引号那几句"description": "用 search_adgroups 返回的…"
adgroup_id: str参数 adgroup_id,类型 string,必填
tier: Literal["conservative", "standard", "aggressive"]"enum": ["conservative", "standard", "aggressive"]
days: int参数 days,类型 integer
if adgroup_id not in seen_adgroup_ids没有。函数体不进 schema

docstring 不是代码注释。读它的是模型:干什么、什么时候用、参数从哪来。日期格式写进描述,函数体里再验一次;验失败回灌期望格式,它下一轮会改。

schema 仍是软约束。缺字段、日期写错、tier 填「激进档」,要在函数里拦,回灌「期望是什么」,不要只回 invalid。Literal 能收窄取值,拦不住它编一个从没搜过的 AG-999。

search_adgroups 每返回一个广告组,就把 id 记进本次的 seen_adgroup_ids。boost_adgroup 先查这份名单,不在里面就回灌 error,不扣款。白名单不能写进 schema——写进去等于告诉模型「合法 id 是这些」,它反而会抄。

左边是模型看见的:@tool 的 docstring 和函数签名。右边是看不见的:函数体里的校验,以及 adgroup_id 白名单。

TOOLS 里执行类故意排前面。模型应靠 docstring 先搜再查、先搜后投,而不是按数组下标。搜广告组要 campaign_id,所以计划得先单独搜一轮。查报表等 campaign_id,追投等 adgroup_id;两份回灌都到了,就可以同一轮一起调。广告组是本地假数据,和工具设计一篇同一份,不打真实的投放接口。确认下单也还没接,停住留给后面那层。

四、对照手搓看差在哪

手搓 01 / 02这篇
while + dispatchagent ↔ tools 两个节点
messages.appendState 上的 add_messages
手写 TOOL_SCHEMAS@tool:docstring → description,Literal → enum
seen_adgroup_ids同一份,模型看不见
openai SDKinit_chat_model,.env 同一份
一轮 = 问 + 执行图上两步

循环形状没变。模型仍然只负责说出下一个工具;执行和回灌是你的代码,装在 ToolNode 里。先搜后投、id 从哪来、档位只能三项,都靠 schema 和函数体。这篇只留一个出口:没有 tool_calls 就终答。轮数上限和累计 token,后面再加。

五、跑起来看轨迹

完整代码在 agent-in-action/part2-agent-frameworks/langgraph/01-min-loop/。读环境和接模型在 env.py,各层 from env import chat_model。

cd part2-agent-frameworks/langgraph/01-min-loop
python agent.py

目标是「双11-美妆拉新 10 月 31 日的 ROI 怎么样?再帮我按激进档追投,2026 年 11 月 1 日起投两天。」一次跑出来的轨迹如下。报表是本地模拟数据,数字每次一样,模型的措辞会变;形状应是先搜计划、再搜广告组,campaign_id 和 adgroup_id 都回灌后再同一轮查报表、下单:

—— 轨迹 ——
SystemMessage        只能通过工具获取事实,不要编 ROI、campaign_id、日预算或 adgroup_id。没有 campaign_id 时先 search_campaign,再 get_campaign_report 或 search_adgroups。追投没有 adgroup_id 时先 search_adgroups,再 boost_adgroup。有依赖的下一步,等回灌之后再调,不要同一轮一起调。用户只是询问能追投多少或要花多少钱时不要下单。给出终答,带上 ROI、广告组名、档位、开始日期和预付金额。
HumanMessage         双11-美妆拉新 10 月 31 日的 ROI 怎么样?再帮我按激进档追投,2026 年 11 月 1 日起投两天。
AIMessage            要调 ['search_campaign']
ToolMessage          {"campaign_id": "CP-1024", "name": "双11-美妆拉新", "status": "active"}
AIMessage            要调 ['search_adgroups']
ToolMessage          {"start_date": "2026-11-01", "adgroups": [{"id": "AG-001", "name": "直播间引流-新客", "learning": true, "tiers": ["conservative", "standard"], "daily_budget": {"conservative": 180, "standard": 260}}, {"id": "AG-002", "name": "商品卡成交-老客", "learning": false, "tiers": ["conservative", "standard", "aggressive"], "daily_budget": {"conservative": 150, "standard": 220, "aggressive": 400}}, {"id": "AG-003", "name": "短视频种草-兴趣人群", "learning": true, "tiers": ["conservative"], "daily_budget": {"conservative": 110}}]}
AIMessage            要调 ['get_campaign_report', 'boost_adgroup']
ToolMessage          {"campaign_id": "CP-1024", "date": "2026-10-31", "roi": 1.8, "target_roi": 1.5, "spend": 3200, "gmv": 5760, "impressions": 412000, "clicks": 9870, "cvr": 0.031}
ToolMessage          {"status": "ok", "order_id": "BO-001", "adgroup": "商品卡成交-老客", "tier": "aggressive", "start_date": "2026-11-01", "days": 2, "total": 800}
AIMessage            双11-美妆拉新 10 月 31 日 ROI 1.8,高于目标 1.5,当天花费 3200 元、成交 5760 元。我已为「商品卡成交-老客」(AG-002)下了 aggressive 档追投,2026 年 11 月 1 日起投两天,预付 800 元,追投单号 BO-001。

终答里的 ROI 应与报表回灌一致。adgroup_id 来自搜索结果,tier 是 aggressive。三个广告组里只有过了学习期的 AG-002 支持 aggressive。跳过搜索、自造 id、档位填中文,先看 docstring 和白名单回灌。

你看到什么先查哪儿
不调工具,自己编 ROI、campaign_id 或日预算system 或工具描述
调了工具,终答对不上回灌有没有回到 messages;tools 有没有连回 agent
跳过搜索,直接 boost_adgroupboost_adgroup 的 docstring;白名单应回灌 error
tier 总填中文Literal 有没有进 schema;失败有没有回灌枚举
日期写成 11月1日docstring 里的格式;_start_date 的回灌
interrupt / 落盘相关报错下一篇 才接 checkpointer

图能转、先搜后投之后,下一篇停住再续,对上手搓上线那一层。


–views
Share this post on:

Previous Post
LangGraph:用 checkpointer 停住,确认之后再续
Next Post
LangChain 和 LangGraph:一套运行时,两个入口