写 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]的两个泛型是:
| 位置 | 泛型 | 对应属性 | 类型来自 | 例子 |
|---|---|---|---|---|
| 第一个 | ContextT | runtime.context | context_schema | CartContext |
| 第二个 | StateT | runtime.state | state_schema | CartState |
⚠️顺序是
[Context, State],不是[State, Context]。写反是一个很常见的坑——它不会让代码崩溃(见下文"为什么写反也能跑"),但会误导 IDE 和类型检查。
context 和 state 的本质区别
这是理解一切的关键:
runtime.context(ContextT) | runtime.state(StateT) | |
|---|---|---|
| 谁定义 | context_schema | state_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让工具"写"回状态。一读一写,工具就从"只会算"升级成"能记事"。