☰
无Tool Calling:用结构化输出打造稳定Agent的实践指南
2026/10/8 4:45:44 网站建设 项目流程

系列第5篇,我打算聊一个稍微反着来的方案:把Tool Calling从Agent里整个拿掉。不是因为我不会接工具,而是踩过一轮工具调用链路的坑之后,我意识到很多分析型Agent真正需要的不是"能调多少个工具",而是一个稳定的结构化出口。我临时管这种方案叫"无Tool Calling的结构化通用Agent"——不注册任何函数、不请求外部API,只靠模型按Schema输出JSON,跑完理解、规划、反思、产出整个流程。如果你们做的是文本分析、计划制定、信息整理这类"只动脑不动手"的任务,这个方案会比function calling省心得多,也更好排查问题。这篇文章会把设计思路、工程细节、可跑的代码和踩过的坑全部摊开。

1. 为什么要把 Tool Calling 从 Agent 里拿掉

1.1 拆工具拆出来的教训

先说背景。我之前的Agent项目,起步第一步永远是接Tool Calling:注册函数、写参数描述、设计调用策略。一套流程下来没几天,我发现线上真正被频繁触发的工具往往就是两三个,剩下几十个工具纯属摆设。更麻烦的是,工具调用链路一出问题,日志里全是"模型想调用A工具但参数缺必填项,重试之后又想去调B工具"这种循环,排错一小时,真正干活五分钟。

后来我做一个纯文本分析类的任务,需要Agent把一段用户描述整理成工作清单。刚开始我也是按老思路接一个保存清单的工具进去,结果发现模型十次里有三次会在调用工具时编造一个"list_name"字段,程序拿着这个幻觉参数真的去写库,写进去的清单牛头不对马嘴。那一刻我意识到,问题不在工具选型,而在我把"让模型输出一段结构化内容"这件事,错误地包装成了"让模型去调用一个写库函数"。

1.2 Tool Calling 的成本常常被低估

这在Demo阶段根本看不出来,跑到真实环境就变成事故率。我整理了一下自己的观察:

  • 每次请求都要把工具声明的JSON Schema塞进上下文,token开销不低,几十个工具就是一长篇说明书。
  • 模型不是真的会执行工具,它只是生成一段"看起来像工具调用"的文本。字段幻觉、漏传参数、枚举值乱填都是常见问题。
  • 工具失败之后的重试策略很头疼:重试几次?错误信息要不要完整返回给模型?如果工具报错信息比任务本身还长,上下文会不会被污染?
  • 本地化部署或不支持function calling的模型,会让你的代码被迫维护两套分支:一支持一不支持,工作量直接翻倍。

这些成本单看都不致命,但它们会一起压过来,把Agent的调试复杂度抬高一个量级。

1.3 Tool Calling 只是"输出约束"的一种特例

我回到底层想了想:模型的输出本质上是一段token序列。Tool Calling做的事情,是在这段序列里强行解码出"函数名+参数",程序拿到之后再去调用真实函数。结构化输出做的事情,是让模型按一个JSON Schema生成内容,程序按字段名读取。两者底层逻辑其实是同一件事:把自由文本约束成机器可消费的数据。

区别在于出口连的方向不一样——Tool Calling的出口连接的是"动作",结构化输出的出口连接的是"信息"。而我当时的任务恰恰只需要"信息",那为什么要绕一大圈去接工具?把信息出口做成结构化JSON,下游程序按需处理,逻辑反而干净得多。

2. 结构化是唯一的主角:无工具 Agent 的骨架

2.1 三张 Schema 撑起一个通用流程

无工具Agent的通用性,不是靠"模型无所不能",而是靠"所有输入都先被结构化成同一种任务表示"。我设计了三个Pydantic模型,分别管任务的三个阶段:

  • TaskParse:回答"用户到底想要什么",包含task_type、objective、constraints、input_summary、required_output等字段。
  • WorkPlan:回答"接下来怎么干",包含steps数组,每个步骤有step_id、name、goal、depends_on。
  • FinalReport:回答"干完的结果是什么",包含conclusion、evidence、risks、recommendations。

用户输入无论多离谱,先经过TaskParse这一步,后续循环只认这个结构化对象。这就像机场的行李转盘——不管托运的箱子是什么形状,最后都落到同一条标准轨道上,后面才能统一分流处理。

2.2 Agent 循环:理解、规划、执行、反思、输出

无工具Agent不调用外部工具,那"执行"是什么?在分析型任务里,执行就是让模型根据任务理解直接生成阶段性结论或文本产出。整个循环我写成五步:

  1. 理解:把原始输入转成TaskParse。
  2. 规划:根据TaskParse生成WorkPlan。
  3. 执行:按WorkPlan调用模型生成每个阶段产出,因为没有工具,这一步产出的是文本或结构化结论。
  4. 反思:把已生成的产出交给模型做质量检查,输出pass/fail结论和问题列表。
  5. 收敛:如果反思结果是pass,直接生成FinalReport;如果是fail,把问题列表带回执行阶段,让模型重新生成局部内容,直到通过或达到最大轮次。

整个循环没有外部副作用,全是模型对自己产出的反复审视。这正是无工具Agent能稳定工作的核心原因:它把风险从"程序执行模型编造的工具调用"降级为"模型自我修正文本",后者永远不会让程序崩溃,最多是结果质量不稳。

2.3 为什么通用性靠"归一化"获得

通用性来源于流程的归一化,而不是模型本身有多聪明。用户说"帮我整理会议纪要"和"帮我想一个健身计划",第一步都会转成同样的TaskParse结构,然后走同一个循环。这样做还有个额外好处:每个环节可以被单独调试。觉得规划不好,就只改规划那一段的prompt;觉得反思太弱,就只调反思的prompt——这比在一个大Agent里东一锤西一棒改提示词要好维护得多。

在实现顺序上,我建议先跑通TaskParse和FinalReport,再加WorkPlan,最后加反思。理由很简单:前面两个是主干道,主干道没有验证稳定之前,加再多支路都是在沙子上盖楼。

3. 稳定输出结构化内容的工程细节

3.1 System Prompt 里的"输出法"要写死

无Tool Calling环境下,Prompt要承担原来Function Calling的schema说明功能。我在System Prompt里固定了一个叫"输出法"的章节,内容长期不改:

  • 严禁输出Markdown代码块,严禁在JSON前后添加任何解释文字。
  • 所有字段名严格按照schema给定清单,不得改成大小写或下划线变体。
  • 如果信息不足以填写某个字段,使用空字符串或null,不要编造内容。
  • 枚举字段只能从给定枚举值中选择,不得自创近义词。

这些规则看着很基础,但我在测试中发现,少写一条,格式漂移率能从5%涨到30%。结构化Agent里,提示词约束不是装饰品,是稳定性的地基。

3.2 解析、校验和重试:三条命

结构化输出的落地点在程序侧。如果只靠模型自己说"我会输出JSON",那早晚会被坑。我写了一个带重试的structured_completion函数,核心逻辑是:模型输出 → json.loads → Pydantic校验 → 失败就把错误信息回灌给模型,让它自己改,最多重试三次。

import json from typing import Type, TypeVar from pydantic import BaseModel T = TypeVar("T", bound=BaseModel) OUTPUT_RULES = ( "1. 严禁输出Markdown代码块。" "2. 字段名严格按照schema,不许改大小写和命名风格。" "3. 信息不足就填空字符串或null,不许编造。" "4. 枚举字段只能从给定值中选择。" ) def structured_completion(client, system: str, user: str, schema: Type[T], max_retry: int = 3, model: str = "qwen2.5:7b") -> T: last_error = "" for _ in range(max_retry): messages = [ {"role": "system", "content": system + "\n\n输出法:" + OUTPUT_RULES}, {"role": "user", "content": user if not last_error else user + "\n\n上次JSON解析失败,错误如下:\n" + last_error}, ] resp = client.chat.completions.create( model=model, messages=messages, response_format={"type": "json_object"}, temperature=0.1, ) raw = resp.choices[0].message.content try: data = json.loads(raw) return schema.model_validate(data) except Exception as e: last_error = str(e) continue raise ValueError(f"连续{max_retry}次结构化输出失败: {last_error}")

这里有几个实践经验。temperature我一般设0.1而不是0:纯0在部分模型上反而会让JSON格式退化更明显,可能是采样路径太单一导致总是卡在同一个问题上;0.1基本不损失稳定性,却能明显减少重复失败。response_format里的json_object只保证"它是一个JSON",不保证"它符合你的schema",所以Pydantic校验绝不能省。很多模型支持的是json模式而非严格schema模式,程序侧的schema校验才是最后一道防线。

3.3 我踩过的五种失败模式

这部分是血泪经验,我列成表格方便你们对照排查。

失败现象背后的原因我的修复办法
字段名从snake_case变成camelCase模型按训练数据习惯改写字段名在System Prompt里逐字段写出命名清单,并把校验错误回灌给模型
JSON外面套了```json代码块模型把结构化输出理解成了"生成代码"解析前先剥掉首尾反引号,同时强化"输出法"第一条约束
输出被max_tokens截断,JSON不完整预估token不足,字段又多又长给足max_tokens,截断后重试时提示"上次输出被截断,请补全剩余字段"
枚举值自创近义词模型没理解枚举是封闭集合在校验错误里明确列出合法枚举值,要求只能从中选择
反思循环永远不收敛,每次都能挑出毛病反思Prompt没定义停止条件在反思Schema里增加verdict字段,规定只有存在事实性错误才允许fail

其中第五个最隐蔽。我第一次加反思环节时,模型每次审查都能挑出新问题,Agent就一直在"生成-反思-再生成"里打转,直到触发最大次数。后来我在反思的Schema里加了verdict,并明确要求"如果只是风格、语气、排版问题,一律判pass",循环次数立刻降下来了。反思循环要的是终结条件,不是追求完美。

4. 一个能跑的通用 Agent 实例

4.1 环境与模型选择

我用Python 3.11 + pydantic 2.x + openai Python SDK,后端接本地Ollama服务,就是为了验证"无外部依赖"的假设。任何兼容OpenAI Chat Completions协议的端点都可以替换,模型用的是qwen2.5:7b这类指令模型。选它的原因是它不依赖function calling能力,只要模型能生成JSON,就能进这个框架——这正好是验证无Tool Calling方案的关键。

4.2 核心代码:Schema 定义和 Agent 循环

Schema定义部分,我把三个核心模型完整写出来。注意每个字段都要写description,因为在无工具方案里,这些description就是模型理解字段含义的唯一来源。

from typing import List from pydantic import BaseModel, Field from openai import OpenAI import json MODEL = "qwen2.5:7b" client = OpenAI( api_key="ollama", base_url="http://localhost:11434/v1", ) class TaskParse(BaseModel): task_type: str = Field(description="任务类型:analysis/planning/extraction/rewriting/other") objective: str = Field(description="用一句话概括用户的核心诉求") constraints: List[str] = Field(description="用户明确提到的限制条件,例如时间、资源、环境") input_summary: str = Field(description="对原始输入的关键信息概括") required_output: List[str] = Field(description="用户期望输出中包含哪些内容") class Step(BaseModel): step_id: int name: str goal: str depends_on: List[int] = Field(description="依赖的前置步骤id列表,没有则填空数组") class WorkPlan(BaseModel): steps: List[Step] class Reflection(BaseModel): verdict: str = Field(description="pass或fail。只有存在事实性错误才允许fail") issues: List[str] = Field(description="如果verdict为fail,列出需要修正的问题") class FinalReport(BaseModel): conclusion: str = Field(description="最终结论,必须直接回应用户目标") evidence: List[str] = Field(description="支撑结论的关键证据") risks: List[str] = Field(description="可能影响执行的风险") recommendations: List[str] = Field(description="下一步具体行动建议")

Agent本体:

class Agent: def __init__(self, system_prompt: str, max_iterations: int = 3): self.system_prompt = system_prompt self.max_iterations = max_iterations def run(self, user_input: str) -> FinalReport: # 1. 理解任务 task = structured_completion(client, self.system_prompt, user_input, TaskParse) # 2. 制定计划 plan_prompt = f"根据任务解析结果:\n{task.model_dump_json()}\n\n请制定详细工作计划。" plan = structured_completion(client, self.system_prompt, plan_prompt, WorkPlan) # 3. 执行与反思 working_outputs = [] last_issues = [] for _ in range(self.max_iterations): exec_prompt = ( f"任务解析:{task.model_dump_json()}\n" f"工作计划:{plan.model_dump_json()}\n" f"当前已产出:{json.dumps(working_outputs, ensure_ascii=False)}" ) if last_issues: exec_prompt += f"\n你需要修正以下问题:{json.dumps(last_issues, ensure_ascii=False)}" partial = structured_completion(client, self.system_prompt, exec_prompt, FinalReport) working_outputs.append(partial.model_dump()) refl_prompt = f"请审查以下阶段性结论:\n{json.dumps(working_outputs, ensure_ascii=False)}" refl = structured_completion(client, self.system_prompt, refl_prompt, Reflection) if refl.verdict == "pass": break last_issues = refl.issues # 4. 汇总最终报告 final_prompt = ( f"任务解析:{task.model_dump_json()}\n" f"中间产出:{json.dumps(working_outputs, ensure_ascii=False)}\n" f"请基于以上内容输出最终结构化报告。" ) return structured_completion(client, self.system_prompt, final_prompt, FinalReport)

这个Agent最大迭代三次,每次执行都会先看上一次反思有没有留下问题,有就一起带进prompt。整个流程里没有一行代码是调用外部工具的,所有"智能"都花在让模型生成结构化、可校验的JSON上。

4.3 实测:看它怎样处理一个开放式任务

我拿一个典型输入跑了一遍:"我想在三个月内从零基础学会数据分析,并完成一个能写进简历的项目,每天只有晚上一小时。"

TaskParse输出(有删节):

  • task_type: planning
  • objective: 三个月内从零基础入门数据分析并完成一个简历级项目
  • constraints: ["每天只有1小时可用时间", "零基础起步"]
  • input_summary: 用户有明确时间预算和学习目标,但缺少路径
  • required_output: ["学习路径", "具体项目建议", "时间分配方案"]

WorkPlan输出:

  • step 1: 确定学习范围,目标是用最少内容覆盖数据分析核心
  • step 2: 学习数据清洗和Pandas基础,每天完成一个小练习
  • step 3: 选定一个真实数据集做项目,走通清洗-分析-可视化流程
  • step 4: 撰写项目报告并整理到简历

反思结果:verdict为pass,没有要求修改。

FinalReport的核心结论是:每天一小时可行,但要严格控制学习范围,不要陷入工具链搭建;建议第三周开始就接触项目数据,边做边学。risks里提到了"时间不足导致计划中断",recommendations里给出了"先跑通最小闭环再扩展工具"的具体建议。

这个输出看起来平平无奇,但重点是它稳定——同样的输入我跑五次,输出的字段结构基本一致,不会出现一次返回Markdown、一次返回纯文本、一次干脆把字段名变成驼峰的情况。对我这种要做下游程序接数据的人来说,这份稳定性比内容惊艳重要得多。

5. 无 Tool Calling Agent 的能力边界与选型判断

5.1 与 Tool Calling 方案的对照

做一个对照表会看得更清楚:

对比维度Tool Calling Agent无Tool Calling结构化Agent
模型要求必须支持function calling只要能稳定生成JSON
外部依赖依赖目标服务的网络、鉴权、稳定性无,离线也能跑
输出侧能力擅长连接动作,信息结构偏弱擅长信息结构,没有动作执行能力
典型失败模式参数幻觉导致程序执行错误动作格式漂移,基本可重试修复
调试成本需要追踪工具调用链路只需看JSON校验错误
适合任务订票、查询、数据库读写、触发操作计划、分析、整理、抽取、决策建议

我的判断是:两者不是替代关系,是分层的。无Tool Calling结构化Agent更适合做"思考中枢",Tool Calling适合做"手脚"。如果你既要做分析又要落地动作,建议把动作执行放到最后一步,由下游程序根据结构化输出里的字段去触发,而不是让模型直接去调用每一个函数。

5.2 什么样的场景值得选无工具方案

结合这段时间的实践,我认为这几类场景优先考虑无工具方案:

  • 所有上下文都在对话内部,不需要外部数据源。比如会议纪要整理、客户反馈分类、产品需求拆解。
  • 最终产物是结构化内容,而不是一个动作。比如生成周报、制定学习计划、输出风险评估。
  • 部署环境受限,不允许出网或不想维护一堆第三方API凭证。
  • 使用的是本地开源模型,function calling支持不稳定但JSON能力过关。
  • 需要控制token成本,省掉每次请求都携带的大量工具声明。

5.3 它解决不了的事,别硬扛

无工具方案不是万金油。实时行情查询、天气获取、数据库写入、网页检索、精确数学计算——这些它统统做不到,因为"不调用工具"意味着模型只能依赖训练时的知识和用户喂进来的上下文。如果任务本质上是"获取外部信息并加工",那你需要的不是无工具Agent,而是带工具Agent。

我以前很容易陷入一种执念:想把所有能力都塞进一个Agent里,结果做出来哪头都不稳。后来我接受了一个现实——分析型Agent的价值在于把模糊的输入变成结构化的、可执行的结论,至于动作,交给下游系统去做就好。这不是妥协,这只是一种更务实的分工。

跑完这个项目我最大的感受是,"无Tool Calling"不应该被理解成"不调用工具的高难度姿势",而应该理解成"先把Agent的信息出口修稳,再考虑动作出口"。如果以后某个场景真的需要调用外部工具,完全可以在FinalReport里加一个action字段,让下游程序根据结构化结果触发对应动作——那时候你手里是一个输出稳定、链路清晰的底座,再加工具只是做加法。要是你也在被function calling的偶发失败折磨,我建议先试一个星期这种纯结构化方案,大概率会有不一样的体验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询