资讯动态

告别Tool Calling:用结构化JSON构建通用Agent

发布时间:2026/10/26 23:21:52 来源:尧图企业网站定制
1. 为什么我要绕开 Tool Calling 做 Agent1.1 一个被过度神化的能力过去一年几乎所有 Agent 框架的教程都在教你同一件事定义工具、注册工具、让模型通过 Tool Calling 去调用工具。LangChain、Dify、CrewAI包括各家大模型的原生 Function Calling 接口都在强化这个范式。我一开始也是这么做的直到我在一个真实项目里连续踩了三次坑。第一次是模型兼容性。我用的某个开源模型对 Tool Calling 的支持是半成品——它能输出工具调用的 JSON但格式经常飘字段名时而tool_name时而name参数嵌套层级也不稳定。我写了一大堆解析兜底逻辑代码量比业务逻辑还多。第二次是调试困难。Tool Calling 的执行链路是黑盒的模型决定调什么、传什么参数你只能看到结果中间为什么这么决策很难追溯。第三次最要命——当工具数量超过 15 个之后模型的选择准确率肉眼可见地下降经常该调 A 却调了 B或者干脆不调。这三次经历让我开始反思一个问题Tool Calling 到底是 Agent 的必需品还是被框架厂商包装出来的标配1.2 结构化输出才是真正的底层能力我的结论是Agent 的核心不是调用工具这个动作而是模型能稳定输出结构化的决策。工具调用只是结构化输出的一种特殊形式。如果我们能让模型稳定输出一段结构化的 JSON里面包含我要做什么、用什么参数、下一步是什么那么执行层完全可以用普通代码去解析和分发根本不需要依赖模型原生的 Tool Calling 能力。这就是无 Tool Calling 的结构化通用 Agent的核心思路。它把 Agent 拆成两半模型负责思考和输出结构化意图代码负责解析和执行。两者之间用一份约定好的 JSON Schema 作为契约。这样做的好处非常直接模型无关任何能输出 JSON 的模型都能用哪怕是本地跑的小模型完全可观测每一步的决策都是明文 JSON日志里看得清清楚楚调试简单出问题直接看 JSON 哪里不对不用猜模型的心思成本可控不需要为 Tool Calling 的额外 token 付费Prompt 可以精简1.3 这套方案适合谁如果你正在做 Agent 开发遇到下面任何一种情况这套思路值得你认真看下去用的是不支持或半支持 Tool Calling 的模型工具数量多、选择混乱需要严格的可观测性和可复现性想把 Agent 逻辑和具体模型解耦。反过来如果你只是做个 demo用现成的框架一把梭那本文的方案可能有点重但理解它的原理对你没坏处。我下面会从整体设计、核心细节、实操实现、问题排查四个层面把这套方案完整拆开。所有代码都是 Python可以直接跑。2. 整体设计与思路拆解2.1 核心架构三段式循环传统 ReAct 的循环是思考-行动-观察但行动那一步依赖 Tool Calling。我的方案把行动替换成输出结构化指令整体变成三段决策段模型读取当前上下文用户输入 历史 可用能力清单输出一段 JSON包含thought思考、action要执行的动作名、params参数、done是否结束执行段代码解析 JSON根据action字段分发到对应的 Python 函数拿到执行结果观察段把执行结果作为新的上下文喂回模型进入下一轮这个循环和 ReAct 在逻辑上完全一致区别只在于行动的表达方式从模型原生工具调用变成了约定格式的 JSON 文本。听起来简单但魔鬼在细节里。2.2 为什么用 JSON 而不是别的格式我试过三种格式JSON、YAML、自定义的类 XML 标签。最后选了 JSON理由有三条。第一模型对 JSON 的生成能力最强。几乎所有模型在预训练阶段都见过海量 JSON 数据生成 JSON 的稳定性远高于 YAML。YAML 的缩进敏感特性经常让模型翻车尤其是嵌套结构。第二解析成本最低。Python 的json.loads是标准库零依赖解析失败直接抛异常好捕获。YAML 需要额外装 PyYAML而且它的解析容错性反而会掩盖模型的错误。第三Schema 约束清晰。JSON 的字段类型、嵌套关系可以用 JSON Schema 精确描述方便我做校验和自动修复。提示如果你的模型连 JSON 都输出不稳先别急着上 Agent回去把 Prompt 和 few-shot 示例打磨好。结构化输出是这套方案的地基。2.3 能力清单的设计把工具变成说明书既然不用 Tool Calling模型怎么知道有哪些能力可用答案是把能力清单写进 System Prompt。但这里有个关键设计能力清单不是简单的函数签名列表而是一份带示例的说明书。我最初的做法是列个表search(query: str) - str。结果模型经常传错参数类型或者不知道什么时候该用哪个。后来我改成每个能力都带一段自然语言描述 一个调用示例准确率立刻上来了。{ name: search_web, description: 当需要查询实时信息、新闻、事实性知识时使用。不要用于计算或代码执行。, params: {query: string, 搜索关键词尽量具体}, example: {action: search_web, params: {query: 2024年诺贝尔物理学奖得主}} }这份清单会被序列化后塞进 System Prompt。模型看到的不只是有什么能力还有什么时候用、怎么用。这是无 Tool Calling 方案能work的关键——用自然语言把工具语义讲清楚比用结构化签名更符合模型的思维方式。2.4 与主流框架的取舍对比维度Tool Calling 方案结构化 JSON 方案模型要求必须支持 Function Calling任何能输出 JSON 的模型可观测性黑盒依赖框架日志全明文每步可查调试难度高需理解框架内部低看 JSON 即可工具数量上限15 个左右开始下降30 仍可控靠描述区分开发成本框架封装上手快需自己写解析分发灵活性受框架约束完全自由这张表不是要否定 Tool Calling而是说清楚当你需要可控、可观测、模型无关时结构化 JSON 方案更合适。框架是给快速原型用的真上生产你迟早要理解底层。3. 核心细节解析与实操要点3.1 Prompt 的骨架三段式结构System Prompt 是这套方案的灵魂。我打磨了十几版最后稳定在一个三段式结构上第一段角色与规则。明确告诉模型它是什么、要遵守什么。比如你是一个任务执行 Agent每轮必须输出一个 JSON 对象不能输出任何 JSON 之外的内容。第二段输出格式定义。给出 JSON 的完整字段说明和约束。这里要极其精确字段名、类型、是否必填、取值范围都写清楚。第三段能力清单 few-shot 示例。列出所有可用能力并给 2-3 个完整的输入输出示例。SYSTEM_PROMPT 你是一个任务执行 Agent。你的工作是分析用户需求决定下一步动作并以 JSON 格式输出决策。 ## 输出格式 你必须输出一个 JSON 对象包含以下字段 - thought: string, 你的推理过程说明为什么选择这个动作 - action: string, 要执行的动作名必须是能力清单中的某一个或者 finish - params: object, 动作的参数没有参数时为空对象 {} - done: boolean, 任务是否已完成 ## 可用能力 {capabilities} ## 示例 用户北京今天天气怎么样 输出{{thought: 用户想知道北京天气需要查询实时信息, action: search_web, params: {{query: 北京今天天气}}, done: false}} 用户谢谢我知道了 输出{{thought: 用户表示任务结束无需进一步动作, action: finish, params: {{}}, done: true}} 现在开始。只输出 JSON不要有任何其他文字。注意{capabilities}是占位符实际运行时替换成能力清单。示例里的{{是转义因为用了 f-string 或 format。3.2 JSON 解析的容错处理模型输出 JSON 再稳也有翻车的时候。常见的翻车姿势有四种前后带了多余文字好的这是结果{...}、用了单引号、末尾多了逗号、字段缺失。我的解析函数要能处理这些。import json import re def parse_agent_output(text: str) - dict: 从模型输出中提取并解析 JSON带多重容错 # 第一步尝试直接解析 try: return json.loads(text.strip()) except json.JSONDecodeError: pass # 第二步提取第一个完整的 JSON 对象 match re.search(r\{.*\}, text, re.DOTALL) if match: candidate match.group() # 修复常见问题单引号、尾逗号 candidate candidate.replace(, ) candidate re.sub(r,\s*([}\]]), r\1, candidate) try: return json.loads(candidate) except json.JSONDecodeError: pass # 第三步彻底失败返回一个安全的默认决策 return { thought: 解析失败无法确定动作, action: finish, params: {}, done: True, parse_error: True }这个函数的关键在于永远不抛异常。Agent 循环里最怕的就是一个解析错误把整个流程打断。宁可返回一个安全失败的决策让循环优雅结束也不要让程序崩溃。注意第三步的兜底决策里我加了个parse_error标记方便后续统计模型输出的稳定性。如果这个标记频繁出现说明 Prompt 需要优化。3.3 能力分发的实现执行段的核心是一个分发器根据action字段找到对应的 Python 函数并调用。我用一个字典做映射简单直接。class AgentExecutor: def __init__(self): self.capabilities {} def register(self, name: str, func, description: str, params_desc: dict, example: dict): 注册一个能力 self.capabilities[name] { func: func, description: description, params: params_desc, example: example } def execute(self, action: str, params: dict) - str: 执行一个动作返回结果字符串 if action finish: return [任务结束] if action not in self.capabilities: return f[错误] 未知动作{action}。可用动作{list(self.capabilities.keys())} try: result self.capabilities[action][func](**params) return str(result) except TypeError as e: return f[错误] 参数不匹配{e}。该动作需要参数{self.capabilities[action][params]} except Exception as e: return f[错误] 执行失败{type(e).__name__}: {e}这里有个设计要点执行失败不抛异常而是返回错误字符串。因为错误信息本身也是给模型的观察结果模型看到错误后可以自我修正比如换个参数重试。这是 Agent 自我纠错能力的基础。3.4 上下文管理别让历史撑爆窗口Agent 循环跑久了上下文会越来越长。我的做法是保留完整的决策历史但压缩观察结果。具体来说每一轮的thought和action完整保留这些是推理链很重要但执行结果如果太长就截断。def build_context(history: list, max_obs_len: int 500) - str: 把历史记录拼成上下文长观察结果截断 lines [] for i, step in enumerate(history): lines.append(f--- 第 {i1} 轮 ---) lines.append(f思考{step[thought]}) lines.append(f动作{step[action]}) obs step[observation] if len(obs) max_obs_len: obs obs[:max_obs_len] f...[截断原长 {len(obs)}] lines.append(f观察{obs}) return \n.join(lines)max_obs_len设 500 是个经验值。太短会丢失关键信息太长会挤占推理空间。如果你的任务需要处理长文档可以把这个值调大但要相应控制历史轮数。4. 实操过程与核心环节实现4.1 完整可运行的骨架下面是一个能直接跑的最小 Agent。我注册了两个能力一个计算器、一个模拟搜索。你可以照着这个结构加自己的工具。import json import re class StructuredAgent: def __init__(self, llm_call, max_steps: int 10): llm_call: 一个函数接收 prompt 字符串返回模型输出字符串 max_steps: 最大循环轮数防止死循环 self.llm_call llm_call self.max_steps max_steps self.capabilities {} def register(self, name, func, description, params_desc, example): self.capabilities[name] { func: func, description: description, params: params_desc, example: example } def _build_capability_text(self) - str: parts [] for name, cap in self.capabilities.items(): parts.append(f### {name}) parts.append(f描述{cap[description]}) parts.append(f参数{json.dumps(cap[params], ensure_asciiFalse)}) parts.append(f示例{json.dumps(cap[example], ensure_asciiFalse)}) parts.append() return \n.join(parts) def _build_system_prompt(self) - str: return SYSTEM_PROMPT.replace({capabilities}, self._build_capability_text()) def _parse(self, text: str) - dict: try: return json.loads(text.strip()) except json.JSONDecodeError: pass match re.search(r\{.*\}, text, re.DOTALL) if match: candidate match.group().replace(, ) candidate re.sub(r,\s*([}\]]), r\1, candidate) try: return json.loads(candidate) except json.JSONDecodeError: pass return {thought: 解析失败, action: finish, params: {}, done: True, parse_error: True} def _execute(self, action: str, params: dict) - str: if action finish: return [任务结束] if action not in self.capabilities: return f[错误] 未知动作{action}。可用{list(self.capabilities.keys())} try: return str(self.capabilities[action][func](**params)) except TypeError as e: return f[错误] 参数不匹配{e} except Exception as e: return f[错误] {type(e).__name__}: {e} def run(self, user_input: str, verbose: bool True) - str: system_prompt self._build_system_prompt() history [] final_answer for step in range(self.max_steps): context self._build_context(history) full_prompt f{system_prompt}\n\n## 用户需求\n{user_input}\n\n## 历史\n{context}\n\n请输出下一步决策的 JSON raw self.llm_call(full_prompt) decision self._parse(raw) if verbose: print(f[第 {step1} 轮] 思考{decision.get(thought)}) print(f 动作{decision.get(action)} 参数{decision.get(params)}) if decision.get(done) or decision.get(action) finish: final_answer decision.get(thought, ) break observation self._execute(decision[action], decision.get(params, {})) history.append({ thought: decision.get(thought, ), action: decision[action], observation: observation }) if verbose: print(f 观察{observation[:100]}) return final_answer def _build_context(self, history: list, max_obs_len: int 500) - str: if not history: return 无历史这是第一轮 lines [] for i, step in enumerate(history): lines.append(f--- 第 {i1} 轮 ---) lines.append(f思考{step[thought]}) lines.append(f动作{step[action]}) obs step[observation] if len(obs) max_obs_len: obs obs[:max_obs_len] f...[截断] lines.append(f观察{obs}) return \n.join(lines)4.2 注册能力并跑起来def calc(expression: str) - str: 一个安全的计算器只允许数字和基本运算符 if not re.match(r^[\d\s\\-\*/\(\)\.]$, expression): return 表达式包含非法字符 try: return str(eval(expression)) except Exception as e: return f计算错误{e} def search_web(query: str) - str: 模拟搜索实际项目里替换成真实搜索 API fake_db { 北京天气: 北京今天晴气温 15-25 度, python 版本: Python 最新稳定版是 3.12, } for key, val in fake_db.items(): if key in query: return val return f未找到关于「{query}」的信息 # 假设你有一个 llm_call 函数 def my_llm(prompt: str) - str: # 这里替换成你真实的模型调用 # 返回模型输出的字符串 ... agent StructuredAgent(llm_callmy_llm, max_steps8) agent.register( namecalc, funccalc, description执行数学计算。当需要做加减乘除、幂运算时使用。, params_desc{expression: string, 数学表达式如 23*4}, example{action: calc, params: {expression: 23*4}} ) agent.register( namesearch_web, funcsearch_web, description查询实时信息、事实性知识。不要用于计算。, params_desc{query: string, 搜索关键词}, example{action: search_web, params: {query: 北京天气}} ) result agent.run(北京天气怎么样另外帮我算一下 25 乘以 4 等于多少) print(最终结果, result)跑起来之后你会看到类似这样的输出[第 1 轮] 思考用户有两个需求先查天气 动作search_web 参数{query: 北京天气} 观察北京今天晴气温 15-25 度 [第 2 轮] 思考天气查完了现在算 25 乘以 4 动作calc 参数{expression: 25*4} 观察100 [第 3 轮] 思考两个需求都完成了 动作finish 参数{} 最终结果北京今天晴气温 15-25 度25 乘以 4 等于 1004.3 参数计算与选择过程上面代码里有几个参数是我反复调过的说一下取值逻辑。max_steps8这个值取决于任务复杂度。简单问答 3-5 步够用多步推理任务可能需要 10-15 步。设太小任务做不完设太大死循环浪费 token。我的经验是先设 10观察实际任务的步数分布取 P95 值再加 2。max_obs_len500观察结果截断长度。这个值和你的模型上下文窗口有关。假设窗口 8K tokenSystem Prompt 占 1K历史占 4K那留给观察的空间大概是 3K token约 2000 字符。如果历史有 5 轮每轮观察平均 400 字符正好。所以 500 是个保守值。temperature这个在llm_call里控制不在 Agent 类里。结构化输出任务建议 temperature 设 0 到 0.3。太高会让 JSON 格式飘太低0有时会让模型陷入重复。我一般用 0.1。4.4 一个真实场景的完整记录我拿这套方案做过一个文档问答 Agent任务是给一份产品需求文档回答关于功能点的问题。能力清单里注册了三个工具search_doc在文档里检索、summarize总结一段文字、compare对比两个功能点。实际跑的时候模型的表现是这样的用户问登录和注册功能有什么区别模型第一轮输出search_doc查登录第二轮查注册第三轮调compare对比两个结果第四轮finish给出答案。整个过程 4 轮每轮的 JSON 都干净利落日志里一目了然。对比之前用 Tool Calling 的版本最大的改善是调试时间从平均 20 分钟降到 3 分钟。以前出问题要翻框架源码看它怎么解析工具调用现在直接看 JSON 就知道模型哪一步想错了。5. 常见问题与排查技巧实录5.1 模型不输出 JSON 怎么办这是最常见的问题尤其是用小模型或者 Prompt 没写好的时候。模型可能输出一段自然语言或者输出 JSON 但前后带一堆解释。排查顺序是这样的先看 System Prompt 里只输出 JSON的指令够不够强硬。我一般会加一句任何 JSON 之外的文字都会导致解析失败用后果来约束。再看 few-shot 示例够不够。至少给 2 个完整示例让模型有样学样。最后看 temperature调低到 0.1 试试。如果都试了还不行那可能是模型能力问题。我实测下来7B 以上的模型配合好的 PromptJSON 输出稳定性能到 95% 以上。再小的模型就有点勉强了。5.2 模型陷入死循环表现是模型反复调用同一个工具或者在同一轮里打转。原因通常是观察结果没有提供新信息模型不知道该干嘛了。我的解法是在 Prompt 里加一条规则如果连续两轮的动作和参数完全相同说明陷入了循环应该换策略或直接 finish。同时在代码层面加个检测如果连续两轮的action和params完全一样强制注入一条提示你上一步做了相同的动作请换一种方式或结束任务。5.3 参数类型错误模型经常把数字传成字符串或者把列表传成逗号分隔的字符串。比如calc需要expression是字符串模型可能传{expression: 23}数字。解法有两个层面。Prompt 层面在参数描述里明确写类型比如expression: string, 必须是字符串。代码层面在执行前做一次类型转换尝试def coerce_params(params: dict, expected: dict) - dict: 尝试把参数转换成期望的类型 result {} for key, value in params.items(): if key in expected and string in expected[key] and not isinstance(value, str): result[key] str(value) else: result[key] value return result5.4 常见问题速查表现象可能原因排查方向解决手段输出非 JSONPrompt 约束弱检查 System Prompt加强只输出 JSON指令加示例JSON 解析失败格式飘看原始输出用容错解析函数修复单引号尾逗号死循环观察无新信息看历史记录加循环检测Prompt 加换策略规则参数类型错模型理解偏差看 params 字段参数描述写清类型代码做转换选错工具工具描述不清看能力清单每个工具加什么时候用/不用说明任务做不完max_steps 太小看步数分布调大 max_steps或拆分任务上下文超长历史累积看 token 数截断观察结果限制历史轮数5.5 几个我踩过的坑坑一能力清单太长导致模型忽略后面的工具。我一开始把 20 个工具全塞进 Prompt结果模型只用前 5 个。后来改成按任务动态加载能力清单——根据用户输入先做一次粗分类只把相关的能力塞进去。准确率立刻上来了。坑二finish动作的 thought 被当成最终答案。我最初设计里finish时模型的thought就是答案。但模型有时会在thought里写任务完成这种废话而不是真正的答案。后来我改成finish时要求模型把答案放在params.answer字段里thought只放推理过程。这样职责清晰多了。坑三错误信息太长反而干扰模型。执行失败时如果把完整的 Python traceback 塞回去模型会被吓到经常直接放弃。后来我只返回错误类型和一句话说明模型反而能根据简短提示自我修正。坑四忘了处理空参数。有些动作不需要参数模型可能输出params: null而不是{}。我的执行器一开始没处理 null直接**None报错。加一行params params or {}就好了。6. 进阶优化与扩展方向6.1 用 JSON Schema 做严格校验前面用的是手写解析更工程化的做法是用jsonschema库做校验。定义一个 Schema每次解析后校验一遍不符合的直接触发重试。from jsonschema import validate, ValidationError DECISION_SCHEMA { type: object, required: [thought, action, params, done], properties: { thought: {type: string}, action: {type: string}, params: {type: object}, done: {type: boolean} } } def validate_decision(decision: dict) - bool: try: validate(instancedecision, schemaDECISION_SCHEMA) return True except ValidationError: return False校验失败时把错误信息作为观察结果喂回去让模型重新输出。这个校验-重试循环能显著提升稳定性。6.2 并行执行多个动作有些任务里多个动作之间没有依赖关系可以并行。比如同时查三个城市的天气。我的做法是允许模型在action字段里输出一个列表执行器检测到列表就并行跑。if isinstance(decision[action], list): # 并行执行 import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as executor: futures [ executor.submit(self._execute, a, p) for a, p in zip(decision[action], decision[params]) ] observations [f.result() for f in futures] observation \n.join(observations)这个优化能把多步任务压缩成一步省 token 又提速。但要注意只有无依赖的动作才能并行有依赖的必须串行。6.3 记忆机制的引入Agent 跑多轮对话时需要记住之前聊过什么。我的做法是在 Agent 外面套一层记忆管理把每轮对话的摘要存起来下次对话时作为背景信息注入。class Memory: def __init__(self, max_items: int 10): self.items [] self.max_items max_items def add(self, user_input: str, answer: str): self.items.append({user: user_input, answer: answer}) if len(self.items) self.max_items: self.items.pop(0) def to_context(self) - str: if not self.items: return lines [## 历史对话] for item in self.items: lines.append(f用户{item[user]}) lines.append(f回答{item[answer]}) return \n.join(lines)记忆的关键是摘要而非原文。存原文会迅速撑爆上下文存摘要才能长期维持。摘要可以用模型生成也可以简单截断。6.4 从单 Agent 到多 Agent 协作当任务复杂到单个 Agent 搞不定时可以拆成多个专职 Agent。比如一个规划 Agent负责拆解任务多个执行 Agent负责具体动作。它们之间通过结构化 JSON 通信天然兼容。这个方向展开能写一整篇这里只点一下多 Agent 的核心难点不是通信而是任务分配和结果聚合。结构化 JSON 方案在这两点上有天然优势因为每个 Agent 的输入输出都是明确的 JSON聚合逻辑用代码写就行。7. 我个人的一些实操体会这套方案我用了大半年从原型到生产都跑过。最大的感受是Agent 的复杂度不应该来自框架而应该来自任务本身。Tool Calling 框架帮你省了写解析分发的那点代码但引入了模型兼容性、黑盒调试、token 开销三个新问题。算总账对于需要长期维护的项目自己写结构化方案反而更划算。另一个体会是Prompt 工程在这套方案里的权重比想象中高。同样的代码Prompt 打磨得好和差任务成功率能差一倍。我现在的习惯是代码写完后花至少一半时间在 Prompt 上加示例、调措辞、测边界情况。最后分享一个小技巧给每个能力都写一个反例。比如search_web的描述里加一句不要用于计算计算请用 calc。模型对不要做什么的敏感度比要做什么还高加上反例能显著减少误用。这套方案不是银弹它适合需要可控性和可观测性的场景。如果你追求快速出原型用现成框架没问题。但当你开始被框架的边界卡住时回头看看这套思路也许能帮你打开一扇门。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑