我见过不少程序员第一次接触Agent时的反应:这不就是个套了提示词外壳的聊天机器人?等他们自己动手写完一个才发现,真正的Agent是一套让大模型“自己决定下一步干什么”的运行循环,而不是写一段固定流程的脚本。这篇文章就把我跑通一个最小可用Agent的完整路径摊开讲,从本质原理、选型思路到核心代码、真实踩坑,尽量做到保姆级,适合已经会用Python、想在业务里落地Agent但还没系统性搭过的读者。
先说清Agent能做什么:它不是回答问题的ChatBot,而是能调用工具、读取记忆、根据结果反复决策的自动化程序。举个简单例子,你让它“查一下这批货什么时候能发,如果延期超过一天就自动发一封催办邮件”,它要知道先查库存、再查物流、判断条件、最后触发发信动作,每一步都是模型现场决定而不是写死的。这篇文章不会只给概念,我把我实现一个mini-agent的完整过程、代码、调试思路、上线前要过的关卡都写出来,你可以直接照着复现,也可以拿其中的思路改成自己的版本。
1. Agent的本质:你以为在写程序,其实在搭一套“会做决定的循环”
1.1 Agent不是ChatBot,核心区别在于“行动”
闲聊机器人是“你说一句它答一句”,所有信息都在模型参数和输入上下文里,模型不会主动去查数据库、不会真去调用接口、不会改变任何系统状态。Agent则多了一个关键环节:模型可以发起一个“工具请求”,程序收到后实际执行外部操作,再把结果交还给模型。这个“发起请求→真实执行→返回结果→再次决策”的闭环,就是Agent和ChatBot的分水岭。
我用一个生活类比帮刚入门的朋友理解:Agent像是一个会主动查手册的新实习生。你交代一件事,他先去翻资料、查系统、操作软件,做完一步之后回来告诉你“我查到A情况,要不要按B方案继续”,你点头他再动下一步。关键不在于他每一步做得多好,而在于每一步做完之后,他都能基于真实结果重新决定接下来做什么。这份“决定权”属于运行循环,不属于预先写死的那几个if else。
1.2 核心循环:感知→决策→行动→再感知
一个最基础的Agent循环长这样:
- 接收用户任务,把它作为模型输入。
- 模型分析当前情况和可用工具,做出决策。
- 若模型判断需要调用工具,输出结构化的“工具调用”标记。
- 程序解析标记,执行对应函数,拿到真实返回结果。
- 把工具结果以消息形式追加进上下文,再次交给模型。
- 循环直到模型输出最终答案,或达到最大轮次上限。
这个循环看起来简单,但真正落地时每一环都有坑。比如第3步模型输出的格式稍微不规范,解析就直接崩;第4步工具执行失败,模型看不到失败原因就开始胡编;第6步不设上限,一个异常任务能把你的Token消耗拉到天价。后面我会把这些坑一个个展开。
1.3 为什么必须靠“函数调用”,而不是让大模型直接执行代码
很多人会问:既然Agent这么聪明,能不能让模型直接“操作”我们的系统,省掉中间层?不能。原因是模型本身不具备执行能力,它只是一个概率函数,输入文本预测下一段文本。所谓的“调用工具”,在模型眼里就是输出一段特定格式的JSON,类似{"name": "query_stock", "arguments": {"item_id": "SKU001"}}。真正去执行这个查询的是我们程序里的代码。
所以开发Agent本质上是在做两件事:一是把业务能力包装成模型能理解的“工具清单”,二是写一个解释器去执行模型的决定并反馈结果。模型负责“判断用什么工具、填什么参数”,程序负责“替它把手伸出去”。这个分工决定了你要花很大力气在工具定义质量上,而不是指望模型自己会干活。
1.4 不是所有需求都该上Agent
我也见过反面案例:明明是一个固定流程的订单状态查询,有人非要做成Agent,结果模型偶尔把查询参数填错,线上事故频发。判断要不要用Agent,看三个特征:任务是否有多个分支、是否依赖外部动态数据、是否需要在执行过程中根据结果做调整。如果只是“接到请求→查固定字段→返回固定模板”,普通的工作流引擎更合适。Agent的价值场景是“无法提前穷尽所有情况的自动化”。
2. 动手前的选择题:语言、模型和框架到底怎么选
2.1 编程语言:Python起步最顺,但别把状态写进进程内存
首选Python,理由很实在:处理JSON方便、发HTTP请求生态成熟、文件和数据加工库多,而且多数大模型SDK都优先支持Python。你不需要引入特别复杂的依赖,标准库加一个requests就能把核心循环跑起来。
但有一个容易被忽略的点:Agent运行循环是长时延、多轮次的,不适合直接塞在Web业务线程里同步等结果。更合适的做法是把Agent封装成独立服务,通过消息队列或异步任务接口接收请求,执行完再回传结果。如果是团队里已经有Java/Go的主服务,也不必推翻重来,用Python单独起一个worker当“决策引擎”即可。
2.2 大模型接口选型:不是越强越好,工具调用稳定性优先
选模型时,大多数人第一眼看“谁聪明”,但做Agent最核心的是“工具调用格式稳定性”——也就是模型能不能每次都规规矩矩输出可解析的JSON。再聪明的模型,如果十次里有三次把自然语言和函数标记混在一起,你的解析器就会一直报错、Agent根本跑不稳。
我一般按下面几个维度做选择对比:
| 选型方向 | 主要优点 | 主要代价 | 适合场景 |
|---|---|---|---|
| 商用大模型API | 工具调用稳定、开箱即用、省运维 | 数据出域、单次成本高 | 快速验证、非敏感业务 |
| 私有化部署的开源模型 | 数据全在内网、按量成本低 | 需要卡和运维、效果可能不稳 | 数据敏感、高并发 |
| 自研小模型 | 极低延迟、完全可控 | 需要训练数据、效果上限有限 | 场景极简单、单工具调用 |
实际建议是:第一版直接选一个工具调用能力强的商用API,把你全部的注意力放在打磨工具和闭环逻辑上。等跑通了、看清瓶颈了,再考虑往私有化迁移。一开始就自己训模型,大概率会在模型效果上耗掉大量时间。
2.3 从零写还是用框架:我建议第一个Agent从零写
现在开源的Agent框架很多,自带记忆模块、工具调度、可视化面板,听起来很方便。但问题在于抽象层太厚,一旦出错你很难判断是模型问题、工具问题还是框架调度问题。我的经验是:第一个Agent一定从零手写,别再问为什么。
从零写你能搞懂每一行代码在做什么,尤其是工具调用JSON的解析、消息历史的组织、错误反馈的手法。这些基础不牢,后面用框架也是黑盒踩坑。等你完整跑通过一个mini-agent,再用框架就属于“知其然也知其所以然”,框架帮你省掉的只是重复劳动,而不是替你解决理解问题。
3. 最小可用Agent的完整实现:从识别意图到动手干活
3.1 先画任务边界:第一版只做三个工具
我拿一个常见场景举例:企业内部的商品库存查询助手,需要对接库存接口、价格接口和预留申请接口。第一版不要贪多,先只做三个工具:
| 函数名 | 参数 | 返回内容 |
|---|---|---|
| query_stock | item_id: string,日期: string | 该商品在指定日期的可用库存数和预计补货时间 |
| query_price | item_id: string | 商品单价、所属分类 |
| submit_reservation | item_id: string,quantity: int,remark: string | 预留成功后生成的单号或失败原因 |
工具定义写得好不好,直接决定模型会不会用。每个参数名要用业务人员会说的词,别用内部表字段;返回字段要稳定;枚举值要在描述里写清楚。比如quantity要注明“正整数,超过可用库存会失败”,模型就知道提交前先查库存。
3.2 核心循环代码:一个不依赖框架的mini-agent
下面这段是我实际跑通过的简化版伪代码,用Python写,刻意不依赖任何大模型SDK,把“模型调用”抽象成一个llm.chat函数,实际项目里那一层就是某个大模型API的封装。
import json def run_agent(user_input, tools, max_rounds=8): messages = [ {"role": "system", "content": "你是企业库存助手。先查询,再判断,最后行动。不要编造数据。"}, {"role": "user", "content": user_input} ] for round_index in range(max_rounds): response = llm.chat(messages=messages, tools=tools) if response.get("finish_reason") == "stop": return response["answer"] if response.get("tool_calls"): for call in response["tool_calls"]: tool_result = execute_tool(call["name"], call["arguments"]) messages.append({ "role": "tool", "tool_call_id": call["id"], "content": tool_result }) # 这一轮工具结果全部追加后,继续循环让模型根据结果做下一轮决策 continue if response.get("answer"): return response["answer"] return "已达到最大轮次,任务终止,请人工介入"execute_tool是真正执行函数的地方,我建议统一加try/except,连续失败也要把错误用JSON返回给模型,而不是让流程静默断掉:
def execute_tool(name, arguments): try: handler = TOOL_HANDLERS[name] result = handler(**arguments) return json.dumps({"ok": True, "result": result}, ensure_ascii=False) except Exception as e: return json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False)这段代码虽然短,但它包含了Agent的完整灵魂:模型提申请,程序真执行,结果回喂给模型,循环往复直到能给出最终答案。max_rounds是防死循环的第一道保险,我一般默认8轮,内部工具多的任务可以调到16轮,但不要更高,轮次一旦异常,Token消耗会非常恐怖。
3.3 工具返回要“结构化失败”,别让模型自己脑补
很多新手会犯一个错:工具执行出错,直接在代码里抛异常,导致模型拿到的上下文里没有这个错误信息,它就会基于自己的“直觉”补一个看似合理的答案。比如库存接口超时,工具返回空,模型就告诉你“库存不足”,这是完全错误的推断。
所以工具返回的内容必须包含状态字段,成功返回{"ok": true, "result": {...}},失败返回{"ok": false, "error": "库存服务超时"}。模型看到“查询失败”才会如实反馈,而不是替你做决定。这一点在Agent开发里怎么强调都不过分。
3.4 记忆机制:短期靠上下文,长期靠摘要和存储
Agent的记忆分两层。短期记忆就是上面的messages数组,模型靠它“记得”前面说过的话。但大模型上下文窗口有限,对话一久,历史消息就会撑爆或稀释重点。我的做法是设置一个阈值,比如超过20条消息后,把前面的对话扔给模型做一次摘要,浓缩成一段“进展摘要”替换掉旧消息,再塞回system里。相当于给Agent做了一个“工作日志”。
长期记忆则用来存需要跨会话保留的信息:用户偏好、历史订单、审批状态。第一版不需要上向量库,直接写进JSON文件或数据库就能满足大部分业务。等数据量大了、检索方式复杂了,再考虑向量化检索。记忆是Agent的第二个上限,短期记忆管“当前任务能不能连贯”,长期记忆管“这个Agent是不是越用越懂你”。
4. 我踩过的六个坑,和对应的排查思路
4.1 模型把函数调用和普通回答混在一起
现象:response["tool_calls"]没有值,但模型输出了一段解释后面又跟了一个残缺的JSON。排查时我先打印了模型原始响应,发现模型在“自言自语”解释为什么要查库存之后才抛出工具标记。
根因是提示词给得太啰嗦,模型拿捏不准该直接答还是该调用。解决方法是系统提示里明确写“如果你需要工具数据,只输出工具调用标记,不要输出解释文字”;同时优先选择支持“强制结构化输出”的模型接口。现在很多模型都支持response format限定,能大幅减少这种解析灾难。
验证方法:连续跑10个需要查库存的用例,看tool_calls的解析成功率是否达到100%。如果还有少数混着自然语言的,就在解析失败时补一层“重试一次,要求模型重新只输出JSON”的逻辑。
4.2 工具返回结果太长,把上下文直接撑爆
现象:查询一个商品的可用库存,后端接口返回了整整一百行的库房明细,Agent还没开始判断,Token就先烧掉一大截,有时还直接超过模型上下文上限。
根因:没有对工具返回值做裁剪,系统给什么模型就吃什么。解决方式有二:一是接口层就只返回模型判断需要的核心字段,比如总库存、最近补货日期;二是对大批量数据做截断,只返回前N条,并明确告诉模型“结果已截断,共100条,当前显示前10条”。千万别把所有数据都塞进去。
4.3 Agent陷入循环,反复调用同一个工具
现象:日志里query_stock被调用了十几遍,参数一模一样,结果也一样,但Agent还在继续问。
根因:模型没有同一条工具结果去重机制,它每轮都觉得自己“刚拿到新数据”,尤其当工具结果里没有时间戳时。排查时我在每个tool result前面加了query_time字段,循环次数仍然没降下来,最终是靠代码层做去重:如果同一轮次内出现相同函数名和相同参数,直接返回上次的结果,不再重复执行。同时给max_rounds设了更小的值。如果还循环不止,就要考虑是不是任务本身缺信息,引导模型转去问用户。
4.4 工具执行失败后,模型开始胡编结果
现象:查询接口超时,工具返回了空,结果Agent一本正经地告诉用户“该商品库存为0,建议补货”。这其实比报错还糟糕,因为用户会把这个假结果当真。
根因:工具失败时的content是空字符串,模型把它当成了合法结果并做了推断。我在所有工具出口统一包了状态字段,错误时明确告诉模型“查询失败,无法获得真实数据”,并且系统提示加了一句“没有真实工具结果时,禁止回答任何业务数据”。这之后还在代码里加了校验:如果最终回答里的数据点无法追溯到任一成功工具响应,就打回重答。
4.5 并发使用时状态互相污染
现象:同一个Agent实例同时被两个会话使用,A用户的消息跑到了B用户的上下文里,回答张冠李戴。
根因:我用了一个全局的messages数组,所有会话都往里面追加消息,并发时互相覆盖。排查时要做的第一件事就是梳理所有全局可变状态。正确做法是每个会话独立维护一份messages上下文,按session_id隔离,工具执行状态的缓存也要带上session_id前缀。之后我做了并发压测:同时开50个会话,每个会话发不同商品ID的查询,验证回答和上下文完全隔离。
4.6 异步工具调用时结果顺序错乱
现象:Agent同时发起两个工具调用,设计上是“先查库存再查价格”,但因为异步执行,价格结果先返回并按顺序拼进上下文,模型拿到后发现工具返回顺序与发起顺序不一致,产生幻觉。
根因:靠“列表顺序”对应“工具结果顺序”是脆弱的。正确做法是给每个tool call都生成唯一ID,工具执行完的结果必须带上这个ID,按ID归集到对应的调用位置,全部完成后再统一交给模型。这个模式和并发安全是同一套理念:所有状态都按ID关联,不要依赖线程执行顺序或JSON数组索引。
5. 从“跑通”到“敢上线”:进阶实操
5.1 先别搞“多Agent群聊”,用主从分工就够了
很多人在网上看到多Agent协作的演示很酷,回去就照抄,结果一堆Agent互相等、互相绕,问题没解还多花好几倍Token。我在实际项目中用的最顺的其实是简单的主从模式:一个主Agent负责理解用户意图、拆解任务,把子任务下发给几个专门的子Agent,每个子Agent返回结构化结果,最后由主Agent汇总输出。
比如一个数据分析助手,主Agent接到“分析本月销售额并生成图表”,就先拆成“查数据”和“画图表”两个子任务,分别交给不同子Agent,而不是让同一个Agent在一长串消息里反复横跳。这种结构对调试、日志追踪都友好,每个子Agent只用干一件小而清楚的事。
5.2 没有日志就别上线:每个行动都要可追溯
Agent上线前最重要的一件事,是给每一次模型决策和工具调用打日志。我的日志模板是固定字段:request_id贯穿整个会话、当前轮次、模型输入消息条数、模型决策内容是直接回答还是调用工具、工具名、参数、执行耗时、返回结果摘要、Token消耗。每天定时跑一遍日志分析,能很清楚地看到每个Agent卡在哪一步、哪些工具调用失败率最高。
没有日志的Agent跟没有监控的下线系统一样危险。模型的行为是概率性的,你今天测过的用例明天都可能翻车,日志就是事后追查的唯一线索。
5.3 上线前我必过的三道关:确认、超时、降级
第一道关是人机确认节点。凡是影响现实业务的操作,比如提交订单、发送邮件、修改数据,我都在流程里加了一个“待人工确认”状态,Agent执行到这里会把建议动作展示给用户,等确认后再执行。人在回路不是倒退,是对概率模型上的一道安全锁。
第二道关是每个工具访问必须有超时与熔断。外部接口超时设定不能超过5秒,连续失败三次直接熔断,不让Agent在死循环里重试到天荒地老。
第三道关是降级通路。Agent异常退出时,用户不能面对一个白屏,而是收到一句“当前服务繁忙,请稍后再试或联系人工客服”。降级文案是系统能力的一部分,不是可有可无的补丁。
5.4 成本优化:缓存、路由、摘要三板斧
Agent烧钱的速度我相信做过的人都懂,尤其长对话和多轮工具调用。我做降本的三个抓手:
第一是缓存。凡是工具参数完全相同的调用,短时间窗口内直接复用结果,避免模型反复触发同一查询。第二是路由。简单任务走小模型,复杂任务才走大模型,这个判断可以在入口处人工标记,也可以让一个小模型先做意图分级。第三是对话摘要。长聊天的历史记录定期压缩成摘要,别永远带着完整上下文。
这三板斧能让同样业务量的成本降到原来的三四成,效果很直观。
我个人现在开发新Agent的标准流程是:先手写最小闭环跑通,再加记忆,再上日志和监控,最后才考虑是不是引入别人封装好的框架。你上手时千万别急着把“自动化”拉到最满,哪怕技术上已经成熟,也建议每个关键决策保留人工确认链路。Agent的自主性越高,你越要清楚它的边界在哪里。