1. 为什么你的 Agent 只会“想”不会“做”
很多人第一次写 Agent,都会掉进同一个坑:把提示词写成“请你一步步思考,然后给出答案”。模型确实会输出一大段推理,看起来很像那么回事,但它从头到尾没有调用过任何工具,遇到需要实时信息或多跳查询的任务,就开始一本正经地编。这就是纯 Chain-of-Thought 的典型症状——想得多,但手不动。
另一种极端是纯 Action:你给它一个搜索工具,它拿到关键词就搜,搜完不分析结果直接进入下一步,方向错了也回不来。多跳问题里只要有一环偏了,后面全盘皆输。
ReAct 要解决的就是这个断层。它把推理和行动交错起来,形成 Thought → Action → Observation 的闭环:Thought 决定下一步做什么,Action 真正去调用工具,Observation 把工具返回的结果塞回上下文,驱动下一轮 Thought。模型不再是“想完再做”,而是“边想边做,做完再想”。
这篇面向的是想在自己机器上跑通一个最小 ReAct Agent 的开发者。我会给出可复制的提示词模板、循环控制配置,以及怎么用日志验证 Thought 和 Action 是否对齐、Observation 有没有正确回填。你不需要任何框架,一个能调模型的 API 加几十行 Python 就能跟做。核心检索词就三个:ReAct、Thought-Action-Observation 循环、Agent 工具调用。适合谁?写过基础 prompt、想往 Agent 方向走、但被“模型不调工具”或“调了工具不收敛”卡住的人。
我试过用纯 CoT 提示词让模型查一个需要两步搜索的问题,它直接编了一个不存在的答案,还附上了看起来很真的引用。换成 ReAct 循环后,第一轮它先搜关键词,第二轮根据返回结果调整了搜索词,第三轮才给出结论。差别就在那个 Observation 有没有真正回填。
2. TaoToken 前置:把模型接口和 Key 准备好
ReAct 循环要跑起来,前提是你能稳定地调用一个支持工具调用(function calling / tool use)的模型接口。这里我用 TaoToken 作为接入层,它提供 OpenAI 兼容的接口,改一下 Base URL 和 Key 就能用,省去自己折腾鉴权的麻烦。
先说清楚要准备的三件套,这是后面所有配置的基础:
- Base URL:
https://taotoken.net/api - API Key:在控制台创建,形如
sk-xxxx - Model ID:选一个支持工具调用的模型,比如
claude-sonnet-4-5或同类支持 tool use 的模型
创建 Key 的入口在控制台的 API Keys 页面,登录后新建一个,复制出来存到环境变量里,别硬编码进代码。模型对话的调试入口可以用来先手动验证模型能不能正常返回,再进代码。
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"为什么要用兼容接口而不是直接写死某个厂商 SDK?因为 ReAct 循环里工具调用的返回结构(tool_calls / tool_use block)各家略有差异,用 OpenAI 兼容格式能让你换模型时少改代码。TaoToken 的接口在这一层做了统一,你只需要关心 messages 数组怎么拼、tool 定义怎么写。
有一点要提醒:ReAct 循环对模型的指令遵循能力要求比普通对话高。如果模型不支持工具调用,或者对 system prompt 里的格式约束不敏感,循环会退化成“模型在文本里假装调用了工具”,Observation 永远回填不进去。所以选模型时优先挑明确支持 function calling 的。你可以在模型对话页面先用一段带 tool 定义的请求测一下,看返回里有没有结构化的 tool_calls 字段,有就说明这条路通。
接入文档里有完整的请求示例和参数说明,遇到字段对不上时对着查。这一步不复杂,但它是后面所有循环逻辑的地基,Key 和 Base URL 错了,后面日志里全是 401,排查起来反而绕远路。
3. 可复制配置:提示词模板 + 循环控制
这一节是核心,给你能直接抄的配置。ReAct 的提示词模板关键是把三个角色的输出格式固定下来,让模型每一轮都按Thought:/Action:/Action Input:的结构输出,工具执行完你再把Observation:拼回去。
先看 system prompt 模板:
你是一个使用 ReAct 范式的智能体。每一轮你必须严格按以下格式输出,不要输出多余内容: Thought: 你的推理,说明当前状态和下一步要做什么 Action: 要调用的工具名,必须是 [search, calculator, finish] 之一 Action Input: 传给工具的输入,JSON 格式 当你认为已经可以给出最终答案时,使用: Thought: 我已经收集到足够信息 Action: finish Action Input: {"answer": "你的最终答案"} 规则: 1. 每次只输出一个 Action,不要一次输出多个。 2. Action Input 必须是合法 JSON。 3. 如果上一轮的 Observation 显示工具报错,你需要在 Thought 里分析原因并调整 Action Input。 4. 不要编造 Observation,Observation 只能由系统提供。工具定义用 OpenAI 兼容的 tools 字段,三个工具足够演示:search 模拟检索、calculator 做算术、finish 表示收敛。
{ "tools": [ { "type": "function", "function": { "name": "search", "description": "根据关键词检索信息,返回一段文本", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "检索关键词"} }, "required": ["query"] } } }, { "type": "function", "function": { "name": "calculator", "description": "计算一个数学表达式", "parameters": { "type": "object", "properties": { "expression": {"type": "string", "description": "如 12*7+3"} }, "required": ["expression"] } } }, { "type": "function", "function": { "name": "finish", "description": "给出最终答案并结束循环", "parameters": { "type": "object", "properties": { "answer": {"type": "string"} }, "required": ["answer"] } } } ] }循环控制配置是防止死循环的关键。我一般设三个上限:最大轮数、单轮超时、连续无进展次数。
MAX_TURNS = 8 # 最多 8 轮 Thought-Action-Observation TURN_TIMEOUT = 30 # 单轮模型调用超时秒数 MAX_NO_PROGRESS = 2 # 连续两轮 Observation 无新信息则强制收敛把这三样拼起来,循环骨架长这样:
import os, json, time from openai import OpenAI client = OpenAI( api_key=os.environ["TAOTOKEN_API_KEY"], base_url=os.environ["TAOTOKEN_BASE_URL"], ) def run_react(user_query, tools_schema, tool_impl): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_query}, ] no_progress = 0 for turn in range(MAX_TURNS): resp = client.chat.completions.create( model="claude-sonnet-4-5", messages=messages, tools=tools_schema, timeout=TURN_TIMEOUT, ) msg = resp.choices[0].message messages.append(msg) if not msg.tool_calls: # 模型没调工具,视为直接回答,退出 return msg.content call = msg.tool_calls[0] name = call.function.name args = json.loads(call.function.arguments) if name == "finish": return args["answer"] observation = tool_impl(name, args) messages.append({ "role": "tool", "tool_call_id": call.id, "content": str(observation), }) if observation in ("", "无结果"): no_progress += 1 if no_progress >= MAX_NO_PROGRESS: return "检索无有效结果,提前收敛" else: no_progress = 0 return "达到最大轮数,未收敛"注意messages.append(msg)这一步:把 assistant 的 tool_calls 消息原样追加,再用role: tool把 Observation 回填,tool_call_id必须和call.id对上。这是 Observation 正确回填的硬性要求,对不上模型下一轮就读不到结果。
4. 验证请求:用日志看 Thought 和 Action 是否对齐
配置写完,别急着跑复杂任务。先用一个需要两步检索的问题验证循环是否真的在转。我用的测试问题是:“先查一下 ReAct 论文的提出年份,再把这个年份乘以 2。”
跑之前加一段日志,把每一轮的 Thought、Action、Observation 都打出来:
def log_turn(turn, thought, action, action_input, observation): print(f"--- Turn {turn} ---") print(f"Thought: {thought}") print(f"Action: {action}") print(f"Action Input: {action_input}") print(f"Observation: {observation}")理想情况下你会看到这样的轨迹:
--- Turn 0 --- Thought: 我需要先知道 ReAct 论文的提出年份,应该调用 search Action: search Action Input: {"query": "ReAct 论文 提出年份"} Observation: ReAct 由普林斯顿与 Google Research 于 2022 年提出 --- Turn 1 --- Thought: 已知年份是 2022,需要乘以 2,调用 calculator Action: calculator Action Input: {"expression": "2022*2"} Observation: 4044 --- Turn 2 --- Thought: 已经得到结果 4044,可以给出最终答案 Action: finish Action Input: {"answer": "4044"}怎么判断 Thought 和 Action 对齐?看两点。第一,Thought 里描述的动作和实际 Action 是否一致——如果 Thought 说“我要搜索”,Action 却是 calculator,那就是对齐失败,通常是提示词里格式约束不够强,或者模型把工具名记混了。第二,Action Input 是否包含了 Thought 里提到的关键信息——Thought 说“用 2022 乘以 2”,Action Input 里就该出现 2022 和 2,如果只写了{"expression": "x*2"},说明模型没把 Observation 里的数字正确带入。
Observation 是否正确回填,看下一轮 Thought 有没有引用上一轮的结果。上面 Turn 1 的 Thought 明确说“已知年份是 2022”,这个 2022 只能来自 Turn 0 的 Observation。如果 Turn 1 的 Thought 里没有出现 2022,或者出现了别的数字,说明回填断了——大概率是tool_call_id没对上,或者 messages 顺序错了。
循环是否收敛,看它有没有在合理轮数内走到 finish。上面三轮就收敛了,正常。如果跑到第 8 轮还在 search,且每轮 Observation 都差不多,那就是没收敛,需要检查提示词里有没有告诉模型“Observation 无新信息时应该换策略或收敛”。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
跑 ReAct 循环时,报错基本集中在这几类。我按真实遇到的顺序列一下,对照着查。
401 Unauthorized:最常见。先确认TAOTOKEN_API_KEY环境变量有没有真的导出,echo $TAOTOKEN_API_KEY看是不是空。如果 Key 是对的还报 401,检查 Base URL 有没有多写或少写/v1——TaoToken 的兼容接口是https://taotoken.net/api,有些 SDK 会自动补/v1,有些不会,对不上就 401。另外 Key 如果是在控制台刚创建的,确认没有复制到多余空格。
local proxy failed / connection error:这类通常是网络层的问题,不是 Key 的问题。先确认你的运行环境能正常访问https://taotoken.net/api,用 curl 测一下:
curl -s -o /dev/null -w "%{http_code}" https://taotoken.net/api/models \ -H "Authorization: Bearer $TAOTOKEN_API_KEY"返回 200 说明通,返回 000 或超时说明网络没通。注意不要在代码里配置任何本地代理地址,直接连官方接口即可。
reading 'choices' of undefined:这个报错说明resp.choices是 undefined,也就是返回体结构和你预期的不一样。两种可能:一是请求本身失败了,返回的是错误对象而不是正常响应,你没检查resp.error就直接取choices;二是模型名写错了,接口返回了错误信息。加一层判断:
if not resp or not getattr(resp, "choices", None): print("原始返回:", resp) raise RuntimeError("响应结构异常,检查模型名和请求参数")OAuth / authentication 相关报错:如果你用的是 Claude Code 或 Codex 这类带 OAuth 登录的工具,报 OAuth 错误通常是因为它默认走自己的登录态,而不是你配的 API Key。这时候要显式配置三件套:Base URL 填https://taotoken.net/api,Key 填你的sk-开头 Key,Model ID 填支持工具调用的模型。以 Codex 的auth.json为例,配置结构大致是:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的key", "model": "claude-sonnet-4-5" }如果你用 CC Switch 或 Cline 的 MCP 配置,同样把这三件套填全,缺一个都会导致鉴权失败或模型调用不到。Cline 的 MCP 配置里 Base URL 和 Key 是分开的字段,别只填一个。
还有一个隐蔽的坑:工具调用返回的arguments是字符串,不是 dict,必须json.loads一次。如果模型输出的 JSON 不合法(比如多了个逗号),json.loads会抛异常,循环直接崩。加个 try 包一下,解析失败就把错误信息作为 Observation 回填,让模型自己修:
try: args = json.loads(call.function.arguments) except json.JSONDecodeError as e: observation = f"Action Input 不是合法 JSON: {e}" messages.append({"role": "tool", "tool_call_id": call.id, "content": observation}) continue这样模型下一轮看到“JSON 不合法”的 Observation,通常会重新输出一个合法的。
6. 把循环跑稳之后,往哪走
最小 ReAct 循环跑通后,你会发现真正的难点不在循环本身,而在工具设计和收敛控制。工具的输出格式越标准化,模型越容易把它当 Observation 用;工具报错信息越具体,模型自我纠错的成功率越高。我一般会让工具返回结构化的文本,比如{"status": "ok", "data": "..."}或{"status": "error", "reason": "..."},而不是直接抛异常。
收敛控制上,除了最大轮数,还可以加一个“重复检测”:如果连续两轮的 Action 和 Action Input 完全一样,直接强制收敛,因为模型卡住了。这个判断放在循环里很简单:
if turn > 0 and action == prev_action and action_input == prev_input: return "检测到重复动作,强制收敛" prev_action, prev_input = action, action_input想长期跑编码类或 Agent 类任务,单次调用按量付费不如用 Coding Plan 划算,尤其是循环轮数多的时候。模型对话入口适合先手动验证提示词和工具定义,确认模型能按格式输出后再进代码。接入文档里有工具调用和流式返回的完整字段说明,遇到tool_calls结构对不上时对着查最快。
最后留一个实用习惯:每次跑完把完整的 messages 数组 dump 成 JSON 存下来。ReAct 的价值就在于决策轨迹可回放,出问题时把这份日志打开,逐轮看 Thought 和 Observation 的对应关系,比盯着报错猜快得多。