☰
OpenAPI自动生成DeepSeek工具:50个REST接口批量接入实践
2026/10/3 5:34:44 网站建设 项目流程

最近我把一套内部系统的 REST API 全部接入到了 DeepSeek 的 Tools 机制里,一共 50 个接口,没有一个工具定义是手写的。整个过程跑下来,最大的感受就是:只要你的系统有一份靠谱的 OpenAPI 文档,生成工具这件事完全可以自动化,而且比手写更快、更不容易出错。

这个思路适用面很广:不管你是要给内部管理系统加一个自然语言助手,还是想把第三方 SaaS 的开放接口直接暴露给大模型去调度,只要底层是 REST API,上层是支持 function calling 的大模型(OpenAI 兼容协议的都行,DeepSeek 就在其中),就可以用同一套逻辑批量生成工具定义。这篇文章就记录一下我的完整实操过程:怎么解析 OpenAPI、怎么生成 DeepSeek 需要的 Tools 结构、怎么把模型返回的 tool_calls 映射回真实 HTTP 请求,以及 50 个工具批量落地过程中踩过的坑。

如果你是做 LLM 应用开发的,或者正准备给现有业务系统接一个“能自己查数据、改数据”的智能助手,这篇文章会很有参考价值。

1. 为什么我不想手写 50 个 Tools

1.1 手写 Tools 到底烦在哪

先说清楚一个背景:在 DeepSeek 这类模型 API 里,“Tools”指的不是某个插件,而是你在接口请求中传给模型的一段 JSON 数组,里面描述清楚每个函数叫什么、有什么用、参数是什么结构。模型在回答用户问题时,会判断需不需要调用这些工具,如果需要,就返回一个 tool_calls,里面带着函数名和参数。真正的 HTTP 请求还是得由你这段业务代码去执行。

听起来很简单对吧?但当你面对的是 50 个真实业务接口时,事情就变味了。假设一个订单接口:

{ "type": "function", "function": { "name": "get_order_detail", "description": "根据订单ID查询订单详情,包括商品明细、金额、状态、收货信息", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单ID,格式如 ORD20250101001" } }, "required": ["order_id"] } } }

这一个工具定义看着不难,但 50 个接口里还包括分页列表、状态筛选、创建/更新数据的各种请求体,每个接口都要手动把参数从 REST 风格转换成 JSON Schema。我试过,写到第 15 个左右就开始头晕了:有的接口参数在 path 里、有的在 query 里、有的在 request body 里;有的字段是嵌套对象,有的字段是可空类型,还有枚举值。手工抄一遍不仅累,还特别容易抄错,比如把 required 字段漏了,或者把 type 写成 string 实际接口要的是 int。

更要命的是维护成本。业务 API 是会变的:加字段、删参数、调整路由。手写的工具定义和 OpenAPI 文档之间没有任何强关联,文档一更新,你的工具定义就过期了,而模型拿着过期的 schema 去生成参数,轻则调用失败,重则产生幻觉参数。与其维护 50 份手写 JSON,不如写一个能把 OpenAPI 文档自动翻译成 Tools 的生成器。

1.2 OpenAPI 本来就是现成的“契约”

绝大多数正规团队对外提供的接口,都会维护一份 OpenAPI 文档,不管是 2.0(Swagger)还是 3.0/3.1 版本。所谓 OpenAPI 文档,本质就是一个 JSON 或 YAML 文件,机器可读地描述了所有路径、方法、参数、请求体、响应结构、认证方式。

一提到 OpenAPI 文档,很多人第一反应是配合 Swagger UI 在线调试接口,或者用来生成客户端 SDK。但在我这个场景里,它最值钱的地方在于:

  • 路径、方法、operationId 是现成的:工具名字可以直接从 operationId 来,不需要自己想。
  • 参数定义是现成的:path、query、header、requestBody 四种来源的字段名、类型、必需性都有了。
  • 接口描述是现成的:大多数团队的 OpenAPI 文档会给每个 operation 写 summary 和 description,这些内容正好可以挪作工具描述。
  • 字段类型是现成的:JSON Schema 本身就是大模型 tools 参数部分需要的格式,基本可以无缝搬运。

也就是说,手写 Tools 时最费精力的四件事,OpenAPI 文档里其实都已经有人帮你做完了。你要做的只是把它“翻译”成模型 API 的工具格式,再补一个不依赖具体业务的通用执行器。

1.3 自动生成方案的收益与边界

用 OpenAPI 自动生成工具,最直接的收益就是快。我的机器上跑一次脚本,50 个工具从解析到生成 JSON 文件,一秒钟左右搞定。后面 API 一更新,重新跑一遍脚本就行,不需要在代码仓库里人肉改 50 处。

它的边界我也先说明白,免得你带着不切实际的预期去做:自动生成能解决大约 90% 的重复劳动,但剩下 10% 还是要人工参与,比如给特殊接口补一段详细的操作注意事项,或者对某些字段的用途做语义说明。因为 OpenAPI 的 description 经常写得比较简短,模型选工具时要想准确命中,描述的自然语言质量很重要。这部分通常需要你在生成结果上做一轮微调,但微调 50 个描述的工作量,和从头手写 50 个完整工具定义,完全不是一个量级。

2. 开工前先摸清家底:OpenAPI 文档与协议选型

2.1 给 OpenAPI 文档做个体检

拿到 OpenAPI 文档后,我建议不要急着写解析脚本,先做一次三分钟的人工体检。因为解析脚本最怕的不是复杂,而是“不符合预期”。

第一个要确认的是版本。OpenAPI 2.0 和 3.0 最大的区别在于请求体的表达方式:2.0 的 body 参数是 parameters 里一个in: body的特殊参数,3.0 则把它独立成requestBody。这两种结构在解析时要走完全不同的分支。我这次用的是 3.0,所以后面的示例代码也是基于 3.0 写的。

第二个要确认的是$ref的使用情况。稍微规范一点的团队都会把公共数据类型放在components/schemas下,然后通过$ref引用。解析时必须把$ref展开成真正的 schema,否则生成的工具参数里到处都是{"$ref": "#/components/schemas/Order"},模型根本没法用。

第三个要确认的是认证方式。大部分内部系统的 OpenAPI 文档里会写 security 定义,比如 API Key、Bearer Token 或者自定义头。这部分不会进入模型工具的 JSON Schema,但会影响执行器里的 header 设置。建议先看文档,确定执行时到底是统一挂在网关上的全局鉴权,还是每个接口各自不同。

提示:如果文档里没有 operationId,或者重复严重,生成工具名时会比较麻烦。建议在体检阶段就把这个问题暴露出来,先统一补一遍 operationId,这是成本最低的时候。

2.2 解析库选型与对比

我调研下来,常见选择有这么几类,直接列成表格看比较清楚:

方案语言优点缺点适用场景
prancePython能直接解析 OpenAPI JSON/YAML,可展开本地$ref展开内部嵌套 ref 偶尔不彻底后端逻辑是 Python 时首选
openapi-spec-validatorPython适合做文档合规性校验不负责加载和引用解析配合 prance 使用做前置校验
@apidevtools/swagger-parserTypeScript/Node资料多,社区活跃,$ref展开能力强Node 环境依赖安装稍重前端团队维护生成脚本时好用
openapi-typescriptTypeScript生成 TypeScript 类型而非工具定义产出形态需要二次加工已有配套类型系统时用

我最后选了 Python 的 prance,理由很简单:我后续要用 requests 写执行器,Python 能一条链路走完,中间不需要跨语言。如果你的团队是 Node 技术栈,用 swagger-parser 也是完全可以的,核心思路一样的。

依赖装好之后,加载一个文档就这么写:

from prance import ResolvingParser parser = ResolvingParser("openapi.yaml", strict=False) spec = parser.specification

strict=False的意思是:文档即使有些小瑕疵也尽量容错解析,而不是直接抛异常中止。实际业务文档里总能碰到一些不太规范的写法,先让它跑起来,后续在生成结果里人工把关。

2.3 DeepSeek 工具协议的关键限制

DeepSeek API 的工具调用协议兼容主流大模型通用的格式,即在请求参数里放一个tools数组:

[ { "type": "function", "function": { "name": "get_order_detail", "description": "根据订单ID查询订单详情", "parameters": { "type": "object", "properties": { "order_id": { "type": "string" } }, "required": ["order_id"] } } } ]

这里有几个细节我觉得值得提醒:

  • 函数名只允许字母、数字、下划线和中划线,通常是^[a-zA-Z0-9_-]{1,64}$。OpenAPI 的 operationId 一般满足这个规则,但有些团队会把 operationId 写成驼峰的純字母形式,也没问题。坚决不能用点号和空格。
  • description 字段会在每次请求时被切进 prompt,50 个工具的 description 全都写得特别长,token 消耗会非常可观。后面我会讲怎么控制。
  • parameters 必须是一个 JSON Schema object 类型,不能是数组或者裸字符串。

3. 从 OpenAPI 到 DeepSeek Tools 的完整实现

下面是整个方案的主干代码逻辑。我拆成五步来讲,每一步都能独立理解。

3.1 加载并展开 OpenAPI 文档

先写一个最基础的遍历入口,把文档里所有路径、方法都扫出来:

METHODS = ["get", "post", "put", "delete", "patch"] for path, path_item in spec.get("paths", {}).items(): for method in METHODS: operation = path_item.get(method) if not operation: continue # 接下来处理 operation

这里解释一下为什么这样写:OpenAPI 的 paths 下面每个路径是一个对象,里面按 HTTP method 区分不同的操作。比如/api/orders/{order_id}下面可能有 get 和 delete 两个 operation,它们对应两个不同的工具。按 method 维度遍历,天然就和 REST 语义对上号。

$ref展开的细节也不能省。prance 在加载时会把顶层的引用解析掉,但遇到嵌套在 schema 里的引用,保险起见我会再加一层手动解析:

def deref(schema): if "$ref" in schema: parts = schema["$ref"].lstrip("#/").split("/") node = spec for p in parts: node = node[p] return deref(node) if isinstance(schema, dict) and "properties" in schema: for key, value in schema["properties"].items(): schema["properties"][key] = deref(value) return schema

这是简化写法,应对内部系统足够用了。如果你要处理的是跨文件引用或者极端复杂的嵌套,直接用 jsonref 库会更省心。

3.2 把每个 operation 翻译成函数定义

在遍历到每个 operation 之后,需要确定三件事:函数名、函数描述、参数结构。

函数名优先取operationId,没有就用“请求方法 + 路径”拼一个稳定的名字:

op_id = operation.get("operationId") if not op_id: op_id = f"{method}_{path.replace('/', '_').replace('{', '').replace('}', '')}"

描述部分优先取description,其次是summary,都没有的话就把完整的请求路径拼进去,至少让模型知道这个工具对应哪个端点:

description = ( operation.get("description") or operation.get("summary") or f"调用 {method.upper()} {path} 对应的接口" )

我在实际项目中会把路径信息追加到描述末尾,而不是只放在参数里。原因很直接:很多工具在功能上高度相似,比如“查询订单列表”“查询已完成订单列表”“查询异常订单列表”,如果不写清楚具体端点,模型很容易混淆。

3.3 生成 tools 数组与参数 Schema

接下来是把 REST 参数映射成 JSON Schema。OpenAPI 3.0 里参数有几种来源:path、query、header、以及独立的 requestBody。我采用的策略是:path 和 query 统一塞进一个名为params的对象参数里,requestBody 单独作为body参数。

properties = {} required = [] # 处理 path + query 参数 for p in operation.get("parameters", []): p = deref(p) if p.get("in") not in ("path", "query"): continue name = p["name"] schema = p.get("schema", {"type": "string"}) schema.setdefault("description", p.get("description", "")) properties[name] = schema if p.get("required"): required.append(name) params_schema = { "type": "object", "properties": properties, "required": required, "description": "接口路径参数或查询参数,按字段名传入" } # 处理 requestBody body_schema = None request_body = operation.get("requestBody") if request_body: request_body = deref(request_body) media = request_body.get("content", {}).get("application/json", {}) body_schema = media.get("schema")

注意deref时要同时处理字段 description 缺失的情况。OpenAPI 的参数可能没有字段级描述,但模型需要靠这些信息生成参数值,所以我给空描述补了一个兜底空字符串,防止生成出 None。

有了params_schema和body_schema,就可以拼装最终的函数定义:

parameters = { "type": "object", "properties": {} } if properties: parameters["properties"]["params"] = params_schema if body_schema: parameters["properties"]["body"] = deref(body_schema) if parameters["properties"]: parameters["required"] = list(parameters["properties"].keys()) tool = { "type": "function", "function": { "name": op_id, "description": description, "parameters": parameters } }

这里的关键思路是:把“一个 REST 请求”映射成“一个工具调用”,而不是把“一个 HTTP method”映射成“一个大型对象”。模型只需要学会填两个字段:params 给 path/query 用,body 给请求体用。执行器拿到这两个字段,再按各自的流向拼装回 HTTP 请求,语义完全无损失。

3.4 写一个不做任何业务判断的通用执行器

工具定义只是给模型看的说明书,真正干活的是下游执行器。这个执行器最重要的设计原则是:不写任何针对具体接口的业务代码。

import json import requests BASE_URL = "https://api.internal.example.com" API_KEY = os.environ["INTERNAL_API_KEY"] def execute_tool(name, arguments, tool_registry): meta = tool_registry[name] method = meta["method"] path_template = meta["path"] args = json.loads(arguments) if isinstance(arguments, str) else arguments params = args.get("params", {}) body = args.get("body", {}) url = BASE_URL + path_template for key, value in params.items(): url = url.replace("{" + key + "}", str(value)) query = {k: v for k, v in params.items() if "{" + k + "}" not in path_template} headers = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json" } resp = requests.request( method, url, params=query, json=body if body else None, headers=headers, timeout=30 ) try: result = resp.json() except Exception: result = resp.text return { "status": resp.status_code, "data": result }

tool_registry就是我在生成 tools 数组时顺带建立的一个映射:函数名 → (请求方法、路径模板、参数映射)。这个映射表是生成过程的副作用,不需要手写。

你看这个执行器,里面没有任何“订单”“用户”之类的业务痕迹,完全是通过函数名到路径模板的映射在做转发。这样即使 OpenAPI 文档新增了 30 个接口,也只需要重新生成注册表,执行器一行不用改。

3.5 接入 DeepSeek 对话循环

执行器准备好之后,就是标准的对话循环:

messages = [{"role": "user", "content": "把订单 ORD20250101001 的发货状态改成已发货"}] resp = client.chat.completions.create( model="deepseek-chat", messages=messages, tools=tools, ) choice = resp.choices[0].message if choice.tool_calls: for call in choice.tool_calls: tool_result = execute_tool(call.function.name, call.function.arguments, tool_registry) messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(tool_result, ensure_ascii=False) }) # 把带工具结果的完整上下文再发给模型,让它给出最终回答 final_resp = client.chat.completions.create( model="deepseek-chat", messages=messages, tools=tools, )

这里有一个细节我一开始没用上,后来加上之后效果提升明显:工具结果不要直接把整个响应塞给模型,而是保持{"status": 200, "data": ...}的结构。这样模型能明确区分“请求本身成没成功”和“业务数据长什么样”,它在最终回复里才能准确判断要不要编造或者道歉。

4. 50 个工具批量生成的细节优化

4.1 工具命名与描述决定模型“会不会用”

50 个工具一起交给模型时,模型的工具选择本质上是在一堆名字和描述里做语义匹配。命名规范的影响比你想的大得多。

我的经验是:不要只依赖 operationId。比如某系统里有一个接口叫getUserInfo,另一个叫getUserDetail,光看名字模型其实分不清差异,选错概率会增加。这时候就应该在自动生成的基础上,人为把 description 强化。贴一段我后来微调过的描述示例:

{ "name": "get_user_info", "description": "查询用户基本信息(姓名、部门、联系方式)。注意:本接口只能查当前登录用户本人信息,如果要查询其他用户请使用 get_user_detail_by_admin。", "parameters": { ... } }

这种“什么时候用 + 什么时候不用 + 与相邻工具的区别”的写法,实测能明显减少模型误调用。

另外,写操作类工具我建议在描述开头固定加上“[写操作]”三个字。原因很简单:模型有时会不自觉地把查询类任务误判成修改类调用,或者反过来。有了前缀,能让它在选择时多一层判断。

4.2 参数折叠与请求拆分策略

前面我把 path/query 参数折叠成params,把 requestBody 单独放成body,这么做的一个直接好处是:当接口只有 path/query 参数、没有 body 时,模型只需要填一个很扁平的 params 对象,幻觉空间小。当接口只有一个复杂嵌套 body 时,模型直接参考原始的 JSON Schema,层级准确。

但也有人更喜欢把所有参数扁平化进一个对象。我试过,对于“只有 3-5 个参数”的简单接口来说,扁平化反而更友好,因为它少了一层嵌套,模型理解成本低。问题出在混合型接口上:既有 path 参数、又有嵌套很深的 body,全部扁平之后字段名会撞车,或者语义混在一起。

我最终的建议是:50 个工具这么大批量,稳定优先。统一params + body结构,执行逻辑简单、可预期;针对那些确实很简单的接口,可以手工把这几个工具改成扁平参数,属于特判优化。批量生成的工具,统一结构比局部舒适更重要。

4.3 一次性生成 50 个工具的自测方法

生成完 50 个工具后,不能直接交给模型就完事,我至少会跑一遍无业务含义的健康检查。脚本逻辑很简单:

  1. 遍历生成的 tools 数组,检查函数名是否符合命名规范。
  2. 对部分工具的参数 Schema 做一次 JSON Schema 基础校验,确保没有$ref残留。
  3. 用“空参数”或“构造的假参数”直接向内部 API 发请求,看网关能不能正常响应,以此确认路径模板拼接正确。

第三点听起来像废话,但实际特别能发现问题。自动生成时路径模板里的{order_id}和参数名对不上的情况很常见,OpenAPI 文档里手滑写成的路径参数名和 parameters 里的 name 不一致,一张张看根本看不出来。让脚本把所有路径模板的占位符和参数名集合做差集比对,一分钟就能扫完。

5. 实测避坑:高频问题与排查记录

5.1 接口 404/405,OpenAPI 和真实路由对不上

我遇到的第一批问题里,接近三分之一来自 OpenAPI 文档里的路径和实际网关路由不一致。比如文档里写的是/api/v1/orders/{order_id},实际服务是挂在/api/v2/orders/{order_id}下的,或者网关有个全局前缀/internal文档里没写。

排查思路很简单:先确认 BASE_URL 拼完后整体 URL 是否可通。用 curl 模拟一次真实请求,再把 OpenAPI 文档里同一个操作对应的路径打印出来对比。我建议在 tool_registry 里把完整 URL 模板打印出来,生成后扫一遍,看看哪些路径模板明显不对劲。

这里还要提一个不太起眼的坑:复数路径。有的接口文档里写/order,实际服务用的是/orders,这种差异 curl 一下立刻暴露,但手写代码时很容易忽略。

5.2 模型总是不选中某个工具

如果某个工具在真实对话里从来不被模型选中,我一般从两个方向排查。

第一个方向是工具描述是否足够有辨识度。我遇到过某接口文档 description 只有一句“查询信息”,summary 是“list”,生成出来的工具描述泛得不行,模型根本不知道它能干什么。这种就属于必须在微调阶段补描述的典型。

第二个方向是参数层数太深。如果某个工具的 body 是一个三层嵌套对象,每个字段又没有 description,模型在第一步就生成不出来,甚至可能直接放弃调用。解决办法是在生成后对过深嵌套的参数结构做一次裁剪,把不重要的子对象收敛成{"type": "object"},或者允许执行器接受模型返回的部分参数然后补默认值。

5.3 工具返回内容太长、解析失败

50 个工具里总有那么几个是“查询列表”类接口,响应可能是几百条记录,全量返回给模型不仅 token 消耗大,还容易撑爆上下文。我第一次实测时就发现,某报表接口的返回值超过 8000 个 token,模型在后续上下文里明显开始错乱。

我的处理方式是给执行器增加一个全局截断规则:统一最多返回给模型前 N 条记录,并在 data 里带一个 truncated 标记,让模型知道结果是被截断过的。这个逻辑都是通用的,不针对具体业务:

MAX_RECORDS = 20 if isinstance(result, dict) and isinstance(result.get("data"), list): if len(result["data"]) > MAX_RECORDS: result["data"] = result["data"][:MAX_RECORDS] result["truncated"] = True

模型看到 truncated 标记后,会自然地在回复里提醒用户“结果较多,仅显示前 20 条,需要我按条件再筛选吗”。

5.4 生成速度慢与 token 成本控制

50 个工具全部放进请求后,有一个绕不开的成本问题:tools 数组每次对话都要被编码进 prompt。粗算一笔账:平均每个工具描述约 60 个 token,参数 Schema 约 150 个 token,50 个工具就是一万多 token。这还没算模型在决策时需要额外注意力来阅读这些定义。

有几个实用手段可以控制:

  • 拆域挂载。不要一次性把 50 个工具全塞进去,而是按业务域拆成几个工具组。用户聊订单时只挂订单相关的 10 个工具,聊工单时只挂工单相关的 12 个工具。判断域可以用一个前置的轻量意图分类,也可以简单粗暴地根据首轮会话关键词做匹配。
  • 精简 Schema。给模型看的 parameters 里可以删掉对被调用不影响的字段,比如 enum 的完整枚举只保留常用的几个并注明“其它值参见接口文档”。
  • 滚动淘汰。某一次请求如果模型连续多轮都没有选中某些工具,下一轮就把它们临时移出 tools 数组,减少模型的选择空间。

这些手段都不复杂,但组合起来能让单次会话的 token 消耗下降 30%-50%。

6. 把生成脚本变成工程资产

6.1 不要只把它当成一次性脚本

生成 50 个工具只是起点。如果你只是把脚本跑一遍、把生成的 JSON 贴进代码里,那和手写一次没有本质区别,只不过省了一次体力活。我后来把生成脚本放进了 CI 流程:每当业务 API 的 OpenAPI 文档有变更,自动重新生成一次 tools 文件,再和上一次的版本做 diff,把新增工具、删除工具、参数变化结果直接发到团队群里让相关人确认。

这样一来,工具定义和接口契约永远保持同步,不会出现“模型还在用上周的参数 schema、实际接口这周已经删了那个字段”的尴尬局面。

如果你暂时不上 CI,也建议在本地保留生成脚本和工具注册表,把它当成接口文档的一个“编译产物”。业务侧改 API 时说一声“你重新跑一下生成”,比你手动改十几行 JSON 要快得多。

6.2 后续还能往哪扩展

这套“OpenAPI → Tools”的路径,不止适用于 DeepSeek。本质上它就是把你已有的 REST API 转换成一个模型可读的标准化描述,任何一个兼容 tool calling 的模型都能复用,区别只在于个别模型对参数描述格式的容忍度不同。

我目前正在试的第二层扩展是把工具描述里的权限规则显式化:在 tools 数组里附加一个内部字段,标记某个工具只能由特定角色调用,执行器在真正发请求前先做一次鉴权。这个字段不影响模型理解,但能避免模型在某些角色下触碰到不该调用的接口。毕竟语言模型自己不知道当前用户是谁,安全边界必须落在执行器这一侧。

最后分享一个小技巧:如果你不想每次都把完整 tools 数组拿出来做销毁性调试,可以在生成脚本里加一个--dump参数,只输出某个指定工具名的 JSON 定义,方便单独验证。我实测下来,这个参数在排查“某个工具为什么总选错”时特别好用,比盯着一整份 50 工具的数组看要轻松得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询