首页 / 资讯中心 / 文章详情

从零手写大模型Agent:核心架构、工具调用与避坑指南

从零手写大模型Agent:核心架构、工具调用与避坑指南 ★ FEATURED ARTICLE
1. 大模型Agent到底是个什么东西先把概念说清楚不然后面全是空中楼阁。大模型Agent说白了就是让大模型从“你问我答的聊天框”变成“能自己动手干活的执行者”。普通的大模型调用是这样的你发一段提示词它回一段文本结束。而Agent是在这个基础上加了三样东西——规划能力、工具调用能力、记忆能力。它接到一个任务后会自己拆解步骤、决定用哪个工具、执行、看结果、再决定下一步直到任务完成。我举个生活化的例子。普通大模型像一个博学的顾问你问他“帮我订一张明天去北京的票”他会告诉你“你可以去某平台搜索选择合适车次”。而Agent像一个助理你说同样的话他会直接打开购票接口、查余票、比价格、下单、把订单截图发给你。区别就在于Agent能操作外部世界而聊天模型只能输出文字。那为什么现在Agent这么火核心原因是三个条件同时成熟了。第一大模型的推理能力上来了能稳定做多步规划第二Function Calling工具调用成了主流模型的标配能力模型可以输出结构化的调用请求第三上下文长度大幅提升从早期的4K到现在的128K甚至更长Agent能记住的中间状态变多了。这三件事凑齐Agent才从论文里的概念变成了能跑起来的产品。这篇文章适合谁看如果你有基本的编程能力了解API调用是怎么回事想从零搭一个能跑起来的Agent那这篇就是写给你的。我会从架构设计讲到代码落地把每一步的“为什么”都讲透而不是只丢一段代码让你抄。如果你完全没写过代码也能看懂前面的原理部分知道Agent大概是怎么运转的。提示本文涉及的代码示例以Python为主因为当前Agent生态里Python的库最成熟。如果你用其他语言思路完全一致只是SDK不同。2. Agent的核心架构拆解与方案选型2.1 一个Agent最少需要哪几个模块很多人一上来就去看LangChain、AutoGPT这些框架结果被一堆抽象概念绕晕。我的建议是先把Agent拆到最简理解每个模块的职责再去用框架。一个能干活的最小Agent核心就四个部分大脑LLM负责推理和决策是整个Agent的核心。它接收当前状态输出下一步该做什么。工具集ToolsAgent能调用的外部能力比如搜索、计算、读写文件、调用API。每个工具都有明确的名称、描述和参数定义。记忆Memory短期记忆保存当前任务的对话历史长期记忆保存跨会话的知识。没有记忆的Agent每次都是失忆状态做不了多步任务。执行循环Loop把上面三个串起来的控制流。典型流程是观察当前状态→LLM推理→输出动作→执行动作→把结果写回记忆→再推理直到任务完成或达到最大步数。这四个模块里执行循环是最容易被忽视但最关键的。很多新手写的Agent跑几轮就死循环了或者提前终止问题基本都出在循环的退出条件设计上。2.2 为什么我建议从裸写开始而不是直接上框架现在Agent框架很多LangChain、LlamaIndex、AutoGen、CrewAI各有各的定位。但我强烈建议你第一个Agent用手写循环的方式实现不要一上来就用框架。原因有三个。第一框架屏蔽了太多细节。你用LangChain的AgentExecutor几行代码就能跑起来但你不知道它内部是怎么组织提示词的、怎么解析工具调用的、怎么处理解析失败的。一旦出问题你完全不知道从哪查。第二框架的抽象层会限制你的理解。Agent的核心逻辑其实很简单就是“提示词工程循环工具调用”。你手写一遍两个小时就能搞明白。之后再用框架你是在用它的工程化能力而不是在猜它的黑盒。第三手写版本更容易调试。你可以随时打印中间状态看到LLM每一步到底输出了什么。框架里这些都被封装了调试成本反而更高。我的实际路径是这样的先手写一个能调用两三个工具的Agent跑通完整循环然后再用框架重写一遍对比两者的差异最后根据项目需求决定用哪个。这个顺序走下来你对Agent的理解会比直接抄框架代码深得多。2.3 工具调用的两种实现路线对比让大模型调用工具目前有两条主流路线理解它们的区别很重要。路线一基于提示词的文本解析。你在系统提示词里告诉模型“你可以使用以下工具格式是Action: 工具名Action Input: 参数”然后模型输出文本你用正则表达式解析出来。这是早期ReAct模式的做法。优点是兼容任何模型不依赖特定API能力缺点是解析容易出错模型稍微不听话格式就乱了。路线二基于原生Function Calling。主流模型厂商都提供了工具调用接口你传入工具的结构化定义JSON Schema模型直接返回结构化的调用请求不用你解析文本。优点是稳定、准确率高缺点是依赖模型支持且不同厂商的接口格式有差异。对比维度提示词解析路线原生Function Calling兼容性任何模型都能用需要模型支持稳定性依赖模型遵循格式易出错结构化输出稳定开发成本需要写解析和容错逻辑接口直接返回省事调试难度出错时难定位是模型还是解析问题错误信息清晰适用场景老模型、本地小模型主流商用模型我的建议是能用Function Calling就用Function Calling除非你用的是不支持该能力的小模型。稳定性差距在实际项目里非常明显提示词解析路线在复杂任务下失败率能到20%以上而Function Calling基本在5%以下。3. 从零手写一个Agent的完整实操3.1 环境准备与依赖安装先把环境搭起来。我用的是Python 3.10以上版本主要依赖两个库一个是模型厂商的SDK一个是用来做HTTP请求的。如果你用OpenAI兼容接口装openai库就行。pip install openai python-dotenvAPI密钥不要硬编码在代码里用环境变量管理。建一个.env文件LLM_API_KEY你的密钥 LLM_BASE_URL你的接口地址然后在代码里加载import os from dotenv import load_dotenv from openai import OpenAI load_dotenv() client OpenAI( api_keyos.getenv(LLM_API_KEY), base_urlos.getenv(LLM_BASE_URL) )注意如果你用的是国内模型的兼容接口base_url一定要填对很多人卡在这一步报错说连接不上其实就是地址写错了。3.2 定义你的第一批工具工具的定义要包含三部分名称、描述、参数schema。描述非常关键模型就是靠描述来判断什么时候该用这个工具的。描述写得含糊模型就会乱调用。我先定义两个最基础的工具一个计算器一个获取当前时间。别小看这两个它们能覆盖很多测试场景。import json from datetime import datetime def calculator(expression: str) - str: 计算数学表达式 try: result eval(expression, {__builtins__: {}}, {}) return str(result) except Exception as e: return f计算错误: {e} def get_current_time() - str: 获取当前时间 return datetime.now().strftime(%Y-%m-%d %H:%M:%S) # 工具的结构化定义 tools [ { type: function, function: { name: calculator, description: 计算数学表达式输入应该是合法的Python数学表达式比如 2 3 * 4, parameters: { type: object, properties: { expression: { type: string, description: 要计算的数学表达式 } }, required: [expression] } } }, { type: function, function: { name: get_current_time, description: 获取当前的日期和时间当用户询问现在几点、今天几号时使用, parameters: { type: object, properties: {}, required: [] } } } ] # 工具名到实际函数的映射 tool_map { calculator: calculator, get_current_time: get_current_time }这里有个细节值得说calculator里我用了eval但把__builtins__设成了空字典这是为了防止模型生成恶意代码。虽然模型一般不会乱来但安全边界该有还是要有。生产环境里更稳妥的做法是用ast.literal_eval或者专门的表达式解析库。3.3 核心执行循环的编写这是整个Agent的心脏。逻辑其实不复杂把对话历史发给模型看它是要调用工具还是直接回答。如果要调用工具就执行工具把结果追加到历史里再发给模型。循环往复直到模型给出最终答案。import json def run_agent(user_input: str, max_steps: int 10): messages [ { role: system, content: 你是一个助手可以使用工具来帮助用户解决问题。 当需要计算或获取时间时调用相应的工具。 得到工具结果后用自然语言回答用户。 }, {role: user, content: user_input} ] for step in range(max_steps): response client.chat.completions.create( model你的模型名称, messagesmessages, toolstools, tool_choiceauto ) msg response.choices[0].message # 如果模型没有调用工具说明它给出了最终答案 if not msg.tool_calls: return msg.content # 把模型的回复加入历史 messages.append(msg) # 依次执行每个工具调用 for tool_call in msg.tool_calls: func_name tool_call.function.name func_args json.loads(tool_call.function.arguments) print(f[步骤{step1}] 调用工具: {func_name}, 参数: {func_args}) if func_name in tool_map: result tool_map[func_name](**func_args) else: result f未知工具: {func_name} print(f[步骤{step1}] 工具返回: {result}) messages.append({ role: tool, tool_call_id: tool_call.id, content: str(result) }) return 达到最大步数限制任务未完成跑一下试试answer run_agent(帮我算一下 (25 17) * 3 等于多少然后告诉我现在几点) print(answer)正常的话你会看到Agent先调用calculator算出126再调用get_current_time拿到时间最后组织成一句话回答你。这就是一个最小可用的Agent了。3.4 记忆模块的加入上面的版本有个问题每次调用run_agent都是全新的对话没有跨轮次的记忆。如果你先问“北京天气怎么样”再问“那上海呢”第二个问题里的“那”指代什么Agent完全不知道。短期记忆好解决把messages提到函数外面维护就行。但长期记忆需要额外设计。最简单的做法是用一个列表存历史对话每次请求时把最近N轮拼进去。更完善的做法是用向量数据库做语义检索只把相关的历史片段召回。class SimpleMemory: def __init__(self, max_turns: int 10): self.history [] self.max_turns max_turns def add(self, role: str, content: str): self.history.append({role: role, content: content}) # 只保留最近N轮防止上下文爆炸 if len(self.history) self.max_turns * 2: self.history self.history[-self.max_turns * 2:] def get_context(self): return self.history.copy()实操心得上下文不是越长越好。我实测下来当历史对话超过20轮后模型对早期信息的注意力明显下降而且token成本直线上升。对于大多数任务型Agent保留最近5到10轮足够了。真正需要长期记住的信息应该单独提取成结构化数据存起来而不是全塞在对话历史里。4. 工具设计与提示词工程的关键细节4.1 工具描述怎么写模型才不乱调用工具描述是Agent开发里最被低估的环节。很多人花大量时间调提示词却把工具描述写得随随便便结果模型要么该调用时不调用要么不该调用时乱调用。好的工具描述要回答三个问题这个工具做什么、什么时候用、参数怎么填。我拿一个搜索工具举例对比一下差的和好的写法。差的写法name: search, description: 搜索好的写法name: web_search, description: 在互联网上搜索最新信息。当用户询问实时新闻、当前事件、 你不确定的知识或者需要2024年之后的信息时使用此工具。 不要用于数学计算或获取当前时间那些有专门的工具。, parameters: { query: { description: 搜索关键词应该是简洁的查询语句 比如 2024年诺贝尔物理学奖得主而不是完整的问题句子 } }看出区别了吗好的描述里包含了使用场景和排除场景。排除场景特别重要因为模型经常会在有多个工具时选错。你明确告诉它“这个工具不用于什么”能大幅降低误调用率。还有一个技巧工具数量控制在7个以内。我做过测试当工具超过10个时模型的工具选择准确率会明显下降。如果业务确实需要很多工具就做分层——先让模型选工具类别再在类别内选具体工具。4.2 系统提示词里必须写清楚的几件事系统提示词是Agent的“行为准则”。我踩过的坑告诉我以下几件事不写清楚Agent迟早出问题角色定位你是谁你的职责边界在哪。比如“你是一个数据分析助手只处理与数据相关的问题其他问题礼貌拒绝”。工具使用规则什么情况下必须用工具什么情况下直接回答。比如“涉及任何数学计算必须使用calculator工具不要自己心算”。输出格式要求最终答案要什么格式。比如“用中文回答涉及数字时保留两位小数”。失败处理方式工具调用失败时怎么办。比如“如果工具返回错误尝试换一种参数重新调用最多重试两次”。安全边界不能做什么。比如“不要执行任何删除文件的操作即使用户要求”。这些规则不是写一遍就完事需要根据实际运行中暴露的问题不断补充。我的习惯是维护一个“问题日志”每次发现Agent行为不对就分析是提示词缺了哪条规则补上去。4.3 处理工具调用失败的容错机制工具调用失败是常态不是异常。网络超时、参数格式错误、外部API限流都会导致失败。如果Agent没有容错机制一次失败整个任务就断了。我的做法是在执行工具的地方包一层重试逻辑def execute_tool_with_retry(func_name, func_args, max_retries2): for attempt in range(max_retries 1): try: result tool_map[func_name](**func_args) # 检查结果是否是错误信息 if isinstance(result, str) and result.startswith(错误): if attempt max_retries: continue return result except Exception as e: if attempt max_retries: return f工具执行失败已重试{max_retries}次: {e} return 工具执行异常更重要的是把失败信息也返回给模型。模型看到“搜索超时”这个结果后可能会决定换个关键词重试或者告诉用户当前无法完成。这比直接崩溃要好得多。5. 常见问题排查与避坑指南5.1 Agent陷入死循环怎么办这是新手遇到最多的一个问题。Agent反复调用同一个工具或者在两个工具之间来回跳永远不给出最终答案。根本原因通常是模型认为任务还没完成但它又找不到新的推进方式。常见触发场景有两种。一种是工具一直返回错误模型不断重试同样的调用另一种是任务本身模糊模型不知道该做到什么程度算完成。解决办法分三层。第一层设置最大步数限制这是兜底必须有。第二层在提示词里明确“如果连续两次工具调用返回相同结果停止尝试并告知用户”。第三层检测重复调用如果发现连续三次调用同一个工具且参数相同强制中断并返回当前状态。def detect_loop(messages, window6): 检测最近几轮是否有重复的工具调用 recent_calls [] for msg in messages[-window:]: if hasattr(msg, tool_calls) and msg.tool_calls: for tc in msg.tool_calls: recent_calls.append((tc.function.name, tc.function.arguments)) if len(recent_calls) 3: last_three recent_calls[-3:] if last_three[0] last_three[1] last_three[2]: return True return False5.2 模型不调用工具直接瞎编答案这个问题的表现是你明明提供了计算器工具问它“123乘以456等于多少”它不调用工具直接给你一个错误答案。原因通常是系统提示词里没有强制要求。模型默认倾向于直接回答因为它的训练数据里大部分情况就是直接回答。你需要在提示词里用比较强的语气规定“涉及数学计算必须使用calculator工具禁止自行计算。”另一个原因是工具描述不够有吸引力。如果calculator的描述只是“计算数学表达式”模型可能觉得“我自己也能算”。改成“精确计算数学表达式避免心算错误所有数学计算都应使用此工具”效果会好很多。5.3 工具参数传错的排查思路模型传错参数是很常见的尤其是参数类型复杂的时候。比如你定义了一个参数是数组类型模型可能传个字符串过来。排查步骤是这样的首先打印出模型返回的原始tool_calls看它到底传了什么。其次检查你的参数schema定义是否清晰有没有给每个参数写description。再次在代码里加参数校验类型不对时返回明确的错误信息给模型让它重新传。常见参数错误原因解决办法类型不匹配schema描述不清在description里写明类型和示例缺少必填参数required没配好检查required数组参数名拼错模型幻觉在description里强调参数名嵌套结构错误复杂schema难理解拆成多个简单工具避坑技巧参数校验的错误信息要写得对模型友好。不要返回“TypeError: expected str, got int”而是返回“参数expression应该是字符串类型你传的是数字请重新调用”。模型看到后者才知道怎么改。5.4 上下文爆炸与成本控制Agent跑多步任务时每一轮都要把完整历史发给模型token消耗是累积的。一个10步的任务如果每步平均2000 token总消耗就是2万token成本是单次调用的10倍。控制成本有几个实用手段。第一精简工具返回结果。工具返回的内容不需要全塞给模型只保留关键信息。比如搜索返回10条结果你截取前3条的摘要就够了。第二定期压缩历史。当对话超过一定轮数用模型把前面的历史总结成一段话替换掉原始消息。第三按需加载工具。不是所有工具每一轮都需要可以根据当前任务阶段动态调整工具列表。我实测过一个案例一个原本消耗3万token的任务通过精简工具返回和压缩历史降到了8000token左右效果基本没损失。6. 从Demo到可用产品的进阶方向6.1 多Agent协作的适用场景单Agent能搞定的事不要上多Agent。多Agent协作会引入通信开销和协调复杂度只有在任务确实需要不同专业角色时才有价值。典型适合多Agent的场景是任务可以明确拆分成几个子任务每个子任务需要不同的工具集和提示词。比如一个“市场分析”任务可以拆成数据收集Agent、数据分析Agent、报告撰写Agent。每个Agent专注自己的领域通过消息传递协作。但如果你的任务只是“查个天气再算个数”单Agent完全够用硬拆成多Agent纯属给自己找麻烦。我的判断标准是当单Agent的工具超过10个或者系统提示词超过2000字还说不清楚时才考虑拆分。6.2 给Agent加上长期记忆前面说的SimpleMemory只是短期记忆。真正的长期记忆需要解决两个问题存什么和怎么取。存什么不是所有对话都值得存。值得长期保留的是用户的偏好、重要的结论、任务的关键中间结果。这些信息应该被提取成结构化数据而不是原样存对话。怎么取最简单的是按时间倒序取最近N条。进阶做法是用向量检索把当前问题转成向量在历史记忆里找语义最相近的几条。这样即使相关记忆是很久以前的也能被召回。# 伪代码示意向量检索记忆 def retrieve_relevant_memory(query, memory_store, top_k3): query_vector embed(query) scores [] for mem in memory_store: score cosine_similarity(query_vector, mem.vector) scores.append((score, mem)) scores.sort(reverseTrue) return [mem for _, mem in scores[:top_k]]6.3 Agent安全边界的设置Agent能操作外部世界这意味着它也能造成破坏。安全边界必须在设计阶段就考虑不能等出事再补。最基本的三条规则最小权限原则Agent只应该拥有完成任务必需的最小工具集不要图省事把所有工具都给它危险操作二次确认涉及删除、支付、发送等不可逆操作时必须让用户确认输入输出过滤对Agent的输入做注入检测对输出做敏感信息过滤。还有一个容易被忽视的点工具的参数校验要在服务端做。不要信任模型传来的参数该校验的校验该限制范围的限制。模型可能被诱导生成恶意参数服务端的校验是最后一道防线。7. 我实际踩过的几个坑第一个坑是过度依赖框架。我最早用某个Agent框架搭项目跑Demo很顺一上真实场景就各种问题。排查了两天才发现是框架内部对工具调用的解析逻辑有bug但被封装了看不到。后来换成手写循环问题一目了然。不是说框架不好而是你要先理解底层再用框架。第二个坑是工具描述写得太简略。我一开始觉得描述随便写写就行反正模型聪明。结果模型频繁在相似工具之间选错。后来把每个工具的描述都扩充到包含使用场景、排除场景、参数示例准确率从70%提到了95%以上。这个投入产出比非常高。第三个坑是没有做步数限制。有一次测试一个复杂任务Agent跑了40多步还没停token烧了一大截。从那以后我所有Agent都强制设max_steps默认10步复杂任务最多20步。超过就中断返回当前进度让用户决定。第四个坑是忽略了工具返回结果的格式。我有个工具返回的是JSON字符串模型看到一堆花括号就懵了经常解析错。后来改成返回自然语言描述模型理解起来顺畅多了。工具返回给模型的内容要按模型容易理解的方式组织而不是按程序方便的方式。这几个坑的共同点是问题都不在模型能力上而在工程细节上。Agent开发模型只占一半另一半是提示词、工具设计、容错逻辑这些脏活累活。把这些做好了用中等能力的模型也能跑出不错的效果。
阅读完成 · 觉得有帮助?
咨询建站