资讯详情 从零实现Agent智能助手:核心循环、工具设计与踩坑全解析
📅 2026/10/18 2:08:34
最近后台经常有人问我同一个问题“Agent这么火但我翻了几篇教程全是概念和架构图看完还是不知道第一行代码写在哪。”今天我把个人从零搭第一个智能助手的完整过程拉出来晒一晒包括设计思路、关键代码、踩过的坑以及那些文档里不会写的调试心得。如果你有一点Python基础想明白Agent到底是怎么“干活”的这篇应该能帮你少走不少弯路。1. 先从整体聊聊Agent到底是怎么“想”的1.1 它和普通程序、ChatGPT聊天窗口的差别很多人以为Agent就是一个苹果版的ChatGPT换个壳、接个API就行。真相不是这样。普通程序是确定性的你写if a 10它就永远走那个分支。聊天窗口的核心是一个“生成下一个字”的模型你问一句它答一句模型本身没有任何办法去查数据库、调接口、操作文件——它只会生成文本。而Agent在这两者之间架了一座桥让模型在需要的时候主动提出“我要调用某个工具”然后代码负责执行再把结果喂回给模型模型根据结果决定下一步。我用一个生活类比给你讲透Agent的脑子是模型手和脚是代码。模型负责“想”它像一个刚入职的实习生脑子灵活、懂很多理论但不会用公司的内部系统。代码就是那些系统的操作手册和权限卡实习生说“我需要查一下这个客户的订单”系统管理员你的代码就把订单查出来递给他实习生看完订单说“我需要给这个客户发个优惠券”系统管理员就去调发券接口。整个过程的关键在于模型不是在“模拟”做事而是真的通过工具触达了系统拿到了真实数据再基于真实数据做判断。这也是Agent和普通程序最本质的区别普通程序是人把每一步都写死Agent是模型在运行时自己决定走哪一步。人只负责提供“有哪些路可以走”。1.2 一个最朴素的Agent运行循环抛开所有框架的花活Agent的核心就一个循环四步模型接收用户任务和当前上下文决定是直接回复还是需要调用某个工具。如果需要工具模型输出一个结构化指令比如“调用函数A参数是X”。你的代码实际执行这个函数拿到真实结果。把工具返回结果拼接进对话上下文回传给模型回到第1步。这个循环不断重复直到模型认为任务完成、不再调用工具为止。说穿了Agent不是什么玄学它就是一个带“工具决策”能力的对话循环。你把这个循环想明白后面所有实现都在给这个循环填肉。我当初犯的第一个错误就是去看各种Agent框架的源码被内部调度、记忆管理器、任务分解器这些概念绕晕了。回头再看这些全是循环的变体和强化。你先能徒手写出这个循环再去玩框架就是降维打击。1.3 为什么“工具”才是Agent的灵魂如果模型是Agent的大脑工具就是Agent的手脚和感官。没有工具的模型能干的事极其有限你问它“现在几点了”它只能瞎编一个时间因为它没有时钟你让它“查一下这个IP归属地”它只能给你一段模糊的说法因为它查不了网络你让它“帮我把这段文字翻译成文档并保存”它生成完文本就结束了因为文件系统不归它管。工具赋予了模型“感知世界”和“影响世界”的能力。换个角度看你在给模型做一套“外设”每个工具都是一个标准化的动作模型负责选动作、填参数你负责把动作真正落地。这里有个非常重要的设计理念你给模型工具的粒度直接决定了它的能力边界。工具给得太粗比如只给一个“执行任意SQL”模型就像拿了把万能钥匙很容易做错事甚至搞破坏工具给得太细比如把一次HTTP请求拆成十个工具模型会在工具之间来回折腾浪费大量时间和Token。合理的设计是把一个完整业务动作作为一个工具参数尽量精简说明书尽量清晰。2. 动手前的准备把地基打牢2.1 技术栈选型与项目初始化我第一次做Agent选型的时候脑子里过了好几个框架最后全推翻了。原因特别简单我要先理解核心原理而不是被框架绑架。你如果一上来就引LangChain遇到报错会分不清是框架的问题还是自己逻辑的问题。先用最少依赖把循环跑通后面再上框架不迟。我用的技术栈就三样Python 3.10requests库调模型的HTTP接口一个支持工具调用function calling的大模型API为什么选Python而不是Node或Go不是因为Python性能好而是因为AI生态最成熟后面你无论接向量库、接数据处理、接PDF解析Python都是一句话装个包的事。Agent开发调试频繁Python的交互式和脚本式开发体验也是最顺手的。初始化项目非常简单一个文件夹加两个文件就够agent_demo/ ├── agent.py # 主逻辑 ├── tools.py # 工具定义与注册 └── requirements.txt # 依赖requirements.txt 里我只写了三行requests2.31.0 python-dotenv1.0.0dotenv是用来管理API密钥的千万别把密钥写死在代码里。项目正式一点的同学可以再加一个config.py统一管理模型名、API地址、超时时间等参数。2.2 设计工具函数的“契约”工具函数写起来不难难的是“契约”设计。什么意思你要让模型知道这个工具叫什么、干什么用的、需要传什么参数、返回什么结果。模型是个实习生你不把操作手册写清楚他就不敢用或者乱用。我给Agent设计的第一个原型工具集就三个获取当前时间、安全计算器、查城市天气模拟数据。三个刚好覆盖了“无参数工具”“带基础参数工具”“带结构化参数工具”三种类型足够你把模式跑通。每个工具要提供两份内容函数本体Python代码真正干活的部分。工具说明书用JSON Schema描述名称、描述、参数结构。说明书的作用非常关键。你可以把它理解为给实习生看的“操作手册”模型会仔细读这一段来决定何时调用、怎么填参数。描述写得太模糊比如“获取信息”这种模型就完全不知道什么时候用写得太复杂模型更容易解析出错。我推荐一个经验值每个工具的描述控制在两三句话明确两个信息——什么场景用、参数怎么填。我以天气工具为例给你看说明书长什么样{ type: function, function: { name: search_weather, description: 查询指定城市当前的天气情况包括温度和天气状况。当用户询问天气、温度、是否下雨时使用。, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海、广州 } }, required: [city] } } }看到没有description是从“用户会怎么问”的角度写的而不是从“函数内部实现”的角度。这是新手最容易踩的坑描述写成了“调用天气API并解析返回值”模型看了还是不知道什么时候该动手。你要反过来想模型是通过“用户意图”匹配工具的你的描述应该写“当用户问天气时用”它就秒懂了。2.3 模型接口层该怎么做模型调用层是整个Agent的地基我强烈建议你别省这一步单独封装一个函数。这样后面换模型、加日志、做重试都只改一个地方。我的call_llm函数核心逻辑长这样import requests import json import os API_URL os.getenv(LLM_API_URL, https://api.example.com/v1/chat/completions) API_KEY os.getenv(LLM_API_KEY) MODEL_NAME os.getenv(LLM_MODEL_NAME, model-name) def call_llm(messages, toolsNone, temperature0.2, max_tokens1024, timeout30): payload { model: MODEL_NAME, messages: messages, temperature: temperature, max_tokens: max_tokens, } if tools: payload[tools] tools payload[tool_choice] auto headers { Authorization: fBearer {API_KEY}, Content-Type: application/json } for attempt in range(3): try: resp requests.post(API_URL, headersheaders, jsonpayload, timeouttimeout) resp.raise_for_status() data resp.json() return data[choices][0][message] except requests.exceptions.Timeout: # 超时重试退避一下 time.sleep(2 * (attempt 1)) except requests.exceptions.HTTPError as e: # 4xx不重试5xx重试 if resp.status_code 500 or attempt 2: raise time.sleep(2 * (attempt 1)) except json.JSONDecodeError: if attempt 2: raise time.sleep(1) raise RuntimeError(模型调用失败)几个参数我解释一下都是测试出来的经验值temperature设成0.2。为什么工具调用场景里你希望模型“稳定地”选择工具、填参数而不是“放飞自我”地发挥。温度越高模型越容易突发奇想编一个不存在的函数名。但如果你这个Agent定位是文案创作temperature可以提高到0.7左右看场景。tool_choice设为auto意思是让模型自己决定调不调工具。你也可以强制它调用某个工具那是在业务流程明确、必须走某条路径时才用的手段。timeout设30秒并做重试。大模型接口偶发超时太常见了不做重试你的Agent就像个纸糊的稍微有点波动就报错。消息结构也是这里要说清楚的重点。和模型对话的数据是一个消息列表每条消息带role和contentrole有四种system系统设定、user用户输入、assistant模型回复、tool工具返回结果。Agent循环的每一步本质都是在往这个列表里追加消息。这个结构你理解透了后面看啥框架都通。3. 从零实现写出Agent的核心循环3.1 先规划项目结构工具和主逻辑分开这是我的习惯。tools.py只干一件事定义工具函数和它们的说明书。agent.py只干一件事控制循环调用模型执行工具。这样以后工具越加越多你也不会一个文件写上千行。先看tools.py的完整实现。我用一个字典做工具注册表再用一个装饰器把函数自动装进注册表。这个模式看起来不起眼但你以后写几十个工具时会感谢这个设计的——每个函数只需要加一行register_tool说明书跟着函数走不会散落各处。# tools.py import ast import operator from datetime import datetime import random # 工具注册表name - {function: func, spec: 说明书} TOOL_REGISTRY {} def register_tool(spec): 装饰器把函数注册进工具表 def decorator(func): TOOL_REGISTRY[spec[function][name]] { function: func, spec: spec } return func return decorator # ---------- 工具1获取当前时间无参数 ---------- register_tool({ type: function, function: { name: get_current_time, description: 获取当前的日期和时间。当用户询问现在几点、今天是几号时使用。, parameters: {type: object, properties: {}} } }) def get_current_time(): return {current_time: datetime.now().strftime(%Y-%m-%d %H:%M:%S)} # ---------- 工具2安全计算器带基础参数 ---------- _ALLOWED_OPERATORS { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, } def _safe_eval(expr): 只允许数字和四则运算禁止任何函数调用、变量、属性访问 tree ast.parse(expr, modeeval) for node in ast.walk(tree): if not isinstance(node, (ast.Expression, ast.BinOp, ast.Constant, ast.UnaryOp, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.USub)): raise ValueError(表达式包含不支持的语法) def _eval_node(node): if isinstance(node, ast.Constant): if isinstance(node.value, (int, float)): return node.value raise ValueError(只支持数字) if isinstance(node, ast.BinOp): op _ALLOWED_OPERATORS[type(node.op)] return op(_eval_node(node.left), _eval_node(node.right)) if isinstance(node, ast.UnaryOp): return -_eval_node(node.operand) raise ValueError(不支持的语法) return _eval_node(tree.body) register_tool({ type: function, function: { name: calculator, description: 计算数学表达式支持加、减、乘、除、幂运算。当用户需要计算时使用。, parameters: { type: object, properties: { expression: { type: string, description: 数学表达式例如 (12 5) * 3 } }, required: [expression] } } }) def calculator(expression: str): try: result _safe_eval(expression) return {result: result} except Exception as e: return {error: f计算失败: {str(e)}} # ---------- 工具3查天气模拟数据 ---------- register_tool({ type: function, function: { name: search_weather, description: 查询指定城市当前天气包括温度、天气状况和湿度。当用户询问天气时使用。, parameters: { type: object, properties: { city: {type: string, description: 城市名称如北京、上海、广州} }, required: [city] } } }) def search_weather(city: str): # 为了演示这里返回模拟数据。真实场景可以替换成调用气象API。 conditions [晴, 多云, 小雨, 阴] return { city: city, temperature: random.randint(15, 30), condition: random.choice(conditions), humidity: random.randint(40, 80) }为什么安全计算器要搞这么复杂不直接eval?因为eval会把字符串当代码执行模型又可能传进来你看不懂的输入生产环境这就是个炸弹。我用ast模块把表达式解析成语法树只允许数字和四则运算节点通过其他一概拒绝。这不是炫技是安全意识你做的Agent越强越要防着它工具被恶意利用。3.2 Agent主循环的实现思路agent.py的核心就是这个循环。逻辑不复杂但你每一行都要知道为什么写。流程如下把系统提示词和用户消息组装成messages。调用模型。如果模型返回了工具调用指令就解析并执行对应工具。把工具结果转成roletool的消息追加到messages。再次调用模型看它是否还要继续调工具。直到模型不再调工具把它最后生成的内容作为最终回复。这个流程里最关键的解析动作是怎么知道模型想调哪个工具通常大模型的function calling接口会在返回里带一个tool_calls字段里面有函数名和参数JSON。如果你用的模型没有这个结构化接口就得让模型自己输出一段JSON你再正则匹配那会痛苦得多。所以选型时我一定优先选支持function calling的模型省掉无数解析的坑。既然我们已经拿到整个Input Output了我写的就是用tool_calls的标准实现。你如果用的是不同服务商解析字段名可能有差异但大思路完全一样。# agent.py import json from tools import TOOL_REGISTRY from llm import call_llm SYSTEM_PROMPT 你是一个智能助手。你可以使用提供的工具来帮助用户完成任务。 当需要查询数据、执行计算时请调用相应工具不要凭空编造数据。 调用工具后根据工具返回的结果给用户完整、准确的中文回答。 def run_agent(user_input: str, max_rounds: int 5): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input} ] round_count 0 while round_count max_rounds: round_count 1 # 1. 调用模型 assistant_msg call_llm( messagesmessages, tools[item[spec] for item in TOOL_REGISTRY.values()] ) # 2. 追加模型回复 messages.append(assistant_msg) # 3. 检查模型是否要调用工具 tool_calls assistant_msg.get(tool_calls, []) if not tool_calls: # 模型不调工具了说明它已经想好最终回复 return assistant_msg[content] # 4. 逐个执行工具调用 for tool_call in tool_calls: function_name tool_call[function][name] arguments json.loads(tool_call[function][arguments] or {}) if function_name not in TOOL_REGISTRY: tool_result {error: f未知工具: {function_name}} else: try: tool_result TOOL_REGISTRY[function_name][function](**arguments) except Exception as e: tool_result {error: f工具执行异常: {str(e)}} # 5. 把工具结果作为 tool 消息追加 messages.append({ role: tool, tool_call_id: tool_call[id], content: json.dumps(tool_result, ensure_asciiFalse) }) # 循环继续模型会看到工具结果决定下一步 return 任务步骤过多已停止。请尝试简化请求或联系管理员。几个细节我要单独拎出来说。max_rounds上限为什么是5因为模型万一陷入“调工具→看结果→继续调工具”的死循环里没有这个上限你的请求就是无底洞几分钟跑不出结果还狂烧Token。先把保护机制做上去再讨论优化这是工程习惯。实际业务里我一般设10到20轮简单问答5轮足够了。工具执行一定要包try...except。模型填参数时可能填错类型、可能传None、可能传一个超长字符串你的工具函数要有容错能力。工具执行失败不可怕可怕的是异常直接终结了整个Agent进程。记住一条原则Agent的每一个环节都要能够失败后继续运行这样模型才有机会自行纠偏。3.3 跑通第一个完整任务代码写完了直接跑一个用户输入试试if __name__ __main__: result run_agent(北京市现在天气怎么样适合穿短袖吗) print(result)模拟一下内部运行的轨迹实际就是这样的第一轮模型收到用户问题判断“用户问天气”触发search_weather参数{city: 北京}。代码执行search_weather(北京)返回{city: 北京, temperature: 24, condition: 多云, humidity: 60}。第二轮模型收到工具结果看到温度24度、多云推理“24度适合穿短袖但多云建议带件薄外套”输出最终回答。实际模型输出可能是北京当前多云气温24度湿度60%。体感比较舒适适合穿短袖不过云量大早晚偏凉的话建议随身带一件薄外套。你注意这个回答里“24度”“多云”“湿度60%”全部是工具返回的真实数据不是模型编的。模型做的事是“基于数据给出穿衣建议”。这个链路就是我们常说的“AI Agent有实时感知能力”的真实含义。再试一个组合任务“帮我算一下(87 15) * 4然后再告诉我现在几点。”这种多步任务最能体现Agent的价值。模型第一轮可能会先调calculator因为它计算能力弱自己心算不放心拿到结果后第二轮再调get_current_time第三轮把计算结果和当前时间组装成回答。一个简单问题背后发生了三次模型调用这就是Agent的成本所在——它解决问题的能力更强了但单次请求的延迟和Token消耗也随之上升。3.4 回复质量的几个关键调优参数跑通是第一关跑得稳是第二关。我在调试中发现同样一套代码参数不同效果天差地别。分享几个直接影响成败的调优点。第一系统提示词里一定写清楚“不要编造数据”。不加这句模型就可能跳过工具凭训练记忆直接回答“北京今天28度”。模型不知道今天是几号它的训练数据里可能有去年夏天某天的北京天气但它会当成今天的告诉你而且语气无比笃定。你必须在系统提示词里反复强调涉及实时信息必须调用工具。第二工具的执行结果要转成JSON字符串再喂回模型。有些同学偷懒直接把Python字典当content传进去API直接报错。模型接口要求content必须是字符串所以统一json.dumps(...)。同时ensure_asciiFalse一定要写否则中文会变成\u转义模型也不是看不懂但多一层转换就多一分出错概率。第三日志打到细。Agent debug和普通程序debug完全是两种思路普通程序可以断点调试Agent是黑盒推理你唯一能看的就是每一步的输入输出。我在调试期会在循环里加一行print(f 第{round_count}轮模型调用了工具: {function_name}参数: {arguments}) print(f 工具返回: {tool_result})你看到日志里“模型开始调用工具”和“工具返回真实结果”这两行悬着的心就放下来了。Agent开发里可视化日志不是辅助手段是主手段。4. 真实踩坑记录这些问题你一定也会遇到4.1 模型就是不调用工具怎么办这是新手第一个大坑。你辛辛苦苦写了工具模型却像个倔驴一样完全不搭理它直接凭记忆回复。我排查这个问题时发现原因往往是工具说明书写得太烂。你要理解模型是靠描述匹配意图的你的描述里必须出现“当用户问XX时使用”这样的触发信号。还有一种情况是系统提示词压制了工具调用。我当时为了追求“人味”写了一句“请用自然的方式与用户交流”结果模型还真就光交流不干活了。系统提示词里对于工具使用的态度必须非常明确不要给模型留“可以不用工具”的含糊空间。最后一个很现实的原因你用的模型本身工具调用能力太弱。不是所有大模型都擅长function calling有些模型你给它工具列表它过一会儿就忘了。这种情况有两个解法一是换个工具能力更强的模型二是把工具描述精简到极致减少模型记忆负担。4.2 JSON解析失败、参数格式五花八门模型生成的arguments是一个JSON字符串理论上该是标准JSON但架不住模型偶尔给你来个{city: 北京}——少了引号。我遇到过参数值是数字却传了字符串、参数名和你定义的对不上、甚至多传一个你根本没定义的参数。解决办法是双层的。第一层解析时别直接json.loads裸奔包一层异常处理解析失败就构造一个错误结果返回给模型让模型自己修正参数后重试。第二层你在工具函数入口加参数校验不符合预期就返回明确的错误信息比如“参数city必须是字符串”。经验法则不要让异常打断循环要让异常变成一条消息流回给模型。模型看到错误信息后往往会自己修正这种“自愈”能力是Agent最迷人的地方。4.3 上下文越来越长Token很快爆掉Agent每轮都往messages里追加消息多步任务跑下来messages可能几十条Token消耗随轮次线性上涨。我跑过一个十轮任务一次对话烧掉了几万Token肉疼。几个实用的兜底手段对历史消息做截断只保留最近的N条比如保留最近10轮。系统提示词和工具说明书每次都要送它们占了固定成本但压缩工具描述能显著省Token。更高级的做法是历史摘要把更早的消息让模型压成一段摘要再作为一条system消息送进去。这样既保留上下文关键信息又控制Token消耗。你如果只是做Demo第1种截断就够用。真要落地到生产摘要方案是必须的否则用户多聊几轮你的成本就失控了。4.4 并发调用、超时重试这些工程问题单用户用着好好的一旦多个用户同时用就暴露出各种问题。模型接口并发会限流一个请求超时导致用户长时间转圈。我建议在本地至少做两件事一是所有模型调用走统一超时和重试二是在Agent外层套一个简单的请求队列或并发限制。如果用户量大模型调用务必做成异步而不是阻塞式同步请求。另外每次用户会话都要独立维护一个messages数组千万别用一个全局变量存消息。我当时偷懒用一个全局list存消息两个用户同时说话上下文互相串了回答完全错乱。教训深刻。4.5 排查问题速查表给你整理一个实操时直接对着查的表现象大概率原因解决动作模型不调用工具工具描述缺少触发信号描述里写清“当用户问……时使用”模型调用不存在的工具工具列表没传或tool_choice设错检查调用时是否传了tools参数参数解析报错模型生成JSON不标准包try/except把错误喂回模型自愈工具执行报错参数类型不对或工具本身有bug加参数校验错误结果返回给模型回答还是编数据系统提示词没强调明令禁止编造实时数据先调工具Token消耗过快消息越长越多截断历史压缩工具描述做摘要多人使用互相串台共享了messages变量每个会话独立消息数组偶发超时报错接口不稳定统一超时配置加指数退避重试轮次太多死循环缺少max_rounds设置合理的最大轮次上限4.6 我建议的调试节奏别上来就想做全能型Agent。我的实践路径是分四步走先让单工具跑通再让多工具组合跑通然后加错误自愈逻辑最后才考虑记忆和并发这些生产问题。每一步都要肉眼看到效果再走下一步。调试阶段有一个很笨但很有效的方法把模型每一步的原始返回dump成文件。agent_debug.log里记录每一轮的完整消息列表、工具调用、工具返回。一旦某个任务行为异常你翻日志能看到是哪一轮开始跑偏的。绝大多数Agent的诡异行为都能在日志里找到根源——不是模型“抽风”了而是你在某一轮丢给它了一个意料之外的内容或者工具说明书让它产生了误解。我个人偏好把调试做成“穷举”而不是“碰运气”。给Agent列一个测试用例清单比如“问天气”“算数”“多步组合任务”“故意问它无法回答的问题”每个用例跑一遍把结果记录下来。AI应用最怕“这次好了”的错觉只有可重复的结果才值得信任。写在最后的一点感受第一次跑通Agent全流程的那天晚上盯着终端里模型调用工具、拿回真数据、再组织成回答的整个过程我突然意识到AI应用开发的核心不是多么高深的算法而是“如何把模型的能力稳稳地接进真实业务流程”这件事。它极其考验工程耐心工具说明书写得不好模型就频繁出错上下文管不好成本就失控异常处理不当系统就崩。你在这篇文章里看到的每一个细节——安全计算器、max_rounds、错误回传、日志调试——都是从一次次翻车中磨出来的。如果你正准备动手我的建议只有一个不要对着框架文档空想先把我这段核心循环代码抄下来、跑起来、改一改。哪怕只加一个工具你都会比只看不练的人快十倍理解Agent。围绕这个循环你后面可以无限扩展可以做多Agent协作、接向量数据库做长期记忆、封装成HTTP服务供前端调用。每一层都是在这个最小循环上长出来的。最后分享一个小偏好我的Agent第一个工具永远是获取当前时间。这个工具简单、无参数、效果即时可见适合拿来验证整个调用链路。当你看到模型真的通过代码获取到了此刻的时间而不是背一个训练数据里的日期时你就真正理解Agent了。