LangGraph 的 create_react_agent 换模型,第一件事不是改 tools,而是处理 ChatOpenAI 里那个 base_url。TaoToken 的入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end ,注册之后你会拿到一把 Key 和一个 Base URL,它不替你的 Agent 做推理,也不接管 AgentNode 里那行 llm.invoke。原文 3.3 节把 ReAct 循环拆得很直白:AgentNode 调一次 LLM 产出消息,ToolsNode 执行工具、把 ToolMessage 写回共享状态,再靠 should_continue 条件边判断是继续还是收尾。每一轮迭代都要真调一次模型,token 就是在 agent 与 tools 的来回之间消耗掉的。所以把 ChatOpenAI 收成一处可切换的实例,不只是少改几行代码,更是让所有节点走同一个入口、对得上同一份用量记录。
1. LangGraph 里 AgentNode 与 ToolsNode 拆开之后,model 参数成了唯一入口
1.1 create_react_agent 内部其实就两个节点加一条边
把循环摊开看,create_react_agent 不是黑盒。它内部是一张 StateGraph:一个 agent 节点,一个 tools 节点,外加一条从 agent 出发的条件边。agent 节点拿到当前消息列表,调一次 LLM;如果模型返回的消息里含 tool_calls,条件边就把流程导向 tools 节点;tools 节点按 tool_calls 逐个执行工具,把结果包成 ToolMessage 追加回 messages;然后再回到 agent 节点,让模型看着工具结果继续推理。直到某一轮模型不再发起工具调用,条件边指向终点,最后那条 AIMessage 里的内容就是 final_answer。
from langgraph.prebuilt import create_react_agent from langchain_openai import ChatOpenAI from langgraph.checkpoint.memory import MemorySaver llm = ChatOpenAI(model="gpt-4o", temperature=0) agent = create_react_agent( model=llm, tools=[...], prompt="你是一个会调用工具的助手", checkpointer=MemorySaver(), ) result = agent.invoke( {"messages": [{"role": "user", "content": "帮我查一下订单 A1001 的状态"}]}, config={"configurable": {"thread_id": "demo-1"}}, )这段里真正决定推理质量、计费归属和可用模型范围的,就是 model 参数。tools 列表换不换,节点结构变不变,都不影响「谁来推理」这件事,只有 model 决定。
1.2 传字符串和传实例,是两种完全不同的自由度
create_react_agent 的 model 参数既接受字符串模型名,也接受已经实例化好的 ChatModel 对象。传字符串的时候,框架会按默认 provider 去构造,endpoint 和鉴权都走框架的默认约定,你插不上手。传实例的时候,base_url、api_key、timeout、max_retries 全在你手里,也才有可能把这些参数从每个自定义节点里抽出来、收敛成一个地方。后面要做的切换,本质上是先把「传实例」这条路径走通,再把实例的构造收成一个工厂函数。
2. create_react_agent 的 model 一旦写死,换供应商要动多少地方
2.1 每个自定义 AgentNode 各 new 一个 ChatOpenAI,Key 就散了一地
一旦你为了给不同节点配不同模型而自建 StateGraph,很容易写成这样:planner_node 里一个 ChatOpenAI,agent_node 里一个,summarizer_node 里再一个,endpoint 和 key 各自写各自的。等到要换供应商,你得挨个文件搜 base_url,挨个替换,漏掉一处就是线上 401 或者调到了旧通道。更常见的坑是有人把 Key 直接硬编码进函数默认参数,提交到仓库以后又得回头清洗历史。
# 反面写法:endpoint 与 key 散落在每个节点 planner_llm = ChatOpenAI( model="gpt-4o", base_url="https://provider-a.example.com/v1", api_key="sk-a-...", ) agent_llm = ChatOpenAI( model="claude-3-5-sonnet", base_url="https://provider-b.example.com/v1", api_key="sk-b-...", )这种写法还有一层隐性成本:两把 Key、两个后台、两套用量页面。某天一个 ReAct 循环跑出异常调用,你想知道是哪一步、哪个节点烧掉的 token,就得登录两个控制台分别对时间戳,排查效率非常低。
2.2 ReAct 每一轮迭代都在真实计费,写错的代价被循环放大
ReAct 是 while 循环,不是一次性推理。一次用户提问通常触发两到五次 LLM 调用:第一轮决定调不调工具,工具结果回来后决定要不要再调,最后才生成答案。这意味着端点或者 Key 只要写错一处,循环里的每一步都会失败,或者更糟——每一步都成功,但记到了别的账上。把入口集中到一个 Base URL,不只是为了改起来方便,也是为了让 ReAct 的每一轮调用都能被同一条用量记录覆盖。
3. 用 TaoToken 的一把 Key 把 ChatOpenAI 收成工厂
3.1 先去官网注册、创建 Key,再确认 Base URL 和模型 ID
打开 TaoToken 注册账号,进控制台创建一把 API Key,记成YOUR_API_KEY。这把 Key 和配套的 Base URL 就是后面所有节点共用的东西。Base URL 填https://taotoken.net/api,末尾不要加/v1:langchain_openai 会在它后面自己拼/chat/completions,你再加一层就会变成两条路径,请求直接 404。
模型 ID 不要在这里凭记忆写,去 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 的模型广场挑当前可用的那个,填进 model 参数。不同时间可选的模型不一样,所以任何写死的模型名都只适合演示,真正的工程配置应该从环境变量或者配置中心读。
3.2 写一个 build_llm 工厂,把 endpoint 和 Key 收进一处
import os from langchain_openai import ChatOpenAI TAOTOKEN_BASE_URL = "https://taotoken.net/api" def build_llm(model_id: str | None = None) -> ChatOpenAI: return ChatOpenAI( model=model_id or os.environ["LANGGRAPH_MODEL_ID"], base_url=TAOTOKEN_BASE_URL, api_key=os.environ["TAOTOKEN_API_KEY"], temperature=0, timeout=60, max_retries=2, )工厂写好之后,想换模型只改LANGGRAPH_MODEL_ID,base_url 和 Key 一行都不用碰。想换一批节点用的模型,给 agent_node 传build_llm("YOUR_MODEL_ID"),planner_node 用默认值,两者仍然是同一个 endpoint。
3.3 环境变量别写进仓库
export TAOTOKEN_API_KEY=YOUR_API_KEY export LANGGRAPH_MODEL_ID=YOUR_MODEL_ID注意环境变量里只放https://taotoken.net/api这个 Base URL,不要挂任何查询参数,也不要写成带/v1的形式。Key 从 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 创建,用完记得在控制台里按环境分开发放,方便出问题时单独吊销。
4. 把工厂实例交给 create_react_agent 和自定义 AgentNode
4.1 预置 create_react_agent 只改 model 一处
from langgraph.prebuilt import create_react_agent from langgraph.checkpoint.memory import MemorySaver from tools import build_tools llm = build_llm() # base_url 与 Key 都在工厂里 tools = build_tools() agent = create_react_agent( model=llm, tools=tools, prompt="你是一个会调用工具的助手,需要时先调工具再回答。", checkpointer=MemorySaver(), )换成别的模型时,只需要把build_llm()的参数改掉,或者改环境变量。tools 列表不动,prompt 不动,checkpointer 不动,图结构也不动。
4.2 自建 StateGraph 时复用同一个实例
from langgraph.graph import StateGraph, START, END, MessagesState from langgraph.prebuilt import ToolNode from langchain_core.tools import tool @tool def get_order_status(order_id: str) -> str: """查询本地演示用的订单状态,真实项目请替换为你自己的只读接口。""" demo = {"A1001": "已发货", "A1002": "待付款"} return demo.get(order_id, f"没有找到订单 {order_id}") tools = [get_order_status] llm_with_tools = build_llm().bind_tools(tools) def agent_node(state: MessagesState): return {"messages": [llm_with_tools.invoke(state["messages"])]} def should_continue(state: MessagesState) -> str: last = state["messages"][-1] if getattr(last, "tool_calls", None): return "tools" return END graph = StateGraph(MessagesState) graph.add_node("agent", agent_node) graph.add_node("tools", ToolNode(tools)) graph.add_edge(START, "agent") graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END}) graph.add_edge("tools", "agent") app = graph.compile(checkpointer=MemorySaver())agent_node 里那个build_llm()就是唯一需要动的入口。上面这个工具函数查的是本地演示字典,不是生产库;真实项目里让 Agent 只负责生成 SQL 或调用参数,实际查询由你在本地或只读副本上执行,再把结果贴回对话,别把生产连接直接塞进 tool。
4.3 should_continue 与 Checkpointer 为什么不用动
条件边只关心「最后一条消息有没有 tool_calls」,它不关心这个 tool_calls 是哪个模型生成的;Checkpointer 只关心状态怎么存、thread_id 怎么续,它也不关心推理走的是谁。真正跟供应商绑定的只有 model 实例本身。把实例收进工厂之后,这两个部分就是纯粹的图逻辑,换模型时不需要也不应该改。
5. 跑一轮完整 ReAct,看 ToolMessage 回写、final_answer 和用量
5.1 用一个只读工具把循环跑起来
config = {"configurable": {"thread_id": "react-demo-1"}} result = app.invoke( {"messages": [{"role": "user", "content": "订单 A1001 现在是什么状态?"}]}, config=config, ) for msg in result["messages"]: print(type(msg).__name__, "->", getattr(msg, "content", ""))正常情况下你会看到四类消息按顺序出现:HumanMessage(用户输入)、带tool_calls的AIMessage(AgentNode 决定调工具)、ToolMessage(ToolsNode 把get_order_status的结果写回共享状态)、最后一条不带 tool_calls 的AIMessage(final_answer)。如果第三类缺失,说明条件边没把流程导向 tools;如果最后一条还是带 tool_calls,说明循环没有正确收尾。
5.2 确认 ToolMessage 真的进了 messages
只打印内容还不够,ReAct 能不能续上,取决于 ToolMessage 有没有和对应的 tool_call_id 对上。可以单独检查一遍:
for msg in result["messages"]: if type(msg).__name__ == "ToolMessage": print("tool_call_id:", msg.tool_call_id) print("content:", msg.content)打印出tool_call_id,再和上一条AIMessage.tool_calls[0]["id"]比对,两边一致才算写回成功。这一步确认完,换模型的动作才算真正跑通——因为模型换了、工具链没断。
5.3 回控制台对一下这次模型调用
跑通之后,回到 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 的控制台,看这次react-demo-1的调用有没有进用量记录。ReAct 一次问答会对应多条调用记录,数量应该和 messages 里AIMessage的条数接近。如果调用成功但用量里查不到,八成是环境变量里还有旧的 endpoint 没清干净;如果有用量但工具没执行,那就是 bind_tools 没生效,问题在模型侧而不在 Key。
6. 换模型时会撞上的 401、404 与工具调用报错
6.1 401:Key 没带上,或者带的是上一个供应商的
最常见的是api_key环境变量在某个节点里被局部覆盖,或者.env里还留着旧值。排查时先把build_llm()里读的变量名统一成一个,再确认运行时确实读到了。另一个隐蔽情况是有人把 Key 写进了ChatOpenAI的default_headers,看起来能通,但换了模型之后 header 和 Key 对不上。
6.2 404:base_url 末尾多了 /v1
https://taotoken.net/api后面不要再接/v1、/v1/或者任何查询参数。ChatOpenAI 会自己拼上 OpenAI 兼容路径,多写一层就变成/api/v1/chat/completions,服务端找不到这个路由。报错表现为 404 而不是 401,这类错误跟 Key 没关系,先看 URL 拼出来长什么样。
6.3 模型 ID 不在列表,或工具调用格式不兼容
模型 ID 一律以模型广场当前列表为准,别凭记忆写带日期后缀的名字。有些模型支持 function calling 的格式和其它模型略有差异,表现出来是bind_tools阶段不报错,但运行时报参数结构不对。遇到这种情况先把 tool 简化成一个必填字符串参数,跑通再逐步加回复杂结构,不要一上来就用嵌套 schema 调。
7. 把入口固定下来,再去扩整张图
7.1 换模型这件事,改一行就该结束
LangGraph 的 ReAct 循环本身不绑定任何供应商,AgentNode 和 ToolsNode 的分工也不会因为换了模型而变化。真正需要固定的只是构造 ChatOpenAI 那几行:base_url 指向https://taotoken.net/api,api_key 从 TaoToken 控制台新建的那把读,model 名从模型广场挑当前可用的填进环境变量。这几行收进build_llm()之后,图上任何节点想换模型都只改参数,不再翻遍工程。
7.2 下一步:先用模型对话验一遍,再决定额度
配置固定好之后,先在 TaoToken 模型对话 里用同一把 Key 发一条测试消息,确认模型 ID 和 Base URL 两边都对得上。如果你的 ReAct Agent 要跑批量任务或者长时间循环,可以打开 Coding Plan 看看套餐额度是否够用。Key 在 控制台 API Keys 创建和管理,之后排查某次异常调用时,也回到这里对照 thread 开始时间和用量记录。