
系列第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, boundBaseModel) 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( modelmodel, messagesmessages, response_format{type: json_object}, temperature0.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_keyollama, base_urlhttp://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(descriptionpass或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_asciiFalse)} ) if last_issues: exec_prompt f\n你需要修正以下问题{json.dumps(last_issues, ensure_asciiFalse)} 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_asciiFalse)} 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_asciiFalse)}\n f请基于以上内容输出最终结构化报告。 ) return structured_completion(client, self.system_prompt, final_prompt, FinalReport)这个Agent最大迭代三次每次执行都会先看上一次反思有没有留下问题有就一起带进prompt。整个流程里没有一行代码是调用外部工具的所有智能都花在让模型生成结构化、可校验的JSON上。4.3 实测看它怎样处理一个开放式任务我拿一个典型输入跑了一遍我想在三个月内从零基础学会数据分析并完成一个能写进简历的项目每天只有晚上一小时。TaskParse输出有删节task_type: planningobjective: 三个月内从零基础入门数据分析并完成一个简历级项目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的偶发失败折磨我建议先试一个星期这种纯结构化方案大概率会有不一样的体验。