7.ToolNode 把我手写的 37 行砍成 15 行,但有三件事它没替我做

上一篇我手搓了一遍 ReAct 循环 —— 自己解析 tool_calls、自己执行工具、
自己拼 ToolMessage、自己连成环。这次换成官方组件 ToolNode + tools_condition,
只改两处,模型、工具、提示词、图的形状一个字没动。

用脚本对两版的 build_agent 统一口径数了一遍:37 行 → 15 行。

但我想记的不是这个数字。我把 ToolNode 的源码翻了一遍、又逐条实测之后,
真正有价值的是它没替我做的那几件事 —— 而这几件事,
恰恰是照着教程抄的人最容易漏的。

一、它默认不会兜住工具异常

我一直以为 ToolNode 会自动把工具抛的异常包成 ToolMessage 喂回模型
(很多教程也是这么写的)。翻到源码才发现,在 langgraph-prebuilt 1.1.0 上默认是这样:

def _default_handle_tool_errors(e: Exception) -> str:
    if isinstance(e, ToolInvocationError):
        return e.message
    raise e                    # ← 其它异常原样往外抛

实测,工具里 raise RuntimeError("数据库连接超时"):

💥 RuntimeError: 数据库连接超时
→ 整张图崩了,用户什么都拿不到。

我后来想明白这个默认值其实设计得有道理:

  • 参数校验失败是模型的错 → 报错喂回去,它下一轮能改对
  • 工具内部炸了是我的系统的事 → 框架不敢替我决定要不要吞

道理归道理,坑还是坑。它只在工具真的出错时才暴露 ——
本地跑一百遍碰不到,上线遇到第一次超时就是 500。

五种取值我都跑了一遍:

取值 实测行为
默认 只兜参数校验错误,其它抛穿
True 全兜,内容是英文模板 "Error: RuntimeError('...')\n Please fix your mistakes."
"一句话" 全兜,内容就是这句话
callable 全兜,内容是函数返回值
(TimeoutError, ...) 只兜名单里的类型

有两条是我跑之前没想到的:

字符串取值等于一句临时提示词。 它是说给模型听的。我现在的判断是:
生产环境应该默认用它挡住内部异常 —— 因为 repr(e) 里可能带连接串、表名、内网 IP,
那些会原样进模型上下文,也会进日志和 trace。

callable 取值会读你 handler 的类型注解。 这个是真没猜到,
工具固定抛 RuntimeError,只换 handler 的注解:

def h(e: TimeoutError) -> str  →  抛出 RuntimeError: 超时     ← 没兜住
def h(e: Exception) -> str     →  '全兜'
def h(e) -> str                →  '无注解'(也全兜)

源码里对可调用 handler 做了 _infer_handled_types(...)。写窄了就悄悄漏兜。

我现在的习惯是:写 ToolNode 永远显式传 handle_tool_errors,
哪怕就传个 True。而元组取值我觉得最实用 ——
(TimeoutError, ConnectionError) 兜住,KeyError 让它崩。
因为 KeyError 是我代码写错了,喂回模型只会让它编一个答案把 bug 盖住。

二、换成 tools_condition,我把循环上限弄丢了

tools_condition 的完整逻辑就两行判断:

if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0:
    return "tools"
return "__end__"

我上一篇手写的路由函数里有这么一段:

rounds = sum(1 for m in state["messages"] if isinstance(m, ToolMessage))
if rounds >= MAX_TOOL_ROUNDS: return END

换掉之后,这段就没了。兜底只剩默认 recursion_limit —— 我第三篇实测过那个数是
10007,不是流传的 25。而这里每一步都在调模型,跑飞就是一万次 API 调用。

修法很简单,tools_condition 只是个普通函数,包一层就行:

def route(state) -> str:
    rounds = sum(1 for m in state["messages"] if isinstance(m, ToolMessage))
    return END if rounds >= MAX_TOOL_ROUNDS else tools_condition(state)

这件事让我总结出一条自己的规矩:换 prebuilt 组件之前,先问一句
"我原来那段代码里,哪几行它没有?"
组件替掉的东西里,
总有一部分是我本来就该有的。

三、它并行了,所以我的工具得线程安全

这条是白拿的好处,但附带一个约束。

ToolNode 执行多个 tool_calls 用的是线程池:

with get_executor_for_config(config) as executor:
    outputs = list(executor.map(self._run_one, tool_calls, ...))

三个工具各 sleep(0.5) 实测:

手写 for 循环串行:1.51s
ToolNode 线程池:  0.51s

省下来的是实打实的。但反过来:我手写版那个串行 for 循环里怎么写都没事的东西 ——
往全局 dict 里写、复用一个非线程安全的 client、os.chdir() ——
换成 ToolNode 之后就是竞态。

最难受的是它只在模型一轮点了多个工具时才出现。测试的时候大概率碰不到。

意外收获:一行 Literal 注解能把静默 bug 变成编译报错

这条跟 ToolNode 关系不大,但我觉得是这次最值钱的发现。

我故意把工具节点命名成 hr_tools,然后直接
add_conditional_edges("agent", tools_condition),结果:

ValueError: At 'agent' node, 'tools_condition' branch found unknown target 'tools'

报错了,而且指得很准。 但我第三篇明明实测过:路由函数返回不存在的节点名
是静默忽略的,图直接结束,只在 stderr 打一行日志。

差别在哪?我写了个最小对照:

def router_annotated(s) -> Literal["ghost", "__end__"]: return "ghost"
# → ValueError: ... branch found unknown target 'ghost'    编译期就炸

def router_plain(s): return "ghost"
# → 静默结束,只有一行 "wrote to unknown channel branch:to:ghost, ignoring it."

区别就是有没有 Literal 返回注解。LangGraph 拿这个注解当路由目标清单去校验。

所以:给自己的路由函数加上 Literal 返回注解。
一行注解,把一个能让你查半天的静默 bug 变成编译期报错。
这条跟 ToolNode 无关,但适用于我写的每一个条件边。

另外两个小坑,省你半小时

单独 invoke 一个 ToolNode 会报天书:

ValueError: Missing required config key 'N/A' for 'tools'.

真实原因是 ToolNode 要从 config 里取 Runtime(store、context、
stream_writer 都在里面),裸 invoke 没人给它。
解法是塞进一张只有一个节点的图里跑。

这个套路我后来发现有个更大的价值:ToolNode 可以脱离模型做单元测试。
自己伪造一条带 tool_calls 的 AIMessage 就行:

{"messages": [AIMessage(content="", tool_calls=[
    {"name": "query_employee", "args": {"name": "小陈"},
     "id": "call_0", "type": "tool_call"}])]}

我这次的 demo 里,除了主流程,错误处理、并行计时、依赖注入、坑合集
全都不需要 API key,就是靠这个。工具逻辑的测试不该花钱、不该看模型脸色。

工具想读 state,别让模型传。 比如工具要知道"当前登录用户是谁",
如果写成 def my_leave_left(user: str),就等于把越权查询的开关交给了模型 ——
用户说一句"帮我查下老板的年假",模型很可能就照做了。

正确写法是注入,参数根本不出现在模型看到的 schema 里:

@tool
def my_leave_left(runtime: ToolRuntime) -> str:
    """查询当前登录用户今年还剩几天年假。不需要参数,用户身份从会话里取。"""
    user = runtime.state["user"]

模型收到的 schema 实测是空的:"parameters": {"properties": {}, "type": "object"}。

顺带一个连带的坑:带注入参数的工具不能再用
tool.args_schema.model_json_schema() 打印 schema 了
(我上一篇就是这么打印的),
pydantic 会直接抛 PydanticInvalidForJsonSchema。
要看模型实际收到什么,用 convert_to_openai_tool(工具) 或 tool.tool_call_schema。

我现在的判断

只要是"一堆平级工具、模型自己挑"这种形态,就该用 ToolNode ——
手写除了浪费时间没有别的收益,而且手写的大概率是串行的。

但"能用组件"和"知道组件没做什么"是两回事。
这次真正让我有收获的不是省下的 22 行代码,
而是那三条:默认不兜异常、没有循环上限、并行带来的线程安全约束。
这三条没有一条写在快速入门里,全是翻源码 + 实测出来的。


我在从零重学 LangGraph,边学边把笔记整理出来,这是第 8 篇。
下一篇打算啃 create_react_agent —— 一行就能造出这张图,
那我想搞明白的是:什么时候它不够用。

环境:langgraph 1.2.10 / langgraph-prebuilt 1.1.0 / langchain-core 1.5.2,
模型走 Gemini 的 OpenAI 兼容端点。有理解错的地方欢迎指出,也欢迎交流你踩过的坑。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容