工具调用是Agent从”会说话”走向”能做事”的关键一步。一个没有工具能力的Agent,本质上只是一个问答接口;而一个工具设计不当的Agent,则可能在真实环境里制造混乱。外部系统千差万别:有的提供RESTful API,有的用GraphQL,有的还在用SOAP;认证方式也各不相同,从简单的API Key到OAuth 2.0流程,再到需要双向TLS证书的企业内网服务。如何让Agent稳定、安全、高效地与这些系统打交道,是每个Agent架构师必须解决的问题。
这篇文章会从工具抽象、API集成、认证机制、协议适配、错误处理、重试逻辑和限流熔断七个维度,讲清楚Agent与外部系统集成的完整方法论。
工具抽象:让Agent学会”打电话”
工具抽象的核心目标只有一个:把外部系统的调用细节隐藏起来,让Agent只看到”做什么”,不必关心”怎么做”。
一个设计良好的工具层,应该像一本电话簿。Agent不需要知道电话线路怎么铺设,只需要知道打给谁、说什么、期望得到什么。具体实现上,我通常把工具抽象成三层:接口层、适配层和执行层。
接口层定义工具的契约。它告诉Agent这个工具叫什么名字、需要什么参数、返回什么结构。这个契约要足够稳定,不能随便改动,否则Agent的行为就会失控。下面是一个典型的工具接口定义:
interface ToolDefinition {
name: string;
description: string;
parameters: JSONSchema;
returns: JSONSchema;
examples?: ToolExample[];
}
interface ToolExample {
input: unknown;
output: unknown;
explanation: string;
}
这个定义里,description字段尤其重要。Agent通过自然语言理解来匹配工具和任务,描述写得越具体,匹配准确率越高。比如”查询天气”就不如”查询指定城市的实时天气,返回温度、湿度和风力等级”精确。
适配层负责把Agent传来的结构化参数,转换成外部系统能理解的格式。比如Agent传了一个{ city: "北京" },适配层可能要根据目标API的要求,把它变成GET /weather?location=beijing&lang=zh。这个转换过程可能涉及参数重命名、格式转换、编码处理,甚至多参数拼接。
执行层是最靠近网络的一层。它负责真正的HTTP请求、超时控制、连接池管理、SSL握手等底层操作。执行层应该尽量通用,不要被某个特定API的形状污染。如果每个外部系统都需要自己写一套HTTP客户端,代码会迅速膨胀成难以维护的状态。
API集成:不要只封装请求,要封装语义
很多开发者在集成外部API时,犯的第一个错误就是把API调用原样封装成函数。比如把GET /api/v1/users/{id}封装成getUserById(id)。这看起来没问题,但实际上只是把HTTP请求翻译成函数调用,没有解决语义层面的问题。
Agent使用工具时,面对的不是代码,而是自然语言。它需要的是语义层面的理解:这个工具能帮我做什么?什么情况下该用它?输入参数的真实含义是什么?返回结果里的每个字段代表什么?
所以API集成不能只停留在技术封装,必须做到语义封装。具体来说,要做好三件事:意图映射、参数语义化和结果结构化。
意图映射是指让Agent能根据任务描述,准确判断该调用哪个工具。这需要工具描述写得足够清晰,同时也要考虑工具之间的区分度。如果两个工具的功能描述太接近,Agent就会犹豫不决。
参数语义化是指给每个参数添加自然语言描述,说明它的含义、格式要求和约束条件。比如:
const toolDefinition = {
name: "search_documents",
description: "在文档库中搜索与查询相关的文档,返回最匹配的结果列表",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "搜索关键词,支持中文和英文。建议使用具体名词而非整句"
},
limit: {
type: "number",
description: "返回结果的最大数量,必须在1到50之间",
default: 10
},
dateRange: {
type: "string",
description: "时间范围过滤,格式为YYYY-MM-DD..YYYY-MM-DD。如要搜索全部时间,不传此参数"
}
},
required: ["query"]
}
};
这样Agent就能明白,query应该放关键词而不是问题句子,dateRange有特定的格式要求,limit有上下限。
结果结构化是指把API返回的原始JSON,转换成Agent容易理解的格式。很多API返回的数据结构很复杂,嵌套层级深,字段命名不直观。直接丢给Agent,它可能找不到关键信息。应该在适配层做一层映射,把结果重新组织成扁平、直观的结构。
认证机制:安全的第一道门槛
外部系统的认证方式五花八门,但归纳起来主要就几种:API Key、OAuth 2.0、JWT Token、Basic Auth、以及基于证书的双向TLS。Agent在调用外部服务时,必须正确处理这些认证机制,否则连门都进不去。
API Key是最简单的一种。通常在请求头里加一个Authorization或X-API-Key字段。实现上要注意两件事:第一,Key不能硬编码在代码里,应该走配置或密钥管理系统;第二,Key不能出现在日志和错误信息里,避免意外泄露。
class ApiKeyAuth implements AuthProvider {
constructor(private key: string) {}
apply(request: HttpRequest): HttpRequest {
return {
...request,
headers: {
...request.headers,
"Authorization": `Bearer ${this.key}`
}
};
}
// 在日志中脱敏
sanitizeForLogging(request: HttpRequest): HttpRequest {
const headers = { ...request.headers };
if (headers["Authorization"]) {
headers["Authorization"] = "Bearer ***REDACTED***";
}
return { ...request, headers };
}
}
OAuth 2.0就复杂得多。它需要走完整的授权流程:获取授权码、交换访问令牌、刷新令牌、处理令牌过期。Agent作为客户端,通常只需要处理”客户端凭证模式”(Client Credentials)或”授权码模式”(Authorization Code)的后续步骤。
class OAuth2Auth implements AuthProvider {
private accessToken: string | null = null;
private expiresAt: number = 0;
constructor(
private clientId: string,
private clientSecret: string,
private tokenUrl: string
) {}
async apply(request: HttpRequest): Promise<HttpRequest> {
const token = await this.ensureToken();
return {
...request,
headers: {
...request.headers,
"Authorization": `Bearer ${token}`
}
};
}
private async ensureToken(): Promise<string> {
const now = Date.now();
if (this.accessToken && now < this.expiresAt - 60000) {
return this.accessToken;
}
const response = await fetch(this.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: this.clientId,
client_secret: this.clientSecret
})
});
const data = await response.json();
this.accessToken = data.access_token;
this.expiresAt = now + data.expires_in * 1000;
return this.accessToken;
}
}
JWT Token相对简单,但需要注意令牌的刷新策略。很多系统颁发的JWT有效期很短,可能只有几分钟。Agent需要在令牌过期前主动刷新,不能让请求因为令牌过期而失败。
协议适配:不只有HTTP
虽然RESTful API是目前最主流的外部系统接口形式,但Agent实际工作中会碰到各种协议。GraphQL让客户端可以精确请求所需字段,避免了过度获取;gRPC基于HTTP/2,适合高性能内部通信;消息队列如RabbitMQ、Kafka适合异步任务;甚至有些遗留系统还在用SOAP或XML-RPC。
协议适配的关键是不要让Agent感知协议差异。无论底层是HTTP、gRPC还是消息队列,Agent看到的都应该是一致的工具调用接口。
对于GraphQL,可以在适配层把工具调用转换成GraphQL查询。Agent传一个工具名和参数,适配层根据预定义的查询模板生成GraphQL语句:
class GraphQLAdapter implements ProtocolAdapter {
constructor(
private endpoint: string,
private queryMap: Map<string, string>
) {}
async execute(toolName: string, params: unknown): Promise<unknown> {
const queryTemplate = this.queryMap.get(toolName);
if (!queryTemplate) {
throw new Error(`Unknown GraphQL tool: ${toolName}`);
}
const query = this.bindParameters(queryTemplate, params);
const response = await fetch(this.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query })
});
const result = await response.json();
if (result.errors) {
throw new GraphQLError(result.errors);
}
return result.data;
}
private bindParameters(template: string, params: unknown): string {
// 将模板变量替换为实际参数值
let query = template;
for (const [key, value] of Object.entries(params as Record<string, unknown>)) {
query = query.replace(`$${key}`, JSON.stringify(value));
}
return query;
}
}
对于消息队列,可以把工具调用包装成消息发送。Agent调用工具时,消息被发送到队列;处理结果通过回调或轮询的方式返回。这种模式适合耗时较长的任务,比如文档生成、数据分析、批量处理等。
协议适配层还应该处理数据格式的转换。JSON、XML、Protobuf、MessagePack,每种格式都有自己的序列化规则。适配层要能把外部系统的返回格式统一转换成Agent能处理的JSON结构。
错误处理:失败不是异常,是信息
Agent调用外部工具时,失败是常态,不是例外。网络抖动、服务过载、参数错误、认证过期、依赖服务故障,这些情况每天都会发生。一个健壮的Agent系统,必须有一套完整的错误处理策略。
首先要建立错误分类体系。不是所有错误都一样严重,也不是所有错误都应该重试。通常我把错误分成四类:
客户端错误(4xx):请求有问题。比如400 Bad Request是参数格式不对,401 Unauthorized是认证失败,403 Forbidden是权限不足,404 Not Found是资源不存在。这类错误重试没有意义,应该直接返回给Agent,让它决定怎么处理。
服务端错误(5xx):服务内部出问题。比如500 Internal Server Error、502 Bad Gateway、503 Service Unavailable、504 Gateway Timeout。这类错误通常可以重试,因为问题可能在服务端是暂时的。
网络错误:连接超时、DNS解析失败、SSL握手失败、连接被重置。这类错误也可以重试,但要区分超时类型。连接超时可以重试,读取超时则要谨慎,因为请求可能已经到达服务端并被执行了。
业务错误:HTTP状态码是200,但返回的数据里包含错误信息。比如{ "success": false, "error": "余额不足" }。这类错误需要解析响应体才能识别,处理方式取决于业务逻辑。
enum ErrorCategory {
CLIENT_ERROR, // 4xx
SERVER_ERROR, // 5xx
NETWORK_ERROR, // 连接/超时
BUSINESS_ERROR, // 业务逻辑错误
UNKNOWN
}
interface CategorizedError {
category: ErrorCategory;
original: Error;
httpStatus?: number;
retryable: boolean;
message: string;
}
function categorizeError(error: unknown, response?: Response): CategorizedError {
if (error instanceof NetworkError) {
return {
category: ErrorCategory.NETWORK_ERROR,
original: error,
retryable: true,
message: `网络错误: ${error.message}`
};
}
if (response) {
if (response.status >= 400 && response.status < 500) {
return {
category: ErrorCategory.CLIENT_ERROR,
original: error as Error,
httpStatus: response.status,
retryable: false,
message: `客户端错误 ${response.status}: ${response.statusText}`
};
}
if (response.status >= 500) {
return {
category: ErrorCategory.SERVER_ERROR,
original: error as Error,
httpStatus: response.status,
retryable: true,
message: `服务端错误 ${response.status}: ${response.statusText}`
};
}
}
return {
category: ErrorCategory.UNKNOWN,
original: error as Error,
retryable: false,
message: `未知错误: ${(error as Error).message}`
};
}
错误信息返回给Agent时,要做适当的脱敏和简化。不要把完整的堆栈跟踪、内部IP地址、数据库连接字符串暴露出去。同时要让Agent能理解错误原因,以便它决定下一步行动。
重试逻辑:在失败和浪费之间找平衡
重试是解决暂时性故障的标准手段,但重试不是免费的。每次重试都消耗时间、带宽和对方的资源。不加控制的重试,会把小问题放大成大问题。
好的重试策略需要回答四个问题:什么时候重试、重试多少次、间隔多久、退避策略是什么。
什么时候重试:只对暂时性错误重试。网络超时、服务暂时不可用、速率限制,这些可以重试。参数错误、权限不足、资源不存在,这些重试也没用。
重试多少次:通常3到5次比较合适。太少会漏掉真正暂时的故障,太多会让失败请求拖太久。对于关键路径,可以适当增加;对于非关键操作,可以减少。
间隔多久:不能立即重试,因为故障可能还在持续。至少等几百毫秒,给服务端恢复的时间。
退避策略:指数退避是最常用的策略。第一次等1秒,第二次等2秒,第三次等4秒,以此类推。这样可以避免大量客户端同时重试造成的”惊群效应”。还可以加上随机抖动(jitter),让重试时间分散开。
interface RetryPolicy {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
jitterFactor: number;
retryableCategories: ErrorCategory[];
}
const defaultRetryPolicy: RetryPolicy = {
maxAttempts: 3,
baseDelayMs: 1000,
maxDelayMs: 30000,
backoffMultiplier: 2,
jitterFactor: 0.3,
retryableCategories: [ErrorCategory.SERVER_ERROR, ErrorCategory.NETWORK_ERROR]
};
async function executeWithRetry<T>(
operation: () => Promise<T>,
policy: RetryPolicy = defaultRetryPolicy
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
const categorized = categorizeError(error);
lastError = categorized.original;
if (!policy.retryableCategories.includes(categorized.category)) {
throw error;
}
if (attempt === policy.maxAttempts) {
break;
}
const delay = calculateDelay(attempt, policy);
await sleep(delay);
}
}
throw lastError;
}
function calculateDelay(attempt: number, policy: RetryPolicy): number {
const exponential = policy.baseDelayMs * Math.pow(policy.backoffMultiplier, attempt - 1);
const capped = Math.min(exponential, policy.maxDelayMs);
const jitter = capped * policy.jitterFactor * (Math.random() * 2 - 1);
return Math.max(0, capped + jitter);
}
有些API会在响应头里给出重试建议,比如Retry-After。这种信息比重试策略自己算出来的更可靠,应该优先采用。
限流与熔断:保护双方
Agent调用外部系统时,不能只考虑自己能不能调用成功,还要考虑对方的承受能力。如果一个Agent因为任务紧急就疯狂发送请求,很可能会把对方服务打挂,或者触发对方的限流机制导致所有请求被拒绝。
限流(Rate Limiting)是在客户端主动控制自己的请求频率。熔断(Circuit Breaker)则是在检测到对方服务持续故障时,暂时停止请求,避免继续浪费资源。
客户端限流通常用令牌桶或漏桶算法实现。令牌桶允许一定程度的突发流量,漏桶则更平滑。对于Agent系统,令牌桶通常更合适,因为Agent的任务往往有突发性。
class TokenBucket {
private tokens: number;
private lastRefill: number;
constructor(
private capacity: number,
private refillRate: number // 每秒补充的令牌数
) {
this.tokens = capacity;
this.lastRefill = Date.now();
}
async acquire(): Promise<void> {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return;
}
const waitMs = (1 - this.tokens) / this.refillRate * 1000;
await sleep(waitMs);
return this.acquire();
}
private refill(): void {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
}
熔断器有三种状态:关闭(正常通过)、打开(拒绝所有请求)、半开(允许少量试探请求)。当失败率达到阈值时,熔断器从关闭转为打开;经过一段冷却时间后,转为半开;如果试探请求成功,再回到关闭,否则重新打开。
type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
class CircuitBreaker {
private state: CircuitState = "CLOSED";
private failureCount = 0;
private successCount = 0;
private nextAttempt = 0;
constructor(
private failureThreshold = 5,
private successThreshold = 3,
private timeoutMs = 30000
) {}
async execute<T>(operation: () => Promise<T>): Promise<T> {
if (this.state === "OPEN") {
if (Date.now() < this.nextAttempt) {
throw new CircuitOpenError("熔断器已打开,请稍后重试");
}
this.state = "HALF_OPEN";
this.successCount = 0;
}
try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failureCount = 0;
if (this.state === "HALF_OPEN") {
this.successCount += 1;
if (this.successCount >= this.successThreshold) {
this.state = "CLOSED";
this.successCount = 0;
}
}
}
private onFailure(): void {
this.failureCount += 1;
if (this.failureCount >= this.failureThreshold) {
this.state = "OPEN";
this.nextAttempt = Date.now() + this.timeoutMs;
}
}
}
限流和熔断可以组合使用。限流保护对方不被过载,熔断保护自己不被拖垮。Agent在调用外部系统时,应该同时带上这两层保护。
实际案例:多源数据聚合Agent
假设我们要构建一个电商价格监控Agent,它需要同时查询多个电商平台的商品信息,比较价格后给用户最优推荐。这个场景完美展示了工具集成中的各种挑战。
这个Agent需要对接三个平台:A平台提供RESTful API,需要OAuth 2.0认证;B平台使用GraphQL,需要API Key;C平台是一个内部系统,通过gRPC暴露接口,需要双向TLS。
首先,我们为每个平台定义工具抽象:
const tools: ToolDefinition[] = [
{
name: "query_platform_a",
description: "在A平台查询商品信息,返回价格、库存和评分",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "商品关键词" },
category: { type: "string", description: "商品分类ID" }
},
required: ["keyword"]
}
},
{
name: "query_platform_b",
description: "在B平台搜索商品,支持按价格和销量排序",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "搜索关键词" },
sortBy: { type: "string", enum: ["price", "sales"], description: "排序方式" }
},
required: ["keyword"]
}
},
{
name: "query_platform_c",
description: "查询内部系统的商品成本价和供应商信息",
parameters: {
type: "object",
properties: {
skuId: { type: "string", description: "商品SKU编号" }
},
required: ["skuId"]
}
}
];
然后,为每个平台配置不同的认证和适配器:
const integrations = {
platformA: {
auth: new OAuth2Auth(clientIdA, clientSecretA, "https://api.a.com/oauth/token"),
adapter: new RestAdapter("https://api.a.com/v1"),
rateLimiter: new TokenBucket(100, 10), // 每秒10请求,桶容量100
circuitBreaker: new CircuitBreaker(5, 3, 60000)
},
platformB: {
auth: new ApiKeyAuth(apiKeyB),
adapter: new GraphQLAdapter("https://api.b.com/graphql", queryMapB),
rateLimiter: new TokenBucket(200, 20),
circuitBreaker: new CircuitBreaker(5, 3, 30000)
},
platformC: {
auth: new MutualTLSAuth(certPathC, keyPathC, caPathC),
adapter: new GrpcAdapter("internal-api.company.local:50051", protoPathC),
rateLimiter: new TokenBucket(500, 50),
circuitBreaker: new CircuitBreaker(10, 5, 15000)
}
};
Agent收到”帮我找最便宜的iPhone 16”这个请求后,会同时调用三个平台的查询工具。由于三个调用之间没有依赖关系,可以并行执行。每个调用都经过限流、熔断、重试的保护。如果某个平台超时或失败,不影响其他平台的结果收集。
结果回来后,Agent对比三个平台的价格、库存和评分,给用户一个综合推荐。如果A平台因为熔断暂时没有返回,Agent会明确告诉用户”A平台暂时不可用,以下是B平台和内部系统的结果”。
总结与最佳实践
Agent与外部系统集成是一项系统工程,不是简单封装几个HTTP请求就能搞定的。根据上面的讨论,我总结了几条最佳实践:
工具描述要精确。Agent靠描述来匹配工具,模糊不清的描述会导致选错工具或参数填错。描述里要明确说明工具的用途、适用场景、参数含义和返回结构。
分层设计:接口层、适配层、执行层各司其职。接口层面向Agent,保持稳定;适配层处理协议差异;执行层处理网络细节。某一层改动不要影响其他层。
认证信息绝不硬编码。API Key、客户端密钥、证书路径,都应该走配置系统或密钥管理。代码里只保留引用,不保留真实值。
错误要分类处理。不是所有错误都要重试,也不是所有错误都要抛给Agent。建立清晰的错误分类体系,让每一类错误都有明确的处理策略。
重试要有节制。指数退避加随机抖动是标准做法。设置最大重试次数和最大延迟上限,避免无限重试。尊重对方的Retry-After头。
限流保护对方,熔断保护自己。两个机制都不可或缺。限流防止自己成为DDoS攻击者,熔断避免在对方故障时浪费资源。
超时设置要合理。连接超时、读取超时、总超时,三个维度都要配置。连接超时通常3-5秒,读取超时取决于接口的P99响应时间,总超时不要超过Agent任务的预期完成时间。
日志要完整但脱敏。记录每次调用的请求参数、响应状态、耗时、重试次数,方便排查问题。但要把敏感信息(Token、Key、密码)脱敏后再记录。
把这些原则落地后,Agent就能像经验丰富的工程师一样,可靠地与各种外部系统打交道。工具调用不再是Agent的弱点,而是它的核心竞争力。