摘要 :之前在第 6 篇中介绍的工具是"无状态、只返回字符串"的函数。但在 Agent 语境下,工具升级出五项新能力:使用
ToolRuntime获取运行时上下文(state / context / store)、使用Command(update={...})修改 Agent 状态、return_direct短路循环、@wrap_model_call动态筛选工具集、@wrap_tool_call统一错误处理。本篇使用 DeepSeek 模型实测,讲清工具从"只读"到"能读可写、能短路、能筛选、能容错"的范式变化。
前言
上一篇,我们介绍了 create_agent 和中间件,搭起了 Agent 的骨架,本篇介绍 Agent 中的工具。
传送门:【LangChain 1.x】08、Agent 核心入门|create_agent、中间件、结构化与流式输出
在第 6 篇中,介绍了工具定义(@tool / Pydantic / JsonSchema)和手动调用循环。那时的工具"很朴素":一个无状态的函数,接收参数、返回字符串,模型调用工具、拿到结果、再组织回答。
但在 Agent 语境下(配合 create_agent),工具有了一整套新能力,不同之处在于:
- 工具能读运行时上下文(对话状态、用户身份、长期记忆......)
- 工具能改 Agent 状态(不只是返回字符串)
- 工具能短路循环(结果直接回调用方,节省一次模型调用)
- 工具能按权限动态筛选(不同用户看到不同工具集)
- 工具错误能统一拦截(不让一次异常搞崩整个 Agent)
本篇,会对以上几点进行展开说明。
一、ToolRuntime:工具访问运行时上下文
在第 6 篇中,工具是一个"瞎子":只能看到模型传进来的参数,但不知道对话进行到哪、用户是谁。
但在 Agent 语境下,工具可以通过 ToolRuntime 访问完整的运行时上下文。
用法:给工具添加 runtime: ToolRuntime 参数,它和 config 一样都是保留参数名,可以自动注入、对 LLM 不可见:不会进入到 tool schema 中,模型看不到它。然后通过 runtime 读取到各种上下文:
| 字段 | 含义 |
|---|---|
runtime.state |
短期记忆(messages + 自定义字段,可读写) |
runtime.context |
invoke 时传入的上下文(配合 context_schema,第 8 篇讲过) |
runtime.store |
长期记忆(跨会话,第 11 篇讲) |
runtime.stream_writer |
向 "custom" 流推送实时更新 |
runtime.tool_call_id |
当前工具调用的 ID(构造 ToolMessage 时用) |
先看读 state 和 context 两个最常见的(store 会在后续长期记忆展开介绍)。
1.1 读 state(对话状态)
python
from langchain.tools import tool, ToolRuntime
@tool
def count_messages(runtime: ToolRuntime) -> str:
"""统计当前对话的消息数量。"""
n = len(runtime.state["messages"])
return f"当前对话有 {n} 条消息。"
agent = create_agent(model=deepseek_llm, tools=[count_messages])
resp = agent.invoke({"messages": [{"role": "user", "content": "现在对话里有几条消息?"}]})
print(resp["messages"][-1].content)
当前对话中有 2 条消息(包括你的提问和这条回复)。
工具通过 runtime.state["messages"] 读到了当前对话的消息数(在纯函数工具中是做不到)。
1.2 读 context(用户上下文)
配合第 8 篇中介绍的 context_schema,工具能够读取到模型 invoke 调用时传入的上下文(如用户身份):
python
from dataclasses import dataclass
@dataclass
class UserContext:
user_role: str # "admin" 或 "guest"
@tool
def who_am_i(runtime: ToolRuntime) -> str:
"""告诉调用方当前的身份。"""
role = runtime.context.user_role
return f"你当前的 user_role 是 {role}。"
agent = create_agent(
model=deepseek_llm,
tools=[who_am_i],
context_schema=UserContext
)
resp = agent.invoke(
{"messages": [{"role": "user", "content": "我是什么身份?"}]},
context=UserContext(user_role="admin"),
)
print(resp["messages"][-1].content)
你当前的用户身份是 管理员(Admin),拥有系统的最高权限!
工具读到了 runtime.context.user_role------context 是 invoke 时作为关键字参数传入的(不是 messages 里的内容),工具能拿到,中间件也能拿到(第 8 篇的 @dynamic_prompt 就是用同一套机制)。
注意:用 dataclass 做
context_schema时,LangGraph 内部序列化可能打一个 Pydantic 警告(无害,不影响功能)。要消除它,可以在文件开头加warnings.filterwarnings("ignore", message="Pydantic serializer warnings")。
二、Command(update={...}):工具修改 Agent 状态
第 6 篇中的工具,只能返回字符串("只读"),状态需要在 Agent 循环外部进行维护;
在 Agent 语境下,工具可以通过返回 Command(update={...})直接修改 Agent 中的状态字段,从"只读"升级为"可写"。
使用场景:工具执行完成后,需要留下必要的执行信息,让后续步骤可以看到。(比如:设置语言、记录中间结果、累加计数)
python
from langchain.agents.middleware import AgentState
from langchain.messages import ToolMessage
from langgraph.types import Command
class MyState(AgentState):
language: str = "" # 自定义状态字段,工具会改它
@tool
def set_language(language: str, runtime: ToolRuntime) -> Command:
"""设置 Agent 的回复语言,存到状态里。"""
return Command(update={
"language": language, # 直接改 state 的 language 字段
"messages": [ToolMessage( # 让模型看到工具结果(用 tool_call_id 关联)
content=f"语言已设置为 {language}。",
tool_call_id=runtime.tool_call_id,
)],
})
agent = create_agent(
model=deepseek_llm,
tools=[set_language],
state_schema=MyState
)
resp = agent.invoke({"messages": [{"role": "user", "content": "请把语言设为 English"}]})
print(f"state 里的 language 字段: {resp['language']}")
sql
模型最终回答: Language has been set to English. How can I help you?
state 里的 language 字段: English
两个关键点:
- 修改自定义状态中的字段,需要向
create_agent中传入state_schema(继承AgentState加字段)。 - 在
Command函数的update对象中,"messages"需要传入一个ToolMessage对象(使用runtime.tool_call_id进行关联),让模型看到工具"做了什么";如果模型拿不到工具执行结果就会卡住。
三、return_direct:短路 Agent 循环
普通工具的流程:工具返回结果 → Agent 把结果回传模型 → 模型再调一次、组织成自然语言回答。
但有些场景下,工具返回的结果就是最终答案(比如:查天气直接返回数据、查订单状态直接返回),不需要模型再"翻译"一遍,可以使用 return_direct=True 跳过这一步。
python
@tool(return_direct=True)
def direct_weather(city: str) -> str:
"""查天气(return_direct,结果直接返回调用方)。"""
return f"{city}:晴朗,25°C"
对比普通工具和 return_direct 工具,看最后一条消息的差异:
diff
--- 普通工具(模型会再调一次,组织成自然语言)---
最后消息类型: ai
内容: 北京的天气情况如下:晴朗 ☀️ 当前温度 25°C......(模型加工过)
--- return_direct 工具(跳过模型,工具输出直接当最终响应)---
最后消息类型: tool
内容: 北京:晴朗,25°C(工具的原始输出,没经过模型)
区别:
- 普通工具 :最后是
ai消息,最终输出的内容是通过模型组织的自然语言(会多调用一次模型)。 - return_direct 工具 :最后是
tool消息,内容是工具的原始输出(跳过了模型,节省一次调用)。
适用:工具结果就是最终答案、不需要模型再加工的场景(查状态、查库存、直接返回原始数据)。
注意:只有当本轮所有被调工具都设了
return_direct=True才生效。
四、动态工具选择:按权限筛选工具集
第 8 篇用 @wrap_model_call override model=(动态选模型);这里用同一个装饰器 override tools=(动态筛工具集)------同装饰器、不同字段。
典型场景:按用户角色(权限)动态给模型不同工具集。管理员可以使用全部工具、普通用户只能用部分。
python
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
@wrap_model_call
def filter_tools(request: ModelRequest, handler) -> ModelResponse:
role = request.runtime.context.user_role
if role == "admin":
filtered = request.tools # admin:全部工具
else: # viewer
filtered = [t for t in request.tools if t.name == "read_data"] # 只能读
print(f" [filter_tools] role={role} → 可用工具: {[t.name for t in filtered]}")
return handler(request.override(tools=filtered))
同一个请求"删除数据",不同角色看到的工具集不同、行为不同:
ini
--- role=admin,请求'删除数据' ---
[filter_tools] role=admin → 可用工具: ['read_data', 'write_data', 'delete_data']
回答: 已成功删除所有数据。
--- role=viewer,请求'删除数据' ---
[filter_tools] role=viewer → 可用工具: ['read_data']
回答: 当前可用的工具是 read_data,只能用来读取数据,没有删除数据的功能。
因此我无法完成删除数据的操作。
admin 有 delete_data 能删;viewer 只有 read_data,模型老实说明"无法完成"。这就是动态工具选择的价值:同一个 Agent、同一套工具定义,按运行时上下文(用户角色/会话阶段/环境)给模型不同的工具子集。
一个实战提醒:让模型在"没有对应工具"时老实说明、别编造结果,最好配合
system_prompt约束(如"只能用可用工具,没有就说明无法完成,不要编造")。否则有些模型会幻觉说"已完成"。
五、Headless tools:schema 在服务端、实现客户端(概念)
前面四节的工具,schema 和实现都在 Python 端(服务端)。还有一类叫 Headless tools :schema 在服务端、实现在客户端(如浏览器)。
为什么需要它?前端 Agent 应用里,有些动作必须在客户端执行------获取浏览器定位、访问摄像头、读写本地 IndexedDB、调用用户登录态的接口。这些动作服务端做不了(没权限、没环境),只能让浏览器来。但模型决策还得在服务端------所以 schema(工具名、描述、参数)定义在服务端给模型看,模型决定调用后,Agent 暂停(interrupt) ,把调用请求发给客户端;客户端执行完,把结果 resume 回来,Agent 继续。
css
模型决定调用 headless 工具
↓
Agent interrupt(暂停,等客户端)
↓ payload: {"type": "tool", "tool_call": {"name": ..., "args": ...}}
客户端(浏览器)执行(用 JS SDK 的 useStream 检测中断、跑实现)
↓
resume 结果回 Agent(ToolMessage)
↓
Agent 基于结果继续
适用场景:所有"服务端做不了、得在用户浏览器里做"的工具------定位、通知、本地存储、客户端 AI 接口等。
说明:Headless tools 的 Python 端 API(定义 schema-only 工具)在 langchain 1.3.14 尚不稳定,文档先行、实现还在调整。待 API 稳定后,我们会补一份可运行的示例。前端部分(JS SDK 的
useStream检测中断 + 客户端实现 + 自动 resume)属于前端集成,可参考官方 headless-tools 文档。
六、@wrap_tool_call:工具错误处理
第 6 篇讲工具调用时,工具抛异常需要手写 try/except。
Agent 语境下,使用 @wrap_tool_call 中间件可以统一拦截所有工具的异常,返回自定义 ToolMessage 给模型,模型看到错误后可以自行重试或告知用户,而不是让程序直接崩溃。
python
from langchain.agents.middleware import wrap_tool_call
from langchain.tools.tool_node import ToolCallRequest
from langchain_core.messages import ToolMessage
@wrap_tool_call
def handle_tool_errors(request: ToolCallRequest, handler):
try:
return handler(request) # 正常执行工具
except Exception as e:
return ToolMessage(
content=f"工具执行出错: {type(e).__name__} - {e}。请检查输入或换个方式。",
tool_call_id=request.tool_call["id"],
)
@wrap_tool_call来自langchain.agents.middleware;request的类型ToolCallRequest来自langchain.tools.tool_node(注意不在 middleware 包里)。request.tool_call["id" / "name" / "args"]获取调用信息;handler(request)执行原工具。
对比有无这个中间件,工具抛 ZeroDivisionError 时的表现:
diff
--- 没有 wrap_tool_call,工具抛错直接崩 ---
直接抛异常: ZeroDivisionError
--- 有 wrap_tool_call,工具抛错被优雅拦截 ---
最终回答: 计算 1/0 时发生了除零错误(ZeroDivisionError)。
在数学中,任何数除以 0 都是没有定义(undefined)的......
没有中间件,工具一抛异常整个 Agent 就崩溃了;
有了中间件,异常被转换为一条友好的 ToolMessage,模型基于它给用户解释清楚,程序没有崩溃、体验也好。
和第 6 篇的区别:
- 第 6 篇是"在调用循环里手写 try/except 处理某一个工具的错误";
- 使用
@wrap_tool_call是"挂载一个中间件、统一处理所有工具的错误",不需要每个工具各写一遍。
七、总结
本篇,把 Agent 语境下工具的五项新能力简单梳理了一遍:
- ToolRuntime :工具通过
runtime: ToolRuntime(保留参数、对 LLM 不可见)读运行时上下文:state(对话状态)、context(用户上下文)、还有store(后续在长期记忆章节展开)等。 Command(update={...}):工具不再只能返回字符串,还可以直接修改 Agent 状态("只读"→"可写"),配合state_schema添加自定义字段。return_direct=True:工具输出绕过模型、直接输出给调用方,节省一次模型调用;适合工具结果就是最终答案的场景。- 动态工具选择 :
@wrap_model_calloverridetools=,按context(用户角色/权限)动态给模型不同工具集。 @wrap_tool_call错误处理:统一拦截所有工具抛出的异常、返回自定义 ToolMessage,模型可以自行处理、不会让程序崩溃。
另外,简单介绍了 Headless tools(schema 服务端、实现客户端、interrupt/resume),用于前端 Agent 调浏览器能力的场景(该 API 在 1.3.14 尚不稳定,待稳定后补充代码示例)。
一句话概括:工具从第 6 篇的"无状态、只返回字符串的函数",升级到"能读运行时、能改状态、能短路、能按权限筛选、能容错"的 Agent 一等公民。 这些能力让工具真正融入了 Agent 的状态与控制流。
下一篇,将介绍短期记忆,看 Agent 怎么在单次会话里管理对话历史、防止上下文爆炸(裁剪、删除、摘要)。