首页>AI>正文

手写一个最小可用AI Agent:从工具注册到ReAct循环

Feng 39 阅读 AI

大模型本身只会吐文本。给它一段输入,它回一段输出,没有记忆,碰不到外部世界,也不会自己决定下一步干什么。所谓 Agent,就是在模型外面套一层调度器:让它拆任务、挑工具、看结果、再决定继续还是收工。这层调度器不神秘,核心循环几十行代码就能写完。

先明确一件事:Agent 不是万能解法。单轮问答、格式转换、固定流程的批处理,直接调模型或写死代码更快更便宜。Agent 的价值出现在任务步数不确定、需要外部数据、且中间结果会影响后续决策的场合。反过来,对延迟敏感(比如在线客服首响要求 500ms 内)或者工具调用有副作用且不可回滚的场景,上自主循环要格外谨慎——模型多绕两步,成本和风险都成倍放大。

拆开看:Agent 到底由哪几块拼成

抛开框架术语,一个能跑的最小系统只有四件事。

规划:把用户目标翻译成“下一步做什么”。最朴素的实现就是让模型输出结构化决策。

记忆:短期靠对话消息列表,长期靠外部存储检索。前者是上下文窗口里的数组,后者才需要向量库。

工具:一组带名字、描述和参数签名的函数。模型只能看到描述,真正执行靠你的代码。

反思:拿到工具结果后判断是否偏离目标。它可以是循环里的下一次模型调用,也可以是独立的一次评估请求。

这四块里,工具注册和循环控制是骨架,规划和反思都是模型调用,记忆是数据结构。下面按“先能跑、再能扛”的顺序搭。

工具层:注册表加统一执行入口

工具的本质是可被模型“点名”的函数。注册表负责按名字查找,执行入口负责兜住异常——工具抛错不能让整个循环崩掉,要把错误变成字符串喂回模型,让它自己决定换路。

from typing import Callable, Dict, Any

class Tool:
 def __init__(self, name: str, description: str, func: Callable):
 self.name = name
 self.description = description
 self.func = func

 def run(self, kwargs) -> str:
 try:
 return str(self.func(kwargs))
 except Exception as e:
 return f"工具执行出错: {e}"

class ToolRegistry:
 def __init__(self):
 self._tools: Dict[str, Tool] = {}

 def register(self, tool: Tool):
 self._tools[tool.name] = tool

 def get(self, name: str):
 return self._tools.get(name)

 def list_tools(self):
 return [{"name": t.name, "description": t.description}
 for t in self._tools.values()]

工具描述要写清“什么时候用”而不只是“能干什么”。模型选错工具,八成是描述含糊。参数尽量扁平,嵌套结构会显著拉高模型输出格式出错的概率。

循环层:一次决策只走一步

ReAct 的骨架是“思考—行动—观察”反复滚动。每次迭代让模型输出一个 JSON 决策,代码解析后要么执行工具、把结果塞回消息列表,要么判定任务完成直接返回。

import json
from openai import OpenAI

class Agent:
 def __init__(self, registry: ToolRegistry):
 self.registry = registry
 self.client = OpenAI(api_key=OPENAI_API_KEY, base_url=OPENAI_BASE_URL)
 self.messages = []

 def _system_prompt(self) -> str:
 tools_desc = "\n".join(
 f"- {t['name']}: {t['description']}"
 for t in self.registry.list_tools()
 )
 return f"""你是一个智能助手,可使用以下工具:
{tools_desc}

严格输出 JSON:
{{"thought": "", "action": "工具名或 none",
 "action_input": {{"参数名": "参数值"}}, "final_answer": "完成时的答案"}}

规则:需要工具时 action 填工具名;任务完成时 action 填 none 并填 final_answer;每次只调一个工具。"""

 def run(self, user_input: str, max_steps: int = 8) -> str:
 self.messages.append({"role": "user", "content": user_input})
 for step in range(max_steps):
 resp = self.client.chat.completions.create(
 model=MODEL_NAME,
 messages=[{"role": "system", "content": self._system_prompt()},
 *self.messages],
 temperature=0,
 )
 raw = resp.choices[0].message.content
 decision = self._parse(raw)
 if decision is None:
 self.messages.append({"role": "user",
 "content": "上一条输出不是合法 JSON,请只输出 JSON,不要加解释。"})
 continue
 if decision.get("action") == "none":
 return decision.get("final_answer", "")
 tool = self.registry.get(decision["action"])
 result = tool.run(**decision.get("action_input", {})) if tool \
 else f"错误: 工具 {decision['action']} 不存在"
 self.messages.append({"role": "assistant",
 "content": json.dumps(decision, ensure_ascii=False)})
 self.messages.append({"role": "user", "content": f"工具执行结果: {result}"})
 return "已达到最大步数,任务未完成"

max_steps 是硬刹车,没有它,模型在工具反复报错时会无限重试,账单和延迟一起失控。

模型输出不可信:解析失败才是常态

上面代码里 _parse 不是随手 json.loads。工程上必须假设模型会输出带 Markdown 围栏的 JSON、前后带解释文字、甚至被截断。三个真实坑:

第一,message.content 可能为 None。模型触发拒答(refusal)时内容为空,直接 json.loads(None) 抛 TypeError。判断 if raw is None 后读 resp.choices[0].message.refusal 或直接重试。

第二,finish_reason == "length" 说明输出被 max_tokens 截断,JSON 必然不完整。此时不要解析,直接提高上限或缩短提示词后重发。

第三,围栏污染。安全做法是先剥掉 json 和 包裹,再定位第一个 { 到最后一个 } 的子串解析,失败则把错误信息回灌给模型重试一次。

def _parse(self, raw):
 if not raw:
 return None
 text = raw.strip()
 if text.startswith("```"):
 text = text.split("\n", 1)[-1].rsplit("```", 1)[0]
 start, end = text.find("{"), text.rfind("}")
 if start == -1 or end == -1:
 return None
 try:
 return json.loads(text[start:end+1])
 except json.JSONDecodeError:
 return None

想彻底绕开解析问题,就用模型原生的 Function Calling(tools 参数),让接口层保证结构化输出。代价是绑定具体厂商的接口形态,可移植性下降。原型阶段手写 JSON 更透明,生产环境优先原生工具调用。

工具安全:计算器和数据库都得设防

原文的计算器用 eval 配空 __builtins__,这不够。().__class__.__bases__ 这类属性链仍可能逃逸。更稳的做法是用 ast.parse 白名单校验:只允许 Expression、BinOp、UnaryOp、Constant 和四则运算节点,遇到 Call、Attribute 直接拒绝。

数据库工具的风险更直接。让模型自由拼 SQL,一句 DROP TABLE 就能清库。两层防护:

conn = sqlite3.connect("sales.db")
conn.execute("PRAGMA query_only = ON;")

query_only 让连接只读,写操作直接报错。再叠加 authorizer 回调,只放行 SQLITE_SELECT、SQLITE_READ 等读动作,其余一律拒绝。这样即使模型被提示注入诱导,也改不动数据。

工具输入还要做类型与范围校验。参数是城市名就限制长度和字符集,是数字就检查上下界。校验失败返回结构化错误而不是抛异常,模型看到“参数非法:city 不能为空”比看到堆栈更容易自我纠正。

记忆与反思:别急着上向量库

短期记忆就是 self.messages。上下文一长,两个问题冒出来:token 超限和关键信息被稀释。常见做法是保留最近 N 轮原文,更早的压缩成摘要,摘要本身也占 token,要算进预算。

长期记忆才需要向量检索。余弦相似度那段实现只适合理解原理,真上生产要考虑 embedding 模型版本一致性、维度对齐、以及检索结果的相关性阈值——相似度 0.6 的结果未必有用,无脑 top_k 会把噪声塞进上下文。

反思机制不必单独造一个方法。把“上一步结果是否推进了目标”写进系统提示词的规则里,让模型在每次决策的 thought 字段自评,成本最低。独立反思调用适合长链路任务,但每多一次调用就多一份延迟和费用,权衡后再加。

什么时候该换框架

手写循环的价值在于每一步都看得见,调试时能精确定位是提示词问题、解析问题还是工具问题。等这套跑通、边界摸清,再考虑 LangChain、LlamaIndex 这类框架。它们省的是组件拼装和生态集成,不是替你解决提示词设计和错误处理——那些问题换框架照样存在,只是藏得更深。

多 Agent 协作(AutoGen 那类)适合角色分工明确的场景,比如一个查数据、一个写报告、一个审校。但每多一个 Agent 就多一层消息传递和状态同步,调试复杂度非线性上升。单 Agent 加多工具能解决的事,别急着拆成多角色。

收尾

Agent 的骨架是循环,血肉是工具,神经是提示词。先把 max_steps 设好、把工具异常兜住、把模型输出当不可信输入处理,这三件事做完,系统才算能跑。之后再谈记忆增强、并行工具调用、反思优化。顺序反了,容易在框架的抽象层里迷路,连一次工具调用为什么失败都查不出来。

ID:6651

Feng
这位作者很神秘,还没有填写简介。