1. 为什么我要把 ReAct 循环单独拎出来跑一遍
如果你最近在翻 GitHub 上的 Agent 项目,大概率会刷到那个 4.3 万 Star 的 Python 框架——nanobot。它用大约 4000 行代码把「感知-思考-行动」的闭环讲得明明白白,很多人把它当成 OpenClaw / Claude Code 的轻量可研究平替。但真把仓库 clone 下来跑的时候,第一道坎往往不是代码看不懂,而是 LLM 通道怎么接:每个 Provider 一套 Key、一套 Base URL,换模型就要改环境变量,调试 ReAct 循环时注意力全被配置分散了。
这篇就聚焦一件事:用 TaoToken 统一 Key 和 API 通道,在本地把 Python Agent 框架的 ReAct 核心循环跑通。所谓 ReAct,就是 Reasoning + Acting:模型先输出一段 Thought(我该干什么),再输出 Action(调用哪个工具、传什么参数),代码执行工具后把 Observation(工具返回结果)塞回上下文,模型继续下一轮 Thought,直到给出 Final Answer。这个循环是 Agent 调度与 LLM 调用真正咬合的地方,理解了它,再看任何框架的 AgentLoop 都不会发怵。
适合谁看:写过一点 Python、调过 OpenAI 风格接口、想搞懂 Agent 内部到底怎么转起来的开发者。你不需要先精通 nanobot 全部源码,只要能把下面这套最小脚本跑起来,就能自己往里加工具、换模型、观察每一轮的消息结构。整篇的节奏是:先讲清楚问题和场景,再配好统一通道,然后给可复制的配置和脚本,接着验证一次真实工具调用链路,最后把常见报错挨个排掉。
2. 用 TaoToken 做统一 LLM 通道的前置准备
在动手写 Agent 之前,先把「模型从哪来」这件事固定下来。ReAct 循环里每一轮 Thought 都要打一次 LLM,如果通道不稳定或者换模型要改一堆代码,调试体验会很差。我的做法是让所有请求都走同一个 OpenAI 兼容入口,Base URL 指向https://taotoken.net/api,Key 用同一个,模型名通过参数传进去。这样 Agent 脚本里只认一个 client,换模型只改一个字符串。
先注册并拿到 Key。打开控制台地址https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=react_loop,在 API Keys 页面创建一个新 Key,复制出来先存到本地。注意 Key 只在创建时完整显示一次,丢了就重新建一个,别硬找。
拿到 Key 之后,建议先别急着写 Agent,用最小请求确认通道是通的。这一步能帮你把「通道问题」和「Agent 逻辑问题」分开,后面排错会省很多时间。你可以用 curl 直接打一次对话接口:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "只回复两个字:通了"}], "max_tokens": 32 }'如果返回的 JSON 里choices[0].message.content是「通了」,说明 Key、Base URL、模型名三件套都对。这里有个细节:OpenAI 兼容接口的路径是/v1/chat/completions,而 Base URL 只写到/api,SDK 会自动补后面的路径,别把/v1也塞进 Base URL,否则会拼成/api/v1/v1/...直接 404。
模型名这块,TaoToken 支持多家模型,你在控制台的模型列表里能看到当前可用的 ID。写脚本时把它抽成环境变量,比如TAOTOKEN_MODEL,这样同一份 Agent 代码可以在不同模型间切换对比 ReAct 的表现。我个人调试时会先用一个响应快、指令跟随稳的模型把循环跑通,再换更强的模型看复杂任务下的多步推理。
环境变量统一放.env里,别硬编码进代码。Python 侧用python-dotenv读取,或者直接在 shell 里 export。下面这份就是后面脚本要用的全部配置,先建好文件:
# .env TAOTOKEN_API_KEY=sk-你的Key TAOTOKEN_BASE_URL=https://taotoken.net/api TAOTOKEN_MODEL=claude-sonnet-4-20250514装依赖只需要两个包:openai负责请求,python-dotenv负责读环境变量。用你习惯的虚拟环境装:
python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install openai python-dotenv到这里前置就齐了:一个 Key、一个 Base URL、一个模型名、两个依赖。接下来所有 Agent 逻辑都建立在这套统一通道上,不再碰任何 Provider 专属配置。
3. 可复制的 ReAct 最小 Agent 脚本与配置
这一节直接给能跑的代码。核心思路是把 ReAct 循环拆成三块:工具注册表、LLM 调用封装、循环调度器。工具先用一个最简单的计算器和一个查时间工具,方便你观察 Observation 是怎么回填的。
先建tools.py,定义工具和它们的 JSON Schema。Agent 靠这份 Schema 告诉模型「有哪些工具可用、参数长什么样」:
# tools.py import json from datetime import datetime def calculator(expression: str) -> str: """只允许数字和四则运算,避免 eval 执行任意代码""" allowed = set("0123456789+-*/(). ") if not set(expression) <= allowed: return "错误:表达式包含非法字符" try: return str(eval(expression, {"__builtins__": {}}, {})) except Exception as e: return f"计算失败:{e}" def now_time(_: str = "") -> str: return datetime.now().strftime("%Y-%m-%d %H:%M:%S") TOOL_REGISTRY = { "calculator": calculator, "now_time": now_time, } TOOL_SCHEMAS = [ { "type": "function", "function": { "name": "calculator", "description": "计算一个数学表达式,例如 12*(3+4)", "parameters": { "type": "object", "properties": { "expression": {"type": "string", "description": "要计算的表达式"} }, "required": ["expression"], }, }, }, { "type": "function", "function": { "name": "now_time", "description": "获取当前本地时间", "parameters": {"type": "object", "properties": {}}, }, }, ]再建agent.py,这是 ReAct 循环的主体。关键点在于:每轮把模型返回的tool_calls解析出来,执行对应工具,把结果以role: tool的消息追加回messages,然后再次请求模型。循环有最大轮数保护,避免模型陷入死循环:
# agent.py import json import os from dotenv import load_dotenv from openai import OpenAI from tools import TOOL_REGISTRY, TOOL_SCHEMAS load_dotenv() client = OpenAI( api_key=os.environ["TAOTOKEN_API_KEY"], base_url=os.environ["TAOTOKEN_BASE_URL"], ) MODEL = os.environ["TAOTOKEN_MODEL"] SYSTEM_PROMPT = ( "你是一个会使用工具的助手。需要计算或查时间时,必须调用工具," "不要凭记忆回答。拿到工具结果后再给出最终答案。" ) def run_react(user_input: str, max_turns: int = 6) -> str: messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] for turn in range(max_turns): resp = client.chat.completions.create( model=MODEL, messages=messages, tools=TOOL_SCHEMAS, tool_choice="auto", ) msg = resp.choices[0].message messages.append(msg) # 没有工具调用,说明模型给出了最终答案 if not msg.tool_calls: return msg.content # 有工具调用:逐个执行,把 Observation 回填 for call in msg.tool_calls: name = call.function.name args = json.loads(call.function.arguments or "{}") print(f"[Turn {turn}] Action: {name} Args: {args}") fn = TOOL_REGISTRY.get(name) observation = fn(**args) if fn else f"未知工具:{name}" print(f"[Turn {turn}] Observation: {observation}") messages.append({ "role": "tool", "tool_call_id": call.id, "content": str(observation), }) return "达到最大轮数仍未得到最终答案" if __name__ == "__main__": print(run_react("帮我算一下 (128 + 72) * 3 等于多少,再告诉我现在几点"))跑之前确认.env和两个 py 文件在同一目录,然后:
python agent.py预期你会看到类似这样的输出,Action 和 Observation 交替出现,最后模型给出合并了计算和时间的结果:
[Turn 0] Action: calculator Args: {'expression': '(128 + 72) * 3'} [Turn 0] Observation: 600 [Turn 1] Action: now_time Args: {} [Turn 1] Observation: 2026-01-15 14:32:07 (128 + 72) * 3 = 600,当前时间是 2026-01-15 14:32:07。这里就是 ReAct 的精髓:模型不是一次性把答案编出来,而是先决定「我要调 calculator」,拿到 600 这个 Observation 后,再决定「我还要调 now_time」,最后才组织语言。你可以在run_react里加一行打印messages的长度,会看到每轮都在增长,这就是上下文在累积 Thought-Action-Observation 的过程。
如果你用的是 Claude Code 这类工具做辅助开发,配置里同样填这三件套:Base URL 填https://taotoken.net/api,Key 填你的 TaoToken Key,Model ID 填控制台里的模型名。Cline、CC Switch 这类插件的 MCP 或 Provider 配置也是同一个逻辑,认准 OpenAI 兼容格式即可。想长期跑编码类 Agent 任务,可以了解下 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=react_loop。
4. 验证一次完整工具调用链路与成功结果
脚本能跑出答案只是第一步,真正要确认的是「调度与 LLM 调用的衔接点」有没有按预期工作。我建议做一次带日志的验证,把每一轮的消息角色和工具调用 ID 都打出来,这样你能清楚看到框架是怎么把模型的意图翻译成函数执行的。
改造一下循环,加一个调试开关,把每轮的消息摘要打印出来:
def run_react_debug(user_input: str, max_turns: int = 6) -> str: messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] for turn in range(max_turns): resp = client.chat.completions.create( model=MODEL, messages=messages, tools=TOOL_SCHEMAS, tool_choice="auto", ) msg = resp.choices[0].message print(f"--- Turn {turn} ---") print(f"finish_reason: {resp.choices[0].finish_reason}") print(f"content: {msg.content!r}") print(f"tool_calls: {[c.function.name for c in (msg.tool_calls or [])]}") messages.append(msg) if not msg.tool_calls: return msg.content for call in msg.tool_calls: name = call.function.name args = json.loads(call.function.arguments or "{}") fn = TOOL_REGISTRY.get(name) observation = fn(**args) if fn else f"未知工具:{name}" print(f"tool_call_id: {call.id} -> {observation}") messages.append({ "role": "tool", "tool_call_id": call.id, "content": str(observation), }) return "达到最大轮数"跑一次run_react_debug("先算 45*8,再算 360/9,最后告诉我现在时间"),你会观察到几个关键信号。第一轮finish_reason是tool_calls,content通常是空的,tool_calls里是calculator;执行完回填后,第二轮模型可能继续调calculator算第二个式子;第三轮调now_time;直到某一轮finish_reason变成stop、tool_calls为空、content有内容,循环才结束。这个finish_reason从tool_calls到stop的切换,就是 ReAct 循环的退出条件,很多框架内部也是靠它判断的。
成功结果应该满足三点:工具被真实执行(Observation 是算出来的,不是模型编的)、多步调用按顺序发生、最终答案里包含了工具返回的数据。如果模型跳过工具直接给答案,多半是 System Prompt 不够强硬,或者tool_choice被设成了none。你可以把tool_choice临时改成强制调用某个工具来验证链路:
tool_choice={"type": "function", "function": {"name": "calculator"}}这样模型第一轮必定调 calculator,用来确认「请求-解析-执行-回填」这条链路本身没问题。确认后再改回auto,让模型自己决定。
验证通过后,你可以把工具换成真实业务里的函数,比如查数据库、调内部 HTTP 接口、读文件。ReAct 循环本身不用改,只要往TOOL_REGISTRY和TOOL_SCHEMAS里加条目就行。这也是为什么统一通道很重要:工具越加越多,模型调用越频繁,通道稳定和 Key 统一能让你把精力放在工具逻辑上,而不是到处找配置。
5. 跑 ReAct 循环时常见的报错与排查
调试 Agent 时踩的坑基本集中在通道和消息结构两类。下面这几个是我实际遇到过的,对照着排会快很多。
401 Unauthorized / invalid api key:最常见。先确认.env里的 Key 没有多余空格或换行,load_dotenv()有没有真的加载到(可以print(os.environ.get("TAOTOKEN_API_KEY")[:8])看前几位)。如果 Key 是从控制台复制的,注意别把前后引号也带进去。还有一种情况是 Key 被删了或过期,去控制台重新建一个。
local proxy failed / connection error:这类报错通常是 Base URL 写错或网络出口有问题。检查TAOTOKEN_BASE_URL是不是https://taotoken.net/api,末尾不要带/,也不要带/v1。如果你在容器或远程机器里跑,确认那台机器能正常访问外网 HTTPS。用前面那条 curl 命令单独测一次,能通就说明是代码侧的问题。
reading 'choices' of undefined / KeyError: 'choices':说明返回体不是预期的 OpenAI 格式,多半是请求打到了错误路径,或者返回了一个错误 JSON。把resp整个打印出来看,常见原因是 Base URL 拼成了/api/v1/v1/chat/completions,或者模型名写错导致服务端返回错误对象。模型名一定要和控制台里列出的 ID 完全一致,大小写和日期后缀都别改。
tool_calls 为空但模型没给答案:有时模型返回finish_reason: stop但content是空字符串。这通常是max_tokens太小,或者 System Prompt 让它「必须调工具」但它判断不需要调。把max_tokens调大,或者检查tool_choice设置。如果用的是推理型模型,还要注意它可能把内容放在别的字段里,打印完整resp确认。
OAuth / 认证方式不匹配:如果你之前用某个 CLI 工具登录过,本地可能残留了 OAuth 凭证,SDK 优先用了它而不是你的 API Key。检查环境里有没有OPENAI_API_KEY之类的变量被覆盖,或者 CLI 的配置文件里存了旧凭证。最稳妥的办法是在脚本里显式传api_key,不依赖环境推断。
消息结构报错 invalid messages / tool_call_id mismatch:ReAct 循环里最容易犯的错。每个role: tool的消息必须带tool_call_id,且要和对应tool_calls里的id完全一致。如果你手动构造消息,漏了tool_call_id或者顺序乱了,服务端会拒绝。用 SDK 返回的msg对象直接 append 最省事,别自己拼。
排错时记住一个原则:先用 curl 确认通道,再用最小脚本确认消息结构,最后才怀疑 Agent 逻辑。把变量一个个固定住,问题范围会小很多。接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=react_loop,接口路径和参数以文档为准。
6. 把统一 Key 用在更多 Agent 场景
ReAct 循环跑通之后,你会发现它其实是个通用骨架。nanobot 那类框架的 AgentLoop 再复杂,核心也是「拿消息、调模型、执行工具、回填、再调模型」这套。区别只在于它们加了会话锁、记忆压缩、子 Agent 这些工程化能力。你自己写的这版最小循环,正好是理解那些机制的起点。
接下来可以往几个方向扩展。一是加记忆:把每轮对话存到列表或文件,下次请求时带上历史,就接近了 nanobot 的 Hot Memory 思路。二是加多工具路由:工具多了以后,Schema 会变长,可以按任务类型动态筛选要传给模型的工具,减少 token 消耗。三是加并发:不同会话用独立的消息列表,用asyncio并发跑,单会话内保持串行,这就是很多框架「Per-session 串行、跨 session 并发」的做法。
统一 Key 的价值在这些扩展里会越来越明显。你不需要为每个模型、每个工具链单独维护凭证,Base URL 和 Key 固定,模型名当参数传,切换和对比成本极低。想验证不同模型在 ReAct 多步推理上的表现,直接改TAOTOKEN_MODEL重跑就行。需要看模型对话效果可以走https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=react_loop,要管理 Key 和额度去https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=react_loop。
最后留一个实用技巧:调试 ReAct 时把每轮的messages完整 dump 成 JSON 存文件,出问题时直接看模型到底收到了什么。很多「模型不调工具」的怪现象,翻一眼上下文就发现是上一轮的 Observation 没回填对,或者 System Prompt 被历史消息挤掉了。这个习惯比任何调试器都好用。