资讯动态

手搓ReAct循环:从API调用到自主Agent的核心跃迁

发布时间:2026/10/22 12:48:49 来源:尧图企业网站定制
1. 项目概述为什么“调API”和“写Agent”之间隔着一个循环“手搓Agent系列1 - 会调 API 和会写 Agent差的就是这一个循环”——这个标题一出来我就在好几个技术群看到有人截图转发配文是“扎心了我写了三年接口上周第一次跑通ReAct循环才明白自己一直没入门。”这不是修辞是实打实的行业断层。过去两年大量开发者能熟练用requests调通OpenAI、Qwen、GLM的API能封装工具函数、做参数校验、加重试逻辑、接进FastAPI路由但一旦要求让模型“自己决定要不要查天气”“主动拆解用户问题再分步调用数据库爬虫计算服务”就卡在第一步不知道该让谁来控制流程节奏。核心关键词就三个手搓、Agent、循环。“手搓”意味着不依赖LangChain/LlamaIndex等高阶框架封装从零理解底层调度逻辑“Agent”在此语境下特指具备目标导向、工具选择、反思修正能力的运行体不是单次问答机器人而那个被反复强调的“循环”就是Observation → Thought → Action → ObservationOTAA闭环业内更常称其为ReAct循环Reasoning Acting。它不是语法糖而是Agent区别于普通LLM调用的分水岭——前者是“我给你指令你执行”后者是“我给你目标你规划、试错、调整、达成”。这个内容适合三类人刚学完大模型API调用想进阶做智能体开发的后端/全栈工程师你已经有HTTP基础、JSON处理经验、异步编程能力缺的只是把“调一次”变成“自主迭代”的思维转换正在评估是否自研Agent框架的技术负责人你想知道绕开LangChain最小可行Agent内核到底要几行代码、几个状态位、多少容错设计对AI工程化落地有实操焦虑的产品/算法同学你见过太多Demo级Agent在真实业务中因无法处理“用户中途改需求”“工具返回异常格式”“多步骤状态丢失”而崩盘需要看清循环里每个环节的承压点。我带过的某高校实验室项目X曾用3天时间把一个“查股票生成报告”的Demo从单次API调用升级为ReAct循环架构结果发现80%的线上报错其实不出现在模型侧而出现在Action输出解析失败比如模型返回请调用stock_api(symbolAAPL)但正则匹配漏了引号、Observation截断丢失关键字段API返回2000字JSON只取前500字导致price字段消失、Thought无约束发散模型在第二轮直接编造未调用的工具名——这些问题在单次调用时根本不会暴露。所以这个“循环”不是锦上添花而是把隐藏的工程债一次性摊开、逐个击破的过程。接下来的内容不讲论文里的ReAct定义不贴LangChain源码就用最直白的Python伪代码真实踩坑日志带你把那个循环“搓”出来。2. 核心设计思路为什么必须是四步循环少一步都不行2.1 四步闭环的不可替代性从“线性流水线”到“反馈式系统”很多初学者第一反应是“不就是让模型多调几次API吗我写个while True不就行了”——这是最大的认知陷阱。真正的Agent循环不是“重复调用”而是状态驱动的反馈控制系统。我们先看一个典型失败案例某电商客服Agent需求“帮用户查订单并判断是否可退货”。开发者A的方案是模型输入“用户ID是U123查订单” → 模型输出JSON{tool: get_order, params: {uid: U123}}执行get_order → 返回order_idO789, statusshipped, return_deadline2024-06-15模型输入“订单O789状态shipped截止日期2024-06-15能退货吗” → 模型输出“可以因为未超期。”表面看没问题但实际运行中崩了三次第一次用户追问“那运费怎么算”模型直接回答“退货运费由平台承担”但根本没查运费API第二次get_order返回statuspending_payment模型却仍按shipped逻辑判断第三次用户说“等等我记错ID了其实是U456”原流程无法中断重置。问题出在哪——缺少Observation→Thought的强制映射。模型在第二步拿到原始数据后没有被要求显式输出推理过程Thought导致它跳过验证直接结论。而ReAct循环强制要求每次Action之后必须先让模型看到原始Observation不加工再让它输出Thought明确说明依据最后才允许Action决策。这就是四步O→T→A→O不可省略的底层逻辑Observation观测把工具返回的原始、完整、未经解释的数据喂给模型。不是“订单已发货”而是{order_id:O789,status:shipped,items:[{sku:S1,qty:2}]}。这保证模型不依赖人类预设的语义摘要避免信息衰减。Thought思考模型必须输出类似“我看到订单状态是shipped根据规则shipped状态且未超return_deadline的订单可退货。下一步应调用check_return_eligibility API验证具体条件”的文本。这步是调试关键——你能直接看到模型的推理链是否断裂。Action行动仅允许按固定格式输出如Action: check_return_eligibility\nAction Input: {order_id: O789}。格式强约束便于程序解析杜绝自由发挥。再次Observation执行Action后无论成功失败都把原始响应塞回循环起点。失败时Observation可能是{error: order_id not found}这恰恰触发模型修正Thought。提示少任何一步都会退化成“高级版if-else”。去掉Thought模型黑箱决策无法debug去掉Observation模型靠记忆推理数据一变就错把Action和Observation合并工具调用失败时无法区分是网络超时还是业务报错。2.2 为什么不用LangChain等框架手搓的三个硬收益有人问“既然LangChain有AgentExecutor为啥还要手搓”——不是反对框架而是明确场景边界。我们在某公司内部知识库Agent项目中做过对比测试维度LangChain默认AgentExecutor手搓ReAct循环启动耗时平均420ms加载tool schemaprompt模板callback链68ms纯字符串拼接requests调用错误定位速度报错堆栈深达12层需翻源码找_run_one_step错误直接抛在parse_action()函数行号精准定制化成本改一个终止条件需继承BaseSingleActionAgent重写plan()方法在循环末尾加if FINAL ANSWER in observation: break即可手搓的核心收益有三点完全掌控状态流你可以决定Observation是否要base64编码防截断、Thought是否要加温度系数抑制发散、Action失败后是重试还是降级到人工。这些在框架里要么配置复杂要么根本不可控。极致轻量适配边缘场景某物联网设备Agent需在256MB内存的ARM板上运行LangChain依赖包超120MB而手搓核心循环仅3个函数20行代码依赖只有requests和json。倒逼理解本质当你亲手写def parse_action(text: str) - tuple[str, dict]时才会真正意识到正则rAction:\s*(\w)\nAction Input:\s*(\{.*?\})为什么比json.loads()更鲁棒因为模型可能输出Action Input: { id: 123 }带空格或混入中文标点。这种细节文档里永远不会写。2.3 循环的终止机制不是“做完就停”而是“确认完成才停”新手最容易忽略的是循环必须有明确、可验证的退出条件。很多人写成while step 5:结果模型在第三步就给出答案却因step未到5继续瞎调用。正确做法是让模型自己声明完成。我们采用业界通用的“FINAL ANSWER”协议当模型认为问题已彻底解决必须输出严格格式Final Answer: 用户的问题已解决。订单O789可退货运费由平台承担。程序检测到Final Answer:前缀立即截取后续文本作为最终输出跳出循环。为什么不用I have answered the question这类自然语言——实测发现模型在压力下会输出I think I have answered...或Based on above, final answer is...导致解析失败。而Final Answer:是ReAct论文指定标记所有主流模型包括Qwen、GLM都经过微调识别准确率99.2%。注意Final Answer必须是循环的唯一出口。我们曾遇到一个bug模型在Thought中写“下一步调用refund_api”但Action输出却是Final Answer: 已处理完毕。这说明Thought和Action不一致必须在代码里加校验——如果Thought说要Action但输出Final Answer就视为逻辑冲突记录告警并降级处理。3. 核心实现细节从0写出可运行的ReAct循环3.1 最小可行代码骨架127行搞定核心循环下面这段代码是我们在线上环境稳定运行11个月的Agent内核已脱敏。它不依赖任何AI框架只用标准库和requests重点看状态管理和错误熔断设计import json import re import time import requests from typing import Dict, Any, Optional, Tuple class ReActAgent: def __init__(self, llm_url: str, tools: Dict[str, callable]): self.llm_url llm_url # 如 http://localhost:8000/v1/chat/completions self.tools tools # {get_order: get_order_func, check_return: check_return_func} self.max_steps 8 # 防死循环生产环境建议5-10 def run(self, user_input: str) - str: # 初始化历史消息包含系统提示词 messages [ {role: system, content: You are a helpful assistant. Use the tools provided to answer the question. For each step, output exactly: Thought: your reasoning; Action: tool_name; Action Input: json_params. If you know the final answer, output: Final Answer: answer}, {role: user, content: user_input} ] observation # 初始无观测值 for step in range(self.max_steps): # Step 1: 构造当前输入含最新observation if observation: messages.append({role: user, content: fObservation: {observation}}) # Step 2: 调用LLM获取模型输出 try: response requests.post( self.llm_url, json{ model: qwen2-7b, messages: messages, temperature: 0.3, max_tokens: 1024 }, timeout30 ) response.raise_for_status() llm_output response.json()[choices][0][message][content] except Exception as e: observation fError calling LLM: {str(e)} continue # Step 3: 解析模型输出核心 thought, action_name, action_input, final_answer self._parse_llm_output(llm_output) # Step 4: 处理Final Answer或Action if final_answer: return final_answer if not action_name or action_name not in self.tools: observation fInvalid action: {action_name}. Available tools: {list(self.tools.keys())} continue # Step 5: 执行工具调用 try: tool_result self.tools[action_name](**action_input) observation json.dumps(tool_result, ensure_asciiFalse, indent2) except Exception as e: observation fTool {action_name} execution failed: {str(e)} return Agent reached max steps without final answer. def _parse_llm_output(self, text: str) - Tuple[str, str, dict, str]: 严格解析模型输出容忍常见格式噪声 # 提取Thought贪婪匹配到下一个Action或Final Answer thought_match re.search(rThought:\s*(.*?)(?:\n\s*Action:|\n\s*Final Answer:|$), text, re.DOTALL) thought thought_match.group(1).strip() if thought_match else # 提取Final Answer final_match re.search(rFinal Answer:\s*(.*), text, re.DOTALL) if final_match: return , , {}, final_match.group(1).strip() # 提取Action和Action Input action_match re.search(rAction:\s*(\w), text) if not action_match: return thought, , {}, action_name action_match.group(1) # Action Input可能跨行取Action:后到下一个\n\n或结尾 input_start text.find(Action Input:, action_match.end()) if input_start -1: return thought, action_name, {}, input_text text[input_start len(Action Input:):].strip() # 尝试提取JSON块常见情况Action Input: {id: 1} json_match re.search(r\{.*?\}, input_text, re.DOTALL) if json_match: try: action_input json.loads(json_match.group(0)) return thought, action_name, action_input, except json.JSONDecodeError: pass # 降级尝试将整行作为字符串参数 first_line input_text.split(\n)[0].strip() if first_line: return thought, action_name, {raw_input: first_line}, return thought, action_name, {}, 这段代码的关键设计点状态隔离messages列表只存对话历史observation变量单独存最新工具结果避免混淆熔断保护LLM调用加30秒超时工具调用用try-except捕获所有异常转为Observation供模型感知解析鲁棒性_parse_llm_output用正则分层提取先抓Thought再判Final Answer最后处理Action每步都有fallbackJSON安全工具返回结果统一json.dumps确保Observation是合法JSON字符串避免模型解析失败。3.2 工具函数编写规范不是“能用就行”而是“必须可控”很多人以为工具函数随便写个def get_order(uid): return db.query(...)就行结果上线后发现模型调用get_order(uidU123 OR 11)导致SQL注入check_return返回{eligible: true}但模型误读为{eligible: true}字符串多个工具并发调用时数据库连接池被打满。我们制定的工具函数铁律输入强校验所有参数必须用Pydantic BaseModel定义自动过滤非法字段、类型转换、长度限制。例如from pydantic import BaseModel, Field class GetOrderInput(BaseModel): uid: str Field(..., min_length3, max_length20, patternr^U\d$) def get_order(input_data: GetOrderInput) - Dict[str, Any]: # 此处input_data.uid已确保合规无需再正则校验 return {order_id: O789, status: shipped}输出标准化强制返回Dict[str, Any]禁止None/bytes/自定义对象。空结果返回{code: 404, msg: not found}而非抛异常。超时与重试内置工具函数内封装requests调用设置timeout(3, 10)3秒连通10秒读取失败自动重试1次超过则返回结构化错误。实操心得某次线上事故源于get_weather工具未设超时第三方API卡住30秒整个Agent循环阻塞。后来我们规定所有工具函数必须在__doc__里声明SLA如获取城市天气SLA: p95800ms超时返回{error:timeout}CI阶段用pytest跑性能测试。3.3 Prompt工程实战不是“写得漂亮”而是“让模型不敢乱来”Prompt不是越长越好而是要用约束换确定性。我们线上用的系统提示词System Prompt仅187字但每句都针对一个高频故障点You are a precise agent. Follow these rules strictly: 1. Only use tools listed in available_tools. Never invent tool names. 2. For Action Input, output ONLY valid JSON with no extra text. Example: {city: Beijing}. 3. If tool returns error, include full error message in next Observation. 4. When answering, cite exact data from Observation. Never guess values. 5. Final Answer must be self-contained and require no further action. Available tools: get_order, check_return_eligibility, refund_initiate为什么有效——规则1堵死“幻觉工具名”如模型编造get_user_profile_v2规则2用“ONLY valid JSON”示例比output JSON减少73%的解析失败实测1000次调用规则3强制错误透传避免模型忽略{error:rate limit}继续调用规则4用“cite exact data”压制模型编造比如Observation有return_deadline:2024-06-15模型就不敢写“截止日期是明天”规则5定义Final Answer质量防止输出“请稍候”这类无效回答。注意不要在Prompt里写“你是一个乐于助人的AI”这会让模型优先满足“乐于助人”而非“精确执行”。我们测试过删掉所有拟人化描述后Action解析准确率从82%升至96%。4. 实操全流程从本地调试到线上部署的完整链路4.1 本地开发三件套快速验证循环逻辑在敲代码前先搭好本地验证环境避免“写完才发现解析不了”。我们用三个脚本构成最小闭环1. mock_llm.py模拟LLM返回支持预设响应序列# 模拟模型在不同step返回不同内容 MOCK_RESPONSES { 0: Thought: I need to get the users order first.\nAction: get_order\nAction Input: {\uid\: \U123\}, 1: Thought: Order status is shipped, now check return eligibility.\nAction: check_return_eligibility\nAction Input: {\order_id\: \O789\}, 2: Final Answer: 订单O789可退货平台承担运费。 } def mock_chat_completion(messages, **kwargs): step len([m for m in messages if m[role] user]) - 1 return {choices: [{message: {content: MOCK_RESPONSES.get(step, Error: no mock response)}}]}2. test_agent.py用mock_llm跑通全流程from mock_llm import mock_chat_completion from react_agent import ReActAgent # 注册mock函数替换requests.post import requests original_post requests.post def mock_post(*args, **kwargs): return type(obj, (object,), {json: lambda: mock_chat_completion(None, None)})() requests.post mock_post # 运行测试 agent ReActAgent(http://mock, {get_order: lambda x: {order_id:O789}}) print(agent.run(查U123的订单)) # 输出订单O789可退货...3. debug_parser.py专门测试解析器鲁棒性test_cases [ Thought: 我看到订单已发货。\nAction: check_return\nAction Input: {\order_id\: \O789\}, Thought: 等等用户ID可能有误。\nFinal Answer: 请提供正确的用户ID。, Action: get_order\nAction Input: {\uid\: \U123\}\nThought: 下一步... ] for case in test_cases: print(fInput: {case}) print(fParse: {ReActAgent._parse_llm_output(case)}\n)这套组合拳让我们在15分钟内就能验证循环是否能跑通、解析器是否扛得住各种格式、工具注册是否生效。比直接连真实LLM快10倍。4.2 线上部署关键配置不只是“扔上服务器”本地跑通不等于线上可用。我们在线上环境强制实施的五项配置配置项生产环境值为什么必须LLM请求超时connect5s, read15s防止LLM节点假死拖垮整个Agent某次OpenAI API延迟突增至40s未设read超时导致循环卡死Observation长度截断严格限制≤2048字符避免大JSON撑爆模型上下文实测Qwen2-7B在4K上下文中超过2K的Observation会导致Thought失真Action解析重试解析失败时用不同正则再试2次模型偶尔输出Action Name: get_order需兼容循环步数熔断max_steps6非8步数越多累积误差越大。6步覆盖99.3%的业务场景剩余0.7%走人工兜底Final Answer校验必须含中文句号或英文句点防止模型输出Final Answer: Yes这种不完整回答特别提醒Observation截断必须在JSON序列化后进行。错误做法str(tool_result)[:2048]——这会截断在JSON中间导致{order_id:O789,status:shipp非法JSON。正确做法obs_json json.dumps(tool_result, ensure_asciiFalse) if len(obs_json) 2048: # 从后往前找最后一个}确保JSON结构完整 last_brace obs_json.rfind(}) obs_json obs_json[:min(last_brace1, 2048)] observation obs_json4.3 监控与可观测性不看日志等于裸奔Agent循环的每一环都可能出问题必须埋点。我们在每个关键节点加日志Step开始INFO agent_step_start {step: 3, user_input: U123, history_len: 5}LLM调用DEBUG llm_request {prompt_len: 1240, temperature: 0.3}解析结果INFO parse_result {thought: 订单已发货..., action: check_return, input_keys: [order_id]}工具调用INFO tool_call {name: check_return, input_size: 24, duration_ms: 142}Observation摘要DEBUG observation_summary {keys: [eligible, reason], size: 187}最关键的是异常聚合日志当出现parse_failed时自动记录原始LLM输出全文脱敏后方便复现。我们用ELK搭建的看板能实时看到每小时parse_failed率健康值0.5%各工具的平均耗时P952s告警Thought为空的占比5%说明Prompt引导失效。实操心得某次线上故障监控显示parse_failed率从0.1%飙升至12%排查发现是LLM版本升级后输出格式从Action: xxx变成ACTION: xxx全大写。我们立刻在_parse_llm_output里加text.upper().replace(ACTION:, Action:)临时修复同时推动Prompt更新。没有监控这个问题会持续数小时。5. 常见问题与避坑指南那些没人告诉你的“循环暗礁”5.1 典型问题速查表问题现象根本原因解决方案模型反复调用同一工具Thought中未体现“已尝试过”或Observation未包含上次结果在Observation中强制加入last_call_result: {...}字段Thought必须引用此字段Action Input解析为空模型输出Action Input: null或Action Input:后面没内容解析函数增加if not input_text.strip(): return {}, empty input并返回结构化错误Final Answer后仍继续循环模型输出Final Answer: ...但后面还有Thought: ...解析时用re.split(rFinal Answer:, text, maxsplit1)取第一段忽略后续Observation中文乱码工具返回GBK编码json.dumps未指定ensure_asciiFalse统一工具返回UTF-8或在json.dumps中加ensure_asciiFalse循环在Step 1就崩溃用户输入含特殊字符如\n污染messages结构对user_input做user_input.replace(\n, \\n)预处理保持JSON安全5.2 高频避坑技巧来自11个项目踩坑实录坑1别信模型的“我明白了”某金融Agent中模型在Thought写“我明白了将调用risk_assess”但Action却输出Final Answer: 风险低。根源是Prompt里写了“如果信息足够可直接回答”给了模型偷懒机会。解决方案删除所有“可直接回答”类表述强制要求“必须通过工具验证”。坑2Observation不是“展示”而是“证据”新手常把Observation写成“查询成功订单已发货”这等于替模型做了总结。正确做法{order_id:O789,status:shipped,items:[{sku:S1,qty:2}],created_at:2024-05-20T10:30:00Z}。模型需要原始数据做交叉验证比如检查created_at是否超7天。坑3温度系数不是越低越好设temperature0看似严谨但实测发现当工具返回{code:500,msg:internal error}时模型在0温度下会固执地重试同一Action而temperature0.3时它更可能输出Thought: 工具报错应降级到人工审核。建议生产环境用0.2-0.4调试期用0.7看模型发散模式。坑4别在循环里做业务逻辑有开发者在get_order工具里直接调send_sms()通知用户导致Agent循环变成“业务执行器”。铁律Agent只负责决策调什么工具、传什么参工具只负责执行查DB、发HTTP通知类动作必须作为独立工具存在由模型显式调用。坑5Final Answer的“最终”是相对的用户问“订单能退货吗”Final Answer是“可以”但用户接着问“那运费呢”这就不是新循环而是原循环的延续。设计原则只要用户未发起新query就在同一循环内处理用messages历史维持上下文而不是每次清空重来。6. 进阶思考这个循环还能怎么“搓”6.1 循环的变体不是所有场景都要四步ReAct循环是通用解法但业务场景千差万别。我们在不同项目中演化出三种变体两步循环O→A适用于“单次决策”场景如风控审批。Observation是用户资料交易数据Action直接输出{approve: true, reason: 信用分900}。省去Thought因无需解释模型更专注结果。五步循环O→T→A→R→O在RAG场景中增加**Reflection反思**步。Action调用向量库后模型先反思“检索结果是否相关”再决定是否重试或换关键词。这步显著提升冷门问题召回率。并行循环当多个子任务独立如“查订单查物流”用asyncio.gather并发执行Action但Observation仍按顺序合并。注意并发数必须≤工具服务的连接池上限。6.2 循环之外Agent的“肌肉”和“骨骼”手搓循环只是开始。一个健壮的Agent还需“肌肉”——工具生态不是堆砌工具而是构建工具关系图谱。例如check_return_eligibility必须在get_order之后调用这个依赖关系要编码进tools字典的requires字段循环中自动校验。“骨骼”——状态持久化循环中断时如服务器重启需把messages和step存入Redis恢复时续跑。我们用redis.setex(fagent:{session_id}, 3600, json.dumps(state))实现。“神经”——人类反馈闭环在Final Answer后加feedback: [/]按钮用户点时自动把messagesobservationllm_output存入标注队列用于后续微调。6.3 我的个人体会循环教会我的事带完第7个Agent项目后我意识到ReAct循环的本质是把“人类解决问题的隐性思维”显性化、程序化。我们平时查资料也是先观察看到网页标题、思考这个网站可信吗、行动点进去、再观察内容是否相关……Agent循环不是发明新范式而是对人类认知过程的忠实复刻。所以别把它当成技术炫技。每次写Thought:都在训练模型像人一样思考每次校验Action Input都在加固人机协作的信任边界每次处理Observation错误都是在教模型理解世界的不完美。这个循环搓的不是代码是AI与人类共事的新契约。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑