ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

从零手搓AI Agent:循环、提示词与工具分发实战

从零手搓AI Agent:循环、提示词与工具分发实战 1. 从零手搓一个小 Agent为什么我不建议你直接上大框架这两年 Agent 这个词被炒得火热打开任何一个技术社区满屏都是智能体自主规划工具调用这类词。但真到动手的时候很多人第一反应是去拉一个成熟框架装一堆依赖跑通一个 Demo然后……就没有然后了。因为框架把该藏的细节全藏起来了你根本不知道一个 Agent 到底是怎么想的出了问题也不知道从哪查。我自己走过这条路。最开始也是拿现成框架搭能跑但一旦要改行为逻辑、要控制成本、要排查为什么它突然不调用工具了就抓瞎。后来我干脆花了一个周末参照一个极简 Agent 实现社区里常被拿来当教学样本的那类我这边就叫它 pi-agent 思路自己从零写了一个不到三百行的小 Agent。写完那一刻才真正理解Agent 的本质没那么玄乎它就是一个循环 提示词 工具分发的组合体。这篇东西就是那次折腾的完整复盘。我会把整体设计思路、核心模块拆解、可运行的实操步骤、以及我踩过的坑全部摊开讲。适合两类人一是想真正搞懂 Agent 内部机制、不想被框架黑盒困住的开发者二是已经会用框架、但想自己掌控每一行逻辑的中级选手。看完你应该能自己动手写出一个能跑、能调工具、能多轮对话的最小 Agent并且知道每个参数为什么这么设。2. 整体设计一个 Agent 到底由哪几块拼起来2.1 先想清楚Agent 和普通聊天机器人的分界线在哪很多人把能对话的大模型和Agent混为一谈。区别其实就一条Agent 能根据当前状态自主决定下一步动作并且这个动作可以作用于外部世界。普通聊天机器人是你问一句它答一句被动响应Agent 是给它一个目标它自己判断我现在该查资料、该算数、还是该直接回答然后执行拿到结果再判断下一步。这个判断—执行—再判断的过程落到代码上就是一个循环。循环的每一轮模型输出一个意图程序解析这个意图如果是调用工具就去调把结果塞回上下文再进入下一轮如果是直接回答就结束。听起来简单但魔鬼全在细节里意图怎么表达、工具怎么注册、结果怎么回填、什么时候该停。我参照 pi-agent 的思路把整个 Agent 拆成四个核心模块对话循环Loop、提示词模板Prompt、工具注册表Tool Registry、消息历史管理Memory。下面逐个说。2.2 为什么选循环 工具分发而不是一次性规划市面上 Agent 的架构大致分两派一派是先规划再执行让模型一次性输出完整的多步计划然后按计划走另一派是边想边做每一轮只决定下一步。我选的是后者也就是 ReAct 那一类的思路。原因很实际。一次性规划看起来优雅但模型对长链条的预判能力其实很弱计划到第三步往往就偏了而且一旦某步失败整个计划作废重规划成本高。边想边做虽然轮次多、token 消耗大一点但每一步都基于最新的真实结果做决策容错性强得多。对于一个小 Agent 来说可控性和容错性比优雅重要得多。提示如果你做的任务步骤非常固定比如固定的数据清洗流水线一次性规划反而更省 token但只要任务有不确定性边想边做几乎总是更稳。2.3 模块之间的数据流长什么样我用一段话来描述整个数据流你对照着理解后面代码会轻松很多用户输入进入 → 拼进消息历史 → 连同系统提示词一起发给模型 → 模型返回要么是普通文本、要么是工具调用请求 → 程序判断类型 → 如果是工具调用执行对应函数把返回值作为一条新消息追加到历史 → 再次发给模型 → 重复直到模型返回普通文本或达到最大轮次 → 输出给用户。这里有个关键设计点工具调用的结果必须以特定角色通常是 tool 角色回填而不是简单拼成一段文字。因为模型需要明确区分这是我请求的工具返回和这是用户说的话否则多轮之后它会混乱。这个细节很多手写 Agent 的人第一次都会踩。3. 核心模块拆解每一块怎么写才不出坑3.1 对话循环整个 Agent 的心脏循环的骨架大概是这样用 Python 伪代码示意实际语言随意def run_agent(user_input, max_turns10): messages.append({role: user, content: user_input}) for turn in range(max_turns): response call_model(messages, toolstool_schemas) msg response.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content for call in msg.tool_calls: result execute_tool(call.name, call.arguments) messages.append({ role: tool, tool_call_id: call.id, content: str(result) }) return 达到最大轮次任务未完成这段代码短但每一行都有讲究。max_turns是必须的否则模型可能陷入调工具—不满意—再调的死循环烧钱又烧时间。我一般设 8 到 12具体看任务复杂度。tool_call_id也不能省它是把工具返回和具体某次调用对应起来的钥匙多工具并行调用时尤其重要。还有一个容易忽略的点每轮都要把模型的原始返回含 tool_calls追加进历史而不是只追加文本内容。因为下一轮模型需要看到我上一轮请求了什么才能理解工具返回的是什么。3.2 提示词模板决定 Agent 聪明还是智障系统提示词是 Agent 的人格说明书写得好坏直接决定它会不会用工具、用得对不对。我踩过的最大坑就是提示词写得太客气模型经常该调工具的时候不调直接凭记忆瞎答。一个能用的系统提示词至少要说清三件事你是谁、你有什么工具、什么时候该用工具。我常用的模板结构是这样的你是一个可以调用工具的助手。你可以使用以下工具 {tool_descriptions} 规则 1. 当问题涉及实时信息、精确计算或你不确定的事实时必须调用工具不要凭记忆回答。 2. 一次只调用必要的工具拿到结果后再决定下一步。 3. 如果工具返回错误尝试换一种参数或换一个工具不要重复同样的调用。 4. 当你有足够信息回答用户时直接给出最终答案不要再调用工具。第 1 条和第 4 条是最关键的。第 1 条治该调不调第 4 条治调起来没完。我实测下来加上第 4 条之后无谓的工具调用能减少一大半。注意工具描述tool_descriptions不是随便写写。模型完全靠这段文字判断工具用途描述里必须包含这个工具做什么、参数是什么、什么时候用。写得含糊模型就会乱调。3.3 工具注册表让 Agent 的手能伸出去工具注册表的核心是一份 schema 一个函数的映射。schema 给模型看函数给程序执行。我用一个字典来管理TOOLS { get_weather: { schema: { type: function, function: { name: get_weather, description: 查询指定城市的当前天气, parameters: { type: object, properties: { city: {type: string, description: 城市名} }, required: [city] } } }, func: lambda city: real_weather_api(city) } }这里有个设计取舍schema 和函数分开存还是绑在一起。我选绑在一起因为改工具的时候不容易漏改另一半。执行时用TOOLS[name][func](**args)一行搞定。参数校验千万别省。模型给的参数经常是字符串形式的数字、或者缺字段直接传给函数会炸。我在execute_tool里加了一层 try/except把异常信息作为工具返回内容回填给模型让它自己纠正。这比程序直接崩溃友好太多。3.4 消息历史管理上下文不是越长越好消息历史是 Agent 的记忆但它也是成本大头。每一轮都要把完整历史发给模型历史越长token 越贵而且模型注意力会被稀释容易忘掉早期关键信息。我的做法是保留系统提示词 最近 N 轮完整对话 更早内容的摘要。N 一般取 6 到 10。摘要可以用模型生成也可以简单截断。对于小 Agent我甚至直接用一个滑动窗口超过就丢最早的实测对短任务够用。提示工具返回的超长内容比如一整页网页一定要截断或摘要后再回填否则一次就能把上下文撑爆。我一般限制单条工具返回不超过 2000 字符。4. 实操从零跑通一个能查天气和算数的小 Agent4.1 环境准备与依赖选择我用的环境很朴素Python 3.10一个模型 API 的 SDK加一个 HTTP 请求库。没有用任何 Agent 框架就是为了看清每一层。模型我选的是支持 function calling 的通用对话模型因为工具调用能力是 Agent 的命脉不支持 function calling 的模型得靠提示词硬凑 JSON稳定性差很多。依赖清单就三样模型 SDK、requests、python-dotenv管理密钥。装完大概十几秒。密钥放.env文件别硬编码进代码这个习惯从第一天就要养成。4.2 定义两个工具一个查天气一个算数为了演示工具分发我定义两个差异明显的工具。查天气代表外部信息获取算数代表精确计算——这两类恰好是模型最容易出错的场景也最能体现 Agent 的价值。import math def get_weather(city: str) - str: # 实际项目里换成真实天气 API fake_db {北京: 晴18度, 上海: 多云22度} return fake_db.get(city, f未找到{city}的天气数据) def calculate(expression: str) - str: try: # 只允许安全表达式 allowed {k: getattr(math, k) for k in dir(math) if not k.startswith(_)} result eval(expression, {__builtins__: {}}, allowed) return f计算结果{result} except Exception as e: return f计算失败{e}算数工具用eval有安全风险我做了两层限制清空__builtins__只暴露 math 里的函数。生产环境更稳妥的做法是用专门的表达式解析库但演示够用了。4.3 组装主循环并跑通第一个任务把前面的模块拼起来主循环大概五十行。跑一个北京天气怎么样顺便算一下 23 乘以 47的任务你会看到 Agent 先调天气工具再调算数工具最后汇总回答。整个过程两到三轮token 消耗可控。这里有个实测细节两个工具调用有时会并行返回模型一次返回多个 tool_calls有时会串行。你的循环必须两种都能处理。我一开始只处理了单个调用结果遇到并行调用直接漏掉一个排查了半天。4.4 参数计算max_turns 和温度怎么定max_turns我前面说 8 到 12具体怎么定我的经验公式是预估任务最大步数 × 1.5。比如一个任务最多需要查 3 次资料、算 2 次那就是 5 步乘 1.5 取 8。留余量是因为模型偶尔会走弯路。温度temperature对 Agent 影响很大。工具调用场景我一般设 0 到 0.3越低越稳定模型更倾向于按规则走。设高了它会发挥创意该调工具的时候跟你聊天。只有做创意类任务时才调高。参数推荐值说明max_turns8-12任务步数 × 1.5temperature0-0.3工具场景求稳单条工具返回上限2000 字符防上下文爆炸历史保留轮数6-10平衡成本与记忆5. 常见问题与排查技巧实录5.1 模型死活不调用工具怎么办这是最高频的问题。排查顺序我总结成三步先看工具描述是不是太模糊模型看不懂自然不会用再看系统提示词有没有明确必须调用的规则最后看模型本身是否支持 function calling。我遇到过描述里写获取信息这种含糊词改成查询指定城市的实时天气返回温度和天气状况之后调用率立刻上来了。5.2 工具调用陷入死循环怎么破表现是模型反复调同一个工具、传同样的参数。原因通常是工具返回了错误但模型没理解或者提示词没告诉它拿到结果就停。解法有两个一是max_turns兜底二是把错误信息写清楚让模型知道这条路走不通。我在工具返回里会明确写错误参数 city 不能为空而不是抛一个裸异常。5.3 上下文越来越长、越来越贵前面提过滑动窗口和截断。补充一个技巧把工具返回的原始 JSON 精简后再回填。比如天气 API 返回一大坨我只提取温度和天气两个字段拼成一句话回填。这样既省 token模型也更容易抓重点。5.4 常见问题速查表现象可能原因解决方向不调用工具描述模糊/提示词没要求改描述、加规则死循环无 max_turns/错误信息不清加轮次上限、明确错误上下文爆炸工具返回过长截断、摘要、精简字段参数报错模型给错类型加校验、异常回填答非所问历史混乱检查角色标记是否正确提示调试 Agent 最有效的手段是把每一轮的完整 messages 打印出来。你会直观看到模型看到了什么、想了什么问题一目了然。我调试时几乎全程开着这个日志。6. 我踩过的几个坑和一点个人体会第一个坑是把工具结果拼成普通文本回填。早期我图省事把工具返回直接拼进 assistant 的消息里结果模型分不清哪些是自己说的、哪些是工具给的多轮之后开始胡编。改成独立的 tool 角色消息后问题消失。第二个坑是忘了处理并行工具调用。模型一次返回多个 tool_calls 时我最初只取了第一个导致任务信息缺失。后来改成遍历所有调用、逐个执行、逐个回填才稳定下来。第三个坑是提示词里没写够了就停。模型拿到工具结果后有时会想要不再确认一下于是反复调用。加上信息足够时直接回答这条规则后轮次明显下降。我个人在实际操作中的体会是写 Agent 最难的从来不是代码而是把什么时候该做什么用自然语言给模型讲清楚。代码只是骨架提示词才是灵魂。你花在打磨提示词上的时间往往比写循环本身多得多。所以别急着堆功能先把一个工具、一条规则调稳再往上加。一个小而稳的 Agent价值远大于一个大而乱的。后续如果想扩展我建议按这个顺序加先加工具调用失败重试再加多轮记忆摘要最后才考虑多 Agent 协作。每一步都跑稳了再走下一步这是我折腾下来最实在的经验。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表