【LangChain 1.x】09、工具进阶|ToolRuntime、Command 与动态工具选择

摘要 :之前在第 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 时用)

先看读 statecontext 两个最常见的(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------contextinvoke 时作为关键字参数传入的(不是 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 toolsschema 在服务端、实现在客户端(如浏览器)。

为什么需要它?前端 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.middlewarerequest 的类型 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_call override tools=,按 context(用户角色/权限)动态给模型不同工具集。
  • @wrap_tool_call 错误处理:统一拦截所有工具抛出的异常、返回自定义 ToolMessage,模型可以自行处理、不会让程序崩溃。

另外,简单介绍了 Headless tools(schema 服务端、实现客户端、interrupt/resume),用于前端 Agent 调浏览器能力的场景(该 API 在 1.3.14 尚不稳定,待稳定后补充代码示例)。

一句话概括:工具从第 6 篇的"无状态、只返回字符串的函数",升级到"能读运行时、能改状态、能短路、能按权限筛选、能容错"的 Agent 一等公民。 这些能力让工具真正融入了 Agent 的状态与控制流。

下一篇,将介绍短期记忆,看 Agent 怎么在单次会话里管理对话历史、防止上下文爆炸(裁剪、删除、摘要)。

相关推荐
早点睡啊Y9 小时前
深入学 LangChain 官方文档(十四)MCP 模型上下文协议首讲
langchain
草莓熊Lotso10 小时前
【LangChain】输出解析器全解:让大模型输出从 “聊天” 变 “机器可读”
服务器·数据库·python·langchain·pip
徐小超10 小时前
从一句 Hello World 到完整 RAG 系统:一个 AI 知识库的架构选型实录
langchain·node.js·ai编程
小白说大模型1 天前
从向量嵌入到复杂 Agent:LLM、LangChain、LangGraph 完整科普
java·开发语言·人工智能·gpt·深度学习·langchain
geo搜搜果数据1 天前
实测AI搜索GEO监测工具:对比DeepSeek与豆包品牌排名差异
人工智能·langchain·embedding·搜搜果
YIAN1 天前
大模型总 "胡说八道"?用 LangChain.js 从零实现 RAG 语义检索系统
javascript·langchain
我叫张小白。1 天前
LangChain 结构化输出(Structured Output)技术文档
java·数据库·langchain
lhxcc_fly1 天前
LangGraph 项目部署知识点总结
ai·langchain·项目部署·langgraph
早点睡啊Y1 天前
深入学LangChain 官方文档(九)Memory 记忆系统首讲
langchain