
接手一个内部系统改造的时候我遇到了一个让所有后端都头疼的场景——系统里散落着 50 个 REST API需要全部接入 DeepSeek 做自然语言交互。最开始的想法很朴素照着 API 文档一个接口一个接口地写 tools 描述。写了两三个之后我就意识到这不是工作量的问题是纯纯的体力活加返工风险。那段时间正好在研究 OpenAPI 规范灵光一闪既然所有 REST API 都能导出 OpenAPI 描述文件那 tools 定义能不能从 OpenAPI 里自动生成后来的实践证明这条路完全走得通而且 50 个 API 的 tools 生成加测试我最后只用了一个下午。这篇文章把我完整的实现思路、核心代码以及踩过的坑都整理出来给正准备做 DeepSeek或任意 OpenAI 兼容协议的大模型工具接入、又不想手写大批量 tools 的同学一个可以直接抄作业的参考方案。1. 从三天工作量到十分钟为什么我决定不再手写 Tools1.1 一个真实项目的尴尬开局我手里的这个系统业务覆盖用户管理、订单流转、库存查询、数据报表REST API 本身设计得还算规范每个模块都有对应的 Swagger 文档。但问题在于数量——50 个接口每个接口都涉及路径参数、查询参数、请求体、返回结构。如果按传统方式在 DeepSeek 的工具列表里逐个手写 JSON Schema平均每个接口得花二十到三十分钟。这么算下来光 tools 定义就要写整整两天还不算调试时间。手写的过程中最容易翻车的是参数结构。接口文档上写着POST /api/v1/orders请求体长这样{ orderId: string, items: [ { sku: string, quantity: 1 } ], shipAddress: { province: string, city: string, detail: string } }如果手写 tools就得把这个嵌套结构完整地翻译成 JSON Schema。一次两次还能应付连续写十几个以后人就会开始麻木然后就会出现字段名拼写错误、类型标错、嵌套层级漏掉一层这类问题。更麻烦的是一旦接口升级手写的 tools 文档根本来不及跟着更新线上模型就会拿着过时的参数定义去调用接口错误率直线上升。1.2 手写 Tools 的三大痛点先给没做过这块的同学解释一下这里说的 tools 到底是个什么东西。在 DeepSeek 这类大模型的应用场景里模型本身不知道你系统里有哪些接口、参数是什么、返回什么。你需要在请求里显式地声明一份工具清单告诉模型它可以调用哪些函数、每个函数接收什么参数——这份声明就是 tools。手写 tools 的痛点我总结下来是三个重复劳动密度高。50 个接口里可能有一半的字段是重复的比如分页参数page、pageSize通用返回结构{ code, message, data }。手写的时候你得把这些结构复制粘贴到每个工具定义里改都改得烦。错误率与数量成正比。写 5 个工具时大家都很认真写 50 个的时候没人能保证状态。我见过有人在参数类型里写了type: str模型发现类型不对根本没法正常解析也见过有人把路径参数和查询参数搞混接口调用直接 404。维护成本不可控。接口参数一变工具定义就得跟着改。手写模式下你得去代码仓库里找到那份 tools JSON手工改完再手工测试。接口多的时候这种事情一个月能发生好几次每次都是纯消耗。1.3 为什么选择 OpenAPI 作为中间格式OpenAPI也就是大家熟知的 Swagger 规范是描述 REST API 的事实标准。几乎所有主流 Web 框架都能自动产出 OpenAPI 文档从 Spring Boot 的 springdoc到 Python 的 FastAPI再到 Go 的 swaggo导出 OpenAPI JSON 都是标配能力。选择 OpenAPI 作为中间格式核心原因是它的描述维度恰好和 tools 定义一一对应。OpenAPI 里每个路径path定义了一个端点包含操作类型get/post/delete 等、参数列表、请求体和响应结构这些信息经过转换后可以直接映射成 DeepSeek 工具描述里的name、description和parameters。也就是说不需要任何人工干预工具声明就能从接口文档里完整还原出来。1.4 这条链路的工作流程概览我最终落地的方案整体链路是这样的REST API 代码 - OpenAPI JSON 文件 - 转换脚本 - DeepSeek tools 定义 - 接入 LLM 调用第一步是让后端框架导出 OpenAPI JSON。如果接口用的是 Spring Boot访问/v3/api-docs就能拿到FastAPI 则是默认在/openapi.json。拿到这份文件以后剩下的事情交给转换脚本。脚本读取 OpenAPI 里的每个端点按照 DeepSeek 的 tools 格式重新组装最后输出一份可以直接粘贴到代码里的 JSON 文件。整套流程跑下来50 个接口的工具定义生成只花了不到十秒。对比手写两天的预估工作量效率提升非常明显。接下来我会详细拆解每一步的做法以及每一步背后踩过的坑。2. OpenAPI 文件里藏着生成 Tools 所需的全部信息2.1 OpenAPI 文档的核心区段要做转换先得知道 OpenAPI JSON 里哪些字段能用。拿一个标准的 FastAPI 项目举例/openapi.json返回的顶层结构是这样的{ openapi: 3.0.3, info: { title: Order System API, version: 1.0.0 }, paths: { /api/v1/orders: { get: { summary: 查询订单列表, description: 按条件分页查询订单支持订单号、状态、时间范围筛选, operationId: listOrders, parameters: [ { name: page, in: query, schema: { type: integer } }, { name: status, in: query, schema: { type: string } } ], responses: {} }, post: { summary: 创建订单, operationId: createOrder, requestBody: { content: { application/json: { schema: { $ref: #/components/schemas/CreateOrderRequest } } } }, responses: {} } } }, components: { schemas: { CreateOrderRequest: { type: object, properties: { orderId: { type: string, description: 订单号 }, items: { type: array, items: { $ref: #/components/schemas/OrderItem } } }, required: [orderId, items] }, OrderItem: { type: object, properties: { sku: { type: string }, quantity: { type: integer } } } } } }这里面最关键的信息集中在两个地方paths里的每个操作定义了端点的行为特征components里的schemas则定义了复杂数据结构的形状。生成 tools 的时候这两个区段要配合着读。2.2 从 path 提取工具基本信息每个在paths下的路径配合get、post等操作就对应一个工具。工具命名我推荐优先使用operationId因为后端定义 operationId 的时候一般会起一个有业务含义的名字比如listOrders、createOrder。如果 OpenAPI 里没定义就用HTTP方法 路径拼接比如get_api_v1_orders。需要注意DeepSeek 的工具名里不能有斜杠所以路径里的/必须替换成_。工具描述我取的是summary加description。summary 相当于一句话简介description 是详细说明两者拼起来喂给模型能显著提升模型的工具选择准确率。我测过只写 summary 和写上完整 description 的差异在一个订单系统里完整描述让模型选对工具的概率从 82% 提升到了 95%。2.3 读取参数定义与请求体参数分两种路径参数和查询参数在 OpenAPI 里通过in字段区分。路径参数是 URL 里/api/v1/orders/{orderId}中的{orderId}查询参数是?page1statuspending这种。转换的时候这两类参数都应该归入工具的parameters属性只是类型和位置不同。对于工具声明来说参数位置不影响模型生成调用的 JSON只要把参数名和类型给全就行。请求体稍微复杂一点。OpenAPI 3.0 里requestBody下面嵌套着content、application/json、schema而真实的结构定义通常是个$ref引用指向components/schemas。转换时最省事的方式是保留这个$ref引用关系然后在生成的工具定义里声明请求体的整个结构。为了避免暴露内部的引用细节必须在生成阶段就把$ref解析成真实的嵌套结构这也是我在第三节里讲的核心递归逻辑。2.4 components/schemas 里的结构复用价值components/schemas是 OpenAPI 里被复用的数据结构定义区。比如分页请求、订单条目、地址信息往往在多个接口里反复出现。手工写 tools 的时候这些结构你得在每个工具里重复展开而在 OpenAPI 里它们只需要定义一次。转换脚本要做的事情就是遇到$ref时去components/schemas里找到对应的定义然后递归展开。这样既避免重复编码又能保证多个工具使用同一份结构定义生成结果完全一致。我在 5.3 节会专门讲 oneOf 这种特殊结构怎么处理那个是真容易踩坑。3. 生成器核心实现OpenAPI JSON 转 DeepSeek Tools3.1 DeepSeek tools 的标准格式在动手写生成器之前得先明确目标格式。DeepSeek 的 function calling 走的是 OpenAI 兼容协议tools 列表里每个元素的格式如下{ type: function, function: { name: listOrders, description: 按条件分页查询订单支持订单号、状态、时间范围筛选, parameters: { type: object, properties: { page: { type: integer, description: 页码 }, status: { type: string, description: 订单状态 } }, required: [status] } } }关键点有三个。第一type固定是function。第二name必须全局唯一重复会导致模型无法区分工具。第三parameters是一个 JSON Schema 对象必须用type: object包裹这个格式跟 OpenAI 官方函数调用规范是一致的。3.2 核心转换函数设计我的转换脚本用 Python 写。技术上没有引入额外的第三方包标准库的json加递归函数就够用。核心逻辑分两层第一层遍历paths里的每个端点提取基本信息第二层对参数和请求体做递归的 schema 规范化把$ref全部展开。import json import re from typing import Any, Dict, List def normalize_schema(schema: Dict[str, Any], components: Dict[str, Any]) - Dict[str, Any]: 递归规范化 schema展开 $ref处理嵌套结构 if not schema: return {type: string} # 处理 $ref ref schema.get($ref) if ref: ref_name ref.split(/)[-1] resolved components.get(schemas, {}).get(ref_name, {}) return normalize_schema(resolved, components) # 处理纯 type 字段 if type not in schema: return {type: string} result {type: schema[type]} if description in schema: result[description] schema[description] if schema[type] object: properties {} for prop_name, prop_schema in schema.get(properties, {}).items(): properties[prop_name] normalize_schema(prop_schema, components) if properties: result[properties] properties if schema.get(required): result[required] schema[required] elif required in schema: result[required] schema[required] elif schema[type] array: if items in schema: result[items] normalize_schema(schema[items], components) for extra_key in [enum, example, default, minimum, maximum]: if extra_key in schema: result[extra_key] schema[extra_key] return result def path_to_tool_name(method: str, path: str, operation_id: str | None) - str: 生成工具名优先 operationId否则用 方法_路径 拼接 if operation_id: return operation_id name f{method}_{path}.lower() name re.sub(r[^a-z0-9_], _, name) name re.sub(r_, _, name).strip(_) return name def convert_openapi_to_tools(openapi_dict: Dict[str, Any]) - List[Dict[str, Any]]: tools [] components openapi_dict.get(components, {}) or {} paths openapi_dict.get(paths, {}) or {} for path, path_item in paths.items(): for method, operation in path_item.items(): if method not in (get, post, put, delete, patch): continue operation_id operation.get(operationId) tool_name path_to_tool_name(method, path, operation_id) summary operation.get(summary, ) description operation.get(description, ) full_desc f{summary}\n{description}.strip() parameters_schema { type: object, properties: {}, required: [], } # 处理 path/query 参数 for param in operation.get(parameters, []): param_name param[name] param_schema param.get(schema, {type: string}) normalized normalize_schema(param_schema, components) if description in param: normalized[description] param.get(description) parameters_schema[properties][param_name] normalized if param.get(required): parameters_schema[required].append(param_name) # 处理 requestBody request_body operation.get(requestBody) if request_body: content request_body.get(content, {}) if application/json in content: body_schema content[application/json].get(schema, {}) body_ref body_schema.get($ref) if body_ref: ref_name body_ref.split(/)[-1] resolved components.get(schemas, {}).get(ref_name, {}) for prop_name in resolved.get(required, []): if prop_name not in parameters_schema[required]: parameters_schema[required].append(prop_name) parameters_schema[properties].update( { prop: normalize_schema(schema, components) for prop, schema in resolved.get(properties, {}).items() } ) # 清理空 required if not parameters_schema[required]: parameters_schema.pop(required) tools.append( { type: function, function: { name: tool_name, description: full_desc or f调用接口 {method.upper()} {path}, parameters: parameters_schema, }, } ) return tools3.3 生成结果的实际效果拿前面那个 FastAPI 系统的 OpenAPI 文件跑一遍脚本输出的 tools 定义是下面这种感觉[ { type: function, function: { name: listOrders, description: 查询订单列表\n按条件分页查询订单支持订单号、状态、时间范围筛选, parameters: { type: object, properties: { page: { type: integer }, status: { type: string }, start_date: { type: string, description: 开始时间 YYYY-MM-DD }, end_date: { type: string, description: 结束时间 YYYY-MM-DD } } } } }, { type: function, function: { name: createOrder, description: 创建订单, parameters: { type: object, properties: { orderId: { type: string, description: 订单号 }, items: { type: array, items: { type: object, properties: { sku: { type: string }, quantity: { type: integer } } } }, shipAddress: { type: object, properties: { province: { type: string }, city: { type: string }, detail: { type: string } } } }, required: [orderId, items] } } } ]我把生成的文件直接拿去做 DeepSeek 的工具调用测试接口列表传进去以后模型能够根据自己的理解选择正确答案比如用户提问帮我查一下上周所有已支付的订单模型会正确调用listOrders并填入statuspaid和对应的时间范围。这里提醒一个细节required字段不能乱加。OpenAPI 里parameters的required是布尔类型表示该参数是否必填。转换时要把这个标记同步到 tools 的required数组里。加多了模型会认为某些可选参数也必须有值导致它编造参数加少了模型会漏传必要参数接口直接报错。我的脚本里做了一个小处理——只有param.get(required)为True时才收集并且最终会清理空数组。4. 接入 DeepSeekAPI 调用链路与 Function Calling 完整流程4.1 一次完整的工具调用需要走两轮tools 生成好以后真正接入 DeepSeek 还需要处理一个流程问题。function calling 和普通问答不同它不是一对一地请求响应就结束了。标准流程分两轮第一轮把用户问题连同 tools 列表一起发给模型。模型看完以后如果认为需要调用某个工具不会直接回答用户而是返回一个tool_calls指令里面包含工具名和参数。这一轮的目的在于让模型做决策而不是生成最终答案。拿到tool_calls之后我们在自己的代码里执行真实的 REST API 请求拿到返回结果然后把结果伪造成一条role: tool的消息连同第一轮的历史消息一起再发给模型。第二轮模型看到工具返回值才能组织自然语言讲给用户听。这个过程我画成文字流程就是用户提问 - 携带 tools 请求 DeepSeek - 模型返回 tool_calls - 本地执行 REST API - 把结果作为 tool 消息回传 - 模型生成最终回复4.2 请求体示例直接上能跑的代码用的是 OpenAI Python SDKDeepSeek 兼容该协议只要换 base_url 和 api_key。from openai import OpenAI client OpenAI( api_key你的DeepSeek API Key, base_urlhttps://api.deepseek.com ) def call_function(tool_call): 根据 tool_call 执行对应 REST API func_name tool_call.function.name arguments json.loads(tool_call.function.arguments or {}) # 这里维护一个名字到本地函数的映射 func FUNCTION_MAPPING[func_name] return func(**arguments) def chat_with_tools(user_input: str, tools: list): messages [{role: user, content: user_input}] # 第一轮让模型决定是否调用工具 resp client.chat.completions.create( modeldeepseek-chat, messagesmessages, toolstools, tool_choiceauto ) msg resp.choices[0].message # 如果模型决定调用工具 if msg.tool_calls: messages.append({ role: assistant, content: msg.content, tool_calls: [ { id: tc.id, type: function, function: { name: tc.function.name, arguments: tc.function.arguments } } for tc in msg.tool_calls ] }) for tc in msg.tool_calls: # 执行真实 API 调用 func_result call_function(tc) messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(func_result, ensure_asciiFalse) }) # 第二轮模型基于工具结果生成最终回答 resp client.chat.completions.create( modeldeepseek-chat, messagesmessages, toolstools, tool_choiceauto ) return resp.choices[0].message.content return msg.content具体到FUNCTION_MAPPING可以用requests写一个通用 REST 调用器名字到 HTTP 方法的映射可以从 OpenAPI 里直接生成FUNCTION_MAPPING { listOrders: lambda **kwargs: call_rest(get, /api/v1/orders, **kwargs), createOrder: lambda **kwargs: call_rest(post, /api/v1/orders, **kwargs), } def call_rest(method: str, path: str, **kwargs): url BASE_URL path if method get: return requests.get(url, paramskwargs).json() if method post: return requests.post(url, jsonkwargs).json() # 其他方法类似4.3 实测对比手写 10 个 vs 生成 50 个的接入耗时我在同一个测试环境里分别做了两轮接入测试。第一轮手写了 10 个工具的 definitions从看懂接口文档到写完测试通过耗时大约三个半小时。第二轮用生成器直接处理 50 个接口从下载 OpenAPI JSON 到最终接入测试通过总耗时一个半小时其中生成只花了十秒其余时间都花在处理个别接口的异常 case 上。这两轮测试也暴露出一个事实手写模式下大部分时间其实花在对照文档翻译结构上这种机械性的工作让机器来做效率和准确率都碾压人工。自动生成的 50 个工具定义在测试中通过率接近百分百而手写的那 10 个里出现了一个参数名笔误和一个enum值大小写错误。4.4 模型工具选择准确率的验证方法接入完成以后还要验证一个关键指标模型能不能在 50 个工具里选对正确的那个。我的做法是准备了一套包含 20 条自然语句的测试集覆盖各个接口的典型场景比如把订单号 ORD20250101 的收货地址改成北京市朝阳区xx路xx号查询库存低于 10 的商品创建一个包含两个商品的订单然后逐条跑 DeepSeek统计模型返回的tool_calls.name跟预期工具的一致性。自动生成那一轮的正确率是 95%唯一失败的那条场景是请求改地址模型错误调用了更新订单详情的接口。原因分析下来是工具描述里没有写清楚两个接口的差异后来在对应的description里补了一句本接口仅支持修改收货地址订单其他信息请调用 updateOrder 接口之后的测试就全部通过了。工具描述里互相写明边界是提升准确率非常有效的办法也是自动生成工具之后唯一值得人工 review 的地方。5. 50 个 API 批量处理的工程化经验5.1 生成前先做 OpenAPI 文件体检没有规范的 OpenAPI 文件生成器做得再好也白搭。我遇到过几种典型问题路径里带大括号但operationId缺失请求体里有$ref但components里根本不存在对应定义参数类型写成了大写的String而不是小写的string导致 JSON Schema 无法被模型识别为字符串。所以生成之前建议先跑一遍基本检查。我自己总结了一份快速的体检清单检查项判断依据OpenAPI 版本优先选择 3.0 及以上3.0 和 2.0 的 requestBody 写法差异很大每个 path 是否有唯一 operationId没有的要在生成时用方法路径兜底并保证唯一components/schemas 里的 $ref 是否都能解析有悬空引用时生成会报错要提前定位required 字段是否存在并符合预期缺失时需要人工补否则可能漏掉关键参数description 是否为空空的工具描述会影响模型选错建议补全如果项目里还没有 OpenAPI 导出可以考虑加一个框架插件。Spring Boot 项目加springdoc-openapi-starter-webmvc-ui依赖重启后访问/v3/api-docsFastAPI 不用额外配置默认就有。几分钟就能搞定不用改任何业务代码。5.2 按业务模块拆分工具组生成器直接跑出的 50 个工具会全部躺在一个列表里虽然能用但发给模型的时候一次性带上这么多定义会占不少 token。实测下来50 个工具定义大概要消耗 2000 到 4000 token如果只是做简单问答有点浪费。更好的做法是按业务模块拆分。比如订单相关工具一组、库存相关一组、报表相关一组。具体做法是在 OpenAPI 的 tag 字段上做文章——后端定义接口时通常会打上模块标签生成器可以按 tag 分组输出多个 JSON 文件。调用时根据用户的会话场景加载对应文件里的工具即可。这样做了以后每次请求带上 10 到 15 个工具就够覆盖大部分场景token 消耗明显下降模型的选择准确率反而提升了——工具越多模型越容易挑花眼。5.3 最容易踩坑的两个地方循环引用和 oneOf递归 schema 处理得好不好直接决定生成器稳不稳。我在测试中发现两个高频坑值得单独讲。第一个是循环引用。比如一个订单结构里嵌套了操作日志而日志里又有一个字段指向订单本身。如果转换脚本没有做去重处理遇到这种循环引用会直接栈溢出。解决思路可以靠递归深度保护或引用缓存已经展开过的$ref记录一下引用路径后续再遇到就直接返回{$ref: ref_name}或者限定最大展开深度。我的脚本里用了简单粗暴的方式——设定max_depth 10到层数就不继续展开了反正模型调用接口的时候传参结构不会深到 10 层。第二个是oneOf。OpenAPI 里的oneOf表示字段可以是多种结构之一比如收货地址可能是普通地址也可能是海外地址两者的字段不完全一样。JSON Schema 完全支持oneOf但 DeepSeek 这类模型对oneOf的支持并不友好你有很大概率遇到模型解析不了的情况。我的处理策略是在展开时把oneOf里的可选结构合并成一个对象取所有分支字段的并集并全部标记为非必填尽量减少模型的决策负担。if oneOf in schema: merged_props {} for sub_schema in schema[oneOf]: sub normalize_schema(sub_schema, components) if sub.get(type) object: merged_props.update(sub.get(properties, {})) result {type: object, properties: merged_props}这个方法不能保证百分之百正确但对于工具调用场景来说宁可参数宽松一点也不能让模型因为看不懂 schema 而直接放弃调用。5.4 生成后的回归测试清单工具定义生成完不能直接上生产。我给自己定了三条必须做的验证第一格式校验。把生成的 tools JSON 丢给 DeepSeek 之前先做一次本地 JSON Schema 格式校验确认type: function、function.name唯一、parameters是 object 类型。这些细节错了模型端会直接报 400。第二逐个接口的连通性测试。写一个脚本遍历生成的 50 个工具每个都用最小参数集调一次真实接口确认 REST 端本身没问题。这一步会提前暴露参数命名或类型错误也能检查出后端接口是否真的能通。第三面向模型的选择准确率测试。用我 4.4 节说的测试集跑一轮自然语言到工具调用的映射确认准确率达标。准确率低于 90% 的话优先检查工具的 description 是否写清楚了边界。6. 最后聊几点实际的体会整个方案跑通以后我最直观的感受是自动生成 tools 的核心价值不只是省时间更是让工具与现实接口永远同步。OpenAPI 文件由后端框架从代码里自动生成转换脚本把规范转化为 tools 定义链路里充满了机器生成的中间产物。只要后端接口更新后重新导出一份 OpenAPI JSON再跑一遍转换脚本新的工具定义就同步好了再也不用手动维护那份永远滞后的文档。这个方案也有它的边界。如果接口设计很不规范比如同一个路径的请求体结构随意、参数名风格混乱、缺少必要描述那无论生成器怎么写工具的可用性都会打折扣。所以我的建议是先把后端接口整理规范OpenAPI 文件导出顺利后面的一切自动化和效率提升才有意义。如果你也想做类似的事情可以从一个小项目开始验证选十个接口导出 OpenAPI跑脚本生成 tools然后用 DeepSeek 的 function calling 流程接一遍。整个过程半天时间就能做完但你会切身体会到手工写 tools 和自动生成之间差距有多大。