☰
无 Tool Calling 的结构化通用 Agent:设计思路与代码实践
2026/10/27 22:39:46 网站建设 项目流程

“Agent实践”这个系列写到第5篇,反而要聊一个看起来有点“逆潮流”的话题——无 Tool Calling 的结构化通用 Agent。为什么说逆潮流?因为现在不管是国内头部大模型API还是开源模型,基本都内置了 Function Calling / Tool Calling 支持,LangChain、Dify、CrewAI 这些框架更是把这个能力当成默认配置,网上一搜就是“三步让 Agent 学会调用工具”的教程。

但我自己确实在几个真实项目里遇到了必须放弃 Tool Calling 的场景,而且这些场景不是个例:一是自建或本地部署的开源模型不支持函数调用,模型本身没这个能力;二是业务流程比较敏感,比如工单自动处理和客服机器人,用 Tool Calling 会出现工具选择不稳定、参数乱填的问题,反而把流程搞坏;三是项目要求彻底解耦模型厂商,同一个 Agent 要能无缝切换不同后端,这时候 Tool Calling 各家定义不一致就成了致命伤。这个标题里的“结构化通用 Agent”,本质上就是一套不依赖平台函数调用机制的替代方案:让模型只输出结构化JSON,由代码负责意图路由和工具执行。

我尽量把这篇文章写得实在一点,把设计思路、踩坑记录和可以直接复制的代码都放出来。不管你是刚接触 Agent 开发,还是已经写过不少工具调用流程,应该都能从这里拿到点有用的东西。

1. 先把 Tool Calling 的原理拆开看,再说服你为什么要绕开它

1.1 Tool Calling 的本质是“把工具决策权交给文本生成”

很多人第一次用 Function Calling 的时候会觉得“模型成精了,居然会自己选工具”。其实没那么玄乎。大模型本质上还是一个“文本接龙”系统,它并不知道自己真的调用了一个函数。Tool Calling 的工作机制是:开发者预先定义一组工具,把函数的名称、功能描述、参数结构(通常是 JSON Schema)一并塞进系统提示词或者 API 的 tools 参数里;模型读完这些工具的说明后,根据用户当前输入生成一段结构化的“意图声明”,比如“我要调用 query_order,参数是 order_id=123456”。然后代码拿到这段声明,去执行真实函数,把结果回填给模型,模型再基于执行结果写回复。

整个过程下来你会发现,所谓的“工具调用”其实是两段拼接:第一段是模型根据文本生成决策JSON,第二段是开发者代码根据JSON执行函数。了解这个本质之后,无 Tool Calling 方案就变得很好理解了——它只不过是把第一段里的所有工具选择逻辑全部交回给代码,模型只负责输出“用户意图”和“关键参数”,剩下的事全由程序调度。

1.2 Tool Calling 的隐形成本和限制

我并不是说 Tool Calling 一无是处。在模型能力足够强、工具数量不多、参数定义清晰的情况下,它确实能做出非常灵活的 Agent。但我经历过的坑也不少,这里细数一下:

  • 幻觉工具调用:模型在不确定该用哪个工具时,会强行挑选一个看着“沾边”的工具,然后编造参数。尤其在用户表达模糊的时候,比如“我想处理一下我买的东西”,模型直接调用了退货工具,实际上用户可能只是想查物流。
  • 工具数量膨胀后的选择困难:当工具列表超过10个,模型的准确率会肉眼可见下降。我做过一个后台管理 Agent,工具定义堆了二十多个,结果模型频繁选错,最后只能砍工具数量硬憋。
  • Token 开销僵化:不管用户问什么,系统都要把全部工具定义发给模型。工具描述写详细一点,一次请求就要多干几百甚至上千个 token。高并发场景下这笔开销非常可观。
  • 厂商绑定风险:不同平台的 tools 参数格式并不统一。有的用functions,有的用tools,返回内容的字段名和嵌套结构也有差异。一旦要换模型后端,工具调用的改造工作量会大得你怀疑人生。
  • 多轮状态管理困难:Tool Calling 天然是“无状态”的,每轮都要把所有历史消息和工具定义一起传进去,模型才能稍微记得住上下文。一旦轮次变长,上下文膨胀得厉害,成本直线上升。

1.3 什么情况下真的应该选无 Tool Calling 方案

说了这么多限制,直接给结论。如果你遇到下面任意一种情况,我建议认真考虑无 Tool Calling 的结构化 Agent:

  • 你使用的模型本身不支持 Function Calling(不少本地部署的开源模型、自训练的垂直模型就是这种情况)。
  • 业务流程是有限枚举的,所有工具和操作都能提前列死。比如客服系统就那几个意图:查订单、退货、改地址、转人工。这种场景根本不需要模型动态发明工具名,只需要在预置列表里作选择题。
  • 你希望 Agent 逻辑完全可控,不想让模型“自由发挥”决定哪个工具跑哪个函数——出问题好排查。
  • 你需要同一个 Agent 代码跑在不同模型厂商之上,不绑死任何一个。

在以上这些场景里,无 Tool Calling 反而比“正统”的 Tool Calling 更可靠,也更便宜。

2. 结构化通用 Agent 的整体设计思路

2.1 核心转变:把“工具选择”从模型手里收回来

在设计这套无 Tool Calling 的 Agent 时,我脑海里最核心的一句话是:“模型当规划员,代码当执行员。” 打个比方:传统 Tool Calling 是把施工方案外包给一个不太靠谱的中间商,你不仅要把全套图纸给他,还得指望他选对供应商;而无 Tool Calling 方案里,你只让模型告诉你“客户想盖什么房子”,具体的钢筋水泥采购、施工队伍调度全部由你代码里的路由逻辑来搞定。

这就意味着你要在系统中定义一个**意图(Intent)**的概念。用户输入进来以后,模型唯一要输出的就是一段结构化JSON,里面写清楚:

  • intent:用户到底想做什么(属于哪个意图)。
  • params:执行这个意图需要的参数(从用户话里提取出来)。
  • reply:给用户的一句自然语言回复。

代码拿到这段JSON之后,按照intent找到对应的处理函数,传参执行,把结果拼进回复里返回给用户。如果模型不知道该归到哪个意图,就输出unknown,代码可以走兜底流程。

2.2 结构化输出的三种实现路径

“结构化输出”这四个字听着简单,做起来有几个层次。我把常见的做法列了一下,你在自己项目里可以按需选择:

实现方式可靠程度实现成本适用场景
纯提示词约束中低极低模型能力强、输出稳定性要求不高的快速原型
提示词 + JSON解析 + 重试中高低大多数生产环境,推荐
语法级约束(如 JSON Mode / Grammar)高中API支持时优先用,能彻底杜绝非法JSON

2.3 意图路由与状态管理:不靠模型靠状态机

无 Tool Calling 方案里最容易翻车的是状态管理。Tool Calling 靠模型内嵌的历史消息来“回忆”用户上下文,而我们这套方案必须自己管状态。我是这么设计的:每个会话维护一个session_id,每个 session 里有一个state字段,用来存业务上的阶段性状态,比如“未登录”“已选择商品”“待确认支付”。

模型每次输出结构化的意图之后,代码不是立刻无脑执行,而是先检查当前状态是否允许执行这个意图。举个例子:用户还没登录,意图却是cancel_order,那直接执行肯定出问题。这时候代码会拦截掉这个意图,返回一条“请先登录”的回复,或触发登录意图。这种逻辑用状态机表达,可读性和可维护性都远比把一堆历史消息丢给模型,指望它自己推断要靠谱得多。而且一旦出问题,你可以直接在代码里查某个状态下为什么某个意图被拒绝了,完全不需要猜测模型的“脑回路”。

3. 手把手搭一个可运行的结构化通用 Agent

3.1 第一步:设计意图清单和系统提示词

既然要做“通用”,当然不能把意图写死在代码里。我通常是维护一个配置文件或 Python 字典,把当前业务的所有意图、参数说明、处理函数映射关系都放在一起。下面是一个电商客服场景的例子:

INTENTS = { "query_order": { "description": "用户查询订单状态、物流信息", "params": {"order_id": "string, 订单编号"}, "handler": "handle_query_order", }, "create_order": { "description": "用户创建新订单", "params": {"product_id": "string", "quantity": "integer"}, "handler": "handle_create_order", }, "cancel_order": { "description": "用户取消未发货的订单", "params": {"order_id": "string", "reason": "string, 可选"}, "handler": "handle_cancel_order", }, "return_goods": { "description": "用户申请退货", "params": {"order_id": "string", "reason": "string"}, "handler": "handle_return_goods", }, "unknown": { "description": "无法确定用户意图时使用", "params": {}, "handler": "handle_unknown", }, }

有了这个意图清单,下一步是把它拼进系统提示词里。我在项目里的做法是写一个build_system_prompt()函数,动态生成提示词。这样新增一个意图,只需要在字典里加一项,提示词会自动更新,不用手工维护一大段文本,省心很多。

def build_system_prompt(intents: dict) -> str: intent_lines = [] for name, meta in intents.items(): desc = meta["description"] params = json.dumps(meta["params"], ensure_ascii=False) intent_lines.append(f"- {name}: {desc}, 参数: {params}") intent_section = "\n".join(intent_lines) prompt = f""" 你是一个结构化智能助手。你必须严格按照用户给定的意图列表来理解用户请求。 只输出 JSON,不要输出任何其他文字或 Markdown 代码块。 可用意图: {intent_section} 输出格式: {{ "intent": "意图名称", "params": {{ "参数名": "从用户话中提取的值" }}, "reply": "一句话回复用户" }} 规则: 1. 必须从可用意图中选择一个,不要发明新意图。 2. 如果用户的需求不在任何意图中,选择 unknown。 3. 提取参数时必须严格使用用户提到的信息,信息不足时留空字符串。 4. reply 要自然、简洁,使用中文。 """ return prompt

这里有个很重要的点:提示词里给的示例越准,模型输出的质量越高。很多人忽略这点,直接在提示词里写“你要输出JSON”,然后就没有然后了。我一般会额外加一个少样本示例块,比如给一个真实对话和对应的输出JSON,模型照着样例写基本不会跑偏。

3.2 第二步:实现 JSON 提取与校验层

模型输出进来,第一步是把 JSON 从文本里“抠”出来。虽然你告诉他不要输出 Markdown 代码块,但总有模型不听使唤,前面加个json,后面加个,甚至还会说两句废话。所以提取这层必须写得健壮,不能直接json.loads(整个content)。

下面这个extract_json函数是我一直在用的,靠正则把最外层大括号内容抓出来,再去做解析。实测能处理 90% 以上的不规范输出。

import json import re def extract_json(text: str) -> dict: text = text.strip() # 去掉 markdown 代码块围栏 text = re.sub(r"^```(?:json)?\s*", "", text) text = re.sub(r"\s*```$", "", text) try: return json.loads(text) except json.JSONDecodeError: pass # 尝试抓取最外层 JSON 对象 match = re.search(r"\{.*\}", text, re.DOTALL) if match: try: return json.loads(match.group(0)) except json.JSONDecodeError: pass raise ValueError("无法从模型输出中解析出合法 JSON")

3.3 第三步:封装带重试机制的结构化调用

解析失败不要慌,与其自己硬猜,不如把解析失败这件事反馈给模型,让它自己重新输出一次。我在get_structured_response函数里加了一个最多重试两次的机制:第一次返回不合法,就把错误信息拼进消息列表,提醒模型重新生成;第二次还是不行,直接抛异常并告警,切到兜底回复“抱歉,刚才我没有理解您的问题”。

from openai import OpenAI client = OpenAI(base_url="你的API地址", api_key="你的API Key") def get_structured_response(messages, system_prompt, model="gpt-4o", max_retries=2): full_messages = [{"role": "system", "content": system_prompt}] + messages for attempt in range(max_retries + 1): response = client.chat.completions.create( model=model, messages=full_messages, temperature=0.1, response_format={"type": "json_object"}, # 如果后端支持的话 ) content = response.choices[0].message.content try: parsed = extract_json(content) return parsed except ValueError: if attempt < max_retries: # 把错误回传给模型,让它重新生成 full_messages.append({"role": "assistant", "content": content}) full_messages.append({ "role": "user", "content": "你刚才的输出不是合法 JSON。请重新只输出一个 JSON 对象,不要包含任何其他内容。" }) continue raise

这里有两个细节需要说明。一是temperature设成 0.1,目的是降低模型的随机性,保证输出格式稳定;如果你用response_format参数而你的后端不支持,会直接报错,所以在调response_format之前最好确认一下服务商兼容性。二是我把system_prompt也放在参数里,这样更换业务角色、更换意图集合的时候,函数本身不用动,只传新的提示词进去就好,符合“通用”两个字。

3.4 第四步:写一个简易 Agent 类,路由意图并管理会话状态

前面几步都是“零件”,现在把零件组装起来。我写了一个很轻量的StructuredAgent类,维护一个全局的session_states字典,每个会话记住自己的状态。每次用户输入进来,Agent 先获取历史消息,调用结构化接口拿到 intent,然后根据 intent 找到处理函数执行,最后把模型回复和处理结果拼起来返回。

class StructuredAgent: def __init__(self, system_prompt_builder, handlers): self.system_prompt_builder = system_prompt_builder self.handlers = handlers self.session_states = {} # session_id -> state self.session_messages = {} # session_id -> list of messages def _get_state(self, session_id: str) -> dict: if session_id not in self.session_states: self.session_states[session_id] = {"state": "init"} self.session_messages[session_id] = [] return self.session_states[session_id] def chat(self, session_id: str, user_input: str): state = self._get_state(session_id) messages = self.session_messages[session_id] messages.append({"role": "user", "content": user_input}) system_prompt = self.system_prompt_builder() # 传入意图清单 result = get_structured_response(messages, system_prompt) intent = result.get("intent", "unknown") params = result.get("params", {}) reply = result.get("reply", "") handler = self.handlers.get(intent, self.handlers["unknown"]) # 执行真实业务逻辑,返回 (处理结果文本, 新状态) handle_result, new_state = handler(state, params, messages) # 更新会话状态和消息历史 state["state"] = new_state.get("state", state["state"]) messages.append({"role": "assistant", "content": reply}) return reply + handle_result

这个方案把 Agent 的执行路径拉得非常直白:模型只负责输出JSON,代码负责一切。出现任何问题,你都可以从日志里直接看到这个 session 的意图、参数、模型回复、状态迁移,逐段排查。

3.5 关于安全的一个补充

Agent 安全是个大话题。我在这套无 Tool Calling 架构里额外做了一层“安全检查器”,在意图执行前拦截敏感操作。比如delete_order这种高风险的意图,就算模型识别对了、参数齐全,代码也会检查这个用户是否有删除权限、订单状态是否允许删除,而不是傻乎乎地直接执行。这比把安全完全寄托在模型“能选对工具”上要可靠得多。

4. 踩坑记录与排查技巧实录

4.1 模型输出不稳定,JSON解析老是失败

这是最容易遇到的问题,表现形式有几种:模型在 JSON 前面加了“好的,这是你要的:”、输出里夹杂了//注释、JSON 里多了一个逗号、键名被翻译成了中文。我的应对组合拳是:

  • 把temperature调到 0.1,甚至 0。
  • 系统提示词里加上“不要输出 Markdown 代码块”的明确指令。
  • 强烈建议在提示词里给出一个完整的输出示例,模型跟着格式写基本不会出格。
  • 保留extract_json这个容错函数,兜底处理。
  • 重试机制最多两轮,再多就是浪费钱。

4.2 意图误判,用户明明要退货,模型选了退款

这种问题的根因通常不是模型太笨,而是意图定义之间有重叠。上一步我提到过cancel_order和return_goods,这两个在语义上边界本来就模糊。解决办法是在意图描述里把边界写清楚:“取消订单是指订单尚未发货,用户不想买了;退货是指订单已发货/已签收,用户申请退回商品。”

如果模型还是反复误判,就在少样本示例里加入这条边界对比:同一个用户话说一半,分别给出两个意图的示例输出。这个比写十行文字说明都管用。

4.3 多轮对话历史导致上下文膨胀

session_messages如果一直无脑追加,每轮对话都会把所有历史消息传给模型,成本越来越高,且模型被“淹”在无关历史里反而会降低意图识别正确率。我后来加了两个策略:

  • 只保留最近5到6轮的消息,更早的放进摘要里,每轮结束后把已归档的对话压缩成一句摘要字符串,下次请求时作为一条 system message 传入。
  • 用state字段而不是历史消息来判断业务状态。意图路由看state,对话历史主要是用来做语义理解的,两者分开。

4.4 常见问题速查表

整理了一份我个人项目里的排查表,按症状查原因,效率会比瞎猜高很多。

症状可能原因解决思路
JSON 解析频繁失败提示词没有给示例 / temperature 过高加入少样本示例,降低 temperature,开启重试
意图总是 unknown意图描述过于模糊或缺失细化描述,补充边界案例
两个意图经常混淆意图语义重叠在描述中加入排除条件,加对比示例
参数提取为空用户信息确实没给全在 reply 里追问,而不是返回空参数执行
执行的业务逻辑报错没做参数合法性校验在 handler 入口加类型、范围检查
多轮后识别准确率下降上下文过长,模型注意力跑偏历史裁剪 + 摘要策略
模型输出了 JSON 但没有 reply 字段提示词格式被模型忽略后处理校验必填字段,缺失时用默认文案

4.5 一个容易被忽略的细节:参数校验

模型提取参数的值并不可靠。我遇到过提取订单号时把用户说的“订单号是 123456”整个句子当参数值塞进去,也有把“两件”这种数量单位漏掉的情况。处理办法是在 handler 里做前置校验:参数必须能通过正则匹配(比如订单号要求纯数字且长度固定),不匹配就返回提示消息要求用户补充准确信息。先校验,后执行,这五个字值得刻在工位上。

5. 这种方案的适用边界和扩展方向

5.1 它能做什么,不能做什么

无 Tool Calling 的结构化 Agent 不是万能的,它的核心假设是“意图可以被提前枚举”。如果你的业务需要模型自己创造工具组合、处理完全开放式任务,比如让 Agent 自己写代码去调未知API,那这套方案确实不适用,老老实实上 Tool Calling 或者更复杂的 ReAct 框架更合适。但反过来看,很多实际生产系统的核心流程是相对固定的,像智能客服、工单分拣、审批流、售后处理,这类场景用无 Tool Calling 方案反而能得到更稳定可控的效果。

5.2 后续扩展方向

目前这个版本还比较基础,我接下来准备做两件事:一个是把意图路由升级成可配置的多级路由,比如“大意图 - 子意图”,在用户几句话内跨多个意图完成任务;另一个是把状态机从手写字典改成标准状态机库,让复杂流程的状态迁移可视、可审计。后面有进展我会继续更新在“Agent实践”系列里。

最后分享一点个人体会:不管用什么架构,Agent 的核心不是“让模型自由发挥”,而是“让模型在约束内发挥”。Tool Calling 是一种约束,结构化输出也是一种约束,而且后者在很多场景下更轻、更稳、更好排查。写代码的时间越久,我越倾向于这种“笨但可靠”的做法——毕竟线上出问题的时候,能靠日志快速定位原因,比什么都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询