ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从Function Calling到PTC:大模型工具调用与动态工作流引擎实战

2026/9/13 22:00:16 拓冰建站 浏览量
从Function Calling到PTC:大模型工具调用与动态工作流引擎实战 做AI应用这一年多我最大的一个感受是大模型工具调用远远不只是“让模型调一个函数”这么简单。当工具数量变多、流程变复杂之后程序化工具调用PTC和动态工作流引擎才是真正把模型能力落到业务里的关键。这篇文章我打算把这两年踩过的坑和沉淀下来的架构思路一次说清楚内容包括PTC是什么、动态工作流引擎怎么设计、最小实现怎么搭以及生产环境里最常见的几个问题。适合正在做Agent、Copilot、自动化工单系统的朋友参考也适合想从单次Function Calling往复杂任务推进的团队。1. 从函数调用到PTC大模型工具调用的架构演进1.1 Function Calling 的局限一次调用的天花板早期接入大模型工具调用大家用的基本都是Function Calling。思路很简单你定义一堆JSON Schema告诉模型有哪些函数可用模型在回答时输出一个tool_calls里面包含函数名和参数应用层拿到之后去执行真实函数再把结果拼进下一轮对话。这套方案解决的是“模型如何调用外部能力”的问题但它天生只解决“一次调用”。真实业务里几乎没有单函数能搞定的需求订单查询往往要跟着物流查询物流查询可能还要跟售后退款判断数据分析更是要经历“查数、看结果、改查询、再看结果”的循环。如果只用Function Calling应用层只能自己管理这些步骤每执行完一个函数就往对话历史里塞一轮新的user message代码很快就变成一团乱麻。另一个痛点是状态完全靠外部维护。模型本身不知道自己已经走到了哪一步当前上下文中哪些变量可用、下一步该怎么走全都散落在业务代码里。一旦任务链路长了上下文被中间结果塞满token成本暴涨模型也会开始“忘记”最初的目标。我见过很多项目从单次调用升级到复杂流程时第一个反应是继续堆Function Calling把十几个工具全部暴露给模型指望它自己“临场发挥”。结果就是模型频繁调错参数、反复调用同一个工具、甚至自创一个不存在的函数名。问题不在模型而在于我们只给了它“一堆函数”却没有给它“执行流程的控制能力”。1.2 ReAct与Agent Loop从单步走向多步为了突破单次调用大家很快想到让模型边想边做于是ReAct范式流行起来。流程变成模型输出Thought思考和Action动作应用层执行Action把Observation观察结果返回模型继续思考直到输出Final Answer。ReAct的价值在于第一次把“循环”引入了工具调用。不再是一次性的函数执行而是让模型在一个循环里不断决策。现在市面上很多Agent框架的核心就是这种Loopwhile True: 模型生成动作 - 执行 - 返回结果 - 继续只是把Thought/Action包装得更抽象。但ReAct的问题也很明显循环逻辑是写死在应用层的模型只能决定“下一步调哪个工具”不能决定“整个流程长什么样”。遇到需要固定顺序处理的场景比如必须先生成订单快照、再校验库存、然后拆单你很难通过一段预设的循环表达出来遇到一个任务需要拆成多个子任务每个子任务又需要独立的循环时ReAct就更加吃力。还有一个容易被忽略的点不可编排、不可测试。ReAct每一步都依赖模型“自由发挥”流程没有显式的结构出了错只能看日志里又臭又长的对话记录。QA想写一个“订单发货中走物流查询分支”的自动化测试几乎无从下手因为模型可能这次走A路径下次走B路径流程本身不可复现。1.3 PTC把工具调用变成一道可执行程序程序化工具调用Programmatic Tool CallingPTC的核心思想是把“工具调用”从单次动作升级为“模型输出一段脚本引擎负责执行这段脚本”。模型不再只是回答“调用哪个函数”而是输出一棵结构化的执行计划包含步骤节点、参数绑定、条件分支、循环、终止条件然后由动态工作流引擎去执行。这里的关键转变是控制流从应用代码转移到了模型输出。一个PTC脚本不再是“调一下search_order就结束”而是像下面这样先查订单状态再根据状态走不同分支发货中的查物流退款中的直接回复异常情况转人工。每一步怎么走都由脚本里的next规则决定引擎只负责解释执行。这听起来有点像把模型当成编译器把自然语言翻译成中间代码。好处很直接流程变得可控、可测试、可回放。你可以在执行前校验脚本在执行中记录每一步的状态在执行后复盘整个路径。模型负责生成流程引擎负责保证流程不跑飞。维度传统Function CallingReAct/Agent LoopPTC 动态工作流引擎控制流位置外部业务代码外部循环逻辑模型生成的脚本状态管理完全外部外部维护对话历史引擎上下文容器多步编排不支持支持但不可控支持且结构化条件分支外部硬编码不显式支持脚本显式声明可测试性一般差好适用场景单点查询/操作开放式对话任务复杂流程、企业自动化2. 动态工作流引擎的设计思路2.1 为什么必须是“动态”工作流如果只是把控制流从代码挪到脚本里还远远不够。传统工作流引擎老早就有了Activiti、Temporal这些系统都能定义DAG、安排任务、做重试。问题在于它们通常要求流程图在运行前就完全确定节点和边都是静态的。可大模型场景下你根本不知道用户下一次会提出什么需求也不知道某个工具返回结果后流程要往哪走。动态工作流引擎的“动态”体现在两个地方。第一流程脚本本身由模型在运行时生成不是开发阶段写死的。第二引擎在执行过程中允许根据工具结果动态插入新节点、修改后续路径甚至让模型中途修正自己生成的脚本。我常用的一个类比是静态工作流是火车轨道提前铺好火车只能按固定线路走动态工作流是网约车导航可以根据实时路况变道、绕路、换终点。大模型工具调用天然需要后者因为工具返回的结果往往不在预期之内比如第三方接口超时、查到的数据格式不对、用户一句话里包含多个意图都需要流程现场调整。还有一个容易忽略的点动态工作流不能等于“完全放飞”。引擎仍然需要边界比如最大步数、循环次数、可调用工具白名单、参数校验规则。动态解决的是流程路径的灵活性边界解决的是失控的风险。两者缺一不可。2.2 核心抽象节点、边与上下文设计一个动态工作流引擎我的经验是先抽象好三个东西节点Node、边Edge和上下文Context。节点是执行单元。我常用的最小节点类型只有四种tool节点负责调外部工具gate节点负责纯条件判断llm节点负责调用大模型做生成或总结reply节点负责直接给用户输出结果。四种类型基本能覆盖大部分业务再多就容易把引擎搞复杂。有些场景还需要parallel节点做并行调用这个可以后期按需加不建议一开始就上。边决定流程怎么走。最简单的边是一个字符串指向下一个节点ID复杂的边是一组条件规则。比如next可以是{conditions: [{match: s1.status, op: , value: shipped, next: s3}], default: s4}。引擎每执行完一个节点都要根据当前上下文求值决定跳转目标。上下文是整个引擎的“内存”。工具执行的结果、模型的输出、中间变量都放在上下文里节点之间通过上下文传递数据。这里我强烈建议用显式的变量绑定而不是让节点直接读写全局状态。比如params里写{order_id: {user_input.order_id}}引擎负责把{user_input.order_id}解析成上下文里的实际值。这样做的好处是脚本可读、可校验不会出现某个节点悄悄改了另一个节点的数据。工程上我一般还会给上下文加两个辅助字段workflow_id和outputs。workflow_id用于追踪一次完整的执行链路方便日志聚合outputs是每个节点的执行结果缓存节点重试时不需要重新调外部工具直接取缓存即可。3. 实操从零搭建PTC 动态工作流最小实现3.1 工具层统一封装先解决工具层。PTC脚本要能通用地调用不同工具工具接口必须统一。我惯用的抽象是一个BaseTool基类包含名称、描述、参数Schema和run方法。from abc import ABC, abstractmethod from typing import Any, Dict class BaseTool(ABC): name: str description: str parameters: Dict[str, Any] {} abstractmethod async def run(self, **kwargs) - Dict[str, Any]: raise NotImplementedError具体工具实现时我会用Pydantic来做参数校验。这里特别说一句千万不要只依赖模型输出的JSON模型在参数类型、必填字段上经常翻车参数校验必须由代码兜底。from pydantic import BaseModel, Field class SearchOrderParams(BaseModel): order_id: str Field(, description订单号如 ORD20250117) phone: str Field(, description用户手机号) class SearchOrderTool(BaseTool): name search_order description 根据订单号或手机号查询订单状态返回物流信息和当前节点 parameters SearchOrderParams.model_json_schema() async def run(self, **kwargs) - Dict[str, Any]: params SearchOrderParams.model_validate(kwargs) # 这里内部去查订单中心 return { order_id: params.order_id, status: shipped, logistics: 已到达上海分拨中心, eta: 2025-01-20 }统一接口后PTC脚本里不需要关心某个工具内部怎么实现只需要写type: tool, name: search_order即可。工具注册到一个字典里引擎按名字查找TOOL_REGISTRY: Dict[str, BaseTool] { search_order: SearchOrderTool(), get_logistics: GetLogisticsTool(), create_refund_order: CreateRefundOrderTool(), }3.2 PTC脚本格式PTC脚本是模型输出的JSON也是引擎执行的中间表示。我把每一步定义为一个PTCStepfrom dataclasses import dataclass, field from typing import Any, Dict dataclass class PTCStep: id: str type: str # tool / gate / llm / reply name: str # typetool时是工具名typellm时是提示词模板名 params: Dict[str, Any] field(default_factorydict) next: Any exit # 字符串或条件字典 meta: Dict[str, Any] field(default_factorydict)一个完整的PTC脚本长这样{ steps: [ { id: s1, type: tool, name: search_order, params: {order_id: {user_input.order_id}}, next: s2 }, { id: s2, type: gate, name: order_status_gate, params: {}, next: { conditions: [ {match: s1.status, op: , value: shipped, next: s3}, {match: s1.status, op: , value: refunding, next: s4} ], default: s5 } }, { id: s3, type: tool, name: get_logistics, params: {order_id: {s1.order_id}}, next: s5 }, { id: s4, type: reply, name: refund_reply, params: {text: 您的订单正在退款处理中预计3个工作日原路退回。}, next: exit }, { id: s5, type: reply, name: final_reply, params: {text: 已经帮您查到最新进展稍后为您生成完整答复。}, next: exit } ] }这里params里的{user_input.order_id}是变量引用引擎执行时会从上下文中解析。变量引用语法我建议统一用{path.to.variable}避免跟普通字符串混淆。3.3 动态工作流引擎的核心实现引擎的职责是加载脚本、解析变量、执行节点、根据next跳转、记录执行轨迹同时做步数限制和死循环检测。一个精简版实现如下import re from typing import Any, Dict, List class DynamicWorkflowEngine: def __init__(self, tools: Dict[str, BaseTool], max_steps: int 50): self.tools tools self.max_steps max_steps self.context: Dict[str, Any] {} async def run(self, script: Dict[str, Any], initial_context: Dict[str, Any]): self.context {user_input: initial_context} step_map {step[id]: step for step in script[steps]} current script[steps][0][id] visited [] for _ in range(self.max_steps): if current exit: return self.context step step_map[current] visited.append(step[id]) if self._has_loop(visited): raise WorkflowLoopError(f检测到重复执行: {visited}) if step[type] tool: resolved_params self._resolve_params(step.get(params, {})) tool self.tools[step[name]] result await tool.run(**resolved_params) self.context[step[id]] result current self._resolve_next(step.get(next), step[id]) elif step[type] gate: current self._resolve_next(step.get(next), step[id]) elif step[type] reply: text step[params].get(text, ) resolved_text self._resolve_template(text) self.context[reply] resolved_text return self.context else: raise WorkflowError(f未知节点类型: {step[type]}) raise WorkflowTimeoutError(f超过最大步数 {self.max_steps})变量解析和条件跳转是两个核心函数。变量解析要支持两层逻辑如果整个字符串就是一个变量引用则取原始值如果变量引用嵌在普通文本里则做字符串插值。def _resolve_params(self, params: Dict[str, Any]) - Dict[str, Any]: resolved {} for key, value in params.items(): if isinstance(value, str): resolved[key] self._resolve_template(value) else: resolved[key] value return resolved def _resolve_template(self, template: str) - Any: matches re.findall(r\{(.?)\}, template) if not matches: return template if template.strip() { matches[0] }: return self._get_path(matches[0]) result template for m in matches: result result.replace({ m }, str(self._get_path(m))) return result def _get_path(self, path: str) - Any: obj: Any self.context for key in path.split(.): if isinstance(obj, dict): obj obj.get(key) else: return None return obj条件求值沿用同一个上下文只支持、!、in、contains等常见操作符够用就好不要一上来就引入复杂表达式引擎。def _resolve_next(self, rule: Any, current: str) - str: if rule exit: return exit if isinstance(rule, str): return rule if isinstance(rule, dict): for cond in rule.get(conditions, []): left self._get_path(cond[match]) right cond[value] if self._compare(left, cond[op], right): return cond[next] return rule.get(default, exit) raise WorkflowError(f非法的 next 规则: {rule}) def _compare(self, left: Any, op: str, right: Any) - bool: if op : return left right if op !: return left ! right if op in: return left in right if op contains: return right in left return False死循环检测我用了一个简单办法记录每一步的ID如果同一个节点连续执行超过N次直接抛异常。真实场景里循环是必要的比如模型反复修正SQL但循环次数必须受限否则一旦模型抽风会产生大量外部请求和token消耗。3.4 让大模型生成PTC脚本引擎就绪后最关键的一步是让模型输出合法、可执行的PTC脚本。我的做法是给模型一个编排器角色明确告诉它可用工具、字段格式、类型限制和跳转规则并要求输出JSON。你是一个工作流编排器。根据用户目标和可用工具输出一个PTC脚本。 可用工具 search_order: 查询订单状态。参数order_id, phone。返回 status, logistics, eta get_logistics: 查询物流轨迹。参数order_id create_refund_order: 创建退款单。参数order_id, reason PTC脚本格式要求 1. 必须是一个JSON对象包含 steps 数组 2. 每一步必须包含 id, type, name, params, next 3. type 只能是 tool / gate / reply 4. tool 的 name 必须是上述工具名不能编造 5. gate 节点的 next 是一个对象结构为 {conditions: [...], default: ...} 6. 如果信息不足直接用 reply 节点告诉用户需要补充的信息 7. 输出结果只包含JSON不要包含其他解释实际请求大模型时我会开启JSON模式或结构化输出并且把temperature设为0否则模型偶尔会在JSON外面包一层Markdown代码块解析容易出错。max_tokens也要给足PTC脚本动辄几百上千token截断会导致JSON不完整。模型输出脚本后还需要做两步后置校验第一步是JSON Parse第二步是Schema校验检查节点类型是否合法、工具是否在白名单内、next指向的节点是否存在。校验不通过时有两种处理方式一种是直接返回给模型把校验错误信息拼进下一轮让模型自行修复另一种是走兜底回复。生产环境我推荐两者结合重试一次还不行就走兜底避免无限循环。4. 生产案例三种典型场景拆解4.1 智能客服工单门控分支与多系统流转第一个案例是客服工单系统。用户发来一条消息“我的订单ORD20250117显示已经支付了为什么还没发货”如果只用Function Calling应用层只能“查询订单”然后把结果丢给模型去生成回答。但实际业务需要根据订单状态走不同的处理链路已发货的查物流、未发货的催发货、退款中的查退款进度、异常状态的转人工。用PTC后模型先调用search_order拿到订单的status然后通过gate节点判断分支。已发货就继续调用get_logistics退款中就直接复用SOP文案回复异常状态则调用人工客服创建接口把工单派给对应团队。整条链路是模型动态生成的但每个分支的执行规则清晰可见运营团队可以在后台看到每一次工单处理走了哪条路径。这个场景给我最大的启发是分支判断不要交给模型用自然语言做而要交给gate节点用代码做。订单状态是确定的枚举值用判断比让模型读一遍结果再“自由发挥”稳得多。模型只负责决定“要不要判断”和“判断后走哪条路”实际的判断执行由引擎完成准确率接近100%。4.2 数据分析报告循环修正与断言第二个案例是数据分析。用户问“分析一下上周销售数据看看哪个品类跌得最凶”。这个任务不是一次查询能搞定的通常要先跑SQL看结果如果结果为空或者字段对不上还要修改SQL重新跑。如果用ReAct模型每一步都在“想”中间任何一次幻觉都会带偏方向。PTC脚本会把这个过程显式表达出来先执行query_sales_data再用gate检查结果是否为空为空就调用llm_fix_sql节点让模型修正查询条件修正后回到query_sales_data重新执行。如果重试超过两次还查不到数据就进入reply节点告诉用户“暂时无法查询”。这里有个实践细节llm_fix_sql节点本质上是“让模型基于当前错误信息生成新的SQL”它也是工具调用只是工具是模型自己。动态工作流引擎不需要区分“外部API”和“模型生成”都当成节点执行即可。这样做的好处是整个修正循环的步数限制、日志记录、重试策略完全复用同一套机制。4.3 多智能体任务规划动态扩展子任务第三个案例更贴合Agent方向。用户说“帮我准备下周的项目周报”底层需要拉取需求列表、缺陷列表、工时统计、上次周报几个数据源。这些数据源并不是每个项目都存在如果用户某个项目没有接入工时系统这个数据源就不应该被调用。模型在生成PTC脚本时可以先生成一个“探测”步骤调用check_project_data_source根据返回结果动态决定后续要加入哪些数据采集节点。比如探测发现项目A没有工时系统脚本里就跳过fetch_hours节点直接进入需求与缺陷采集。这种“先探测再展开”的写法是动态工作流引擎比静态DAG更强的地方静态DAG在开发时就把所有节点定义好了而PTC脚本是模型运行时动态生成的天然支持根据结果扩展子任务。实际跑下来这种多数据源采集场景的并行度很重要。PTC脚本里可以增加parallel节点把互不依赖的数据采集步骤放到同一个并行组里引擎用asyncio.gather并发执行整体耗时能从十几秒降到三四秒。先串行跑通再上并行不要一上来就并行否则变量依赖会把你绕晕。5. 生产落地避坑指南我踩过的5个坑5.1 工具白名单与幻觉拒绝模型在生成PTC脚本时偶尔会编造一个不存在的工具名。有时候是拼写错误比如把search_order写成search_order_info有时候是完全虚构比如get_user_credit_score。如果引擎直接把这类名字拿去工具注册表里查会抛KeyError用户看到的效果就是“机器人突然报错”。我的做法是执行前做白名单校验发现未注册工具时把错误信息当作提示词的一部分回传给模型让它重新生成脚本。同时要把available_tools完整列表塞进提示词并且明确要求“只能使用上述工具名”。即便如此模型还是可能犯错所以白名单校验是必须的不能指望提示词100%生效。5.2 参数绑定与Schema不一致模型输出的参数经常与工具Schema不一致典型的有三种把必填参数漏了、把数字类型传成字符串、把订单号字段名写错。Parameter校验必须在tool.run之前做我用Pydantic统一处理校验失败时返回结构化错误信息让流程走重试或兜底。这里有一个容易忽略的变量绑定问题当params里的值来自上一个节点时模型经常写错变量路径。比如上一个节点的输出存在上下文{s1: {order_id: 123}}模型可能会写{s1.orderId}。我在校验阶段会额外扫描所有变量引用逐个检查路径是否能在上下文中解析解析不到直接报错并回传模型修复避免执行到一半才暴露问题。5.3 死循环与资源爆炸动态工作流最大的风险是失控。模型让某个节点反复执行或者gate条件永远不满足导致一直跳转都会把外部API调用次数和token消耗瞬间拉高。我在引擎里做了三层防护第一层是max_steps总步数限制硬性的第二层是单节点连续执行次数限制比如同一个tool节点最多执行3次第三层是外部工具调用级别的超时和并发限制避免一个用户请求拖垮下游系统。排查循环问题的时候光看最终报错是不够的一定要把每次跳转的路径打出来。我习惯为每个workflow生成一个trace内容类似[s1 - s2 - s3 - s2 - s3 - s2]出问题后一眼就能看出循环点在哪。5.4 上下文污染与Token成本把工具返回的完整结果塞回上下文是我见过最常见的浪费。一个订单查询可能返回几百字节一个日志查询可能返回几十KB全塞回去的话模型下一轮根本处理不过来token成本也高得吓人。我的经验是上下文里只保存结构化、摘要化的关键字段。工具返回原始数据后引擎可以自动做一层裁剪比如只保留status、order_id、eta把冗长的物流轨迹文本截断到200字。对于需要后续步骤使用的数据再单独存到context里供变量引用不要依赖模型从对话历史中“回忆”。初始输入类的大文本也应该单独放不参与每轮token计算等模型真正需要某个字段时才通过变量引用读取。5.5 可观测性没有日志就没法迭代PTC脚本一旦跑起来涉及的环节很多模型生成脚本、脚本校验、工具执行、条件跳转、异常重试每一步都可能出问题。没有结构化日志排查问题基本靠猜。我给引擎加了一个统一的执行日志每条日志包含workflow_id、step_id、step_type、node_name、输入参数摘要、输出结果摘要、耗时和token消耗。出问题时先按workflow_id拉全链路日志看模型生成了什么脚本脚本在哪一步跳出了预期路径就能快速定位是模型的问题还是工具的问题。这里整理一个速查表是我在实际排障时最常用的现象可能原因排查/解决建议模型调用不存在的工具提示词工具列表不完整、模型幻觉白名单校验错误回传模型重写参数类型报错Pydantic校验过严或模型输出错误校验错误结构化返回允许重试任务重复执行相同节点死循环、gate条件判断错误检查trace加单节点连续执行次数限制token消耗异常高中间结果全量塞入上下文结果裁剪只保留摘要字段分支走向不符合预期_get_path变量路径错误打印gate判断时的左值与右值最后聊几句动态工作流引擎本身并不复杂核心代码可能只有几百行真正复杂的是围绕它的配套机制工具标准化、脚本校验、变量解析、日志追踪、安全边界、成本控制。我和团队在做第一版PTC引擎时花了将近一半的精力处理“模型不按规矩来”的情况后来才明白PTC的价值恰恰不是让模型更自由而是让模型在可控的约束里生成流程把不可预测的部分锁在引擎边界之内。如果要从零开始我建议先别追求大而全。挑一个你最常用的业务场景定义三到五个工具让模型生成带一个条件分支的PTC脚本用最简单的循环引擎跑通。跑通之后再加循环、加并行、加动态节点扩展一步一步迭代。等你把日志和校验体系补上就会感受到这套架构带来的稳定性和安全感。