前两篇先把这一套拆开: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。
先把图里的四个词对上,后面几篇就不会把它们混在一起:
| 名字 | 在这张最小图里是什么 | 负责什么 |
|---|---|---|
| State | messages | 保存当前执行快照,沿节点继续传递 |
| 节点(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,留到状态契约篇展开。
这篇只有两个节点、三条边,是最小的一张。后面要加能力,步骤不变,多的是节点和边:
- 追投、确认、压缩,各加一个
add_node - ROI 达标走一条、没确认走另一条,多写几条活线
State里除了messages,还可以放记忆、计划compile时传入 checkpointer,就能落盘、停住再续
立图、加节点、连线、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 是这些」,它反而会抄。
TOOLS 里执行类故意排前面。模型应靠 docstring 先搜再查、先搜后投,而不是按数组下标。搜广告组要 campaign_id,所以计划得先单独搜一轮。查报表等 campaign_id,追投等 adgroup_id;两份回灌都到了,就可以同一轮一起调。广告组是本地假数据,和工具设计一篇同一份,不打真实的投放接口。确认下单也还没接,停住留给后面那层。
四、对照手搓看差在哪
手搓 01 / 02 | 这篇 |
|---|---|
while + dispatch | agent ↔ tools 两个节点 |
messages.append | State 上的 add_messages |
手写 TOOL_SCHEMAS | @tool:docstring → description,Literal → enum |
seen_adgroup_ids | 同一份,模型看不见 |
openai SDK | init_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_adgroup | boost_adgroup 的 docstring;白名单应回灌 error |
tier 总填中文 | Literal 有没有进 schema;失败有没有回灌枚举 |
日期写成 11月1日 | docstring 里的格式;_start_date 的回灌 |
interrupt / 落盘相关报错 | 下一篇 才接 checkpointer |
图能转、先搜后投之后,下一篇停住再续,对上手搓上线那一层。