很多人把 Agent 开发想得很玄总觉得要先啃一堆框架、懂强化学习、会编排复杂流程。我的真实体验是如果你已经把大模型接口调通了那 Agent 离你其实只有 100 行代码的距离。今天这篇实战就干一件事——从零写一个能查天气、能做算术、能一步步拆解任务的极简 Agent。我会把模型调用、工具注册、ReAct 循环、结果解析、异常处理全部揉进这 100 行里新手可以直接照着抄老手也可以看看哪些坑值得避。先收藏后面你一定用得上。1. Agent 到底是什么先破除神秘感1.1 大脑、手脚与循环Agent 这个词被讲得很复杂但拆开来看任何一个 Agent 都只有三个部件大脑、手脚、循环。大脑就是大模型本身负责理解用户问题、决定下一步动作、把工具返回的信息组织成最终回答。手脚就是工具典型的有天气查询、计算器、数据库查询、网页搜索、第三方 API。循环才是 Agent 和普通聊天机器人的本质区别普通聊天机器人问一句答一句一次调用就结束Agent 则是让模型在一次又一次的“思考 - 调用工具 - 观察结果 - 再思考”中逼近目标。我见过不少入门者卡在“循环”这一环根源是没分清“模型的回复”和“模型的最终回答”。模型收到用户问题后第一轮输出往往不是答案而是一个“调用意图”和“参数”你要做的是识别出这个意图、把工具结果喂回去再让模型继续处理。这个过程可能一次也可能很多次直到模型明确给出最终回答为止。1.2 你实际需要的只有三个东西从零做一个最简 Agent三样东西就够了一个支持工具调用的大模型接口、一段描述工具清单的 JSON Schema、一份控制模型行为逻辑的系统提示词。工具调用是大模型接口里一个特定的参数在 Chat Completions 协议里叫tools。你把这个参数传给接口模型在需要的时候就会返回一个结构化的“我想调用函数 x参数是 y”。注意这个结构化返回和普通文本生成不一样它是被接口专门格式化过的可以直接解析不需要用正则从长文本里硬抠。这是 Agent 开发里最关键的一环也是后面代码能控制在 100 行的原因。1.3 为什么用工具调用而不是让模型硬答有人会问我能不能在系统提示词里写“如果用户问你天气你就说‘我去查一下’”然后自己从回答里猜他要去查什么可以但你会遇到两个问题第一模型很容易“一本正经地胡说八道”明明没查到天气却根据训练数据编一个“北京今天 26 度”第二你很难稳定地从自然语言里解析出结构化的参数。工具调用的意义就是让模型把“行动意图”和“说话内容”分开。模型在需要获取外部信息时不再生成自然语言而是生成一个严格遵循 JSON 结构的tool_calls里面带着函数名和参数。你只要执行这个函数再把结果作为新的消息传回去模型就能基于真实数据继续回答。这一招直接把“幻觉”的温床拆掉了大半。2. 环境准备三分钟把地基打好2.1 依赖到底要装几个极简教程就该有极简的样子。我推荐的依赖只有两个Python 3.9 及以上版本requests库用于请求大模型接口。核心依赖就上面两个。如果你的项目想方便地管理密钥还可以顺手装一个python-dotenv但这只是锦上添花不装也完全不影响。有人问为什么不装各家大模型厂商的官方 SDK。我的原因后面会详细说这里先给结论官方 SDK 能帮你省几行代码但也把你绑死在特定厂商上而且很多新人不看 SDK 源码出了问题根本不知道怎么排查。2.2 API 端点和密钥配置准备好一个你能正常调用的大模型 API 服务商拿到三个信息API 地址、模型名称、API 密钥。我把它们全部用环境变量管理这样既能避免把密钥写死在代码里也方便在不同模型之间切换。打开终端按你的操作系统设置环境变量# Linux / macOS export LLM_BASE_URLhttps://api.example.com/v1 export LLM_MODELyour-chat-model export LLM_API_KEYsk-your-key # Windows PowerShell $env:LLM_BASE_URLhttps://api.example.com/v1 $env:LLM_MODELyour-chat-model $env:LLM_API_KEYsk-your-keyLLM_BASE_URL是接口的根路径后面我们的代码会自动拼上/chat/completions。不同服务商的路径可能略有差异但只要是兼容 Chat Completions 协议的接口基本都长这样。2.3 我为什么坚持用 Requests 而不是各家 SDK我用requests直连接口不是和 SDK 过不去而是有三个实际考量。第一这个教程的目标读者是“想搞懂 Agent 原理”的人用 SDK 会把底层通信细节藏起来你看不清请求体到底长什么样。第二工具调用接口在不同 SDK 里的写法差异很大有的要传tools有的要传functions有的还要包一层自定义类型初学者经常在这些包装层里迷失。第三很多实际项目里你用的网关或中间件并不一定和官方 SDK 完全兼容但只要是标准协议requests就一定通用。3. 100 行代码实战逐段拆解一个极简 Agent3.1 先上完整代码下面就是这个极简 Agent 的全部代码核心逻辑 100 行出头。我把代码分成了配置、工具清单、工具执行器、模型调用封装、主循环五个部分每部分职责单一方便你直接抄到自己的项目里改。import json import os import requests # 基础配置 LLM_BASE_URL os.getenv(LLM_BASE_URL, https://api.example.com/v1) LLM_MODEL os.getenv(LLM_MODEL, your-chat-model) LLM_API_KEY os.getenv(LLM_API_KEY, ) SYSTEM_PROMPT 你是一个能调用工具来完成任务的助手。 当用户请求涉及天气、计算等问题时请通过工具获取真实信息不要自己编造。 请严格基于工具的返回内容用简洁、准确的中文回答用户。 # 工具清单 TOOLS [ { type: function, function: { name: get_weather, description: 查询指定城市当前的天气情况, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海 } }, required: [city] } } }, { type: function, function: { name: calculator, description: 计算简单的数学表达式支持加减乘除和括号, parameters: { type: object, properties: { expression: { type: string, description: 数学表达式例如(23 45) * 2 } }, required: [expression] } } } ] # 工具执行器 def get_weather(city: str) - str: return f{city}晴气温26℃东南风2级 def calculator(expression: str) - str: # 演示用 eval生产环境务必换成安全计算库 return str(eval(expression, {__builtins__: {}}, {})) FUNCTIONS { get_weather: get_weather, calculator: calculator, } def call_function(name: str, arguments: dict) - str: if name not in FUNCTIONS: return f错误找不到工具 {name} try: return str(FUNCTIONS[name](**arguments)) except Exception as e: return f错误工具执行失败{type(e).__name__}: {e} # 大模型调用封装 def call_llm(messages, toolsNone): headers {Authorization: fBearer {LLM_API_KEY}} payload { model: LLM_MODEL, messages: messages, temperature: 0.3, } if tools: payload[tools] tools resp requests.post( f{LLM_BASE_URL}/chat/completions, headersheaders, jsonpayload, timeout30 ) if resp.status_code ! 200: raise RuntimeError(f大模型API请求失败{resp.status_code} {resp.text}) return resp.json()[choices][0][message] # Agent 主循环 def run_agent(user_input: str, max_steps: int 5): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input} ] for step in range(1, max_steps 1): print(f\n--- 第 {step} 轮 ---) msg call_llm(messages, toolsTOOLS) # 模型没有产生工具调用说明它认为任务已经完成 if not msg.get(tool_calls): print(最终回答, msg[content]) return msg[content] # 模型产生了一个或多个工具调用执行并反馈结果 messages.append(msg) for tool_call in msg[tool_calls]: fn_name tool_call[function][name] fn_args json.loads(tool_call[function][arguments]) print(f[Agent] 调用工具 {fn_name}({fn_args})) result call_function(fn_name, fn_args) print(f[Tool] 返回{result}) messages.append({ role: tool, tool_call_id: tool_call[id], content: result }) print(已达最大轮数强制停止。) return messages[-1][content] if __name__ __main__: question input(请提问) run_agent(question)别被这么多字吓到真正读起来你会发现它很简单。下面我按模块逐个拆解。3.2 模型调用封装把注意力留给调度call_llm这个函数是整个 Agent 的“管道层”。它接收当前对话历史messages和一份可选的tools清单然后把它们组装成标准的请求体POST 到大模型接口。这里最值得看的是payload的组装。前面所有消息无论是系统指令还是用户问题都被无差别地塞进messages数组。当tools存在时我们把它一并传进去模型就知道“这次对话过程中我有工具可以用”。temperature我设成了 0.3这是我在日常 Agent 开发里反复试出来的经验值工具调用任务敏感度很高温度过高会导致模型选择工具不够稳定0.2 到 0.4 这个区间最稳妥。还有一个细节如果接口返回状态码不是 200我直接抛异常而不是默默吞掉。实际项目里遇到过很多同学在这里写了个print就继续跑问题藏在日志里完全看不出来。直接抛异常让程序快速失败反而是最省事的护栏。3.3 工具清单是 Agent 的说明书TOOLS列表是整个 Agent 里最像“文档”的部分它用的是 JSON Schema 格式。你可以把它理解为你在给模型递交一份工具说明书说明书里写了每个工具的用途、参数、参数类型以及哪些参数是必填的。get_weather的描述是“查询指定城市当前的天气情况”参数是city类型是string必填。calculator的描述是“计算简单的数学表达式”参数是expression也是string必填。模型读的就是这段描述它依据这段描述来决定“什么时候该用工具、用什么工具、参数填什么”。这段描述的质量直接影响 Agent 的成功率。我见过不少人图省事把描述写成“天气工具”四个字结果模型经常不调用或者把参数名猜成拼音。正确做法是在描述里写清“这个工具负责什么、输入是什么、边界在哪里”尽量像写给同事的接口文档一样写。工具名建议用英文小写加下划线很多模型对中文参数名的解析确实不如英文稳定。3.4 工具执行器的容错设计call_function是模型和真实世界交互的“最后一公里”。模型说“我要调用get_weather参数是{city: 北京}”你的代码真实执行这个函数然后把执行结果作为字符串返回。这里有两个特别容易踩的坑。第一个坑是安全问题。示例里calculator用了eval这是为了方便演示但eval在真实环境里等同于把你的服务器大门敞开。生产环境请务必换成ast安全解析或者simpleeval这类库只允许运算符号和数字禁止执行任意代码。我特意在eval里限制了__builtins__为空字典能把风险压掉大半但依然只是演示级别。第二个坑是异常处理。工具执行失败不能直接中断整个 Agent因为你还要把错误信息告诉模型让它决定是换一种方式还是放弃。我在call_function里把所有异常捕获后转成一段中文错误消息返回。这种做法很有用——模型读到“错误工具执行失败ZeroDivisionError: division by zero”之后就知道应该换个思路回答用户而不是傻站在那里。3.5 主循环思考-调用-观察的闭环run_agent是整个代码的心脏。它维护一个messages列表这个列表就是 Agent 的“工作记忆”。每一轮循环里我都把完整的工作记忆发给模型模型基于当前状态做判断。判断的结果有两种可能一种是没有tool_calls说明模型觉得信息已经够了输出的content就是最终答案另一种是有tool_calls说明模型需要外部信息这时候我把模型给出的消息追加到工作记忆里然后逐个执行工具把工具结果以role: tool的消息追加回去再进入下一轮循环。这里要特别强调role: tool的对应关系。tool_call_id必须和模型返回的一致否则很多接口会报错。另外max_steps必须设置它是在给循环兜底。模型偶发抽风的时候如果没有这个上限你的 Agent 会一直调工具调到天荒地老账单先炸掉。4. 跑一遍看效果从冷冰冰的代码到能对话的 Agent4.1 演示一一条工具调用就完成的任务运行程序输入“北京天气怎么样”你会看到类似下面的输出请提问北京天气怎么样 --- 第 1 轮 --- [Agent] 调用工具 get_weather({city: 北京}) [Tool] 返回北京晴气温26℃东南风2级 最终回答 北京今天晴气温26℃东南风2级适合外出活动。这轮跑下来你已经把 Agent 最核心的链路走通了模型识别到“天气”关键词生成工具调用意图代码执行工具把结果回填给模型模型基于真实数据生成自然语言回答。注意看顺序第一轮模型并没有直接回答“北京今天晴”而是先产生了tool_calls。这就是工具调用的意义——模型宁可去拿真实数据再说话也不硬编一个城市天气。你想让这个链路的稳定性更高可以把SYSTEM_PROMPT里“不要自己编造”这几个字再加重一点。4.2 演示二多步骤任务的完整链路输入“帮我算一下 (2345)*2 等于多少顺便看看上海天气”这种复合任务时有些模型会在一轮里同时调两个工具这是接口允许多个tool_calls设计的目的。输出大概长这样--- 第 1 轮 --- [Agent] 调用工具 calculator({expression: (2345)*2}) [Agent] 调用工具 get_weather({city: 上海}) [Tool] 返回136 [Tool] 返回上海晴气温29℃西南风1级 最终回答 (2345)×2 的结果是 136上海天气晴气温29℃西南风1级。注意代码里for tool_call in msg[tool_calls]这个循环它天然支持了“同一轮调用多个工具”。当多个工具之间没有依赖关系时模型会为了效率把它们打包在一起发起调用。虽然在这个示例里两个工具的结果互不影响但代码的写法已经把这条路打开了。4.3 调参心得哪些参数真正影响 Agent 表现如果你把这套代码拷到自己的项目里我建议你重点调三个参数。第一个是temperature。工具调用任务我建议固定在 0.2 到 0.4 之间太高会让模型“灵光一现”出奇怪的参数太低会显得死板。第二个是max_steps简单任务 3 轮足够复杂任务 8 轮也被证明非常充裕超过 10 轮基本就是死循环要查设置了。第三个是timeout我设置的是 30 秒但如果你调用的工具链路很长或者是本地部署的模型这个值可能不够网络抖动一次就直接抛异常了。还有一个容易被忽略的点模型对工具描述的理解能力有差异。你换一个模型参数时最好先跑一遍最简单的测试用例比如“北京天气怎么样”如果这一步都走不通大概率是模型本身不支持工具调用而不是你代码写错了。5. 常见问题与排障实录5.1 模型就是不按套路调用工具这是我最常被问的问题。排查顺序一般是这样的先确认你确实把tools参数传进了请求体因为你可能漏了if tools: payload[tools] tools这段逻辑。再确认你的模型支持工具调用很多轻量模型压根不支持这个参数你传了它也只当没看见。接着检查工具描述这是最容易忽视的描述写得含糊模型根本不知道什么时候该用。最后还可以用一句更明确的用户输入测试比如“请调用 get_weather 工具查询北京天气”如果这样模型还不调用那就是上面某个环节出问题了。建议在系统提示词里多写一句“当遇到计算和天气等外部信息需求时必须先用工具获取信息再基于工具结果回答”这种主动强调比单纯放在工具描述里效果好很多。5.2 参数解析失败与 JSON 截断模型返回的arguments理论上是合法 JSON但你一定会遇到解析失败。常见原因有两个一是输出被max_tokens截断了函数名和部分参数在后面的}却被吃掉了二是模型偶尔会多输出一些注释或者前后多余的文本。我建议给json.loads包一层异常处理解析失败时把错误消息返回给模型让它自己修正。同时把接口返回里的max_tokens参数调大比如 512 以上给完整的 JSON 留够空间。如果你的接口支持response_format设成 JSON 模式也建议用上能显著降低这类问题。5.3 Agent 陷入无限循环最常见的情况是模型调用了一个查询工具结果不理想于是它换个参数再查一次又不行再查一次循环往复。这种循环在日志里看起来特别像“努力”但实际是在空转。我的应对手段有三个。第一是max_steps必须存在本文代码里设的是 5到上限强制停止并退出。第二是在系统提示词里加一条应急指令——“如果工具连续两次返回错误或无效结果请直接告诉用户当前无法完成请求不要再次尝试”。第三是检查工具返回的错误消息是否足够明确模型读不懂错误信息时才会原地打转。5.4 上下文爆炸与记忆丢失每执行一轮工具调用messages里就会追加至少两条消息多轮任务跑下来这个数组会膨胀得非常快。模型上下文窗口是有限的一旦超出要么请求报错要么最早的消息被系统默默丢弃Agent 就“失忆”了。生产级项目里没人会把整个工作记忆无限传下去。可以给messages加一个长度上限超出时把最老的对话做摘要后塞回系统提示词里也可以在中间环节精简工具返回比如把一大段 JSON 结果压缩成一行摘要。这一步很重要别让上下文爆炸在线上悄悄烧掉你的成本。5.5 工具抛错后模型不知所措如果call_function返回 “错误缺少参数 city”有些模型会道歉后放弃有些则会假装完成。想让模型正确应对错误错误消息本身要提供“修复线索”。我推荐把错误消息返回成结构化文本比如错误参数 city 是必填的请提供城市名称。这比裸返回一个KeyError可读性强太多。还有一个技巧在系统提示词里写清楚“工具返回错误时请根据错误信息决定是否重新调用工具若连续失败则向用户说明原因”。这套策略基本能覆盖绝大多数工具报错场景。问题现象最可能的根因快速处理办法模型不调用工具tools没传 / 模型不支持检查请求体换支持工具调用的模型参数解析失败输出被截断 / 格式不规范调大max_tokens加解析异常处理无限循环工具结果不理想导致反复重试设置max_steps提示词加“连续失败即放弃”上下文膨胀消息逐轮累积过多只保留最近几轮或对旧消息做摘要工具报错后模型懵错误消息缺乏修复线索返回结构化错误文本写明缺什么6. 从 100 行到生产级的扩展经验6.1 用装饰器自动生成工具定义手写 JSON Schema 很枯燥而且代码和定义容易漂移。生产环境里我习惯用装饰器加类型注解自动生成工具清单类似这样from typing import Callable def register_tool(func: Callable): # 根据函数名、docstring、参数注解生成TOOLS条目 # 并注册到FUNCTIONS字典 name func.__name__ doc func.__doc__ or # ... 生成JSON Schema并登记 return func这个思路的收益很大工具函数本身是普通的 Python 函数业务逻辑写在函数体里工具的说明书和调用入口都是自动生成的你少写一半代码还不会写错。市面上很多 Agent 框架的底层就是这么做的你在自己的项目里完全可以复刻一个轻量版。6.2 给 Agent 补上记忆和重试100 行示例里没有记忆每次run_agent都从干净的messages开始。生产中如果用户连续追问你需要把上一次的关键信息进行摘要并拼接到新对话的开头。这个能力可以先用“让大模型压缩历史”的办法实现哪怕只是存 5 轮摘要效果也会比完全无记忆好很多。同时给call_llm加上指数退避重试。大模型接口偶尔返回 429 或 502直接失败很影响体验。重试时建议加随机抖动避免多个请求同时打上去造成雪崩。记住一句话重试不是增加成功率是给下游服务留恢复时间。6.3 我的几个工具设计心得最后聊点实际的。工具数量变多之后你会发现“模型选错工具”比“模型不会用工具”更常见。我踩过几次坑总结出三条经验第一工具名要尽量语义化query_weather比get_info好用得多模型对名字含义的敏感度远超想象。第二工具描述里最好带一个简短的示例比如在 description 里写 “城市名称例如北京、上海”模型就能正确地往参数里填城市名而不是填天气。第三当一个工具的参数超过三四个时模型出错率会明显上升拆成多个单一职责的小工具比塞一个万能大工具健壮得多。关于“怎么判断一个 Agent 写得好不好”我的经验是先跑最简单的一条链路再跑一条需要两步工具调用的链路最后故意在工具里抛个错看模型能不能优雅地收场。这三关过了这个 Agent 的骨架就已经合格了剩下的都是工程细节的打磨。
阅读完成 · 觉得有帮助?