内部 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 时,模型侧才不会默默过期。
评论