Agent工具调用与外部系统集成

工具调用是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是最简单的一种。通常在请求头里加一个AuthorizationX-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的弱点,而是它的核心竞争力。