
我刚把一个内部 Agent 系统里最伤脑筋的部分重写了一遍核心就一句话把工具编排从模型循环里搬出来放进运行时。以前我们写的多工具 Agent基本都是一个 while 循环里反复问模型下一步调哪个工具模型既当决策者又当调度员还得记住每一步的工具结果。工具少的时候没什么感觉一旦工具超过 10 个、流程超过两步或者产品要求这次调用必须可审计、可重试、可回放那种写法就撑不住了。这篇文章就聊聊我是怎么理解这个问题的以及在落地 PTCPlan-Trace-Execute这套思路时踩过的坑。如果你也在做 Agent 工具调用、Function Calling或者正在纠结要不要上工作流引擎这篇应该对你有用。1. 先说痛点每次对话都让模型临时编排到底输在哪1.1 从一个看似成功的多工具调用说起先还原一下最常见的模型循环里做工具编排的写法。早期我们做客服助手用户问一句我的订单到哪了Agent 的流程大概是模型收到用户问题决定先调用get_user_info确认用户身份拿到用户信息后模型再决定调用search_order查订单列表从订单列表里选出最近一个订单模型又决定调用get_order_status查物流拿到物流轨迹后模型把信息整理成自然语言返回给用户。表面上看每一步都成功了模型也确实调对了工具。但问题是这 4 次工具调用没有一次是必须由模型临场发挥的。一个老客服看到用户查订单物流这句话根本不需要思考 4 轮才知道该查什么。真正需要模型判断的只有两件事用户想干嘛意图以及订单号是哪个参数。这就是模型循环编排的第一个问题它逼着模型在每个轮次重新做一遍全局规划哪怕这个规划 100 次里有 95 次是一模一样的。1.2 模型循环编排的五个隐性成本我说五个最要命的都是我们在生产环境里真实遇到的成本表现为什么难忍Token 膨胀每轮决策都要把系统提示词、工具描述、历史对话重新发给模型工具描述写长一点一轮就是几千 token10 轮就是几万延迟叠加每轮一次模型推理串行叠加动辄 5-8 秒才出结果用户等不起产品经理更等不起不可复现同样的用户问题这次走 3 个工具下次走 5 个工具测试没法做断言回归变成玄学错误恢复难第 3 步工具超时了模型要从第 3 步重新决策但历史上下文里已经混入脏数据恢复逻辑只能靠模型将错就错审计困难工具调用参数散落在多个轮次的对话历史里想对账都不知道从哪开始风控要日志合规要审计直接给不出来我以前觉得这些是多工具调用的固有代价后来才发现不是。固有代价是模型一定要决策而不是每个步骤都要模型决策。工具编排属于后者也就是纯执行逻辑。执行逻辑就该放在运行时里让代码去管。这才是把工具编排从模型循环搬进运行时这句话的真正含义。2. 拆解 PTC 思路编排的责任边界到底怎么划2.1 模型循环 vs 运行时一次职责边界的搬家打个比方。模型循环编排就像一家小餐馆客人点一道红烧鱼厨师每做一步都要跑出来问客人我该去腥吗我该放酱油吗我该出锅了吗客人不仅烦而且厨师每次问完还要重新回忆菜谱菜做得又慢又不稳定。PTC 的思路更像是中央厨房客人只需要说我要红烧鱼前台把需求记下来后厨按标准作业流程执行哪一步放什么料、烧几分钟、出锅前检查什么都是固定的程序。客人不需要关心后厨每一步后厨也不需要反复问客人。对应到技术上就是模型只负责理解意图 抽取参数 判断边界这在 PTC 里叫Plan运行时负责按任务定义调度工具、管理状态、处理重试和超时这叫Trace和Execute。一句话总结模型做减法运行时做加法。模型从每步都要管退到只在决策点上出现运行时则把工具调用变成可定义、可编排、可恢复的执行流。2.2 运行时接管了什么状态、调度、恢复、审计很多朋友一听运行时就以为是又搞一套微服务框架其实没那么玄。PTC 的运行时本质上是一个执行引擎它接管四件事状态管理任务现在执行到哪一步、每个工具返回了什么、当前上下文是什么都由引擎统一持有。不再像以前那样散落在模型的对话历史里。步骤调度任务定义里可能是一个靠前一个靠后的顺序流也可能是带条件分支的流程。引擎负责按依赖关系决定谁先执行、谁可以并行、谁能跳过。失败恢复某一步超时或者返回错误引擎按预定义策略重试、降级或者终止整个任务。恢复逻辑是写死的代码不是模型的临场发挥。执行审计每一步的开始时间、结束时间、入参、出参、重试次数全部落日志。要做对账、要复盘、要给用户展示你的问题处理到了哪一步直接从运行时拉数据就行。我并不是说这些能力以前不存在。工作流引擎、状态机、消息队列早就把这套做熟了。PTC 真正新鲜的地方在于把 LLM 嵌入运行时让它成为一个按需出现的决策子模块而不是把运行时塞进模型的循环里。这个方向反过来了。2.3 PTC 里的模型还在干什么决策点收窄但更关键有人会担心把编排搬走了模型是不是就没用了恰恰相反模型在 PTC 里依然站在关键路径上只是站在更少但更重要的位置。我按经验列了几个典型决策点意图解析用户消息进来模型先判断这属于哪个任务域。比如退货还是查物流这个必须靠模型理解语义。参数抽取模型从对话里抽出订单号、商品名、日期等结构化参数塞给运行时的任务输入。异常分流运行时走到某一步发现数据不满足条件比如订单已取消没法查物流这时候需要模型生成面向用户的自然语言解释或者决定要不要走人工客服流程。动态子流程少数场景下预定义任务真的覆盖不了模型可以现场拼一个临时子流程。但临时子流程会被记录回到团队里复盘看能不能固化成正式任务。也就是说模型从每轮都做全量规划变成只在有岔路口的地方做判断。岔路口越少系统越稳定、越快模型的价值反而越聚焦、越明显。3. 落地 PTC一个可以直接抄的运行时定义方案3.1 第一步用任务定义文件取代自然语言计划既然要把工具调用流程固化到运行时里第一步就是给流程一个可解析、可版本化、可评审的定义格式。我们最初用 YAML 定义任务效果不错一个典型的查订单任务长这样id: order_query version: 1.2 steps: - id: resolve_user tool: account.resolve timeout: 2s - id: fetch_order tool: order.get_by_id depends_on: resolve_user timeout: 3s retry: max_attempts: 2 backoff: 0.5s - id: trace_shipment tool: logistics.trace depends_on: fetch_order when: order.status in [shipped, delivering] timeout: 5s - id: compose_reply type: llm.summarize input: [fetch_order, trace_shipment] prompt_template: | 用户询问订单 {order.id} 的物流进度。 订单状态{order.status} 物流轨迹{trace_result} 请用友好的语气回复用户。几个细节值得说明depends_on声明依赖关系引擎拿到整个定义后先转成一个有向无环图DAG再按拓扑序调度。没有依赖的步骤可以并行执行这块收益在工具多了以后非常明显。when是条件分支。比如订单还没发货就不需要调用物流接口直接走未发货的回复模板。有了这个我们就不用再让模型看到物流空结果后自己脑补哦原来没发货。retry是引擎层面的策略。注意我特意强调引擎层面——意味着所有任务可以用同一套重试语义而不是每个工具自己实现一遍。compose_reply是llm.summarize类型表示这一步不是调用外部工具而是调用模型做一次纯生成。在运行时里LLM 调用也只是一种工具类型享受同样的超时、重试、日志。3.2 第二步工具契约与能力注册有了任务定义还不够运行时必须知道每个工具有什么能力、能传什么参数、返回什么结构。这个工具契约是整套体系的地基。我们在工具注册表里给每个工具登记五类信息TOOLS { order.get_by_id: { description: 按订单 ID 获取订单详情, input_schema: { order_id: {type: string, required: True}, user_id: {type: string, required: True}, }, output_schema: { order_id: string, status: string, amount: number, items: array, }, timeout: 3s, idempotent: False, cost_unit: 0.01, }, logistics.trace: { description: 查询物流轨迹, input_schema: { order_id: {type: string, required: True}, }, output_schema: { tracking: array, }, timeout: 5s, idempotent: True, cost_unit: 0.02, }, }这里最难做也最容易被忽略的是idempotent幂等性。幂等字段直接决定了运行时敢不敢对这个工具做重试。一个读操作的物流查询挂了重试 3 次完全没问题但一个提交工单或者扣款接口重试一次可能就多扣一次款。后面踩坑那一节我会专门展开讲这个问题这里先记住契约里必须有幂等标记否则重试机制就是一颗定时炸弹。3.3 第三步执行引擎的三个核心模块任务定义和工具契约都准备好了接下来就是这个运行时的主心骨——执行引擎。我在实现里把它拆成三个模块Plan Loader加载任务定义 YAML解析依赖关系把步骤列表转成 DAG同时做静态校验。比如depends_on指向不存在的步骤、参数引用了未定义字段在这一步直接报错不等到运行期炸。State Manager负责记录任务实例的完整状态。每个任务实例有唯一 ID每一步的工具入参、出参、开始时间、结束时间、重试次数都写进状态存储。任务挂了可以从状态快照恢复而不是靠模型回忆之前发生了什么。Dispatcher最核心的执行循环逻辑大概是while graph.has_pending_steps(): ready_steps graph.get_ready_steps() # 依赖全部完成的步骤 # 并发执行 ready_steps每个 step 由 Dispatcher 包一层 results executor.run(ready_steps) for step, result in results.items(): if result.ok: graph.mark_success(step) elif step.retry_count step.max_attempts: graph.retry_later(step, backoffstep.backoff) else: graph.mark_failed(step) if graph.has_failed_step(): break注意 Dispatcher 里没有任何 LLM 调用它就是一个纯代码驱动的事件循环。这个循环的执行语义是可预测的同一条任务定义输入相同执行路径一定相同。这是模型循环完全做不到的但偏偏是让系统可测试、可审计的前提。4. 迁移路径把老 Agent 从模型循环搬到运行时4.1 第一步翻出日志找出那些其实不是决策的决策改造旧系统最忌讳拍脑袋。我建议不管新项目还是老项目都先做一步拉半个月的 Agent 日志把每个成功案例的工具调用序列统计一下。我们当时统计完发现客服场景里排名前 10 的工具调用序列占了全部流量的 83%。而且这 10 条序列里90% 的上下文是完全一样的。这一步的意义在于帮团队区分两类东西哪类是真正的动态决策哪类只是长得像动态决策的固定流程。用户说查订单模型选择search_order这不是决策这是条件反射。用户说帮我退款模型里跳出来退款要检查订单状态、检查是否过了退货期、走审批流程这其实是一段固定的业务规则——也不该交给模型临场发挥。把这些假决策从模型循环里摘出来就是迁移的全部功课。4.2 第二步把 ReAct 循环改成窄决策 运行时执行改造前后的代码结构差异最能说明问题。改造前是经典的 ReAct 循环messages [system_prompt] while True: response llm.complete(messages) if response.action call_tool: result execute_tool(response.tool_name, response.args) messages.append({role: tool, tool_name: response.tool_name, content: result}) continue if response.action finish: return response.answer这个循环的问题在于每一轮迭代messages都在无限膨胀而模型每轮都要从头理解一遍所有的工具描述和历史工具返回。改造后的结构是intent llm.classify(user_query, domain[order_query, refund, complaint]) if intent order_query: payload llm.extract_params(user_query, schematask_schema(order_query)) result runtime.execute(order_query, payload) return result.reply改造后模型调用从一个 while 循环里调 4 到 6 次变成固定 2 次一次意图分类、一次参数抽取。剩下所有事都交给runtime.execute。这个改动上线当天我们观测到的平均 token 消耗直接掉了 60% 多。那时候我意识到架构上一个小的职责转移带来的收益是实打实的不是概念上的自嗨。4.3 第三步渐进式迁移预留逃逸舱全量迁移风险太大我们当时的策略是按复杂度分三批第一批迁单步查询类比如查积分、查订单状态这种只涉及一个工具没有分支先把运行时流程跑通。第二批迁顺序流程类比如查订单查物流回复没有条件分支但有明确的先后依赖。第三批才迁含分支和异常处理的任务比如退款流程涉及条件判断、人工审核跳转、多工具组合。同时我强烈建议留一个逃逸舱当模型判定预定义任务都覆盖不了当前需求时允许它临时拼一个动态子流程执行。但每次动态子流程都要写日志隔周复盘一次。这样既能保住 PTC 的确定性又不会因为流程固化把长尾场景的用户体验搞死。5. 实测结果延迟、成本、成功率到底变了多少5.1 一组能直接拿去做汇报的对比数据我们客服助手场景切换 PTC 之后跑了整整四周我挑三个核心指标LLM 调用次数、端到端延迟、成功率和成本直接看表。指标改前模型循环改后PTC 运行时变化平均 LLM 调用次数/请求5.8 次2.1 次减少 64%平均端到端延迟7.2 秒2.4 秒降低 67%工具调用成功率86%97%提升 11 个百分点单请求 Token 成本基准约 40%降低约 60%人工介入率12%5%降低一大截成功率提升特别值得解释一下。以前在模型循环里工具超时或者返回异常模型要根据一堆脏上下文想办法经常想不出来就硬编一个回答告诉用户系统异常。现在运行时统一处理超时重试重试 2 次还是失败就走降级模板不再依赖模型的临场发挥失败路径的可预期性高了很多。但我要加一句免责声明这不是严格意义的 A/B 测试只是我们生产环境里的一个对照组样本偏客服场景不代表所有 Agent 场景都有同样的收益。我敢拿出来说是为了让还没动手的人有个体感这个方向值得试。真要评估你的场景拿自己的流量跑两周比什么都有说服力。5.2 别把运行时当成万能哪些场景不适合搬我也得泼一盆冷水。PTC 不是银弹有几类场景我强烈不建议硬搬开放探索型任务比如帮我写一段代码实现某某功能、给我一个营销创意。这类任务的工具调用序列高度不确定模型每一步都需要看前一步的结果才能决定下一步固化流程等于自废武功。极度依赖创造性的任务比如写文案、写故事工具的调用极不规律PTC 的收益趋近于零。工具链极短且极稳定的场景如果只有一个工具比如翻译一下这句话压根不需要编排直接一个 function call 完事。我自己判断一个场景适不适合 PTC 的尺子很简单把半年日志拉出来如果 80% 的请求模型选择的前 3 个工具序列都是一样的那就值得搬。如果每个请求的工具路径都不重样那就别折腾。6. 踩过的坑运行时编排不是银弹6.1 坑一工具契约写得不严格运行时形同虚设第一个坑出乎我意料我们花大力气做了运行时结果线上连续出了几单流程判断失效的问题。查到最后根因是某个工具返回错误时不是抛异常而是正常返回{ok: false, message: ...}。任务定义里when条件是order.status in [shipped]但工具在某些异常情况返回的status字段是缺失的条件判断直接走错分支。教训是工具契约里的output_schema必须覆盖异常路径。我们后来在契约里加了一条铁律所有工具必须返回结构化错误对象{error: {code: ORDER_NOT_FOUND, message: ...}}不能返回裸字符串更不能让正常运行和异常运行共用一套字段但语义不一致。运行时解析工具输出时先看有没有error字段有的话直接走任务定义里配置的on_error分支。这套约束补上之后运行时才真正硬起来。6.2 坑二状态存本地Pod 一重启任务全丢一开始我图省事State Manager 直接把任务状态存在进程内存里。崩溃测试一跑就现原形任务执行到第 3 步Pod 重启整个任务从第 1 步重新开始。查物流这种读操作重跑一遍没问题但退款任务重跑一遍……用户会被退两次款这真的会出大事。后来我们把状态存储迁到了带持久化的存储里任务每一步的关键快照都同步落盘。恢复时引擎拿着任务 ID 重新加载 DAG找到最后一个成功步骤往下继续。核心原则是每个步骤在推进前先标记开始成功后标记完成恢复逻辑只认完成标记不认开始标记。这样即使执行中途宕机最多重复执行开始但没完成的那一步而不是整个任务重来。6.3 坑三重试策略照搬外部扣费接口差点翻倍扣款这个坑让我对统一重试有了敬畏。当时有个步骤是调第三方退款接口我图省事直接给所有工具套了失败重试 2 次的默认策略。结果线上某个订单第一笔退款请求实际已经成功但响应超时了引擎以为失败又重试了一次用户被退了两次钱。排查下来根因就是我在工具契约里反复强调的幂等性没有落地。解决方案分两层第一层contract 里给每个工具标记idempotent: true/false非幂等写操作默认禁止自动重试第二层所有写操作强制要求透传业务侧幂等键比如退款单号第三方接口即使重复收到请求也能根据幂等键识别这是同一次操作。现在我们的引擎里重试前会做一道检查非幂等且没有幂等键直接拒绝重试转为人工处理。6.4 坑四可观测性断裂调试从看打印变成看状态快照运行时化之后Agent 不再是一段从头打到尾的对话流任务会在多个步骤之间跳转。以前出了问题打印一下 messages 历史就能看到模型干了什么现在要查一个任务为什么失败得先拿到任务 ID查状态快照、查每步时间线、查工具出入参、查是哪个条件分支判断跳错了。踩过几次坑之后我给运行时加了三样东西trace_id 贯穿一个任务实例的所有日志都带同一 ID、步骤级事件日志开始、成功、失败、重试、分支命中全部打点、状态快照回放能从任意历史阶段重建上下文。有了这三样排查效率高了一个数量级但也确实比以前的打印法多了一层心智负担。这份负担换来的是稳定性和可审计性值不值得看你的业务对可靠性有多在意。整个改造过程走下来我最大的体会是工具编排这件事本身根本不该由模型来扛。模型的优势是理解不确定的语义运行时的优势是执行确定的过程把两者放到各自擅长的位置上系统的质量上限是完全不一样的。PTC 不是什么高深理论它更像一次朴素的职责搬家。如果你正在被模型循环里的工具调用折磨不妨也试试先拉日志找固定路径再把它们搬进运行时。