1. 为什么我要绕开 Tool Calling 做 Agent
1.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"--- 第 {i+1} 轮 ---") 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_ascii=False)}") parts.append(f"示例:{json.dumps(cap['example'], ensure_ascii=False)}") 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"[第 {step+1} 轮] 思考:{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"--- 第 {i+1} 轮 ---") 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_call=my_llm, max_steps=8) agent.register( name="calc", func=calc, description="执行数学计算。当需要做加减乘除、幂运算时使用。", params_desc={"expression": "string, 数学表达式,如 '2+3*4'"}, example={"action": "calc", "params": {"expression": "2+3*4"}} ) agent.register( name="search_web", func=search_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_steps=8:这个值取决于任务复杂度。简单问答 3-5 步够用,多步推理任务可能需要 10-15 步。设太小任务做不完,设太大死循环浪费 token。我的经验是先设 10,观察实际任务的步数分布,取 P95 值再加 2。
max_obs_len=500:观察结果截断长度。这个值和你的模型上下文窗口有关。假设窗口 8K token,System 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 以上的模型配合好的 Prompt,JSON 输出稳定性能到 95% 以上。再小的模型就有点勉强了。
5.2 模型陷入死循环
表现是模型反复调用同一个工具,或者在同一轮里打转。原因通常是观察结果没有提供新信息,模型不知道该干嘛了。
我的解法是在 Prompt 里加一条规则:"如果连续两轮的动作和参数完全相同,说明陷入了循环,应该换策略或直接 finish"。同时在代码层面加个检测:如果连续两轮的action和params完全一样,强制注入一条提示"你上一步做了相同的动作,请换一种方式或结束任务"。
5.3 参数类型错误
模型经常把数字传成字符串,或者把列表传成逗号分隔的字符串。比如calc需要expression是字符串,模型可能传{"expression": 2+3}(数字)。
解法有两个层面。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 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 输出非 JSON | Prompt 约束弱 | 检查 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(instance=decision, schema=DECISION_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"。模型对"不要做什么"的敏感度比"要做什么"还高,加上反例能显著减少误用。
这套方案不是银弹,它适合需要可控性和可观测性的场景。如果你追求快速出原型,用现成框架没问题。但当你开始被框架的边界卡住时,回头看看这套思路,也许能帮你打开一扇门。