
1. ReAct 推理行动闭环在 Agent Harness 里到底卡在哪ReAct 框架的核心其实一句话就能说清让模型在每一步先输出一段显式思考Thought再决定调用哪个工具Action拿到工具返回Observation后继续下一轮直到给出最终答案。它解决的是传统 LLM 那种“一口气把答案吐出来、中间过程不可见、错了也不知道错在哪”的问题。适合谁适合正在做 AI Agent Harness Engineering 的开发者——也就是那些不满足于“调个 API 问一句答一句”而是想让 Agent 真正能查资料、跑代码、调接口、多步推理的人。但真把它落到工程里卡点往往不在 ReAct 这个概念本身而在“接入层”。我见过太多项目ReAct 循环写得挺漂亮结果一跑就崩原因集中在三块第一块是 Key 和通道管理混乱。一个 Agent 里可能同时要调对话模型、要调代码模型、还要调 embedding如果每个模型各配一套 Key、各写一套 Base URL环境变量能堆到十几个换一个供应商就要改一遍代码。更麻烦的是很多团队在本地调试时用一套配置上了服务器又是另一套最后排查问题时根本分不清是模型的问题还是配置的问题。第二块是推理链不可观测。ReAct 的价值就在于 Thought-Action-Observation 是显式的但如果你只是把模型输出 print 出来日志里全是混在一起的文本你根本没法快速判断“这一轮到底有没有按预期交替执行”——是模型跳过了 Thought 直接 Action还是 Action 调用了不存在的工具还是 Observation 没被正确回填导致下一轮上下文断裂没有结构化日志这些全靠肉眼猜。第三块是工具注册和解析的边界问题。ReAct 要求模型输出严格遵循Thought: ... Action: ... Action Input: ...的格式但模型偶尔会自由发挥比如把 Action 写成中文、把参数塞进 Thought 里、或者一次输出两个 Action。如果你的解析器不够健壮整个循环就会在某一轮直接抛异常中断。这篇要做的就是用 TaoToken 作为统一的 Key/API 通道把上面三块一次性理顺一套 Base URL、一个 Key 管住所有模型调用再配一套可复制的环境变量、工具注册示例和日志检查步骤让你能亲眼看到 ReAct 的推理链是不是按预期在跑。下面从接入配置开始一步步来。2. 用 TaoToken 统一 Key 接入 ReAct Agent 的模型通道在动手写 ReAct 循环之前先把模型通道这件事解决掉。ReAct Agent 对模型的要求其实比普通问答高它需要模型稳定地遵循格式输出 Thought/Action需要支持多轮上下文拼接还需要在工具调用失败时能根据 Observation 重新规划。如果模型通道本身不稳定或者 Key 管理混乱导致你频繁切换配置调试成本会成倍上升。TaoToken 在这里扮演的角色是“统一入口”你只需要在官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册后拿到一个 Key然后在控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 里创建 API Key后续所有模型调用都走同一个 Base URLhttps://taotoken.net/api。注意这个 API 地址后面不加任何 UTM 参数直接用它作为 OpenAI 兼容的 base_url 即可。为什么强调“统一”因为 ReAct Agent 在实际运行中不同环节可能想用不同模型推理主循环用能力强的模型工具参数解析用快而便宜的模型日志摘要用轻量模型。如果每个模型都单独配 Key 和地址你的.env会变成一团乱麻。统一通道之后你只需要在请求时改model字段Key 和 Base URL 始终不变。具体操作上先去 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 生成一个 Key然后把它写进环境变量。我建议不要硬编码在代码里而是用.env文件管理这样本地和服务器可以共用同一套代码只换环境变量。环境变量配置如下你可以直接复制# .env 文件 TAOTOKEN_API_KEYsk-你的实际Key TAOTOKEN_BASE_URLhttps://taotoken.net/api REACT_MODELgpt-4o-mini REACT_TEMPERATURE0 REACT_MAX_STEPS8这里REACT_TEMPERATURE0是关键ReAct 需要确定性输出温度调高会让模型在 Thought 里“发散”导致 Action 格式不稳定。REACT_MAX_STEPS是循环上限防止模型陷入无限思考。然后在 Python 里读取import os from dotenv import load_dotenv from openai import OpenAI load_dotenv() client OpenAI( api_keyos.getenv(TAOTOKEN_API_KEY), base_urlos.getenv(TAOTOKEN_BASE_URL), ) def call_model(messages, modelNone): resp client.chat.completions.create( modelmodel or os.getenv(REACT_MODEL), messagesmessages, temperaturefloat(os.getenv(REACT_TEMPERATURE, 0)), stop[\nObservation:], # 关键让模型在 Observation 前停下 ) return resp.choices[0].message.content注意stop[\nObservation:]这一行。ReAct 的循环里Observation 是由你的代码工具执行结果填进去的不是模型生成的。如果你不设 stop模型可能会自己编一个 Observation导致推理链和真实工具结果脱节。设了这个 stop 之后模型输出到 Action Input 结束就会停你拿到结果后自己拼 Observation 再发下一轮。如果你用的是 Claude Code 这类工具做辅助开发也可以在它的配置里把 Base URL 指向同一个地址Key 用同一个这样你在编辑器里调试 prompt 和在实际 Agent 里跑用的是同一套通道减少“本地能跑线上不行”的问题。Claude Code 的接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里有说明核心就是三件套Base URL 填https://taotoken.net/apiKey 填你的 TaoToken KeyModel ID 填你控制台里可用的模型名。统一通道之后接下来就可以专心写 ReAct 循环本身了。3. 可复制的 ReAct 循环配置与工具注册示例这一节是核心直接给你能跑的代码结构。ReAct 循环的骨架其实不复杂维护一个messages列表每轮把当前上下文发给模型解析出 Thought 和 Action执行工具拿到 Observation把 Observation 追加进messages继续下一轮直到解析出 Final Answer 或达到步数上限。先定义工具注册表。工具的本质是一个字典名字映射到可调用函数同时给模型一段描述说明这个工具干什么、参数是什么格式。import json import re # 工具注册表 TOOLS {} def register_tool(name, description, func): TOOLS[name] {description: description, func: func} # 示例工具1计算器 def calculator(expression: str) - str: try: # 只允许数字和基本运算符生产环境务必做沙箱 allowed set(0123456789-*/(). ) if not set(expression) allowed: return 错误表达式包含不允许的字符 result eval(expression, {__builtins__: {}}, {}) return str(result) except Exception as e: return f计算错误{e} register_tool( calculator, 用于数学计算。输入是一个算术表达式例如 23*4715。, calculator, ) # 示例工具2模拟搜索实际项目替换为真实检索 def mock_search(query: str) - str: knowledge { react framework: ReAct 是 Reasoning Acting 的缩写由普林斯顿和谷歌在 2022 年提出核心是让 LLM 交替生成推理轨迹和行动。, harness engineering: Harness Engineering 指围绕 Agent 构建可观测、可控制、可复现的运行框架包括工具注册、日志、错误恢复等。, } for k, v in knowledge.items(): if k in query.lower(): return v return f未找到关于「{query}」的本地知识请尝试其他关键词。 register_tool( search, 用于检索知识库。输入是一个查询字符串例如 react framework。, mock_search, )工具描述要写得让模型能判断“什么时候用哪个”。描述里最好带一个输入示例这样模型生成 Action Input 时格式更稳。接下来是 ReAct 的 prompt 模板。这个模板决定了模型输出的格式必须严格REACT_PROMPT 你是一个能够进行推理和行动的 AI Agent。你可以使用以下工具 {tool_descriptions} 请严格按照以下格式回答 Thought: 你的思考过程说明你下一步要做什么以及为什么。 Action: 工具名称必须是 [{tool_names}] 之一。 Action Input: 工具的输入参数。 当你已经获得足够信息可以回答用户问题时使用以下格式 Thought: 我已经有足够信息回答问题了。 Final Answer: 你的最终答案。 重要规则 1. 每次只能输出一个 Action。 2. Action 必须是工具列表中的名称不要自创工具。 3. Action Input 必须是纯文本不要加引号或 JSON 包裹。 4. 不要自己编造 ObservationObservation 会由系统提供。 用户问题{question} {scratchpad}scratchpad是历史轨迹的拼接区每轮把之前的 Thought/Action/Observation 追加进去。这样模型能看到自己走过的路避免重复调用同一个工具。解析器负责从模型输出里提取 Thought、Action、Action Input 或 Final Answerdef parse_react_output(text: str): # 优先检查 Final Answer final_match re.search(rFinal Answer:\s*(.), text, re.DOTALL) if final_match: return {type: final, answer: final_match.group(1).strip()} thought_match re.search(rThought:\s*(.?)(?\nAction:|$), text, re.DOTALL) action_match re.search(rAction:\s*(.?)(?\nAction Input:|$), text, re.DOTALL) input_match re.search(rAction Input:\s*(.), text, re.DOTALL) if not action_match: return {type: error, raw: text, reason: 未找到 Action} return { type: action, thought: thought_match.group(1).strip() if thought_match else , action: action_match.group(1).strip(), action_input: input_match.group(1).strip() if input_match else , }主循环def run_react_agent(question: str, max_steps: int 8): tool_descriptions \n.join( f- {name}: {info[description]} for name, info in TOOLS.items() ) tool_names , .join(TOOLS.keys()) scratchpad trace [] # 结构化日志 for step in range(max_steps): prompt REACT_PROMPT.format( tool_descriptionstool_descriptions, tool_namestool_names, questionquestion, scratchpadscratchpad, ) raw call_model([{role: user, content: prompt}]) parsed parse_react_output(raw) if parsed[type] final: trace.append({step: step, type: final, answer: parsed[answer]}) return parsed[answer], trace if parsed[type] error: trace.append({step: step, type: parse_error, raw: raw}) scratchpad f\n{raw}\nObservation: 解析失败请严格按格式输出。\n continue action_name parsed[action] action_input parsed[action_input] if action_name not in TOOLS: observation f错误工具 {action_name} 不存在可用工具{tool_names} else: try: observation TOOLS[action_name][func](action_input) except Exception as e: observation f工具执行异常{e} trace.append({ step: step, type: action, thought: parsed[thought], action: action_name, action_input: action_input, observation: observation, }) scratchpad ( f\nThought: {parsed[thought]}\n fAction: {action_name}\n fAction Input: {action_input}\n fObservation: {observation}\n ) return 达到最大步数仍未得出最终答案, trace这段代码里trace就是可观测性的关键。每一轮的结构化记录都存下来后面验证推理链是否交替执行直接看这个列表就行。如果你用 Cline 或类似支持 MCP 的工具做开发辅助可以把上面的工具注册逻辑抽成一个 MCP server让编辑器里的 Agent 也能调用同一套工具。配置时同样注意三件套Base URL 用https://taotoken.net/apiKey 用 TaoToken 的 KeyModel ID 填你实际使用的模型。这样你在编辑器里测试工具调用和在实际 Agent 里跑行为是一致的。4. 验证请求用日志确认推理链与工具调用交替执行代码写完了怎么确认它真的在按 ReAct 的方式跑不能只看最终答案对不对因为答案对可能是模型蒙的。要看的是中间过程Thought 和 Action 有没有交替出现Observation 有没有正确回填工具调用参数是不是合理。先跑一个需要两步工具调用的测试问题question 请先搜索 react framework 是什么然后用计算器算一下 23*4715 等于多少。 answer, trace run_react_agent(question) print(最终答案, answer) print(\n 推理链日志 ) for item in trace: print(json.dumps(item, ensure_asciiFalse, indent2))预期你会看到类似这样的结构化日志实际内容因模型输出略有差异[ { step: 0, type: action, thought: 用户要求先搜索 ReAct 框架我应该先调用 search 工具。, action: search, action_input: react framework, observation: ReAct 是 Reasoning Acting 的缩写... }, { step: 1, type: action, thought: 已经获得 ReAct 的定义接下来需要计算 23*4715。, action: calculator, action_input: 23*4715, observation: 1096 }, { step: 2, type: final, answer: ReAct 是 Reasoning Acting 的缩写... 23*4715 的结果是 1096。 } ]检查要点有三个第一type字段是否按action - action - final的顺序出现。如果第一步就是final说明模型跳过了工具调用直接凭内部知识回答这不符合 ReAct 的预期。如果连续出现多个action但observation是空的或报错说明工具执行环节有问题。第二每个action的action_input是否和工具描述匹配。比如calculator的输入应该是纯表达式如果模型输出了23*4715带引号你的工具函数要能容错处理。我试过在解析器里加一层strip(\)来去掉模型偶尔加的引号。第三observation是否被正确回填到下一轮的scratchpad。你可以在run_react_agent里加一行调试输出打印每轮发给模型的完整 prompt确认 Observation 确实出现在上下文里。如果 Observation 没进去模型下一轮会重复调用同一个工具形成死循环。除了看 trace还可以把每轮的原始模型输出也记下来。在call_model返回后加一行with open(react_raw.log, a, encodingutf-8) as f: f.write(f step {step} \n{raw}\n\n)这样当解析失败时你能直接看到模型到底输出了什么是格式跑偏还是内容有问题。实测下来大部分“ReAct 不工作”的情况根源都是模型输出格式和解析器预期不一致而不是 ReAct 逻辑本身有问题。如果你想让验证更直观可以用模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 手动把同样的 prompt 贴进去看看模型在交互式环境下的输出格式和你在代码里拿到的做对比。有时候差异来自 stop 参数或 temperature 设置对比一下就能定位。5. 本篇常见错误排查401、local proxy failed、reading choices、OAuthReAct Agent 跑不起来报错往往集中在接入层和解析层。下面按真实报错逐个说。401 Unauthorized。这个最直接Key 不对或没传。检查三处.env里TAOTOKEN_API_KEY是否真的被load_dotenv()读到了可以print(os.getenv(TAOTOKEN_API_KEY)[:8])看前几位Key 是否在控制台被删除或过期请求头里的Authorization: Bearer sk-xxx格式是否正确。如果你用的是某些封装库它可能默认读OPENAI_API_KEY而不是你自定义的变量名这时候要么改库的配置要么把变量名对齐。local proxy failed / connection error。这类报错通常和网络环境有关。先确认TAOTOKEN_BASE_URL写的是https://taotoken.net/api没有多余斜杠或路径。然后确认你的运行环境能正常访问这个地址可以用curl -I https://taotoken.net/api测一下连通性。如果是在容器或服务器里跑检查是否配了额外的 HTTP_PROXY/HTTPS_PROXY 环境变量这些变量有时会干扰正常请求。注意这里说的是排查你自己环境里的代理配置不是让你去搭什么通道把干扰项去掉即可。reading choices 报错比如KeyError: choices或list index out of range。这说明你拿到的响应结构和你预期的不一样。常见原因有两个一是请求根本没成功返回的是错误 JSON比如{error: {...}}但你的代码直接去取resp[choices]二是流式和非流式响应混用你按非流式解析但实际开了 stream。解决办法是在call_model里加一层判断resp client.chat.completions.create(...) if not resp.choices: raise RuntimeError(f响应无 choices{resp}) return resp.choices[0].message.content这样报错信息会明确告诉你响应内容而不是一个模糊的 KeyError。OAuth / authentication 相关报错。如果你在用 Claude Code 或类似工具它可能默认走 OAuth 登录流程而不是 API Key。这时候需要在工具的配置里显式指定用 API Key 模式并把 Base URL 指向https://taotoken.net/api。具体配置项名称各工具不同但核心是三件套Base URL、Key、Model ID。以 Claude Code 为例它的配置文件里需要填ANTHROPIC_BASE_URL和ANTHROPIC_API_KEYModel ID 填你控制台里可用的模型名。配置文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里有详细说明。还有一个容易被忽略的错误模型输出格式漂移导致解析器返回 error但循环没有正确处理。表现是 Agent 跑了几步后突然输出“达到最大步数仍未得出最终答案”但 trace 里全是parse_error。这时候要看react_raw.log大概率是模型某一轮把Action:写成了行动或者把参数塞进了 Thought。解决办法是在 prompt 里加强格式约束或者在解析器里加中文关键词的兼容映射。排障的核心思路是先确认通道通401/连接类错误再确认响应结构对choices 类错误最后确认解析逻辑稳parse_error 类。三层都过了ReAct 循环基本就能稳定跑起来。6. 把 ReAct 闭环接到长期编码与 Agent 工作流单次 ReAct 循环跑通只是第一步。真正在 Harness Engineering 里落地你需要考虑的是这个循环怎么被复用、怎么被观测、怎么在长任务里保持稳定。一个实用的做法是把 ReAct 循环封装成一个可配置的 Runner工具注册表、prompt 模板、解析器都作为参数传入。这样你可以针对不同任务注册不同工具集而循环逻辑不变。比如做代码调试的 Agent 注册“读文件、跑测试、查文档”三个工具做数据分析的 Agent 注册“查数据库、画图、算统计”三个工具底层用的是同一套 ReAct 引擎。长期运行的 Agent 还需要考虑上下文管理。ReAct 的 scratchpad 会随着步数增长越来越长最终可能超出模型上下文窗口。解决办法是在每轮之后对 scratchpad 做压缩保留最近 N 轮的完整轨迹更早的轮次只保留 Thought 和最终 Observation 的摘要。摘要可以用同一个模型通道生成因为 Key 和 Base URL 是统一的调用成本很低。如果你要做的是持续性的编码任务或需要多轮 Agent 协作的场景可以了解一下 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 它针对长期编码场景做了通道和配额上的优化适合把上面这套 ReAct 循环跑在真实项目里而不是 demo 里。最后给一个实操建议在你自己的项目里先把trace日志接到你现有的日志系统里用结构化字段step、type、action、observation建索引。这样当 Agent 行为异常时你可以直接按typeparse_error或action某个工具过滤快速定位是哪一环出了问题。ReAct 的可解释性优势只有在你真的把中间过程记下来并利用起来的时候才真正发挥出来。