ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

无 Tool Calling 的结构化通用 Agent 设计与实践

无 Tool Calling 的结构化通用 Agent 设计与实践 这个系列已经写到第五篇了前面记录的几轮 Agent 实践大多集中在框架选型、记忆机制和任务编排上。这次我想单独聊聊一个反常规的题目不做 Tool Calling仍然把 Agent 做成“结构化”且“通用”。这话听起来有点矛盾——现在主流 Agent 玩法几乎都默认绑定 function calling / tool use模型自己决定调哪个工具、传什么参数运行时再帮你执行。不调用 Tool Calling难道要退回纯文本聊天还真不是。我想说清楚的是Tool Calling 只是模型侧的一种输出协议不是 Agent 能力的唯一来源。通过自己定义一套结构化协议、执行器注册表和状态机我完全可以得到同样能规划、能执行、能反思的通用 Agent甚至在可审计性、跨模型迁移和安全控制上会比原生 Tool Calling 更顺手。这篇文章不会卖弄概念而是把我在实际项目里的完整设计、代码骨架、提示词模板以及踩过的坑都摊开讲。适合正在做 Agent 工程化、又受限于模型网关不支持 function calling、或者需要统一接入多家模型的读者。看完你就能照着这套思路搭一个“无 Tool Calling 的结构化通用 Agent”并且知道为什么这样设计能成立。1. 为什么我选择做“无 Tool Calling 的结构化通用 Agent”1.1 Tool Calling 是什么它什么情况下很好用先说清楚原生 Tool Calling 到底干了什么。它其实是把“工具列表”通过 API 参数传给模型模型在生成文本的同时会额外输出一个结构化的 function call 对象里面包含工具名和参数。运行时拿到这个对象执行对应的函数然后把结果再回传给模型让模型继续推理。这个过程确实是 Agent 最自然的交互方式也是市面上多数框架默认的能力来源。原生 Tool Calling 很适合一个场景模型能力强、工具数量少、调用链路浅。比如一个插件系统给模型挂了四五个工具模型选择“查天气”“发邮件”“算数学”每一个都是独立、无状态、单一职责的调用。这时候全链路交给模型判断代码省事效果也好基本不需要自己做额外的协议解析。我最早也是这么干的。LangChain、OpenAI function calling、各种 agent 框架绕不开那一套。但我在后续项目里开始意识到原生 Tool Calling 不是免费的。它把“意图解析”交给了各家模型的私有实现而模型返回的 function call 结构在不同厂商、不同版本之间并不完全兼容。想在自建网关后面统一接多家模型或者在日志里做精细的权限审计原生 Tool Calling 反而成了阻碍。1.2 我放弃原生 Tool Calling 的四个真实理由第一个理由很现实不是所有环境都支持 Tool Calling。公司内部自建的大模型网关很多时候只暴露一个 OpenAI 兼容的 chat completion 接口甚至有的是纯文本补全连 response_format 都不一定支持。我不可能为了一个 Agent 项目去改造整个网关。只要模型能输出文本我就能用结构化协议把工具调用这件事自己做掉。第二个理由是跨模型一致性。我在同一个 Agent 项目里要横向对比 GPT 系列、Claude 系列和开源模型。原生 function calling 在 OpenAI 有 strict schema在 Claude 有 tool_choice在开源模型上则千奇百怪。今天我准备换掉某个模型供应商却发现函数调用的解析逻辑要重写一遍这谁也受不了。把工具调用收敛成“输出一段固定 JSON”所有模型看到的都是同一份指令迁移成本瞬间降下来。第三个理由是审计和可读性。业务方经常要回答一个问题Agent 刚才为什么执行了这个动作、参数是什么、风险评估是什么。原生 function calling 的返回对象虽然也是结构化的但各家对 reasoning 字段的支持不一致。我在结构协议里强制模型输出 thought、action_name、parameters、risk_level等于把决策链暴露成一条可读取、可检索的记录。这比从调用日志里反推模型的意图要直接得多。第四个理由其实是架构洁癖。通用 Agent 要处理的往往不只是“调用一个工具”而是“规划多个步骤、维护上下文、在必要时向用户确认”。原生 function calling 把“意图”塞进 API 的 schema工具一多模型经常漏参数或者选错动作。我感觉更稳的路子是把动作的集合、参数约束和风险等级直接写进 Prompt让模型把它当作一个“决策问题”来处理。这样出的结果我再用 JSON Schema 去卡不依赖任何模型私有行为。1.3 “结构化通用”这个词怎么理解“结构化”指的是模型的输出被约束成一种固定的中间语言这个中间语言不能是自由文本必须是可被代码直接解析的 JSON 对象。它描述“我要执行哪个动作、传什么参数、为什么、风险多高、需不需要用户确认”。这一层如果做扎实了模型输出是文本还是 function call 对象其实没有本质区别。“通用”则体现在动作的注册机制上。Agent 不内置任何具体业务逻辑所有能力都挂在执行器注册表里。今天想加一个“查询订单”的能力就注册一个 query_order 执行器告诉注册表这个动作的名称、描述、参数结构、风险级别。明天想接一个“写数据库”的能力同样注册一个执行器。Agent 的主循环、状态存储、重试逻辑一概不动。这样一来这个 Agent 就从一个“只会查天气的玩具”变成了“可以接任意业务能力的中枢”。回到标题里的“无 Tool Calling”。我在设计里去掉的不是工具调用能力而是模型 API 里那个专门的 tools 参数。取而代之的是执行器注册表生成的 JSON Schema 描述统一写进 Prompt 上下文。模型看完这些描述自己生成符合契约的动作指令。模型的角色被重新定义成“意图生成器”代码的角色是“意图校验与执行器”。2. 把“无 Tool Calling”变成架构优势三大关键设计2.1 意图协议模型与执行器之间的契约既然不用 Tool Calling第一件事就是定义模型输出和代码执行之间的契约。我把它叫做“意图协议”。这个协议必须是白纸黑字、有版本、有 Schema 的否则模型输出五花八门后面解析逻辑会被拖垮。我实际用的意图对象大概是这样的{ thought: 用户想看杭州的天气我先调用天气服务查询实时数据, action_name: query_weather, parameters: { city: 杭州, unit: celsius }, risk_level: low, need_user_confirm: false }这里的 thought 字段是我强烈建议保留的它让模型在生成动作之前先给出一步推理痕迹。真正排查问题的时候thought 的价值比一大堆日志都大。action_name 必须对应注册表里的某个动作parameters 必须通过该动作的输入校验。risk_level 和 need_user_confirm 是给后续安全模块用的后面我会单独展开讲。为什么选择 JSON 而不是 XML 或者 Markdown 代码块我最开始也试过让模型输出actionquery_weather/action这种 XML解析并不难但 XML 的属性、嵌套对于很多小模型来说太容易错位。Markdown 代码块的问题是需要去除围栏、处理无意义的 json 标记。JSON 的好处是语言原生支持而且可以直接靠 Pydantic 之类的库做强类型校验。模型只需要输出一个合法的 JSON object我就能一把梭把校验、类型转换、默认值都处理掉。2.2 通用执行器注册表有了动作协议还需要一个地方管理和暴露所有可用动作。我管它叫“通用执行器注册表”。它是一个全局对象维护一个字典key 是动作名value 是执行器描述、输入 Schema、风险级别和真正的 Python 函数。注册表承担四件事注册、索引、校验、执行。注册是给新增能力的开发者用的通过装饰器就可以把一个普通函数挂进来。索引是给模型看的注册表会把自己管理的所有动作信息序列化成一份统一的描述文本插进 Prompt。校验是代码侧的动作要被执行前先验证 parameters 是否符合该动作的输入 Schema不符合就拒绝。执行是最后一步代码调用真正的 executor 函数并把执行结果返回给主循环。这个模式其实就是插件化但是把“插件的接口”定义成了结构化数据。业务方只要写一个普通函数配上参数说明和风险等级Agent 就自动学会了这个能力。这也是“通用”二字的来源——Agent 框架层完全不感知具体业务它只负责意图协议和动作执行。2.3 结构化校验从字符串到可信意图模型输出的是文本就算我提示它“只输出 JSON”它也可能在前后加解释、漏字段、把字段名拼错。所以解析这步绝对不能靠正则必须用严格的校验器。在 Python 里我用 Pydantic 定义意图对象from typing import Any from enum import Enum from pydantic import BaseModel, Field, field_validator class RiskLevel(str, Enum): LOW low MEDIUM medium HIGH high class AgentAction(BaseModel): thought: str Field(..., description模型输出动作前的推理过程) action_name: str Field(..., description动作名称必须存在于执行器注册表) parameters: dict[str, Any] Field(default_factorydict, description动作的执行参数) risk_level: RiskLevel Field(defaultRiskLevel.LOW, description动作风险等级) need_user_confirm: bool Field(defaultFalse, description是否需要用户确认后执行) field_validator(action_name) classmethod def action_must_exist(cls, v: str) - str: if v not in registry.list_names(): raise ValueError(f未知动作: {v}) return v这层的价值在于让模型输出阈值“可信”。模型也许会在 parameters 里传来一个多余字段Pydantic 默认会忽略或者报错看你配置。我的建议是显式打开 extraforbid宁可直接报错、进入重试分支也不要让一个带脏数据的动作悄悄执行。你可能觉得这样有点严格模型一次可能生成不了那么规整的 JSON。没有问题解析失败后我会让主循环走固定修复路径告诉模型“上一次输出不是合法的 JSON请严格按 Schema 重新生成”。这个重试要有次数上限不能无限循环。实践下来在强模型上一两次重试基本就能成功在弱模型上设置 3 次重试上限就够超过就把控制权交还给用户。3. 落地实现一个通用的无 Tool Calling Agent 骨架三章我计划说标题内容确实多。跳转到未来MCP统一值得一提的是当下的“不要工具调用”正好给我们一个机会去掌控。我可能会稍微缩一下加入MCP的一点可供迁移性但不离题。现在进入第3章3. 落地实现一个通用的无 Tool Calling Agent 骨架3.1 核心循环代码骨架之前讲的概念再多也要落到代码上。我这个 Agent 的主循环非常精简核心思想就是“模型输出意图、代码解析意图、执行器执行、结果回灌”。import json from typing import Any class AgentRuntime: def __init__(self, registry, llm, max_steps8, max_retries3): self.registry registry self.llm llm self.max_steps max_steps self.max_retries max_retries self.messages [] self.state {turn: 0, history: [], completed: False} def run(self, user_request: str) - dict[str, Any]: self.messages self.build_initial_messages(user_request) for step in range(self.max_steps): response_text self.llm.chat(self.messages) action, error self.parse_action(response_text) if action is None: if self.max_retries 0: return {status: error, message: 模型输出多次解析失败} self.messages.append({role: user, content: f解析失败请重新输出{error}}) self.max_retries - 1 continue if action.action_name in {finish, answer_user}: self.state[completed] True return {status: done, answer: action.parameters.get(answer)} result self.registry.execute(action) self.state[turn] step self.state[history].append({action: action.action_name, result: result}) self.messages.append(self.render_result_message(action, result)) return {status: max_steps, message: 达到最大步数仍未完成}这里面 parse_action 干的事就是调用刚才的 AgentAction 模型做校验。密码不通过就抛错。execute 则是从注册表取出 executor 执行并把异常捕获、统一处理。这里有一个重要细节不要每次循环都把完整的历史一股脑发给模型否则上下文越长模型越容易犯错。我采用的方案是定期压缩把已经完成的子步骤摘要化只保留最近两三轮的原始动作结果。这个有点像程序里的滑动窗口后面第 3.3 节专门讲。3.2 提示词怎么写模型才稳定输出 JSON没有 Tool Calling 的情况下Prompt 就是一切。我不指望模型天生会输出符合协议的 JSON我会把约束写得极其明确。我自己常用的模板包含这么几块第一角色定义。写清楚“你是结构化 Agent 的意图生成器你的任务是根据对话历史选择动作并输出 JSON”。第二动作列表。把注册表索引后的内容放进来每一项包含动作名、参数说明、返回值说明。我一般会渲染成一个 Markdown 表格因为模型对表格的理解比纯文本好。第三输出格式约束。直接给一个带 Schema 的示例并写明“只输出 JSON不要任何解释文字”。这个“只输出 JSON”在很多开源模型上仍然会被无视所以我在解析时要做好兼容。第四边界兜底。我会告诉模型“如果用户的需求不在动作列表范围内输出 action_name 为 answer_user 的动作并附带回答文本。”这样既不会让模型硬编一个不存在的动作也能保持流程完整。下面是一个简化版的 Prompt 片段你可以执行的动作用如下 Markdown 表格描述 | 动作名 | 说明 | 参数 | | query_weather | 查询天气 | city: 城市名; unit: celsius/fahrenheit | | answer_user | 直接回答用户 | answer: 回答内容 | 你必须输出如下结构的 JSON不能出现除 JSON 以外的任何内容 { thought: 简述你选择这个动作的思考过程, action_name: 动作名, parameters: {参数名: 参数值}, risk_level: low/medium/high, need_user_confirm: true/false }这个模板还需要配合顶层 system 指令。我在标题上会说“你正在处理步骤 {step_number}当前还剩 {remaining_steps} 步。”3.3 一轮任务的完成条件与上下文裁剪很多人写 Agent 只关心怎么让它跑不关心什么时候停。结果就是模型在那里反复输出同样的动作白白烧 token。我要在状态里显式维护三个指标总步数、最近动作序列、历史摘要。总步数就是主循环里的 max_steps我一般设 8 步。超过步数还没结束我宁可返回“当前任务过于复杂请拆分后重试”也不想让它在死循环里消耗更多资源。历史摘要就更有意思了。我维护一个列表 recent_actions只保留最近 5 条完整动作结果其余的更早期结果会被压缩成一句摘要比如“用户已确认查询杭州天气并展示。到目前为止已完成 3 次查询、1 次用户提问”。这样模型既知道整体进展又不会被十几轮原始 JSON 淹没。完成条件上我的主循环只有两种正常退出模型输出 answer_user 动作或者执行器返回“终止信号”。这个终止信号由业务方自己在 executor 里控制。比如一个下单 Agentexecutor 在下单成功后返回 statusterminal主循环立刻停止。不要让模型自己决定“我是否应该结束”要由执行结果决定。这样跑起来会稳很多。4. 常见问题、安全边界与多模型切换手记4.1 模型输出脱轨非 JSON 与幻觉字段的排查这个是我实操里碰过最多的问题没有之一。模型口头上答应你“只输出 JSON”实际返回却是一段带着大段解释的中文或者是以json 开头、以结尾的 Markdown 代码块。我的解决方案是写一个容错解析器逻辑分三层先直接 json.loads。成功了直接进 Pydantic 校验。失败则尝试从文本里抽取第一对花括号的内容再做一次 json.loads。如果还失败就干脆当成解析错误走重试分支。不要以为第一层就能覆盖大多数情况实测只有强模型能做到。开源模型经常给你干净的第一对花括号但是里面半截字符串有缺失引号。这种情况我会把原始文本和错误信息拼给模型让它自己修正。幻觉字段的问题是另一个坑。模型会在 JSON 里多加一个参数比如 query_weather 的 parameters 里塞入一个“temperature_scale”但我们的 Schema 根本没这个字段。开了 extraforbid 之后这直接触发校验失败。一开始我觉得这是矫枉过正后来发现这个策略能尽早暴露参数名不统一的问题逼着我把 Prompt 里的字段说明写得更准确长远看是省事的。4.2 任务死循环重复动作与进展判定死循环的典型场景是任务依赖一个失败的外部服务。比如“查询订单”的 API 超时了执行器返回的是一个错误对象。我把执行结果显示给模型模型一看没拿到数据就再来一次“查询订单”。如此反复直到步数耗尽。我处理这个问题的三板斧如下第一执行器返回结果要带状态标记。一次执行可能有 success、retryable_error、fatal_error 三种结果。对 retryable_error我会在回灌提示词时明确说明“刚才的动作失败且属于可重试但你已经重试过 N 次了”让模型自己评估还要不要再来一次。第二主循环里做动作防重。维护一个 recent_actions如果同一个动作连续出现超过 2 次且中间没有其他动作穿插就自动插入一条系统提示“你可能在重复尝试请换一种策略或者向用户确认”。第三设置 max_steps 作为最后的熔断机制前面已经说过了。这套组合下来我的 Agent 很少把 token 烧在无限循环里。4.3 安全边界执行器校验、用户确认与审计日志很多人以为结构化输出就是安全的关键实际上结构化输出只是给了你安全的“抓手”真正的防线在代码层。比如模型输出了一个 risk_level 为 low 的“写数据库”动作你要是直接执行了那问题就大了。所以我在执行器层做强制校验而不是模型说什么就信什么。我给每个动作都配置了一个安全级别动作本身是 high 级别就算模型在 JSON 里把 risk_level 填成 low执行器也会忽略模型的声明坚持走用户确认流程。模型可以表达意图但不能单方面降低权限。这是很重要的原则。涉及文件删除、数据库写操作、发送外部消息这类动作我还会要求 additional_params 里带上 confirm_token。用户在界面确认后系统生成一个一次性 token执行器必须拿到这个 token 才执行。这个机制不是我发明的但实测下来非常有效能挡住绝大多数误操作。审计日志我就直接用动作对象本身。因为 every 动作都包含 thought、action_name、parameters、risk_level、need_user_confirm这已经是一份自带推理链路的审计记录。再叠加执行结果、耗时、调用人基本能满足业务侧查证需求。4.4 多模型迁移从 GPT 到开源模型的切换经验切换到无 Tool Calling 协议最大的红利就在于多模型迁移。我的 Agent 不需要改任何主循环代码只需要换 llm 客户端。但你说完全无缝也不现实不同模型对格式遵循能力的差异非常明显。我用 GPT-4o 和 Claude 系列时它可以一两次就给出符合 Schema 的 JSON。切换到以 Qwen、DeepSeek 为代表的模型时得分往往取决于 Prompt 的静稳程度。我发现几个技巧对弱模型特别有用一是把示例从“一个”变成“一组”每次在 Prompt 里给两个正例和一个反例。反例专门展示“什么是不该输出的”比如带 markdown 围栏。二是降低温度尽量设为 0 或者 0.1。模型生成 JSON 时温度越高越容易引入结构噪声。部分模型还可以开 json_object 模式的 response_format就尽量开。三是一开始在评测集上跑一轮“格式通过率”基线。我有一套很小的评测集只有二三十条典型请求统计模型输出能被解析并校验通过的比例。低于 70% 的模型我不会直接接入生产先调提示词和参数再说。这套流程走下来我后来接任何一个新模型大约半小时就能得出能不能上线的判断。这就比换原生 function calling 的实现快多了。最后想说的一点如果让我总结这个项目里最大的心得那就是不要为了使用 Tool Calling 而使用 Tool Calling。原生工具调用确实是一种优秀的产品设计但它不是万能药。当你面对的模型环境复杂、业务对审计和权限有极端要求、或者希望 Agent 骨架能跨多家模型复用的时候自己定义一套结构化意图协议把动作交给执行器注册表管理可能是一条更稳的路。它让模型退回到“意图生成”的位置把确定性控制权重新拿回代码手里。我个人的习惯是在小范围、模型可控的项目里我依然会优先用原生的 Tool Calling毕竟省事但只要牵涉到跨网关、多模型或者强安全审计我就会毫不犹豫搬出这套无 Tool Calling 的结构化方案。它多出来的那点解析代码换取的是整个 Agent 架构的确定性和可迁移性。如果你正好在做一个需要长期演进的 Agent 项目不妨把这一套协议和执行器注册表作为核心试着让业务能力以插件的形态生长出来。我踩过的坑应该能帮你少走不少弯路。
返回列表