系列第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不调用外部工具,那"执行"是什么?在分析型任务里,执行就是让模型根据任务理解直接生成阶段性结论或文本产出。整个循环我写成五步:
- 理解:把原始输入转成TaskParse。
- 规划:根据TaskParse生成WorkPlan。
- 执行:按WorkPlan调用模型生成每个阶段产出,因为没有工具,这一步产出的是文本或结构化结论。
- 反思:把已生成的产出交给模型做质量检查,输出pass/fail结论和问题列表。
- 收敛:如果反思结果是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的偶发失败折磨,我建议先试一个星期这种纯结构化方案,大概率会有不一样的体验。