OpenAPI自动生成DeepSeek工具定义:告别手写50个函数

发布时间:2026/10/25 13:50:45
OpenAPI自动生成DeepSeek工具定义:告别手写50个函数

1. 从手写 50 个工具函数说起:这个项目到底在解决什么问题

如果你最近在折腾大模型应用,尤其是想让模型调用外部 REST API 来完成实际任务,那你大概率经历过这样一个阶段:打开编辑器,新建一个tools.py,然后开始一个接口一个接口地手写工具定义。每个工具都要写名称、描述、参数 schema、必填项、类型约束,写完还要跟模型反复调试描述文案,确保它能在正确的场景下选中正确的工具。

50 个接口是什么概念?假设每个工具平均 30 行定义代码,那就是 1500 行纯手工维护的样板代码。更麻烦的是,后端接口一改字段,你得手动同步;参数类型从 string 改成 integer,你得挨个检查;某个接口新增了可选参数,你还得回去补 schema。这种活干一次两次还行,接口数量上到几十个之后,维护成本会迅速失控。

这个项目的核心思路很直接:既然 REST API 本身已经有 OpenAPI(Swagger)规范描述,为什么还要人工再翻译一遍成工具定义?让程序读取 OpenAPI 文档,自动生成符合 DeepSeek 工具调用格式的 Tools 定义,把人力从重复劳动里解放出来。

一句话概括:输入一份 OpenAPI 规范文件,输出一组可以直接喂给 DeepSeek 的工具定义,中间不需要你手写任何 schema。

它适合谁?三类人最受益。第一类是做 AI Agent 应用的开发者,手里有一堆内部或第三方 REST 接口需要接入模型;第二类是做企业级集成的工程师,公司内部服务都带 Swagger 文档,想快速搭一个能调这些服务的智能助手;第三类是想学习工具调用机制的技术爱好者,通过自动生成的过程反过来理解工具定义的结构和约束。

我自己的场景是:手头有一个包含 50 多个接口的后端服务,全部有完整的 OpenAPI 3.0 文档,之前手工写了 20 多个工具就已经快崩溃了,字段对不上、描述写不准、模型选错工具的问题层出不穷。后来把这套自动生成流程跑通,维护成本直接降了一个数量级。下面把这套东西的完整思路和实现细节拆开讲。

2. 整体设计思路:为什么是 OpenAPI 到 Tools 的自动映射

2.1 手写工具定义的三个致命痛点

在讲方案之前,先把问题说透,这样你才能理解后面每个设计决策的合理性。

痛点一:schema 与接口实际定义脱节。手写工具定义时,参数类型、枚举值、必填项这些信息全靠人肉从接口文档里抄。接口文档更新了,工具定义不会自动跟着变。我遇到过最离谱的一次是后端把某个参数从必填改成了可选,但工具定义里还标着 required,导致模型每次调用都硬塞一个空值进去,接口直接报参数校验失败。

痛点二:描述文案质量参差不齐。工具能不能被模型正确选中,很大程度上取决于 description 写得好不好。50 个工具,每个都要写一段既准确又能让模型理解使用场景的描述,这本身就是个文案活。不同人写的风格不统一,模型在相似工具之间就容易选错。

痛点三:维护成本随接口数量线性增长。10 个接口还能忍,50 个接口就是灾难。每次接口变更都要人工比对、修改、测试,稍不留神就漏掉一个。

2.2 为什么选 OpenAPI 作为中间层

OpenAPI 规范本身就是对 REST API 的结构化描述,它天然包含了工具定义需要的几乎所有信息:

工具定义需要的字段OpenAPI 中的对应位置
工具名称operationId 或 path + method 组合
工具描述summary / description
参数列表parameters(path、query、header)
请求体结构requestBody 的 schema
参数类型schema.type
必填项required 数组
枚举约束schema.enum
参数说明每个参数的 description

也就是说,OpenAPI 文档里已经有一份完整的、机器可读的接口描述,工具定义本质上就是这份描述的另一种表达形式。中间做一次格式转换,比人工重新写一遍可靠得多,也快得多。

2.3 为什么目标格式是 DeepSeek 的 Tools

DeepSeek 的工具调用格式遵循主流大模型的 function calling 规范,工具定义是一个 JSON 数组,每个元素包含type: "function"和function对象,function 里有name、description、parameters(JSON Schema 格式)。这个结构和 OpenAPI 的 schema 高度相似,转换起来映射关系清晰,不需要做太多语义层面的猜测。

选 DeepSeek 还有一个实际考虑:它的工具调用能力在国产模型里比较扎实,对 JSON Schema 的理解到位,参数提取准确率不错,而且接口兼容性好,本地部署和云端调用都能用。你把这套生成逻辑跑通后,换成其他遵循相同 function calling 规范的模型,改一下输出格式就行,核心转换逻辑是通用的。

2.4 整体架构:三步走

整个流程拆成三步,每步职责单一,方便调试和替换:

  1. 解析层:读取 OpenAPI 文件(JSON 或 YAML),解析成 Python 字典结构,提取所有 paths 和对应的操作。
  2. 转换层:把每个操作映射成一个工具定义,处理名称生成、描述拼接、参数合并、schema 转换、引用解析。
  3. 输出层:把工具定义列表序列化成 DeepSeek 需要的格式,写入文件或直接传给模型接口。

这个分层的好处是,如果哪天 OpenAPI 版本升级了,或者目标模型换了工具格式,你只需要改对应那一层,不用推倒重来。

3. 核心转换逻辑拆解:从接口到工具的每一步

3.1 工具名称的生成策略

工具名称是模型选择工具的第一道线索,必须满足两个条件:唯一、可读。OpenAPI 里的operationId通常是最好的来源,比如getUserById、createOrder,语义清晰。但现实情况是,很多接口文档的 operationId 要么缺失,要么是自动生成的乱码(比如getUsingGET_1),这时候就得有兜底方案。

我的策略是三级优先:

  1. 优先用operationId,如果它存在且符合命名规范(字母数字下划线,不以数字开头)。
  2. 其次用method + path拼接,比如GET /users/{id}转成get_users_by_id,把路径参数也体现进去。
  3. 最后做去重处理,如果生成的名称重复,追加数字后缀。

这里有个细节要注意:DeepSeek 对工具名称的字符集有要求,通常只接受字母、数字、下划线和短横线,长度也有限制。所以生成后要做一次清洗,把斜杠、花括号、点号这些替换掉,超长的截断。

import re def sanitize_tool_name(name, max_length=64): # 把非字母数字下划线短横线的字符替换成下划线 name = re.sub(r'[^a-zA-Z0-9_-]', '_', name) # 合并连续下划线 name = re.sub(r'_+', '_', name) # 去掉首尾下划线 name = name.strip('_') # 截断 if len(name) > max_length: name = name[:max_length] return name

3.2 描述文案的自动拼接

描述决定了模型在什么场景下会选这个工具,重要性不亚于名称。OpenAPI 里的summary和description是主要素材,但往往不够完整。我的做法是把多个来源的信息拼起来:

  • summary:一句话概括,放最前面。
  • description:详细说明,补充在后面。
  • HTTP 方法和路径:让模型知道这是个什么类型的操作。
  • 参数摘要:把关键参数列一下,帮助模型判断调用时机。

拼接时要注意控制长度,太长的描述会占用上下文,而且模型对超长描述的注意力会分散。我的经验是控制在 200 到 500 字符之间比较合适,重点信息前置。

def build_description(operation, method, path): parts = [] if operation.get('summary'): parts.append(operation['summary'].strip()) if operation.get('description'): parts.append(operation['description'].strip()) parts.append(f"HTTP {method.upper()} {path}") return ' '.join(parts)[:500]

3.3 参数合并:path、query、header 一个都不能漏

OpenAPI 里参数分散在三个地方:path 参数、query 参数、header 参数,都在parameters数组里,通过in字段区分。请求体(requestBody)则是单独的一块。转换时要把这些全部合并到一个 JSON Schema 的properties里。

合并时有个坑:不同位置的参数可能重名。比如 path 里有个id,query 里也有个id,直接合并会覆盖。我的处理是给重名参数加前缀,比如path_id、query_id,同时在描述里说明来源。虽然这种情况不常见,但一旦出现,不处理就会导致模型传错参数。

另一个细节是 header 参数。大部分 header 是认证、内容类型这类基础设施性质的,不应该暴露给模型。我的做法是维护一个黑名单,把Authorization、Content-Type、Accept、User-Agent这些过滤掉,只保留业务相关的自定义 header。

3.4 schema 转换:OpenAPI 到 JSON Schema 的映射

OpenAPI 的 schema 和 JSON Schema 大部分是兼容的,但有几个地方需要处理:

  • $ref引用:OpenAPI 里大量使用$ref指向components/schemas下的定义,必须递归解析展开,否则模型看不懂引用。
  • nullable:OpenAPI 3.0 用nullable: true表示可空,JSON Schema 里没有这个关键字,要转成type: ["string", "null"]这种形式。
  • allOf/oneOf/anyOf:组合 schema 要递归处理,DeepSeek 对anyOf的支持较好,allOf可以尝试合并。
  • example和default:保留下来,对模型理解参数有帮助。

$ref解析是这里最容易出问题的地方,因为引用可能嵌套引用,甚至循环引用。必须加一个已访问集合来防止无限递归。

def resolve_ref(schema, components, visited=None): if visited is None: visited = set() if not isinstance(schema, dict): return schema if '$ref' in schema: ref_path = schema['$ref'] if ref_path in visited: return {"type": "object"} # 循环引用兜底 visited.add(ref_path) # 解析 #/components/schemas/Xxx 这种路径 ref_name = ref_path.split('/')[-1] target = components.get('schemas', {}).get(ref_name, {}) return resolve_ref(target, components, visited) # 递归处理 properties、items 等 result = {} for key, value in schema.items(): if key == 'properties' and isinstance(value, dict): result[key] = {k: resolve_ref(v, components, visited) for k, v in value.items()} elif key == 'items': result[key] = resolve_ref(value, components, visited) else: result[key] = value return result

3.5 必填项的准确提取

必填项判断错,模型就会漏传参数或者多传空值。OpenAPI 里必填信息有两个来源:参数自身的required: true,以及 requestBody schema 里的required数组。这两处都要处理,合并到最终的required列表里。

有个容易忽略的点:path 参数在 OpenAPI 规范里是强制 required 的,但有些文档写得不规范,漏标了。我的做法是,只要是in: path的参数,一律视为必填,不管文档里写没写。这是符合 REST 语义的,路径参数不传,请求根本发不出去。

4. 完整实操:从零跑通自动生成流程

4.1 环境准备与依赖安装

这套东西用 Python 写最顺手,因为 OpenAPI 解析和 JSON 处理在 Python 生态里工具最全。基础依赖就两个:

pip install pyyaml requests

pyyaml用来解析 YAML 格式的 OpenAPI 文件(很多项目用 YAML 写文档),requests用来在需要时从远程 URL 拉取 OpenAPI 文档。如果你只用 JSON 格式的文档,pyyaml都可以省掉,标准库的json就够了。

Python 版本建议 3.8 以上,我用的是 3.10,类型提示和字典操作都比较舒服。不需要额外的框架,纯标准库加这两个依赖就能跑。

4.2 读取和解析 OpenAPI 文档

先写一个加载函数,同时支持本地文件和远程 URL,自动识别 JSON 和 YAML:

import json import yaml import requests def load_openapi(source): if source.startswith('http://') or source.startswith('https://'): resp = requests.get(source, timeout=30) resp.raise_for_status() content = resp.text else: with open(source, 'r', encoding='utf-8') as f: content = f.read() # 先尝试 JSON,失败再试 YAML try: return json.loads(content) except json.JSONDecodeError: return yaml.safe_load(content)

这里有个实操经验:有些 OpenAPI 文档是 JSON 但扩展名是.yaml,或者反过来。不要靠扩展名判断,直接按内容试解析最稳。先试 JSON 是因为 JSON 解析更快,失败再走 YAML。

4.3 遍历所有接口操作

OpenAPI 的paths是个字典,key 是路径,value 是各 HTTP 方法的操作定义。遍历时要过滤掉非标准方法的 key,比如parameters、summary这些路径级别的字段:

HTTP_METHODS = {'get', 'post', 'put', 'delete', 'patch', 'head', 'options'} def iter_operations(spec): paths = spec.get('paths', {}) for path, path_item in paths.items(): for method, operation in path_item.items(): if method.lower() not in HTTP_METHODS: continue if not isinstance(operation, dict): continue yield method.lower(), path, operation

4.4 单个接口转工具定义的完整实现

把前面几节的逻辑串起来,写一个完整的转换函数:

def convert_operation(method, path, operation, components, used_names): # 1. 生成工具名称 raw_name = operation.get('operationId') if not raw_name: raw_name = f"{method}_{path}" name = sanitize_tool_name(raw_name) # 去重 base_name = name counter = 1 while name in used_names: name = f"{base_name}_{counter}" counter += 1 used_names.add(name) # 2. 构建描述 description = build_description(operation, method, path) # 3. 合并参数 properties = {} required = [] for param in operation.get('parameters', []): if '$ref' in param: param = resolve_ref(param, components) param_name = param.get('name') param_in = param.get('in') if not param_name or param_in == 'header': # 跳过 header 参数 continue schema = resolve_ref(param.get('schema', {}), components) if param.get('description'): schema['description'] = param['description'] properties[param_name] = schema if param.get('required') or param_in == 'path': required.append(param_name) # 4. 处理 requestBody body = operation.get('requestBody', {}) if '$ref' in body: body = resolve_ref(body, components) content = body.get('content', {}) json_content = content.get('application/json', {}) if json_content: body_schema = resolve_ref(json_content.get('schema', {}), components) if body_schema.get('type') == 'object' and 'properties' in body_schema: for prop_name, prop_schema in body_schema['properties'].items(): properties[prop_name] = prop_schema for req in body_schema.get('required', []): if req not in required: required.append(req) else: # 非对象类型的 body,整体作为一个参数 properties['body'] = body_schema if body.get('required'): required.append('body') # 5. 组装工具定义 parameters_schema = { "type": "object", "properties": properties, } if required: parameters_schema["required"] = required return { "type": "function", "function": { "name": name, "description": description, "parameters": parameters_schema } }

4.5 批量生成与输出

最后把整个流程串起来,批量处理所有接口,输出成 DeepSeek 需要的格式:

def generate_tools(spec): components = spec.get('components', {}) used_names = set() tools = [] for method, path, operation in iter_operations(spec): tool = convert_operation(method, path, operation, components, used_names) tools.append(tool) return tools def main(source, output): spec = load_openapi(source) tools = generate_tools(spec) with open(output, 'w', encoding='utf-8') as f: json.dump(tools, f, ensure_ascii=False, indent=2) print(f"生成了 {len(tools)} 个工具定义,已写入 {output}") if __name__ == '__main__': main('openapi.json', 'tools.json')

跑一遍,50 个接口几秒钟就全部转完了。生成的tools.json可以直接作为tools参数传给 DeepSeek 的对话接口。

4.6 接入 DeepSeek 调用

生成工具定义只是第一步,真正用起来还要把它传给模型。DeepSeek 的接口兼容 OpenAI 的格式,调用方式很直接:

import requests def chat_with_tools(user_message, tools): resp = requests.post( "https://api.deepseek.com/chat/completions", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" }, json={ "model": "deepseek-chat", "messages": [{"role": "user", "content": user_message}], "tools": tools, "tool_choice": "auto" }, timeout=60 ) return resp.json()

模型返回的tool_calls里会包含它选择的工具名称和参数,你解析出来,映射回实际的 HTTP 请求,调用真实接口,再把结果喂回模型,就完成了一轮工具调用闭环。

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

5.1 工具数量太多导致模型选择困难

50 个工具一次性全塞给模型,会出现两个问题:一是上下文占用大,二是模型在相似工具之间容易选错。我的经验是,工具数量超过 20 个之后,选择准确率会明显下降。

解决办法有两个方向。一是按场景分组,把工具分成几组,根据用户意图先判断属于哪组,只把该组的工具传给模型。二是精简描述,把不必要的信息砍掉,让每个工具的描述更聚焦。实测下来,分组方案效果更好,但需要额外做一层意图路由。

5.2 $ref 解析失败或循环引用

这是最常见的报错来源。表现是生成的 schema 里还残留着$ref字段,或者程序直接栈溢出。排查步骤:

  1. 检查components.schemas里是否真的存在被引用的定义,有时候文档写得不完整,引用了不存在的 schema。
  2. 检查是否有循环引用,比如 A 引用 B,B 又引用 A。这种情况必须用 visited 集合兜底。
  3. 检查$ref路径格式,有些文档用的是完整 URL 而不是#/components/schemas/Xxx这种内部引用。

5.3 参数类型转换错误

OpenAPI 里有些类型 JSON Schema 不直接支持,比如type: file、type: binary。这些参数在工具调用场景下没法直接传,我的做法是统一转成type: string,并在描述里注明是文件内容或路径。模型传字符串过来,你在实际调用接口时再做转换。

5.4 常见问题速查表

问题现象可能原因解决方向
生成的 schema 里有 $ref引用未解析检查 resolve_ref 递归逻辑
模型选错工具描述太相似或太模糊优化 description,增加区分度
参数漏传required 提取不全检查 path 参数和 body required
参数类型不对schema 转换遗漏检查 nullable、allOf 处理
工具名重复operationId 缺失或重复启用去重逻辑,加后缀
上下文超限工具太多或描述太长分组加载,精简描述

5.5 几个踩过的坑

坑一:不要相信 operationId 的唯一性。我遇到过一份文档里有两个接口的 operationId 完全一样,直接导致工具名冲突。去重逻辑必须要有,不能省。

坑二:description 里的换行和特殊字符要清理。有些文档的 description 里带 markdown 格式、换行符、甚至 HTML 标签,直接塞给模型会干扰理解。我一般会做一次清洗,去掉多余空白和格式符号。

坑三:枚举值要保留。有些参数是枚举类型,OpenAPI 里用enum列出可选值。这个信息对模型非常重要,能大幅降低传错值的概率。转换时一定要保留enum字段。

坑四:分页参数要特别处理。列表类接口通常有page、pageSize、offset、limit这类分页参数。这些参数模型不一定知道该传多少,可以在描述里给个默认值建议,比如"默认每页 20 条"。

6. 进阶优化:让自动生成的工具更好用

6.1 描述文案的二次加工

自动拼接的描述虽然准确,但读起来往往比较生硬。如果对效果要求高,可以在自动生成的基础上做一层模板化加工。比如把"HTTP GET /users/{id}"这种技术描述,转成"根据用户 ID 查询用户详细信息"这种业务语言。

我的做法是维护一个动词映射表,把 HTTP 方法和路径模式映射成业务动作:

ACTION_MAP = { ('get', 'list'): '查询列表', ('get', 'detail'): '查询详情', ('post', 'create'): '创建', ('put', 'update'): '更新', ('delete', 'delete'): '删除', }

结合路径里的关键词做匹配,生成更自然的描述。这一步不是必须的,但做了之后模型的选择准确率会有可见提升。

6.2 工具分组与动态加载

前面提到工具太多会影响选择准确率,分组是有效的应对。具体做法是给每个工具打上分类标签,可以基于路径前缀自动分类,比如/user/*归到用户组,/order/*归到订单组。调用时先让模型判断意图属于哪组,再加载对应组的工具。

这个方案的关键是分类要合理,组内工具数量控制在 10 到 15 个比较合适。组太多会增加路由复杂度,组太少又起不到精简作用。

6.3 参数默认值与示例注入

模型对参数的取值经常拿不准,尤其是可选参数。在 schema 里注入default和example能显著改善这个问题。OpenAPI 文档里如果已经有这些信息,直接保留;如果没有,可以根据参数名和类型推断一个合理的默认值。

比如pageSize默认 20,page默认 1,status如果有枚举就选第一个。这些默认值不一定要精确,主要是给模型一个参考锚点。

6.4 生成结果的校验

自动生成的东西不能盲信,上线前一定要做校验。我一般会检查这几项:

  • 工具名称是否唯一、是否符合字符集要求。
  • 每个工具的parameters是否是合法的 JSON Schema。
  • required里的字段是否都在properties里存在。
  • 描述是否为空、是否超长。

写一个校验函数,生成后跑一遍,有问题直接报出来,比等到模型调用失败再排查高效得多。

def validate_tools(tools): errors = [] names = set() for tool in tools: fn = tool['function'] name = fn['name'] if name in names: errors.append(f"工具名重复: {name}") names.add(name) params = fn['parameters'] props = params.get('properties', {}) for req in params.get('required', []): if req not in props: errors.append(f"{name}: required 字段 {req} 不在 properties 中") if not fn.get('description'): errors.append(f"{name}: 描述为空") return errors

这套自动生成流程跑通之后,我维护那 50 多个接口的工具定义,从原来每次改动要花大半天,变成了改完 OpenAPI 文档重新跑一遍脚本,几分钟搞定。真正花时间的部分转移到了描述优化和分组策略上,而这些是一次性投入、长期受益的工作。如果你手头也有带 OpenAPI 文档的接口需要接入模型,强烈建议先把这套自动化跑起来,别在手工写 schema 上浪费生命。