最近在重构一个Agent服务,遇到了一个很典型的问题:工具调用一多,模型的推理循环就开始发飘。一会儿调用参数不对,一会儿明明上一步结果已经拿到了,模型却绕回去重复调用同一个工具。折腾了几天,我把工具编排这块从模型循环里彻底搬了出来,放到一个独立的运行时里执行。业内管这个做法叫PTC,全称一般是Plan-Tool-Composite,也有人叫Plan-Tool-Call,核心就一句话:工具编排到底该放在模型循环里,还是放在模型外部的运行时里。
这篇文章就围绕这个问题展开。我会先拆解为什么模型循环在工具变多之后撑不住,再讲PTC运行时的设计思路,然后用一个实际例子演示怎么把一条工具链从模型循环迁到运行时,最后补充一些只有踩过坑才会知道的细节。适合正在做Agent应用、大模型应用编排,或者被function calling稳定性折磨的工程师参考。哪怕你现在刚接触这个概念,只要会写Python,跟着动手也能跑通一个最小版本。
1. 先想清楚:模型循环为什么撑不住
1.1 模型循环的日常成本
所谓模型循环,就是最常见的ReAct风格agent loop:把用户的请求发给模型,模型返回一个tool_call指令,系统执行工具,把结果以tool message的形式塞回对话,再发给模型,模型再决定下一步,直到模型认为可以给出最终答案为止。
这个模式在工具数量少、调用链短的时候完全没问题。比如用户问“北京今天适合穿什么”,模型调用一次天气工具,拿到结果就能回答。但一旦工具链变长,比如用户问“帮我查一下某只股票的最新价格和最近三条新闻,然后给我一份简单能看懂的分析报告”,整个循环就不一样了:
第一步,模型需要解析意图,想想该先查价格还是先查新闻;第二步,系统执行“查价格”工具;第三步,把价格数据塞回对话,模型继续想“哦,现在还需要查新闻”;第四步,系统执行“查新闻”工具;第五步,把新闻塞回去,模型再总结输出报告。
每一次往返,之前所有的上下文都要重新编码一遍。假设首轮上下文是1400 token,工具返回结果加上去之后第二轮变成2800 token,第三轮可能就到了4600 token。算下来,一次简单的三节点查询,模型侧累积消耗的token大概是PTC方式的三到四倍。这部分成本还不是最要命的,要命的是延迟也跟着成倍增长。模型每多推理一次,就是一次完整的网络往返,用户感受到的响应时间从400毫秒被拉到2秒、4秒,体验直线下降。
我见过最夸张的情况,是同事做的一个竞品分析机器人,工具链里挂了检索、网页抓取、数据库查询、文件解析四类工具,一次深度分析任务里循环了九次。最后一次模型已经拿到了所有数据,却在总结前又调用了一次检索工具,把之前的结果重新抓了一遍。你说它错了吧,它确实在按流程走;你说它对吧,浪费的token和时间都是实打实的成本。
1.2 三个隐藏问题
除了成本和延迟,模型循环还有三个隐藏问题,平时工具少发现不了,工具一多就全都浮出来。
第一个问题是稳定性。模型决策本质上是概率采样,同样的输入,这次决定先查价格,下次可能决定先查新闻。如果上下游工具之间有依赖关系,比如“查完价格才能计算涨跌幅”,模型一旦决策顺序错误,整个执行链就直接崩了。你当然可以通过prompt约束它,但约束得再死,也无法保证十次里有十次都走同一条路径。
第二个问题是上下文膨胀导致的注意力衰减。模型对长上下文的专注度不是线性的,塞进去的tool result越多,越靠前的关键信息就越容易被稀释。尤其是当工具返回大段JSON、长文本网页内容时,模型经常在总结时漏掉最关键的那条数据。我调试过不少case,最终结论都是:信息其实都在对话里,模型就是没看到。
第三个问题是事务性。模型循环里的每一次工具调用都是即时决策的,一旦某个中间步骤的决策出错了,你想要回滚、重试、临时插一个新步骤,都非常困难。因为整个流程的状态都藏在messages数组和模型推理上下文里,而不是一个显式的执行状态中。你无法回答这样一个简单问题:“现在执行到哪一步了?”
2. PTC运行时:把编排从“下一代”里搬出来
2.1 PTC到底是什么
PTC的思路很直接:把“工具调用链怎么走”这件事,从模型的反复推理中剥离出来,放回一个确定的、可控制的外部运行时里执行。
在这个模型里,一次请求的执行路径不再是一连串模型推理,而是一张预先定义好的执行图。图上的节点是工具调用、确定性逻辑、LLM决策点或LLM总结节点;图上的边表示节点之间的依赖关系;运行时引擎负责按照拓扑顺序调度这些节点,处理并行、失败重试、状态持久化和结果回传。模型在这里变成了执行图里的一个普通节点,而不是整个流程的“驾驶员”。
我在给团队讲这个概念时经常用一个类比:以前模型循环像是每做一步工作都要打电话给领导汇报,领导再重新翻一遍聊天记录做决定;PTC则像是先把工作流定成一张流程卡,每个节点收到输入就干活,干完把结果交给下一个节点,只有真正需要人脑判断的地方才去请示领导。领导从“每步都决策”降级成“只在关键节点决策”。
当然,这不是说完全抛弃模型循环。PTC运行时里仍然可以有LLM节点,比如用户需求模糊时让模型做工具参数抽取,或者所有工具结果都拿到之后让模型写总结。关键区别在于:模型的决策不再负责流程走向,流程走向由DAG结构决定。这样一来,确定性逻辑归运行时,开放性生成归模型,各管各的,问题边界一下就清楚了。
2.2 运行时设计的三个原则
把工具编排搬进运行时,不是简单写一个if-else调度器就完了。我实践下来,真正行之有效的设计背后有三条原则。
第一条原则叫模型最小化。能不用模型做决策的地方就不用模型。判断“价格大于100就走A分支”,这是数值比较,用代码写死,别让模型来判;判断“用户这句话里是否包含股票代码”,这是NER问题,可以用模型做参数抽取,但抽取结果的后续路由必须由运行时的确定性逻辑控制。模型只负责理解和生成,流程控制全部交给代码。
第二条原则叫可恢复性。运行时必须让每一个节点都处于可查询的状态:这个节点执行了没有、成功没有、返回了什么、重试了几次。一旦进程崩溃或者外部工具超时,新的进程可以读取状态,从失败的节点继续跑,而不是从头再来。这就要求状态不能只存在内存里,至少要落到Redis或数据库。
第三条原则叫可观测性。工具调用链越长,越需要知道每个节点花了多长时间、消耗了多少token、调用了哪个外部服务。运行时里必须有trace信息,至少要在日志里把node_id、工具名、入参、出参、耗时、重试次数打清楚。否则线上出了问题,你连“卡在哪个工具”都看不出来,就变成靠猜了。
3. 实操:把一个工具调用链从模型循环迁到运行时
3.1 从一段真实代码开始
我拿一个实际做过的功能举例:股票查询分析助手。用户输入“帮我查一下AAPL的最新价格和最近三条新闻,然后给个简单报告”。我先展示旧实现,也就是模型循环版本,大概是这样的:
messages = [{"role": "user", "content": user_input}] while True: response = llm.chat(messages) if not response.tool_calls: break for call in response.tool_calls: result = execute_tool(call.name, call.arguments) messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result) })表面上看挺爽,模型自己决定调什么、按什么顺序调。但问题就在这里,顺序和参数都不可控。我遇到过模型把symbol参数传成“Apple”而不是“AAPL”,也遇到过它先查新闻再查价格,导致下游计算“最新价格”时数据已经过时了十分钟。
换成PTC运行时之后,代码结构完全不同。我把流程定义成一张图,节点之间没有先后依赖的可以直接并行:
from ptc_runtime import PTCGraph, ToolNode, LLMNode, Edge, run graph = PTCGraph( nodes=[ ToolNode("fetch_price", tool=stock_price, timeout_s=10), ToolNode("fetch_news", tool=stock_news, timeout_s=15), LLMNode("summarize", prompt="股票报告生成"), ], edges=[ Edge("start", "fetch_price"), Edge("start", "fetch_news"), Edge("fetch_price", "summarize"), Edge("fetch_news", "summarize"), ], ) result = run(graph, payload={"symbol": "AAPL"})关键变化在于:工具调用不再是模型推理循环里的“副产品”,而是图上的确定性节点。fetch_price和fetch_news之间没有依赖关系,运行时可以并行执行这两个节点,而不是像模型循环那样一个接一个串行推理。summarize节点必须等前面两个节点都完成后才能执行,这个约束由DAG的边来表达,不需要模型来理解。
3.2 调度的核心实现
运行时最核心的部分是调度器。我这里的实现不复杂,核心是一个“就绪节点队列”加一个拓扑约束检查器。当一个节点的所有上游节点都处于completed状态时,这个节点才进入就绪队列;调度器从就绪队列里取出节点,交给执行器执行,执行完更新状态,再检查它的下游节点是否满足执行条件。
def run(self, graph, payload): state = init_state(graph, payload) while not graph.is_finished(state): ready_nodes = [ n for n in graph.nodes if state.status(n.id) == "pending" and all(state.status(up) == "completed" for up in graph.upstreams(n.id)) ] for node in ready_nodes: self.dispatch_async(node.id, state) return state.final_result()这里最值得注意的一点是:模型循环天然是串行的,但PTC运行时的图结构允许并行节点存在。上面例子里的fetch_price和fetch_news是同时执行的,总耗时取决于慢的那个,而不是两个加起来。在模型循环里,你没法简单做到这一点——模型一次只会发出一个tool_call,即使同时有多个tool_calls字段,也常有最多同时调用N个工具的限制,而且它不知道哪些可以并行。
调度器里还要处理节点执行状态机。我用的状态字段很简单:pending、running、succeeded、failed、skipped。每个节点执行前都写一条状态记录,执行完再更新。这样万一进程挂了,重启后从持久化的state里能知道哪些节点已完成,直接跳过,从失败的节点重跑。
3.3 参数怎么定
参数这块我踩了很多次坑,总结下来有三个参数必须在每个工具节点上显式配置:超时时间、重试次数、幂等标识。
超时时间不能拍脑袋。外部HTTP接口的工具,我默认设10秒;数据量较大的搜索工具,设15秒到20秒;如果有文件下载类工具,可能要到30秒以上。但不管设多少,一定要小于网关超时。不然工具还在等响应,请求已经被网关切了,状态看起来像是失败,实际外部可能已经执行成功了。
重试次数按工具类型分。读操作类工具(查价格、查新闻、查数据库)可以重试2次,因为重复读一般没副作用。写操作类工具(下单、发消息、写库)默认不重试,除非你给外部接口提供了幂等键。这里的配置我一般这么写:
| 工具类型 | 超时时间 | 重试次数 | 幂等要求 |
|---|---|---|---|
| 查询类 | 10s-20s | 2次 | 不需要 |
| 搜索类 | 15s-30s | 1次 | 尽量带上trace_id |
| 写操作类 | 5s-10s | 0次 | 必须带幂等键 |
| 文件处理类 | 30s-60s | 1次 | 建议带 |
模型节点单独处理。LLMNode的重试不是简单的重复调用,因为同样的prompt调100次可能返回100个不同结果。我的做法是:第一次超时之后,第二次调用时把超时上限往上加50%,同时在prompt里追加一句“前一次处理超时,请直接输出结果”。这么做比单纯重试稳很多。
4. 迁移过程中的常见问题与排查
4.1 坑一:状态到底存什么才安全
第一次把状态落到Redis时,我偷懒直接存了整个payload,包括上游节点返回的全部tool result。结果一个抓取网页的工具返回了400KB HTML,节点一多,状态体积直接爆炸。而且Redis里存大对象,每次序列化反序列化都有性能开销,整个恢复流程慢得不可接受。
后来我调整了策略:状态里只存每个节点的元信息和结果的引用。具体来说,每个节点的状态记录node_id、status、retry_count、started_at、finished_at、output_ref。output_ref指向一个对象存储的key,或者数据库里的一行记录。只有小体积的结果(比如几百字节的JSON)才直接内联存。
另外,节点输入的payload也要注意。很多调度器实现里,如果上游节点挂了,重新执行时需要把上游的输出重新传给下一个节点,这时候如果状态里只存了下游节点的输入引用,而没有保留下游节点“上次执行到一半”的输入快照,恢复时可能因为输入被误删而拿不到数据。我现在的做法是:一个节点只要进入running状态,就把它的输入payload一起落盘,这样即使执行过程中挂了,恢复时也能用同一份输入重跑。
4.2 幂等与超时,稍不留神就重复扣钱
PTC运行时的重试机制虽然好,但有一个钱包杀手级的问题:如果一个写操作工具执行成功了,但响应超时导致运行时认为它失败了,于是发起重试,用户就会被扣两次钱,或者收到两条下单短信。这种问题在模型循环时代也存在,但PTC运行时因为有了自动重试机制,反而更容易触发。
解法是给每一个工具节点配备operation_id。这个id在运行时启动时生成,对整张执行图是唯一的。凡是调用外部写操作接口,一律把operation_id作为幂等键传给对方。外部接口拿到同一个operation_id,即使收到两次相同请求,也只会执行一次。我在内部的标准是:不能提供幂等键的工具,一律按“不重试”处理。
排查这一类问题时,我建议先看日志里有没有同一节点出现两次succeeded状态迁移记录。真出现了,说明重试逻辑没有检查“节点是不是已经成功过”,这是典型的调度器状态判断bug。
4.3 LLM节点别当万能胶
刚开始做PTC时,我有一个思维惯性:遇到流程分支判断,就想放一个LLM节点去“智能决策”。比如价格高于100就走A工具,低于100就走B工具。这种判断用LLM做,结果非常不可控。
有一次测试,同样的输入,模型第一次判断“高于100”,第二次判断“低于100”,我查了日志才发现两次的prompt完全一样,纯粹是采样波动。从那开始我立了一个规矩:能用比较运算符解决的判断,绝对不用LLM;只有无法用确定性逻辑表达的真实语义理解任务,才交给LLM节点。
真正的LLM节点,只出现在两个位置:入口处做参数抽取,把用户的自然语言输入转成结构化的工具参数;出口处做结果综合,把一堆工具结果写成自然语言报告。中间的所有路由、分支、并行、聚合,全部用代码控制。
4.4 长任务怎么处理
工具编排一旦搬进运行时,就会出现一个模型循环时代不太明显的诉求:长任务。模型循环是同步推理,用户迟迟等不到响应会不满;PTC运行时把工具执行拆成了节点,一个节点跑3秒,五个节点串行就是15秒,很容易超过HTTP长连接的心理预期。
我的处理方案是区分同步和异步。执行路径少于3个节点、预计总耗时不超过8秒的,走同步接口,用户直接等结果;超过这个门槛的,接口立即返回一个task_id,运行时在后台继续执行,前端通过轮询或WebSocket拿结果。
异步模式下,状态持久化就显得格外重要。因为后台任务随时可能被调度系统杀掉重启,没有持久化状态,任务就直接丢了。我目前用的是数据库存状态,Redis缓存热数据,两条都写,防止单点。
5. 什么场景不该搬
5.1 别为了架构而架构
说了这么多PTC的好处,必须泼一盆冷水:不是所有场景都适合把工具编排搬出模型循环。
如果一次请求只需要调用一两个工具,且工具之间没有复杂的依赖关系,那模型循环的成本完全可以接受。比如“这个文档里含不含敏感词”这类单工具任务,你引入PTC运行时反而要维护DAG定义、状态存储、幂等逻辑,工程复杂度远超收益。我见过最离谱的案例,是有人为了让一个“图片转文字”的功能用上PTC,写了二十多行图定义,最后实际效果和直接调一次模型函数一模一样。
另外,如果你的工具链本身是高度动态的,每次请求都可能调用一组完全不同的工具组合,而且这种组合只有在执行过程中才能确定,那PTC这种预定义DAG反而绑住了手脚。这种情况下更适合保留agent loop,让模型动态规划路径。说到底,PTC适合的是“路径相对稳定、但需要可靠执行”的场景,而不是“路径完全开放、追求最大灵活性”的场景。
5.2 判断信号与替代方案
我总结了几个信号,如果你发现自己正在犹豫要不要做迁移,可以参考一下:
- 工具调用超过3个节点,且模型循环经常在中间步骤出现参数错误或顺序错误;
- 同一段工具链每天被触发很多次,token成本和延迟已经肉眼可见;
- 工具之间有明确的先后依赖或并行关系,靠模型推理来维持这种关系很吃力;
- 需要审计“某个请求到底走了哪些工具”,但当前日志里根本找不到。
如果这些信号占了两个以上,把编排搬进运行时是值得的。如果只是想让模型具备“偶尔调用一次工具”的能力,那老老实实继续用function calling就好,不要为了架构而架构。
如果不想做重型迁移,也可以先走一个折中方案:保留模型循环,但把“工具选择权”从模型手里拿回来。具体做法是,由系统先行解析用户意图,选出候选工具集,再让模型在候选集范围内填参数。这样模型还是负责调用,但选择范围被限制住了,稳定性会好不少。至少能撑到你有时间做真正的PTC改造。
最后再分享一个我自己的体会
如果你也在做Agent服务,我建议不要一上来就上重型运行时。我第一次做PTC重构时,花了整整一周搭了一个很完善的状态机,每个节点支持暂停、恢复、超时熔断、审计追踪,结果第一个业务方接入只用了其中10%的功能。
后来我学乖了:先用最小的图结构跑通一个工具链,比如就三个节点串行,把日志打全,把状态落库,然后看线上数据再逐步加功能。工具编排从模型循环搬到运行时,本质上不是一次技术升级,而是一次责任转移:把“流程可靠”的责任从概率模型手里拿回来,交到确定性代码手里。这个思路本身,比任何具体的框架和工具都重要。