拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从OpenAPI到DeepSeek Function Calling:REST API工具自动化生成实战

从OpenAPI到DeepSeek Function Calling:REST API工具自动化生成实战

50 个 REST API,全部手动转成 DeepSeek 能调的 Tools?我一开始也这么想,结果写到第 23 个就放弃了。这跟加班没关系,纯粹是这套流程的重复劳动强度太高:每个接口都要写 name、description、parameters 的 JSON Schema,参数类型抄错一个,模型选工具的时候就会翻车,接口一更新还得整套跟着改,脑子根本记不住哪个定义和哪个接口对应。

后来我换了一个思路:正经的 REST 服务基本都会暴露 OpenAPI 文档,里面已经把每个接口的路径、参数、类型、说明写得清清楚楚。这本身就是现成的工具定义素材。只要把 OpenAPI 文档解析成 DeepSeek Function Calling 要的 tools 数组,再写一个轻量执行器去发真实 HTTP 请求,50 个接口半小时就能搞定。这篇文章就是把我的这套流程整体复盘一遍,从映射思路、Python 生成器代码,到实际踩过的坑,全部记录在这里。适合正在做 DeepSeek / OpenAI 兼容 Function Calling 接入、或者想把一批 REST API 快速变成 Agent 工具的开发者参考。

1. REST API 工具手写的三个坑,以及 OpenAPI 怎么救场

1.1 手写 Tools 的三大痛点

先说工作量。50 个 REST API,如果全手写,每个工具平均至少 20 行 JSON,包含函数名、描述、参数对象、必填字段、枚举值、嵌套结构。这意味着你要凭空写出上千行样板代码,而且大部分内容是从接口文档复制过来的。复制粘贴就罢了,更难受的是格式没法统一,一会儿参数叫 user_id,一会儿叫 userId,描述也是想到什么写什么,模型根本没法稳定理解。

维护成本就更不用说了。后端接口一旦升级,比如某个参数从必填改成选填、响应里多了一个字段、接口路径变了,手写的工具定义不会跟着变。你只能靠人肉去 grep 代码,找出之前写过的 name,再一个个改。关键是改了还不一定生效,因为模型选工具依赖 description 里的语义信息,改漏一个描述,线上就会莫名其妙选错工具。

第三个问题是一致性。同一个团队里不同开发写的描述风格可能完全不同。一个人写 "根据用户ID查询用户信息",另一个人写 "Get user profile by user_id",模型在语义匹配上就会出现偏差。尤其在 50 个工具的规模下,工具之间本身就有很多相似语义,如果不统一描述策略,幻觉调用和误调用概率会直线上升。

1.2 OpenAPI 就是现成的接口说明书

OpenAPI,以前叫 Swagger,是一种描述 REST API 的标准格式。它把接口路径、HTTP 方法、请求参数、请求体、响应结构、认证方式全部结构化地写在一个 JSON 或 YAML 文件里。我后来仔细对比了一下,发现 OpenAPI 里的信息跟 DeepSeek tools 定义高度重合,几乎是天然的映射源。

这里先放一张对应关系表,方便后面理解:

OpenAPI 元素DeepSeek Tool 对应项说明
paths 下的每个路径+方法一个 tool一个 REST 操作对应一个可调用函数
operationIdtool 的 name作为唯一的函数名,没有就自动生成
summary + descriptiontool 的 description让模型理解何时调用这个工具
operation.parametersparameters JSON Schema包含 query、path、header、cookie 参数
requestBodyparameters 里的请求体字段需要解析 content 里的 schema
components.schemas嵌套类型定义通过 $ref 引用,必须递归解析
securitySchemes执行器认证不放进工具定义,由执行器统一处理

把这一层想明白之后,你会发现 OpenAPI 不仅是给 Swagger UI 用的文档,更是一份机器可读的接口能力清单。有了它,工具的自动生成就不是玄学,而是一个确定性的解析和转换过程。

1.3 不是所有 API 都适合这套玩法

当然也得泼一盆冷水。OpenAPI 自动生成的前提是 API 有规范且能拿到 OpenAPI 文档。如果内部系统只有一份过期的 Word 文档,或者接口用了非标准的自定义协议,那自动生成的产出会非常难看。还有一些老接口,虽然按 REST 风格写,但没有完整的 OpenAPI 定义,只在网关层面有一份残缺的 Swagger,这种就建议先把文档补齐,再考虑接入工具自动生成。

我个人的判断标准很简单:能不能从某个 URL 或配置中心稳定拉取到完整 OpenAPI 文档。能,就走自动生成;不能,就老实手写,或者让接口负责人把文档补全。50 个 REST API 的大项目里,通常总有那么几个“特殊分子”,别让它们拖慢整条流水线。

2. 核心设计:从 OpenAPI 到 DeepSeek Tools 的映射

2.1 DeepSeek Function Calling 的输入格式

DeepSeek 的接口走的是 OpenAI 兼容协议,所以 tools 的结构和 OpenAI Function Calling 保持一致。一个工具的基本单位是 function,里面有三个核心字段:name、description、parameters。其中 parameters 是 JSON Schema 格式,描述这个函数接受什么参数。

下面是一个标准工具定义示例:

{ "type": "function", "function": { "name": "get_user_by_id", "description": "根据用户ID查询用户基本信息,适用于用户详情页、账号查询等场景。", "parameters": { "type": "object", "properties": { "user_id": { "type": "string", "description": "用户唯一ID,格式为UUID" } }, "required": ["user_id"] } } }

在调用 DeepSeek 的 chat completions 接口时,把这样的 tools 数组放到请求体里。模型如果认为某个工具能解决用户问题,就会在返回内容中带上 tool_calls 字段,里面包含工具名称和一段 JSON 字符串形式的参数。接下来需要你自行解析这段 JSON,去执行真实 API 调用,再通过 tool role 的消息把结果返回给模型。

所以这里有一个关键点:自动生成 tools 只是第一层,执行器才是真正让工具生效的引擎。

2.2 从 OpenAPI operation 到 tool 的映射规则

我把 50 个接口的生成过程拆成几条硬性规则,逐条说清楚。

第一,tool name 怎么定。优先用 operationId。operationId 在 OpenAPI 里本来就是开发者为每个操作起的函数名,语义和唯一性都有保证。但实际文档里经常出现 operationId 缺失、重复、带点号或空格的情况。缺失时我用 method + path 生成,路径里的/和{id}全部清洗成下划线。比如GET /users/{id}可以生成get_users__id。如果遇到重复,就在后面补一个自增序号。只保留字母、数字、下划线、中划线,且总长度尽量控制在 64 个字符以内。

第二,description 怎么写。OpenAPI 里的 summary 和 description 往往比较短,直接塞给模型效果一般。我的做法是组合summary + description,再自动追加一句“该工具会请求 {METHOD} {path},用于 {根据summary提炼的场景}”。这样模型在选择时就有更多上下文。

第三,参数怎么映射。operation.parameters 是一个数组,包含 query、path、header、cookie 四种位置。所有这些参数最终都要合并进一个 JSON Schema 对象,但在执行器里必须记清楚每个参数来自哪个位置,否则路径参数会被错误地塞到 query 里。而 requestBody 我倾向于把它的 schema 直接合并到顶层 properties,而不是包一层 body,因为模型直接生成字段名会比生成嵌套 body 更准确。

第四,$ref 必递归。OpenAPI 里大量使用#/components/schemas/xxx引用,生成工具时必须递归解析这些引用,把被引用的结构展开成真正的 schema。

2.3 精简 JSON Schema 还是原样传递

有一个问题我纠结了很久:要不要把 OpenAPI 里的 schema 原封不动地塞给 DeepSeek?答案是不建议。OpenAPI schema 里经常包含 example、xml、externalDocs、discriminator 这些对模型判断没帮助的字段,它们只会白白增加 tokens。

我最终保留的字段是:type、properties、required、items、enum、description、default、anyOf、oneOf、allOf。其余全部过滤掉。这个精简步骤在最开始经常被忽略,直到有次我数了一下一个复杂接口的原始 schema,光 description 和 example 就占了 2000 多 token,50 个接口合起来非常夸张。精简之后,接口定义体积能压缩一半以上,模型选工具的准确率反而上升了。

2.4 执行器的路由结构

生成 tools 数组只是给模型看的菜单,真正的点菜还得靠执行器。我给执行器定义了一个内部路由表,key 是 tool name,value 是一个包含 method、path、参数位置信息的结构:

{ "get_user_by_id": { "method": "GET", "path": "/users/{user_id}", "path_params": ["user_id"], "query_params": [], "header_params": [], "body_params": [], "content_type": "application/json" } }

当模型返回 tool_calls 时,执行器拿到 arguments 这个 JSON 对象,按路由表把 path_params 替换到 URL 模板里,query_params 加到 requests 的 params,header_params 加到 headers,body_params 放到 json 或 data。认证信息一律从环境变量里取,在发送真实请求前动态注入 Authorization 头,绝不写进工具定义。这个拆分的核心原因是:tools 是给模型看的静态元数据,而执行器是给真实调用用的动态逻辑,两者必须解耦。

3. 实操:一个可复用的 Python 自动生成器

3.1 拉取 OpenAPI 文档

我通常会从服务的openapi.json端点拉取文档,比如 FastAPI 自带/openapi.json,很多 Spring Boot 服务配合 springdoc 也有类似路径。也可以用本地文件,这样 CI 环境里不依赖服务是否在线。

import json import requests def load_openapi(url_or_file: str) -> dict: if url_or_file.startswith("http"): resp = requests.get(url_or_file, timeout=10) resp.raise_for_status() return resp.json() with open(url_or_file, "r", encoding="utf-8") as f: return json.load(f)

这里有个小技巧:如果目标 OpenAPI 文档很大,且内网 DNS 解析有延迟,可以先在本地把 JSON 缓存下来,生成器跑离线模式。这样每次改生成逻辑,不需要反复请求线上文档,速度更快。

3.2 递归解析 $ref 和精简 schema

下面是我实际在用的两个核心函数。第一个负责解析 $ref,第二个负责过滤掉不影响模型判断的冗余字段。注意处理循环引用时要加深度限制,不然碰上递归嵌套的数据结构会直接爆栈。

def resolve_ref(node, root, max_depth=10, depth=0): if depth > max_depth: raise RuntimeError("ref depth exceeded") if isinstance(node, dict): if "$ref" in node: ref_path = node["$ref"].lstrip("#/").split("/") cur = root for part in ref_path: cur = cur[part] return resolve_ref(cur, root, max_depth, depth + 1) return {k: resolve_ref(v, root, max_depth, depth) for k, v in node.items()} if isinstance(node, list): return [resolve_ref(i, root, max_depth, depth) for i in node] return node def sanitize_schema(schema: dict) -> dict: allowed = {"type", "properties", "required", "items", "enum", "description", "default", "anyOf", "oneOf", "allOf"} result = {} for k, v in schema.items(): if k not in allowed: continue if isinstance(v, dict): result[k] = sanitize_schema(v) elif isinstance(v, list): result[k] = [sanitize_schema(i) if isinstance(i, dict) else i for i in v] else: result[k] = v return result

如果项目里 OpenAPI 文档特别复杂,或者你不想自己维护解析器,直接用 prance 或 openapi-spec-validator 也是靠谱的。但自写这套的好处是能完全控制输出结构,也更容易加调试日志。

3.3 把 operation 转换成 tool 定义

核心转换函数我放在下面。这里把 query、path、header 参数统一放进一个 properties 对象,同时把 requestBody 的 schema 合并到顶层。

def operation_to_tool(method: str, path: str, op: dict, root: dict) -> dict: op = resolve_ref(op, root) name = op.get("operationId", "") if not name: name = method.upper() + "_" + path.replace("/", "_").replace("{", "").replace("}", "") name = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)[:63] desc_parts = [op.get("summary", ""), op.get("description", "")] desc = " ".join(x.strip() for x in desc_parts if x) desc += f" 该工具会请求 {method.upper()} {path}。" properties = {} required = [] for p in op.get("parameters", []): p = resolve_ref(p, root) pname = p.get("name") if not pname: continue p_schema = resolve_ref(p.get("schema", {"type": "string"}), root) prop = {"description": p.get("description", "")} prop.update(sanitize_schema(p_schema)) properties[pname] = prop if p.get("required"): required.append(pname) rb = op.get("requestBody") if rb: rb = resolve_ref(rb, root) content = rb.get("content", {}) json_schema = content.get("application/json", {}).get("schema") if json_schema: body_schema = resolve_ref(json_schema, root) body_props = sanitize_schema(body_schema).get("properties", {}) properties.update(body_props) body_required = sanitize_schema(body_schema).get("required", []) required.extend(body_required) if rb.get("required") and not required: # 请求体必填时,至少有一个表示“是否传入body”的标记 required.append("request_body_present") # 去重并保持顺序 seen = set() unique_required = [] for r in required: if r not in seen: seen.add(r) unique_required.append(r) return { "type": "function", "function": { "name": name, "description": desc, "parameters": { "type": "object", "properties": properties, "required": list(dict.fromkeys([r for r in unique_required if r in properties])) } } }

这段代码里有一个细节:当 required 字段中的名字没有出现在 properties 时,直接忽略,避免模型生成一个没有对应字段定义的参数,造成幻觉。这个判断是我调了很久才加上的。

3.4 批量处理多个服务

如果 50 个接口分散在好几个服务,我会为每个服务加一个 prefix,再合并所有 tools:

def generate_tools_from_openapi(openapi_dict: dict, prefix: str = "") -> list: tools = [] for path, path_item in openapi_dict.get("paths", {}).items(): for method, op in path_item.items(): if method.lower() not in ("get", "post", "put", "delete", "patch"): continue tool = operation_to_tool(method, path, op, openapi_dict) if prefix: tool["function"]["name"] = f"{prefix}_{tool['function']['name']}" tools.append(tool) return tools all_tools = [] for service_name, doc_url in [("user", "services/user/openapi.json"), ("order", "services/order/openapi.json")]: doc = load_openapi(doc_url) all_tools.extend(generate_tools_from_openapi(doc, prefix=service_name)) print(f"generated {len(all_tools)} tools")

这样即使两个服务里都有同名接口,也不会冲突,因为工具名带上了user_、order_这样的前缀。清洗函数里已经考虑到了只允许字母数字下划线,所以 prefix 加下划线是安全的。

3.5 调用 DeepSeek 并执行工具

生成完 tools,要真正跑通对话闭环。我用 requests 直接调 DeepSeek 接口,避免额外依赖 SDK。下面是一个最小可运行版本:

import json import requests DEEPSEEK_API_KEY = "your_api_key" def chat_once(messages, tools, model="deepseek-chat"): payload = { "model": model, "messages": messages, "tools": tools, } resp = requests.post( "https://api.deepseek.com/chat/completions", headers={"Authorization": f"Bearer {DEEPSEEK_API_KEY}"}, json=payload, timeout=60, ) resp.raise_for_status() return resp.json()["choices"][0]["message"] def run_conversation(user_input, tools, executor): messages = [{"role": "user", "content": user_input}] for _ in range(3): # 最多允许3次工具调用 msg = chat_once(messages, tools) messages.append(msg) if not msg.get("tool_calls"): return msg.get("content", "") for tool_call in msg["tool_calls"]: fn = tool_call["function"] args = json.loads(fn["arguments"] or "{}") result = executor.execute(fn["name"], args) messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": json.dumps(result, ensure_ascii=False), }) return messages[-1].get("content", "")

这里的关键坑是tool_call_id必须原样返回,如果拼错或者漏了,DeepSeek 会报错。另外,工具执行异常时,把错误信息通过 tool role 返回给模型,比直接抛异常在用户侧更友好。比如某个接口返回 404,我应该把{"error": "user not found"}作为返回内容回传,模型就能理解查询对象不存在,并组织成一句人话回复用户。

3.6 生成完一定要做校验

50 个工具,靠肉眼检查不现实。我写了一个 validate 函数,在生成后立刻检查是否有重复名字、空描述、缺 properties:

def validate_tools(tools): seen = set() for t in tools: name = t["function"]["name"] if name in seen: raise ValueError(f"duplicate tool name: {name}") seen.add(name) if not t["function"].get("description"): raise ValueError(f"empty description for {name}") params = t["function"].get("parameters", {}) if "properties" not in params: raise ValueError(f"missing properties for {name}") print(f"validated {len(tools)} tools")

这个脚本我直接接到了 CI 流程里,OpenAPI 文档一变,自动生成新快照,校验不通过就阻断合并。

4. 常见问题与排查技巧实录

4.1 $ref 解析不彻底,生成了一堆空壳

最开始我的生成器只解析了一层 $ref,结果很多工具的参数是{"$ref": "#/components/schemas/UserFilter"},模型根本不知道要传什么字段,执行器也拿不到参数。排查方法很直接:生成后随机挑几个工具,看 parameters 里还有没有$ref关键字。如果有,说明递归解析没有生效。

解决方法是把 resolve_ref 做成递归函数,并且对 components.schemas 里的嵌套引用也要处理。需要注意的是,某些 OpenAPI 文档存在循环引用,比如 User 里面嵌了一个 department,department 又引用了 User。这种结构不设深度上限会直接 RecursionError。我加了一个 max_depth 参数,超过 10 层就报错,让解析器停下来而不是卡死。

4.2 operationId 缺失、重复、带特殊字符

老系统里没有 operationId 是常态,甚至有一些 operationId 包含中划线、点号,这种名字传给模型可能会被过滤或者匹配失败。我的清洗函数很粗暴:只保留字母数字下划线和中划线,超过 63 字符直接截断。

重复问题是更大的坑,两个接口操作可能都叫list,合并时前缀也镇不住同一个服务内的重复。我的处理是:在批量生成时维护一个全局计数器,如果名字被占用,就在后面拼上_2、_3。但这种方式生成的名字不稳定,最好还是要求后端把 operationId 补齐,属于规范问题。

4.3 description 太短,模型老是选错工具

这是我认为影响最大的一个环节。OpenAPI 文档里的 summary 常常只是“查询列表”“删除用户”这种极短描述,模型面对大量相似工具时,很容易把“查询用户列表”描述成“查询订单列表”。

我的解决方法是生成后做自动增强,在 description 里拼接请求方法和路径,并追加预期场景。比如:

根据用户ID查询用户基本信息,适用于用户详情页、账号查询等场景。该工具会请求 GET /users/{user_id}。

这样一个补充描述,比直接拿 summary 塞给模型要稳得多。我在项目里跑过一组评估集,加了场景描述的版本工具选择准确率提升了 15 个点左右。这是投入产出比最高的一步。

4.4 请求体是 multipart/form-data,按 JSON 处理就挂了

自动生成通常会优先取 application/json 的 schema,但如果某个上传接口只有 multipart/form-data,那执行器就不能简单地把 arguments 塞给 json 参数。我最后的约定是:文件类型的字段在参数里传本地路径或已上传文件的 URL,执行器检测到 content-type 是 multipart/form-data 后,用 multipart 编码去发送,文件字段用 open(path, "rb") 读取,其他字段按普通表单字段发送。

这个方法虽然绕了一圈,但能把文件上传接口也纳入自动生成体系里,否则这类接口永远只能手写专用代码。

4.5 50 个工具全部塞给模型,token 开销大选择率还低

把 50 个工具的完整定义一起传给 DeepSeek,并不是不行,但请求体积会变得很大,而且模型在大量工具里找正确目标,注意力会被稀释,选错概率显著上升。我后来的做法是给工具分组,按服务或按 tag。每个分组单独生成一个 tools_snapshot,对话开始时,先用一个轻量的“路由模型”或者关键词规则判断当前问题属于哪个服务,再只加载那组工具。

还可以用 embedding 做粗匹配:把用户 query 和每个工具的 description 做向量相似度排序,只把 Top N 个工具传进去。这个方案我不在这里展开,但效果是真的明显,50 个工具的场景下,每次减少到 8 个,延迟和准确率都有改善。

4.6 认证信息被忽略,执行请求一直 401

OpenAPI 文档里的 securitySchemes 通常只声明了类型,不会给真实密钥。很多人刚接入时会忘了执行器要自己加 Authorization 头。我踩过的坑是:模型的 tools 都生成了,执行器也调用了,但一直收到 401,查了半天才发现执行器里根本没写认证逻辑。

解决方式很固定:生成器只负责产出工具定义,执行器从环境变量统一读取 token。这样既不会把密钥泄露进大模型的上下文中,也让更换密钥时不需要重新生成工具快照。

5. 再进一步:工具生成的工程化与自动更新

5.1 把生成器变成构建任务

这套流程真正跑起来之后,我把生成器固化成 CI 里的一个步骤。后端服务更新 OpenAPI 文档后,CI 自动拉取文档,生成 tools_snapshot.json,执行 validate_tools,通过后推送到共享制品库。Agent 服务启动时只需要加载这个 JSON,不需要自己去解析 OpenAPI。这样做的好处是,Agent 服务不依赖后端服务在线,启动速度也更快。

5.2 按业务域拆分组,让模型按需加载

前面提到了分组,这里说得更具体一点。如果 50 个接口里,用户服务占 15 个,订单服务占 20 个,支付服务占 15 个,我就生成三份 JSON:user_tools.json、order_tools.json、pay_tools.json。然后在系统提示词里写清楚:“当用户问题涉及订单、物流、售后时,你主要参考 order_tools;涉及用户资料时,参考 user_tools。”实测下来,模型会优先从对应分组里找工具,选工具准确率会明显提升。

这个分组甚至可以用 OpenAPI 里的 tags 自动完成。大部分后端团队都会在接口上打user、order这样的 tag,直接用 tag 作为分组键非常省事。

5.3 用回归测试保住下限

最后再分享一个经验:工具生成器一旦跑起来,很容易越改越复杂。这时候如果没有回归测试,改一个解析逻辑可能悄悄弄坏十几个工具。

我给每个服务维护了一组“黄金测试用例”,每个用例包含一个用户 query、一个预期 tool name、一组预期参数。比如:

{ "query": "查一下用户 123 的信息", "expected_tool": "user_get_user_by_id", "expected_args": {"user_id": "123"} }

每次生成逻辑改动,就跑一遍这些用例,确认模型选工具结果没有变化。不需要很多,每个服务 5-10 条就够,但能在后期省掉大把线上排障时间。

5.4 别把自动生成当作万能药

说了这么多,还是得提醒一句:自动生成能把 80% 的 REST API 变成可用工具,但剩下那 20% 仍然需要人工介入。比如某些接口需要复杂的上下文拼接,或者返回结果需要二次加工,又或者同一个路径下多个方法语义模糊,这些工具的定义可能还需要你手动微调 description,甚至改参数结构。

我自己在实际操作中的体会是:不要追求 100% 自动,而是让自动生成接管重复劳动,留出精力去处理真正需要业务判断的部分。OpenAPI 生成 DeepSeek 工具这条路,走通一次之后,后面再新增接口就真的是“改文档,跑脚本,完事”了。

返回列表