从零手写生产级 Agent:ReAct 循环、工具注册与容错设计
框架用多了容易产生一种错觉:Agent 就是"调一个库"。但真正遇到框架解决不了的场景——比如需要深度定制工具协议、严格审计每一步动作、或者目标环境过于受限时——你就必须理解 Agent 内部到底在发生什么。这篇文章不推荐你生产环境手写 Agent,而是通过从零实现一个带工具调用的 ReAct Agent,把"模型决策、工具执行、循环终止、容错恢复"这些核心机制彻底讲透。读懂这些,你再用任何框架都会觉得是在看自己的代码。
一、ReAct 循环的本质:思考、行动、观察
ReAct(Reason + Act)是目前绝大多数 Agent 的基础范式。它把问题求解过程拆成三步循环:
- Thought(思考):模型分析当前状态,决定下一步做什么。
- Action(行动):模型输出一个工具调用意图。
- Observation(观察):程序执行工具,把结果回传给模型。
循环往复,直到模型认为任务完成。这个循环的每个环节,都需要工程化处理。
- Observation(观察):程序执行工具,把结果回传给模型。
# 核心循环骨架defagent_loop(query,tools,llm,max_steps=10):messages=[{"role":"user","content":query}]forstepinrange(max_steps):response=llm.chat(messages)action=parse_action(response)# 解析模型输出的动作ifactionisNone:returnresponse# 模型认为完成,直接返回result=execute_tool(action,tools)# 执行工具messages.append({"role":"assistant","content":response})messages.append({"role":"tool","content":result})return"达到最大步数,任务未完成"```## 二、工具注册:让模型知道"你能做什么"Agent 的工具系统由两部分组成:**工具的元信息**(让模型知道有什么工具、怎么用)和**工具的执行器**(程序真正干活的部分)。 ```python TOOLS={}defregister_tool(name,description,params_schema):defdecorator(func):TOOLS[name]={"func":func,"description":description,"params_schema":params_schema,}returnfuncreturndecorator@register_tool(name="get_weather",description="查询指定城市的当前天气,城市用中文名称。",params_schema={"city":"string, 必填,城市名"},)defget_weather(city:str):# 实际项目里这里调用天气 APIreturnf"{city}今天晴,25 度"@register_tool(name="calculator",description="执行四则运算,表达式如 '3 + 4 * 2'。",params_schema={"expr":"string, 必填,数学表达式"},)defcalculator(expr:str):returnstr(eval(expr))# 生产环境严禁 eval,仅作演示``` 模型怎么知道这些工具存在?靠的是**工具描述注入**。把每个工具的 name、description、params 拼进系统提示词,让模型"看到"工具箱: ```pythondefbuild_tool_prompt():lines=["你可以使用以下工具:",""]forname,metainTOOLS.items():lines.append(f"-{name}:{meta['description']}参数:{meta['params_schema']}")lines.append(""" 使用工具时,输出格式为: ACTION: 工具名 PARAMS: JSON 参数 任务完成或无法解决时,输出: DONE: 最终答案 """)return"\n".join(lines)``` 这里有两个工程细节:一是工具描述要写清楚**适用场景和参数格式**,描述越模糊,模型越容易调用错;二是要明确输出协议——模型用固定格式声明它要调用的工具,程序端解析这个格式并执行,双方靠"协议"沟通而不是靠猜。## 三、动作解析与执行:协议的两端模型的输出是自由文本,必须解析成结构化的工具调用。生产级实现应该让模型直接输出 JSON,用 `response_format` 强制约束,再用try-except兜底: ```pythonimportjsondefparse_action(response):text=response.strip()iftext.startswith("DONE"):returnNone# 任务完成iftext.startswith("ACTION:"):lines=text.split("\n")tool_name=lines[0].replace("ACTION:","").strip()params_raw=text.split("PARAMS:",1)[1].strip()if"PARAMS:"intextelse""try:params=json.loads(params_raw)exceptjson.JSONDecodeError:params=# 解析失败时降级为空参数return{"tool":tool_name,"params":params}returnNone``` 执行环节必须有完整的错误处理链: ```pythondefexecute_tool(action,tools,max_tool_retries=1):tool_name,params=action["tool"],action["params"]iftool_namenotintools:returnf"错误:工具{tool_name}不存在,可选工具:{list(tools.keys())}"func=tools[tool_name]["func"]try:returnstr(func(**params))exceptTypeErrorase:returnf"错误:参数不匹配{e},请参考参数规范重新调用"exceptExceptionase:returnf"错误:工具执行失败{e}"``` 注意,**工具执行失败的信息要原样回传给模型**,让模型根据错误信息自行修正。比如参数格式错了,模型看到错误描述后会重新组织参数再次调用。这个"失败反馈-自我修正"机制,是 Agent 比固定脚本强的地方。## 四、Function Calling:更可靠的替代协议上面手写的 ACTION/PARAMS 协议教学意义完整,但生产环境有更可靠的方案:让模型原生支持 function calling。OpenAI 兼容接口提供 `tools` 参数,模型会在响应里返回结构化的 `tool_calls`,省去文本解析这一步——解析错误和格式漂移都消失了。 ```pythondefagent_with_function_calling(query,max_steps=10):messages=[{"role":"user","content":query}]tools_spec=[{"type":"function","function":{"name":name,"description":meta["description"],"parameters":{"type":"object","properties":{"expr":{"type":"string"}}}}}forname,metainTOOLS.items()]forstepinrange(max_steps):resp=client.chat.completions.create(model="gpt-4o-mini",messages=messages,tools=tools_spec,)msg=resp.choices[0].messageifnotmsg.tool_calls:returnmsg.content# 没有工具调用,说明任务完成messages.append(msg)fortcinmsg.tool_calls:result=execute_tool({"tool":tc.function.name,"params":json.loads(tc.function.arguments)},TOOLS)messages.append({"role":"tool","tool_call_id":tc.id,"content":result})return"达到最大步数"``` 从手写协议到 function calling,是 Agent 工程里一次重要的"协议升级":手写协议让你理解原理,function calling 让你上生产。两者并非二选一——理解前者能帮你诊断后者的问题(比如模型不触发工具调用时,往往是工具描述写得太含糊)。## 五、容错与安全:Agent 的底线设计生产级 Agent 和教学 Demo 的分水岭,全在容错与安全设计上。以下几条是必须的:**第一,最大步数限制。**循环必须有硬上限(通常8~15步),防止模型陷入无限循环。每步之间记录耗时,超过阈值强制终止。**第二,工具白名单与权限隔离。**生产环境绝不把任意代码执行暴露给模型。文件读写、网络请求、数据库操作等敏感工具必须走权限校验,最好在独立沙箱进程里执行。**第三,敏感操作的人工审批。**对不可逆操作(删除、支付、对外发送)插入确认环节——执行前暂停,把动作详情展示给人工,批准后才真正执行。这也是现在各大框架都在强调"人在回路"的原因。**第四,幂等与重试。**工具调用可能重复执行(模型超时重试导致同一步跑两遍),写工具时要保证幂等性——同样的参数执行两次结果一致,不产生副作用。**第五,可观测性。**每一步的 Thought、Action、Observation 都要落 trace。线上出问题时,能看到完整的"推理轨迹"而不是一个孤立报错。建议记录:step 序号、模型输入输出、工具名称与参数、执行耗时、返回结果摘要。## 六、评测一个 Agent:光看结果远远不够最后补一个关键认知:Agent 系统的评测比普通 LLM 应用难一个量级,因为过程复杂、路径众多。推荐从三个层次建立评测:1.**结果正确性**:最终答案是否达到目标(需要人工或 LLM-as-Judge 标注)。2.2.**工具调用正确性**:每一步是否调用了正确工具、传了正确参数——这能定位"是模型决策错了还是工具实现错了"。3.3.**路径合理性**:是否走了冗余步骤、是否反复横跳、是否过早放弃。这类过程指标暴露的问题,往往比结果指标更早。 ```pythondefevaluate_tool_usage(traces,expected_tools):"""检查 Agent 执行轨迹里是否按预期顺序调用了工具"""used=[t["tool"]fortintraces]correct=all(einusedforeinexpected_tools)returncorrect,used ```## 七、小结手写一遍 Agent 的价值不在于"我有自己的 Agent",而在于你彻底理解了那条循环里每一环的职责:模型负责决策,协议负责沟通,工具负责执行,循环负责推进,容错负责兜底。有了这套心智模型,你再去用 LangGraph、AutoGen 或任何新框架,看到的就不再是黑盒 API,而是一个你亲手搭过一遍的系统的封装。请记住:框架可以帮你省掉脚手架,但省不掉的是——你对"循环之外"那些工程问题的理解。