☰
绕开Tool Calling:用“意图解析+槽位填充”打造可控的通用Agent
2026/10/10 4:57:34 网站建设 项目流程

开篇先说实话:这段时间做Agent落地,我越来越觉得,大家一窝蜂扑向Tool Calling(工具调用)这条路,其实很多场景根本不需要那么重的机制。所以这次Agent实践系列的第五篇,我专门聊聊一种“反主流”的做法——不依赖Tool Calling,照样能搭出一个结构化、通用的Agent。这套方案的核心一句话就能讲完:让模型只负责把用户的自然语言翻译成一个带固定结构的“意图包”,剩下的动作执行、流程控制全部交给传统代码。思路听起来简单,但真落地的时候坑也不少,这篇我会把架构、提示词、路由、状态管理、出错兜底全部分享出来,都是能直接拿去用的东西。

1. 为什么刻意绕开Tool Calling

1.1 Tool Calling在解决什么问题、又带来什么问题

Tool Calling这种机制,最早是为了解决“大模型不会算数、不会查数据、不能操控外部系统”这些问题。它的工作方式很直接:系统提前注册一堆工具,每个工具有名字、有描述、有参数结构,然后把工具列表随着用户问题一起交给模型,模型从里面挑一个并生成调用参数,外部收到后执行并回传结果,模型再基于结果继续推理。听起来非常顺滑,当时代入一下它的使用场景,就像你在前台点菜,菜单写得清清楚楚,服务员帮你把菜名勾好递给后厨。

但在真实的工程实践里,这个路线有几个绕不过去的毛病。

第一是平台绑定问题。主流的几家大模型服务商,各自实现的工具调用协议不太一样。你在A平台上写好的一套工具定义,迁到B平台基本要重写一遍。本地上部署的开源模型,不同版本的函数调用能力差异更是天差地别,有些模型甚至根本没有可靠的Tool Calling能力。这意味着你的核心业务逻辑一旦绑定了某个特定模型平台的工具协议,后续换模型、做多云部署都会变得很痛苦。

第二是上下文的占用和成本。每轮对话都要把完整的工具定义列表塞给模型。工具少还好,如果业务复杂,工具定义动辄几十个,每个定义还都要写清晰的描述和JSON Schema,一次请求的token开销就非常吓人。更糟的是,模型处理超长上下文的速度会变慢,延迟也上去了,用户体感一下子就从“秒回”退化到“转圈”。

第三是选择不可控。工具一多,模型选错工具的概率就会上升。我见过不少案例,明明用户只是想查订单状态,模型却调用了创建工单的接口。这种错误在测试环境里不容易发现,一上生产就是事故。工具调用越开放,行为的不确定性就越大,而很多ToB场景恰恰最讨厌不确定性。

1.2 把思路切回“意图与槽位”

在没有Tool Calling的时代,做对话系统最经典的方法论叫“意图识别 + 槽位填充”。用户说“帮我订明天去上海的机票”,系统先识别出意图是“订机票”,再抽取槽位:时间是“明天”,目的地是“上海”。抽完槽位后,交给后端一个固定的API去执行。整个流程非常稳定,每一步都能审计。

我做的这个无Tool Calling的结构化Agent,本质上就是把这套古典方法论用大模型重新实现了一遍。区别在于,传统NLU需要针对每个领域训练意图分类模型,槽位抽取也依赖词典和规则,想泛化很困难。大模型出现后,这个事儿一下子变得简单了:你不需要为每个领域单独训练模型,只要在提示词里写清楚意图枚举、字段定义、输出格式,模型就能完成高质量的意图解析。

这个思路的优势在哪里?逻辑分支的控制权又回到开发者的手里。模型不再直接决定“要调用哪个函数”,而是决定“用户这句话属于哪个意图、需要抽哪些参数”。至于这个意图对应什么操作、能不能执行、执行完要返回什么,这些都是代码里的确定性逻辑。哪怕模型判断错了,问题也被局限在“意图分类错误”这一层,而不是“模型乱调用外部API”这一层。

1.3 无Tool Calling方案的本质拆解

把这三个词拆开看,这个方案的核心是:

  • 无Tool Calling:模型不直接产生函数调用指令,只输出结构化的文本结果。
  • 结构化:输出的内容严格遵循预定义的JSON Schema,意图字段、参数字段、置信度字段都是固定枚举和固定类型。
  • 通用:模型只负责泛化的语义理解,领域知识通过提示词里的枚举定义和路由层的代码来体现,换一个场景只需要改枚举和执行器,不需要重写推理逻辑。

说白了,我们是在用“传统代码的骨架 + 大模型的语义理解能力”做一个杂交体。要的就是可控性和可扩展性兼得。

2. 整体架构与关键设计

2.1 四层结构:意图解析、路由分发、执行器、反馈聚合

这个Agent的整体架构分成四层,各干各的活,互不越界。

第一层是输入编排层。接收用户的原始文本,同时把当前会话的上下文(历史对话摘要、之前填好的槽位信息)组装成一个上下文对象,传给模型。

第二层是意图解析层。这一层就是用LLM把输入翻译成一个JSON意图包。它不包含任何业务逻辑,是个纯粹的“语义翻译器”。核心产物是类似这样的结构:

{ "intent": "query_order", "confidence": 0.92, "slots": { "order_id": "SO2024100001", "customer_name": "" }, "missing_slots": ["customer_name"] }

第三层是路由分发层。收到意图包之后,先做校验,比如intent在不在枚举里、slots类型对不对。然后根据intent字段查一个注册表,找到对应的执行器函数,把slots作为参数传进去。

第四层是执行器与反馈聚合层。每个执行器就是普通的Python函数,内部可以做任何操作:查数据库、调内部接口、渲染一段固定回答。执行完成后返回统一的结果对象,这个对象会被格式化成一个“可读的消息”和一组“更新的状态”回给用户,更新后的状态同时会写回会话上下文,供下一轮解析使用。

这样分层的好处是,每一层都可以单独测试。意图解析层甚至可以拉出来专门做一批评测样本,看准确率;路由层和执行器层直接写单测,逻辑完全确定。这在给客户演示的时候特别有用,出了问题能快速定位是哪个环节挂了。

2.2 结构化定义:让Agent的状态和输出都可观测

我见过很多Agent项目失败,不是能力不够,而是不可观测。你不知道它脑子里在想什么,下一步要干什么。所以在这个方案里,我把“结构化”贯穿到所有交互数据上。

首先是输入结构化。不仅是用户这句话,还包括会话状态。每次调用模型前,我会构建一个包含如下字段的状态对象:

  • session_id:会话唯一标识
  • current_state:当前流程节点,比如“待确认订单号”
  • accumulated_slots:已经收集到的参数信息,键值对
  • dialogue_history:精简过的最近几轮对话,按角色区分
  • business_context:业务侧注入的静态信息,比如用户等级、可用权益

接着是输出结构化。模型输出必须是一段干净的JSON,且只包含预定义字段。我在设计Schema的时候尽量收敛:不要模型自由发挥。意图枚举提前定义好,比如query_order、create_order、cancel_order、FAQ、unknown。slots字段也是提前定义好的键值,模型只能往里面填值,不能自己发明新键。

最后是执行结果结构化。每个执行器返回的不能是一段随意的文本,而是带结构的结果包,包含speak(给用户看的话)、state_update(状态变更)、next_state(下一个流程节点)、end_session(是否结束会话)。这样统一之后,前端渲染、日志记录、多轮衔接全部有据可依。

2.3 两种方案的取舍对比

很多朋友会问,既然最终都是“理解意图参数再执行”,那为什么不直接用Tool Calling,还要自己在上面套一层?我整理过一张对比表,贴出来给大家参考。

对比维度Tool Calling方案无Tool Calling结构化方案
模型要求必须支持工具调用协议,本地小模型基本不可用只要求能稳定输出JSON,几乎所有模型都可以
跨平台迁移工具定义格式随平台变,迁移成本高同一套提示词和Schema通用,成本极低
上下文开销每轮携带全量工具定义,工具多时开销大只携带意图枚举和字段说明,token利用效率高
行为可控性模型可直接触发外部动作,风险相对高动作必须经过路由层,天然隔离
调试难度需要看模型抓取哪个工具、传了什么参只需看意图JSON解析对不对,很好定位
灵活性工具变更随时可加,模型侧自动适配新增一个意图需要改枚举和路由表,但非常明确
典型场景开放式任务、工具动态扩展流程类业务、强合规场景、对延迟敏感场景

你可以看到,在偏ToB、业务流程重、对稳定性要求高的场景里,无Tool Calling的结构化方案有明显优势。它牺牲了一点点“模型自由发挥”的空间,换来了稳定性和可维护性。

3. 从零实现结构化通用Agent

3.1 基础数据结构

写代码之前,先把数据结构定义清楚。我一般用Python的dataclass,简单直接。

from dataclasses import dataclass, field from typing import Any, Optional @dataclass class AgentState: session_id: str current_state: str = "initial" accumulated_slots: dict = field(default_factory=dict) dialogue_history: list = field(default_factory=list) business_context: dict = field(default_factory=dict) @dataclass class IntentResult: intent: str confidence: float slots: dict missing_slots: list raw_output: str = ""

IntentResult就是模型解析层的产物。missing_slots这个字段很重要,后面做多轮追问全靠它。

执行器返回结构:

@dataclass class ActionResult: speak: str # 展示给用户的文本 state_update: dict = field(default_factory=dict) # 更新状态 next_state: Optional[str] = None # 下一步流转 end_session: bool = False # 是否结束会话

3.2 提示词设计:让LLM稳定输出意图JSON

这一节是整个方案里最有含金量的部分。我用过很多版本提示词,踩了不少坑,最后总结出一个比较稳定的小模板,直接分享给大家。

你是对话系统的语义解析模块,你的职责是唯一且固定的: 把用户当前这句话解析成结构化意图,不要执行任何操作,不要生成任何多余内容。 你可以输出的意图枚举如下: - query_order:查询订单相关信息 - create_order:创建订单 - cancel_order:取消订单 - update_contact:修改联系信息 - faq:回答常见问题 - unknown:无法判断意图或超出范围 每个意图需要的参数如下: - query_order:order_id(订单号),customer_name(客户姓名) - create_order:product_id(商品编码),quantity(数量),address(收货地址) - cancel_order:order_id(订单号),reason(取消原因) - update_contact:customer_name(客户姓名),new_phone(新手机号) - faq:question(用户具体问题原文) 约束条件: 1. 只输出JSON,不要包含markdown代码块标记。 2. 如果用户没有提供某个建议参数,填入空字符串。 3. missing_slots字段填所有为空字符串的建议参数名。 4. 如果意图完全无法判断,intent填unknown,confidence填0。 5. 不要编造不存在的参数,不要输出JSON以外的文字。 输出格式固定为: {"intent": "...", "confidence": 0.0-1.0, "slots": {"参数名": "值"}, "missing_slots": []}

几个容易被忽略的细节,我吃了不少亏才记住:

第一,系统提示词里千万不能让模型感觉到自己在扮演一个“Agent”。一旦它觉得自己是智能助手,就容易自由发挥,说出“好的,我来帮您查询订单”这种话,然后根本不输出JSON。角色定位必须是“语义解析模块”,把它的输出空间死死限制住。

第二,枚举和参数说明要尽量详细。很多人只给一个枚举名,不给字段解释,模型就会在边缘语义上反复摇摆。我给每个参数都配了中文说明,实测下来准确率明显提高。这就是“告诉模型你不知道什么,而不是让它猜你默认什么”。

第三,missing_slots字段必须让模型自己算。模型对“这个参数当前缺不缺”的判断其实比传统规则更灵活。比如用户说“我不记得订单号了,我叫张三”,模型会识别出缺order_id,但会把customer_name填进去。这个结果对后续追问策略非常重要。

3.3 意图解析与路由执行

拿到模型输出之后,下一步是解析JSON、校验、路由。

import json import re def parse_intent(raw_output: str) -> IntentResult: # 清洗:去掉可能出现的markdown包裹 cleaned = re.sub(r'^```json|```$', '', raw_output.strip()) try: data = json.loads(cleaned) except json.JSONDecodeError: raise ValueError("模型输出不是合法JSON") intent = data.get("intent", "unknown") confidence = float(data.get("confidence", 0.0)) slots = data.get("slots", {}) missing_slots = data.get("missing_slots", []) return IntentResult( intent=intent, confidence=confidence, slots=slots, missing_slots=missing_slots, raw_output=raw_output, )

路由层用一个字典做执行器注册表,新增加一个意图只需要注册一个函数,不改其他任何代码:

class AgentExecutor: def __init__(self): self._handlers = {} def register(self, intent_name): def decorator(func): self._handlers[intent_name] = func return func return decorator def dispatch(self, intent_result: IntentResult, state: AgentState) -> ActionResult: handler = self._handlers.get(intent_result.intent) if handler is None: return ActionResult( speak="抱歉,我暂时无法处理这个请求。", next_state="initial" ) return handler(intent_result, state) executor = AgentExecutor() @executor.register("query_order") def handle_query_order(result: IntentResult, state: AgentState): order_id = result.slots.get("order_id", "") if not order_id: return ActionResult( speak="请提供订单号,我可以帮您查询订单状态。", next_state="waiting_order_id" ) # 这里接业务查询逻辑 order_status = query_real_order_status(order_id) return ActionResult( speak=f"您的订单{order_id}当前状态是{order_status}。", state_update={"last_order_id": order_id}, end_session=True )

这里要强调一个原则:执行器内部不允许再调用LLM。所有判断全部用代码写死,条件分支是显式的。一旦让执行器内部又去问LLM,稳定性、延迟、成本全部失控。模型只在一个地方出现——意图解析层。

3.4 多轮对话状态管理

没有Tool Calling,多轮对话怎么做?很多人一上来就想到“把聊天记录全塞给模型”,这么做能跑,但越往后上下文越长,越贵越慢。

我这里的做法是显式状态机 + 槽位累积,不把希望寄托在模型的长期记忆上。

以“取消订单”为例,完整的状态流可能是:

  1. 用户说“我要取消订单”,意图cancel_order,缺order_id。
  2. 当前状态设为waiting_order_id。
  3. 下一轮用户说“订单号是SO2024100001”,因为状态在waiting_order_id,我先绕过意图解析,直接把这句话当order_id填进去。
  4. 确认用户是否确定取消,状态变为confirm_cancel。
  5. 用户回答“确定”,进入执行器真正执行取消操作。

这个流程里,LLM只在第1步参与了意图解析。后面几轮全是规则化的状态推进。对比一下,这样做的体验比“每轮都重新解析一次用户意图”要稳定得多,因为后续轮次里用户说话往往很随意,比如直接报一串单号,脱离上下文你根本判断不出这是订单号,但状态机会告诉你这就是order_id。

def handle_user_input(user_input: str, state: AgentState) -> ActionResult: # 如果正好在等待参数状态,优先按状态机规则处理 waiting_map = { "waiting_order_id": "order_id", "waiting_reason": "reason", "waiting_product_id": "product_id", } if state.current_state in waiting_map: slot_key = waiting_map[state.current_state] state.accumulated_slots[slot_key] = user_input # 进入该状态的后续逻辑 return continue_flow(state) # 否则才走LLM意图解析 intent_result = parse_intent(call_llm(build_prompt(user_input, state))) return agent_loop(intent_result, state)

这里有一个很实用的技巧:action结果里的speak不只是给用户看的,也可以提示后续输入。比如系统说“请提供订单号”,用户接下来的回复大概率就是订单号。顺着这个规律做状态迁移,准确率非常可观。

3.5 鲁棒性处理与兜底策略

模型输出永远是概率性的,所以容错策略是这个方案里绝对不能省的一环。

第一个兜底是JSON清洗。模型偶尔会在JSON外面包上markdown的```json标记,偶尔会输出带尾逗号的JSON,偶尔会把字段名key的外面加上引号。我的思路是,宁可做多轮正则清洗,也不要让用户看到一次报错。清洗规则我一般按顺序执行:去掉代码块标记、去掉首尾花括号外的一切非JSON文本、用json.loads解析,失败则尝试用正则抽{}中间的部分。

第二个兜底是重试机制。如果第一次解析失败,或者confidence低于阈值(比如0.5),我会重试一次,同时把前一次的错误输出拼进提示词,告诉模型“上次输出不符合要求,请重新输出”。这个做法非常简单,但非常有效。实测重试一次的成功率已经接近99%。

第三个兜底是unknown意图的处理。当模型输出unknown,千万不要硬着头皮往下走。我这里直接走“未识别兜底话术”,同时把这轮对话记录下来,作为后续优化提示词的语料。积累多了你就能发现哪些用户话术是这个场景下没覆盖到的。

第四个兜底是执行器异常。比如查订单超时、下游接口抛出异常,一定要在execute层包一层try-except,并给用户一个友好的话“系统开小差了,请稍后再试”,同时把完整异常信息打到日志里。别把堆栈信息直接怼给用户,也别静默吞异常。

4. 落地常见问题与排查技巧

4.1 模型总是不按Schema输出怎么办

这个出现的概率比想象中高。排查思路按优先级来:

先看temperature。做意图解析,temperature我一般设置在0到0.1之间,拉高就是给自己找麻烦。看看是不是顺手用了默认的温度参数。

再查提示词里有没有多余的自由发挥空间。比如你是否让模型“适当的时候可以解释一下”,这是催着它输出非JSON内容的元凶。系统提示词里只给约束、枚举、范例,不给开放式任务。

还不行就看是不是没有给出示例。少数模型面对纯格式说明理解能力略弱,给它一两个完整的用户话术到JSON输出的few-shot示例,格式立刻稳下来。我把few-shot样本放在系统提示词的末尾,效果最好。

4.2 意图分错怎么排查

分错有几种原因。一种是枚举定义之间的边界重叠。比如query_order和cancel_order都能和订单产生关系,模型容易混淆。这种情况最有效的手段是给每个枚举补帮助判断的规则说明,比如“只要用户提到取消就归cancel_order,即使也涉及查询”。

另一种是单轮对话没有结合上下文。用户上一个问题说“帮我查订单”,下一个问题说“还是这个单取消掉吧”。第二次单独解析极大概率会被识别成cancel_order但缺单号。这种情况就要靠3.4里的状态机,在明确上下文指向时,不要重新走LLM解析,而是直接用已有的accumulated_slots补齐。

排查这些问题的时候,我习惯把每一次意图解析的原始输入、原始输出、最终选定的意图全部记到日志里。出的问题多了,拉出错误case归类,比盲收阈值要好用得多。

4.3 多步流程衔接困难

一个常见的心智陷阱是:让LLM去规划未来三步该做什么。模型在长链路的规划上经常出幺蛾子,三步以上就开始逻辑混乱,而且不可控。

我的做法是做单步决策,也就是当前这轮只决定当前这一步的意图。流程推进靠状态机,每一步执行完,代码逻辑告诉状态机下一站是哪里。比如创建订单流程,必须先确认商品、确认数量、确认地址、最后确认下单。每一步都是单独的意图,状态机决定下一步的追问内容。这样每个环节都简单、明确、可测试,用户随时打断、更改信息也能正确响应。

4.4 安全与审计

无论用不用Tool Calling,一个Agent能编程式地触发业务动作,就必须考虑安全问题。

我这里的经验是:所有执行器在做真实动作(取消订单、创建订单、修改信息)之前,必须做权限校验。校验规则包括但不限于:会话对应用户是否有权限、执行参数里的订单号是否属于这个用户、当前状态是否为允许执行该动作的节点。校验不通过,返回拒绝话术。

第二是全链路审计日志。每个意图解析结果、每次路由分发、每个执行器的入参和出参、每次外部接口调用的耗时和响应码,都必须记录。真要出了事,能做到全过程回溯。

5. 适用场景与后续扩展

5.1 哪些场景适合、哪些不适合

没有Tool Calling意味着模型无法动态地发现和调用“之前没定义过”的功能,所以它天然不适合开放式探索类场景。比如让它帮你“分析这个网页上的所有链接并抓取内容”,这种任务必须靠动态工具,Tool Calling才是正解。

而它适合的场景,我总结了几个标签:

  • 流程固定:业务流程是可枚举的,比如客服、售后、下单、查询。每个步骤都能被定义成若干个确定性的节点。
  • 强合规:需要每一步可审计、可回退,不允许模型直接操作外部系统。
  • 多平台部署:同一个意图解析模块可以对接不同的LLM,不用改业务代码。
  • 本地化/隐私敏感:可以用7B、13B这种开源小模型,只要它具备基本JSON输出能力,整个链路就能跑起来。

我自己在多个实际项目里验证下来,前面说的客服工单、IoT指令控制、内部系统信息查询助手,用这套方案都是杀鸡用牛刀,反而比Tool Calling路线稳定得多。

5.2 后续可以做哪些扩展

这个方案不是一条死胡同,它的演进路径也很清晰。当你发现某个场景确实需要动态工具、需要模型自己决策调什么函数时,可以在这个框架上叠一个轻量的“模型可调函数表”:让模型选函数,但仍然走同样的路由层和执行器,只是把intent换成function_name。这样从“无Tool Calling”平滑切换到“轻量函数注册”,业务代码复用率很高。

另一个扩展方向是注册中心插件化。我把执行器注册表改成基于配置扫描加载,新增一个意图只需要在配置里加一行handler路径,服务无需重启就能热加载。

最后就是引入检索增强。当意图枚举太多、参数语义太碎时,单纯靠提示词塞枚举会遇到长度瓶颈。这时可以先用向量检索召回相关意图定义,再放入提示词,这样即使业务包含上百个意图,提示词里每次也只带最相关的十个左右。这一步不会改变整体架构,但是能把“通用”两个字再往前推一大截。

我个人在实践中体会最深的一点是,技术选型从来不是越先进越好,而是要找到当前业务约束下的最优解。Tool Calling确实强大,但强大不等于适合所有场景。像这种把模型能力收窄到一个“语义解析器”、把决策和执行全部交给确定代码的路子,看起来不那么炫酷,但在生产环境里,它带来的稳定性和可维护性是实打实的。下次有人再和你聊Agent,不妨先问问:你这个场景,到底需要模型做到哪一层?答案或许就是——它只需要听懂人话,剩下的事,交给老实的代码去办就好。

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

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

立即咨询