CodeGym /课程 /ChatGPT Apps /代理工具(Tools):模式、路由、错误

代理工具(Tools):模式、路由、错误

ChatGPT Apps
第 12 级 , 课程 1
可用

1. 代理工具:它究竟是什么

在前面的模块中,你已经从 Apps SDK 的角度见过工具——就像“后端函数”,ChatGPT 通过你的 App 去调用。现在换个视角:从 代理(Agents SDK)看工具,并解释它如何选择要调用的工具以及如何处理错误。

在传统后端中,你习惯按“端点”“控制器方法”“服务函数”来思考。在代理世界里,行动的基本单位变成了工具(tool)代理的 tools 与 mcp-tools 是不同但有交集的概念。

严格来说:在 ChatGPT Agents SDK 的语境中,工具是模型可以请求执行的函数的描述。模型本身不执行代码;它生成结构化请求(通常是 JSON),而运行时(你的代码、MCP 服务器或 Agents SDK)负责执行并返回结果。

在 ChatGPT Agents SDK 生态中,工具由配置描述:它有 namedescriptionparameters(参数的 JSON Schema)。代理将这组工具纳入自己的上下文,并在推理过程中决定调用哪个 tool 以及使用哪些参数。

代理(或作为宿主的 ChatGPT)拿到这份列表,将其“记住”到上下文中,并在推理(reasoning)过程中决定:面对某个用户请求要调用哪个工具以及传什么参数。因此规范中反复强调“tools are a contract”——工具是模型与你的代码之间的契约,而不只是“某个 Python/TS 函数”。

可以类比经典 API。路由 /api/gifts/search 只是语法:URL、方法、请求体格式。而 tool search_gifts语义:“根据画像与预算搜索礼物”。工具的描述本质上也是提示词,只是结构化并且面向 LLM,而非面向人。

2. 工具类型:LLM 代理具体能做什么

为避免陷入“什么都能做的函数”的混乱,建议把工具看作若干典型类别。这不是 SDK 的形式化类型划分,而是一种非常有用的架构思维。

在我们的后端中,LLM 代理通常有三个工具来源。

  • 本地业务工具。驻留在你的后端:数据库操作、领域逻辑(过滤、推荐、评分)。例如在 GiftGenius 中,可以有从 PostgreSQL 表取商品,或计算“这个礼物对该用户的匹配度”的工具。
  • MCP 工具。MCP 服务器作为工具(tools)的提供者:注册函数、资源和提示词,并提供给客户端(ChatGPT、LLM 代理)。通过 MCP 的工具可以调用外部 API、处理文件或提供提示模板。
  • 集成类工具。把你接入外部世界的一切:ACP/commerce(创建订单与结账)、发送邮件、webhooks、写入 CRM。这类工具(tools)通常更“危险”,因为会改变外部系统的状态,必须在安全与幂等性上格外严格。

还有一种有用的分类——按动作性质。在 LLM 工具研究中,常见划分为:数据获取(搜索、RAG、get_*)、有副作用的动作型(create_ordersend_email)、纯计算型(calculate_loan)、以及系统/控制类(handoff_to_humanfinish_task)。

为了固定这个概念,来看张小表。

类别 GiftGenius 示例 副作用 风险
Data Retrieval
search_gifts, get_details
Action / Mutating
create_order, buy_gift
Computation
estimate_delivery_cost
中等
System / Control
finish_recommendation
逻辑

从架构角度,最重要的是:只读工具应当数量多且成本低;而变更型工具应当稀少、极其谨慎,配有日志、幂等性,并且通常需要用户确认。

接下来主要讨论数据获取与 Action 类工具,因为 GiftGenius 的逻辑正是建立在它们之上。

3. JSON Schema:模型与代码之间的契约

现在深入看工具如何被描述。在 ChatGPT Agents SDK(与 Apps SDK 一样)中,描述工具参数的标准格式是 JSON Schema:你描述 object 类型、其 properties、字段类型、必填项、约束等。

要理解:此处 JSON Schema 不仅仅是为了校验。它是模型提示词的一部分。OpenAI 官方的工具(tools)设计指南明确指出:代理工作的质量,强烈依赖字段、名称与注释的详细与明确程度。

来看一个 GiftGenius 的示例,这在课程计划中出现过。

{
  "name": "search_gifts",
  "description": "根据收件人类型、兴趣和预算查找礼物。",
  "parameters": {
    "type": "object",
    "properties": {
      "recipient_type": {
        "type": "string",
        "description": "礼物的收件人是谁(例如,'男性'、'女性'、'儿童')。"
      },
      "interests": {
        "type": "array",
        "items": { "type": "string" },
        "description": "关键兴趣(运动、书籍、科技等)。"
      },
      "budget": {
        "type": "number",
        "description": "用户所用货币的最高预算。"
      }
    },
    "required": ["recipient_type", "budget"]
  }
}

这里有几个要点。

  • 首先,namedescription。对模型而言,它们是何时使用该工具的主要信号。语义路由文档强调,工具的描述其实就是面向模型的 API:若把工具命名为 func1 并写上“做点有用的事”,模型确实无法理解何时调用它;而如果叫 search_gifts 并给出清晰描述,选择就容易多了。
  • 其次,parameters。字段名与其描述至关重要。对 LLM 来说,recipient_typetype 清楚得多。像“礼物的收件人是谁……”这样的优质描述,能提示模型应当填入收件人的类型,而不是例如包装的形式。
  • 第三,required。这不仅是你这边的校验,也是对模型的提示:它会努力填充必填字段,而当上下文不明确时可忽略非必填项,从而减少“空”或不正确的 tool 调用。

Apps SDK 的官方指南明确建议:让工具更窄、更单一职责、具备清晰的名称与描述,避免那种“把关于礼物的一切都做了”的大一统工具。

4. 设计 GiftGenius 的工具:从模式到代码

以 GiftGenius 为例,添加两个几乎在所有场景都需要的关键 LLM 代理工具:

  • suggest_gifts(profile, budget) —— 输出候选列表;
  • get_gift_details(gift_id) —— 展示某个礼物的详细信息。

我们的 suggest_giftsget_gift_details 是上一节分类中的典型本地业务工具,主要属于 Data Retrieval。

suggest_gifts 设计模式

先写一份纯 JSON Schema,再展示在 TypeScript 后端/代理运行时代码中可能的样子。

{
  "name": "suggest_gifts",
  "description": "基于收件人画像与预算推荐礼物列表。",
  "parameters": {
    "type": "object",
    "properties": {
      "age": {
        "type": "integer",
        "minimum": 0,
        "maximum": 120,
        "description": "收件人的年龄(岁)。"
      },
      "relationship": {
        "type": "string",
        "enum": ["friend", "coworker", "partner", "family"],
        "description": "与收件人的关系:朋友、同事、伴侣、家人。"
      },
      "interests": {
        "type": "array",
        "items": { "type": "string" },
        "description": "收件人的兴趣(运动、书籍、科技等)。"
      },
      "budget": {
        "type": "number",
        "minimum": 1,
        "description": "用户所用货币的最高预算。"
      }
    },
    "required": ["budget"]
  }
}

这里我们为 relationship 使用了 enum,以避免模型编造任意字符串(比如 "糟糕的同事")并把它们传进代码。模式设计越严谨,模型就越清楚可用选项,开发者在运行时也越不容易被“意外”击中。

现在假设我们有一个基于 Node.js 的 MCP 服务器,用一个虚构的 McpServer。注册工具可能是这样的:

// 在 MCP 服务器中注册工具的简化示例
server.registerTool(
  {
    name: "suggest_gifts",
    description: "根据画像与预算推荐礼物。",
    inputSchema: suggestGiftsSchema
  },
  async (input, ctx) => {
    const gifts = await findGiftsInDb(input, ctx.userLocale);
    return { items: gifts }; // 代理稍后将看到的 JSON
  }
);

代码被大幅简化,但逻辑很清晰:一处是契约说明(名称、描述、模式),另一处是实现。

get_gift_details 设计模式

第二个在几乎任何展示页都需要的工具:

{
  "name": "get_gift_details",
  "description": "根据礼物的标识符获取其完整信息。",
  "parameters": {
    "type": "object",
    "properties": {
      "gift_id": {
        "type": "string",
        "description": "GiftGenius 数据库中礼物的 UUID。"
      }
    },
    "required": ["gift_id"]
  }
}

注册方式类似:

server.registerTool(
  {
    name: "get_gift_details",
    description: "返回礼物的详细信息。",
    inputSchema: getGiftDetailsSchema
  },
  async ({ gift_id }) => {
    const gift = await db.gifts.findById(gift_id);
    if (!gift) return { notFound: true };
    return { gift };
  }
);

请注意:我们直接表明该工具可能返回 notFound: true。这已经是语义错误(业务错误)的萌芽,我们稍后会展开。代理能够看到“礼物未找到”,并做出决策:例如尝试另一个 id,或建议用户选择其他商品。

5. 代理如何选择要调用的工具

现在来到重点:路由。在传统 Web 应用中,路由是硬编码的:URL → 指定控制器。在 ChatGPT Apps 与代理的世界里,工具的选择是语义的、概率性的。

高层循环可以这样表示:

flowchart TD
  U[用户消息] --> M["模型(代理)"]
  M -->|分析请求| C{需要 tool 吗?}
  C -->|否| T[文本回复]
  C -->|是| S[选择工具]
  S --> K[构造 JSON 参数]
  K --> R[执行工具]
  R --> M2[模型看到结果]
  M2 --> T2[最终回答或下一步]

在每一步,代理会看到几件事:

  • 首先,system 指令(代理角色、约束);
  • 其次,对话历史;
  • 最后,工具(tools)列表及其 namedescriptioninputSchema

当有新的用户消息到来时,模型会将请求语义与工具描述进行匹配(语义对齐)。如果请求是“给同事推荐 50 美元以内的礼物”,suggest_gifts 的描述显然比 get_gift_details 更相关,代理就很可能选择它。

官方指南强调两点,它们对路由质量影响很大:

  • 首先,避免语义重叠的工具:如果同时有 search_giftsfind_gifts,而且描述差不多,模型会混淆。
  • 其次,尽量遵循单一职责:一个 tool 做一件清晰的事,而不是“既推荐礼物又创建订单还发送邮件”。

不同 LLM 代理中,通常可以设置工具选择的模式:例如“auto”(模型自行决定是否调用工具)、“required”(必须调用工具)、“none”(禁用工具)。这在复杂工作流(多步场景)中很有帮助,比如某一步你想强制调用 suggest_gifts,而不是让模型闲聊。

GiftGenius 中的语义路由示例

假设代理至少有两个工具:suggest_giftsget_gift_details

  1. 用户说:“给一位喜欢桌游的同事推荐 30 美元以内的礼物”。
  2. 代理发现请求包含“推荐礼物”的目标、预算信息与兴趣点。suggest_gifts 的描述完全匹配——调用它。
  3. 工具返回包含五个礼物的列表及其 id、名称与简要描述。
  4. 用户接着说:“详细介绍一下第三个选项”。代理把“第三个选项”与前面结果中的 id 对应起来,此时语义上更合适的是 get_gift_details——于是调用它。

要点在于:你并没有在代码里写“如果请求里有‘推荐’这个词就调用 suggest_gifts”。这一切由模型基于你提供的描述与对话历史自行完成。你的责任,是让这个选择对模型和人类都尽可能“显而易见”。

6. 工具错误:不要只给 500,要给模型信号

还记得我们在 get_gift_details 里展示过 notFound: true 吗?这正是业务错误的例子,代理应该能够看见并合理处理,而不是接收一个冰冷的 500

再来看“痛点”。在普通的 REST API 中,如果后端深处出了问题——返回 500 Internal Server Error,把堆栈跟踪写到日志,然后用户自己想办法。在代理场景中,这种方式效果很差。

Agents SDK 的实践指南建议把工具错误当作可观察事件,而不是“崩溃”。这常用“Error as Observation”一词来概括。

直白地说,你不应该“无声坠毁”;而应向模型返回结构化的结果,解释哪里出了问题,从而让它调整行为:重述请求、向用户提问、尝试别的工具,等等。

错误类型通常分三组:

  • 参数校验错误。模型可能生成不正确的参数:漏必填、用字符串代替数字、超出允许范围。此时不要只做异常抛出,也要返回有意义的响应:指出哪个字段不正确以及原因。
  • 业务错误。可预期的情况,如“商品未找到”“地区不可用”“预算对该类礼物来说过低”。从 API 角度看仍是错误,但应在正常响应结构内返回——附明确的错误码与消息,而非直接崩溃。
  • 系统错误。外部服务超时、网络问题、数据库故障。通常给代理一个谨慎、概括性的消息即可,如“服务暂时不可用,请稍后再试”。不要给堆栈、表名等模型不需要且可能有安全风险的信息。

Agents SDK 的官方资料甚至提供了 failure_error_function 之类的机制,用于优雅地构造模型可见的错误文本,而非简单把异常往上抛。

“友好”错误的结构

在代理工具(你的后端)里,可以约定任何错误都以如下对象返回:

type ToolError = {
  code: string;      // 'VALIDATION_ERROR', 'OUT_OF_STOCK', ...
  message: string;   // 给模型看的消息
  retryable: boolean;
};

而工具的返回结果可以是联合类型:

type SuggestGiftsResult =
  | {
      ok: true;
      items: GiftSummary[];
    }
  | {
      ok: false;
      error: ToolError;
    };

模型(或代理运行时)看到这样的 JSON,就能决定:如果 retryable: true,可以尝试小改动后重试;如果是不可重试的业务错误,则应回到用户处解释问题所在。

7. 示例:校验错误、业务错误与系统错误

回到我们的后端/代理工具,看看如何在代码中实现这些思路。

校验错误

假设调用了 suggest_gifts,但模型传来了一个负预算。

async function handleSuggestGifts(input: SuggestGiftsInput)
  : Promise<SuggestGiftsResult> {

  if (input.budget <= 0) {
    return {
      ok: false,
      error: {
        code: "VALIDATION_ERROR",
        message: "budget 必须是正数。",
        retryable: false
      }
    };
  }

  const items = await findGiftsInDb(input);
  return { ok: true, items };
}

这里我们刻意不抛异常,而是返回结构化错误。代理可以重新理解请求:也许它意识到搞错了货币单位,于是向用户确认,或者直接说明在该预算下无法推荐礼物。

业务错误

再看 get_gift_details。给定的 id 可能根本不存在相应的礼物。

async function handleGetGiftDetails(input: { gift_id: string }) {
  const gift = await db.gifts.findById(input.gift_id);

  if (!gift) {
    return {
      ok: false,
      error: {
        code: "GIFT_NOT_FOUND",
        message: "未找到具有此标识符的礼物。",
        retryable: false
      }
    };
  }

  return { ok: true, gift };
}

模型可能给出类似回复:“看起来所选礼物已不可用。我可以从相似类别里再给你推荐几个备选吗?”为此,代理无需看到 SQL 错误和堆栈,只需要清晰的 codemessage

系统错误

最后是系统错误示例。假设你的工具要调用一个外部配送 API,而这个 API 偶尔会“挂掉”。

async function handleEstimateDelivery(input: EstimateDeliveryInput) {
  try {
    const eta = await callDeliveryApi(input);
    return { ok: true, eta_days: eta };
  } catch (e) {
    return {
      ok: false,
      error: {
        code: "DELIVERY_SERVICE_UNAVAILABLE",
        message: "配送服务暂时不可用。",
        retryable: true
      }
    };
  }
}

代理可能会决定:“配送服务现在不可用。我仍会向你展示礼物,但具体配送时间可能有偏差。要继续吗?”

8. 工具的安全与幂等性(从 tools 视角的速览)

关于安全与权限会有单独的主题,但代理工具与之关系太密切,这里简单提及。

首先,区分读与写的工具。在描述、模式与权限里明确哪些 tools 只读且绝对安全,哪些会扣款、修改订单等。文档与社区对代理场景都强调 ReadOnly 与 Mutating 工具(tools)的区分。

其次,对变更型工具要考虑幂等性。代理或 MCP 客户端完全可能重试调用(例如网络错误导致),你可不希望 create_order 变成两个订单。典型做法:

  • 传入 idempotency‑key 作为工具参数;
  • 在执行前检查操作是否已存在;
  • 把步骤拆分为“创建订单草稿”和“确认订单”。

这些都与工具契约的设计强相关:如果 JSON Schema 中没有 idempotency‑key 字段,后续想加幂等性就会非常痛苦。

9. 简看 Agents SDK:在代理运行时长什么样

本节是给会使用偏 TypeScript 的 Agents SDK 的同学的简短概览。尽管课程主体是 MCP,但理解 Agents SDK 如何看待类似的工具以及工具在运行时的典型形态仍然有用。

官方文档通常描述一种“函数式工具”:任何通过配置对象(或类似 tool(...) 的 helper)定义并带有类型的函数,都能自动转化为工具;SDK 会为其生成 JSON Schema 与描述。

在概念层面,这与我们已讨论的并无二致:函数名、其参数以及注释/description 就分别扮演工具的名称、模式与描述。差别在于,SDK 和/或用于模式的辅助库(如 Zod 或 JSON Schema)替你完成了大量“机械化”工作。

一个示意(伪 TypeScript,简化):

type Gift = {
  id: string;
  title: string;
  // ...
};

const suggestGifts = tool({
  name: "suggest_gifts",
  description: "根据收件人类型与预算推荐礼物列表。",
  parameters: {
    type: "object",
    properties: {
      recipient_type: {
        type: "string",
        description: "礼物的收件人是谁(例如,'男性'、'女性'、'儿童')。"
      },
      budget: {
        type: "number",
        description: "用户所用货币的最高预算。"
      }
    },
    required: ["recipient_type", "budget"]
  }
}, async (args: { recipient_type: string; budget: number }): Promise<Gift[]> => {
  // 这里是你的领域逻辑
  return findGifts(args.recipient_type, args.budget);
});

SDK(或你的 tool helper)会基于 parameters 对象构建 JSON Schema 并传给代理,运行时负责参数的校验与编解码。理念上,这与你在 TypeScript 的 MCP 服务器里手工做的是一样的,只是现在工具“直接接入”代理运行时。

关键不在死记 helper tool 的具体语法,而是抓住要点:高质量的类型定义 + 清晰的 description/注释 = 高质量的工具

总而言之,一个好的代理工具,应当是窄而清晰的函数:有经过设计的 JSON Schema、对模型友好的描述,以及严谨的错误处理。只要工具之间不发生语义重叠,语义路由就会良好工作。而对变更型操作,必须保证安全与幂等,否则代理一上线就会制造惊喜(不好的那种)。

10. 设计代理工具的常见错误

错误一:过于宽泛的“do_everything”工具。
有时很想把所有事情塞进一个 manage_gifts:既搜索礼物、又显示详情、还创建订单、并发邮件。对模型而言这很痛苦:描述会变得模糊,语义路由退化,代理会“以防万一”到处调用它,哪怕只需要一个简单搜索。最好把任务拆分为各自单一职责的工具。

错误二:语义重叠的工具。
如果有 search_giftsfind_gifts,且都“按兴趣搜索礼物”,模型会随机在二者间选择,导致行为不稳定:相同请求有时走这个 tool,有时走另一个。尽量让每个名称与描述在语义空间中占据唯一“位置”。

错误三:糟糕或缺失的描述与模式字段。
func1 这样的名字、"Does something" 这样的描述,以及 data: string 这样的参数,都是让代理变“笨”的经典做法。模型不是读心术,它看不到你的源码。它依赖于模式中的 descriptionproperties 以及各字段的 description。如果你不解释 recipient_type 是什么,模型就只能瞎猜。

错误四:只考虑 happy path,忽略错误处理。
很多工具实现假设“一定会有正确参数与可用服务”。现实中模型很容易生成错误参数、外部服务会宕机、数据库也会超时。若不设计错误格式并返回有意义的信息,代理无法调整行为,要么悄然失败,要么开始幻觉。

错误五:把生硬的 500 与堆栈抛给 LLM。
在 REST API 中我们习惯把完整堆栈打日志以便调试。但在代理场景中,把堆栈给模型既无用(模型不了解你具体库里的 SQLException),也有风险(暴露实现细节甚至敏感信息)。更有价值的是捕获异常,把细节写日志,再给模型返回干净的 codemessage

错误六:变更型工具缺乏幂等性。
没有 idempotency‑key 的 create_order,在网络抖动与自动重试下,就是“重复下单”的邀请函。若你的代理涉及商业场景,与资金相关的工具必须确保重复调用不会带来额外扣款或重复记录。

错误七:在模式或描述里泄露机密与技术细节。
有的开发者习惯在 description 里写:“内部调用 https://internal-api.example.com 的服务 X”。模型不需要,用户更不需要。模式与描述是提示词的一部分,会进入模型上下文,不应包含内部服务 URL、私有表名,尤其不能包含密钥。

错误八:把一切原样丢给工具,而不是精心设计字段。
“把用户的整段提示字符串原封不动传进来,后面再说”的想法很诱人。但这会丢掉 JSON Schema 带来的结构化好处:模型不再知道请求中哪些部分对逻辑是关键,你也失去校验与可预测性。更好的做法是从请求中抽取显式字段(budgetinterestsuser_location),并把它们写进工具契约。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION