公司动态
智能体面试准备(十二):从零写一个 ReAct Agent——不依赖框架的完整可运行实现
智能体面试准备十二从零写一个 ReAct Agent——不依赖框架的完整可运行实现系列写到第十二篇该交作业了。前面讲了 ReAct 范式B2、工具调用B3、状态机编排B9、评估体系B11今天把这些线头收拢从零写一个不依赖任何 Agent 框架、纯 Python 标准库、可以直接运行调试的 ReAct Agent。为什么面试要准备这个因为手写一个简单 Agent正在成为智能体岗位的新型手撕题。框架用得再熟create_react_agent()一行调用证明不了理解深度能白板写出提示词模板→模型输出解析→工具执行→结果回填这个闭环的人才真正把 ReAct 焊进了脑子。更实际的是自己写过一遍之后LangChain/LangGraph 里那些魔法般的抽象会突然变得透明。一、动手前想清楚:ReAct 闭环的五个组件ReAct 的本质一句话让模型交替产生推理Thought和动作Action动作的观察结果Observation回填进上下文循环往复直到模型认为可以给出最终答案Final Answer。拆成工程组件就是五块组件职责关键设计决策提示词模板告诉模型输出格式与可用工具格式约束越明确解析越稳工具注册表管理工具的名称、描述、参数、执行函数描述是写给模型看的质量决定选对率输出解析器从模型自由文本中抽出 Action/Final Answer必须容错格式漂移是最高频故障执行引擎调工具、捕获异常、把结果回填工具报错要回喂给模型而不是直接崩溃循环控制最大步数、重复动作检测、超时没有护栏的循环生产上必出事故这张表本身就是答题框架面试官问设计一个 Agent 要考虑什么按五个组件逐个展开即可。二、完整实现:150 行可运行代码下面的实现有两个刻意设计其一FakeLLM用规则模拟模型行为让整个 Agent无需 API Key 就能跑通你可以先在纯离线环境理解机制再把FakeLLM换成真实的 OpenAI 兼容接口其二所有护栏最大步数、重复动作、解析失败处理都写全了这些正是面试官区分玩具和工程的检查点。从零实现 ReAct Agent纯标准库无需 API Key 即可运行 import re, json, math # 1. 工具注册表 class ToolRegistry: def __init__(self): self._tools {} def register(self, name, description): def deco(fn): self._tools[name] {desc: description, fn: fn} return fn return deco def spec_text(self): return \n.join(f- {n}: {t[desc]} for n, t in self._tools.items()) def run(self, name, arg): if name not in self._tools: return f错误工具 {name} 不存在可用工具{list(self._tools)} try: return str(self._tools[name][fn](arg)) except Exception as e: # 工具异常回喂模型而非崩溃 return f工具执行出错{e} registry ToolRegistry() registry.register(calculator, 计算数学表达式输入如 23*4712) def calculator(expr): if not re.fullmatch(r[0-9\-*/().\s], expr): raise ValueError(仅支持数字与四则运算) return eval(expr) # 已用白名单正则约束演示可用 registry.register(knowledge_base, 查询知识库输入为关键词) def knowledge_base(query): kb {珠穆朗玛峰: 珠穆朗玛峰海拔 8848.86 米, 马里亚纳海沟: 马里亚纳海沟最深处约 10909 米} for k, v in kb.items(): if k in query: return v return 知识库中未找到相关条目 # 2. 提示词模板 PROMPT 尽你所能回答问题。你可以使用以下工具 {tools} 严格使用以下格式 Question: 需要回答的问题 Thought: 你的思考 Action: 工具名必须是 [{names}] 之一 Action Input: 工具输入 Observation: 工具返回结果 ...Thought/Action/Action Input/Observation 可重复多轮 Thought: 我已知道最终答案 Final Answer: 对原问题的最终回答 开始 Question: {question} {scratchpad} # 3. 输出解析器容错 def parse(text): if Final Answer: in text: return finish, text.split(Final Answer:)[-1].strip() m re.search(rAction:\s*(\w)\s*[\n\r]Action Input:\s*(.), text) if m: return action, (m.group(1).strip(), m.group(2).strip().strip()) return error, 无法解析输出请严格按 Action/Action Input 格式 # 4. 模拟 LLM可换成真实 API 调用 class FakeLLM: 按 scratchpad 状态决定下一步模拟真实模型的 ReAct 行为 def invoke(self, prompt): if Observation: 珠穆朗玛峰海拔 not in prompt: return (Thought: 我需要先查珠峰海拔\n Action: knowledge_base\nAction Input: 珠穆朗玛峰) if Observation: 5 not in prompt: return (Thought: 海拔 8848.86 米换算成千米需要除以 1000 再保留两位\nAction: calculator\nAction Input: 8848.86/1000) return (Thought: 我已知道最终答案\n Final Answer: 珠穆朗玛峰海拔约 8848.86 米即约 8.85 千米) # 5. 执行引擎与循环控制 class ReActAgent: def __init__(self, llm, registry, max_steps6): self.llm, self.registry, self.max_steps llm, registry, max_steps def run(self, question, verboseTrue): scratchpad, seen_actions , set() for step in range(1, self.max_steps 1): prompt PROMPT.format(toolsself.registry.spec_text(), names, .join(self.registry._tools), questionquestion, scratchpadscratchpad) output self.llm.invoke(prompt) kind, payload parse(output) if verbose: print(f--- Step {step} ---\n{output}\n) if kind finish: return {answer: payload, steps: step, status: ok} if kind error: # 解析失败回喂纠错 scratchpad f\nObservation: {payload}\n continue tool, arg payload if (tool, arg) in seen_actions: # 重复动作护栏 scratchpad \nObservation: 检测到重复动作请换一种思路\n continue seen_actions.add((tool, arg)) obs self.registry.run(tool, arg) scratchpad f\n{output}\nObservation: {obs}\n return {answer: None, steps: self.max_steps, status: max_steps} # 运行 if __name__ __main__: agent ReActAgent(FakeLLM(), registry) result agent.run(珠穆朗玛峰海拔多少米换算成千米是多少) print(最终结果:, json.dumps(result, ensure_asciiFalse))直接python react_agent.py就能看到完整的 Thought→Action→Observation 三轮循环与最终答案。接真实模型时只需把FakeLLM.invoke换成对 OpenAI 兼容接口的调用stop[Observation:]参数别忘了——防止模型自己编造 Observation这是接真实 API 时最经典的坑。三、代码讲解:六个面试检查点写完不算完要能讲。以下六个点对应面试官大概率的追问为什么 scratchpad 是字符串拼接而不是消息列表原始 ReAct 论文基于 completion 模式把历史拼进 prompt。改成 chat 模式的消息列表tool role 回填就演化成了 Function Calling 形态——两者机制等价Function Calling 由 API 层强制格式解析更稳B3 详述。解析失败为什么不重试而是回喂把错误信息作为 Observation 回喂让模型自我纠正比机械重试更有效——模型看到格式错了通常下一轮就改对。这是 self-correction 的最小实现。重复动作检测为什么用 (tool, arg) 二元组只记 tool 会误伤同一工具不同参数的合法调用记完整二元组精确命中真死循环。B9 讲过的防死循环三层护栏步数/重复/预算这里落了两层。工具异常为什么返回字符串而不抛出Agent 的鲁棒性原则环境的任何反馈都是信息。参数错误回喂给模型它往往能自己修正参数重试直接崩溃则整条任务报废。eval 的安全问题演示里用白名单正则约束了字符集生产必须换ast.literal_eval或专用表达式引擎并放进沙箱——这里主动提一句 Agent 安全B16 会展开显得有安全意识。这个 Agent 怎么测直接套上一篇B11的评估器固定任务集 参考轨迹统计成功率、轨迹匹配度、工具选择准确率。run()返回的结构化结果就是为评估预留的接口。四、从 150 行到生产还差什么差距项本文实现生产要求模型输出FakeLLM 规则模拟真实 API 流式 重试退避状态管理内存字符串持久化断点续跑、审计并发单任务同步异步并发 队列可观测printtracing 埋点langfuse 等安全白名单正则沙箱执行 权限最小化评估无评估集 CI 回归这张表也是极好的答题素材写一个 demo Agent 和上生产差在哪——按行展开即可且每一行都能挂回本系列对应篇目。收尾建议把这份代码放进自己的 GitHubREADME 里写清设计决策与护栏思路面试时直接投屏讲。能讲清为什么这么写的 150 行比能跑起来的 1500 行更有说服力。下一篇 B13 继续实战给 Agent 加上可持久化的记忆系统把 B6 的记忆理论落成完整可运行的工程实现。