内部 API 一旦靠手写 JSON Schema 暴露给模型,参数名、路径和鉴权很快会与真实接口分叉。智能体随后漏字段、打错 URL,或把整段响应塞进上下文。本文从一份可运行的 OpenAPI 生成工具定义,在模型外注入鉴权与截止时间,按字段白名单裁剪响应,并用契约测试把规范和实现锁在一起。

手写工具层会在哪裂开

给智能体接内部接口,常见做法是再写一份 function calling 用的 JSON Schema:工具名、参数、一句描述。两个接口时尚可维护;后端一周改三次,说明书一定落后。

裂口很具体。后端把 userId 改成必填,模型侧仍标成可选,于是漏传、接口 400。有人把 Bearer Token、超时秒数写进 description,模型既执行不了这些约束,还可能把密钥当成普通参数来编。列表接口把嵌套对象原样送回对话,地址、坐标占满上下文,真正要的 id 和 email 反而要靠下一轮再问。

更隐蔽的是路径写死在提示词里:OpenAPI 已经改成 /v2/posts/{id},模型仍打旧路径。根因不是采样温度,是契约写了两份。OpenAPI 已是后端契约,工具层应当是它的投影,而不是平行文档。参数约束放在 JSON Schema 的 type / required 上,由执行层拒绝非法调用;不要指望模型稳定遵守自然语言里的「必须是数字」。

先写一份能跑通的 OpenAPI

演示用 JSONPlaceholder,无需密钥。GET /posts/{id}、GET /posts?userId=、GET /users/{id} 都是真实路径。把规范存成 openapi.json,schema 全部内联,避免示例里再解析 $ref。

{
  "openapi": "3.0.3",
  "info": {
    "title": "JSONPlaceholder",
    "version": "1.0.0"
  },
  "servers": [
    { "url": "https://jsonplaceholder.typicode.com" }
  ],
  "paths": {
    "/posts/{id}": {
      "get": {
        "operationId": "getPost",
        "summary": "按 id 获取一篇帖子",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "帖子 id",
            "schema": { "type": "integer" }
          }
        ],
        "responses": {
          "200": {
            "description": "帖子",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "userId": { "type": "integer" },
                    "id": { "type": "integer" },
                    "title": { "type": "string" },
                    "body": { "type": "string" }
                  },
                  "required": ["userId", "id", "title", "body"]
                }
              }
            }
          }
        }
      }
    },
    "/posts": {
      "get": {
        "operationId": "listPosts",
        "summary": "按作者列出帖子",
        "parameters": [
          {
            "name": "userId",
            "in": "query",
            "required": false,
            "description": "作者 id",
            "schema": { "type": "integer" }
          }
        ],
        "responses": {
          "200": {
            "description": "帖子列表",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "userId": { "type": "integer" },
                      "id": { "type": "integer" },
                      "title": { "type": "string" },
                      "body": { "type": "string" }
                    },
                    "required": ["userId", "id", "title", "body"]
                  }
                }
              }
            }
          }
        }
      }
    },
    "/users/{id}": {
      "get": {
        "operationId": "getUser",
        "summary": "按 id 获取用户的公开字段",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "用户 id",
            "schema": { "type": "integer" }
          }
        ],
        "responses": {
          "200": {
            "description": "用户",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": { "type": "integer" },
                    "name": { "type": "string" },
                    "email": { "type": "string" },
                    "website": { "type": "string" }
                  },
                  "required": ["id", "name", "email"]
                }
              }
            }
          }
        }
      }
    }
  }
}

两处是刻意的。operationId 作为工具名,避免模型去复述 HTTP 路径。响应 schema 可以窄于线上 JSON:getUser 只声明 id、name、email、website,地址和公司默认不进上下文。servers.url 必须是真实可访问的源,执行层会直接拼接。

把 Operation 转成工具定义

工具 schema 只包含模型可填的参数。path、method、Header 鉴权都不要出现在 parameters 里。把 openapi.json 与下面的 agent-tools.mjs 放在同一目录(Node.js 18+)。转换结果的字段结构与 OpenAI Chat Completions 的 tools 数组一致;其它厂商只是外套不同,核心仍是 name、description 与一份 JSON Schema。

import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = dirname(fileURLToPath(import.meta.url));
const spec = JSON.parse(await readFile(join(root, 'openapi.json'), 'utf8'));

function httpOps(methods) {
  const out = {};
  for (const [key, op] of Object.entries(methods)) {
    if (['get', 'post', 'put', 'patch', 'delete'].includes(key) && op?.operationId) {
      out[key] = op;
    }
  }
  return out;
}

function toTools(doc) {
  const tools = [];
  for (const [path, methods] of Object.entries(doc.paths)) {
    for (const [method, op] of Object.entries(httpOps(methods))) {
      const properties = {};
      const required = [];
      for (const param of op.parameters ?? []) {
        const schema = { ...(param.schema ?? { type: 'string' }) };
        if (param.description) schema.description = param.description;
        properties[param.name] = schema;
        if (param.required) required.push(param.name);
      }
      tools.push({
        type: 'function',
        function: {
          name: op.operationId,
          description: op.summary || op.description || `${method.toUpperCase()} ${path}`,
          parameters: {
            type: 'object',
            properties,
            required,
            additionalProperties: false,
          },
        },
      });
    }
  }
  return tools;
}

function findOperation(operationId) {
  for (const [path, methods] of Object.entries(spec.paths)) {
    for (const [method, op] of Object.entries(httpOps(methods))) {
      if (op.operationId === operationId) {
        return { path, method: method.toUpperCase(), op };
      }
    }
  }
  throw new Error(`未知 operationId: ${operationId}`);
}

function coerce(schema, value) {
  if (value === undefined) return value;
  if (schema?.type === 'integer' && typeof value === 'string' && /^-?\d+$/.test(value)) {
    return Number.parseInt(value, 10);
  }
  if (schema?.type === 'number' && typeof value === 'string' && value !== '' && Number.isFinite(Number(value))) {
    return Number(value);
  }
  return value;
}

function coerceArgs(op, args) {
  const out = { ...args };
  for (const param of op.parameters ?? []) {
    if (param.name in out) out[param.name] = coerce(param.schema, out[param.name]);
  }
  return out;
}

function jsonSchema(op) {
  return op.responses?.['200']?.content?.['application/json']?.schema ?? null;
}

function pickBySchema(data, schema) {
  if (!schema) return data;
  if (schema.type === 'array') {
    if (!Array.isArray(data)) return data;
    return data.map((item) => pickBySchema(item, schema.items ?? {}));
  }
  if (
    schema.type === 'object' &&
    schema.properties &&
    data &&
    typeof data === 'object' &&
    !Array.isArray(data)
  ) {
    const out = {};
    for (const key of Object.keys(schema.properties)) {
      if (Object.prototype.hasOwnProperty.call(data, key)) {
        out[key] = pickBySchema(data[key], schema.properties[key]);
      }
    }
    return out;
  }
  return data;
}

function assertRequired(data, schema, path = '$') {
  if (!schema) return;
  if (schema.type === 'array') {
    if (!Array.isArray(data)) throw new Error(`${path} 应为数组`);
    data.forEach((item, i) => assertRequired(item, schema.items ?? {}, `${path}[${i}]`));
    return;
  }
  if (schema.type === 'object') {
    if (!data || typeof data !== 'object' || Array.isArray(data)) {
      throw new Error(`${path} 应为对象`);
    }
    for (const key of schema.required ?? []) {
      if (!Object.prototype.hasOwnProperty.call(data, key)) {
        throw new Error(`缺少字段 ${path}.${key}`);
      }
    }
  }
}

async function callTool(operationId, rawArgs, options = {}) {
  const { path, method, op } = findOperation(operationId);
  const args = coerceArgs(op, rawArgs ?? {});
  for (const param of op.parameters ?? []) {
    if (param.required && (args[param.name] === undefined || args[param.name] === '')) {
      throw new Error(`缺少参数 ${param.name}`);
    }
  }

  const base = String(spec.servers[0].url).replace(/\/$/, '');
  let urlPath = path;
  for (const param of op.parameters ?? []) {
    if (param.in === 'path') {
      urlPath = urlPath.replace(`{${param.name}}`, encodeURIComponent(String(args[param.name])));
    }
  }
  const url = new URL(base + urlPath);
  for (const param of op.parameters ?? []) {
    if (param.in === 'query' && args[param.name] !== undefined) {
      url.searchParams.set(param.name, String(args[param.name]));
    }
  }

  const headers = { Accept: 'application/json' };
  if (process.env.TOKEN) {
    headers.Authorization = `Bearer ${process.env.TOKEN}`;
  }

  const timeoutMs = options.timeoutMs ?? 8000;
  const res = await fetch(url, { method, headers, signal: AbortSignal.timeout(timeoutMs) });
  if (!res.ok) {
    throw new Error(`${method} ${url.pathname} -> ${res.status}`);
  }
  return pickBySchema(await res.json(), jsonSchema(op));
}

const examples = {
  getPost: { id: 1 },
  listPosts: { userId: 1 },
  getUser: { id: 1 },
};

const [cmd, name, jsonArgs] = process.argv.slice(2);

if (cmd === 'generate') {
  console.log(JSON.stringify(toTools(spec), null, 2));
} else if (cmd === 'call') {
  const result = await callTool(name, jsonArgs ? JSON.parse(jsonArgs) : {});
  console.log(JSON.stringify(result, null, 2));
} else if (cmd === 'test') {
  for (const [operationId, args] of Object.entries(examples)) {
    const { op } = findOperation(operationId);
    const data = await callTool(operationId, args);
    assertRequired(data, jsonSchema(op));
    console.log(`ok ${operationId}`);
  }
} else {
  console.error('usage: node agent-tools.mjs generate | call <id> [json] | test');
  process.exit(1);
}

执行 node agent-tools.mjs generate。getPost 的 parameters 里应当只有 id,additionalProperties 为 false,减少模型编造未知参数。生成结果建议提交进仓库,和 openapi.json 一起评审,而不是每次在运行时静默改写。

执行层:鉴权、截止时间、字段白名单

模型只应返回 operationId 与 arguments。HTTP 方法、URL 模板来自规范:path 参数做 URL 编码,query 写入 URLSearchParams。

鉴权从环境变量 TOKEN 注入 Authorization。JSONPlaceholder 不校验该头,内部 API 却几乎总是这种接法。不要把 authorization 放进工具 parameters,否则模型会尝试编造密钥。截止时间用 AbortSignal.timeout(8000)。工具网络失败应在编排层变成明确错误,而不是让 fetch 一直挂着占住对话。

模型常把整数 id 传成字符串 "1"。执行层按规范里的 type: integer 做一次窄转换再发请求。缺必填参数直接抛错;把 4xx 响应原文丢回模型,对下一轮填参帮助有限。

node agent-tools.mjs call getPost '{"id":1}'
node agent-tools.mjs call getUser '{"id":1}'
TOKEN=dev-token node agent-tools.mjs call listPosts '{"userId":1}'

getUser 的线上 JSON 含 address、company;按 200 响应 schema 取字段后,进模型的只剩声明过的键。数组则对每个元素做同样裁剪。这是上下文治理,不是安全沙箱:白名单防的是噪声和体积,不是恶意后端。

契约测试:防止规范再漂

生成器和执行器都信任规范。需要一轮不经过模型的检查:用固定示例参数打真实接口,断言 HTTP 成功,并确认响应含有 schema 的 required 字段。

node agent-tools.mjs test

失败常见原因:路径改了、字段改名、或白名单写了线上不存在的键。它验证的是「规范相对实现」,不替代业务单测。JSONPlaceholder 的 /posts 无分页,不带 userId 会返回很长的数组,所以示例里始终带上该参数。真实服务应在 OpenAPI 里声明分页,而不是在执行层偷偷 slice。

放到 CI 即可。预发把 servers.url 换成预发基址。不要用模型采样做契约测试——失败必须确定、可复现。规范、生成结果、契约测试应同一提交,避免再出现「实现已改、工具说明书还在上周」。

总结

不要为智能体再维护一套工具说明书。从 OpenAPI 生成 name / description / parameters,在执行层处理鉴权、超时、类型窄转换和字段白名单,再用契约测试盯住线上字段。模型只负责选工具、填参数;路径、密钥、超时和裁剪都不进上下文。

现有后端要接进智能体,优先补齐 operationId 和窄的响应 schema,而不是先手写一份平行的 JSON Schema。规范是唯一契约,工具层是它的只读投影。接口变更走同一份 OpenAPI 时,模型侧才不会默默过期。