让工具能“读身份、写状态“:讲透 LangChain 的 Command 与 ToolRuntime
2026/6/26 5:18:33 网站建设 项目流程

写 Agent 工具时你迟早会遇到两个问题:工具怎么知道"当前用户是谁"?工具怎么把一个结果"记下来"给后面用?前者靠ToolRuntime,后者靠Command。这篇文章用一个购物车的小例子,把这两个概念一次讲清。代码完整可运行,复制即跑。


一、先看完整例子:购物车累计金额

需求很简单:用户加购商品,工具累加总额;用户问总额,工具报出来。

importosfromdataclassesimportdataclassfromdotenvimportload_dotenvfromlangchain.agentsimportAgentState,create_agentfromlangchain.chat_modelsimportinit_chat_modelfromlangchain.toolsimporttoolfromlangchain_core.messagesimportToolMessagefromlanggraph.checkpoint.memoryimportInMemorySaverfromlanggraph.prebuiltimportToolRuntimefromlanggraph.typesimportCommand load_dotenv()model=init_chat_model(os.getenv("MODEL_NAME","glm-5.1"),model_provider="openai",base_url=os.getenv("OPENAI_API_BASE"),api_key=os.getenv("OPENAI_API_KEY"),streaming=True,)# ── State:会变的,能被工具写、被 checkpointer 持久化 ──classCartState(AgentState):total:float# ── Context:只读的静态输入,invoke 时通过 context= 传入 ──@dataclassclassCartContext:user_id:strcurrency:str# 泛型顺序:ToolRuntime[Context 类型, State 类型]@tooldefadd_item(price:float,runtime:ToolRuntime[CartContext,CartState])->Command:"""把一件商品加入购物车并返回累计金额。"""user=runtime.context.user_id# context → CartContext(第一个泛型)current=runtime.state.get("total",0.0)# state → CartState (第二个泛型)new_total=current+pricereturnCommand(update={"total":new_total,# 写自定义 State 字段(覆盖)"messages":[ToolMessage(# 必须回应本次 tool_callcontent=f"{user}加入{price}{runtime.context.currency},累计{new_total}",tool_call_id=runtime.tool_call_id,)],})@tooldefshow_total(runtime:ToolRuntime[CartContext,CartState])->str:"""查看购物车累计金额。"""returnf"累计:{runtime.state.get('total',0.0)}{runtime.context.currency}"agent=create_agent(model=model,tools=[add_item,show_total],state_schema=CartState,# 声明自定义 Statecontext_schema=CartContext,# 声明 Contextcheckpointer=InMemorySaver(),system_prompt="你是购物助手。加购商品调用 add_item,查询总额调用 show_total。",)config={"configurable":{"thread_id":"t1"}}ctx=CartContext(user_id="user_1",currency="元")if__name__=="__main__":agent.invoke({"messages":[{"role":"user","content":"加一件 30 元的,再加一件 12 元的"}]},config=config,context=ctx,)r=agent.invoke({"messages":[{"role":"user","content":"我一共花了多少"}]},config=config,context=ctx,)print(r["messages"][-1].content)# 累计:42 元

两个工具,分别演示"读"和"写"。下面拆开讲。


二、ToolRuntime[ContextT, StateT]:工具的运行时句柄

ToolRuntime是框架自动注入工具的对象——工具函数只要声明一个runtime: ToolRuntime参数,框架就在调用时把它塞进来(这个参数对 LLM 隐藏,模型不会也不用传)。

它的定义是Generic[ContextT, StateT],所以ToolRuntime[A, B]的两个泛型是:

位置泛型对应属性类型来自例子
第一个ContextTruntime.contextcontext_schemaCartContext
第二个StateTruntime.statestate_schemaCartState

⚠️顺序是[Context, State],不是[State, Context]写反是一个很常见的坑——它不会让代码崩溃(见下文"为什么写反也能跑"),但会误导 IDE 和类型检查。

context 和 state 的本质区别

这是理解一切的关键:

runtime.context(ContextT)runtime.state(StateT)
谁定义context_schemastate_schema
谁传入invoke(..., context=...)随对话流转,工具可写
可变性只读(运行期不变)可变(工具能写、被持久化)
例子user_id、currency(我是谁、用什么货币)total(累计金额,会变)

一句话:context 是"这次调用的固定配置",state 是"会话里会变的数据"。所以例子里user_id/currency放 context,total放 state。

写对泛型的收益

这两个泛型纯粹是给类型检查器/IDE 用的

runtime.context.user_id# IDE 知道是 CartContext → 补全 user_id / currencyruntime.state.get("total")# IDE 知道是 CartState → 提示 total 字段

为什么写反也能跑?因为 Python 的泛型在运行时被擦除、不强制。框架注入的runtime.context/runtime.state是真实对象,跟你标注的顺序无关。所以写反 → 功能正常,但类型提示是错的(IDE 会把 context 当成 State 类型),静态检查会误报。能跑 ≠ 写对。


三、Command:工具"写状态"的唯一方式

工具有两种返回方式,能力完全不同:

return"文字"# ① 普通:只往对话里加一句话,碰不到 total 这种字段returnCommand(update={...})# ② 写状态:能写 State 的任意自定义字段

show_total只读、不改状态,所以return 字符串就够了。
add_item要累加total——普通返回改不了自定义 State 字段,所以必须返回Command

Command 的字段

Command是 LangGraph 的控制对象,能同时"改状态"和"控流程":

Command(update={...},# 把这个 dict 合并进 State(本例用到)goto="节点名",# 控制流程:跳到哪个节点(路由场景用,本例没用)graph=...,# 作用在哪一层图(子图写父图用 Command.PARENT))

update 是怎么"合并"进 State 的

update里的字段按各自的reducer 规则并入状态:

returnCommand(update={"total":new_total,# 普通字段 → 直接覆盖旧值"messages":[ToolMessage(...)],# messages 带 add_messages reducer → 追加})
  • total没有特殊 reducer → 新值覆盖旧值。
  • messages内置add_messagesreducer → 列表是追加,不会把历史冲掉。

为什么必须带那条 ToolMessage

每次工具调用(tool_call)都必须有一条 ToolMessage 回应,否则消息格式非法、模型调用报错。

  • 普通return 字符串→ 框架自动生成 ToolMessage。
  • 一旦返回Command→ 框架不自动生成,你得在update["messages"]手动补,并用tool_call_id=runtime.tool_call_id标明回应的是哪次调用。
"messages":[ToolMessage(content="...",tool_call_id=runtime.tool_call_id)]

漏了这条,常见报错是"工具调用没有对应响应"。


四、把两者串起来看运行流程

用户:"加一件 30 元的,再加一件 12 元的" ↓ 模型调用 add_item(price=30) runtime.context.user_id → "user_1" (ContextT = CartContext,只读) runtime.state["total"] → 0 → Command 写回 30 (StateT = CartState,可写) ↓ 模型调用 add_item(price=12) runtime.state["total"] → 30 → 写回 42 ← 读到上一步写的,证明 state 被持久化 ↓ 用户:"我一共花了多少" → 模型调用 show_total → 读 state.total=42 → "累计:42 元"

两个关键现象:

  • 第二次add_item能读到 30:因为第一次Command(update={"total":...})写进了 State 并被 checkpointer 持久化,下一个工具读得到。这就是"工具写状态"的意义。
  • runtime.context.currency全程是"元":因为它来自只读 context,运行期不变。

五、一句话总结

概念是什么关键点
ToolRuntime[Context, State]注入工具的运行时句柄两个泛型顺序是Context 在前、State 在后;只影响类型提示
runtime.context只读配置(我是谁)来自context_schema+invoke(context=...)
runtime.state可变数据(会话里会变的)来自state_schema,工具可写、被持久化
Command(update=...)工具写 State 的唯一方式普通 return 只能"说话";返回 Command 要手动补 ToolMessage

记住这条主线:ToolRuntime让工具"读"到身份与状态,Command让工具"写"回状态。一读一写,工具就从"只会算"升级成"能记事"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询