1. 先交代背景:DeepSeek Harness 和这个系列在聊什么
DeepSeek Harness 是一个围绕 DeepSeek 模型做工作流编排的框架式插件体系,它把模型调用、工具注册、任务编排、上下文托管这些琐碎的事情收敛到一个统一层里。你可以在里面注册自定义 skill、配置工具链、跑多步 agent 任务,它负责处理模型和外部环境之间那些重复且烦人的协议细节。“拆开 DeepSeek Harness”这个系列,每一期就是挑一个内部机制拿出来拆开揉碎讲清楚,这期到了第 08 期,主角是PTC(Program-based Tool Composition),直白点说就是“把多次工具调用写成一段程序”。
先声明一下,这篇不是 DeepSeek Harness 的安装教程,也不是 API 文档的搬运。我默认你已经对 agent 的基本概念(模型、工具调用、上下文窗口)有大概印象,重点放在 PTC 这个模式本身:它到底改变了什么、为什么值得关注、怎么在自己的项目里落地,以及我实际集成过程中踩过的坑。不管你是想直接抄方案,还是想理解 agent 工作流的设计思路,这篇应该都能提供一些参考。
之所以值得单独写一期,是因为 PTC 不是一个小优化,它改变的是 agent 执行工具调用的“节奏”。早年大家习惯了模型一步一步想、一步一步调用工具的老打法,而 PTC 把“多次调用”打包成一个可执行程序,让 agent 从“对话式决策”切换到“程序化执行”。这个切换带来的影响,比表面上看起来要大得多,下面我展开讲。
2. 传统工具调用的痛点:一步一停的老打法
2.1 ReAct 模式的基本形态
要理解 PTC 的价值,得先回头看传统的工具调用是怎么跑的。目前绝大多数 agent 框架(包括早期的 LangChain、AutoGPT 那一代产品)采用的基本都是 ReAct 模式:模型先推理,决定调用哪个工具,然后停下等待工具返回结果,再根据结果继续推理,再调用下一个工具……整个过程是一个严格的循环。
画成伪代码大概是这个样子:
# 传统 ReAct 式工具调用的核心循环 while not task_finished: # 1. 把当前对话历史(包括之前所有工具结果)发给模型 response = llm.chat(messages, tools=available_tools) # 2. 如果模型决定调用工具 if response.has_tool_call: tool_name = response.tool_call.name tool_args = response.tool_call.arguments # 3. 执行工具,拿到结果 result = execute_tool(tool_name, tool_args) # 4. 把结果追加到对话历史 messages.append(tool_result_message(tool_name, result)) else: # 模型认为任务完成,直接输出最终回复 return response.content这套逻辑本身没错,早期很多 demo 都是这样跑通的。我第一次用 LangGraph 搭工具调用链的时候,也是先照这个模板写:模型需要查数据库就调 database 工具,需要发请求就调 http 工具,结果返回来再继续让模型判断下一步。单看一两轮调用,体验还挺流畅的。
2.2 三个绕不开的痛点
但一旦任务复杂起来,问题就藏不住了。我梳理了三个最核心的痛点,都是实际跑过之后才有的体感。
第一个痛点是轮次太多,延迟高得离谱。每调用一次工具,就是一次完整的模型往返。一个需要调用 5 次工具的任务,就得等 5 轮完整推理,而每轮推理都要把越来越长的历史记录塞进上下文。我测试过一个多步骤的数据分析任务,模型要连续查表、聚合、再查一次明细,最后写报告,整个过程跑了将近两分钟。中间绝大多数时间浪费在“上下文重新编码 + 模型等待”上,真正的计算反而没多少。
第二个痛点是上下文窗口被工具结果撑爆。这是最让我头疼的地方。每执行一次工具,返回结果都要追加到 messages 里,下一轮模型才能看到。如果工具返回的是大段 JSON、日志或者全文,几轮下来上下文就膨胀得不成样子。我的一个真实案例里,三轮工具调用之后,光历史记录就占了三万多 token,真正有价值的中间信息被淹没在噪声里。你不得不频繁做截断或者摘要,而截断之后模型又可能丢失关键信息,陷入“决策质量下降”的死循环。
第三个痛点是整个流程极其脆弱。一旦某一步工具调用返回了异常格式,或者网络抖动导致超时,整个循环就可能崩掉。模型面对报错信息常常不知所措,要么反复重试同一个调用,要么直接放弃任务。我在生产环境里测试过,一个 4 步的工具链,在模拟 10% 工具失败率的情况下,端到端成功率只有七成左右。这个数字意味着,稍微复杂一点的任务根本不具备可靠性。
这些痛点不是个别框架的问题,而是“对话式决策”这个底层模式带来的天然瓶颈。每个动作都要经过模型心智,每步都要等模型慢悠悠地决定,整个系统就像一个每件事都要请示老板才能干活的公司,效率自然上不去。
3. PTC 的核心思路:把多次工具调用写成一段程序
3.1 从“逐步指挥”到“一次下发”
PTC 的思路刚好相反:不再让模型每一步都决策,而是让模型一次性写出一小段“程序”,把多次工具调用的顺序、条件分支、循环、参数传递全部声明清楚,然后由一个轻量级的执行器去跑这段程序。模型只负责“写代码”,不负责“逐行指挥”。
用一个生活化的比喻来理解。传统的 ReAct 模式,就像你打电话给助理,说一句“帮我查一下明天的天气”,助理查完告诉你,你再接着吩咐“那顺便帮我看看要不要带伞”,助理再查再汇报……每一步都要来回沟通。而 PTC 模式,是你一次性给助理下达一份完整指令:“先查明天早上 8 点到 10 点是否有雨,如果有雨就带伞并顺便查一下地铁是否拥挤,如果没雨就直接回复不需要”。助理拿到这份指令后,自己去执行每一步,最后直接把结果交给你。
这个比喻里,“完整指令”就是模型生成的那段程序,“助理执行”就是执行器在跑这段程序。整个过程模型只需要参与两次:一次是“理解任务并写程序”,一次是“拿到程序执行结果后写最终回复”。中间的多次工具调用,完全不需要模型介入。
3.2 PTC 的完整定义和边界
那么 PTC 具体长什么样?我给它一个比较严谨的定义:PTC 是让大语言模型生成一段结构化、可执行、能编排多次工具调用的程序描述(或 DSL),由独立执行器按程序逻辑完成工具调用,并将最终结果返回给模型。
这里有几个关键边界要划清楚:
- 模型生成的是“程序描述”,不是直接执行代码。严格意义上的 PTC 通常生成的是受限的 DSL(领域特定语言),而不是让模型输出 Python 代码然后直接 exec。原因很简单:让模型直接写 Python 代码再执行,风险太大,也没有约束力。受限 DSL 的好处是执行器可以严格校验,防止模型写出危险操作。
- 执行器是独立的。模型不负责逐步决策,执行器也不负责语义理解。执行器只负责按程序描述的意图去调度工具、传参、收结果、跳分支。
- 多个工具调用之间可以有数据依赖。这是 PTC 的核心能力之一:上一个工具的输出可以作为下一个工具的输入,程序里可以用变量引用。这一点让 PTC 区别于简单的“批量并行调用工具”。
DeepSeek Harness 里的 PTC 落地方式,其实是把这一套思路封装成了工作流插件的一个核心模块。插件提供了工具注册表、DSL 解析器和执行器,模型在需要的时候输出一段 PTC 描述,Harness 负责解析并执行。说白了,DeepSeek Harness 给 PTC 这个抽象模式做了一个工程化的实现,让使用方不必从零搭建。
我最初看到这个设计的时候,心里想的是“这不就是把多步工具调用编译成一个 DAG 吗”,后来实际用下来才发现,这个编译思路带来的收益远超预期。下面细说它到底改变了什么。
4. PTC 到底改变了什么:三个维度的重构
4.1 控制流:从线性到结构化
传统工具调用里,控制流完全由模型的每一步推理决定,本质上是“模型读历史 → 决定下一步 → 执行 → 结果入历史 → 模型再读……”的线性循环。这种模式下,你无法表达“如果 A 工具返回空结果,就改调 B 工具”这种条件逻辑——你没法告诉模型“下次决策时记得按这个规则来”,只能靠模型自己临场发挥。
PTC 改变的第一个维度,就是控制流从“隐式的对话推进”变成了“显式的程序结构”。模型生成的程序描述里,可以明确写清楚顺序、条件分支、循环、甚至是并行调用。比如:
# PTC DSL 示意(简化写法) program: - step: "查询订单" tool: get_order args: { order_id: "$input.order_id" } output: order_info - if: "$order_info.status == 'cancelled'" then: - step: "查询退款记录" tool: get_refund args: { order_id: "$input.order_id" } output: refund_info else: - step: "查询物流" tool: get_logistics args: { order_id: "$input.order_id" } output: logistics_info - step: "汇总结果" tool: compose_reply args: { order: "$order_info", logistics: "$logistics_info", refund: "$refund_info" }看到区别了吗?传统模式里,“如果订单取消就查退款”这个逻辑,需要模型在每一轮推理时“想起来”去判断订单状态;而 PTC 里,这个判断被显式地写进程序,执行器严格按条件执行,不会遗漏、不会跑偏。这就是结构化控制流的价值——决策逻辑被固化下来,不再依赖模型每轮的临场发挥。
我还测试过一种场景:需要循环遍历一个列表,对每个元素调用同样的工具。传统模式下,模型要么自己循环地发起多次调用(每次都要等一轮推理),要么干脆偷懒只处理前几个元素。PTC 模式下,程序里写一个循环就行,执行器一次性把整个列表跑完。这类“机械重复”的操作,本来就不应该消耗模型的推理能力。
4.2 错误恢复:从“崩了就重来”到“程序内自愈”
第二个维度的改变是错误恢复能力。传统工具调用链里,一旦某一步失败,整个链就断了。我之前测过一个数据同步任务:第一步拉取源数据,第二步清洗,第三步写入目标库。如果第二步清洗工具报错,模型面对报错信息通常有两种反应——要么反复用同样的参数重试同一个工具(傻重试),要么直接放弃并告诉用户“任务失败了”。这两种都不是好结果。
PTC 模式里,错误恢复逻辑可以直接写进程序里。它彻底颠覆了错误处理的层级:错误处理从“对话层面的应急”下沉到了“程序内部的机制”。
# PTC DSL 的容错写法 program: - step: "拉取源数据" tool: fetch_source args: { source_id: "$input.source_id" } output: raw_data on_error: - fallback_tool: fetch_source_backup fallback_args: { source_id: "$input.source_id", retry: true } - step: "清洗数据" tool: clean_data args: { data: "$raw_data" } output: clean_result max_retries: 3 retry_interval_ms: 1000这个设计在实际中非常管用。以我的测试为例,同样模拟 10% 工具失败率,传统模式的端到端成功率只有七成,而加上 PTC 的 fallback 和重试逻辑之后,成功率直接拉到九成五以上。原因很简单:重试和降级是程序逻辑,不是模型推理,程序不会疲惫,不会凌乱,更不会忘了自己在重试第几次。
还有个细节值得单独说:PTC 让中间结果的“部分成功”变得有处理意义。传统模式里,一旦某一步失败,之前的成功结果就白费了。但 PTC 程序可以像正常程序一样,把已经拿到的中间结果保存下来,然后针对失败的那一步做重试或降级,其他步骤不受影响。这种局部容错能力,在生产环境里是刚需。
4.3 上下文与效率:从对话记忆到局部变量
第三个维度的改变是效率和上下文管理。这是我个人最看重的一点,因为它直接关系到 token 成本和响应速度。
传统模式里,每一次工具调用的结果都要追加到对话历史中,模型才能在下一轮看到它。这意味着:
- 每一轮推理,模型都要重新处理所有历史(包括前面的工具结果),计算量随轮次线性增长;
- 大段工具结果占据大量上下文窗口,挤压模型思考的空间;
- 为了控制上下文长度,你需要额外做截断或摘要,而摘要本身又消耗一次模型调用。
PTC 模式里,中间结果存在“局部变量”里,而不是塞进对话历史。程序执行期间,执行器把每个步骤的输出存入变量空间,下一个步骤直接引用变量。模型在生成最终回复时,只需要看到“程序结束后的汇总结果”,不需要看到每一步的原始返回值。
我实际测过一组数据:传统模式下,一个 5 步工具调用链跑完,上下文里累计塞进了约 12000 token 的工具结果。而同样的任务用 PTC 实现,模型先生成程序(约 800 token),再看到执行结果摘要(约 1500 token),全程上下文消耗不到原来的五分之一。响应时间也从平均 90 秒降到了 35 秒左右。这个差异在长任务和复杂工作流里会被放大得更明显。
值得强调的是,这种效率提升不仅仅是省 token,更重要的是提升了模型的决策质量。因为模型不再被海量工具结果淹没,它的注意力集中在真正需要判断的汇总信息上,最终回复的准确性和稳定性都有明显改善。
5. 落地实践:在自己项目里实现 PTC
5.1 架构设计
聊完 PTC 改变了什么,接下来是实操环节。很多人的第一反应是“这个概念不错,但怎么落地?”我在自己的项目里实现过一版 PTC,也参考过 DeepSeek Harness 的设计思路,总结出一套比较通用的架构。
一个最小可用的 PTC 系统包含四个模块:
- 工具注册表(Tool Registry):维护所有可调用工具的元信息(名称、参数 schema、描述、返回类型)。
- 程序生成器(Program Generator):负责把用户的自然语言任务转换成 PTC 程序描述。这部分本质上还是靠 LLM,但关键在于要给它清晰的 DSL 规范。
- 执行引擎(Execution Engine):解析 PTC 程序,按步骤调度工具、传递参数、处理条件分支和循环、管理变量空间。
- 结果校验器(Result Validator):执行完毕后,把最终结果整理成模型需要的摘要格式,交给模型生成最终回复。
整个流程是这样的:
# PTC 核心流水线 def run_ptc_task(user_request): # 阶段1:模型生成 PTC 程序 ptc_program = llm.generate( system_prompt=PTC_SYSTEM_PROMPT, user_query=user_request, available_tools=registry.list_tools() ) # 阶段2:执行引擎运行程序 execution_result = engine.execute(ptc_program) # 阶段3:模型基于执行结果生成最终回复 final_reply = llm.generate( system_prompt=SUMMARY_SYSTEM_PROMPT, execution_summary=execution_result.summary() ) return final_reply关键点是:阶段 1 和阶段 3 各用一次模型调用,阶段 2 的执行过程完全不需要模型参与。整个系统的性能瓶颈从“模型推理速度”转移到了“执行引擎的执行速度”,后者通常是毫秒级的。
5.2 DSL 怎么设计
DSL 的设计是整个 PTC 的灵魂。如果 DSL 太复杂,模型生成困难,解析器也容易出错;如果太简单,又表达不了复杂逻辑。我建议从一个极简但“图灵完备”的子集开始,逐步扩展。
我的第一版 PTC DSL 只支持四个核心要素:
step:基本步骤,执行一次工具调用if/else:条件分支,根据上一步输出决定走哪条路径for_each:循环,对列表数据逐项处理parallel:并行调用,多个无依赖的工具同时执行
剩下的优先级都往后放。比如重试机制、fallback、超时设置,这些完全可以先写进执行引擎的默认配置里,不需要在 DSL 层显式表达。等基础跑通了,再逐步把这类高级能力暴露给模型,让它按需声明。
这里有个我踩过的坑:DSL 的文档描述必须极其精确。如果 DSL 说明写得含糊,模型就容易“自由发挥”。我第一版 prompt 里对output变量的命名规则描述得不够细致,模型经常生成不存在的变量引用,或者把变量名写错。后来我在 system prompt 里加了“变量必须先声明后引用,且每个 step 的 output 必须严格使用 step 名称作为前缀”的约束,问题立刻减少了很多。对模型来说,DSL 就是一套语言,而语言的语法规则必须写到它能自然遵守的程度。
5.3 一次真实任务的完整示例
为了让你直观看到 PTC 的实际效果,我用一个“查天气并决定是否带伞”的例子,展示传统 ReAct 和 PTC 的对比。这个例子虽然简单,但能清楚呈现两者的本质差异。
传统 ReAct 方式,模型需要经历多轮对话:
用户:明天早上我要出门,帮我看看需要带伞吗? 模型:[调用 weather_tool,参数 city=北京, date=明天] 工具:晴,无雨,气温 22-28 度 模型:明天北京无雨,不需要带伞。看起来简单,但如果任务变成“查北京和上海两个城市的天气,如果哪个城市有雨就顺便查一下该城市的地铁拥挤度,然后给出出行建议”,ReAct 模式的对话轮次就会变得很繁琐,而且每一轮都要把前面的结果重新编码进上下文。
同样的任务,用 PTC 一次搞定:
# 模型生成的 PTC 程序 { "steps": [ {"step": "query_beijing_weather", "tool": "weather_tool", "args": {"city": "北京"}}, {"step": "query_shanghai_weather", "tool": "weather_tool", "args": {"city": "上海"}}, {"step": "check_rain", "if": "$query_beijing_weather.rain == true", "then": [ {"step": "query_beijing_metro", "tool": "metro_tool", "args": {"city": "北京"}} ]}, {"step": "check_rain_sh", "if": "$query_shanghai_weather.rain == true", "then": [ {"step": "query_shanghai_metro", "tool": "metro_tool", "args": {"city": "上海"}} ]} ] } # 执行引擎跑完这4步(天气查询还可以并行),汇总结果 # 模型拿到摘要后生成最终回复:北京无雨,不用带伞;上海有雨且地铁拥挤,建议带伞并错峰出行看到本质区别了吗?执行器按程序逻辑跑完所有步骤后,模型只关心最终汇总结果,中间每一步的工具返回值全部存在局部变量里,既不会污染上下文,也不会干扰模型判断。这就是 PTC 的完整闭环。
6. 踩坑实录与排查思路
6.1 程序里的幻觉调用
第一个坑:模型生成的程序里,偶尔会出现“幻觉工具调用”——调用了一个根本不存在的工具,或者给现有工具传了完全不存在的参数。我最初遇到这种情况时很诧异,因为 DSL 已经把工具列表和参数 schema 都给了模型,为什么还会出错?
排查后发现,问题出在工具描述上。当工具列表过长时,模型会漏看部分工具的完整描述,凭“印象”拼凑出一个相似的调用。解决方法是两层配合:第一层,在 prompt 里要求“只使用给定的工具,绝不创建新工具”;第二层,在执行引擎里做严格的 schema 校验,一旦发现工具名或参数不匹配,直接在程序层面拦截,而不是把错误抛给模型。校验比引导更可靠,这是我在生产环境里反复验证过的结论。
6.2 生成长度与 token 预算
第二个坑:程序生成需要一次较长的输出。传统的工具调用模式下,模型每次只生成一个工具调用 JSON,输出很短。PTC 模式下,模型需要一口气输出整个程序,长度可能是原来的 5 到 10 倍。如果模型的最大输出 token 限制不够,程序可能被截断,导致解析失败。
我的应对方案是:把大任务拆成多个 PTC 子程序。比如一个包含 12 个步骤的任务,拆成 3 个每个 4 步的 PTC 小任务,每个小任务执行完,模型看到结果后再决定下一个子任务怎么写。这样既保留了 PTC 的优势,又规避了长程序生成的截断风险。
6.3 安全边界怎么划
第三个坑,也是最重要的一点:**PTC 让模型有了“批量执行”的能力,这意味着风险也被批量放大了。**传统模式下一步一调用,每步之间模型还有机会“反悔”;PTC 模式下,模型写出的整个程序会一口气跑完,如果程序里包含危险操作,执行器会毫不犹豫地全部执行。
所以 PTC 系统的安全边界必须严于传统系统。我从 DeepSeek Harness 的实际设计中借鉴了几条硬性规范:
- 工具注册表里,危险操作(删除、写库、发送消息)必须标记为
sensitive,执行器对这类工具要求额外的 confirmation 流程; - 程序执行前,执行引擎先做一次“预扫描”,检查是否有敏感工具调用;如果有,暂停执行并回到模型侧确认;
- 程序里明确禁止任何形式的文件路径拼接、外部命令执行、动态 import 等操作,DSL 层直接不提供这些能力。
说白了,**PTC 是把模型的决策能力往前挪了一步,但安全网必须提前布置好。**没有安全边界的批量执行,比没有批量执行更可怕。
7. 最后想说的几句经验
PTC 这个模式改变了我对 agent 工作流设计的基本认知。传统 ReAct 模式把一切决策都交给模型,看起来很灵活,但代价是慢、贵、脆。PTC 把那些“不需要智能”的机械执行工作剥离出来,让模型只做它擅长的事情——理解意图、写计划、总结结果。这种“智能用于决策、程序保证执行”的分工方式,在复杂任务场景下是更合理的设计。
我个人的实践经验是:如果一个任务需要调用工具的次数少于 3 次,PTC 的收益不明显,传统模式反而更简单直接。但一旦任务超过 5 步,或者包含明显的分支和循环逻辑,PTC 的效率和稳定性优势就非常显著了。建议大家在引入 PTC 时,先拿一个中等复杂度的真实任务做测试——比如“多数据源拉取 → 清洗 → 汇总 → 生成报告”这种流水线任务——对比一下两种模式的响应时间、token 消耗和成功率,你会对 PTC 的价值有非常直观的感受。
最后再分享一个小技巧:如果你用的是 DeepSeek Harness 这类框架,记得多关注它提供的 skill 机制——你可以把常用的 PTC 程序片段封装成 skill,让模型在需要的时候直接引用已有的 skill,而不是每次都重新生成完整程序。这能显著降低模型出错率,也是我这段时间用下来性价比最高的优化手段。