1. 从“大而全”到“小而美”:为什么我们需要一个50行的Agent
最近和几个做AI应用的朋友聊天,发现一个挺有意思的现象:大家一提到要搞个AI Agent,第一反应就是去翻LangChain、AutoGen或者CrewAI的文档。这些框架确实强大,功能齐全,社区活跃,文档也写得不错。但问题也随之而来——为了一个简单的、验证性的想法,我们往往需要先花半天时间理解框架的抽象概念,再花半天时间配置环境、处理依赖冲突,最后写出来的代码里,属于自己核心逻辑的部分可能还不到20%。更头疼的是,当你想调整一下Agent的思考流程,或者想看看某个中间状态到底发生了什么时,你发现自己被困在了框架预设的“黑盒”里,调试起来异常痛苦。
这让我想起了早期学编程时,老师总说“不要一上来就用框架,先理解底层原理”。这句话放在Agent开发上同样适用。一个50行代码的Agent,其价值不在于它功能有多强大,而在于它足够“透明”和“可控”。它剥离了所有非必要的抽象层,让你能清晰地看到从用户输入,到LLM思考,再到工具调用和最终输出的完整链路。这对于理解Agent的核心工作模式——尤其是经典的ReAct(Reasoning + Acting)范式——至关重要。
通过亲手实现一个微型Agent,你能彻底搞明白几个关键问题:LLM的提示词(Prompt)是如何引导其进行链式思考的?工具(Tools)的接口应该如何设计,才能让LLM理解和调用?Agent的内部状态(State)该如何管理和传递?这些认知,是直接使用成熟框架时很难深刻体会到的。当你掌握了这些“元知识”,再回头去用那些大框架,你会更加得心应手,知道它们每个组件在解决什么问题,甚至能在框架不满足需求时,自己动手进行定制或优化。
所以,这篇内容的目标很明确:我们不依赖任何第三方Agent框架,仅用Python标准库和OpenAI API(或其他你喜欢的LLM API),用大约50行代码,构建一个具备基础ReAct推理能力的微型Agent。这个过程,更像是一次“外科手术式”的解剖,让我们看清Agent的“五脏六腑”。
2. 核心蓝图:ReAct范式的极简实现
在开始写代码之前,我们必须先统一思想,明确我们要构建的Agent究竟遵循什么样的工作流程。这里我们选择实现ReAct范式,这是目前最经典、也最易于理解的Agent架构之一。
ReAct的核心思想是让LLM在“思考”和“行动”之间循环。具体来说,它包含以下几个步骤:
- 观察(Observation):Agent接收来自用户的问题(Question)和来自外部环境或工具的反馈(Observation)。
- 思考(Reasoning):LLM基于当前的观察,进行内部推理,分析现状,并计划下一步该做什么。这一步的输出是“思考轨迹”(Thought)。
- 行动(Acting):根据上一步的思考,LLM决定是调用某个工具(Action)来获取新信息,还是已经得出最终答案可以结束任务(Final Answer)。
- 循环:如果决定调用工具,则执行工具,将工具返回的结果作为新的“观察”,送入下一轮循环。
这个循环会一直持续,直到LLM认为它已经掌握了足够的信息,可以给出最终答案为止。整个过程,LLM的“思考”和“行动”都被记录并暴露出来,形成了可解释的推理链。
那么,在一个极简实现中,我们需要哪些核心组件呢?
- 一个LLM客户端:用于发送提示词和接收回复。我们将使用
openai库,但设计上会保持接口通用性。 - 一套工具(Tools):赋予Agent行动能力。我们将实现两个最基础的工具:一个网络搜索(模拟)和一个计算器。
- 一个提示词(Prompt)模板:这是Agent的“大脑”和“操作规程”,它必须清晰地定义ReAct的格式、可用工具以及输出规范。
- 一个主循环(Loop):负责管理对话状态,拼接提示词,调用LLM,解析输出,并执行工具调用。
我们的代码结构将围绕这四个部分展开。为了让逻辑更清晰,我们会先定义工具和提示词模板,再实现主循环。整个代码将保持在一个Python文件中,无需任何额外的目录结构。
3. 工具定义:赋予Agent“手”和“脚”
工具是Agent与外部世界交互的桥梁。在框架中,工具通常被抽象成带有复杂描述和验证逻辑的类。在我们的极简版本里,我们只关注最本质的东西:一个工具就是一个可以被调用的函数,以及一段能让LLM理解它用途的描述。
我们先来实现两个工具:
工具一:模拟搜索(search)在真实场景中,这可能需要调用Serper API、Google Search API等。为了简化且避免网络依赖,我们实现一个“模拟”搜索函数,它根据查询关键词返回一段预设的文本。这足以演示工具调用的完整流程。
import json def search(query: str) -> str: """ 模拟网络搜索工具。 根据查询词返回一段预设的文本信息。 """ # 一个简单的模拟数据库 knowledge_base = { "上海天气": "上海今天晴转多云,气温15-22摄氏度,东南风3-4级。", "Python创始人": "Python语言的创始人是吉多·范罗苏姆(Guido van Rossum)。", " OpenAI": "OpenAI是一家人工智能研究公司,推出了GPT系列模型。", "1+1": "这是一个基本的数学运算问题。" } # 简单匹配,实际应用应使用更复杂的匹配或真实API for key in knowledge_base: if key in query: return knowledge_base[key] return f"未找到与 '{query}' 直接相关的信息。"工具二:计算器(calculator)这个工具直接使用Python的eval函数来执行数学表达式。请注意:在生产环境中,直接使用eval是极度危险的,因为它会执行任意代码。这里仅用于演示,真实场景中必须使用安全的数学表达式解析库(如ast.literal_eval配合自定义解析器)。
def calculator(expression: str) -> str: """ 计算数学表达式。 警告:此实现使用eval,仅用于演示,存在安全风险。 """ try: # 极度危险!仅用于演示。 result = eval(expression) return str(result) except Exception as e: return f"计算错误:{e}"有了工具函数,我们还需要一份“工具说明书”,让LLM知道它有哪些工具可用,以及每个工具怎么用。我们用字典来定义:
# 工具定义表 TOOLS = { "search": { "function": search, "description": "当你需要获取实时或事实性信息(如天气、新闻、概念解释)时使用此工具。输入应为搜索查询字符串。" }, "calculator": { "function": calculator, "description": "当需要进行数学计算时使用此工具。输入应为合法的数学表达式字符串,例如 '3 * (4 + 5)'。" } } # 生成给LLM看的工具描述文本 def get_tools_description(): descriptions = [] for name, info in TOOLS.items(): descriptions.append(f"{name}: {info['description']}") return "\n".join(descriptions)这样,get_tools_description()函数就能生成一段清晰的文本,告诉LLM:“你现在有两个工具,一个叫search,用来查资料;一个叫calculator,用来算数。”
4. 大脑的指令:精心设计提示词模板
提示词是Agent的灵魂,它直接决定了LLM的行为模式。一个糟糕的提示词会让聪明的模型表现得像个傻瓜。我们的提示词需要完成以下几件事:
- 设定角色:告诉LLM它现在是一个Agent。
- 交代任务:明确它的目标是回答问题。
- 说明规则:严格规定它必须按照“Thought: ... Action: ... Observation: ...”的格式进行输出。
- 提供工具:列出所有可用工具及其用法。
- 定义终止条件:告诉它什么情况下应该输出“Final Answer:”。
下面是我们精心设计的提示词模板。注意,我们使用了三重引号(""")来定义多行字符串,并使用花括号{}作为占位符,用于在运行时插入变量(如工具描述、对话历史等)。
# 系统提示词模板 SYSTEM_PROMPT_TEMPLATE = """ 你是一个智能助手,必须严格按照以下格式进行回应,以完成任务。 你可以使用以下工具: {tools_descriptions} 你必须遵循的格式: Thought: 这里是你对当前情况的分析和下一步计划。 Action: 你要调用的工具名称,必须是以下之一:[{tool_names}]。如果你认为已经可以回答问题,则 Action 为 “Final Answer”。 Action Input: 调用工具时需要的输入内容,必须是一个字符串。如果 Action 是 “Final Answer”,则此项为空。 Observation: 工具返回的结果。如果 Action 是 “Final Answer”,则此项为空。 ...(这个 Thought/Action/Action Input/Observation 循环可以重复多次) 最终,当你拥有足够信息时,你必须输出: Thought: 我已经得到所有需要的信息。 Action: Final Answer Action Input: Observation: Final Answer: 这里是你对用户问题的最终答案。 现在开始。所有对话历史如下: {history} 当前问题:{question} """这个模板有几个关键设计点:
- 工具列表动态化:
{tools_descriptions}和{tool_names}会在程序运行时被替换,这样我们增减工具时,只需修改TOOLS字典,提示词会自动更新。 - 严格的格式约束:明确要求LLM以“Thought:”、“Action:”等为前缀输出。这便于我们后续用程序进行解析(Parsing)。LLM(特别是GPT-4)对这种结构化格式的遵循能力很强。
- 包含对话历史:
{history}占位符将包含之前所有轮次的Thought/Action/Observation记录,这为Agent提供了完整的上下文,使其能进行多轮复杂推理。 - 清晰的终止信号:明确给出了输出最终答案的格式范例,减少了LLM的困惑。
提示:在实际使用中,你可能会发现LLM偶尔不按格式输出。除了优化提示词,更健壮的做法是在代码中加入输出格式的校验和修复逻辑,例如使用正则表达式进行匹配,或在提示词中提供更详细的示例(Few-Shot Prompting)。为了保持代码简洁,本文暂不展开。
5. 主循环实现:串联一切的引擎
现在,我们有了工具,有了“大脑指令”(提示词),最后需要实现一个驱动整个流程的引擎——主循环。这个循环将负责:
- 维护对话历史。
- 根据历史和当前问题,组装完整的提示词。
- 调用LLM获取回复。
- 解析LLM的回复,判断是调用工具还是给出最终答案。
- 如果调用工具,则执行工具函数,并将结果作为“Observation”加入历史,进入下一轮循环。
- 如果给出最终答案,则结束循环,返回答案。
以下是主循环的核心代码。我们假设你已经设置了OpenAI的API密钥(环境变量OPENAI_API_KEY)。
import os import re from openai import OpenAI # 初始化OpenAI客户端 client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) def run_agent(question: str, max_turns: int = 5) -> str: """ 运行Agent主循环。 :param question: 用户问题 :param max_turns: 最大循环轮次,防止无限循环 :return: 最终答案字符串 """ history = [] # 用于存储每一轮的 Thought, Action, Action Input, Observation tools_descriptions = get_tools_description() tool_names = ", ".join(TOOLS.keys()) for turn in range(max_turns): # 1. 构建当前轮次的完整提示词 history_text = "\n".join(history) if history else "无" prompt = SYSTEM_PROMPT_TEMPLATE.format( tools_descriptions=tools_descriptions, tool_names=tool_names, history=history_text, question=question ) # 2. 调用LLM try: response = client.chat.completions.create( model="gpt-3.5-turbo", # 也可使用 gpt-4 messages=[{"role": "user", "content": prompt}], temperature=0.1, # 低温度使输出更稳定、更遵循格式 max_tokens=500 ) llm_output = response.choices[0].message.content.strip() except Exception as e: return f"调用LLM API时出错:{e}" # 3. 解析LLM的输出(这是关键且容易出错的一步) # 使用正则表达式匹配关键字段 thought_match = re.search(r'Thought:\s*(.*?)(?=\nAction:|$)', llm_output, re.DOTALL) action_match = re.search(r'Action:\s*(.*?)(?=\nAction Input:|$)', llm_output, re.DOTALL) action_input_match = re.search(r'Action Input:\s*(.*?)(?=\nObservation:|$)', llm_output, re.DOTALL) final_answer_match = re.search(r'Final Answer:\s*(.*)', llm_output, re.DOTALL) thought = thought_match.group(1).strip() if thought_match else "" action = action_match.group(1).strip() if action_match else "" action_input = action_input_match.group(1).strip() if action_input_match else "" # 4. 将本轮LLM的“输出”记录到历史中 current_step = f"Thought: {thought}\nAction: {action}\nAction Input: {action_input}" history.append(current_step) # 5. 判断并执行动作 if action == "Final Answer": # 找到最终答案,结束循环 final_answer = final_answer_match.group(1).strip() if final_answer_match else "未找到明确最终答案。" # 将最终答案也加入历史,便于查看完整流程 history.append(f"Observation: \nFinal Answer: {final_answer}") print("\n=== 完整推理链 ===") print("\n".join(history)) return final_answer elif action in TOOLS: # 执行工具调用 try: tool_function = TOOLS[action]["function"] observation = tool_function(action_input) except Exception as e: observation = f"调用工具 {action} 时出错:{e}" # 将观察结果记录到历史,作为下一轮LLM的输入 history.append(f"Observation: {observation}") print(f"Turn {turn+1}: {action}({action_input}) -> {observation[:50]}...") # 打印进度 else: # LLM输出的Action不在工具列表中,可能是格式错误或未知工具 error_msg = f"LLM返回了未知或格式错误的Action: '{action}'. 本轮输出:{llm_output[:200]}" history.append(f"Observation: {error_msg}") # 可以选择直接退出或继续,这里我们选择返回错误 return f"Agent执行过程中出现错误:{error_msg}" # 如果循环结束仍未得到最终答案 return f"经过 {max_turns} 轮推理仍未得出最终答案。当前历史:\n" + "\n".join(history) # 示例:运行Agent if __name__ == "__main__": user_question = "上海今天的天气怎么样?如果气温是22摄氏度,相当于多少华氏度?" answer = run_agent(user_question) print(f"\n用户问题:{user_question}") print(f"Agent最终答案:{answer}")代码逐段解析与避坑指南:
历史(
history)的管理:我们用一个简单的字符串列表来存储每一轮的完整输出。每一轮结束后,我们将Thought、Action和Action Input拼接成一个字符串加入历史。当工具返回结果后,再将Observation加入历史。这样,在构建下一轮的提示词时,history_text就包含了之前所有的推理步骤,为LLM提供了完整的上下文。这是实现多步推理的关键。提示词组装:在每一轮循环开始时,我们都用当前的
history、question以及工具描述来填充提示词模板。这意味着LLM每次看到的都是完整的对话进程。LLM调用参数:这里使用了
temperature=0.1。较低的temperature值会使LLM的输出更加确定性和一致性,这对于需要严格遵循格式的Agent任务非常重要,可以减少输出格式的随机性错误。max_tokens限制了单次回复的长度,防止输出过长。输出解析(Parsing):这是整个循环中最脆弱但也最关键的一环。我们使用了正则表达式(
re.search)来从LLM的文本回复中提取Thought、Action等字段。- 为什么用正则表达式?因为它简单、直接,且在我们的严格格式要求下足够有效。更健壮的方案是要求LLM输出JSON格式,然后直接解析JSON,或者使用LangChain等框架提供的
OutputParser组件。 - 正则表达式的风险:如果LLM的输出格式稍有偏差(比如多了一个空格,换行符不一致),正则表达式就可能匹配失败。我们的模式
r'Thought:\s*(.*?)(?=\nAction:|$)'使用了非贪婪匹配(.*?)和前瞻断言(?=\nAction:|$),旨在匹配从“Thought:”开始,到下一个“Action:”或字符串结尾为止的内容,这在一定程度上提高了容错性。 - 必须添加错误处理:代码中对每个字段的匹配结果都进行了判断(
if match),如果匹配失败,字段会被设为空字符串,并在后续逻辑中可能导致错误。在生产环境中,这里需要更完善的错误处理和重试机制。
- 为什么用正则表达式?因为它简单、直接,且在我们的严格格式要求下足够有效。更健壮的方案是要求LLM输出JSON格式,然后直接解析JSON,或者使用LangChain等框架提供的
工具调用与状态更新:如果解析出的
action是Final Answer,则提取最终答案并结束循环。如果action是一个已知工具名,则从TOOLS字典中取得对应的函数并执行,将结果存入observation,然后将其加入历史。如果action既不是Final Answer也不是已知工具,则说明LLM输出不符合预期,程序会记录错误并退出。循环终止条件:我们设置了
max_turns(默认为5)来防止Agent陷入无限循环。对于一些复杂问题,可能需要更多轮次,但这个安全阀是必要的。
运行上面的示例代码,你会看到类似以下的输出(具体内容因LLM的随机性可能略有不同):
Turn 1: search(上海天气) -> 上海今天晴转多云,气温15-22摄氏度,东南风3-4级。... Turn 2: calculator(22 * 9/5 + 32) -> 71.6... === 完整推理链 === Thought: 用户问了两个问题:上海的天气,以及22摄氏度换算成华氏度。我需要先获取天气信息,然后进行温度换算。 Action: search Action Input: 上海天气 Observation: 上海今天晴转多云,气温15-22摄氏度,东南风3-4级。 Thought: 我已经得到了上海的天气信息。现在需要将22摄氏度转换为华氏度。转换公式是 F = C * 9/5 + 32。 Action: calculator Action Input: 22 * 9/5 + 32 Observation: 71.6 Thought: 我已经得到所有需要的信息。 Action: Final Answer Action Input: Observation: Final Answer: 上海今天晴转多云,气温15-22摄氏度,东南风3-4级。其中22摄氏度约等于71.6华氏度。 用户问题:上海今天的天气怎么样?如果气温是22摄氏度,相当于多少华氏度? Agent最终答案:上海今天晴转多云,气温15-22摄氏度,东南风3-4级。其中22摄氏度约等于71.6华氏度。可以看到,Agent成功地进行了两步推理:先搜索天气,再计算温度换算,最后整合信息给出了最终答案。整个思考过程清晰可见。
6. 从“玩具”到“工具”:优化与扩展思路
我们的50行核心代码已经展示了一个可工作的Agent雏形。但正如你所见,它还很脆弱,像一个精致的“玩具”。要把它变成一个可靠的“工具”,我们需要在以下几个关键方面进行强化:
1. 健壮性提升:让Agent更稳定
- 输出解析加固:正则表达式是脆弱的。更优解是使用结构化输出(Structured Outputs)。例如,在调用LLM时,要求其以指定的JSON格式返回。OpenAI的Chat Completions API支持通过
response_format参数指定JSON Schema,这能极大提高输出的一致性。如果使用的模型不支持此功能,可以在提示词中更严格地要求输出JSON,并使用json.loads()进行解析,同时做好异常捕获。 - 错误处理与重试:网络请求可能失败,LLM可能返回无法解析的内容,工具函数可能抛出异常。主循环中每个与外部交互的步骤(API调用、工具执行)都应该有
try...except包裹。对于可恢复的错误(如格式错误),可以设计重试逻辑,例如将错误信息作为Observation反馈给LLM,让它自我修正。 - 超时与循环控制:除了最大轮次,还应设置总耗时超时。对于某些卡住的场景(比如LLM反复调用同一个工具得不到新信息),可以设计更智能的终止逻辑,例如检测到历史中出现重复的
Thought-Action模式就提前退出。
2. 能力扩展:让Agent更强大
- 工具扩展:
TOOLS字典的设计使得添加新工具非常方便。只需定义好函数和描述,将其加入字典即可。你可以集成真正的搜索引擎API、数据库查询、代码执行环境、企业内部系统接口等。 - 记忆与状态管理:我们当前的
history是简单的会话记忆。对于更复杂的任务,你可能需要:- 长期记忆:将重要的历史信息向量化后存入数据库(如ChromaDB),供后续会话检索。
- 状态总结:在对话轮次较多时,完整的
history可能会超出LLM的上下文长度限制。此时需要引入“总结”步骤,定期将冗长的历史压缩成精炼的摘要,再喂给LLM。
- 多Agent协作:单个Agent能力有限。你可以创建多个具有不同专长(如“研究员”、“写手”、“校对员”)的Agent实例,让它们通过共享一个工作空间或互相传递消息来协同完成任务。这需要设计更复杂的协调机制(如一个“主控”Agent来分配任务)。
3. 效率与成本优化
- 提示词优化:我们的系统提示词还可以精炼。使用少样本示例(Few-Shot)在提示词中提供几个完美的输入输出对,能更有效地引导LLM遵循格式。对于复杂工具,可以提供更具体的调用示例。
- 上下文管理:LLM的上下文窗口是宝贵的资源。除了上述的状态总结,还可以有选择地将历史中的
Observation(尤其是冗长的工具返回结果)进行摘要后再存入历史,只保留关键信息。 - 模型选择:对于简单的工具调用任务,
gpt-3.5-turbo通常足够且成本更低。对于需要复杂规划或推理的任务,再考虑使用gpt-4。可以根据任务难度动态选择模型。
4. 监控与可观测性
- 日志记录:将每一轮的
prompt、llm_output、action、observation都详细记录下来,这对于调试和优化Agent行为至关重要。 - 链路追踪(Tracing):在分布式或复杂流程中,可以使用像OpenTelemetry这样的标准来追踪一个请求在多个Agent或工具间的完整流动路径,便于定位性能瓶颈或错误源头。
实现这些优化后,你的微型Agent将逐渐具备生产级应用的雏形。这个从零搭建的过程,会让你对市面上那些成熟框架的每个设计决策都有更深刻的理解。
7. 与主流框架的对比:我们获得了什么,放弃了什么
最后,让我们把目光拉回起点,对比一下我们这个50行的“手搓”Agent和直接使用LangChain这样的框架,到底有什么区别。
我们获得的东西:
- 极致的透明度和控制力:每一行代码你都知道在干什么。出现bug时,你可以迅速定位到是提示词问题、解析问题还是工具函数问题。你可以随心所欲地修改Agent的决策逻辑、状态管理方式。
- 深刻的概念理解:你亲手实现了ReAct循环、工具调用、历史管理这些核心概念。现在你去读LangChain的
AgentExecutor源码,会发现它无非是用更优雅、更健壮的方式实现了类似我们主循环的逻辑,周围包裹了更多功能组件。 - 无依赖的轻量级部署:你的代码库可能只需要
openai这一个第三方库(如果你用其他LLM服务,甚至可能只需要requests)。这意味著更小的镜像、更快的启动速度、更少的依赖冲突。 - 定制的灵活性:如果你的业务逻辑非常特殊,框架的抽象反而会成为束缚。而你的微型Agent可以轻松地融入现有的代码架构,或者实现一些框架不支持的古怪工作流。
我们放弃的东西:
- 开箱即用的丰富功能:LangChain提供了数十种现成的工具(搜索引擎、维基百科、Python REPL等)、多种Agent类型(ReAct, Plan-and-Execute, OpenAI Functions等)、以及链(Chains)、记忆(Memory)、索引(Indexes)等一系列高级组件。我们需要自己从头实现每一个需要的功能。
- 工业级的健壮性:框架经过了大量生产环境的测试,处理了各种边界情况(如LLM输出格式错误、工具调用超时、上下文窗口管理等)。我们自己实现的简单版本需要投入大量工作才能达到同等稳定性。
- 活跃的社区和生态:使用主流框架意味着可以轻松找到大量的教程、示例代码、现成的集成方案和遇到问题时的社区解答。自己造轮子则要独自面对所有挑战。
- 开发效率:对于大多数标准场景,使用框架能让你在几分钟内搭建一个可用的Agent原型。自己实现则需要更多时间。
结论与选择建议:这个50行的Agent不是一个用来替代LangChain的生产方案,而是一个绝佳的教学工具和原型验证工具。
- 如果你是学习者:强烈建议按照本文的思路亲手实现一遍。这是理解Agent内核最快、最深刻的方式。
- 如果你在验证一个非常新颖的AI工作流想法:框架的复杂性可能会干扰你的核心创意。先用极简代码实现核心循环,验证想法是否跑得通,之后再考虑用框架重构以获得更好的工程支持。
- 如果你的需求极其简单且固定:比如就是一个内部用的、调用一两个固定工具的小助手,那么这个微型Agent可能就足够了,引入一个大框架反而是过度设计。
- 对于大多数正式项目:当你理解了原理之后,还是应该选择像LangChain、LlamaIndex这样的成熟框架。它们能帮你节省大量时间,让你更专注于业务逻辑本身,而不是重复造轮子。
编程的世界里,最好的工具不是最强大的那个,而是最适合你当下场景的那个。这次从零构建Agent的经历,正是为了帮你获得这种“选择”的能力。下次当你启动一个新AI项目时,你可以自信地判断:这次,我是该直接pip install langchain,还是先花半小时,写一个属于自己的、50行的核心循环?