写 Agent 工具时你迟早会遇到两个问题工具怎么知道当前用户是谁工具怎么把一个结果记下来给后面用前者靠ToolRuntime后者靠Command。这篇文章用一个购物车的小例子把这两个概念一次讲清。代码完整可运行复制即跑。一、先看完整例子购物车累计金额需求很简单用户加购商品工具累加总额用户问总额工具报出来。importosfromdataclassesimportdataclassfromdotenvimportload_dotenvfromlangchain.agentsimportAgentState,create_agentfromlangchain.chat_modelsimportinit_chat_modelfromlangchain.toolsimporttoolfromlangchain_core.messagesimportToolMessagefromlanggraph.checkpoint.memoryimportInMemorySaverfromlanggraph.prebuiltimportToolRuntimefromlanggraph.typesimportCommand load_dotenv()modelinit_chat_model(os.getenv(MODEL_NAME,glm-5.1),model_provideropenai,base_urlos.getenv(OPENAI_API_BASE),api_keyos.getenv(OPENAI_API_KEY),streamingTrue,)# ── 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:把一件商品加入购物车并返回累计金额。userruntime.context.user_id# context → CartContext第一个泛型currentruntime.state.get(total,0.0)# state → CartState 第二个泛型new_totalcurrentpricereturnCommand(update{total:new_total,# 写自定义 State 字段覆盖messages:[ToolMessage(# 必须回应本次 tool_callcontentf{user}加入{price}{runtime.context.currency}累计{new_total},tool_call_idruntime.tool_call_id,)],})tooldefshow_total(runtime:ToolRuntime[CartContext,CartState])-str:查看购物车累计金额。returnf累计{runtime.state.get(total,0.0)}{runtime.context.currency}agentcreate_agent(modelmodel,tools[add_item,show_total],state_schemaCartState,# 声明自定义 Statecontext_schemaCartContext,# 声明 ContextcheckpointerInMemorySaver(),system_prompt你是购物助手。加购商品调用 add_item查询总额调用 show_total。,)config{configurable:{thread_id:t1}}ctxCartContext(user_iduser_1,currency元)if__name____main__:agent.invoke({messages:[{role:user,content:加一件 30 元的再加一件 12 元的}]},configconfig,contextctx,)ragent.invoke({messages:[{role:user,content:我一共花了多少}]},configconfig,contextctx,)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.contextContextTruntime.stateStateT谁定义context_schemastate_schema谁传入invoke(..., context...)随对话流转工具可写可变性只读运行期不变可变工具能写、被持久化例子user_id、currency我是谁、用什么货币total累计金额会变一句话context 是这次调用的固定配置state 是会话里会变的数据。所以例子里user_id/currency放 contexttotal放 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_idruntime.tool_call_id标明回应的是哪次调用。messages:[ToolMessage(content...,tool_call_idruntime.tool_call_id)]漏了这条常见报错是工具调用没有对应响应。四、把两者串起来看运行流程用户:加一件 30 元的再加一件 12 元的 ↓ 模型调用 add_item(price30) runtime.context.user_id → user_1 (ContextT CartContext只读) runtime.state[total] → 0 → Command 写回 30 (StateT CartState可写) ↓ 模型调用 add_item(price12) runtime.state[total] → 30 → 写回 42 ← 读到上一步写的证明 state 被持久化 ↓ 用户:我一共花了多少 → 模型调用 show_total → 读 state.total42 → 累计42 元两个关键现象第二次add_item能读到 30因为第一次Command(update{total:...})写进了 State 并被 checkpointer 持久化下一个工具读得到。这就是工具写状态的意义。runtime.context.currency全程是元因为它来自只读 context运行期不变。五、一句话总结概念是什么关键点ToolRuntime[Context, State]注入工具的运行时句柄两个泛型顺序是Context 在前、State 在后只影响类型提示runtime.context只读配置我是谁来自context_schemainvoke(context...)runtime.state可变数据会话里会变的来自state_schema工具可写、被持久化Command(update...)工具写 State 的唯一方式普通 return 只能说话返回 Command 要手动补 ToolMessage记住这条主线ToolRuntime让工具读到身份与状态Command让工具写回状态。一读一写工具就从只会算升级成能记事。