这次我不用任何现成组件 —— 没有 ToolNode,没有 tools_condition,没有 create_react_agent。
自己 bind_tools、自己解析 tool_calls、自己拼 ToolMessage、自己连成环。
写完数了下:核心逻辑 37 行。 但真正的收获不是"原来这么简单",
而是逐轮打印报文之后看到的东西 —— 放在后半段。
那条让图变成 Agent 的边
__start__ --> agent;
agent -.-> __end__; ← 模型说不用工具了 → 结束
agent -.-> tools; ← 模型点了工具 → 去执行
tools --> agent; ← ★ 这条回边让图成环
add_edge("tools", "agent") 这一条边,就是"Agent"和"流水线"的全部区别。
而且这个形状我第三篇就画过了 —— 那时是"干活 → 打分 → 不达标重来"。
现在只是把"干活"换成调模型、"打分"换成"模型要不要调工具"。
四段代码:
bound = llm.bind_tools(TOOLS) # ① 工具 schema 挂到模型上
def agent(state): # ② 调模型,让它决策
reply = bound.invoke([SystemMessage(content=SYSTEM)] + state["messages"])
return {"messages": [reply]}
def tools(state): # ③ 执行模型点名的工具
outputs = []
for call in state["messages"][-1].tool_calls: # 一轮可能有多个,必须循环
try:
content = TOOLS_BY_NAME[call["name"]].invoke(call["args"])
except Exception as e: # 手写版必须自己兜
content = json.dumps({"error": f"{type(e).__name__}: {e}"})
outputs.append(ToolMessage(content=content,
tool_call_id=call["id"], # ★ 必须原样带回
name=call["name"]))
return {"messages": outputs}
def should_continue(state): # ④ 路由
last = state["messages"][-1]
rounds = sum(1 for m in state["messages"] if isinstance(m, ToolMessage))
if getattr(last, "tool_calls", None):
return END if rounds >= MAX_TOOL_ROUNDS else "tools"
return END
注意这四段里没有一个新的图 API。 StateGraph、add_conditional_edges、
MessagesState、add_messages 全是前几篇学过的。
所谓"造一个 Agent",就是用旧积木搭一个有环的图。
跑起来是这样(真实报文):
🤖 模型说:我要调工具(content='')
tool_call: name='query_employee' args={'name': '小陈'}
finish_reason='tool_calls' tokens(in/out)=251/16
⚙️ 执行 query_employee({'name': '小陈'}) → {"hire_date": "2023-03-01", "used": 3}
🤖 模型说:我要调工具(content='')
tool_call: name='annual_leave_quota' args={'years_of_service': 3}
⚙️ 执行 annual_leave_quota({'years_of_service': 3}) → {"quota": 15}
🤖 模型说:不用工具了,直接回答
finish_reason='stop'
→ 小陈今年总共有 15 天年假,已休 3 天,剩余 12 天。
这个两步顺序我没编排,是模型自己决定的(先查档案拿到入职日期 → 算司龄 → 再查额度 → 最后自己做减法)。
这就是 agent 和 workflow 的区别。
两个"第一次接工具必懵"的细节
① 模型要调工具时,content 是空字符串。
同一个问题,只差一个 bind_tools:
没 bind:content='请问您指的是哪位小陈?请提供员工的姓名或工号,以便我查询。'
tool_calls=[]
bind 后:content=''
tool_calls=[{"name": "query_employee", "args": {"name": "小陈"}, "id": "function-call-3352…"}]
真正的意图在 tool_calls 里。如果 UI 直接把 content 打给用户,用户会看到一片空白。
② 模型看不到你的函数体。
@tool 从类型注解生成 schema、从 docstring 生成描述,模型能看到的只有这些:
{
"description": "按员工姓名查询档案,返回入职日期和今年已使用的年假天数。",
"properties": {"name": {"title": "Name", "type": "string"}},
"required": ["name"],
"title": "query_employee"
}
所以 docstring 不是注释,是提示词 —— 它是模型判断"什么时候该用我"的唯一依据。
tool_call_id:三种写错,三种截然不同的后果
这是我觉得唯一必须记死的细节:
| 写法 | 后果 |
|---|---|
不传 tool_call_id
|
构造时 KeyError —— 立刻失败,好设计 |
tool_call_id 写错 |
400: function_response.name: Name cannot be empty |
| 少回一条 / 完全不回 | 不报错,模型返回空 content |
第二种坑得说一下,因为报错信息完全指不到根因:
BadRequestError: 400 - GenerateContentRequest.contents[2].parts[0]
.function_response.name: Name cannot be empty.
它说的是"name 不能为空",实际根因是 id 对不上 ——
langchain-openai 靠 tool_call_id 去历史里找对应的 tool_call 来填 name,找不到就填空。
光看报错你会去查"为什么 name 空了",方向全错。
第三种最危险,两个变体我都试了:
2 个 tool_calls 只回 1 条 ToolMessage → content='' ,零报错
完全不回 ToolMessage 直接追问 → content='' ,零报错
所以有条契约要记住:「带 tool_calls 的 AIMessage」和「对应数量的 ToolMessage」是成对的。
破坏配对不报错,只给你空回复。
顺带一提,模型确实会在一轮里发多个 tool_calls(我问"小陈和小李的入职日期",
它一次就发了两个)。所以工具节点里那个 for 循环不是摆设 ——
写成 tool_calls[0] 就退化成上面那个静默空回复了。
工具抛异常:别往外抛,喂回给模型
try:
content = fn.invoke(call["args"])
except Exception as e:
content = json.dumps({"error": f"{type(e).__name__}: {e}"})
效果:
⚙️ flaky_db → {"error": "RuntimeError: 数据库连接超时"}
★ 模型的回答:对不起,数据库连接超时,我无法查询到小陈的入职日期。请您稍后再试。
- 往外抛 → 整张图崩掉,用户什么都拿不到,已花的 token 白费
- 喂回模型 → 模型能"看见"失败,自己决定重试、换工具、还是告诉用户
我觉得这是 Agent 有韧性的根本原因:它能看见失败。
真正的收获:模型不保证按你想的调工具
上面那次跑是理想情况。但我第一次探测这一课时,annual_leave_quota 的 docstring
只写了一句「根据司龄计算年假总额度」,结果是:
模型只调了查档案的工具,然后自己算额度,还算错了 ——
司龄 3 年给出 10 天,而我的规则是满 3 年 15 天。
注意这个错误的性质:它不报错、不崩溃,就是给了个错数字。 在 HR 系统里,
10 天和 15 天是"驳回"和"通过"的差别。
于是我想演示这个因果,做了个 A/B:弱 docstring + 弱 system 对比强 docstring + 强 system,
看模型会不会偷懒。
结果没能复现 —— 两个版本都老实调用了工具,答案都对。
我本来想删掉这个失败的实验,后来觉得它才是这一课最该写下来的东西:
docstring 确实是提示词,写清楚有帮助;但它只提高概率,不是保证。
同一份代码、同一个模型、temperature=0,不同时间跑都可能给出不同的工具调用决策。
推论我觉得很硬:凡是不能出错的步骤,就别交给模型决定。
额度计算这种有确定规则的事,正确做法是:
- 把「查档案 → 算额度」合成一个工具,模型只负责"要不要查、查谁"
- 或者干脆做成图里的固定节点 —— 那就是 workflow 而不是 agent 了
Agent 的自由度应该花在"要不要查、查谁、下一步做什么"上,而不是"要不要按规则算"。
这也是我坚持逐轮打印报文的理由:不打印,你根本不知道它没调工具。
最终答案看起来通顺,数字是错的。
循环上限:第三篇的教训在这里收费
rounds = sum(1 for m in state["messages"] if isinstance(m, ToolMessage))
if rounds >= MAX_TOOL_ROUNDS: return END
第三篇查到默认 recursion_limit 是 10007(不是流传的 25)。当时只是"有点意外",
到这一课就变成真金白银了:这里每一步都在调模型,死循环 = 一万次 API 调用。
而且模型真的会反复调同一个工具(拿到 error 后不甘心地重试)。
纯 Python 的死循环只是慢;有 LLM 的死循环是烧钱。
顺带一个小设计:上限我用"历史里 ToolMessage 的条数"而不是超步数 —— 更贴业务,
还能做成"超过 N 轮就转人工"。
接下来
这 37 行里有不少是样板代码:遍历 tool_calls、查工具表、try/except、拼 ToolMessage、
判断有没有 tool_calls。这些每个项目都长一样。
下一篇用官方的 ToolNode + tools_condition 重写(大约 20 行),
把两份代码放一起 diff,看清框架到底替我做了什么;
再故意让工具抛异常,看它内置的 handle_tool_errors 默认行为够不够用。
我在从零重学 LangGraph,边学边把笔记整理出来,这是第 6 篇。
环境:Python 3.13 + LangGraph 1.2.10,模型是 Gemini 免费额度(OpenAI 兼容端点)。
文中所有报文都是实跑的;模型行为有随机性,你跑出来可能和我不一样 —— 这恰恰是本篇的重点之一。
有理解错的地方欢迎指出,也欢迎交流你踩过的坑。