资讯详情 无 Tool Calling 的结构化通用 Agent:设计思路与代码实践
📅 2026/10/27 22:39:54
“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_id123456”。然后代码拿到这段声明去执行真实函数把结果回填给模型模型再基于执行结果写回复。整个过程下来你会发现所谓的“工具调用”其实是两段拼接第一段是模型根据文本生成决策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支持时优先用能彻底杜绝非法JSON2.3 意图路由与状态管理不靠模型靠状态机无 Tool Calling 方案里最容易翻车的是状态管理。Tool Calling 靠模型内嵌的历史消息来“回忆”用户上下文而我们这套方案必须自己管状态。我是这么设计的每个会话维护一个session_id每个 session 里有一个state字段用来存业务上的阶段性状态比如“未登录”“已选择商品”“待确认支付”。模型每次输出结构化的意图之后代码不是立刻无脑执行而是先检查当前状态是否允许执行这个意图。举个例子用户还没登录意图却是cancel_order那直接执行肯定出问题。这时候代码会拦截掉这个意图返回一条“请先登录”的回复或触发登录意图。这种逻辑用状态机表达可读性和可维护性都远比把一堆历史消息丢给模型指望它自己推断要靠谱得多。而且一旦出问题你可以直接在代码里查某个状态下为什么某个意图被拒绝了完全不需要猜测模型的“脑回路”。3. 手把手搭一个可运行的结构化通用 Agent3.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_asciiFalse) 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, modelgpt-4o, max_retries2): full_messages [{role: system, content: system_prompt}] messages for attempt in range(max_retries 1): response client.chat.completions.create( modelmodel, messagesfull_messages, temperature0.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 是一种约束结构化输出也是一种约束而且后者在很多场景下更轻、更稳、更好排查。写代码的时间越久我越倾向于这种“笨但可靠”的做法——毕竟线上出问题的时候能靠日志快速定位原因比什么都重要。