大模型的能力边界大家应该都有体感:它能写文案、能总结文档、能回复消息,但真要让它“把日程写进日历”“查一下明天的天气再决定要不要约朋友爬山”,它就卡住了。不是模型不聪明,而是它没有手也没有脚——它不能帮你调用外部系统的API,不能操作你的日历,也不能替你查询实时数据。这也是我在维护开源提示流编排器时反复被问到的问题:怎么才能让流程里的大模型真正“干活”。解法就是给编排器加上Agent节点和Tools工具调用体系。这篇文章是这套开源项目的第九篇,我会从协议设计、节点运行机制、实际场景落地和踩坑经验四个角度,完整拆解一遍我们是怎么一步步给大模型装上手脚的。
1. 为什么提示流编排器非要塞进一个Agent节点
1.1 只有“生成文本”的编排器,本质上还是增强版搜索引擎
先看一个我在真实项目里遇到的例子。一开始编排器只是把“用户提问 → 组装提示词 → 调用大模型 → 输出文本”串成管道,对流式输出、上下文拼接、多轮对话支持得都还不错。可一旦用户的问题是“帮我查一下北京未来三天的天气趋势,如果周六有雨就提醒我取消订好的露营营地”,整个流程就崩了。
问题出在哪呢?编排器里只有LLM调用节点,模型只能基于训练数据给你一个泛泛的回答,它既不会去查天气API,也不会真的帮你取消营地。更尴尬的是,模型常常会用非常自信的语气说“根据我的了解,周六大机率晴朗”,然后用户信以为真,第二天被雨浇个透心凉。
这不是模型的错。所有被训练出来的大模型都活在一个“静态的世界”里,它无法感知实时动态数据,也没办法对数据库、API、文件系统执行实际动作。要让流程真正帮用户解决问题,编排器必须能调用外部工具,并且能根据模型的意图自动决定用哪个工具、传什么参数、怎么处理返回结果。
1.2 Agent节点到底承担什么职责
我们在编排器里新增了一类特殊节点,统称为Agent节点。它的职责不是说一句“好的我来帮你”,而是在一个循环里反复完成四件事:
- 理解当前目标,也就是用户到底想要什么结果;
- 判断要达成这个目标,需要调用哪个已注册的工具;
- 执行工具调用,并把工具返回的真实结果拿回来;
- 根据结果决定继续调用下一步,还是给用户一个最终答案。
这个循环的直观理解是:大模型是大脑,Agent节点是控制系统,Tools是手和脚。大脑做规划,控制系统负责把手脚的每一次动作拆解出来并按顺序执行,手脚做完之后再把感官信息反馈给大脑,继续判断下一步动作。
1.3 为什么不能把工具调用写死在各业务代码里
有同学可能会说:这个需求我在业务代码里写个if-else不就行了?用户说“查天气”我就调天气接口,用户说“写日历”我就调日历接口。这样当然能跑,但嵌在代码里的工具调度完全不可复用,换一个场景、换一套工具就要改代码,流程也无法可视化、无法在运行时动态调整参数。
编排器的优势则是把工具调用变成一套可配置、可观测的协议。我们可以在节点配置里声明“这个Agent节点自带哪些工具”,也可以在流程运行时动态注册新工具。工具的参数用JSON Schema描述,模型拿到schema就能自行决定调用方式和参数取值。这样业务逻辑和工具能力解耦,编排器本身只负责调度、生命周期、上下文维护,工具细节交给各Tool实现。
简单说,Agent节点不是某个具体功能的封装,而是一个通用执行框架。它让大模型从“只会说话”变成“会做事”,而每件具体的事,都由不同的Tools来承担。
2. 工具调用协议设计:先定义好“手和脚”的接口
2.1 设计工具体系前必须回答的四个问题
我们在动手写代码前,花了不少时间讨论协议层。核心是回答四个问题,想清楚后再写实现,后面会少很多坑。
- 模型怎么知道有哪些工具可用?
- 模型怎么表达“我想调用某个工具”?
- 工具参数怎么安全传给执行层?
- 工具执行后的结果怎么回流给模型?
这四个问题分别对应工具描述协议、调用指令协议、参数校验机制和结果回填机制。开源社区里很多Agent框架的实现各不相同,但我们最后统一选择了兼容主流大模型function calling风格的协议。好处是模型侧不用额外微调,只要模型支持function calling或tool calling,就能直接对接我们这套协议。
2.2 工具的统一描述结构
工具描述本质上就是一份结构化说明书,告诉模型:你有一个叫这个名字的工具可用,它用来做什么,它接收哪些参数,参数各自是什么含义。
我们在代码里定义了一个基础的ToolSpec结构,核心字段如下:
@dataclass class ToolSpec: name: str # 工具唯一标识,如 weather_query description: str # 描述工具能解决什么问题,触发条件是什么 parameters: dict # JSON Schema 格式的参数定义 required_roles: list # 可选,工具调用的权限范围为什么要用JSON Schema而不是自己发明一套类型系统?一是大多数大模型平台对JSON Schema都有原生支持,兼容性最好;二是参数校验可以直接复用成熟的jsonschema校验库,不用自造轮子;三是在后续做工具自动生成、测试、文档导出时,JSON Schema有非常多的现成生态。
一个最简的天气工具描述大概是这样的:
weather_spec = ToolSpec( name="weather_query", description="查询任意城市未来指定天数的天气情况,包括温度、降水概率、天气现象;当用户问到天气、温度、降水、是否适合出行时使用", parameters={ "type": "object", "properties": { "city": {"type": "string", "description": "城市名,如北京、上海"}, "days": {"type": "integer", "description": "未来天数,默认3"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius"} }, "required": ["city"] } )注意description这里千万不要写成笼统的“查天气工具”。我们后来在实测中发现,描述写得越具体,模型越容易在正确的时机触发工具。后面我会单独讲这部分经验,它直接影响整个体系好不好用。
2.3 工具注册表:让流程只依赖名字,不依赖对象
工具实体不能散落在各个Agent节点中,最好维护一个全局工具注册表。每个Tool实现一个最小接口,注册后即可被Agent节点调度:
class BaseTool(ABC): @property def spec(self) -> ToolSpec: ... @abstractmethod def run(self, **kwargs) -> str: ...run方法返回的是一个字符串。为什么我们不做成返回结构化对象呢?因为大模型调用工具时最终需要的还是文本化上下文。结构化结果可以在工具内部组装成易读文本,再返回给模型。这样做模型理解更直接,也方便在日志里排查问题。
注册表的实现可以非常简单,但核心是要能做到按需注册、运行时增删:
class ToolRegistry: def __init__(self): self._tools = {} def register(self, tool: BaseTool): self._tools[tool.spec.name] = tool def specs(self): return [tool.spec for tool in self._tools.values()] def execute(self, name: str, arguments: dict): tool = self._tools.get(name) if not tool: raise ToolNotFound(name) return tool.run(**arguments)实际项目中我们还会在注册表外层加一层:工具按租户隔离、按节点启用。比如一个编排流程中的Agent节点能看到的工具集合,和另一个节点的工具集合可能是不同的。这样可以避免模型拿到一堆不该碰的工具,减少误用风险。
2.4 工具调用的返回协议
工具执行结果回流给模型时,需要包含清晰的执行状态。我们定义了一个ToolResult结构,固定两个核心字段:
status:success还是errorcontent:给模型的纯文本结果
是不是有点过于简单?是的,但刻意保持简单。模型不关心你的Python堆栈,也不关心异常类型,它只需要知道“这个工具成功返回了这些信息”或者“这个工具失败了,错误原因是这个”。把大量内部异常堆栈直接塞给模型,反而会干扰它的决策。
这里有一个小设计值得分享:当工具执行失败时,我们会把错误信息整理成一条友好的提示,再返回给模型,让它“看到错误后自己纠正”。比如参数缺失时,返回内容就是“参数city不能为空,请提供城市名后再重试”。模型在下一轮决策时往往会修正参数,重新发起调用。这个机制在后面实测救了很多次,比直接在外部抛异常终止流程体验好得多。
3. Agent节点的运行机制:循环、上下文和终态控制
3.1 Agent节点在编排器里的位置很重要
提示流编排器本质上是一张节点图,数据从输入节点流向输出节点。Agent节点通常不直接接在用户输入之后,而是承担中间调度角色。
我习惯把它放在流程的中间偏前位置:用户输入进来后,先经过参数解析节点把有用信息提取为结构化数据,再交给Agent节点。Agent节点内部会根据目标调用多个工具,最后把工具结果汇总,再调用一次大模型生成自然语言回复,交给输出节点。
这样安排有两个好处:第一,Agent节点之前可以先做一轮信息分析,比如识别用户有没有给出城市名、日期,缺少的条件下可以提前要求用户补充,而不是让Agent在循环里反复试错;第二,Agent节点之后还可以再接普通LLM节点,对生成结果做格式化、翻译、润色,流程各司其职,可复用性更高。
3.2 循环核心:Observe-Plan-Act-Reflect
Agent节点内部并不是“一次调用就结束”,而是跑一个循环。每次循环可以理解为一次完整的“观察—规划—行动—反思”过程。
伪代码看起来是这样:
def run_agent(system_prompt, user_query, tools, max_iterations=10): messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_query}] for step in range(max_iterations): response = llm.chat(messages, tools=tools.specs()) if not response.tool_calls: return response.content for call in response.tool_calls: messages.append({ "role": "assistant", "tool_calls": [{ "id": call.id, "type": "function", "function": { "name": call.name, "arguments": json.dumps(call.arguments) } }] }) try: result = tools.execute(call.name, call.arguments) messages.append({ "role": "tool", "tool_call_id": call.id, "content": result.content }) except Exception as e: messages.append({ "role": "tool", "tool_call_id": call.id, "content": f"工具执行失败: {e},请调整参数后重试或向用户说明情况" }) raise AgentMaxIterationsReached()这个循环里有一个容易被忽略的点:当模型返回tool_call时,我们不仅要把执行结果塞给下一轮对话,还要把assistant这条含tool_calls的消息也完整追加进去。这是因为主流大模型的function calling协议要求“请求里保留前序的工具调用记录”,缺了上下文就会出现结果错位,尤其是一次并发调用多个工具的情况。
3.3 为什么必须限制最大迭代次数
新人第一次写Agent节点时往往会忘了设上限。我们第一次在内网demo时,就发生过一次神级bug:用户问“今天有什么值得关注的新闻”,我们给Agent挂了一个搜索工具,搜索结果里恰好又包含一条“如何看待明天的天气”,于是模型的next action又变成了查天气,查完天气Python工具又解析出一堆文字,又触发搜索……最终模型在一个循环里跑了二十多轮,消耗的token和新出来的废话都让人崩溃。
从那以后,max_iterations成了Agent节点的必选参数。常用的阈值是5到10轮。超过10轮还没有达成目标的,大概率是工具描述有歧义、prompt没讲清目标,或者参数一直校验不过,应该走终止逻辑,而不是放任循环烧token。
3.4 上下文体量膨胀是隐性杀手
Agent节点天然会累积多轮工具调用记录,这在长流程里是隐形杀手。一次天气查询返回500字,一次搜索返回2000字,再叠加前几次的查询记录,很快上下文就逼近模型的窗口上限。
我们在项目里做了两级控制:
第一级是数量控制:只保留最近的N轮tool_call与tool result,更早的调用结果从发送给模型的messages里剔除。这个策略对绝大多数场景够用,因为后续决策主要依赖最近的动作结果。
第二级是内容压缩:如果单次工具返回内容特别长,会对它做截断或摘要。比如搜索结果只保留标题和前300字,数据库查询只保留前20行并附上“共返回xxx条”。这样模型能看到概要,又不会撑爆上下文。
这两级控制是应对“Agent越跑越慢”的关键。如果你只做Agent框架不做上下文治理,跑三五个工具后模型响应时间可能翻好几倍。
4. 让Agent真的干成一件事:查天气并写入日程
4.1 场景设计:别做Hello World,做一个有业务味道的流程
理论说多了容易飘,还是得看实际落地。这里我分享一个我们内部跑过很多次的完整示例:用户输入“明天下午北京适合户外活动吗?帮我查一下天气,如果天气合适就在我的日程里加一条‘北京公园散步’”。
这个场景有读取类工具(天气查询),也有写入类工具(日程创建),体感上非常贴近现实业务。
我们注册两个工具:
registry.register(WeatherQueryTool()) registry.register(CalendarAddTool())天气工具我已经给过schema,日程工具类似:
calendar_spec = ToolSpec( name="calendar_add_event", description="向用户的默认日历中添加一条日程;当用户要求创建提醒、预约、日程时使用", parameters={ "type": "object", "properties": { "summary": {"type": "string", "description": "日程标题"}, "start_time": {"type": "string", "description": "开始时间 ISO8601"}, "end_time": {"type": "string", "description": "结束时间 ISO8601"} }, "required": ["summary", "start_time", "end_time"] } )4.2 流程配置:Agent节点的声明方式
我们在编排器里用一张配置描述Agent节点。简化下来大概是这个意思:
{ "id": "agent_schedule", "type": "agent", "tools": ["weather_query", "calendar_add_event"], "max_iterations": 8, "system_prompt": "你是用户的行程助手。根据用户需求查询天气,并结合天气结果决定是否创建日程。创建日程前必须向用户说明要添加的日程标题和时间。", "input": ["user_query", "structured_params"], "output": "final_reply" }注意两个设计细节:
一是system_prompt里写了“创建日程前必须向用户说明”。为什么要这么写?因为create操作是有副作用的,我们不能让模型在用户还没确认的情况下直接写入日历。实际上我们后续还加了一层“写工具需要用户确认”的能力,在Agent节点外部挂一个人工审批节点,这里为了示例简化,但在生产环境这一点很重要。
二是tools数组里只挂两个工具,而不是把整个注册表都暴露给它。工具越少,模型的决策越聚焦,误调用概率越低。
4.3 Agent的一次真实执行轨迹
下面是一次真实模拟的模型决策轨迹,为了直观我把它整理成对话步骤:
用户输入:“明天下午北京适合户外活动吗?帮我查一下天气,如果天气合适就在我的日程里加一条‘北京公园散步’。”
Agent节点第一轮: 模型返回tool_call,调用
weather_query,参数为{"city": "北京", "days": 1}。工具执行: 返回
{"date": "明天", "weather": "多云转晴", "temp": "22-28℃", "rain_probability": 0.15},并附上一句话“整体适合户外活动”。Agent节点第二轮: 模型看到天气适合,返回第二个tool_call,调用
calendar_add_event,参数为{"summary": "北京公园散步", "start_time": "明天14:00", "end_time": "明天16:00"}。日程工具执行: 返回
{"success": true, "event_id": "evt_123456"}。Agent节点第三轮: 模型不再返回tool_call,直接输出回复:“明天下午北京多云转晴,气温22到28度,降水概率低,适合户外活动。我已经帮你在明天14:00到16:00安排了‘北京公园散步’日程。”
整个过程看起来像是一次自然的多步推理,但本质上就是Agent循环把“查看工具结果”和“决定下一步动作”交替执行。这样用户得到的不再是一句空泛建议,而是一个已经落地到日历的真实提醒。
4.4 从场景中提炼三个通用经验
第一个经验:工具结果一定要翻译成“适合业务决策的语言”。天气查询如果只返回原始JSON,模型还得自己解读温度、降水概率,容易出错。我们在WeatherQueryTool里直接把结果格式化成“温度范围、天气现象、是否适合户外活动”这三条,模型后续决策就非常轻松。
第二个经验:写操作工具尽量让模型少做决策,改为“让模型生成参数,由人去确认”。日程的summary可以由模型生成,但真正写入动作要由流程决定,不能完全放权。Agent出错的代价,在读操作上最多是重查一次,在写操作上就可能导致用户日程表多出垃圾数据。
第三个经验:单次Agent流程的目标要聚焦。我们在配置里限制了“查天气并决定是否写日程”这个目标,不要让模型顺手把明天的会议安排也处理掉。它没有权限也没有必要在同一个Agent节点里无限扩展目标,更复杂的需求可以通过多个Agent节点串联实现。
5. 实测中的坑与绕过的路
5.1 模型陷入“工具调用死循环”的排查思路
这颗坑我在内测时踩得最狠。表现形式是模型不停调用同一个搜索工具,比如第一次搜索“北京天气”,第二次又搜索“天气预报 北京”,第三次搜索“北京 天气 明天”……看起来模型在努力,实际上是在原地打转。
后来排查下来,根本原因不是模型笨,而是工具返回的信息没有满足它的知识缺口。模型想要“明天会不会下雨”,工具返回的却是“晴转多云”没有降水描述,于是模型以为信息不足,继续搜索。
解决方式有两层。第一层是改进工具返回话术,把“降水概率”作为一个固定字段写进返回文本,让模型一次拿到关键信息。第二层是加一个连续性保护:同一工具连续调用次数超过阈值比如3次时,自动终止该工具调用,并把“信息不足,请根据已有信息回答或向用户询问”作为下一轮提示。这个保护机制上线后,死循环问题基本绝迹。
5.2 工具返回内容过长导致上下文爆炸
搜索类工具是重灾区。一次搜索可能返回几十条结果,每条都有标题、摘要、链接,加起来轻松超过四千字。而模型一轮只能消化一部分,后面还有别的工具要执行,上下文很快就装不下了。
我们的处理办法是给工具输出做一个标准化裁切器。搜索结果最多返回前5条,每条摘要不超过150字;数据库查询最多返回前10行,超出部分用“共返回xx条,此处省略xx条”替代;文本文件读取只读前2KB。这套裁切规则写在各Tool内部,而不是在Agent节点统一处理,因为不同工具返回语义不同,只有工具自己才知道什么内容最该保留。
5.3 模型幻觉出工具不存在的参数
某次测试中,模型调用我们日程工具时,传了一个根本不存在的参数calendar=iphone。我们当时用的schema严格校验,工具直接抛了异常。如果用旧的“遇错即停”逻辑,整个流程当场失败。
后来我们把异常返回改成“友好纠错”机制:工具收到非法参数时,不抛异常,而是返回一段结构化提示,例如“发现未支持的参数calendar,工具仅支持summary、start_time、end_time,请修正后重试”。模型看到这个提示会在下一轮自行修正参数,流程继续走下去。实测下来,纠错率大概有七成以上。剩下的三成是反复传同一个错误参数的,同样靠连续调用保护机制兜底。
5.4 写入类工具必须考虑幂等性
Agent的一大特点是执行过程中可能重试,而重试对于写操作可能是灾难。比如用户说“帮我订下周一上午十点的会议室”,Agent第一次调用预订接口时网络超时,但你不知道服务端到底有没有创建成功,于是Agent重试了一次,如果接口没有做幂等处理,用户就拿到了两个会议室。
我们的应对方法在工具层:所有写操作工具带一个可选参数request_id,Agent节点启动时生成唯一UUID,同一流程内所有写操作重试都复用这个ID。服务端通过request_id去重,已在同一ID处理过就不再重复创建。这个改动很小,但极大减少了线上“重复写入”投诉。
5.5 工具鉴权与最小权限原则
最后必须强调一下安全边界,这是最容易踩但又是最不应该踩的坑。
我们在一个演示流程里给Agent挂了一个“发送邮件”工具,结果测试用户输入“帮我给张三发一封邀请他周五来开会的邮件”,模型很快就发了,但收件人、邮件内容都没有经过用户最终确认。这在一个真实系统里是不可接受的。
现在的做法是工具分类别管理:只读工具由Agent自主调用,写操作工具必须经过人工确认节点。凡是会修改外部系统状态、产生费用、发送消息的工具,都在Agent节点输出后等待用户点击确认按钮才真正执行。同时权限规则不能只写在prompt里,必须在工具注册时做硬编码控制,比如required_roles里声明“该工具需要用户确认权限”。安全永远要靠在代码层限制住,不能指望模型自觉。
这套体系上线后,我们的编排器才真正从“一个能聊天的流程引擎”变成“一个能干活的工作台”。给大模型装上手和脚不是一蹴而就的事,从工具协议设计到节点循环控制,每一步都有取舍,也都需要拿真实场景反复打磨。如果你正在设计自己的Agent编排器,建议先从两个只读工具的小流程入手,把工具描述质量打磨到模型一次就选对,再逐步加入写工具、多Agent协作和权限控制。那样踩的坑会少很多。