
把Tool Calling拿掉Agent反而更听话了这是我做这个实践系列以来最反直觉的一个结论。先说清楚背景我一直在搭一套通用Agent目标是让同一套内核能处理写文档、查资料、改文件、整理数据这类日常杂活而不是只为一个垂直场景定制脚本。在试用过程中原生Tool Calling或者说function calling在部分场景里表现很好但只要换到本地模型、或者工具定义变多稳定性就成玄学。于是这个主题我决定换个思路不用Tool Calling而是让模型直接输出结构化动作指令程序端负责解析、校验、执行整个Agent照样把多步任务跑完。这套方案适合谁如果你在自研Agent框架或者被模型不支持function calling卡住又或者不想让模型直接决定一切、希望每个动作都经过程序白名单校验那你应该看完这篇。我会从设计动机、协议定义、完整代码、实测效果、排查笔记几个角度把整个方案讲透。代码用Python写核心不到200行但跑起来就是一个能处理多步任务的通用Agent。1. 为什么要把Tool Calling拿掉三个真实场景逼我换方案1.1 本地模型和开源模型对原生Tool Calling的支持太参差先说最直接的触发点。我的大部分实验跑在云端API上Tool Calling用得很顺。但一旦换到自部署的Qwen、Llama或GLM系列模型原生function calling的支持就是另一回事了。有些模型要特定模板才认工具定义有些干脆把function call和普通文本混在一起输出你拿到一个半结构化字符串却不知道怎么解析。不是说完全不能用而是每个模型都要单独调一套prompt和解析逻辑这违背了我做通用Agent的初衷。这里有个明显的对比结构化输出JSON几乎是所有现代模型的基本能力哪怕是很小的模型只要提示词里给清楚格式和示例它大概率能输出合法JSON。但Tool Calling依赖的是模型在预训练/对齐阶段学到的特殊调用格式这个能力的普及程度远不如JSON输出。所以如果想让Agent内核不绑定具体模型无Tool Calling的路径天然更通用。1.2 工具一多重复编码工具定义的token成本会累积原生Tool Calling有一个很实际的成本问题每轮请求都要把工具列表的完整schema发给模型。工具少的时候两三个无所谓等你维护了20个工具、每个工具带5个参数时光工具定义就有几千token。Agent跑一个多步任务每步都会重复发送这批定义费用和延迟都在肉眼可见地涨。无Tool Calling的做法里工具清单只在系统提示词里出现一次而且我会用自然语言描述工具用途不需要塞结构化的JSON Schema给模型。模型每轮输出的只是一个小JSON动作名参数思考token占用小得多。在我实测的用例里单步决策的输入token能省30%到50%多轮任务累加起来差距很明显。1.3 程序主导、模型执行这种控制关系更符合生产环境的要求Tool Calling把选择哪个工具这个决策完全交给模型这在探索阶段很爽但放到生产环境就有点心里没底。工具调错了怎么办参数里混入了不该有的值怎么办我希望能有一种方案模型的自由度被收敛到提出动作建议而程序端握有最终的执行权和校验权——工具名必须白名单、参数必须过schema、高危操作必须记审计日志。顺着这个思路我意识到所谓Tool Calling的核心价值其实就是模型输出结构化指令而这个能力完全可以用JSON输出 解析校验模拟出来。甚至因为动作协议是我们自己定义的校验、重试、审计都变成了一段确定的代码而不是封装在SDK黑盒里的行为。这种控制关系对于企业内部系统来说往往比模型自由发挥更让人安心。2. 整体设计模型只做决策程序当裁判2.1 角色分层决策和执行彻底分离这套Agent的结构用一个词概括就是双层架构上层是模型负责读用户需求、拆解步骤、决定下一步调什么工具下层是程序负责解析模型输出、检查参数、执行工具、把结果送回对话流。模型不直接接触真实文件、网络或命令它接触的只有动作空间——我给它一张工具清单它从中选名字、填参数。真实操作发生在程序端的handler里。这样做的好处是模型的任何幻觉顶多导致一个无效的参数而不会导致一次真实的危险操作因为参数在校验阶段就能被拦下。2.2 动作协议一个JSON要包含哪些字段我定义的动作协议核心是一个Pydantic模型字段不用多四个就好class Action(BaseModel): thought: str # 模型对这一轮的简短分析方便追溯决策过程 tool: str # 要调用的工具名 args: dict # 工具参数具体结构由每个工具自己的schema约束 reason: str # 为什么选这个工具便于审计日志记录thought和reason不是摆设。它们在调试时非常关键——当Agent行为不对你翻日志能看到模型当时的思考过程而不是只有一个孤零零的动作。另外一个隐含好处是强制模型先写思考再写动作它的输出质量通常会比直接挤一个JSON更稳定。这其实有点ReAct的味道只不过把Thought/Action变成了结构化字段。2.3 主循环决策-校验-执行-反馈-终止核心循环不长逻辑链条是固定的把系统提示词含工具清单和协议说明 历史消息发给模型要求它只输出一个JSON。程序解析JSON用Pydantic校验。校验失败就把错误信息回灌给模型让它修正后重来有次数上限。校验通过后检查工具名是否在白名单里参数是否匹配工具自己的输入schema。执行工具拿到结果把结果追加到消息历史。如果动作是finishAgent把最终答案返回给用户循环结束。finish这个工具是整个设计里最妙的一环。它让终止对话也变成一个动作模型不会用一堆废话结尾而是必须给出一个结构化的收尾信号。用户侧看到的返回值就是这个动作里的answer字段。3. 关键实现结构化输出的稳定性全在兜底手段3.1 提示词模板怎么约束模型别“飘”无Tool Calling的成败一半在提示词模板。我的系统提示词固定由三部分组成角色说明、工具清单、输出协议。工具清单部分长这样TOOL_DESCRIPTIONS { get_current_time: 获取当前日期和时间返回格式YYYY-MM-DD HH:MM:SS无参数。, list_notes: 列出笔记目录下的所有文件返回文件名列表无参数。, read_note: 读取指定笔记内容参数filename(str)。, save_markdown: 保存Markdown内容到本地文件参数filename(str), content(str)。, finish: 告诉用户最终答案并结束任务参数answer(str)。, }输出协议部分强调三件事只输出JSON、不允许markdown代码块包裹、工具名必须来自清单。我曾经偷懒没写不允许代码块包裹这句话结果模型十次里有三次把JSON塞进代码块里解析逻辑被迫做额外的清洗。后来把这句话加进提示词配合代码里的清洗函数情况才稳定下来。另一个经验是给一个具体的完整示例注意是给坏例子好例子的对比效果远胜只给好例子错误输出示例 json {tool: read_note, args: {filename: a.md}}正确输出示例 {thought: 用户需要读取a.md的内容, tool: read_note, args: {filename: a.md}, reason: 根据文件名直接读取笔记}### 3.2 用Pydantic做协议校验把错误信息回灌给模型 Pydantic在这里承担两重作用校验协议结构以及校验每个具体工具的参数。协议校验就是Action.model_validate(json_obj)它不合格时抛出的错误信息非常详细我会原样拼接进回灌消息里。模型的自我修正能力很强看到args.field missing这类具体报错下一轮通常就能给对。 工具参数的校验我采取类似做法每个工具的输入都定义一个Pydantic模型在注册时绑定到工具条目上。这一步绝对省不得——如果只靠模型自觉填参数你会发现它敢给read_note传一个不存在的文件名、给日期类工具传中文日期。schema校验能把这些错误拦截在真实操作之前同时把校验失败的原因作为消息回灌给模型形成闭环。 ### 3.3 JSON清洗处理代码块包裹、散装前缀后缀 即便提示词写了只输出JSON模型偶尔还是会输出带杂音的文本。我的清洗函数做了三层防御 python import json, re def extract_json(raw: str) - dict: raw raw.strip() # 第一层如果被markdown代码块包裹先把外层剥掉 fence_pat re.search(r(?:json)?\s*(.*?), raw, re.S) if fence_pat: raw fence_pat.group(1).strip() try: return json.loads(raw) except json.JSONDecodeError: # 第二层直接找第一个{和最后一个}截取中间内容再解析 start, end raw.find({), raw.rfind(}) if start -1 or end -1 or end start: raise return json.loads(raw[start:end 1])这个函数不会解决所有问题但能解决我实际遇到的95%的情况。剩下5%是模型输出的JSON里有非法转义字符这类只能靠重试逻辑兜底。3.4 校验失败后的回灌重试设定次数上限避免死循环每次解析或校验失败我不会直接报错给用户而是追加一条用户角色消息内容大致是你的输出不符合系统动作协议错误信息具体报错。 请重新输出符合协议格式的JSON工具名必须来自清单。模型看到这条消息会重新生成成功率很高。但需要设定上限我默认是2次。如果两次重试仍然失败就主动放弃这轮返回一条抱歉我没有理解当前任务并结束对话。没有上限的话遇到模型抽风可能会出现无限重试这在生产环境里是致命的。4. 工具注册与通用编排让Agent内核不写死任何业务逻辑4.1 工具注册表从dict到可扩展的注册机制通用体现在哪就体现在工具注册机制上。新增能力时不需要改动Agent内核只需要注册新工具。我的注册表设计很朴素一个dict就够用class ToolRegistry: def __init__(self): self._tools {} def register(self, name, description, args_schema, handler): self._tools[name] { description: description, args_schema: args_schema, handler: handler, } def spec_prompt(self) - str: lines [可用工具清单] for name, meta in self._tools.items(): lines.append(f- {name}: {meta[description]}) return \n.join(lines)args_schema是Pydantic模型类handler是真正的Python函数。执行器拿到Action后先查注册表再实例化参数模型做校验最后调用handler。整个过程对业务零感知——你哪怕注册一百个工具Agent的行为模式还是那一个循环。4.2 从工具说明到模型动作空间控制每轮提示词的长度工具多了以后把所有描述都塞进系统提示词会让模型犯迷糊也浪费token。我的做法是控制每个工具描述不超过40字而且不做嵌套格式全部用平铺文本。参数细节留给程序端的schema校验模型只需要知道这个工具大致是干什么的、参数大概是什么真正的参数正确性由代码保证。如果你工具实在太多超过20个可以考虑对工具做分组第一轮让模型在类别层面决策第二轮再在具体类别内选工具。不过这个属于进阶优化我的当前场景还没到这个量级。4.3 上下文管理执行结果不能无脑往历史里塞工具执行结果追加到消息历史时必须做截断否则多轮任务跑下来上下文会爆炸。我的经验是文本类结果超过800字符就截断加省略号只保留最近3轮工具结果更早的丢进一个已执行动作摘要字段里工具执行结果里不包含表格时尽量转成简洁的key-value文本。这样做有两个好处降token成本、减少模型被大量输出干扰的概率。很多Agent失败案例不是模型不会决策而是被塞进了一堆噪声信息注意力被带偏了。4.4 安全与审计动作白名单和参数类型校验缺一不可这套架构的安全边界在于模型永远不能直接执行代码只能建议动作。做生产化落地时我在执行器里加了三个强制措施工具名必须在注册表里否则直接拒绝并记录异常日志参数必须通过目标工具的args_schema校验字符串、数字、枚举值都按schema来对save_markdown这类写操作执行前把模型生成的参数写入审计日志方便回溯问题。尤其是第二点我见过有人把模型的args直接传给subprocess或os.system这是绝对的危险操作。任何工具handler收到参数后都要把参数当作不可信输入处理自己再做一层约束。5. 完整代码一个最小但能跑的通用Agent5.1 内核实现下面这段代码就是整个Agent的内核依赖只有openai或任何兼容OpenAI接口的SDK、pydanticimport json, re from typing import Any, Callable from pydantic import BaseModel, Field class Action(BaseModel): thought: str tool: str args: dict Field(default_factorydict) reason: str class ToolRegistry: def __init__(self): self._tools {} def register(self, name: str, description: str, args_schema: type[BaseModel], handler: Callable): self._tools[name] {description: description, schema: args_schema, handler: handler} def spec_prompt(self) - str: lines [可用工具清单:] for name, meta in self._tools.items(): lines.append(f- {name}: {meta[description]}) return \n.join(lines) def execute(self, action: Action): meta self._tools.get(action.tool) if not meta: raise ValueError(f未知工具: {action.tool}) validated_args meta[schema].model_validate(action.args).model_dump() return meta[handler](**validated_args) class StructuredAgent: def __init__(self, client, system_prompt: str, registry: ToolRegistry, model: str gpt-4o-mini, max_retries: int 2, max_steps: int 6): self.client client self.model model self.registry registry self.system_prompt system_prompt self.max_retries max_retries self.max_steps max_steps self.history [] def _chat_json(self): resp self.client.chat.completions.create( modelself.model, messagesself.history, response_format{type: json_object}, temperature0, ) return resp.choices[0].message.content def _extract_json(self, raw: str) - dict: raw raw.strip() fence_pat re.search(r(?:json)?\s*(.*?), raw, re.S) if fence_pat: raw fence_pat.group(1).strip() try: return json.loads(raw) except json.JSONDecodeError: start, end raw.find({), raw.rfind(}) if start -1 or end -1: raise return json.loads(raw[start:end 1]) def _get_action(self, retry_count0) - Action: try: raw self._chat_json() data self._extract_json(raw) return Action.model_validate(data) except Exception as e: if retry_count self.max_retries: raise self.history.append({role: user, content: f你的输出不符合动作协议错误信息{e}。请重新输出JSON工具名必须来自清单。}) return self._get_action(retry_count 1) def _compress_tool_result(self, result) - str: text str(result) return text[:800] ... if len(text) 800 else text def run(self, user_input: str): self.history [{role: user, content: self.system_prompt}] self.history.append({role: user, content: user_input}) for step in range(self.max_steps): action self._get_action() if action.tool finish: return action.args.get(answer, 任务完成) try: result self.registry.execute(action) feedback f步骤{step 1}: 工具[{action.tool}]执行结果: {self._compress_tool_result(result)} except Exception as e: feedback f步骤{step 1}: 工具[{action.tool}]执行失败: {str(e)}。请检查参数后重新决策。 self.history.append({role: assistant, content: action.model_dump_json()}) self.history.append({role: user, content: feedback}) return 步骤数超限任务未能完成。注意run方法里我把action.model_dump_json()作为assistant消息、工具执行结果作为user消息回灌这样模型能明确区分上一步动作和动作观察结果决策连续性比粗暴拼接文本好很多。5.2 注册几个实际工具并跑通多步任务为了验证通用性我注册三个八竿子打不着的工具——时间、文件、文本统计from datetime import datetime def get_time_tool(): return datetime.now().strftime(%Y-%m-%d %H:%M:%S) def save_markdown_tool(filename: str, content: str): with open(filename, w, encodingutf-8) as f: f.write(content) return f已保存到 {filename}字数 {len(content)} def count_words_tool(text: str): return f文本字数{len(text)}单词数{len(text.split())} class TimeArgs(BaseModel): pass class SaveMarkdownArgs(BaseModel): filename: str content: str class CountWordsArgs(BaseModel): text: str registry ToolRegistry() registry.register(get_current_time, 获取当前日期和时间无参数, TimeArgs, get_time_tool) registry.register(save_markdown, 保存Markdown到本地文件参数filename, content, SaveMarkdownArgs, save_markdown_tool) registry.register(count_words, 统计文本字数参数text, CountWordsArgs, count_words_tool) registry.register(finish, 结束任务并输出最终答案参数answer, FinishArgs, lambda answer: answer)然后启动Agentfrom openai import OpenAI client OpenAI(api_keyyour-key) SYSTEM_PROMPT 你是一个通用任务Agent。你的工作方式 1. 仔细理解用户需求把目标拆解成可执行的步骤。 2. 每轮只输出一个JSON动作格式 {thought: 分析, tool: 工具名, args: {...}, reason: 选择原因} 3. 工具名只能来自可用工具清单。 4. 如果用户目的已完成调用finish工具参数answer里给出最终回复。 5. 只输出JSON不要包含markdown代码块。 agent StructuredAgent(client, SYSTEM_PROMPT, registry) result agent.run(现在几点了请把时间保存到time.md并统计一下这句话的字数。) print(result)这个任务需要连续调用三个工具先查时间再统计字数最后保存文件。传统方式可能要写死流程但这里模型靠系统提示词里的工具清单自主完成了拆解。我画了个简易的调用足迹大概是这样的第一次动作get_current_time拿到时间字符串第二次动作count_words统计时间字符串字数第三次动作save_markdown把时间写进time.md第四次动作finish向用户汇报保存路径。你会注意每步之间模型都能看到上一步的工具结果所以它能自然地把时间字符串作为下一步的输入这就是多步任务的实质——上下文接力。6. 实测效果与排查笔记6.1 多步任务实测数据我用gpt-4o-mini跑上面的混合任务连续10次成功完成任务9次唯一一次失败是模型把count_words的参数写成了content而不是text被schema校验拦下重试后修正了。平均每步生成耗时0.8秒左右单任务总耗时3-5秒token消耗单次任务约3000-4000 token含工具结果回灌比原生Tool Calling版本大概省20%主要省在工具定义没有重复发送。如果换成本地Qwen2.5-7B这类小模型去掉response_format参数也能跑但输出稳定性会下降重试率明显上升。这就是通用的代价模型底子差结构化的兜底手段再多也不可能完全抵消能力差距。所以无Tool Calling并不是让弱模型变强而是让强模型的动作空间更收敛。6.2 三个高频故障及修复方式先列一个我踩坑最深的表故障现象根因修复手段模型输出被markdown代码块包裹提示词约束力度不够增加只输出JSON约束 extract_json清洗参数名和schema不一致模型对工具参数理解偏差schema校验失败回灌错误信息触发重试多轮后模型开始复读历史动作上下文里残留过多工具结果截断工具结果 只保留最近3轮第一个坑很典型哪怕加了提示词约束某些模型还是会时不时包一层代码块。清洗函数是最后防线但它应该配合提示词一起用而不是只靠清洗。第二个坑要特别注意模型经常犯的不是误解语义而是字段名迁移。比如工具参数叫filename模型可能写成file_name甚至path。这种错误schema校验能抓但更有效的做法是在工具描述里直接写参数名必须是filename把参数名校验前置到提示词层。第三个坑在多步任务里很常见。有一次模型连续三轮调用get_current_time我翻日志发现是因为工具结果格式都一样模型误以为任务没完成。后来我在finish工具的提示词里写明时间一旦获取成功就可以继续下一步不需要重复获取问题就消失了。这说明工具的description不只是给程序看的更是给模型看的决策指南。6.3 什么时候你应该回到Tool Calling无Tool Calling不是银弹我也不会说它全面替代原生方案。遇到下面这些情况我反而建议你用回Tool Calling工具数量很大几十个单靠提示词描述会让模型选择困难延迟敏感的生产服务原生Tool Calling的SDK调用链更成熟、超时控制更精细你的模型本身对function calling支持很好而且你不需要强审计、强校验这类程序端控制。我这套方案的真正价值区间是模型能力参差、工具数量中等、但你需要把动作行为完全掌控在自己手里的时候。同时还附带一个好处它完全不依赖某个厂商SDK的私有实现换模型、换云、甚至换到本地推理迁移成本都很低。6.4 一个值得扩展的方向把动作协议做成版本化接口目前我这个实现的动作协议还是代码内嵌的Python类。如果Agent要开放给第三方使用建议把Action协议和工具清单转成接口定义文件比如JSON Schema或接口描述文档让外部系统可以只对接协议、不接触代码。后续这个系列我大概率会往这个方向走——把Agent的动作接口当成一个公共协议来治理而不是一套内部实现。这样Agent的通用才有了真正的边界业务可以随便换协议是稳定的。