从零搭建可运行Agent系统:Function Calling与ReAct实战

📅 发布时间:2026/9/29 14:02:49
从零搭建可运行Agent系统:Function Calling与ReAct实战
简介这份资源是面向AI应用开发者与软件工程师的Agent系统搭建指南配套源码包帮助读者从零理解Agent的基本概念、角色分工与工程实现路径。包内共9个文件以4个Python脚本为核心分别对应研究者、编辑者、笔记记录者三类角色的功能实现另含requirements依赖清单、环境变量示例、TODO说明及项目配置类文件整体约15KB结构精简便于快速运行与二次修改。内容围绕将笔记系统从离线版升级为联机版展开新增AI搜索与报告生成能力并演示搜索工具与笔记工具的协作流程同时涉及RAG与Agent在AI应用中的定位。目前已有150人学习适合具备一定Python基础、希望动手实践Agent工作流的开发者参考可据此理解多角色协作机制并搭建自己的可运行原型。1. 从零搭一套 Agent 系统为什么“能跑起来”比“架构图好看”重要十倍很多人第一次接触 Agent 系统是从一张漂亮的架构图开始的规划器、记忆模块、工具调用、反思循环画得满满当当。但真到自己动手往往卡在第一步——环境跑不起来或者跑起来了但模型根本不按预期调工具。我见过太多团队花两周设计“完美架构”结果连一个能稳定完成“查天气→发邮件”的 Agent 都没落地。Agent 系统的本质是让大模型在一个循环里自主决定“下一步做什么”并通过工具与外部世界交互。它和普通 Chatbot 最大的区别在于Chatbot 是一问一答Agent 是“给一个目标自己拆步骤、自己调工具、自己判断是否完成”。这套东西适合谁适合已经能熟练调用大模型 API、想进一步做自动化任务编排的工程师也适合想理解 ReAct、Function Calling 底层机制的技术负责人。标题里的“可运行源码”四个字是关键。Agent 系统涉及的东西太多——提示词模板、工具注册、循环控制、异常处理、上下文管理——任何一环写错整个系统就是黑匣子你只能看到它不工作却不知道为什么。所以这篇笔记的思路是先搭一个最小可运行骨架再逐步加工具、加记忆、加多步规划每一步都能跑、能看日志、能调试。热搜词里提到的“系统提示词工程和 skill agent 有什么区别”其实会在搭建过程中自然得到答案系统提示词工程是写静态指令而 skill agent 是把技能封装成可调用单元前者是“说”后者是“做”。2. 最小 Agent 骨架用 Python 和 Function Calling 跑通第一个循环2.1 为什么选 Function Calling 而不是纯文本解析搭建 Agent 系统第一个决策是模型怎么表达“我要调哪个工具”。早期做法是让模型输出特定格式的文本比如Action: search, Input: xxx然后自己写正则解析。这种做法极其脆弱——模型多打一个空格、换个同义词解析就崩了。血泪经验是能用原生 Function Calling 就别自己造解析协议。现在主流大模型 API 都支持 Function Calling也叫 Tools你传入工具定义模型返回结构化的调用请求你执行后把结果传回去。整个循环清晰可控。下面是最小骨架不依赖任何 Agent 框架纯 Python OpenAI 兼容接口。import json from openai import OpenAI client OpenAI(base_urlhttps://api.openai.com/v1, api_keyyour-key) # 1. 定义工具模型能调用的函数清单 tools [ { type: function, function: { name: get_weather, description: 查询指定城市的当前天气, parameters: { type: object, properties: { city: {type: string, description: 城市名如北京} }, required: [city] } } } ] # 2. 工具的真实执行逻辑 def execute_tool(name, args): if name get_weather: # 这里替换成真实 API 调用 return json.dumps({city: args[city], temp: 22°C, condition: 晴}) return json.dumps({error: unknown tool}) # 3. Agent 主循环 def run_agent(user_input, max_steps5): messages [ {role: system, content: 你是一个助手可以调用工具来回答问题。}, {role: user, content: user_input} ] for step in range(max_steps): resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolstools, tool_choiceauto ) msg resp.choices[0].message messages.append(msg) # 如果没有工具调用说明模型给出了最终回答 if not msg.tool_calls: return msg.content # 执行所有工具调用 for tc in msg.tool_calls: args json.loads(tc.function.arguments) result execute_tool(tc.function.name, args) messages.append({ role: tool, tool_call_id: tc.id, content: result }) return 达到最大步数任务未完成这段代码的逻辑说明tools列表告诉模型有哪些工具可用run_agent维护一个messages列表作为对话历史每次循环把历史发给模型模型要么返回文本结束要么返回tool_calls继续。关键参数max_steps是安全阀防止模型陷入死循环无限调工具。tool_choiceauto让模型自己决定是否调工具你也可以设成required强制它必须调。2.2 工具描述怎么写才不让模型“装傻”工具能不能被正确调用八成取决于description和参数描述。我踩过的坑把工具名写成search描述写“搜索”结果模型经常在该调的时候不调。后来改成search_web_knowledge描述写“当用户问题涉及实时信息、新闻、或你不确定的事实时调用此工具搜索互联网”调用准确率明显上升。参数描述同样重要。比如city字段如果只写type: string模型可能传“北京市朝阳区”加上description: 城市名只填城市如北京、上海它就会规规矩矩传“北京”。这不是玄学是模型在根据描述做概率决策。工具描述本质上是给模型的“使用说明书”你写得越像给新人看的文档模型用得越对。还有一个细节工具返回值尽量用 JSON 字符串并且包含足够的上下文。比如查询天气返回{temp: 22°C}就不如返回{city: 北京, temp: 22°C, condition: 晴, timestamp: ...}因为模型在后续推理时需要这些信息来判断下一步。3. 给 Agent 加上记忆和规划从“单步工具调用”到“多步任务执行”3.1 短期记忆用消息列表长期记忆用向量检索最小骨架里的messages列表就是短期记忆——它记录了当前任务的所有对话和工具调用结果。但有两个问题一是长度有限任务步骤多了会超出上下文窗口二是跨会话不保留下次对话从零开始。常见做法是短期记忆保留最近 N 轮超出部分做摘要压缩长期记忆用向量数据库存历史对话和知识需要时检索回来插入上下文。下面是一个带摘要压缩的短期记忆实现思路。def compress_messages(messages, keep_recent6): 当消息过多时把旧消息摘要成一条系统消息 if len(messages) keep_recent 2: return messages old messages[1:-keep_recent] # 保留 system 和最近几条 summary_prompt 请用一段话总结以下对话的关键信息和已完成的操作\n summary_prompt \n.join([f{m[role]}: {m.get(content, )} for m in old if m.get(content)]) resp client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: summary_prompt}] ) summary resp.choices[0].message.content return [messages[0], {role: system, content: f之前对话摘要{summary}}] messages[-keep_recent:]参数keep_recent控制保留最近几条原始消息我一般设 6因为一个工具调用会产生两条消息assistant 的 tool_calls 和 tool 的结果保留 6 条意味着最近 3 次工具调用完整可见。摘要用便宜的小模型做不要用主模型否则成本翻倍。长期记忆的接入方式是在每轮对话开始前用用户输入去向量库检索最相关的几条历史拼到 system 消息里。注意检索结果要标注来源和时间否则模型会把旧信息当新信息用。3.2 用 ReAct 模式做多步规划让 Agent 自己决定下一步Function Calling 解决了“怎么调工具”但“什么时候调哪个工具、调完发现不够怎么办”需要规划能力。ReActReasoning Acting是最实用的模式让模型在每步先输出思考再决定行动。实现上不需要复杂框架在 system 提示词里加一段指令即可SYSTEM_PROMPT 你是一个任务型 Agent。对于每个用户请求按以下格式工作 1. Thought: 分析当前状态思考下一步需要什么信息或操作 2. Action: 如果需要调工具选择工具并传入参数如果信息足够直接给出最终回答 3. Observation: 工具返回的结果由系统填入 规则 - 每次只做一个 Action - 如果工具返回错误尝试换一种方式或换一个工具 - 最多执行 8 步超过则总结当前进展并告知用户 配合 Function Calling 使用时模型会在content里输出 Thought在tool_calls里输出 Action。你不需要解析 Thought它只是让模型的推理过程显式化提高后续决策质量。实测下来加了 Thought 指令后模型在“查天气→判断是否适合出行→如果不适合则查室内活动”这类多步任务上的完成率明显提升。一个容易翻车的地方模型有时会在 Thought 里说“我需要查天气”但 Action 里调了别的工具。解决办法是在工具描述里写清楚触发条件并且在 system 提示词里强调“Action 必须与 Thought 一致”。如果还是出错可以在代码里加校验解析 Thought 中的关键词和实际调用的工具名做匹配不匹配就重试一次。4. 避坑与排查Agent 系统最常见的 5 个翻车现场4.1 模型不调工具只聊天现象用户问“北京天气怎么样”模型直接编一个“今天北京晴22度”完全不调get_weather。原因工具描述不够具体或者 system 提示词没有强调“涉及实时信息必须调工具”。模型默认行为是聊天调工具是额外动作它需要足够强的信号才会做。解决在 system 提示词里加硬性规则比如“任何涉及天气、新闻、股价等实时数据的问题必须先调用对应工具禁止凭记忆回答”。同时把工具描述改成“查询实时天气当用户询问天气时必须调用此工具”。如果还不行把tool_choice设成required强制第一步必须调工具。4.2 工具调用参数格式错误现象模型传的arguments不是合法 JSON或者字段名拼错导致json.loads抛异常。原因模型在生成参数时可能多写逗号、少写引号或者把city写成City。这在用较小模型时尤其常见。解决在json.loads外面包一层 try-except解析失败时把错误信息作为 tool 结果返回给模型让它重新生成。同时可以在工具定义里把参数名写得非常明确比如city_name而不是city减少歧义。另外arguments为空字符串时也要处理直接返回错误提示。4.3 无限循环调同一个工具现象Agent 反复调用同一个工具每次参数略有不同但始终不给出最终回答直到max_steps耗尽。原因工具返回的结果没有让模型满意或者模型陷入了“再查一次可能更好”的循环。常见于搜索类工具第一次结果不理想模型就反复换关键词搜。解决在 system 提示词里加“同一个工具最多调用 3 次超过后必须基于已有信息给出回答”。同时在代码里记录每个工具的调用次数超过阈值就注入一条系统消息提醒模型。更根本的办法是优化工具返回质量让第一次结果就足够好。4.4 上下文爆炸导致响应变慢或截断现象任务执行到第 6、7 步时API 响应明显变慢或者报 context length exceeded 错误。原因每轮工具调用都会往messages里追加消息工具返回结果如果很长比如搜索返回整页 HTML上下文迅速膨胀。解决工具返回前做截断和清洗只保留关键字段。比如搜索工具只返回标题、摘要、URL不要返回全文。同时启用前面说的摘要压缩把旧消息压缩成一条。监控每轮messages的总 token 数超过模型窗口的 70% 就触发压缩。4.5 工具执行超时拖垮整个循环现象某个工具调用卡住 30 秒整个 Agent 无响应用户以为系统挂了。原因工具内部调用了外部 API网络抖动或对方服务慢没有设超时。解决所有工具执行必须加超时Python 里用concurrent.futures或asyncio.wait_for。超时后返回一个错误 JSON让模型决定是重试还是换工具。我一般设 10 秒超时超过就返回{error: timeout, tool: xxx}。模型看到错误后通常会尝试其他方式而不是死等。5. 进阶技巧用日志和回放把 Agent 变成可调试系统Agent 系统最让人头疼的是“它为什么不按我想的做”。没有日志你只能看到输入和最终输出中间发生了什么完全是黑匣子。我的习惯是每一步都记结构化日志包含 step 序号、模型原始返回、工具调用参数、工具返回结果、当前 messages 长度。这样出问题时可以逐帧回放。import logging, json logging.basicConfig(levellogging.INFO, format%(asctime)s %(message)s) def log_step(step, msg, tool_resultNone): record { step: step, role: msg.role if hasattr(msg, role) else tool, content: msg.content if hasattr(msg, content) else None, tool_calls: [ {name: tc.function.name, args: tc.function.arguments} for tc in (msg.tool_calls or []) ] if hasattr(msg, tool_calls) and msg.tool_calls else [], tool_result: tool_result } logging.info(json.dumps(record, ensure_asciiFalse))把日志输出到文件后你可以写一个回放脚本读取日志按 step 重建 messages 列表然后手动修改某一步的模型返回或工具结果看后续会怎么走。这比反复跑真实 API 快得多也便宜得多。我靠这个办法定位过一个问题模型在第三步其实已经拿到了足够信息但因为工具返回的 JSON 里有个null字段它误以为数据不完整又调了一次工具。把null改成空字符串后循环步数从 6 步降到 3 步。另一个技巧是给 Agent 加“后悔药”在关键步骤保存 messages 快照如果最终结果不对可以回滚到某一步重新执行而不是从头再来。这在调试复杂任务时特别有用。实现上就是在每步之后把messages深拷贝一份存到列表里需要时替换回去。最后说一个我自己的教训不要一开始就追求“全自动”。先让 Agent 在关键步骤暂停等人确认后再继续跑通几个真实任务后再逐步放开自动执行。这样你对它的行为边界会有清晰认知也知道哪些工具描述需要加强、哪些异常需要处理。希望帮到你。本文还有配套的精品资源点击获取