1. 代理工具:它究竟是什么
在前面的模块中,你已经从 Apps SDK 的角度见过工具——就像“后端函数”,ChatGPT 通过你的 App 去调用。现在换个视角:从 代理(Agents SDK)看工具,并解释它如何选择要调用的工具以及如何处理错误。
在传统后端中,你习惯按“端点”“控制器方法”“服务函数”来思考。在代理世界里,行动的基本单位变成了工具(tool)。代理的 tools 与 mcp-tools 是不同但有交集的概念。
严格来说:在 ChatGPT Agents SDK 的语境中,工具是模型可以请求执行的函数的描述。模型本身不执行代码;它生成结构化请求(通常是 JSON),而运行时(你的代码、MCP 服务器或 Agents SDK)负责执行并返回结果。
在 ChatGPT Agents SDK 生态中,工具由配置描述:它有 name、description 和 parameters(参数的 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_order、send_email)、纯计算型(calculate_loan)、以及系统/控制类(handoff_to_human、finish_task)。
为了固定这个概念,来看张小表。
| 类别 | GiftGenius 示例 | 副作用 | 风险 |
|---|---|---|---|
| Data Retrieval | |
否 | 低 |
| Action / Mutating | |
是 | 高 |
| Computation | |
否 | 中等 |
| System / Control | |
否 | 逻辑 |
从架构角度,最重要的是:只读工具应当数量多且成本低;而变更型工具应当稀少、极其谨慎,配有日志、幂等性,并且通常需要用户确认。
接下来主要讨论数据获取与 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"]
}
}
这里有几个要点。
- 首先,name 与 description。对模型而言,它们是何时使用该工具的主要信号。语义路由文档强调,工具的描述其实就是面向模型的 API:若把工具命名为 func1 并写上“做点有用的事”,模型确实无法理解何时调用它;而如果叫 search_gifts 并给出清晰描述,选择就容易多了。
- 其次,parameters。字段名与其描述至关重要。对 LLM 来说,recipient_type 比 type 清楚得多。像“礼物的收件人是谁……”这样的优质描述,能提示模型应当填入收件人的类型,而不是例如包装的形式。
- 第三,required。这不仅是你这边的校验,也是对模型的提示:它会努力填充必填字段,而当上下文不明确时可忽略非必填项,从而减少“空”或不正确的 tool 调用。
Apps SDK 的官方指南明确建议:让工具更窄、更单一职责、具备清晰的名称与描述,避免那种“把关于礼物的一切都做了”的大一统工具。
4. 设计 GiftGenius 的工具:从模式到代码
以 GiftGenius 为例,添加两个几乎在所有场景都需要的关键 LLM 代理工具:
- suggest_gifts(profile, budget) —— 输出候选列表;
- get_gift_details(gift_id) —— 展示某个礼物的详细信息。
我们的 suggest_gifts 与 get_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)列表及其 name、description、inputSchema。
当有新的用户消息到来时,模型会将请求语义与工具描述进行匹配(语义对齐)。如果请求是“给同事推荐 50 美元以内的礼物”,suggest_gifts 的描述显然比 get_gift_details 更相关,代理就很可能选择它。
官方指南强调两点,它们对路由质量影响很大:
- 首先,避免语义重叠的工具:如果同时有 search_gifts 与 find_gifts,而且描述差不多,模型会混淆。
- 其次,尽量遵循单一职责:一个 tool 做一件清晰的事,而不是“既推荐礼物又创建订单还发送邮件”。
不同 LLM 代理中,通常可以设置工具选择的模式:例如“auto”(模型自行决定是否调用工具)、“required”(必须调用工具)、“none”(禁用工具)。这在复杂工作流(多步场景)中很有帮助,比如某一步你想强制调用 suggest_gifts,而不是让模型闲聊。
GiftGenius 中的语义路由示例
假设代理至少有两个工具:suggest_gifts 与 get_gift_details。
- 用户说:“给一位喜欢桌游的同事推荐 30 美元以内的礼物”。
- 代理发现请求包含“推荐礼物”的目标、预算信息与兴趣点。suggest_gifts 的描述完全匹配——调用它。
- 工具返回包含五个礼物的列表及其 id、名称与简要描述。
- 用户接着说:“详细介绍一下第三个选项”。代理把“第三个选项”与前面结果中的 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 错误和堆栈,只需要清晰的 code 与 message。
系统错误
最后是系统错误示例。假设你的工具要调用一个外部配送 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_gifts 与 find_gifts,且都“按兴趣搜索礼物”,模型会随机在二者间选择,导致行为不稳定:相同请求有时走这个 tool,有时走另一个。尽量让每个名称与描述在语义空间中占据唯一“位置”。
错误三:糟糕或缺失的描述与模式字段。
func1 这样的名字、"Does something" 这样的描述,以及 data: string 这样的参数,都是让代理变“笨”的经典做法。模型不是读心术,它看不到你的源码。它依赖于模式中的 description、properties 以及各字段的 description。如果你不解释 recipient_type 是什么,模型就只能瞎猜。
错误四:只考虑 happy path,忽略错误处理。
很多工具实现假设“一定会有正确参数与可用服务”。现实中模型很容易生成错误参数、外部服务会宕机、数据库也会超时。若不设计错误格式并返回有意义的信息,代理无法调整行为,要么悄然失败,要么开始幻觉。
错误五:把生硬的 500 与堆栈抛给 LLM。
在 REST API 中我们习惯把完整堆栈打日志以便调试。但在代理场景中,把堆栈给模型既无用(模型不了解你具体库里的 SQLException),也有风险(暴露实现细节甚至敏感信息)。更有价值的是捕获异常,把细节写日志,再给模型返回干净的 code 和 message。
错误六:变更型工具缺乏幂等性。
没有 idempotency‑key 的 create_order,在网络抖动与自动重试下,就是“重复下单”的邀请函。若你的代理涉及商业场景,与资金相关的工具必须确保重复调用不会带来额外扣款或重复记录。
错误七:在模式或描述里泄露机密与技术细节。
有的开发者习惯在 description 里写:“内部调用 https://internal-api.example.com 的服务 X”。模型不需要,用户更不需要。模式与描述是提示词的一部分,会进入模型上下文,不应包含内部服务 URL、私有表名,尤其不能包含密钥。
错误八:把一切原样丢给工具,而不是精心设计字段。
“把用户的整段提示字符串原封不动传进来,后面再说”的想法很诱人。但这会丢掉 JSON Schema 带来的结构化好处:模型不再知道请求中哪些部分对逻辑是关键,你也失去校验与可预测性。更好的做法是从请求中抽取显式字段(budget、interests、user_location),并把它们写进工具契约。
GO TO FULL VERSION