
开篇先说实话这段时间做Agent落地我越来越觉得大家一窝蜂扑向Tool Calling工具调用这条路其实很多场景根本不需要那么重的机制。所以这次Agent实践系列的第五篇我专门聊聊一种“反主流”的做法——不依赖Tool Calling照样能搭出一个结构化、通用的Agent。这套方案的核心一句话就能讲完让模型只负责把用户的自然语言翻译成一个带固定结构的“意图包”剩下的动作执行、流程控制全部交给传统代码。思路听起来简单但真落地的时候坑也不少这篇我会把架构、提示词、路由、状态管理、出错兜底全部分享出来都是能直接拿去用的东西。1. 为什么刻意绕开Tool Calling1.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. 从零实现结构化通用Agent3.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_factorydict) dialogue_history: list field(default_factorylist) business_context: dict field(default_factorydict) 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_factorydict) # 更新状态 next_state: Optional[str] None # 下一步流转 end_session: bool False # 是否结束会话3.2 提示词设计让LLM稳定输出意图JSON这一节是整个方案里最有含金量的部分。我用过很多版本提示词踩了不少坑最后总结出一个比较稳定的小模板直接分享给大家。你是对话系统的语义解析模块你的职责是唯一且固定的 把用户当前这句话解析成结构化意图不要执行任何操作不要生成任何多余内容。 你可以输出的意图枚举如下 - query_order查询订单相关信息 - create_order创建订单 - cancel_order取消订单 - update_contact修改联系信息 - faq回答常见问题 - unknown无法判断意图或超出范围 每个意图需要的参数如下 - query_orderorder_id(订单号)customer_name(客户姓名) - create_orderproduct_id(商品编码)quantity(数量)address(收货地址) - cancel_orderorder_id(订单号)reason(取消原因) - update_contactcustomer_name(客户姓名)new_phone(新手机号) - faqquestion(用户具体问题原文) 约束条件 1. 只输出JSON不要包含markdown代码块标记。 2. 如果用户没有提供某个建议参数填入空字符串。 3. missing_slots字段填所有为空字符串的建议参数名。 4. 如果意图完全无法判断intent填unknownconfidence填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( intentintent, confidenceconfidence, slotsslots, missing_slotsmissing_slots, raw_outputraw_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_stateinitial ) 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_statewaiting_order_id ) # 这里接业务查询逻辑 order_status query_real_order_status(order_id) return ActionResult( speakf您的订单{order_id}当前状态是{order_status}。, state_update{last_order_id: order_id}, end_sessionTrue )这里要强调一个原则执行器内部不允许再调用LLM。所有判断全部用代码写死条件分支是显式的。一旦让执行器内部又去问LLM稳定性、延迟、成本全部失控。模型只在一个地方出现——意图解析层。3.4 多轮对话状态管理没有Tool Calling多轮对话怎么做很多人一上来就想到“把聊天记录全塞给模型”这么做能跑但越往后上下文越长越贵越慢。我这里的做法是显式状态机 槽位累积不把希望寄托在模型的长期记忆上。以“取消订单”为例完整的状态流可能是用户说“我要取消订单”意图cancel_order缺order_id。当前状态设为waiting_order_id。下一轮用户说“订单号是SO2024100001”因为状态在waiting_order_id我先绕过意图解析直接把这句话当order_id填进去。确认用户是否确定取消状态变为confirm_cancel。用户回答“确定”进入执行器真正执行取消操作。这个流程里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不妨先问问你这个场景到底需要模型做到哪一层答案或许就是——它只需要听懂人话剩下的事交给老实的代码去办就好。