
我做开源系列这些年见过不少朋友拿着成熟的 AI 提示流编排器跑 Demo跑完都会说一句很顺滑但然后呢前八篇文章把节点调度、上下文管理、模板渲染这些骨架讲完了可大家真正想要的是让大模型把事办成而不是让它多说几句漂亮话。比如用户丢来一句帮我把下载目录里的文件按类型整理好以前的编排器只能让模型回一段操作建议然后就没下文了。所以从第 9 篇开始我的重心明确转向给大模型装上手和脚在编排器里加入 Agent 节点配合一套完整的 Tools 工具调用体系。这篇文章不绕弯子直接讲清楚数据结构怎么设计、Agent 循环怎么跑、工具怎么注册和管控以及一次端到端文件整理实战的完整过程。如果你正在折腾 Agent 开发这篇应该能给你一份能直接抄作业的落地参考。1. 为什么需要手和脚从对话模型到执行体的跃迁1.1 大模型的能力边界语言很强行动为零大模型从本质上说是语言引擎它做的是概率预测输出的是下一段最合理的文本。这意味着它有三个硬伤知识截止到训练时间拿不到实时数据。问它上海现在气温多少如果训练集里根本没有今天的数据它只能凭记忆编一个数而且大概率已经过时。没有网络访问能力。帮我打开某个网址获取标题它做不到因为模型本身不持有任何网络请求通道。没有文件系统和执行环境。统计我本机某个日志文件里 error 出现的次数它根本看不到你的硬盘更别提删改文件。这是很多人在做 AI 应用时的第一课模型再聪明也只是个大脑。大脑负责思考但真正要让任务落地必须给它配肢体。我的做法就是在提示流编排器里引入两个东西Agent 节点扮演神经中枢负责调度、决策、调用Tools 工具调用体系扮演手和脚负责执行真实的操作。可能有人会说市面上不是已经有 Function Calling 了吗这确实是一条路但真正做业务落地时它撑不起复杂场景。1.2 为什么裸用 Function Calling 还不够OpenAI 和国内几家主流模型都支持函数调用模型会按我提供的参数描述输出一段结构化的调用意图。单看一次调用它确实能用。但真实业务从来不是一次调用能解决的。举个实际场景根据订单号查询物流再生成一段异常提醒。这个任务至少要两次工具调用第一次查订单对应的物流单号第二次用物流单号查轨迹。如果第一次调用返回的运单号需要作为第二次的输入谁来保存这个中间状态模型接口本身不负责这件事调用方必须自己管理。再往深一层看复杂任务还需要条件分支如果订单状态是已签收就不要再生成提醒如果遇到超时异常要不要重试这些流程控制函数调用接口天然不具备。还有工程上的问题工具统一注册谁来管多个节点要用同一个工具怎么避免重复实现工具超时、失败重试、审计日志这些系统级能力函数调用也不管。所以我的结论是函数调用只是模型输出参数的一小步真正重要的是一整套编排体系。编排器要接管循环、状态、分支、容错把模型的一次次思考变成一条可控的执行链。1.3 编排器在 Agent 生态里的三层分工我最终把编排器拆成三个层级流程编排层Flow 把一次完整任务拆成节点节点之间传参、跳转、合并都由编排器管。模型调度层Agent 节点内部完成思考-行动-观察循环调用大模型生成决策和参数。工具执行层Tools 注册表维护所有可调用工具执行前做参数校验和安全检查执行后返回标准化结果。如果把大模型比作大脑Agent 节点就是连接大脑和四肢的运动神经Tools 就是真正干活的肌肉。神经传递指令肌肉执行动作两者配合好坏直接决定这套 Agent 系统是真的能干活还是只是看起来很智能。这三层既相互独立又彼此咬合。工具层不关心你用的是 GPT 还是自家微调模型Agent 层不关心工具内部实现只管按契约调用。这样设计的好处是以后想换一个更好的模型或者加一批新工具都只需要动局部配置。2. 核心对象设计Flow、Node、ToolContract 三个主角2.1 数据模型总览做编排器之前我先把数据模型定清楚。后面所有逻辑都围绕这三个对象展开dataclass class FlowContext: 一次流程执行的全局上下文 inputs: dict # 流程入口参数 variables: dict # 节点间共享变量 execution_id: str # 每次运行的唯一ID用于审计日志 current_node: str # 当前执行到哪个节点 dataclass class BaseNode: id: str # 节点唯一ID例如 agent_main name: str # 节点显示名 node_type: str # agent / llm / tool / condition dataclass class AgentNode(BaseNode): model: str # 模型标识例如 qwen-plus system_prompt: str # Agent 的系统提示词 tools: list[str] # 允许本节点调用的工具名列表 max_iterations: int 5 # 思考-行动循环的最大轮数 dataclass class ToolContract: name: str # 工具名唯一 description: str # 工具描述给模型看的说明书 parameters: dict # 参数 JSON Schema handler: Callable # 真正执行工具的函数 timeout: int 30 # 执行超时单位秒FlowContext 好理解就是一次运行里所有节点共享的公文包。真正花了我最多时间设计的是 ToolContract它决定了模型能不能准确调用工具、调用得对不对。2.2 ToolContract给工具写一份好说明书工具名和描述是模型做决策时的核心依据。名字要短而准确描述要足够详细。我见过不少项目把工具描述写成一两句废话模型自然用错。来看我项目里的一个例子tool_registry.register( namefile_list, description( 列出指定目录下的所有文件及子目录返回每个条目的名称、类型和大小。 适用于任务开始前了解目录结构。注意不接受相对路径必须传入绝对路径。 ), parameters{ type: object, properties: { dir_path: { type: string, description: 要列出的目录绝对路径例如 /home/user/downloads } }, required: [dir_path] } ) def file_list(dir_path: str): ...一条好描述应该回答三个问题工具做什么、什么时候用、失败条件是什么。一个坏描述和好描述的差距直接影响模型的误调用率。我做过一组对比实验同样一个 file_move 工具描述从移动文件改成上面这种带场景和失败条件的描述后模型在文件整理任务里选错工具的次数下降了一半以上。2.3 节点状态与全局上下文流转编排器和调一次模型接口最本质的区别就是上下文可以在节点之间流动。在我的设计里FlowContext.variables 是全局共享存储上游节点把结果写进去下游节点读取。一个典型流程节点 A 是 tool 类型调用 http_request 查询订单详情把订单号写入 variables。节点 B 是 agent 类型它的 system_prompt 里注入订单号是 {variables.order_id}让模型基于真实订单号继续决策。工具执行完的结果同样会写回全局上下文。这样每一步的输出都可以被后续节点引用也可以被审计模块记录。因为所有中间状态都在 FlowContext 里出问题的时候我能直接导出一整条执行轨迹而不是对着黑盒猜。3. Agent 节点的内部循环从思考到行动的完整链路3.1 Agent 节点运行时状态机Agent 节点的核心是一个状态机我的实现里包含五个状态状态动作说明RECEIVE接收输入从 FlowContext 拿到上游数据和工具列表THINK调用模型把系统提示词、历史记录、工具清单发给模型ACT执行工具解析模型输出提取工具名和参数并调用OBSERVE观察结果把工具返回结果追加到对话历史FINISH结束循环模型输出结束标记或超出迭代上限整个流程是按这个顺序循环的RECEIVE 进来之后THINK - ACT - OBSERVE 不断转圈直到模型明确说任务完成或者轮数耗尽。这个循环就是大名鼎鼎的 ReAct 思想的一个具体落地。3.2 工具调用参数提取的两条路线模型思考之后必须产出一个能被编排器解析的结果。我有两条路线第一条是走模型原生的 Function Calling。在请求参数里传 tools 数组{ tools: [ { type: function, function: { name: file_move, description: 将文件从源路径移动到目标路径, parameters: { type: object, properties: { src_path: {type: string}, dest_path: {type: string} }, required: [src_path, dest_path] } } } ] }这种方式的优点是模型输出已经是结构化 JSON解析成本低。缺点是它对模型有强依赖不是所有模型都支持这么完整的 Function Calling 协议。第二条是通用文本模型也能跑的方案约定模型必须输出一个 json 代码块我再用解析器提取。像这样import json, re def extract_tool_call(text: str): match re.search(rjson\n(.*?)\n, text, re.DOTALL) if not match: return None try: return json.loads(match.group(1)) except json.JSONDecodeError: return None这条路看起来简陋但胜在通用。我公司内部有一个基于开源模型微调出来的模型不支持原生 Function Calling我用这第二种方案照样让 Agent 跑起来了。所以我的建议是优先支持方案一同时保留方案二作为降级通道。3.3 循环终止条件与最大迭代保护给模型装上手脚之后最怕的不是它不干活而是它失控。一个 Agent 如果进入死循环会一直调用工具既烧钱又危险。我的编排器设置了四道保险正常结束模型输出END标记或任务完成表示它认为目标已达成。最大迭代默认 max_iterations5超过直接停止并返回当前状态。结果收敛连续两轮观察结果完全一致且没有新的工具调用说明模型陷入了重复强制退出。异常熔断同一个工具和参数连续失败 2 次以上不再允许重复执行直接进入人工复核流程。这些保护代码量不大但价值极高。没有它们Agent 就是一个没有刹车的车。我建议这些参数全部暴露成节点配置不同任务给不同的上限。文件整理这种需要多次移动文件的场景我会把 max_iterations 调到 10 到 15而一些只查询不满意的简单场景3 次就够。4. Tools 工具调用体系的实战设计插件化、安全与控制4.1 内置工具与自定义工具的注册机制工具体系我设计成插件式核心是一个注册表任何函数只要注册进去就能被 Agent 调用class ToolRegistry: def __init__(self): self._contracts {} def register(self, contract: ToolContract): self._contracts[contract.name] contract def get(self, name: str) - ToolContract: return self._contracts[name] def call(self, name: str, params: dict) - dict: contract self.get(name) return execute_with_guard(contract, params)项目里我内置了这组工具基本覆盖了大部分常见需求工具名功能备注http_request发起 HTTP 请求返回状态码和响应体支持 GET/POST自动超时file_list列出目录内容必须传绝对路径file_read读取文本文件限定在沙箱目录内file_write写文本文件限定在沙箱目录内file_move移动文件或目录目标存在时失败不覆盖file_delete删除文件或目录单独工具受沙箱限制md5_checksum计算文件哈希用于重复文件识别calculator安全四则运算只允许数字运算符now_datetime获取当前时间解决模型时间盲区自定义工具更简单照着 2.2 节的装饰器写一个函数注册进去Agent 下轮就能用。工具体系的价值靠数量堆不出来靠的是契约清晰。4.2 工具执行器的安全边界工具调用体系里安全是头等大事。给模型装上手脚意味着它可以执行真实操作。如果没有任何防护一个提示词注入就能让 Agent 删除服务器文件。我的安全设计分四层第一层是路径沙箱。所有文件类工具在真正执行前都要做路径校验防止路径拼接越权def _validate_path(path: str, sandbox: str) - str: abs_path os.path.abspath(path) abs_sandbox os.path.abspath(sandbox) if not abs_path.startswith(abs_sandbox os.sep): raise PermissionError(f路径越界禁止访问沙箱外文件: {path}) return abs_path第二层是命令白名单。如果要有 shell 执行能力绝不能开放任意命令。我做过一个 shell_exec 工具但只允许 ls、df、du 这类只读命令而且参数数量受限。像删除、格式化、重定向这类高风险操作一律交给专门的工具函数处理这样每一步都有日志可查。第三层是网络出口限制。http_request 这类网络工具必须配置目标地址黑名单至少封掉内网地址段。否则一旦提示词被注入模型可能把请求发到内网的管理接口上。第四层是超时控制。每个工具都有独立的超时上限我用线程池做一个简单的带超时执行器from concurrent.futures import ThreadPoolExecutor, TimeoutError def execute_with_timeout(fn, timeout30): with ThreadPoolExecutor(max_workers1) as pool: future pool.submit(fn) try: return future.result(timeouttimeout) except TimeoutError: return {status: error, error: ftool timeout after {timeout}s}这四层必须全部开启缺一不可。哪怕只是自己在本地玩也建议都配上因为模型的输出是不可预测的。4.3 工具返回结果的标准化与错误处理工具执行完不是把原始返回值直接丢给模型就行必须统一包装。我定的标准结构是{ status: ok, action: file_list, params: {dir_path: /home/user/downloads}, result: 共发现 12 个文件其中图片 4 个文档 5 个……, error: null, duration_ms: 18, truncated: false }这个结构里params 和 duration_ms 是我后来加的。params 用于审计模型到底传了什么参数duration_ms 用于性能分析。真正重要的是 status 和 error 的语义当 status 为 error 时错误信息会被完整回传给模型让模型看到失败原因再决定下一步。这一点是工具体系里最容易被忽略但最值钱的机制。举个例子Agent 调用 file_move 想把文件移动到 images 目录但目标位置上已经存在同名文件handler 返回eexist: destination path already exists。模型看到这个错误后有能力自行决策要么换一个目标文件名要么先调用 file_delete 再重试要么直接告诉用户存在同名文件请求确认。这种自主纠错能力让整套系统显得非常智能但它的基础只是错误信息可读这一件事。所以我强烈建议工具的错误信息别写operation failed这种废话要写出具体原因和上下文。5. 端到端实战让 Agent 自己动手完成一次批量文件整理5.1 场景设计与工具集准备理论讲再多不如跑一个真实任务。我选了一个特别常见的场景整理下载目录。假设 /home/user/downloads 下面堆了 12 个文件包括几张 jpg、一个 zip 压缩包、几个文档还有两个重复的 PDF。任务要求是列出目录下所有文件。按扩展名分类图片进 images、文档进 docs、压缩包进 archives。用 md5 找出重复文件两个重复文件只保留体积较大的那个。最后在目录下生成一份 report.txt写明整理过程。这个场景用到的工具有 file_list、file_move、file_delete、md5_checksum、file_write全部来自内置注册表不需要写一行新代码。5.2 Flow 配置和 Agent 提示词我直接在流程配置里定义一个 Agent 节点{ id: file-organizer-flow, nodes: [ { id: agent_main, type: agent, model: gpt-4o-mini, system_prompt: 你是一个文件整理助手。你的任务是整理指定目录必须按以下步骤执行1. 先调用 file_list 查看目录内容2. 根据扩展名设计分类计划3. 使用 file_move 移动文件到对应子目录4. 使用 md5_checksum 检测重复文件保留较大者5. 调用 file_write 生成 report.txt。执行过程中如果某一步失败仔细观察错误信息并修正后重试。所有路径必须使用绝对路径。, tools: [file_list, file_move, file_delete, md5_checksum, file_write], max_iterations: 15 } ] }提示词里按步骤执行这句话很关键。模型在一堆工具面前容易自由发挥给一个显式的执行顺序能显著降低它的迷茫感。我还有一个经验不要用你可以这样做这种语气要用必须按以下步骤执行。语气越明确模型越不会发挥。5.3 运行结果与调试过程下面是其中一次运行的核心轨迹我做了格式化处理[agent_main] 开始执行 [agent_main] THINK: 需要先了解目录结构 [tool] file_list(dir_path/home/user/downloads) - ok, 12 items [agent_main] THINK: 准备按类型分类先创建三个目录 [tool] file_move(src_path/home/user/downloads/photo1.jpg, dest_path/home/user/downloads/images/photo1.jpg) - ok [tool] file_move(src_path/home/user/downloads/report_final.pdf, dest_path/home/user/downloads/docs/report_final.pdf) - ok [agent_main] THINK: 发现两个相同命名的pdf需要比对md5 [tool] md5_checksum(file_path/home/user/downloads/docs/report_final.pdf) - ok [tool] md5_checksum(file_path/home/user/downloads/docs/report_copy.pdf) - ok [agent_main] THINK: 两份文件md5一致删除其中一份 [tool] file_delete(file_path/home/user/downloads/docs/report_copy.pdf) - ok [agent_main] THINK: 生成整理报告 [tool] file_write(file_path/home/user/downloads/report.txt, content...) - ok [agent_main] 输出结束标记任务完成这次跑得挺顺畅但第一次跑的时候根本不是这样。第一次运行里Agent 试图把 images 目录的目标路径写成相对路径images/photo1.jpg结果是我的路径校验直接拒绝了。从那之后我把 file_move 的参数描述里加了一句目标路径必须是绝对路径包含完整目录前缀例如 /home/user/downloads/images。加了这句之后同样的任务连续跑三次都没有再出现相对路径问题。经验就是模型犯的运筹类错误很多时候是工具描述给的信息不够而不是模型不聪明。6. 落地上线后最该记牢的四个坑6.1 参数幻觉模型会编造不存在的参数Agent 在调用工具时偶尔会输出参数 schema 里根本没定义的字段。比如我在文件整理任务里遇到的模型调用 file_move 时传了一个overwrite: true但我的参数 schema 里根本没这个配置。如果不校验这一步就会被静默忽略模型还以为自己开了覆盖权限后续决策全部建立在幻觉上。解决办法是严格校验。在调用 handler 之前用 jsonschema 校验参数import jsonschema def validate_params(contract, params): try: jsonschema.validate(instanceparams, schemacontract.parameters) return None except jsonschema.ValidationError as e: return f参数校验失败: {e.message}一旦校验失败直接给模型返回错误信息overwrite 不是合法参数可用参数为 src_path、dest_path。模型看到这个错误下一轮通常就会修正。这一步把参数幻觉从静默错误变成了可恢复错误。6.2 工具返回过长把上下文撑爆最让我头疼的坑之一是工具结果太长。文件读取类工具尤其危险如果 Agent 好奇地读了一个几百行的日志文件再配合多轮循环上下文窗口很容易被撑爆。上下文一超模型的表现是灾难性的要么开始重复要么输出格式变畸形。我的方案是双管齐下。一方面工具结果在写回对话历史之前做截断超过 1500 字符的部分用[已截断剩余 N 字符]替代。另一方面针对超大文件提供细粒度读取工具 file_tail支持按行号和行数读取模型先用 file_tail 读取文件头部了解结构再决定是否读后续行。这样就避免了一次性把整块内容塞进上下文。6.3 循环失控模型会在错误里打转Agent 连续多次调用同一个工具、传同样的参数每次结果都一样但它还是要重试。最常见的原因是权限错误或者路径不存在。这种死不认错的 Agent 很让人崩溃。我做了三层防御工具调用指纹记录。把工具名 参数JSON哈希成一个指纹同一个指纹失败两次后该指纹直接熔断。连续无效动作检测。连续 3 轮没有产生状态变化没有工具调用成功、没有变量更新强制结束。最后兜底就是 max_iterations。设置成多少要看场景文件整理这种任务我放到 15简单查询放到 5。熔断机制上线之后我再也没见过 Agent 卡死一个接口反复重试的情况。这比单纯限制迭代次数优雅得多因为它在保护运行成本的同时保留了正常的重试空间。6.4 工具命名与描述的措辞工程工具描述这种东西看起来谁都能写但写好非常难。我的经验是工具描述是 Agent 的操作手册它的质量直接决定了模型的行为边界。命名上我倾向于动词开头加明确对象file_move、http_request 这种都比 do_action 强一百倍。描述上一定要写清楚三个点工具解决什么场景、什么时候不该用、失败条件是什么。拿 file_move 举例项目里这个工具的描述最后迭代成了这样将文件或目录从源路径移动到目标路径。用于文件分类、归档、整理目录等场景。目标路径必须位于沙箱目录内。如果 dest_path 已存在同名文件或目录直接返回错误不会覆盖。源文件移动后不再存在于原位置。加了不会覆盖之后模型就很少再试图用一个 move 来顺便覆盖掉旧文件而是会主动考虑先用 file_delete。这种措辞细节是模型实际跑出来后一点点调出来的。我的建议是每新增一个工具一定要做三组冒烟测试正常的调用、参数错误时模型的纠错、以及被明确阻止的场景下模型的行为。测试通过之前这个工具不要暴露给任何流程。这个工具调用体系我前后迭代了四五轮最后沉淀下来最重要的结论其实就一句话大模型的手脚不是越长越多越好而是每一个动作都清晰、可控、可回退。如果你也在做一个带工具的 Agent我建议先把工具契约和错误回传这两件事做扎实再回过头去调提示词。工具是手脚但神经和肌肉的配合得靠一遍遍实测磨出来。