尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

OpenAPI 自动生成 DeepSeek 工具,解决函数调用 JSON 手写难题

OpenAPI 自动生成 DeepSeek 工具,解决函数调用 JSON 手写难题 这些年接过的后端服务越来越多手头维护的 REST API 随便一数就是几十个每次要接大模型 Function Calling 的时候最头疼的就是“写 Tools”。一个接口一个接口地手写 JSON Schema描述参数、写含义、想 example写完了还要一遍遍对文档生怕字段名少个下划线。后来我想通了一件事绝大多数 REST API 背后都有 OpenAPI 规范而这些规范本身就包含了模型需要的一切信息——名字、描述、参数结构、必填项、枚举值。那我为什么不直接让 OpenAPI 自动生成 DeepSeek 的 Tools这篇文章就是我落地这套方案的完整记录包含设计思路、核心代码、踩坑实录和排查手段。如果你也在为“手写 50 个工具的 JSON”头疼这份经验可以直接抄。1. 为什么还要手写 ToolsOpenAPI 自动化的价值拆解1.1 大量 REST API 接入的真实痛点先说一个最常见的场景公司内部有几十个微服务每个服务暴露一组 REST API。产品侧希望让 DeepSeek 具备调用这些接口的能力于是我们需要给大模型注册 Tools。所谓 Tools 就是一组 JSON Schema 描述告诉模型“有哪些函数可以调用、参数是什么、调用后有什么效果”。以前的做法非常原始打开 Swagger UI一个个接口看过去然后手工在代码里写{ type: function, function: { name: get_user_info, description: 查询用户信息, parameters: { type: object, properties: { user_id: { type: integer, description: 用户ID } }, required: [user_id] } } }如果一个系统有 50 个接口那就至少写 50 段类似的 JSON。如果接口经常变你还要同步改。更麻烦的是很多接口的参数远不止一两个嵌套对象、数组、枚举、条件必填全都有手写极其容易出错。我见过因为参数名拼错导致模型调用接口 400 整整查了一下午的案例。为什么会这么痛苦因为 Tools 的格式本质上是 JSON Schema而 OpenAPI 规范中的 requestBody、parameters、schema 本身就是 JSON Schema 的超集。信息已经结构化了人工再去抄一遍纯属重复劳动。1.2 OpenAPI 规范怎么变成 DeepSeek Tool 定义DeepSeek 的 Function Calling 接口兼容 OpenAI 风格也就是说我们需要给 DeepSeek 模型传一个tools参数数组中每个元素就是一个工具描述。工具描述的核心是function.parameters它必须是一个标准的 JSON Schema。OpenAPI 文档现在都是 v3 版本里有三块关键信息可以对应过来paths定义每个 URL 路径和 HTTP 方法这是工具名的来源。operationId或summary定义工具名和描述。很多 OpenAPI 文档会写operationId: getUserInfo这个可以直接作为函数名。parameters、requestBody定义参数结构包含每个字段的类型、是否必填、默认值、枚举、描述。所以自动化的思路就是读取 OpenAPI JSON遍历所有paths下的get/post/put/delete操作把路径参数、查询参数、请求体参数全部进行 Schema 合并然后重新包装成 DeepSeek 需要的 Tools 数组。这中间最大的挑战是 Schema 的复杂度。OpenAPI 里支持$ref引用、allOf组合、oneOf多选、nullable空值这些如果不处理直接塞给大模型容易造成解析异常或者生成的参数明显不对。所以我们需要一个“Schema 清洗层”。1.3 自动生成方案的核心收益把 50 个 REST API 自动生成 DeepSeek Tools最直接的收益是时间。原来可能两三个小时才能写完的 JSON Schema现在我只需要写几十行解析代码几十秒全部搞定。而且每次接口更新重新跑一遍生成脚本就是一个新的 tools 文件不用再手动同步。第二个收益是准确性。OpenAPI 文档是后端同事在维护的字段类型和必填校验都是代码生效的真结构。自动生成的参数就不会出现“文档说传createdAt代码里其实是create_time”这种问题。第三个收益是可扩展性。公司接入新服务只要把新的 OpenAPI 文档丢过来生成器就直接产出新的 Tools。这时候你维护的不再是 50 个接口的细枝末节而是一套稳定的生成机制。当然也有需要注意的地方OpenAPI 文档质量直接决定生成质量。文档里如果全是operationId缺省、description乱写、参数连类型都不标生成出来的工具也会“带病上岗”。所以这个方案有个前置条件就是先保证接口文档本身是规范的。2. 工具选型与整体设计2.1 选型思路解析器、生成器与运行时做这个事不需要从零开始解析 YAMLOpenAPI 解析器在生态里已经非常成熟。我选型的核心标准有三个能解析 OpenAPI 3.x、能处理$ref、能让我方便地拿到最终的 Schema 结构。主要是三个 Python 库值得关注openapi-spec-validator只负责校验文档格式不负责解析成可用对象。openapi-core偏运行时请求验证虽然也能拿到 schema但文档不完整时容易失败。prance/openapi-spec能把多文件、带$ref的 OpenAPI 解析成一个完整的 dict。我最后选的是prance。它在解析阶段把所有$ref都解开了生成的 dict 可以直接遍历不用再自己写引用解析器。如果你用的 OpenAPI 文档是application/json形式的直接用requests拉下来再用prance解析就行。除了解析器还需要一个 JSON Schema 清洗函数。这里我没有引入额外的库而是自己写了递归函数处理allOf、oneOf、nullable等场景。为什么不用json_schema_merge之类的重型工具因为很多生成器默认行为过于激进把oneOf合并后反而丢失了多态信息对大模型来说你只需要给出一个“尽量合理的结构”不像代码校验那样严格。生成器的输出要是一个纯 JSON 文件同时也能动态导入到 Python 运行时中。这样后续接入 DeepSeek API 时既能直接加载本地文件又能通过 import 使用。2.2 自动生成工具的三种主流路径在具体动手之前我先梳理一下市面上的做法主要分三类。第一类是“离线生成静态注册”。就是离线跑一次脚本把 OpenAPI 转成 tools.json然后每次对话时把这个 JSON 原样传给 DeepSeek。这种方式最简单适合接口数量在几十个、变更不频繁的系统。我的方案就是这一类因为 50 个接口这个数量级完全没必要引入太多动态逻辑。第二类是“动态发现按需加载”。每次用户请求进来时根据用户意图或关键词去一个接口注册中心拉取相关的 OpenAPI 片段再动态生成 Tools。这种方式适合接口数量成百上千的大平台因为全部塞进上下文既费 token 又容易让模型“看花眼”。第三类是“服务器推送SDK 自动化”。利用一些后端框架的 OpenAPI 扩展让工具定义完全由框架管理大模型对话服务只负责转发。这种方式侵入性较高但省心。我最终选了第一种。原因很朴素稳定、可复现、出了问题好排查。等到接口量真的超过几百个我再叠加“按需过滤层”也不迟。2.3 目录结构与核心模块划分整个落地工程我拆成了五个模块结构如下openapi_tools/ ├── config.py # 配置文件API地址、鉴权key、OpenAPI文档路径 ├── parser.py # OpenAPI 解析封装负责产出一个统一结构 ├── schema_cleaner.py # JSON Schema 清洗与转换 ├── generator.py # 从统一结构生成 DeepSeek Tools 数组 ├── runtime.py # 运行时加载工具并调用 DeepSeek API └── output/ ├── tools.json # 生成的最终结果 └── manifest.json # 工具索引parser.py的目标很明确把 OpenAPI 文档变成一种中间结构不掺杂任何 DeepSeek 特有格式。这个中间结构是一个字典列表每个元素包含name、description、parameters、url、method。这样未来如果要从 DeepSeek 切换成其他模型只要把generator.py换掉即可解析层无需动。schema_cleaner.py是核心的“清洗”层。它把 OpenAPI 里各种复杂的 Schema 关键字转换成一个精简的 JSON Schema 子集。为什么需要精简因为 DeepSeek 的 Function Calling 并不需要 OpenAPI 全部的功能比如xml、discriminator、example里的复杂对象等。保留这些只会浪费 token而且有些模型解析器对不认识的字段会直接忽略影响参数生成准确性。3. 实操过程从 OpenAPI 到 DeepSeek Tools 的完整落地3.1 环境准备与依赖安装先准备一个干净的 Python 环境建议用 Python 3.10 以上。依赖其实很少核心只有prance和openai因为 DeepSeek 接口兼容 OpenAI 协议直接用openaiSDK 也能调。再加一个requests用来下载远程 OpenAPI 文档。pip install prance openai requests PyYAMLPyYAML是prance解析 YAML 格式 OpenAPI 文档时需要的虽然prance会自动装但显式装一遍能避免版本冲突。我准备的测试文档是一份标准的openapi.json里面包含了一个“订单服务”的十几个接口和“用户服务”的三十几个接口合在一起正好接近 50 个 API。这些接口包含常见的GET /users/{id}、POST /orders、PUT /orders/{id}/status、DELETE /users/{id}等覆盖了路径参数、查询参数、requestBody、嵌套对象、枚举值等全部典型场景。如果你的文档是 YAML也没关系prance会自动识别。3.2 解析 OpenAPI 文档并提取接口元信息第一步把 OpenAPI 文档加载进来然后遍历paths。import prance def load_openapi(source): if source.startswith(http): parser prance.BaseParser(source, backendopenapi-spec-validator) else: parser prance.BaseParser(source, backendopenapi-spec-validator) return parser.specification用prance.BaseParser的原因很简单它能在解析时分步拉取外部引用最后specification里就是一个完全展开的字典。这里注意prance解析远程 URL 文档时会通过网络拉取如果你的 OpenAPI 文档是内网的直接把 JSON 文本传给BaseParser即可。接下来遍历接口def extract_operations(spec): operations [] for path, path_item in spec.get(paths, {}).items(): for method in [get, post, put, delete, patch]: operation path_item.get(method) if not operation: continue operations.append({ path: path, method: method.upper(), operation_id: operation.get(operationId), summary: operation.get(summary, ), description: operation.get(description, ), parameters: operation.get(parameters, []), request_body: operation.get(requestBody, {}), responses: operation.get(responses, {}), }) return operations这里每个 operation 里的parameters既有 path 级别的也有 operation 级别的。在 OpenAPI 规范里path 级别的参数会继承到每个方法里所以最终合并时要把两者拼在一起再去重。operationId优先作为工具名没有的话就需要用method path生成。这个细节我放在后面讲。3.3 生成 DeepSeek 可识别的 function schema从 OpenAPI 到 Tools 最关键的一步就是构造parameters的 JSON Schema。OpenAPI 里的参数分成两类不是在requestBody里的都是parameters数组每个元素有name、in、required、schema等字段。请求体参数在requestBody.content[application/json].schema里。我们要把这些参数整合成一个object类型的 schema把 query 参数、header 参数、path 参数都作为属性放进去。我的核心做法是写一个build_tool_schema函数如下def build_tool_from_operation(operation): props {} required [] # 处理普通参数 params operation.get(parameters, []) for p in params: param_name p[name] schema p.get(schema, {}) schema clean_schema(schema) props[param_name] schema if p.get(required, False): required.append(param_name) # 处理请求体 request_body operation.get(request_body, {}) if request_body: content request_body.get(content, {}) if application/json in content: json_schema content[application/json].get(schema, {}) json_schema clean_schema(json_schema) # 请求体的 schema 如果本身就是 object直接合并属性 if json_schema.get(type) object: body_props json_schema.get(properties, {}) for k, v in body_props.items(): props[k] v if json_schema.get(required): required.extend(json_schema[required]) else: props[body] json_schema parameters { type: object, properties: props, required: sorted(list(set(required))) } return { type: function, function: { name: operation[name], description: operation[description], parameters: parameters } }clean_schema是清洗函数目的是让 Schema 干净可控。我在实际操作中重点处理了几种情况去除$ref。因为prance已经展开理论上没有$ref了但保险起见还是要递归检查。把allOf合并成一个 object。OpenAPI 里很常见的写法是allOf: [{ $ref: BaseModel }, { type: object, properties: {...} }]。清洗时要逐个展开把属性合并、必填项合并。oneOf尽量简化。如果oneOf里只有一个元素直接提取那个元素如果有多个保留为一个简化oneOf数组但每个元素必须是 Object 类型。DeepSeek 是支持oneOf的但描述过多时模型容易迷糊所以我通常会在某个元素是主要结构时直接选择默认项。删除无意义字段比如xml、externalDocs降低 token 消耗。下面是我用的简化版clean_schema核心逻辑def clean_schema(schema, depth0): if not isinstance(schema, dict): return schema if depth 10: return schema if $ref in schema: return clean_schema(schema[$ref], depth 1) # 实际场景中需要查引用的定义 if allOf in schema: merged {type: object, properties: {}, required: []} for sub in schema[allOf]: cleaned clean_schema(sub, depth 1) if cleaned.get(type) object and properties in cleaned: merged[properties].update(cleaned[properties]) if cleaned.get(required): merged[required].extend(cleaned[required]) if description in schema and description not in merged: merged[description] schema[description] return merged if oneOf in schema: choices schema[oneOf] if len(choices) 1: return clean_schema(choices[0], depth 1) return { type: object, oneOf: [clean_schema(item, depth 1) for item in choices], description: schema.get(description, ) } result dict(schema) for key in [xml, externalDocs]: result.pop(key, None) if properties in result: props {} for k, v in result[properties].items(): props[k] clean_schema(v, depth 1) result[properties] props if items in result: result[items] clean_schema(result[items], depth 1) return result这段代码在实际测试中能覆盖绝大多数常见 OpenAPI 文档。当然如果你碰到极端嵌套的oneOf可能会生成一个很“胖”的 schema但至少不会报错。3.4 动态注册 Tools 并接入 DeepSeek API生成好的 Tools 数组可以直接保存为 JSON 文件。运行时加载它然后在每次调用 DeepSeek 时作为tools参数传入。我用的是 OpenAI SDK因为 DeepSeek 的 API 端点和 OpenAI 兼容import json from openai import OpenAI client OpenAI( api_key你的DeepSeek API Key, base_urlhttps://api.deepseek.com ) tools json.load(open(output/tools.json)) response client.chat.completions.create( modeldeepseek-chat, messages[ {role: system, content: 你是订单管理助手可以调用工具完成用户请求。}, {role: user, content: 查询用户 10086 的详情} ], toolstools, tool_choiceauto )拿到响应后判断是否有tool_calls。如果有就执行对应的 REST API 请求然后把结果作为新的消息回传。这个流程跟 OpenAI Function Calling 完全一致。import requests # 模拟执行工具调用 if response.choices[0].message.tool_calls: tool_call response.choices[0].message.tool_calls[0] fn_name tool_call.function.name fn_args json.loads(tool_call.function.arguments) # 根据 fn_name 映射到真实的 REST API 请求 ...这里有个关键点模型生成的参数是一个 JSON 字符串你需要把它映射到后端服务。我的做法是生成一个manifest.json里面保存每个工具对应的method、url、参数位置信息。例如{ get_user_info: { method: GET, url: https://api.example.com/users/{user_id}, path_params: [user_id], query_params: [] } }这个 manifest 在generator.py生成工具定义时同步生成。运行时执行工具调用时先查找 manifest然后替换 URL 中的占位符把其余参数按 query 或 body 位置组装。3.5 全链路跑通一个真实接口我用“查询用户详情”这个接口来演示全链路效果。OpenAPI 文档里对应片段是这样的/users/{user_id}: get: operationId: getUserInfo summary: 查询用户详情 parameters: - name: user_id in: path required: true schema: type: integer - name: verbose in: query required: false schema: type: boolean responses: 200: description: OK经过生成器自动输出的工具定义是{ type: function, function: { name: getUserInfo, description: 查询用户详情, parameters: { type: object, properties: { user_id: { type: integer }, verbose: { type: boolean } }, required: [user_id] } } }模型收到用户问题“查询用户 10086 详情”会自己决定调用getUserInfo并生成{user_id: 10086}。然后运行时把user_id填到 URL 里请求GET /users/10086得到响应后把结果作为消息返回给 DeepSeek模型再总结成自然语言。这个流程走通后其余 49 个接口只是数量上的差异不需要单独写逻辑。我给这套流程打包成了一个命令行工具python generator.py --source openapi.json --output output/。跑一次所有工具就齐了。4. 常见问题与排查技巧实录4.1 参数类型映射不一致最常见的问题OpenAPI 里user_id是string但你后端实际接收的是int。模型生成的user_id是字符串10086后端要求数字请求直接 400。排查方式很简单先在本地写个脚本模拟一次工具调用把模型生成的参数打出来对比原接口的类型。如果发现不一致有两种解法。第一种是生成工具定义时按照后端实际支持的类型覆盖 OpenAPI 里的 schema。第二种是运行时在执行工具前增加一个“类型校正层”比如把10086转成10086。我建议采用后者因为接口类型有时候会改你把校正逻辑集中在运行时比改生成器要灵活。做法是在 manifest 里额外记录每个参数的类型然后执行前做cast。def cast_params(args, schema): for key, definition in schema.get(properties, {}).items(): if key not in args: continue expected_type definition.get(type) if expected_type integer: args[key] int(args[key]) elif expected_type number: args[key] float(args[key]) elif expected_type boolean: if isinstance(args[key], str): args[key] args[key].lower() in [true, 1] return args4.2 OpenAPI 文档不规范导致的解析失败我遇到过一个真实案例某个服务的 OpenAPI 是后端用 Java 框架自动生成的里面大量操作没有operationId且summary是空字符串。这时候生成器会得到一批没有名字的工具。解决这个问题需要引入一个命名规则。我用的是方法 路径转驼峰。def make_operation_id(method, path): parts path.strip(/).split(/) words [method] for part in parts: # 去掉路径参数花括号 if part.startswith({) and part.endswith(}): words.append(by_ part[1:-1]) else: words.append(part) result _.join(words).lower() # 简单转成驼峰 return .join([w.capitalize() if i 0 else w for i, w in enumerate(result.split(_))])这样生成的getUsersByUserId之类的名字虽然不完美但足够可读。更重要的是模型在理解工具名时依然能猜出大致含义。另外如果 OpenAPI 文档里的description缺失可以用summary兜底两个都没有就把路径作为描述的一部分写进去。我给生成器加了一个三级回退策略operationIdsummary 路径模板。不要嫌路径模板不好看至少模型知道这个工具涉及哪个端点。4.3 工具冲突与命名重复两条路径可能对应同一个operationId比如/users/get和/users/list在 OpenAPI 里都被写成了getUser。这会导致模型混淆。排查时我加了一个“工具名唯一性校验”在生成阶段收集所有名字遇到重复就自动加后缀比如getUser_2。这虽然不优雅但能保证不出错。更好的做法是建立一份映射表允许人工在配置里指定别名。我在config.py中加入了operation_map配置operation_map { getUser: getUserByLegacyEndpoint }这种方式适合少量冲突既保留了机器生成的高效又给了人工干预的入口。4.4 请求鉴权与 Base URL 处理很多 REST API 是需要鉴权的。模型的工具调用只是生成参数实际发 HTTP 请求还是你的运行时来做因此鉴权头比如Authorization应该挂在运行时而不是作为工具参数传给模型。这里有个细节如果 OpenAPI 文档里定义了securitySchemes生成器可以直接读取operation.security然后把对应的鉴权方案名称记录到 manifest 里。实测中我遇到过 API Key 放在请求头X-Api-Key里而文档 security 声明的是apiKey命名为ApiKeyAuth。运行时就可以根据 manifest 的 security 类型自动补充请求头。Base URL 的处理也要注意。OpenAPI 里的servers可能有好几个生成器默认选第一个。如果模型工具对应的服务在不同环境有不同域名我建议把servers全部保留在 manifest 里运行时再根据当前环境选择。4.5 生成后的 prompt 上下文过长50 个工具的描述和参数全都传进去单是 tools 序列化之后就可能超过 2 万 token尤其是某些接口的 schema 特别长。这时候需要考虑“裁剪”。一个简单策略是只保留每个服务最核心的工具把低频接口单独拆成第二梯队。但更稳妥的方案是“工具路由”先用一个小模型或者关键词规则判断用户意图再从完整工具表里选出 5~10 个相关工具传给 DeepSeek。我目前的做法是给每个工具定义加一个category字段来自 OpenAPI 的tags。用户提问后先做一次简单的文本分类可以直接用 DeepSeek 但用很小的 prompt命中一个或多个 category然后只加载这些分类下的工具。5. 实战心得与扩展方向5.1 我踩过的几个坑第一个坑是prance默认会把allOf保留而不是合并。如果你直接用原始 schema 传给 DeepSeek模型有时候会生成一个缺少基础字段的参数对象。所以schema_cleaner中的allOf合并是必须的不能偷懒。第二个坑是路径参数名和请求体字段名冲突。比如PUT /orders/{order_id}的 body 里也有order_id两个都有意义。如果简单合并成一个order_id模型就不知道是该填路径里的还是 body 里的。我的解决办法是在路径参数名上加上后缀比如path_order_id然后在 manifest 里记录这个映射关系。虽然名字难看了点但至少请求不会发错。第三个坑是枚举字段。OpenAPI 中的枚举值通常是一串业务代码比如status: [created, paid, shipped, cancelled]模型如果不了解业务很可能生成一个中文值如已支付。我建议在生成 schema 时把枚举值写入 description并且附带“只允许使用给定枚举值”的提示。第四个坑是时间格式。OpenAPI 里format: date-time的字段模型有时候会生成2025-06-01 12:00:00而后端要求2025-06-01T12:00:00Z。这个没有银弹只能在运行时统一格式化。5.2 还可以怎么玩这套自动生成的机制稳定之后我很快扩展到了更多的场景。一个是把 OpenAPI 工具生成器接入了 CI/CD每次后端接口文档更新到代码仓库时流水线自动跑一遍生成脚本然后自动提交新的tools.json。这样前端对话服务永远用的是最新接口。另一个是“反向工具注册”。DeepSeek 不仅能调用外部 API也可以暴露自己的能力给对方。比如我们把公司内部模型的某些能力封装成 OpenAPI 服务让别人也能用同样的方式自动生成 Tools。虽然这个玩法还比较超前但技术链路完全一致。还有一点值得尝试让生成器根据 OpenAPI 的responses自动生成“响应解析 schema”。目前我只解决了“入参”自动生成但模型拿到 API 返回后还需要一层解析逻辑。如果响应结构很复杂也可以在生成时同时产出解析模板减少运行时的手写代码。从我个人的实测体验来说这套方案最大的价值不在于省写了 50 个 JSON 定义而是把“接口对接”这件事从一次性手工劳动变成了可持续的自动化能力。后续每接入一个新的 REST API我只用把 OpenAPI 文档扔进去剩下的交给生成器。这种舒服劲儿只有经历过手写 Tools 折磨的人才能体会到。
返回列表